mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-30 19:32:39 +08:00
Merge remote-tracking branch 'origin/main' into research-linux-filesystem-sandbox
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep turns responsive when snapshot infrastructure stalls and prevent transient snapshot progress from appearing in forked sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Enable autocomplete in Jupyter notebooks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Browse skills, agents, and MCP servers together and filter them by category.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"@kilocode/sdk": minor
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Add session-local macOS sandbox controls, show the effective active state, and confirm toggles in the CLI and VS Code extension.
|
||||
@@ -1,12 +1,14 @@
|
||||
---
|
||||
name: kilocode-merge-minimizer
|
||||
description: Use when changing shared upstream-owned files, editing or reviewing `kilocode_change` markers, or moving additive Kilo-specific behavior into Kilo-owned code to reduce future merge conflicts.
|
||||
description: Use when changing shared upstream-owned files to add Kilo-specific behavior, editing `kilocode_change` markers in shared code, or moving additive behavior out of shared code to reduce upstream merge conflicts. Do not use for changes confined to Kilo-owned paths such as `packages/kilo-vscode/` or `packages/kilo-ui/`.
|
||||
---
|
||||
|
||||
# Kilo Merge Minimizer
|
||||
|
||||
Use this skill whenever a normal development task touches shared upstream-owned code and includes Kilo-specific behavior, especially for marker cleanup, extraction work, or `kilocode_change` annotations.
|
||||
|
||||
Do not use this skill when all changes are confined to Kilo-owned paths, including `packages/kilo-vscode/`, `packages/kilo-ui/`, and paths with `kilocode` in their name. Those files are not merged from upstream and do not need merge-minimization guidance. If a task also touches shared upstream-owned code, use this skill for the shared portion only.
|
||||
|
||||
Do not use this as the primary guide for upstream merge resolution. Upstream merges have their own instructions and should not duplicate that workflow here.
|
||||
|
||||
## Goal
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7338febdf802b5ce501773be164e9da152fb7903e9b16807bd8751fa38f208ed
|
||||
size 10310
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f7e075b4961d3a62c8deda6b481f64cb8777d05183a9c10d0dd2373b7972d3ab
|
||||
size 53678
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b0dd1dc26b28d354ecd14c53dd1b9e6158a161f47152f30299ba4c0827ce1674
|
||||
size 50904
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6eb9d7670f55c5529dce90d11fceb5c9da7a0d3a5f7270fdfd27e66ca1f3d72f
|
||||
size 9851
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1edca81fb2c946f87df5436684418e63b7a9c761079e44f024971a81316b8263
|
||||
size 6549
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ce4ba314abb23bb43694d512ade273d99760f250b061e235899d86e170bb5f9a
|
||||
size 52892
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:631d41ecf6216249943722a797f75c3aefb2f84ebdb7ee80529662a26fa35af5
|
||||
size 50206
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4a7bcef3102838812625393f0cead3a10067ae32e67dd3f174d0e74d03116c8c
|
||||
size 54735
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9f722d2782f69170a3efca6ed64ab02d9140f845bb47059ba193e238c768d0a5
|
||||
size 4852
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c35bc13f78745ab41bf39e732c3945578fc2cb2cc47ef231e51b1357bc0b2817
|
||||
size 54160
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:52c6eeef2d393b159fe9e88a9d283308d969b7311fe68ea23667561d21a2ed87
|
||||
size 51338
|
||||
@@ -22,6 +22,10 @@ export function run<A, E, R>(
|
||||
})
|
||||
}
|
||||
|
||||
export function unrestricted<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return effect.pipe(Effect.provideService(CurrentProfile, undefined))
|
||||
}
|
||||
|
||||
function denied(path: string, method: string) {
|
||||
return PlatformError.systemError({
|
||||
_tag: "PermissionDenied",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type { Profile } from "./profile"
|
||||
export { assertWrite, enabled, run } from "./context"
|
||||
export { assertWrite, enabled, run, unrestricted } from "./context"
|
||||
export { decorateFileSystem, ensureDirectory } from "./filesystem"
|
||||
export { assertNetwork, decorateHttpClient, httpLayer as networkHttpLayer } from "./network"
|
||||
export { batchMutations, mutate, withRunner, type Runner as MutationRunner } from "./mutation"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all other
|
||||
commercial damages or losses), even if such Contributor has been
|
||||
advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2023 Continue Dev, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -15,6 +15,7 @@ import { previewSound } from "./services/attention"
|
||||
import type { EditorContext, IndexingStatus } from "./services/cli-backend/types"
|
||||
import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController"
|
||||
import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete"
|
||||
import { notebookUri } from "./services/autocomplete/continuedev/core/autocomplete/notebook"
|
||||
import { buildWebviewHtml, getWebviewFontSize } from "./utils"
|
||||
import { saveImage } from "./kilo-provider/save-image"
|
||||
import { handleEditorAction } from "./kilo-provider/editor-actions"
|
||||
@@ -293,6 +294,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
/** Remembers the last selected session so /new can stay in the same worktree after clearSession. */
|
||||
private contextSessionID: string | undefined
|
||||
private connectionState: "connecting" | "connected" | "disconnected" | "error" = "connecting"
|
||||
private connectionGeneration = 0
|
||||
private loginAttempt = 0
|
||||
private isWebviewReady = false
|
||||
private readonly extensionVersion =
|
||||
@@ -309,6 +311,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private providersRefresh: Promise<void> | null = null
|
||||
private providersQueued = false
|
||||
private providersGeneration = 0
|
||||
private sandboxRevision = 0
|
||||
private cachedAgentsMessage: unknown = null
|
||||
/** Cached skillsLoaded payload so requestSkills can be served before client is ready */
|
||||
private cachedSkillsMessage: unknown = null
|
||||
@@ -336,6 +339,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private trackedSessionIds: Set<string> = new Set()
|
||||
private syncedChildSessions: Set<string> = new Set()
|
||||
private readonly checkpoints = new Map<string, Promise<void>>()
|
||||
private readonly sessionCreations = new Map<string, Promise<{ sid: string; dir: string }>>()
|
||||
private readonly sandboxTransitions = new Map<string, Promise<void>>()
|
||||
private readonly revisions = new Map<string, { id: string; seq: number }>()
|
||||
private readonly refreshes = new Map<string, number>()
|
||||
private sessionStatusMap = new Map<string, SessionStatus["type"]>() // Latest status used for destructive config warnings.
|
||||
@@ -712,11 +717,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
public setSessionDirectory(sessionId: string, directory: string): void {
|
||||
this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
|
||||
this.sessionDirectories.set(sessionId, directory)
|
||||
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
|
||||
}
|
||||
|
||||
public clearSessionDirectory(sessionId: string): void {
|
||||
this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId))
|
||||
this.sessionDirectories.delete(sessionId)
|
||||
if (this.connectionState === "connected") void this.fetchAndSendSandboxStatus(sessionId)
|
||||
}
|
||||
|
||||
/** Exposes the session→directory map so callers outside the webview can resolve worktree paths. */
|
||||
@@ -1077,6 +1084,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.pendingFollowup = null
|
||||
await handleQuestionReject(this.questionCtx, message.requestID, message.sessionID)
|
||||
break
|
||||
case "requestSandboxStatus":
|
||||
await this.fetchAndSendSandboxStatus(message.sessionID)
|
||||
break
|
||||
case "toggleSandbox":
|
||||
await this.handleToggleSandbox(message)
|
||||
break
|
||||
case "requestConfig":
|
||||
this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e))
|
||||
break
|
||||
@@ -1337,6 +1350,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
console.log("[Kilo New] KiloProvider: 🔧 Starting initializeConnection...")
|
||||
|
||||
this.connectionState = "connecting"
|
||||
this.connectionGeneration++
|
||||
this.postMessage({ type: "connectionState", state: "connecting" })
|
||||
|
||||
// Clean up any existing subscriptions (e.g., sidebar re-shown)
|
||||
@@ -1389,6 +1403,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
// Subscribe to connection state changes
|
||||
this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => {
|
||||
if (this.connectionState !== state) this.connectionGeneration++
|
||||
this.connectionState = state
|
||||
this.postConnectionState(error)
|
||||
|
||||
@@ -2455,6 +2470,142 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage(getWorkStylePayload())
|
||||
}
|
||||
|
||||
private postSandboxError(sessionID: string, error: unknown, revision: number, requestID?: string): void {
|
||||
this.postMessage({
|
||||
type: "sandboxStatusError",
|
||||
sessionID,
|
||||
directory: this.getWorkspaceDirectory(sessionID),
|
||||
message: getErrorMessage(error) || "Failed to update sandbox",
|
||||
requestID,
|
||||
revision,
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchAndSendSandboxStatus(sessionID: string, requestID?: string): Promise<void> {
|
||||
const revision = ++this.sandboxRevision
|
||||
const generation = this.connectionGeneration
|
||||
const client = this.client
|
||||
const sandbox = client?.sandbox
|
||||
if (!sandbox?.status) return
|
||||
if (this.connectionState !== "connected") {
|
||||
this.postSandboxError(sessionID, "Not connected to CLI backend", revision, requestID)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const directory = this.getWorkspaceDirectory(sessionID)
|
||||
const { data } = await sandbox.status({ sessionID, directory }, { throwOnError: true })
|
||||
if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client)
|
||||
return
|
||||
if (!sameDirectory(data.directory, this.getWorkspaceDirectory(sessionID))) {
|
||||
if (requestID) void this.fetchAndSendSandboxStatus(sessionID, requestID)
|
||||
return
|
||||
}
|
||||
this.postMessage({ type: "sandboxStatus", sessionID, revision, ...data, requestID })
|
||||
} catch (error) {
|
||||
if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client)
|
||||
return
|
||||
this.postSandboxError(sessionID, error, revision, requestID)
|
||||
}
|
||||
}
|
||||
|
||||
private sandboxKey(input: {
|
||||
sessionID?: string
|
||||
draftID?: string
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}): string {
|
||||
if (input.sessionID) return `session:${input.sessionID}`
|
||||
if (input.draftID) return `draft:${input.draftID}`
|
||||
return `context:${input.agentManagerContext ?? ""}:${input.contextDirectory ?? this.getRootDirectory()}`
|
||||
}
|
||||
|
||||
private handleToggleSandbox(input: {
|
||||
sessionID?: string
|
||||
draftID?: string
|
||||
requestID: string
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}): Promise<void> {
|
||||
const key = this.sandboxKey(input)
|
||||
const pending = this.sandboxTransitions.get(key)
|
||||
if (pending) return pending.catch(() => undefined)
|
||||
const operation = this.runToggleSandbox(input, key)
|
||||
this.sandboxTransitions.set(key, operation)
|
||||
return operation
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
for (const [id, active] of this.sandboxTransitions) {
|
||||
if (active === operation) this.sandboxTransitions.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async runToggleSandbox(
|
||||
input: {
|
||||
sessionID?: string
|
||||
draftID?: string
|
||||
requestID: string
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
},
|
||||
key: string,
|
||||
): Promise<void> {
|
||||
const revision = ++this.sandboxRevision
|
||||
const generation = this.connectionGeneration
|
||||
const client = this.client
|
||||
const sandbox = client?.sandbox
|
||||
if (!sandbox?.toggle || this.connectionState !== "connected") {
|
||||
const error = new Error("Not connected to CLI backend")
|
||||
this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID)
|
||||
throw error
|
||||
}
|
||||
const resolved = await this.resolveSession(
|
||||
input.sessionID,
|
||||
input.draftID,
|
||||
input.agentManagerContext,
|
||||
input.contextDirectory,
|
||||
).catch((error) => {
|
||||
this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID)
|
||||
throw error
|
||||
})
|
||||
if (!resolved) {
|
||||
const error = new Error("Failed to resolve sandbox session")
|
||||
this.postSandboxError(input.sessionID ?? "", error, revision, input.requestID)
|
||||
throw error
|
||||
}
|
||||
const operation = this.sandboxTransitions.get(key)
|
||||
if (operation) this.sandboxTransitions.set(`session:${resolved.sid}`, operation)
|
||||
if (this.connectionGeneration !== generation || this.client !== client) {
|
||||
throw new Error("Sandbox connection changed")
|
||||
}
|
||||
try {
|
||||
const { data } = await sandbox.toggle(
|
||||
{ sessionID: resolved.sid, directory: resolved.dir },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
if (this.connectionState !== "connected" || this.connectionGeneration !== generation || this.client !== client) {
|
||||
throw new Error("Sandbox connection changed")
|
||||
}
|
||||
if (!data.available) throw new Error(data.reason ?? "Sandbox backend is unavailable")
|
||||
if (!sameDirectory(data.directory, this.getWorkspaceDirectory(resolved.sid))) {
|
||||
throw new Error("Session directory changed during sandbox toggle")
|
||||
}
|
||||
this.postMessage({
|
||||
type: "sandboxStatus",
|
||||
sessionID: resolved.sid,
|
||||
revision,
|
||||
...data,
|
||||
requestID: input.requestID,
|
||||
})
|
||||
} catch (error) {
|
||||
if (this.connectionState === "connected" && this.connectionGeneration === generation && this.client === client) {
|
||||
this.postSandboxError(resolved.sid, error, revision, input.requestID)
|
||||
void this.fetchAndSendSandboxStatus(resolved.sid)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUpdateConfig(
|
||||
partial: Partial<Config>,
|
||||
project: Partial<Config> = {},
|
||||
@@ -2569,21 +2720,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
})
|
||||
|
||||
if (!sessionID && !this.currentSession) {
|
||||
const { data: session } = await this.client.session.create(
|
||||
{ directory: dir, platform: this.opts.platform },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
this.stopCurrentSessionProcesses(session.id)
|
||||
this.setCurrentSession(session)
|
||||
this.contextSessionID = session.id
|
||||
this.focusSession(session.id)
|
||||
this.trackDirectory(session.id, dir)
|
||||
this.trackedSessionIds.add(session.id)
|
||||
this.postMessage({
|
||||
type: "sessionCreated",
|
||||
session: this.sessionToWebview(session),
|
||||
draftID,
|
||||
})
|
||||
const key = `${draftID ?? context ?? "new"}\0${dir}`
|
||||
const pending = this.sessionCreations.get(key)
|
||||
if (pending) return pending
|
||||
const creation = (async () => {
|
||||
const { data: session } = await this.client!.session.create(
|
||||
{ directory: dir, platform: this.opts.platform },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
this.stopCurrentSessionProcesses(session.id)
|
||||
this.setCurrentSession(session)
|
||||
this.contextSessionID = session.id
|
||||
this.focusSession(session.id)
|
||||
this.trackDirectory(session.id, dir)
|
||||
this.trackedSessionIds.add(session.id)
|
||||
this.postMessage({
|
||||
type: "sessionCreated",
|
||||
session: this.sessionToWebview(session),
|
||||
draftID,
|
||||
})
|
||||
return { sid: session.id, dir }
|
||||
})().finally(() => this.sessionCreations.delete(key))
|
||||
this.sessionCreations.set(key, creation)
|
||||
return creation
|
||||
}
|
||||
|
||||
const sid = sessionID || this.currentSession?.id
|
||||
@@ -2697,7 +2856,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
let resolved: { sid: string; dir: string } | undefined
|
||||
try {
|
||||
const sandbox = this.sandboxTransitions.get(
|
||||
this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }),
|
||||
)
|
||||
resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory)
|
||||
if (sandbox) await sandbox
|
||||
|
||||
const parts: Array<TextPartInput | FilePartInput> = []
|
||||
if (files) {
|
||||
@@ -2778,7 +2941,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
let resolved: { sid: string; dir: string } | undefined
|
||||
try {
|
||||
const sandbox = this.sandboxTransitions.get(
|
||||
this.sandboxKey({ sessionID, draftID, agentManagerContext: context, contextDirectory }),
|
||||
)
|
||||
resolved = await this.resolveSession(sessionID, draftID, context, contextDirectory)
|
||||
if (sandbox) await sandbox
|
||||
|
||||
if (messageID) {
|
||||
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
|
||||
@@ -3341,6 +3508,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return
|
||||
}
|
||||
const next = msg.type === "messageCreated" ? { ...msg, message: this.slimInfo(msg.message) } : msg
|
||||
if (next.type === "sandboxStatus") {
|
||||
if (!sameDirectory(next.directory, this.getWorkspaceDirectory(next.sessionID))) return
|
||||
this.postMessage({ ...next, revision: ++this.sandboxRevision })
|
||||
return
|
||||
}
|
||||
if (next.type === "indexingStatusLoaded") {
|
||||
this.cachedIndexingStatusMessage = next
|
||||
}
|
||||
@@ -3432,14 +3604,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const result = new Set<string>()
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.input instanceof vscode.TabInputText) {
|
||||
const uri = tab.input.uri
|
||||
if (uri.scheme === "file") {
|
||||
const rel = path.relative(dir, uri.fsPath)
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) {
|
||||
result.add(rel.replaceAll("\\", "/"))
|
||||
}
|
||||
}
|
||||
const uri =
|
||||
tab.input instanceof vscode.TabInputText || tab.input instanceof vscode.TabInputNotebook
|
||||
? tab.input.uri
|
||||
: undefined
|
||||
if (uri?.scheme !== "file") continue
|
||||
|
||||
const rel = path.relative(dir, uri.fsPath)
|
||||
if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) {
|
||||
result.add(rel.replaceAll("\\", "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3477,21 +3650,30 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
// Visible files (capped to avoid bloating context, filtered through .kilocodeignore)
|
||||
const visibleFiles = vscode.window.visibleTextEditors
|
||||
.map((e) => e.document.uri)
|
||||
.filter((uri) => uri.scheme === "file")
|
||||
.map((uri) => toRelative(uri.fsPath))
|
||||
.filter((p): p is string => p !== undefined && controller.validateAccess(path.resolve(workspaceDir, p)))
|
||||
.slice(0, 200)
|
||||
const visibleFiles = [
|
||||
...new Set(
|
||||
[
|
||||
...vscode.window.visibleTextEditors.map((editor) => notebookUri(editor.document.uri)),
|
||||
...vscode.window.visibleNotebookEditors.map((editor) => editor.notebook.uri),
|
||||
]
|
||||
.filter((uri): uri is vscode.Uri => uri?.scheme === "file")
|
||||
.map((uri) => toRelative(uri.fsPath))
|
||||
.filter(
|
||||
(file): file is string => file !== undefined && controller.validateAccess(path.resolve(workspaceDir, file)),
|
||||
),
|
||||
),
|
||||
].slice(0, 200)
|
||||
|
||||
// Open tabs — use instanceof TabInputText to exclude notebooks, diffs, custom editors
|
||||
// Open tabs — text and notebook files only; exclude diffs and custom editors
|
||||
const openTabs = [...(await this.getOpenTabPaths(workspaceDir))].slice(0, 20)
|
||||
|
||||
// Active file (also filtered through .kilocodeignore)
|
||||
const activeEditor = vscode.window.activeTextEditor
|
||||
const activeRel =
|
||||
activeEditor?.document.uri.scheme === "file" ? toRelative(activeEditor.document.uri.fsPath) : undefined
|
||||
const activeFile = activeRel && controller.validateAccess(activeEditor!.document.uri.fsPath) ? activeRel : undefined
|
||||
const activeUri = activeEditor
|
||||
? notebookUri(activeEditor.document.uri)
|
||||
: vscode.window.activeNotebookEditor?.notebook.uri
|
||||
const activeRel = activeUri ? toRelative(activeUri.fsPath) : undefined
|
||||
const activeFile = activeRel && activeUri && controller.validateAccess(activeUri.fsPath) ? activeRel : undefined
|
||||
|
||||
// Shell
|
||||
const shell = vscode.env.shell || undefined
|
||||
|
||||
@@ -439,7 +439,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
if ((m.type === "sendMessage" || m.type === "sendCommand") && !m.sessionID) {
|
||||
if ((m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && !m.sessionID) {
|
||||
const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined
|
||||
const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined
|
||||
if (worktree) {
|
||||
@@ -448,7 +448,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) {
|
||||
if (
|
||||
(m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") &&
|
||||
m.draftID &&
|
||||
!m.sessionID
|
||||
) {
|
||||
this.activeSessionId = m.draftID
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -639,6 +639,15 @@ interface SendCommandIn {
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
interface ToggleSandboxIn {
|
||||
type: "toggleSandbox"
|
||||
sessionID?: string
|
||||
draftID?: string
|
||||
requestID: string
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
interface RequestTerminalContextIn {
|
||||
type: "requestTerminalContext"
|
||||
requestId: string
|
||||
@@ -783,6 +792,7 @@ export type AgentManagerInMessage =
|
||||
| LoadMessagesIn
|
||||
| SendMessageIn
|
||||
| SendCommandIn
|
||||
| ToggleSandboxIn
|
||||
| RequestTerminalContextIn
|
||||
| ClearSessionIn
|
||||
| AbortIn
|
||||
|
||||
@@ -518,6 +518,15 @@ export type WebviewMessage =
|
||||
| { type: "sessionDeleted"; sessionID: string }
|
||||
| { type: "messageRemoved"; sessionID: string; messageID: string }
|
||||
| { type: "sessionError"; sessionID?: string; error?: unknown }
|
||||
| {
|
||||
type: "sandboxStatus"
|
||||
sessionID: string
|
||||
directory: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
}
|
||||
| null
|
||||
|
||||
type PartEvent =
|
||||
@@ -554,6 +563,12 @@ function mapPartEvent(event: PartEvent, sessionID: string | undefined): WebviewM
|
||||
}
|
||||
}
|
||||
|
||||
function statusExtra(info: Extract<Event, { type: "session.status" }>["properties"]["status"]) {
|
||||
if (info.type === "retry") return { attempt: info.attempt, message: info.message, next: info.next }
|
||||
if (info.type === "offline") return { message: info.message }
|
||||
return {}
|
||||
}
|
||||
|
||||
export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: string | undefined): WebviewMessage {
|
||||
if (event.type === "sync") {
|
||||
switch (event.name) {
|
||||
@@ -595,12 +610,7 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin
|
||||
case "session.status": {
|
||||
const info = event.properties.status
|
||||
const status = info.type
|
||||
const extra =
|
||||
info.type === "retry"
|
||||
? { attempt: info.attempt, message: info.message, next: info.next }
|
||||
: info.type === "offline"
|
||||
? { message: info.message }
|
||||
: {}
|
||||
const extra = statusExtra(info)
|
||||
return {
|
||||
type: "sessionStatus" as const,
|
||||
sessionID: event.properties.sessionID,
|
||||
@@ -681,6 +691,16 @@ export function mapSSEEventToWebviewMessage(event: StreamEvent, sessionID: strin
|
||||
error: event.properties.error,
|
||||
}
|
||||
}
|
||||
case "sandbox.status.changed":
|
||||
return {
|
||||
type: "sandboxStatus",
|
||||
sessionID: event.properties.sessionID,
|
||||
directory: event.properties.directory,
|
||||
enabled: event.properties.enabled,
|
||||
available: event.properties.available,
|
||||
reason: event.properties.reason,
|
||||
version: event.properties.version,
|
||||
}
|
||||
case "indexing.status":
|
||||
return {
|
||||
type: "indexingStatusLoaded",
|
||||
|
||||
@@ -12,10 +12,24 @@ import { NextEditSuggestionManager } from "./next-edit/NextEditSuggestionManager
|
||||
import { toAllowedMercuryRecentSnippets } from "./next-edit/recentSnippetsAdapter"
|
||||
import type { KiloConnectionService } from "../cli-backend"
|
||||
import { hasValidCredentials } from "./fim"
|
||||
import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models"
|
||||
import {
|
||||
DEFAULT_AUTOCOMPLETE_MODEL,
|
||||
getAutocompleteModel,
|
||||
getAutocompleteModelById,
|
||||
} from "../../shared/autocomplete-models"
|
||||
|
||||
const CONFIG_SECTION = "kilo-code.new.autocomplete"
|
||||
|
||||
export function selector(kind: "classic" | "next-edit"): vscode.DocumentSelector {
|
||||
return kind === "classic" ? [{ scheme: "file" }, { scheme: "vscode-notebook-cell" }] : [{ scheme: "file" }]
|
||||
}
|
||||
|
||||
export function notebookModel(provider?: string, model?: string) {
|
||||
const info = getAutocompleteModel(provider, model)
|
||||
if (info.kind !== "edit") return info
|
||||
return getAutocompleteModelById(info.fimModelID)
|
||||
}
|
||||
|
||||
export interface AutocompleteServiceSettings {
|
||||
enableAutoTrigger?: boolean
|
||||
enableSmartInlineTaskKeybinding?: boolean
|
||||
@@ -68,6 +82,7 @@ export class AutocompleteServiceManager {
|
||||
public readonly nextEditProvider: NextEditInlineCompletionProvider
|
||||
public readonly nextEditSuggestionManager: NextEditSuggestionManager
|
||||
private inlineCompletionProviderDisposable: vscode.Disposable | null = null
|
||||
private notebookCompletionProviderDisposable: vscode.Disposable | null = null
|
||||
private inlineCompletionProviderKind: "classic" | "next-edit" | null = null
|
||||
private unsubscribeState: (() => void) | null = null
|
||||
private unsubscribeEvent: (() => void) | null = null
|
||||
@@ -177,7 +192,7 @@ export class AutocompleteServiceManager {
|
||||
public async load() {
|
||||
this.settings = readSettings()
|
||||
|
||||
this.inlineCompletionProvider.setModel(getAutocompleteModel(this.settings.provider, this.settings.model).id)
|
||||
this.inlineCompletionProvider.setModel(notebookModel(this.settings.provider, this.settings.model).id)
|
||||
|
||||
await this.updateGlobalContext()
|
||||
this.updateStatusBar()
|
||||
@@ -204,7 +219,9 @@ export class AutocompleteServiceManager {
|
||||
) {
|
||||
if (this.inlineCompletionProviderKind === "next-edit") this.nextEditSuggestionManager.clear()
|
||||
this.inlineCompletionProviderDisposable?.dispose()
|
||||
this.notebookCompletionProviderDisposable?.dispose()
|
||||
this.inlineCompletionProviderDisposable = null
|
||||
this.notebookCompletionProviderDisposable = null
|
||||
this.inlineCompletionProviderKind = null
|
||||
}
|
||||
|
||||
@@ -216,7 +233,9 @@ export class AutocompleteServiceManager {
|
||||
|
||||
if (!shouldBeRegistered) {
|
||||
this.inlineCompletionProviderDisposable!.dispose()
|
||||
this.notebookCompletionProviderDisposable?.dispose()
|
||||
this.inlineCompletionProviderDisposable = null
|
||||
this.notebookCompletionProviderDisposable = null
|
||||
this.inlineCompletionProviderKind = null
|
||||
return
|
||||
}
|
||||
@@ -224,9 +243,15 @@ export class AutocompleteServiceManager {
|
||||
const provider: vscode.InlineCompletionItemProvider =
|
||||
desiredKind === "next-edit" ? this.nextEditProvider : this.inlineCompletionProvider
|
||||
this.inlineCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider(
|
||||
{ scheme: "file" },
|
||||
selector(desiredKind),
|
||||
provider,
|
||||
)
|
||||
if (desiredKind === "next-edit") {
|
||||
this.notebookCompletionProviderDisposable = vscode.languages.registerInlineCompletionItemProvider(
|
||||
[{ scheme: "vscode-notebook-cell" }],
|
||||
this.inlineCompletionProvider,
|
||||
)
|
||||
}
|
||||
this.inlineCompletionProviderKind = desiredKind
|
||||
}
|
||||
|
||||
@@ -459,6 +484,8 @@ export class AutocompleteServiceManager {
|
||||
this.inlineCompletionProviderDisposable = null
|
||||
this.inlineCompletionProviderKind = null
|
||||
}
|
||||
this.notebookCompletionProviderDisposable?.dispose()
|
||||
this.notebookCompletionProviderDisposable = null
|
||||
|
||||
// Dispose inline completion provider resources
|
||||
this.inlineCompletionProvider.dispose()
|
||||
|
||||
+30
-1
@@ -251,12 +251,41 @@ describe("AutocompleteServiceManager (less mocked logic)", () => {
|
||||
await (manager as any).ensureInlineCompletionProviderRegistration()
|
||||
|
||||
expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenCalledWith(
|
||||
{ scheme: "file" },
|
||||
[{ scheme: "file" }, { scheme: "vscode-notebook-cell" }],
|
||||
manager.inlineCompletionProvider,
|
||||
)
|
||||
expect((manager as any).inlineCompletionProviderDisposable).toBe(disposable)
|
||||
})
|
||||
|
||||
it("registers classic notebook fallback when Next Edit is selected", async () => {
|
||||
const manager = await createManager()
|
||||
|
||||
const primary = { dispose: vi.fn() }
|
||||
const notebook = { dispose: vi.fn() }
|
||||
vi.mocked(vscode.languages.registerInlineCompletionItemProvider)
|
||||
.mockReturnValueOnce(primary as any)
|
||||
.mockReturnValueOnce(notebook as any)
|
||||
;(manager as any).settings = {
|
||||
enableAutoTrigger: true,
|
||||
provider: "kilo",
|
||||
model: "inception/mercury-next-edit",
|
||||
}
|
||||
|
||||
await (manager as any).ensureInlineCompletionProviderRegistration()
|
||||
|
||||
expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[{ scheme: "file" }],
|
||||
manager.nextEditProvider,
|
||||
)
|
||||
expect(vscode.languages.registerInlineCompletionItemProvider).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ scheme: "vscode-notebook-cell" }],
|
||||
manager.inlineCompletionProvider,
|
||||
)
|
||||
expect((manager as any).notebookCompletionProviderDisposable).toBe(notebook)
|
||||
})
|
||||
|
||||
it("does not register the provider when snoozed", async () => {
|
||||
const manager = await createManager()
|
||||
|
||||
|
||||
+69
-54
@@ -15,6 +15,7 @@ import {
|
||||
} from "../types"
|
||||
import {
|
||||
findMatchingSuggestion as _findMatchingSuggestion,
|
||||
findCoveringPendingRequest,
|
||||
applyFirstLineOnly as _applyFirstLineOnly,
|
||||
calcDebounceDelay,
|
||||
MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn,
|
||||
@@ -32,10 +33,21 @@ import { postprocessAutocompleteSuggestion } from "./uselessSuggestionFilter"
|
||||
import { shouldSkipAutocomplete } from "./contextualSkip"
|
||||
import { FileIgnoreController } from "../shims/FileIgnoreController"
|
||||
import { AutocompleteTelemetry } from "./AutocompleteTelemetry"
|
||||
import {
|
||||
autocompleteScope,
|
||||
getNotebookContext,
|
||||
notebookUri,
|
||||
supportsNotebook,
|
||||
} from "../continuedev/core/autocomplete/notebook"
|
||||
import { ErrorBackoff } from "./ErrorBackoff"
|
||||
|
||||
const MAX_SUGGESTIONS_HISTORY = 20
|
||||
|
||||
export function accessible(controller: FileIgnoreController, document: vscode.TextDocument): boolean {
|
||||
const uri = notebookUri(document.uri)
|
||||
return controller.validateAccess(uri?.fsPath ?? document.fileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum debounce delay in milliseconds.
|
||||
* The adaptive debounce delay will never go below this value, even when
|
||||
@@ -70,11 +82,12 @@ export type { CostTrackingCallback, AutocompletePrompt, MatchingSuggestionResult
|
||||
export type MatchingSuggestionWithFillIn = _MatchingSuggestionWithFillIn
|
||||
|
||||
export function findMatchingSuggestion(
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
suggestionsHistory: FillInAtCursorSuggestion[],
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
return _findMatchingSuggestion(prefix, suffix, suggestionsHistory)
|
||||
return _findMatchingSuggestion(scope, prefix, suffix, suggestionsHistory)
|
||||
}
|
||||
|
||||
export function applyFirstLineOnly(
|
||||
@@ -118,8 +131,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
private debouncedPendingRequest: PendingRequest | null = null
|
||||
private isFirstCall: boolean = true
|
||||
public readonly ignoreController: Promise<FileIgnoreController>
|
||||
/** Abort controller for the current in-flight FIM request */
|
||||
private fimAbortController: AbortController | null = null
|
||||
/** Abort controllers for in-flight FIM requests, scoped by file/notebook context. */
|
||||
private fimAbortControllers = new Map<string, AbortController>()
|
||||
private acceptedCommand: vscode.Disposable | null = null
|
||||
private contextService: ContextRetrievalService | null = null
|
||||
private debounceDelayMs: number = INITIAL_DEBOUNCE_DELAY_MS
|
||||
@@ -178,6 +191,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
public updateSuggestions(fillInAtCursor: FillInAtCursorSuggestion): void {
|
||||
const isDuplicate = this.suggestionsHistory.some(
|
||||
(existing) =>
|
||||
existing.scope === fillInAtCursor.scope &&
|
||||
existing.text === fillInAtCursor.text &&
|
||||
existing.prefix === fillInAtCursor.prefix &&
|
||||
existing.suffix === fillInAtCursor.suffix,
|
||||
@@ -211,7 +225,16 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
recentlyEditedRanges,
|
||||
}
|
||||
|
||||
const autocompleteInput = contextToAutocompleteInput(context)
|
||||
const input = contextToAutocompleteInput(context)
|
||||
const notebook = getNotebookContext(document, position)
|
||||
const autocompleteInput = notebook
|
||||
? {
|
||||
...input,
|
||||
filepath: notebook.filepath,
|
||||
pos: { line: notebook.position.line, character: notebook.position.character },
|
||||
manuallyPassFileContents: notebook.contents,
|
||||
}
|
||||
: input
|
||||
|
||||
const { prefix, suffix } = extractPrefixSuffix(document, position)
|
||||
const languageId = document.languageId
|
||||
@@ -235,6 +258,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
|
||||
private processSuggestion(
|
||||
suggestionText: string,
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
telemetryContext: AutocompleteContext,
|
||||
@@ -242,7 +266,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
): FillInAtCursorSuggestion {
|
||||
if (!suggestionText) {
|
||||
this.telemetry?.captureSuggestionFiltered("empty_response", telemetryContext)
|
||||
return { text: "", prefix, suffix }
|
||||
return { text: "", scope, prefix, suffix }
|
||||
}
|
||||
|
||||
const processedText = postprocessAutocompleteSuggestion({
|
||||
@@ -254,11 +278,11 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
})
|
||||
|
||||
if (processedText) {
|
||||
return { text: processedText, prefix, suffix }
|
||||
return { text: processedText, scope, prefix, suffix }
|
||||
}
|
||||
|
||||
this.telemetry?.captureSuggestionFiltered("filtered_by_postprocessing", telemetryContext)
|
||||
return { text: "", prefix, suffix }
|
||||
return { text: "", scope, prefix, suffix }
|
||||
}
|
||||
|
||||
private async disposeIgnoreController(): Promise<void> {
|
||||
@@ -299,8 +323,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
this.settleDebouncedPendingRequest()
|
||||
this.pendingRequests.length = 0
|
||||
this.fimAbortController?.abort()
|
||||
this.fimAbortController = null
|
||||
for (const controller of this.fimAbortControllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
this.fimAbortControllers.clear()
|
||||
this.telemetry?.dispose()
|
||||
this.contextService?.dispose()
|
||||
this.contextService = null
|
||||
@@ -336,6 +362,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
_token: vscode.CancellationToken,
|
||||
): Promise<vscode.InlineCompletionItem[] | vscode.InlineCompletionList> {
|
||||
vscode.commands.executeCommand("setContext", "kilo-code.new.autocomplete.hasSuggestions", false)
|
||||
if (!supportsNotebook(document)) return []
|
||||
|
||||
// Build telemetry context
|
||||
const telemetryContext: AutocompleteContext = {
|
||||
@@ -389,8 +416,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
return []
|
||||
}
|
||||
|
||||
const isAccessible = controller.validateAccess(document.fileName)
|
||||
if (!isAccessible) {
|
||||
if (!accessible(controller, document)) {
|
||||
return []
|
||||
}
|
||||
} catch {
|
||||
@@ -399,10 +425,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
}
|
||||
|
||||
const scope = autocompleteScope(document)
|
||||
const { prefix, suffix } = extractPrefixSuffix(document, position)
|
||||
|
||||
// Check cache first - allow mid-word lookups from cache
|
||||
const matchingResult = applyFirstLineOnly(findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), prefix)
|
||||
const matchingResult = applyFirstLineOnly(
|
||||
findMatchingSuggestion(scope, prefix, suffix, this.suggestionsHistory),
|
||||
prefix,
|
||||
)
|
||||
|
||||
if (matchingResult !== null) {
|
||||
this.lastSuggestion = {
|
||||
@@ -425,9 +455,12 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
|
||||
const { prompt, prefix: promptPrefix, suffix: promptSuffix } = await this.getPrompt(document, position)
|
||||
|
||||
await this.debouncedFetchAndCacheSuggestion(prompt, promptPrefix, promptSuffix, document.languageId)
|
||||
await this.debouncedFetchAndCacheSuggestion(scope, prompt, promptPrefix, promptSuffix, document.languageId)
|
||||
|
||||
const cachedResult = applyFirstLineOnly(findMatchingSuggestion(prefix, suffix, this.suggestionsHistory), prefix)
|
||||
const cachedResult = applyFirstLineOnly(
|
||||
findMatchingSuggestion(scope, prefix, suffix, this.suggestionsHistory),
|
||||
prefix,
|
||||
)
|
||||
if (cachedResult) {
|
||||
this.lastSuggestion = {
|
||||
...telemetryContext,
|
||||
@@ -448,31 +481,6 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pending request that covers the current prefix/suffix.
|
||||
* A request covers the current position if:
|
||||
* 1. The suffix matches (user hasn't changed text after cursor)
|
||||
* 2. The current prefix either equals or extends the pending prefix
|
||||
* (user is typing forward, not backspacing or editing earlier)
|
||||
*
|
||||
* @returns The covering pending request, or null if none found
|
||||
*/
|
||||
private findCoveringPendingRequest(prefix: string, suffix: string): PendingRequest | null {
|
||||
for (const pendingRequest of this.pendingRequests) {
|
||||
// Suffix must match exactly (text after cursor unchanged)
|
||||
if (suffix !== pendingRequest.suffix) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Current prefix must start with the pending prefix (user typed more)
|
||||
// or be exactly equal (same position)
|
||||
if (prefix.startsWith(pendingRequest.prefix)) {
|
||||
return pendingRequest
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending request from the list when it completes.
|
||||
*/
|
||||
@@ -499,13 +507,14 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
* - If a pending request covers the current prefix/suffix, reuse it instead of starting a new one
|
||||
*/
|
||||
private debouncedFetchAndCacheSuggestion(
|
||||
scope: string,
|
||||
prompt: AutocompletePrompt,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
languageId: string,
|
||||
): Promise<void> {
|
||||
// Check if any existing pending request covers this one
|
||||
const coveringRequest = this.findCoveringPendingRequest(prefix, suffix)
|
||||
const coveringRequest = findCoveringPendingRequest(scope, prefix, suffix, this.pendingRequests)
|
||||
if (coveringRequest) {
|
||||
// Wait for the existing request to complete - no need to start a new one
|
||||
return coveringRequest.promise
|
||||
@@ -515,8 +524,8 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
// but still track it as a pending request so subsequent calls can reuse it
|
||||
if (this.isFirstCall && this.debounceTimer === null) {
|
||||
this.isFirstCall = false
|
||||
const promise = this.fetchAndCacheSuggestion(prompt, prefix, suffix, languageId)
|
||||
const leading: PendingRequest = { prefix, suffix, promise }
|
||||
const promise = this.fetchAndCacheSuggestion(scope, prompt, prefix, suffix, languageId)
|
||||
const leading: PendingRequest = { scope, prefix, suffix, promise }
|
||||
promise.finally(() => this.removePendingRequest(leading))
|
||||
this.pendingRequests.push(leading)
|
||||
return promise
|
||||
@@ -533,6 +542,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
|
||||
// Create the pending request object first so we can reference it in the cleanup
|
||||
const pendingRequest: PendingRequest = {
|
||||
scope,
|
||||
prefix,
|
||||
suffix,
|
||||
promise: null!, // Will be set immediately below
|
||||
@@ -545,7 +555,7 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
this.debouncedPendingRequest = null
|
||||
this.isFirstCall = true // Reset for next sequence
|
||||
try {
|
||||
await this.fetchAndCacheSuggestion(prompt, prefix, suffix, languageId)
|
||||
await this.fetchAndCacheSuggestion(scope, prompt, prefix, suffix, languageId)
|
||||
} finally {
|
||||
this.removePendingRequest(pendingRequest)
|
||||
resolve()
|
||||
@@ -566,15 +576,22 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
}
|
||||
|
||||
public async fetchAndCacheSuggestion(
|
||||
scope: string,
|
||||
prompt: AutocompletePrompt,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
languageId: string,
|
||||
): Promise<void> {
|
||||
// Abort any previous in-flight FIM request before starting a new one
|
||||
this.fimAbortController?.abort()
|
||||
// Defense-in-depth: credentials may become invalid between the provider gate and the actual
|
||||
// debounced execution. In that case, do not attempt an LLM call at all.
|
||||
if (!hasValidCredentials(this.connectionService)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Abort only the request superseded within this file/notebook scope.
|
||||
this.fimAbortControllers.get(scope)?.abort()
|
||||
const controller = new AbortController()
|
||||
this.fimAbortController = controller
|
||||
this.fimAbortControllers.set(scope, controller)
|
||||
|
||||
const startTime = performance.now()
|
||||
|
||||
@@ -585,16 +602,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
provider: getAutocompleteModelById(this.contextProvider.modelId).provider,
|
||||
}
|
||||
|
||||
// Defense-in-depth: credentials may become invalid between the provider gate and the actual
|
||||
// debounced execution. In that case, do not attempt an LLM call at all.
|
||||
if (!hasValidCredentials(this.connectionService)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Curry processSuggestion with prefix, suffix, telemetry context, and languageId
|
||||
// Curry processSuggestion with request context
|
||||
const curriedProcessSuggestion = (text: string) =>
|
||||
this.processSuggestion(text, prefix, suffix, telemetryContext, languageId)
|
||||
this.processSuggestion(text, scope, prefix, suffix, telemetryContext, languageId)
|
||||
|
||||
const result = await this.fimPromptBuilder.getFromFIM(
|
||||
this.connectionService,
|
||||
@@ -648,6 +659,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
this.fatalNotified = true
|
||||
this.onFatalError?.(this.backoff.getFatalStatus())
|
||||
}
|
||||
} finally {
|
||||
if (this.fimAbortControllers.get(scope) === controller) {
|
||||
this.fimAbortControllers.delete(scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ describe("AutocompleteTelemetry", () => {
|
||||
function createSuggestion(index: number): FillInAtCursorSuggestion {
|
||||
return {
|
||||
text: `text-${index}`,
|
||||
scope: `scope-${index}`,
|
||||
prefix: `prefix-${index}`,
|
||||
suffix: `suffix-${index}`,
|
||||
}
|
||||
|
||||
+16
-1
@@ -1,4 +1,4 @@
|
||||
import type { FillInAtCursorSuggestion, MatchingSuggestionResult } from "../types"
|
||||
import type { FillInAtCursorSuggestion, MatchingSuggestionResult, PendingRequest } from "../types"
|
||||
|
||||
export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult {
|
||||
fillInAtCursor: FillInAtCursorSuggestion
|
||||
@@ -12,12 +12,14 @@ const MAX_DEBOUNCE_DELAY_MS = 1000
|
||||
* Searches from most recent to least recent.
|
||||
*/
|
||||
export function findMatchingSuggestion(
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
suggestionsHistory: FillInAtCursorSuggestion[],
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
for (let i = suggestionsHistory.length - 1; i >= 0; i--) {
|
||||
const fillInAtCursor = suggestionsHistory[i]!
|
||||
if (scope !== fillInAtCursor.scope) continue
|
||||
|
||||
if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) {
|
||||
return { text: fillInAtCursor.text, matchType: "exact", fillInAtCursor }
|
||||
@@ -42,6 +44,19 @@ export function findMatchingSuggestion(
|
||||
return null
|
||||
}
|
||||
|
||||
export function findCoveringPendingRequest(
|
||||
scope: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
requests: PendingRequest[],
|
||||
): PendingRequest | null {
|
||||
for (const request of requests) {
|
||||
if (scope !== request.scope || suffix !== request.suffix) continue
|
||||
if (prefix.startsWith(request.prefix)) return request
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of lines in a text string.
|
||||
* A single trailing newline does not count as an additional line.
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
// Copied from Continue's VS Code autocomplete provider:
|
||||
// https://github.com/continuedev/continue/blob/d0a3c0b626b5bebc3bef4742eec05a0242be0bab/extensions/vscode/src/autocomplete/completionProvider.ts#L226-L263
|
||||
// Copyright 2023 Continue
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
// Modified by Kilo Code for notebook paths, cursor positions, and cache scoping.
|
||||
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export interface NotebookContext {
|
||||
contents: string
|
||||
filepath: string
|
||||
position: vscode.Position
|
||||
}
|
||||
|
||||
interface NotebookResolution {
|
||||
notebook: vscode.NotebookDocument
|
||||
cells: vscode.NotebookCell[]
|
||||
cell: vscode.NotebookCell
|
||||
index: number
|
||||
version: number
|
||||
}
|
||||
|
||||
const resolutions = new WeakMap<vscode.Uri, NotebookResolution>()
|
||||
|
||||
function resolveNotebook(uri: vscode.Uri): NotebookResolution | undefined {
|
||||
const id = uri.toString()
|
||||
const cached = resolutions.get(uri)
|
||||
if (
|
||||
cached &&
|
||||
cached.notebook.version === cached.version &&
|
||||
vscode.workspace.notebookDocuments.includes(cached.notebook)
|
||||
) {
|
||||
return cached
|
||||
}
|
||||
|
||||
resolutions.delete(uri)
|
||||
for (const notebook of vscode.workspace.notebookDocuments) {
|
||||
const cells = notebook.getCells()
|
||||
const index = cells.findIndex((cell) => cell.document.uri.toString() === id)
|
||||
if (index < 0) continue
|
||||
const resolved = { notebook, cells, cell: cells[index]!, index, version: notebook.version }
|
||||
resolutions.set(uri, resolved)
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
export function notebookUri(uri: vscode.Uri): vscode.Uri | undefined {
|
||||
if (uri.scheme === "file") return uri
|
||||
if (uri.scheme !== "vscode-notebook-cell") return
|
||||
return resolveNotebook(uri)?.notebook.uri
|
||||
}
|
||||
|
||||
export function supportsNotebook(document: vscode.TextDocument): boolean {
|
||||
if (document.uri.scheme !== "vscode-notebook-cell") return true
|
||||
const resolved = resolveNotebook(document.uri)
|
||||
return resolved?.cell.kind === vscode.NotebookCellKind.Code && document.languageId === "python"
|
||||
}
|
||||
|
||||
export function autocompleteScope(document: vscode.TextDocument): string {
|
||||
const id = document.uri.toString()
|
||||
const resolved = resolveNotebook(document.uri)
|
||||
if (!resolved) return id
|
||||
|
||||
const siblings = resolved.cells
|
||||
.filter((_, index) => index !== resolved.index)
|
||||
.map((cell) => [cell.document.uri.toString(), cell.kind, cell.document.languageId, cell.document.version])
|
||||
return JSON.stringify([id, resolved.notebook.uri.toString(), resolved.index, siblings])
|
||||
}
|
||||
|
||||
export function getNotebookContext(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
): NotebookContext | undefined {
|
||||
if (document.uri.scheme !== "vscode-notebook-cell" || !supportsNotebook(document)) return
|
||||
|
||||
const resolved = resolveNotebook(document.uri)
|
||||
if (!resolved) return
|
||||
|
||||
const cells = resolved.cells
|
||||
const contents = cells
|
||||
.map((cell) => {
|
||||
const text = cell.document.getText()
|
||||
if (cell.kind === vscode.NotebookCellKind.Markup) {
|
||||
return `"""${text}"""`
|
||||
}
|
||||
return text
|
||||
})
|
||||
.join("\n\n")
|
||||
|
||||
const line = cells
|
||||
.slice(0, resolved.index)
|
||||
.reduce((line, cell) => line + cell.document.getText().split("\n").length + 1, position.line)
|
||||
|
||||
return {
|
||||
contents,
|
||||
filepath: resolved.notebook.uri.fsPath,
|
||||
position: new vscode.Position(line, position.character),
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export interface FillInAtCursorSuggestion {
|
||||
text: string
|
||||
prefix: string
|
||||
suffix: string
|
||||
scope: string
|
||||
}
|
||||
|
||||
export interface MatchingSuggestionResult {
|
||||
@@ -105,6 +106,7 @@ export interface LastSuggestionInfo extends AutocompleteContext {
|
||||
}
|
||||
|
||||
export interface PendingRequest {
|
||||
scope: string
|
||||
prefix: string
|
||||
suffix: string
|
||||
promise: Promise<void>
|
||||
|
||||
@@ -20,6 +20,7 @@ export function resolveEventSessionId(
|
||||
}
|
||||
|
||||
void lookupMessageSessionId
|
||||
if (event.type === "sandbox.status.changed") return event.properties.sessionID
|
||||
return resolveTransientSessionId(event)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ import { MarketplacePaths } from "./paths"
|
||||
|
||||
type Entry = [string, { type: string }]
|
||||
|
||||
function entry(id: string, type: "agent" | "mcp" | "skill"): Entry {
|
||||
return [`${type}:${id}`, { type }]
|
||||
}
|
||||
|
||||
export interface CliSkill {
|
||||
name: string
|
||||
location: string
|
||||
@@ -52,7 +56,7 @@ export class InstallationDetector {
|
||||
? !!workspace && this.isProjectSkill(s.location, workspace)
|
||||
: !workspace || !this.isProjectSkill(s.location, workspace),
|
||||
)
|
||||
.map((s) => [s.name, { type: "skill" }])
|
||||
.map((skill) => entry(skill.name, "skill"))
|
||||
}
|
||||
|
||||
/** Scan .kilo/agents/*.md files to detect installed marketplace agents. */
|
||||
@@ -60,7 +64,7 @@ export class InstallationDetector {
|
||||
const dir = this.paths.agentsDir(scope, workspace)
|
||||
try {
|
||||
const files = await fs.readdir(dir)
|
||||
return files.filter((f) => f.endsWith(".md")).map((f) => [path.basename(f, ".md"), { type: "agent" }] as Entry)
|
||||
return files.filter((file) => file.endsWith(".md")).map((file) => entry(path.basename(file, ".md"), "agent"))
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
console.warn(`Failed to detect agent files from ${dir}:`, err)
|
||||
@@ -78,13 +82,13 @@ export class InstallationDetector {
|
||||
|
||||
if (parsed?.mcp && typeof parsed.mcp === "object") {
|
||||
for (const key of Object.keys(parsed.mcp)) {
|
||||
entries.push([key, { type: "mcp" }])
|
||||
entries.push(entry(key, "mcp"))
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed?.agent && typeof parsed.agent === "object") {
|
||||
for (const key of Object.keys(parsed.agent)) {
|
||||
entries.push([key, { type: "agent" }])
|
||||
entries.push(entry(key, "agent"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ export interface MarketplaceItemBase {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
author?: string
|
||||
authorUrl?: string
|
||||
tags?: string[]
|
||||
prerequisites?: string[]
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ export interface RawSkill {
|
||||
|
||||
export interface SkillMarketplaceItem extends MarketplaceItemBase {
|
||||
type: "skill"
|
||||
category: string
|
||||
githubUrl: string
|
||||
content: string
|
||||
displayName: string
|
||||
|
||||
@@ -11,8 +11,7 @@ const STORIES = [
|
||||
{ id: "profile--logged-in-personal", name: "Profile / personal account" },
|
||||
{ id: "profile--logged-in", name: "Profile / organization account" },
|
||||
{ id: "settings--providers-configure", name: "Settings / providers empty state" },
|
||||
{ id: "marketplace--skills-tab-empty", name: "Marketplace / skills empty state" },
|
||||
{ id: "marketplace--agents-tab-empty", name: "Marketplace / agents empty state" },
|
||||
{ id: "marketplace--empty-list", name: "Marketplace / empty state" },
|
||||
{ id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" },
|
||||
]
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const mockVscode = {
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: "/repo" } }],
|
||||
textDocuments: [] as Array<unknown>,
|
||||
notebookDocuments: [] as Array<unknown>,
|
||||
onDidOpenTextDocument: () => ({ dispose: noop }),
|
||||
onDidChangeTextDocument: () => ({ dispose: noop }),
|
||||
onDidCloseTextDocument: () => ({ dispose: noop }),
|
||||
@@ -73,7 +74,9 @@ const mockVscode = {
|
||||
},
|
||||
window: {
|
||||
activeTextEditor: undefined,
|
||||
activeNotebookEditor: undefined,
|
||||
visibleTextEditors: [],
|
||||
visibleNotebookEditors: [],
|
||||
tabGroups: { all: [] },
|
||||
showTextDocument: async () => {},
|
||||
showWarningMessage: async () => undefined,
|
||||
@@ -132,6 +135,10 @@ const mockVscode = {
|
||||
TabInputText: class {
|
||||
constructor(public uri: { scheme: string; fsPath: string }) {}
|
||||
},
|
||||
TabInputNotebook: class {
|
||||
constructor(public uri: { scheme: string; fsPath: string }) {}
|
||||
},
|
||||
NotebookCellKind: { Markup: 1, Code: 2 },
|
||||
Position: class {
|
||||
constructor(
|
||||
public line: number,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import * as vscode from "vscode"
|
||||
import { AutocompleteInlineCompletionProvider } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider"
|
||||
|
||||
function createProvider(state = "connected") {
|
||||
;(vscode.window as any).onDidChangeActiveTextEditor = () => ({ dispose: () => {} })
|
||||
;(vscode.window as any).onDidChangeTextEditorSelection = () => ({ dispose: () => {} })
|
||||
return new AutocompleteInlineCompletionProvider(
|
||||
{} as any,
|
||||
"kilo/mistralai/codestral-2508",
|
||||
{ getConnectionState: () => state } as any,
|
||||
() => {},
|
||||
() => ({ enableAutoTrigger: true }),
|
||||
"/repo",
|
||||
)
|
||||
}
|
||||
|
||||
function prompt() {
|
||||
return {
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
modelName: "codestral",
|
||||
completionOptions: {},
|
||||
selectedCompletionInfo: undefined,
|
||||
} as any
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe("autocomplete FIM abort scope", () => {
|
||||
it("does not abort in-flight requests from another scope", async () => {
|
||||
const provider = createProvider()
|
||||
const signals: AbortSignal[] = []
|
||||
const resolvers: Array<() => void> = []
|
||||
;(provider as any).fimPromptBuilder = {
|
||||
getFromFIM: async (
|
||||
_connection: unknown,
|
||||
_model: string,
|
||||
_prompt: unknown,
|
||||
process: (text: string) => unknown,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
signals.push(signal)
|
||||
return new Promise((resolve) => {
|
||||
resolvers.push(() => {
|
||||
resolve({
|
||||
suggestion: process("value"),
|
||||
cost: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const first = provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python")
|
||||
await tick()
|
||||
|
||||
const second = provider.fetchAndCacheSuggestion("scope-b", prompt(), "", "", "python")
|
||||
await tick()
|
||||
|
||||
expect(signals[0]?.aborted).toBe(false)
|
||||
expect(signals[1]?.aborted).toBe(false)
|
||||
|
||||
resolvers.forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
provider.dispose()
|
||||
})
|
||||
|
||||
it("aborts older in-flight requests in the same scope", async () => {
|
||||
const provider = createProvider()
|
||||
const signals: AbortSignal[] = []
|
||||
const resolvers: Array<() => void> = []
|
||||
;(provider as any).fimPromptBuilder = {
|
||||
getFromFIM: async (
|
||||
_connection: unknown,
|
||||
_model: string,
|
||||
_prompt: unknown,
|
||||
process: (text: string) => unknown,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
signals.push(signal)
|
||||
return new Promise((resolve) => {
|
||||
resolvers.push(() => {
|
||||
resolve({
|
||||
suggestion: process("value"),
|
||||
cost: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const first = provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python")
|
||||
await tick()
|
||||
|
||||
const second = provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python")
|
||||
await tick()
|
||||
|
||||
expect(signals[0]?.aborted).toBe(true)
|
||||
expect(signals[1]?.aborted).toBe(false)
|
||||
|
||||
resolvers.forEach((resolve) => resolve())
|
||||
await Promise.all([first, second])
|
||||
provider.dispose()
|
||||
})
|
||||
|
||||
it("does not retain controllers when credentials are invalid", async () => {
|
||||
const provider = createProvider("disconnected")
|
||||
;(provider as any).fimPromptBuilder = {
|
||||
getFromFIM: () => {
|
||||
throw new Error("should not fetch")
|
||||
},
|
||||
}
|
||||
|
||||
await provider.fetchAndCacheSuggestion("scope-a", prompt(), "", "", "python")
|
||||
|
||||
expect((provider as any).fimAbortControllers.size).toBe(0)
|
||||
provider.dispose()
|
||||
})
|
||||
})
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
shouldShowOnlyFirstLine,
|
||||
getFirstLine,
|
||||
calcDebounceDelay,
|
||||
findCoveringPendingRequest,
|
||||
} from "../../src/services/autocomplete/classic-auto-complete/inline-utils"
|
||||
import type { FillInAtCursorSuggestion } from "../../src/services/autocomplete/types"
|
||||
import type { FillInAtCursorSuggestion, PendingRequest } from "../../src/services/autocomplete/types"
|
||||
|
||||
function makeSuggestion(prefix: string, text: string, suffix = ""): FillInAtCursorSuggestion {
|
||||
return { prefix, suffix, text }
|
||||
const scope = "file:///workspace/file.ts"
|
||||
|
||||
function makeSuggestion(prefix: string, text: string, suffix = "", value = scope): FillInAtCursorSuggestion {
|
||||
return { scope: value, prefix, suffix, text }
|
||||
}
|
||||
|
||||
describe("countLines", () => {
|
||||
@@ -89,46 +92,68 @@ describe("shouldShowOnlyFirstLine", () => {
|
||||
|
||||
describe("findMatchingSuggestion", () => {
|
||||
it("returns null for empty history", () => {
|
||||
expect(findMatchingSuggestion("prefix", "suffix", [])).toBeNull()
|
||||
expect(findMatchingSuggestion(scope, "prefix", "suffix", [])).toBeNull()
|
||||
})
|
||||
|
||||
it("returns exact match", () => {
|
||||
const hist = [makeSuggestion("hello ", "world")]
|
||||
const result = findMatchingSuggestion("hello ", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "hello ", "", hist)
|
||||
expect(result?.matchType).toBe("exact")
|
||||
expect(result?.text).toBe("world")
|
||||
})
|
||||
|
||||
it("returns partial_typing match when user typed beginning of suggestion", () => {
|
||||
const hist = [makeSuggestion("he", "llo world")]
|
||||
const result = findMatchingSuggestion("hell", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "hell", "", hist)
|
||||
expect(result?.matchType).toBe("partial_typing")
|
||||
expect(result?.text).toBe("o world")
|
||||
})
|
||||
|
||||
it("returns backward_deletion match when user deleted chars", () => {
|
||||
const hist = [makeSuggestion("hello world", "more", "suffix")]
|
||||
const result = findMatchingSuggestion("hello", "suffix", hist)
|
||||
const result = findMatchingSuggestion(scope, "hello", "suffix", hist)
|
||||
expect(result?.matchType).toBe("backward_deletion")
|
||||
expect(result?.text).toBe(" worldmore")
|
||||
})
|
||||
|
||||
it("prefers most recent suggestion (searches from end)", () => {
|
||||
const hist = [makeSuggestion("prefix", "old suggestion"), makeSuggestion("prefix", "new suggestion")]
|
||||
const result = findMatchingSuggestion("prefix", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "prefix", "", hist)
|
||||
expect(result?.text).toBe("new suggestion")
|
||||
})
|
||||
|
||||
it("returns null when no match found", () => {
|
||||
const hist = [makeSuggestion("different", "no match")]
|
||||
expect(findMatchingSuggestion("unrelated", "suffix", hist)).toBeNull()
|
||||
expect(findMatchingSuggestion(scope, "unrelated", "suffix", hist)).toBeNull()
|
||||
})
|
||||
|
||||
it("does not match empty suggestion text for partial_typing", () => {
|
||||
const hist = [makeSuggestion("prefix", "")]
|
||||
const result = findMatchingSuggestion("prefix more", "", hist)
|
||||
const result = findMatchingSuggestion(scope, "prefix more", "", hist)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("does not reuse suggestions from another scope", () => {
|
||||
const hist = [makeSuggestion("value = ", "first()", "", "vscode-notebook-cell:notebook.ipynb#cell-a")]
|
||||
const result = findMatchingSuggestion("vscode-notebook-cell:notebook.ipynb#cell-b", "value = ", "", hist)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("findCoveringPendingRequest", () => {
|
||||
function request(value: string): PendingRequest {
|
||||
return { scope: value, prefix: "pri", suffix: "", promise: Promise.resolve() }
|
||||
}
|
||||
|
||||
it("reuses forward-typing requests within the same scope", () => {
|
||||
const pending = request(scope)
|
||||
expect(findCoveringPendingRequest(scope, "print", "", [pending])).toBe(pending)
|
||||
})
|
||||
|
||||
it("does not reuse requests from another scope", () => {
|
||||
const pending = request("vscode-notebook-cell:notebook.ipynb#cell-a")
|
||||
expect(findCoveringPendingRequest("vscode-notebook-cell:notebook.ipynb#cell-b", "print", "", [pending])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyFirstLineOnly", () => {
|
||||
@@ -138,21 +163,21 @@ describe("applyFirstLineOnly", () => {
|
||||
|
||||
it("returns empty result unchanged", () => {
|
||||
const hist = [makeSuggestion("p", "")]
|
||||
const result = findMatchingSuggestion("p", "", hist)!
|
||||
const result = findMatchingSuggestion(scope, "p", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "p")
|
||||
expect(applied?.text).toBe("")
|
||||
})
|
||||
|
||||
it("truncates to first line when mid-line suggestion", () => {
|
||||
const hist = [makeSuggestion("function foo() { return ", "x\n const y = 1\n}")]
|
||||
const result = findMatchingSuggestion("function foo() { return ", "", hist)!
|
||||
const result = findMatchingSuggestion(scope, "function foo() { return ", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "function foo() { return ")
|
||||
expect(applied?.text).toBe("x")
|
||||
})
|
||||
|
||||
it("preserves full multi-line suggestion when starting with newline", () => {
|
||||
const hist = [makeSuggestion("foo", "\n const x = 1\n const y = 2")]
|
||||
const result = findMatchingSuggestion("foo", "", hist)!
|
||||
const result = findMatchingSuggestion(scope, "foo", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "foo")
|
||||
expect(applied?.text).toBe("\n const x = 1\n const y = 2")
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { notebookModel, selector } from "../../src/services/autocomplete/AutocompleteServiceManager"
|
||||
|
||||
describe("autocomplete document selector", () => {
|
||||
it("registers classic autocomplete for files and notebook cells", () => {
|
||||
expect(selector("classic")).toEqual([{ scheme: "file" }, { scheme: "vscode-notebook-cell" }])
|
||||
})
|
||||
|
||||
it("keeps Next Edit limited to files", () => {
|
||||
expect(selector("next-edit")).toEqual([{ scheme: "file" }])
|
||||
})
|
||||
|
||||
it("uses the matching FIM model for notebook fallback", () => {
|
||||
expect(notebookModel("kilo", "inception/mercury-next-edit").id).toBe("kilo/inception/mercury-edit-2")
|
||||
expect(notebookModel("inception", "mercury-next-edit").id).toBe("inception/mercury-edit-2")
|
||||
expect(notebookModel("kilo", "mistralai/codestral-2508").id).toBe("kilo/mistralai/codestral-2508")
|
||||
})
|
||||
})
|
||||
@@ -6,29 +6,29 @@ import {
|
||||
|
||||
describe("getSuggestionKey", () => {
|
||||
it("combines prefix, suffix, and text with pipe separators", () => {
|
||||
const key = getSuggestionKey({ prefix: "hello ", suffix: "\n}", text: "world" })
|
||||
const key = getSuggestionKey({ scope: "", prefix: "hello ", suffix: "\n}", text: "world" })
|
||||
expect(key).toBe("hello |\n}|world")
|
||||
})
|
||||
|
||||
it("produces unique keys for different suggestions", () => {
|
||||
const k1 = getSuggestionKey({ prefix: "a", suffix: "c", text: "b" })
|
||||
const k2 = getSuggestionKey({ prefix: "a", suffix: "c", text: "x" })
|
||||
const k1 = getSuggestionKey({ scope: "", prefix: "a", suffix: "c", text: "b" })
|
||||
const k2 = getSuggestionKey({ scope: "", prefix: "a", suffix: "c", text: "x" })
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("same content produces same key (stable)", () => {
|
||||
const s = { prefix: "const x = ", suffix: ";", text: "42" }
|
||||
const s = { scope: "", prefix: "const x = ", suffix: ";", text: "42" }
|
||||
expect(getSuggestionKey(s)).toBe(getSuggestionKey(s))
|
||||
})
|
||||
|
||||
it("different prefix produces different key", () => {
|
||||
const k1 = getSuggestionKey({ prefix: "a", suffix: "", text: "t" })
|
||||
const k2 = getSuggestionKey({ prefix: "b", suffix: "", text: "t" })
|
||||
const k1 = getSuggestionKey({ scope: "", prefix: "a", suffix: "", text: "t" })
|
||||
const k2 = getSuggestionKey({ scope: "", prefix: "b", suffix: "", text: "t" })
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("handles empty strings", () => {
|
||||
const key = getSuggestionKey({ prefix: "", suffix: "", text: "" })
|
||||
const key = getSuggestionKey({ scope: "", prefix: "", suffix: "", text: "" })
|
||||
expect(key).toBe("||")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -158,6 +158,16 @@ describe("resolveEventSessionId", () => {
|
||||
expect(resolveEventSessionId(suggestion, noLookup)).toBe("s10")
|
||||
})
|
||||
|
||||
it("routes sandbox status events", () => {
|
||||
const event = {
|
||||
id: "e12",
|
||||
type: "sandbox.status.changed",
|
||||
properties: { sessionID: "s11", directory: "/repo", enabled: true, available: true, version: 1 },
|
||||
} satisfies Payload
|
||||
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s11")
|
||||
})
|
||||
|
||||
it("returns undefined for global events", () => {
|
||||
const event = { id: "e12", type: "server.connected", properties: {} } satisfies Payload
|
||||
|
||||
|
||||
@@ -61,20 +61,31 @@ function createClient(options?: {
|
||||
sessionData?: unknown
|
||||
sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }>
|
||||
abortFailures?: string[]
|
||||
createDeferred?: Deferred<{ data: unknown }>
|
||||
sandboxDeferred?: Deferred<{ data: unknown }>
|
||||
sandboxStarted?: Deferred<void>
|
||||
}) {
|
||||
const calls: { before?: string; limit?: number }[] = []
|
||||
const stopped: { sessionID: string; directory?: string }[] = []
|
||||
const aborted: { sessionID: string; directory?: string }[] = []
|
||||
const prompted: Array<Record<string, unknown>> = []
|
||||
const reverted: Array<Record<string, unknown>> = []
|
||||
const created: Array<Record<string, unknown>> = []
|
||||
const sandboxed: Array<Record<string, unknown>> = []
|
||||
return {
|
||||
calls,
|
||||
stopped,
|
||||
aborted,
|
||||
prompted,
|
||||
reverted,
|
||||
created,
|
||||
sandboxed,
|
||||
session: {
|
||||
list: async () => ({ data: [] }),
|
||||
create: async (params: Record<string, unknown>) => {
|
||||
created.push(params)
|
||||
return options?.createDeferred?.promise ?? { data: mkSession() }
|
||||
},
|
||||
get: async (params: { sessionID: string; directory?: string }) => {
|
||||
if (options?.sessionGet) return options.sessionGet(params)
|
||||
return { data: options?.sessionData ?? null }
|
||||
@@ -104,6 +115,17 @@ function createClient(options?: {
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
sandbox: {
|
||||
toggle: async (params: Record<string, unknown>) => {
|
||||
sandboxed.push(params)
|
||||
options?.sandboxStarted?.resolve(undefined)
|
||||
return (
|
||||
options?.sandboxDeferred?.promise ?? {
|
||||
data: { directory: "/repo", enabled: true, available: true, version: 1 },
|
||||
}
|
||||
)
|
||||
},
|
||||
},
|
||||
backgroundProcess: {
|
||||
stopSession: async (params: { sessionID: string; directory?: string }) => {
|
||||
stopped.push(params)
|
||||
@@ -163,7 +185,8 @@ type ProviderInternals = {
|
||||
handleEvent: (event: unknown, directory?: string) => void
|
||||
handleAbort: (sid?: string) => Promise<void>
|
||||
handleRevertSession: (sid: string, messageID: string) => Promise<void>
|
||||
handleSendMessage: (text: string, messageID?: string, sessionID?: string) => Promise<void>
|
||||
handleSendMessage: (text: string, messageID?: string, sessionID?: string, draftID?: string) => Promise<void>
|
||||
handleToggleSandbox: (input: { sessionID?: string; draftID?: string; requestID: string }) => Promise<void>
|
||||
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
|
||||
handleDeleteSession: (sid: string) => Promise<void>
|
||||
}
|
||||
@@ -246,6 +269,141 @@ describe("KiloProvider.handleAbort", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider sandbox status", () => {
|
||||
it("ignores events from another directory for the same session", () => {
|
||||
const client = createClient()
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.sessionDirectories.set("s1", "/repo")
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
internal.handleEvent({
|
||||
type: "sandbox.status.changed",
|
||||
properties: { sessionID: "s1", directory: "/other", enabled: true, available: true, version: 1 },
|
||||
})
|
||||
expect(sent.some((message) => (message as { type?: string }).type === "sandboxStatus")).toBe(false)
|
||||
|
||||
internal.handleEvent({
|
||||
type: "sandbox.status.changed",
|
||||
properties: { sessionID: "s1", directory: "/repo", enabled: true, available: true, version: 1 },
|
||||
})
|
||||
expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatus", sessionID: "s1", directory: "/repo" }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider sandbox toggle", () => {
|
||||
it("creates a session before toggling from the empty composer", async () => {
|
||||
const client = createClient()
|
||||
const { internal, sent } = makeProvider(client)
|
||||
|
||||
await internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" })
|
||||
|
||||
expect(client.created).toEqual([expect.objectContaining({ directory: "/repo" })])
|
||||
expect(client.sandboxed).toEqual([{ sessionID: "s1", directory: "/repo" }])
|
||||
expect(sent.findIndex((message) => (message as { type?: string }).type === "sessionCreated")).toBeLessThan(
|
||||
sent.findIndex((message) => (message as { type?: string }).type === "sandboxStatus"),
|
||||
)
|
||||
expect(sent).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "sandboxStatus",
|
||||
sessionID: "s1",
|
||||
requestID: "sandbox-1",
|
||||
enabled: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("shares session creation and finishes the toggle before a prompt", async () => {
|
||||
const create = defer<{ data: unknown }>()
|
||||
const sandbox = defer<{ data: unknown }>()
|
||||
const started = defer<void>()
|
||||
const client = createClient({ createDeferred: create, sandboxDeferred: sandbox, sandboxStarted: started })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.gatherEditorContext = async () => ({})
|
||||
|
||||
const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" })
|
||||
await Promise.resolve()
|
||||
const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1")
|
||||
await Promise.resolve()
|
||||
|
||||
expect(client.created).toHaveLength(1)
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
|
||||
create.resolve({ data: mkSession() })
|
||||
await started.promise
|
||||
expect(client.sandboxed).toHaveLength(1)
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
|
||||
sandbox.resolve({ data: { directory: "/repo", enabled: true, available: true, version: 1 } })
|
||||
await Promise.all([toggle, send])
|
||||
expect(client.created).toHaveLength(1)
|
||||
expect(client.prompted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not send a queued prompt when the sandbox toggle fails", async () => {
|
||||
const log = spyOn(console, "error").mockImplementation(() => {})
|
||||
const sandbox = defer<{ data: unknown }>()
|
||||
const started = defer<void>()
|
||||
const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started })
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.gatherEditorContext = async () => ({})
|
||||
|
||||
const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" })
|
||||
await started.promise
|
||||
const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1")
|
||||
sandbox.reject(new Error("toggle failed"))
|
||||
await Promise.all([toggle, send])
|
||||
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatusError", requestID: "sandbox-1" }))
|
||||
expect(sent).toContainEqual(
|
||||
expect.objectContaining({ type: "sendMessageFailed", sessionID: "s1", messageID: "message-1" }),
|
||||
)
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("does not send a queued prompt when the sandbox backend is unavailable", async () => {
|
||||
const log = spyOn(console, "error").mockImplementation(() => {})
|
||||
const sandbox = defer<{ data: unknown }>()
|
||||
const started = defer<void>()
|
||||
const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started })
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.gatherEditorContext = async () => ({})
|
||||
|
||||
const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" })
|
||||
await started.promise
|
||||
const send = internal.handleSendMessage("hello", "message-1", undefined, "draft-1")
|
||||
sandbox.resolve({
|
||||
data: { directory: "/repo", enabled: false, available: false, reason: "unsupported", version: 0 },
|
||||
})
|
||||
await Promise.all([toggle, send])
|
||||
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
expect(sent).toContainEqual(expect.objectContaining({ type: "sandboxStatusError", message: "unsupported" }))
|
||||
expect(sent).toContainEqual(
|
||||
expect.objectContaining({ type: "sendMessageFailed", sessionID: "s1", messageID: "message-1" }),
|
||||
)
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it("keeps prompts queued after the draft is promoted", async () => {
|
||||
const sandbox = defer<{ data: unknown }>()
|
||||
const started = defer<void>()
|
||||
const client = createClient({ sandboxDeferred: sandbox, sandboxStarted: started })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.gatherEditorContext = async () => ({})
|
||||
|
||||
const toggle = internal.handleToggleSandbox({ draftID: "draft-1", requestID: "sandbox-1" })
|
||||
await started.promise
|
||||
const send = internal.handleSendMessage("hello", "message-1", "s1", "draft-1")
|
||||
await Promise.resolve()
|
||||
expect(client.prompted).toHaveLength(0)
|
||||
|
||||
sandbox.resolve({ data: { directory: "/repo", enabled: true, available: true, version: 1 } })
|
||||
await Promise.all([toggle, send])
|
||||
expect(client.prompted).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider revert ordering", () => {
|
||||
it("unwraps the nested sync payload emitted by the live SSE endpoint", () => {
|
||||
const event = unwrapSyncEvent({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
SyncEventMessageUpdated,
|
||||
EventSessionStatus,
|
||||
EventSessionTurnClose,
|
||||
EventSandboxStatusChanged,
|
||||
EventPermissionAsked,
|
||||
EventPermissionReplied,
|
||||
EventTodoUpdated,
|
||||
@@ -448,6 +449,23 @@ describe("mapSSEEventToWebviewMessage", () => {
|
||||
}
|
||||
})
|
||||
|
||||
it("maps sandbox status changes to effective button state", () => {
|
||||
const event: EventSandboxStatusChanged = {
|
||||
type: "sandbox.status.changed",
|
||||
properties: { sessionID: "sess-1", directory: "/tmp", enabled: true, available: true, version: 3 },
|
||||
}
|
||||
|
||||
expect(mapSSEEventToWebviewMessage(event, "sess-1")).toEqual({
|
||||
type: "sandboxStatus",
|
||||
sessionID: "sess-1",
|
||||
directory: "/tmp",
|
||||
enabled: true,
|
||||
available: true,
|
||||
reason: undefined,
|
||||
version: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it("maps session.turn.close to its terminal reason", () => {
|
||||
const event: EventSessionTurnClose = {
|
||||
id: "evt-turn",
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
type MarketplaceRemoveContext,
|
||||
} from "../../src/services/marketplace/actions"
|
||||
import type { McpMarketplaceItem } from "../../src/services/marketplace/types"
|
||||
import { filterItems, installedScopes, retain } from "../../webview-ui/src/components/marketplace/utils"
|
||||
import type { MarketplaceItem } from "../../webview-ui/src/types/marketplace"
|
||||
|
||||
const project = "/repo"
|
||||
const storage = vscode.Uri.file("/storage")
|
||||
@@ -18,6 +20,7 @@ const item: McpMarketplaceItem = {
|
||||
type: "mcp",
|
||||
name: "Memory",
|
||||
description: "",
|
||||
category: "development",
|
||||
url: "",
|
||||
content: "",
|
||||
}
|
||||
@@ -62,6 +65,71 @@ afterEach(() => {
|
||||
fs.writeFile = original.writeFile
|
||||
})
|
||||
|
||||
describe("Marketplace installation metadata", () => {
|
||||
it("tracks colliding IDs independently by item type", () => {
|
||||
const metadata = {
|
||||
project: {
|
||||
"mcp:dbt": { type: "mcp" },
|
||||
"skill:dbt": { type: "skill" },
|
||||
},
|
||||
global: {},
|
||||
}
|
||||
|
||||
expect(installedScopes("dbt", "mcp", metadata)).toEqual(["project"])
|
||||
expect(installedScopes("dbt", "skill", metadata)).toEqual(["project"])
|
||||
expect(installedScopes("dbt", "agent", metadata)).toEqual([])
|
||||
})
|
||||
|
||||
it("removes filters that are no longer available", () => {
|
||||
expect(retain(["agent", "mcp"], ["mcp", "skill"])).toEqual(["mcp"])
|
||||
})
|
||||
|
||||
it("filters the mixed list by search, category, and status", () => {
|
||||
const items: MarketplaceItem[] = [
|
||||
{
|
||||
type: "agent",
|
||||
id: "reviewer",
|
||||
name: "Code Reviewer",
|
||||
description: "Reviews code",
|
||||
category: "development",
|
||||
content: { mode: "all", description: "Reviews code", prompt: "Review" },
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
id: "warehouse",
|
||||
name: "Warehouse",
|
||||
description: "Queries data",
|
||||
category: "web-automation",
|
||||
url: "https://example.com",
|
||||
content: "{}",
|
||||
},
|
||||
{
|
||||
type: "skill",
|
||||
id: "campaign-writer",
|
||||
name: "Campaign Writer",
|
||||
displayName: "Campaign Writer",
|
||||
description: "Writes campaigns",
|
||||
category: "business",
|
||||
displayCategory: "Business",
|
||||
githubUrl: "https://example.com",
|
||||
content: "https://example.com/skill.tar.gz",
|
||||
},
|
||||
]
|
||||
const metadata = { project: { "mcp:warehouse": { type: "mcp" } }, global: {} }
|
||||
|
||||
expect(filterItems(items, metadata, "reviewer", "all", [], []).map((item) => item.id)).toEqual(["reviewer"])
|
||||
expect(filterItems(items, metadata, "web automation", "all", [], []).map((item) => item.id)).toEqual(["warehouse"])
|
||||
expect(
|
||||
filterItems(items, metadata, "servidor mcp", "all", [], [], { mcp: "Servidor MCP" }).map((item) => item.id),
|
||||
).toEqual(["warehouse"])
|
||||
expect(filterItems(items, metadata, "", "all", ["business"], []).map((item) => item.id)).toEqual([
|
||||
"campaign-writer",
|
||||
])
|
||||
expect(filterItems(items, metadata, "", "installed", [], []).map((item) => item.id)).toEqual(["warehouse"])
|
||||
expect(filterItems(items, metadata, "", "all", [], ["mcp"]).map((item) => item.id)).toEqual(["warehouse"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Marketplace legacy MCP cleanup", () => {
|
||||
it("preserves global legacy config during project removal", async () => {
|
||||
const files = setup()
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("MarketplaceInstaller MCP format normalization", () => {
|
||||
id: "memory",
|
||||
name: "Memory",
|
||||
description: "test",
|
||||
category: "development",
|
||||
url: "https://example.com",
|
||||
content: JSON.stringify({
|
||||
command: "npx",
|
||||
@@ -81,6 +82,7 @@ describe("MarketplaceInstaller MCP format normalization", () => {
|
||||
id: "myremote",
|
||||
name: "Remote",
|
||||
description: "test",
|
||||
category: "development",
|
||||
url: "https://example.com",
|
||||
content: JSON.stringify({
|
||||
type: "sse",
|
||||
@@ -105,6 +107,7 @@ describe("MarketplaceInstaller MCP format normalization", () => {
|
||||
id: "already",
|
||||
name: "Already Done",
|
||||
description: "test",
|
||||
category: "development",
|
||||
url: "https://example.com",
|
||||
content: JSON.stringify({
|
||||
type: "local",
|
||||
@@ -153,6 +156,7 @@ describe("MarketplaceInstaller skills", () => {
|
||||
id: "test-mcp",
|
||||
name: "Test MCP",
|
||||
description: "test",
|
||||
category: "development",
|
||||
url: "https://example.com",
|
||||
content: "{}",
|
||||
},
|
||||
@@ -164,6 +168,7 @@ describe("MarketplaceInstaller skills", () => {
|
||||
id: "test-agent",
|
||||
name: "Test Agent",
|
||||
description: "test",
|
||||
category: "development",
|
||||
content: { mode: "all", description: "test", prompt: "test" },
|
||||
},
|
||||
"project",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test"
|
||||
import * as vscode from "vscode"
|
||||
import {
|
||||
autocompleteScope,
|
||||
getNotebookContext,
|
||||
notebookUri,
|
||||
supportsNotebook,
|
||||
} from "../../src/services/autocomplete/continuedev/core/autocomplete/notebook"
|
||||
import { accessible } from "../../src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider"
|
||||
import type { FileIgnoreController } from "../../src/services/autocomplete/shims/FileIgnoreController"
|
||||
|
||||
function uri(scheme: string, path: string, fragment = ""): vscode.Uri {
|
||||
const value = `${scheme}:${path}${fragment ? `#${fragment}` : ""}`
|
||||
return {
|
||||
scheme,
|
||||
fsPath: path,
|
||||
toString: () => value,
|
||||
} as vscode.Uri
|
||||
}
|
||||
|
||||
function document(id: string, text: string, languageId = "python", version = 1): vscode.TextDocument {
|
||||
return {
|
||||
uri: uri("vscode-notebook-cell", "/workspace/example.ipynb", id),
|
||||
fileName: `/workspace/${id}.py`,
|
||||
languageId,
|
||||
version,
|
||||
getText: () => text,
|
||||
} as vscode.TextDocument
|
||||
}
|
||||
|
||||
function notebooks(value: vscode.NotebookDocument[]): void {
|
||||
Object.defineProperty(vscode.workspace, "notebookDocuments", {
|
||||
configurable: true,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
describe("notebook context", () => {
|
||||
beforeEach(() => notebooks([]))
|
||||
|
||||
it("flattens notebook cells and translates the cursor", () => {
|
||||
const markdown = document("markdown", "# Title\nNotes")
|
||||
const code = document("code", "const value = 1\nvalue += 1", "javascript")
|
||||
const current = document("current", "print(value)\nprint('done')")
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
getCells: () => [
|
||||
{ kind: vscode.NotebookCellKind.Markup, document: markdown },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: code },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: current },
|
||||
],
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
const context = getNotebookContext(current, new vscode.Position(1, 5))
|
||||
|
||||
expect(context).toEqual({
|
||||
contents: `"""# Title\nNotes"""\n\nconst value = 1\nvalue += 1\n\nprint(value)\nprint('done')`,
|
||||
filepath: "/workspace/example.ipynb",
|
||||
position: new vscode.Position(7, 5),
|
||||
})
|
||||
})
|
||||
|
||||
it("limits notebook completion to Python code cells", () => {
|
||||
const python = document("python", "value = 1")
|
||||
const javascript = document("javascript", "const value = 1", "javascript")
|
||||
const markdown = document("markdown", "# Heading", "markdown")
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
getCells: () => [
|
||||
{ kind: vscode.NotebookCellKind.Code, document: python },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: javascript },
|
||||
{ kind: vscode.NotebookCellKind.Markup, document: markdown },
|
||||
],
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
expect(supportsNotebook(python)).toBe(true)
|
||||
expect(supportsNotebook(javascript)).toBe(false)
|
||||
expect(supportsNotebook(markdown)).toBe(false)
|
||||
expect(getNotebookContext(javascript, new vscode.Position(0, 0))).toBeUndefined()
|
||||
expect(getNotebookContext(markdown, new vscode.Position(0, 0))).toBeUndefined()
|
||||
expect(supportsNotebook({ uri: uri("file", "/workspace/file.ts") } as vscode.TextDocument)).toBe(true)
|
||||
})
|
||||
|
||||
it("resolves file and notebook cell URIs", () => {
|
||||
const file = uri("file", "/workspace/file.ts")
|
||||
const cell = document("code", "value = 1")
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
getCells: () => [{ kind: vscode.NotebookCellKind.Code, document: cell }],
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
expect(notebookUri(file)).toBe(file)
|
||||
expect(notebookUri(cell.uri)).toBe(notebook.uri)
|
||||
expect(notebookUri(uri("untitled", "Untitled-1"))).toBeUndefined()
|
||||
})
|
||||
|
||||
it("scopes autocomplete cache to the cell and sibling versions", () => {
|
||||
const current = document("current", "value = 1")
|
||||
const sibling = document("sibling", "other = 1")
|
||||
const cells = [
|
||||
{ kind: vscode.NotebookCellKind.Code, document: current },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: sibling },
|
||||
] as vscode.NotebookCell[]
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
version: 1,
|
||||
getCells: () => cells,
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
const initial = autocompleteScope(current)
|
||||
Object.assign(current, { version: 2 })
|
||||
Object.assign(notebook, { version: 2 })
|
||||
expect(autocompleteScope(current)).toBe(initial)
|
||||
|
||||
Object.assign(sibling, { version: 2 })
|
||||
Object.assign(notebook, { version: 3 })
|
||||
expect(autocompleteScope(current)).not.toBe(initial)
|
||||
expect(autocompleteScope(current)).not.toBe(autocompleteScope(sibling))
|
||||
})
|
||||
|
||||
it("changes autocomplete scope when sibling order changes", () => {
|
||||
const current = document("current", "value = 1")
|
||||
const first = document("first", "first = 1")
|
||||
const second = document("second", "second = 1")
|
||||
const cells = [
|
||||
{ kind: vscode.NotebookCellKind.Code, document: first },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: second },
|
||||
{ kind: vscode.NotebookCellKind.Code, document: current },
|
||||
] as vscode.NotebookCell[]
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
version: 1,
|
||||
getCells: () => cells,
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
const initial = autocompleteScope(current)
|
||||
cells.splice(0, 2, cells[1]!, cells[0]!)
|
||||
Object.assign(notebook, { version: 2 })
|
||||
|
||||
expect(autocompleteScope(current)).not.toBe(initial)
|
||||
})
|
||||
|
||||
it("reuses notebook resolution within the same notebook version", () => {
|
||||
const current = document("current", "value = 1")
|
||||
const cells = [{ kind: vscode.NotebookCellKind.Code, document: current }] as vscode.NotebookCell[]
|
||||
let calls = 0
|
||||
const notebook = {
|
||||
uri: uri("file", "/workspace/example.ipynb"),
|
||||
version: 1,
|
||||
getCells: () => {
|
||||
calls++
|
||||
return cells
|
||||
},
|
||||
} as vscode.NotebookDocument
|
||||
notebooks([notebook])
|
||||
|
||||
expect(supportsNotebook(current)).toBe(true)
|
||||
expect(notebookUri(current.uri)).toBe(notebook.uri)
|
||||
expect(getNotebookContext(current, new vscode.Position(0, 0))).toBeDefined()
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it("validates notebook parent paths regardless of URI scheme", () => {
|
||||
for (const scheme of ["file", "untitled", "memfs"]) {
|
||||
const cell = document(scheme, "value = 1")
|
||||
const notebook = {
|
||||
uri: uri(scheme, `/workspace/${scheme}.ipynb`),
|
||||
getCells: () => [{ kind: vscode.NotebookCellKind.Code, document: cell }],
|
||||
} as vscode.NotebookDocument
|
||||
const paths: string[] = []
|
||||
const controller = {
|
||||
validateAccess: (path: string) => {
|
||||
paths.push(path)
|
||||
return false
|
||||
},
|
||||
} as FileIgnoreController
|
||||
notebooks([notebook])
|
||||
|
||||
expect(accessible(controller, cell)).toBe(false)
|
||||
expect(paths).toEqual([`/workspace/${scheme}.ipynb`])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -20,17 +20,35 @@ describe("PromptInput connection guard", () => {
|
||||
})
|
||||
|
||||
describe("PromptInput sandbox toggle", () => {
|
||||
it("writes only the sandbox patch without saving pending settings drafts", () => {
|
||||
const start = src.indexOf("<Show when={features().sandboxControls}>")
|
||||
const end = src.indexOf("</Show>", start)
|
||||
it("toggles or creates the runtime session instead of writing config", () => {
|
||||
const start = src.indexOf("const toggleSandbox = () =>")
|
||||
const end = src.indexOf("let enhanceCounter", start)
|
||||
const toggle = src.slice(start, end)
|
||||
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
expect(toggle).toContain("vscode.postMessage({")
|
||||
expect(toggle).toContain('type: "updateConfig"')
|
||||
expect(toggle).toContain("config: { experimental: { sandbox: !sandbox() } }")
|
||||
expect(toggle).not.toContain("saveConfig")
|
||||
expect(toggle).not.toContain("updateConfig(")
|
||||
expect(toggle).toContain("const sessionID = sandboxID()")
|
||||
expect(toggle).toContain('type: "toggleSandbox"')
|
||||
expect(toggle).toContain("sessionID,")
|
||||
expect(toggle).toContain("draftID: props.pendingSessionID ?? session.draftSessionID()")
|
||||
expect(toggle).toContain("requestID,")
|
||||
expect(toggle).toContain("setSandboxTarget(sessionID ?? null)")
|
||||
expect(toggle).not.toContain('type: "updateConfig"')
|
||||
})
|
||||
|
||||
it("uses the internal flag for visibility and effective runtime state for the button", () => {
|
||||
expect(src).toContain("features().sandboxControls")
|
||||
expect(src).toContain("<Show when={sandboxVisible()}>")
|
||||
expect(src).toContain('message.type === "sandboxStatus"')
|
||||
expect(src).toContain("message.sessionID !== sandboxID() && !matching")
|
||||
expect(src).toContain("setSandboxState(state)")
|
||||
expect(src).toContain("message.requestID === sandboxRequest()")
|
||||
expect(src).toContain("const target = untrack(sandboxTarget)")
|
||||
expect(src).toContain("if (target && target !== sessionID) clearSandboxRequest()")
|
||||
expect(src).toContain("sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true)")
|
||||
expect(src).toContain("aria-pressed={sandboxEnabled()}")
|
||||
expect(src).toContain("!sandboxReady()")
|
||||
expect(src).toContain("if (sandboxRequest() && target === null) return")
|
||||
expect(src).not.toContain("if (state === current) return true")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,8 +10,41 @@ import {
|
||||
isSuggesting,
|
||||
isQuestioning,
|
||||
isPathMention,
|
||||
applySandboxState,
|
||||
} from "../../webview-ui/src/components/chat/prompt-input-utils"
|
||||
|
||||
describe("applySandboxState", () => {
|
||||
const state = (enabled: boolean, revision: number, sessionID = "ses_1", directory = "/repo") => ({
|
||||
sessionID,
|
||||
directory,
|
||||
enabled,
|
||||
available: true,
|
||||
version: enabled ? 1 : 0,
|
||||
revision,
|
||||
})
|
||||
|
||||
it("ignores an HTTP response with an older backend version", () => {
|
||||
const latest = { ...state(true, 1), version: 2 }
|
||||
const stale = { ...state(false, 2), version: 1 }
|
||||
expect(applySandboxState(latest, stale)).toEqual(latest)
|
||||
})
|
||||
|
||||
it("uses provider revision to order equal backend versions", () => {
|
||||
const current = { ...state(false, 2), version: 4 }
|
||||
const older = { ...state(true, 1), version: 4 }
|
||||
const newer = { ...state(true, 3), version: 4 }
|
||||
expect(applySandboxState(current, older)).toEqual(current)
|
||||
expect(applySandboxState(current, newer)).toEqual(newer)
|
||||
})
|
||||
|
||||
it("keeps global provider ordering across sessions and directories", () => {
|
||||
expect(applySandboxState(state(true, 5, "ses_1"), state(false, 1, "ses_2"))).toEqual(state(true, 5, "ses_1"))
|
||||
expect(applySandboxState(state(true, 5), state(false, 6, "ses_1", "/worktree"))).toEqual(
|
||||
state(false, 6, "ses_1", "/worktree"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fileName", () => {
|
||||
it("extracts the last segment of a unix path", () => {
|
||||
expect(fileName("src/components/chat/PromptInput.tsx")).toBe("PromptInput.tsx")
|
||||
|
||||
@@ -43,8 +43,10 @@ import {
|
||||
insertSpacedText,
|
||||
isPromptBusy,
|
||||
isPathMention,
|
||||
applySandboxState,
|
||||
type SandboxState,
|
||||
} from "./prompt-input-utils"
|
||||
import type { ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages"
|
||||
import type { ExtensionMessage, ReviewComment, SendMessageFailedMessage, TextPart } from "../../types/messages"
|
||||
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
|
||||
import { pendingDraftKey, scopeDraftKey, sessionDraftKey } from "../../utils/prompt-drafts"
|
||||
import { ReviewComments } from "./ReviewComments"
|
||||
@@ -143,10 +145,82 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const [reviewComments, setReviewComments] = createSignal<ReviewComment[]>([])
|
||||
const [enhancing, setEnhancing] = createSignal(false)
|
||||
const [autoApprove, setAutoApprove] = createSignal(false)
|
||||
const sandbox = () => config().experimental?.sandbox ?? false
|
||||
const [sandboxState, setSandboxState] = createSignal<SandboxState>()
|
||||
const [sandboxRequest, setSandboxRequest] = createSignal<string>()
|
||||
const [sandboxTarget, setSandboxTarget] = createSignal<string | null>()
|
||||
let sandboxRetry: ReturnType<typeof setTimeout> | undefined
|
||||
let sandboxAttempts = 0
|
||||
const sandboxID = () => {
|
||||
const id = session.currentSessionID()
|
||||
return id?.startsWith("cloud:") ? undefined : id
|
||||
}
|
||||
const sandboxVisible = () => {
|
||||
const id = session.currentSessionID()
|
||||
return features().sandboxControls && !id?.startsWith("cloud:")
|
||||
}
|
||||
const sandbox = () => {
|
||||
const state = sandboxState()
|
||||
return state?.sessionID === sandboxID() ? state : undefined
|
||||
}
|
||||
const sandboxEnabled = () => sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true)
|
||||
const sandboxReady = () => !sandboxID() || sandbox() !== undefined
|
||||
const requestSandbox = () => {
|
||||
const sessionID = sandboxID()
|
||||
if (!sessionID || server.connectionState() !== "connected") return
|
||||
vscode.postMessage({ type: "requestSandboxStatus", sessionID })
|
||||
}
|
||||
const toggleSandbox = () => {
|
||||
const sessionID = sandboxID()
|
||||
const state = sandbox()
|
||||
if ((sessionID && !state) || state?.available === false || sandboxRequest() || !server.isConnected()) return
|
||||
const requestID = crypto.randomUUID()
|
||||
setSandboxRequest(requestID)
|
||||
setSandboxTarget(sessionID ?? null)
|
||||
vscode.postMessage({
|
||||
type: "toggleSandbox",
|
||||
sessionID,
|
||||
draftID: props.pendingSessionID ?? session.draftSessionID(),
|
||||
requestID,
|
||||
agentManagerContext: ctx(),
|
||||
})
|
||||
}
|
||||
const clearSandboxRequest = () => {
|
||||
setSandboxRequest(undefined)
|
||||
setSandboxTarget(undefined)
|
||||
}
|
||||
const retrySandbox = (sessionID: string) => {
|
||||
if (sandboxAttempts >= 2) return
|
||||
sandboxAttempts++
|
||||
if (sandboxRetry) clearTimeout(sandboxRetry)
|
||||
sandboxRetry = setTimeout(() => {
|
||||
sandboxRetry = undefined
|
||||
if (sandboxID() === sessionID) requestSandbox()
|
||||
}, 1000)
|
||||
}
|
||||
let enhanceCounter = 0
|
||||
let preEnhanceText: string | null = null
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = sandboxID()
|
||||
const connected = server.connectionState() === "connected"
|
||||
const target = untrack(sandboxTarget)
|
||||
if (sandboxRetry) clearTimeout(sandboxRetry)
|
||||
sandboxRetry = undefined
|
||||
sandboxAttempts = 0
|
||||
if (!connected) {
|
||||
clearSandboxRequest()
|
||||
setSandboxState(undefined)
|
||||
return
|
||||
}
|
||||
if (target && target !== sessionID) clearSandboxRequest()
|
||||
if (!sessionID) {
|
||||
setSandboxState(undefined)
|
||||
return
|
||||
}
|
||||
if (sandboxRequest() && target === null) return
|
||||
requestSandbox()
|
||||
})
|
||||
|
||||
const ghost = useGhostText(vscode, text, () => server.isConnected())
|
||||
const speech = useSpeechToText(vscode, server, language)
|
||||
|
||||
@@ -378,7 +452,74 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
imageDrafts.set(target, images)
|
||||
}
|
||||
|
||||
const handleSandboxMessage = (message: ExtensionMessage) => {
|
||||
if (message.type === "sandboxStatus") {
|
||||
const matching = message.requestID !== undefined && message.requestID === sandboxRequest()
|
||||
if (message.sessionID !== sandboxID() && !matching) return false
|
||||
if (!server.isConnected()) return true
|
||||
const current = sandboxState()
|
||||
if (matching) clearSandboxRequest()
|
||||
const state = applySandboxState(current, message)
|
||||
if (state !== current) setSandboxState(state)
|
||||
sandboxAttempts = 0
|
||||
if (sandboxRetry) clearTimeout(sandboxRetry)
|
||||
sandboxRetry = undefined
|
||||
if (matching) {
|
||||
if (!state.available) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: state.reason,
|
||||
})
|
||||
return true
|
||||
}
|
||||
showToast({
|
||||
variant: "success",
|
||||
title: language.t("settings.experimental.sandbox.title"),
|
||||
description: language.t(state.enabled ? "prompt.action.sandbox.enabled" : "prompt.action.sandbox.disabled"),
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type === "sandboxStatusError") {
|
||||
const matching = message.requestID !== undefined && message.requestID === sandboxRequest()
|
||||
if (message.sessionID !== sandboxID() && !matching) return false
|
||||
if (!server.isConnected()) return true
|
||||
const current = sandboxState()
|
||||
if (matching) clearSandboxRequest()
|
||||
if ((current?.revision ?? -1) > message.revision) return true
|
||||
if (!message.requestID) {
|
||||
const same = current?.sessionID === message.sessionID && current.directory === message.directory
|
||||
setSandboxState({
|
||||
sessionID: message.sessionID,
|
||||
directory: message.directory,
|
||||
enabled: same ? current.enabled : false,
|
||||
available: false,
|
||||
reason: message.message,
|
||||
version: same ? current.version : 0,
|
||||
revision: message.revision,
|
||||
})
|
||||
retrySandbox(message.sessionID)
|
||||
}
|
||||
if (matching) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: message.message,
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (message.type !== "configUpdated") return false
|
||||
requestSandbox()
|
||||
return true
|
||||
}
|
||||
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (handleSandboxMessage(message)) return
|
||||
|
||||
if (message.type === "setChatBoxMessage") {
|
||||
setText(message.text)
|
||||
mention.seedFromText(message.text)
|
||||
@@ -470,6 +611,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onCleanup(() => {
|
||||
// Persist current draft before unmounting
|
||||
saveDraft(draftKey(), text(), reviewComments(), imageAttach.images())
|
||||
if (sandboxRetry) clearTimeout(sandboxRetry)
|
||||
unsubAutoApprove()
|
||||
unsubscribe()
|
||||
})
|
||||
@@ -1073,27 +1215,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Icon name="shield" size="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Show when={features().sandboxControls}>
|
||||
<Show when={sandboxVisible()}>
|
||||
<Tooltip
|
||||
value={
|
||||
sandbox() ? language.t("prompt.action.sandbox.enabled") : language.t("prompt.action.sandbox.disabled")
|
||||
sandbox()?.available === false
|
||||
? (sandbox()?.reason ?? language.t("common.requestFailed"))
|
||||
: sandboxEnabled()
|
||||
? language.t("prompt.action.sandbox.enabled")
|
||||
: language.t("prompt.action.sandbox.disabled")
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
vscode.postMessage({
|
||||
type: "updateConfig",
|
||||
config: { experimental: { sandbox: !sandbox() } },
|
||||
})
|
||||
onClick={toggleSandbox}
|
||||
disabled={
|
||||
!server.isConnected() ||
|
||||
!sandboxReady() ||
|
||||
sandbox()?.available === false ||
|
||||
sandboxRequest() !== undefined
|
||||
}
|
||||
aria-label={
|
||||
sandbox() ? language.t("prompt.action.sandbox.disable") : language.t("prompt.action.sandbox.enable")
|
||||
sandboxEnabled()
|
||||
? language.t("prompt.action.sandbox.disable")
|
||||
: language.t("prompt.action.sandbox.enable")
|
||||
}
|
||||
aria-pressed={sandbox()}
|
||||
class={`prompt-status-button ${sandbox() ? "prompt-status-button--active" : ""}`}
|
||||
aria-pressed={sandboxEnabled()}
|
||||
class={`prompt-status-button ${sandboxEnabled() ? "prompt-status-button--active" : ""}`}
|
||||
>
|
||||
<Icon name="lock" size="small" />
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
export type SandboxState = {
|
||||
sessionID: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
directory: string
|
||||
revision: number
|
||||
}
|
||||
|
||||
export function applySandboxState(current: SandboxState | undefined, next: SandboxState) {
|
||||
if (!current) return next
|
||||
const same = current.sessionID === next.sessionID && current.directory === next.directory
|
||||
if (same && current.version > next.version) return current
|
||||
if (same && current.version === next.version && current.revision > next.revision) return current
|
||||
if (!same && current.revision > next.revision) return current
|
||||
return next
|
||||
}
|
||||
|
||||
export function fileName(path: string): string {
|
||||
const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalized.split("/").pop() ?? normalized
|
||||
|
||||
@@ -23,6 +23,11 @@ export const ItemCard = (props: Props) => {
|
||||
const scopes = () => installedScopes(props.item.id, props.item.type, props.metadata)
|
||||
const installed = () => scopes().length > 0
|
||||
const name = () => props.displayName ?? props.item.name
|
||||
const type = () => {
|
||||
if (props.item.type === "mcp") return t("marketplace.badge.mcpServer")
|
||||
if (props.item.type === "agent") return t("marketplace.remove.type.agent")
|
||||
return t("marketplace.remove.type.skill")
|
||||
}
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [clamped, setClamped] = createSignal(false)
|
||||
let ref: HTMLParagraphElement | undefined
|
||||
@@ -44,6 +49,7 @@ export const ItemCard = (props: Props) => {
|
||||
{name()}
|
||||
</span>
|
||||
</Show>
|
||||
<Tag class={`marketplace-badge-type marketplace-type-${props.item.type}`}>{type()}</Tag>
|
||||
</div>
|
||||
<Show when={props.item.author}>
|
||||
<span class="marketplace-card-author">
|
||||
|
||||
+71
-51
@@ -1,4 +1,4 @@
|
||||
import { createSignal, createMemo, For, Show } from "solid-js"
|
||||
import { createSignal, createMemo, createEffect, For, Show } from "solid-js"
|
||||
import { TextField } from "@kilocode/kilo-ui/text-field"
|
||||
import { Select } from "@kilocode/kilo-ui/select"
|
||||
import { Tag } from "@kilocode/kilo-ui/tag"
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
MarketplaceInstalledMetadata,
|
||||
} from "../../types/marketplace"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { isInstalled } from "./utils"
|
||||
import { filterItems, retain } from "./utils"
|
||||
import { ItemCard } from "./ItemCard"
|
||||
import { MarketplaceContribute } from "./MarketplaceContribute"
|
||||
|
||||
@@ -23,7 +23,6 @@ interface Props {
|
||||
items: MarketplaceItem[]
|
||||
metadata: MarketplaceInstalledMetadata
|
||||
fetching: boolean
|
||||
type: "mcp" | "agent" | "skill"
|
||||
searchPlaceholder: string
|
||||
emptyMessage: string
|
||||
onInstall: (item: MarketplaceItem) => void
|
||||
@@ -34,7 +33,8 @@ export const MarketplaceListView = (props: Props) => {
|
||||
const { t } = useLanguage()
|
||||
const [search, setSearch] = createSignal("")
|
||||
const [status, setStatus] = createSignal<StatusOption>({ value: "all", label: t("marketplace.filter.all") })
|
||||
const [tags, setTags] = createSignal<string[]>([])
|
||||
const [types, setTypes] = createSignal<MarketplaceItem["type"][]>([])
|
||||
const [categories, setCategories] = createSignal<string[]>([])
|
||||
|
||||
const options = (): StatusOption[] => [
|
||||
{ value: "all", label: t("marketplace.filter.all") },
|
||||
@@ -42,51 +42,54 @@ export const MarketplaceListView = (props: Props) => {
|
||||
{ value: "notInstalled", label: t("marketplace.filter.notInstalled") },
|
||||
]
|
||||
|
||||
const tagsFor = (item: MarketplaceItem): string[] => {
|
||||
if (item.type === "skill") return [(item as SkillMarketplaceItem).displayCategory]
|
||||
return item.tags ?? []
|
||||
const label = (category: string) =>
|
||||
category
|
||||
.split("-")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
|
||||
const allTypes = createMemo(() => {
|
||||
const available = new Set(props.items.map((item) => item.type))
|
||||
return (["agent", "mcp", "skill"] as const).filter((type) => available.has(type))
|
||||
})
|
||||
const allCategories = createMemo(() => Array.from(new Set(props.items.map((item) => item.category))).sort())
|
||||
|
||||
const typeLabel = (type: MarketplaceItem["type"]) => {
|
||||
if (type === "mcp") return t("marketplace.badge.mcpServer")
|
||||
if (type === "agent") return t("marketplace.remove.type.agent")
|
||||
return t("marketplace.remove.type.skill")
|
||||
}
|
||||
|
||||
const allTags = createMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const item of props.items) {
|
||||
for (const tag of tagsFor(item)) counts.set(tag, (counts.get(tag) ?? 0) + 1)
|
||||
}
|
||||
const min = props.type === "mcp" ? 5 : 1
|
||||
return Array.from(counts.entries())
|
||||
.filter(([, n]) => n >= min)
|
||||
.map(([tag]) => tag)
|
||||
.sort()
|
||||
createEffect(() => {
|
||||
setTypes((current) => retain(current, allTypes()))
|
||||
setCategories((current) => retain(current, allCategories()))
|
||||
})
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
const current = tags()
|
||||
if (current.includes(tag)) {
|
||||
setTags(current.filter((t) => t !== tag))
|
||||
} else {
|
||||
setTags([...current, tag])
|
||||
const toggleType = (type: MarketplaceItem["type"]) => {
|
||||
const current = types()
|
||||
if (current.includes(type)) {
|
||||
setTypes(current.filter((value) => value !== type))
|
||||
return
|
||||
}
|
||||
setTypes([...current, type])
|
||||
}
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const q = search().toLowerCase()
|
||||
const s = status().value
|
||||
const active = tags()
|
||||
return props.items.filter((item) => {
|
||||
if (s === "installed" && !isInstalled(item.id, item.type, props.metadata)) return false
|
||||
if (s === "notInstalled" && isInstalled(item.id, item.type, props.metadata)) return false
|
||||
if (active.length > 0 && !active.some((tag) => tagsFor(item).includes(tag))) return false
|
||||
if (!q) return true
|
||||
const skill = item.type === "skill" ? (item as SkillMarketplaceItem) : undefined
|
||||
return (
|
||||
item.id.toLowerCase().includes(q) ||
|
||||
item.name.toLowerCase().includes(q) ||
|
||||
item.description.toLowerCase().includes(q) ||
|
||||
(item.author?.toLowerCase().includes(q) ?? false) ||
|
||||
(skill?.displayName.toLowerCase().includes(q) ?? false)
|
||||
)
|
||||
})
|
||||
})
|
||||
const toggleCategory = (category: string) => {
|
||||
const current = categories()
|
||||
if (current.includes(category)) {
|
||||
setCategories(current.filter((value) => value !== category))
|
||||
return
|
||||
}
|
||||
setCategories([...current, category])
|
||||
}
|
||||
|
||||
const filtered = createMemo(() =>
|
||||
filterItems(props.items, props.metadata, search(), status().value, categories(), types(), {
|
||||
agent: typeLabel("agent"),
|
||||
mcp: typeLabel("mcp"),
|
||||
skill: typeLabel("skill"),
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="marketplace-list">
|
||||
@@ -102,16 +105,33 @@ export const MarketplaceListView = (props: Props) => {
|
||||
onSelect={(v: StatusOption | undefined) => v && setStatus(v)}
|
||||
/>
|
||||
</div>
|
||||
<Show when={allTags().length > 0}>
|
||||
<div class="marketplace-active-tags">
|
||||
<For each={allTags()}>
|
||||
{(tag) => (
|
||||
<Show when={allTypes().length > 1}>
|
||||
<div class="marketplace-types">
|
||||
<For each={allTypes()}>
|
||||
{(type) => (
|
||||
<button
|
||||
class="marketplace-tag-filter"
|
||||
classList={{ active: tags().includes(tag) }}
|
||||
onClick={() => toggleTag(tag)}
|
||||
class={`marketplace-type-filter marketplace-type-${type}`}
|
||||
classList={{ active: types().includes(type) }}
|
||||
aria-pressed={types().includes(type)}
|
||||
onClick={() => toggleType(type)}
|
||||
>
|
||||
<Tag>{tag}</Tag>
|
||||
<Tag>{typeLabel(type)}</Tag>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={allCategories().length > 0}>
|
||||
<div class="marketplace-categories">
|
||||
<For each={allCategories()}>
|
||||
{(category) => (
|
||||
<button
|
||||
class="marketplace-category-filter"
|
||||
classList={{ active: categories().includes(category) }}
|
||||
aria-pressed={categories().includes(category)}
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
<Tag>{label(category)}</Tag>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -147,7 +167,7 @@ export const MarketplaceListView = (props: Props) => {
|
||||
linkUrl={skill?.githubUrl ?? mcp?.url}
|
||||
onInstall={props.onInstall}
|
||||
onRemove={props.onRemove}
|
||||
footer={<For each={tagsFor(item)}>{(tag) => <Tag>{tag}</Tag>}</For>}
|
||||
footer={<Tag>{label(item.category)}</Tag>}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { createSignal, createMemo, createEffect, onCleanup, onMount, Show } from "solid-js"
|
||||
import { Tabs } from "@kilocode/kilo-ui/tabs"
|
||||
import { createSignal, createEffect, onCleanup, onMount, Show } from "solid-js"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
import { useServer } from "../../context/server"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import type {
|
||||
MarketplaceItem,
|
||||
McpMarketplaceItem,
|
||||
AgentMarketplaceItem,
|
||||
SkillMarketplaceItem,
|
||||
MarketplaceInstalledMetadata,
|
||||
} from "../../types/marketplace"
|
||||
import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../../types/marketplace"
|
||||
import { TelemetryEventName } from "../../../../src/services/telemetry/types"
|
||||
import { MarketplaceListView } from "./MarketplaceListView"
|
||||
import { InstallModal } from "./InstallModal"
|
||||
@@ -31,14 +24,9 @@ export const MarketplaceView = () => {
|
||||
const [metadata, setMetadata] = createSignal<MarketplaceInstalledMetadata>(EMPTY_METADATA)
|
||||
const [fetching, setFetching] = createSignal(true)
|
||||
const [errors, setErrors] = createSignal<string[]>([])
|
||||
const [tab, setTab] = createSignal("agent")
|
||||
const [pending, setPending] = createSignal<{ item: MarketplaceItem; scope: "project" | "global" } | null>(null)
|
||||
const [showMigrationBanner, setShowMigrationBanner] = createSignal(false)
|
||||
|
||||
const skills = createMemo(() => items().filter((i): i is SkillMarketplaceItem => i.type === "skill"))
|
||||
const mcps = createMemo(() => items().filter((i): i is McpMarketplaceItem => i.type === "mcp"))
|
||||
const agents = createMemo(() => items().filter((i): i is AgentMarketplaceItem => i.type === "agent"))
|
||||
|
||||
const fetchData = () => {
|
||||
setFetching(true)
|
||||
vscode.postMessage({ type: "fetchMarketplaceData" })
|
||||
@@ -158,62 +146,23 @@ export const MarketplaceView = () => {
|
||||
))}
|
||||
</Show>
|
||||
|
||||
<Tabs value={tab()} onChange={setTab} class="marketplace-tabs-root">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="agent">{t("marketplace.tab.agents")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp">{t("marketplace.tab.mcp")}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="skill">{t("marketplace.tab.skills")}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<div class="marketplace-content">
|
||||
<Tabs.Content value="agent">
|
||||
<Show when={showMigrationBanner()}>
|
||||
<Card variant="info" class="marketplace-error-banner">
|
||||
<span>{t("marketplace.migration.notice")}</span>
|
||||
<Button variant="ghost" size="small" onClick={dismissMigrationBanner}>
|
||||
{t("marketplace.error.dismiss")}
|
||||
</Button>
|
||||
</Card>
|
||||
</Show>
|
||||
<MarketplaceListView
|
||||
items={agents()}
|
||||
metadata={metadata()}
|
||||
fetching={fetching()}
|
||||
type="agent"
|
||||
searchPlaceholder={t("marketplace.search")}
|
||||
emptyMessage={t("marketplace.empty")}
|
||||
onInstall={handleInstall}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<MarketplaceListView
|
||||
items={mcps()}
|
||||
metadata={metadata()}
|
||||
fetching={fetching()}
|
||||
type="mcp"
|
||||
searchPlaceholder={t("marketplace.search")}
|
||||
emptyMessage={t("marketplace.empty")}
|
||||
onInstall={handleInstall}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="skill">
|
||||
<MarketplaceListView
|
||||
items={skills()}
|
||||
metadata={metadata()}
|
||||
fetching={fetching()}
|
||||
type="skill"
|
||||
searchPlaceholder={t("marketplace.search")}
|
||||
emptyMessage={t("marketplace.empty")}
|
||||
onInstall={handleInstall}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs>
|
||||
<Show when={showMigrationBanner()}>
|
||||
<Card variant="info" class="marketplace-error-banner">
|
||||
<span>{t("marketplace.migration.notice")}</span>
|
||||
<Button variant="ghost" size="small" onClick={dismissMigrationBanner}>
|
||||
{t("marketplace.error.dismiss")}
|
||||
</Button>
|
||||
</Card>
|
||||
</Show>
|
||||
<MarketplaceListView
|
||||
items={items()}
|
||||
metadata={metadata()}
|
||||
fetching={fetching()}
|
||||
searchPlaceholder={t("marketplace.search")}
|
||||
emptyMessage={t("marketplace.empty")}
|
||||
onInstall={handleInstall}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,25 +9,12 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.marketplace-tabs-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.marketplace-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* List layout */
|
||||
|
||||
.marketplace-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.marketplace-filters {
|
||||
@@ -40,30 +27,36 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.marketplace-category-filters {
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* Type and category filters */
|
||||
|
||||
/* Tag filters */
|
||||
|
||||
.marketplace-active-tags {
|
||||
.marketplace-types,
|
||||
.marketplace-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.marketplace-tag-filter {
|
||||
.marketplace-type-filter,
|
||||
.marketplace-category-filter {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.marketplace-tag-filter:hover {
|
||||
.marketplace-type-filter:hover,
|
||||
.marketplace-category-filter:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.marketplace-tag-filter.active {
|
||||
.marketplace-type-filter:focus-visible,
|
||||
.marketplace-category-filter:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.marketplace-type-filter.active,
|
||||
.marketplace-category-filter.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -210,6 +203,32 @@
|
||||
|
||||
/* Installed badge */
|
||||
|
||||
.marketplace-badge-type {
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.marketplace-type-agent [data-component="tag"],
|
||||
.marketplace-badge-type.marketplace-type-agent {
|
||||
color: var(--text-on-success-base) !important;
|
||||
background-color: color-mix(in srgb, var(--border-success-base) 14%, transparent) !important;
|
||||
border-color: var(--border-success-base) !important;
|
||||
}
|
||||
|
||||
.marketplace-type-mcp [data-component="tag"],
|
||||
.marketplace-badge-type.marketplace-type-mcp {
|
||||
color: var(--text-on-info-base) !important;
|
||||
background-color: color-mix(in srgb, var(--border-info-base) 14%, transparent) !important;
|
||||
border-color: var(--border-info-base) !important;
|
||||
}
|
||||
|
||||
.marketplace-type-skill [data-component="tag"],
|
||||
.marketplace-badge-type.marketplace-type-skill {
|
||||
color: var(--text-on-warning-base) !important;
|
||||
background-color: color-mix(in srgb, var(--border-warning-base) 14%, transparent) !important;
|
||||
border-color: var(--border-warning-base) !important;
|
||||
}
|
||||
|
||||
.marketplace-badge-installed {
|
||||
color: var(--text-on-success-base) !important;
|
||||
border-color: var(--border-success-base) !important;
|
||||
@@ -326,11 +345,13 @@ body.vscode-high-contrast-light {
|
||||
border: 1px solid var(--vscode-contrastBorder, transparent);
|
||||
}
|
||||
|
||||
.marketplace-tag-filter {
|
||||
.marketplace-type-filter,
|
||||
.marketplace-category-filter {
|
||||
border: 1px solid var(--vscode-contrastBorder, transparent);
|
||||
}
|
||||
|
||||
.marketplace-tag-filter.active {
|
||||
.marketplace-type-filter.active,
|
||||
.marketplace-category-filter.active {
|
||||
border-color: var(--vscode-contrastActiveBorder, var(--vscode-focusBorder));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { MarketplaceInstalledMetadata } from "../../types/marketplace"
|
||||
import type { MarketplaceInstalledMetadata, MarketplaceItem } from "../../types/marketplace"
|
||||
|
||||
export function retain<T>(selected: T[], available: T[]): T[] {
|
||||
const values = new Set(available)
|
||||
const next = selected.filter((value) => values.has(value))
|
||||
return next.length === selected.length ? selected : next
|
||||
}
|
||||
|
||||
export function isInstalled(
|
||||
id: string,
|
||||
@@ -14,7 +20,44 @@ export function installedScopes(
|
||||
metadata: MarketplaceInstalledMetadata,
|
||||
): ("project" | "global")[] {
|
||||
const scopes: ("project" | "global")[] = []
|
||||
if (metadata.project[id]?.type === type) scopes.push("project")
|
||||
if (metadata.global[id]?.type === type) scopes.push("global")
|
||||
const key = `${type}:${id}`
|
||||
if (metadata.project[key]?.type === type) scopes.push("project")
|
||||
if (metadata.global[key]?.type === type) scopes.push("global")
|
||||
return scopes
|
||||
}
|
||||
|
||||
function matches(item: MarketplaceItem, query: string, labels: Partial<Record<MarketplaceItem["type"], string>>) {
|
||||
const skill = item.type === "skill" ? item : undefined
|
||||
return (
|
||||
item.id.toLowerCase().includes(query) ||
|
||||
item.name.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.category.replaceAll("-", " ").toLowerCase().includes(query) ||
|
||||
item.type.includes(query) ||
|
||||
(labels[item.type]?.toLowerCase().includes(query) ?? false) ||
|
||||
(item.author?.toLowerCase().includes(query) ?? false) ||
|
||||
(skill?.displayName.toLowerCase().includes(query) ?? false)
|
||||
)
|
||||
}
|
||||
|
||||
export function filterItems(
|
||||
items: MarketplaceItem[],
|
||||
metadata: MarketplaceInstalledMetadata,
|
||||
search: string,
|
||||
status: string,
|
||||
categories: string[],
|
||||
types: MarketplaceItem["type"][],
|
||||
labels: Partial<Record<MarketplaceItem["type"], string>> = {},
|
||||
): MarketplaceItem[] {
|
||||
const query = search.trim().toLowerCase()
|
||||
return items
|
||||
.filter((item) => {
|
||||
if (status === "installed" && !isInstalled(item.id, item.type, metadata)) return false
|
||||
if (status === "notInstalled" && isInstalled(item.id, item.type, metadata)) return false
|
||||
if (types.length > 0 && !types.includes(item.type)) return false
|
||||
if (categories.length > 0 && !categories.includes(item.category)) return false
|
||||
if (!query) return true
|
||||
return matches(item, query, labels)
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [
|
||||
'{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } }',
|
||||
parameters: [{ name: "GitHub Token", key: "GITHUB_TOKEN", placeholder: "ghp_xxxxxxxxxxxx" }],
|
||||
author: "Anthropic",
|
||||
tags: ["version-control", "development"],
|
||||
category: "development",
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
@@ -143,7 +143,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [
|
||||
},
|
||||
],
|
||||
author: "Anthropic",
|
||||
tags: ["database", "sql"],
|
||||
category: "data",
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
@@ -153,7 +153,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [
|
||||
url: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||
content: '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "${ALLOWED_DIR}"] }',
|
||||
parameters: [{ name: "Allowed Directory", key: "ALLOWED_DIR", placeholder: "/path/to/directory" }],
|
||||
tags: ["filesystem", "development"],
|
||||
category: "development",
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
@@ -165,7 +165,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [
|
||||
'{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-slack"], "env": { "SLACK_TOKEN": "${SLACK_TOKEN}" } }',
|
||||
parameters: [{ name: "Slack Bot Token", key: "SLACK_TOKEN", placeholder: "xoxb-xxxxxxxxxxxx" }],
|
||||
author: "Anthropic",
|
||||
tags: ["communication", "productivity"],
|
||||
category: "productivity",
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
@@ -176,7 +176,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [
|
||||
content:
|
||||
'{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-brave-search"], "env": { "BRAVE_API_KEY": "${BRAVE_API_KEY}" } }',
|
||||
parameters: [{ name: "API Key", key: "BRAVE_API_KEY", placeholder: "BSA-xxxxxxxxxxxx" }],
|
||||
tags: ["search", "web"],
|
||||
category: "search",
|
||||
},
|
||||
{
|
||||
type: "mcp",
|
||||
@@ -186,7 +186,7 @@ const MOCK_MCPS: McpMarketplaceItem[] = [
|
||||
url: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer",
|
||||
content: '{ "command": "npx", "args": ["-y", "@modelcontextprotocol/server-puppeteer"] }',
|
||||
prerequisites: ["Chrome or Chromium must be installed"],
|
||||
tags: ["browser", "automation", "web"],
|
||||
category: "web-automation",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -205,7 +205,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [
|
||||
permission: { read: "allow", edit: "deny", bash: "deny", mcp: "deny", question: "allow" },
|
||||
},
|
||||
author: "Kilo",
|
||||
tags: ["planning", "design"],
|
||||
category: "development",
|
||||
},
|
||||
{
|
||||
type: "agent",
|
||||
@@ -221,7 +221,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [
|
||||
permission: { read: "allow", edit: "deny", bash: "allow", mcp: "deny", question: "allow" },
|
||||
},
|
||||
author: "Kilo",
|
||||
tags: ["review", "quality"],
|
||||
category: "development",
|
||||
},
|
||||
{
|
||||
type: "agent",
|
||||
@@ -235,7 +235,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [
|
||||
options: { displayName: "Documentation Writer" },
|
||||
permission: { read: "allow", edit: "allow", bash: "allow", mcp: "deny", question: "allow" },
|
||||
},
|
||||
tags: ["documentation", "writing"],
|
||||
category: "business",
|
||||
},
|
||||
{
|
||||
type: "agent",
|
||||
@@ -251,7 +251,7 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [
|
||||
permission: { read: "allow", edit: "allow", bash: "allow", mcp: "allow", question: "allow" },
|
||||
},
|
||||
author: "Community",
|
||||
tags: ["testing", "methodology"],
|
||||
category: "development",
|
||||
},
|
||||
{
|
||||
type: "agent",
|
||||
@@ -265,25 +265,33 @@ const MOCK_AGENTS: AgentMarketplaceItem[] = [
|
||||
options: { displayName: "Debugger" },
|
||||
permission: { read: "allow", edit: "allow", bash: "allow", mcp: "deny", question: "allow" },
|
||||
},
|
||||
tags: ["debugging", "troubleshooting"],
|
||||
category: "development",
|
||||
},
|
||||
]
|
||||
|
||||
const EMPTY_METADATA: MarketplaceInstalledMetadata = { project: {}, global: {} }
|
||||
|
||||
const PARTIAL_INSTALLED_SKILLS: MarketplaceInstalledMetadata = {
|
||||
project: { "nextjs-developer": { type: "skill" } },
|
||||
global: { "python-data-science": { type: "skill" } },
|
||||
project: { "skill:nextjs-developer": { type: "skill" } },
|
||||
global: { "skill:python-data-science": { type: "skill" } },
|
||||
}
|
||||
|
||||
const PARTIAL_INSTALLED_MCPS: MarketplaceInstalledMetadata = {
|
||||
project: { "github-mcp": { type: "mcp" } },
|
||||
global: { "postgres-mcp": { type: "mcp" } },
|
||||
project: { "mcp:github-mcp": { type: "mcp" } },
|
||||
global: { "mcp:postgres-mcp": { type: "mcp" } },
|
||||
}
|
||||
|
||||
const PARTIAL_INSTALLED_AGENTS: MarketplaceInstalledMetadata = {
|
||||
project: { architect: { type: "agent" } },
|
||||
global: { reviewer: { type: "agent" } },
|
||||
project: { "agent:architect": { type: "agent" } },
|
||||
global: { "agent:reviewer": { type: "agent" } },
|
||||
}
|
||||
|
||||
const PARTIAL_INSTALLED_MIXED: MarketplaceInstalledMetadata = {
|
||||
project: {
|
||||
"agent:architect": { type: "agent" },
|
||||
"mcp:github-mcp": { type: "mcp" },
|
||||
},
|
||||
global: { "skill:python-data-science": { type: "skill" } },
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
@@ -292,18 +300,17 @@ const noop = () => {}
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SkillsTabWithItems: Story = {
|
||||
name: "Skills tab — with items",
|
||||
export const MixedListWithItems: Story = {
|
||||
name: "Mixed list — categories and installed items",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "700px", overflow: "auto", padding: "12px" }}>
|
||||
<div style={{ "max-height": "700px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={MOCK_SKILLS}
|
||||
metadata={EMPTY_METADATA}
|
||||
items={[...MOCK_AGENTS, ...MOCK_MCPS, ...MOCK_SKILLS]}
|
||||
metadata={PARTIAL_INSTALLED_MIXED}
|
||||
fetching={false}
|
||||
type="skill"
|
||||
searchPlaceholder="Search skills..."
|
||||
emptyMessage="No skills found"
|
||||
searchPlaceholder="Search marketplace..."
|
||||
emptyMessage="No items found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
@@ -312,38 +319,17 @@ export const SkillsTabWithItems: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
export const SkillsTabWithInstalled: Story = {
|
||||
name: "Skills tab — some installed",
|
||||
export const EmptyList: Story = {
|
||||
name: "Mixed list — empty state",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "700px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={MOCK_SKILLS}
|
||||
metadata={PARTIAL_INSTALLED_SKILLS}
|
||||
fetching={false}
|
||||
type="skill"
|
||||
searchPlaceholder="Search skills..."
|
||||
emptyMessage="No skills found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const SkillsTabEmpty: Story = {
|
||||
name: "Skills tab — empty state",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "400px", overflow: "auto", padding: "12px" }}>
|
||||
<div style={{ "max-height": "400px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={[]}
|
||||
metadata={EMPTY_METADATA}
|
||||
fetching={false}
|
||||
type="skill"
|
||||
searchPlaceholder="Search skills..."
|
||||
emptyMessage="No skills found"
|
||||
searchPlaceholder="Search marketplace..."
|
||||
emptyMessage="No items found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
@@ -392,66 +378,6 @@ export const InstalledSkillCard: Story = {
|
||||
// MCP Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const McpTabWithItems: Story = {
|
||||
name: "MCP tab — with items",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "700px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={MOCK_MCPS}
|
||||
metadata={EMPTY_METADATA}
|
||||
fetching={false}
|
||||
type="mcp"
|
||||
searchPlaceholder="Search MCP servers..."
|
||||
emptyMessage="No MCP servers found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const McpTabWithInstalled: Story = {
|
||||
name: "MCP tab — some installed",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "700px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={MOCK_MCPS}
|
||||
metadata={PARTIAL_INSTALLED_MCPS}
|
||||
fetching={false}
|
||||
type="mcp"
|
||||
searchPlaceholder="Search MCP servers..."
|
||||
emptyMessage="No MCP servers found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const McpTabEmpty: Story = {
|
||||
name: "MCP tab — empty state",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "400px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={[]}
|
||||
metadata={EMPTY_METADATA}
|
||||
fetching={false}
|
||||
type="mcp"
|
||||
searchPlaceholder="Search MCP servers..."
|
||||
emptyMessage="No MCP servers found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const SingleMcpCard: Story = {
|
||||
name: "ItemCard — single MCP not installed",
|
||||
render: () => (
|
||||
@@ -490,66 +416,6 @@ export const InstalledMcpCard: Story = {
|
||||
// Mode Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AgentsTabWithItems: Story = {
|
||||
name: "Agents tab — with items",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "700px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={MOCK_AGENTS}
|
||||
metadata={EMPTY_METADATA}
|
||||
fetching={false}
|
||||
type="agent"
|
||||
searchPlaceholder="Search agents..."
|
||||
emptyMessage="No agents found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const AgentsTabWithInstalled: Story = {
|
||||
name: "Agents tab — some installed",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "700px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={MOCK_AGENTS}
|
||||
metadata={PARTIAL_INSTALLED_AGENTS}
|
||||
fetching={false}
|
||||
type="agent"
|
||||
searchPlaceholder="Search agents..."
|
||||
emptyMessage="No agents found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const AgentsTabEmpty: Story = {
|
||||
name: "Agents tab — empty state",
|
||||
render: () => (
|
||||
<StoryProviders>
|
||||
<div style={{ width: "420px", height: "400px", overflow: "auto", padding: "12px" }}>
|
||||
<MarketplaceListView
|
||||
items={[]}
|
||||
metadata={EMPTY_METADATA}
|
||||
fetching={false}
|
||||
type="agent"
|
||||
searchPlaceholder="Search agents..."
|
||||
emptyMessage="No agents found"
|
||||
onInstall={noop}
|
||||
onRemove={noop}
|
||||
/>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
export const SingleAgentCard: Story = {
|
||||
name: "ItemCard — single agent not installed",
|
||||
render: () => (
|
||||
|
||||
@@ -16,9 +16,9 @@ export interface MarketplaceItemBase {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
author?: string
|
||||
authorUrl?: string
|
||||
tags?: string[]
|
||||
prerequisites?: string[]
|
||||
}
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface AgentMarketplaceItem extends MarketplaceItemBase {
|
||||
|
||||
export interface SkillMarketplaceItem extends MarketplaceItemBase {
|
||||
type: "skill"
|
||||
category: string
|
||||
githubUrl: string
|
||||
content: string
|
||||
displayName: string
|
||||
@@ -66,5 +65,5 @@ export interface MarketplaceInstalledMetadata {
|
||||
export interface MarketplaceFilters {
|
||||
type?: string
|
||||
search?: string
|
||||
tags?: string[]
|
||||
categories?: string[]
|
||||
}
|
||||
|
||||
@@ -644,6 +644,27 @@ export interface AutoApproveStateMessage {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface SandboxStatusMessage {
|
||||
type: "sandboxStatus"
|
||||
sessionID: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
directory: string
|
||||
revision: number
|
||||
requestID?: string
|
||||
}
|
||||
|
||||
export interface SandboxStatusErrorMessage {
|
||||
type: "sandboxStatusError"
|
||||
sessionID: string
|
||||
directory: string
|
||||
message: string
|
||||
revision: number
|
||||
requestID?: string
|
||||
}
|
||||
|
||||
// Multi-version creation progress (extension → webview)
|
||||
export interface AgentManagerMultiVersionProgressMessage {
|
||||
type: "agentManager.multiVersionProgress"
|
||||
@@ -1045,6 +1066,8 @@ export type ExtensionMessage =
|
||||
| AgentManagerRunStatusMessage
|
||||
| AgentManagerKeybindingsMessage
|
||||
| AutoApproveStateMessage
|
||||
| SandboxStatusMessage
|
||||
| SandboxStatusErrorMessage
|
||||
| AgentManagerMultiVersionProgressMessage
|
||||
| AgentManagerSetSessionModelMessage
|
||||
| AgentManagerSendInitialMessage
|
||||
|
||||
@@ -924,6 +924,20 @@ export interface ToggleAutoApproveMessage {
|
||||
type: "toggleAutoApprove"
|
||||
}
|
||||
|
||||
export interface RequestSandboxStatusMessage {
|
||||
type: "requestSandboxStatus"
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export interface ToggleSandboxMessage {
|
||||
type: "toggleSandbox"
|
||||
sessionID?: string
|
||||
draftID?: string
|
||||
requestID: string
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
export interface ToggleRemoteMessage {
|
||||
type: "toggleRemote"
|
||||
}
|
||||
@@ -1257,6 +1271,8 @@ export type WebviewMessage =
|
||||
| AgentManagerOpenSessionsMessage
|
||||
| RequestAutoApproveStateMessage
|
||||
| ToggleAutoApproveMessage
|
||||
| RequestSandboxStatusMessage
|
||||
| ToggleSandboxMessage
|
||||
| FetchMarketplaceDataMessage
|
||||
| FilterMarketplaceItemsMessage
|
||||
| InstallMarketplaceItemMessage
|
||||
|
||||
@@ -10,6 +10,7 @@ import KiloSidebarBackgroundProcesses from "@/kilocode/plugins/sidebar-backgroun
|
||||
import KiloSidebarIndexing from "@/kilocode/plugins/sidebar-indexing"
|
||||
import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr"
|
||||
import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage"
|
||||
import KiloSandbox from "@/kilocode/plugins/sandbox"
|
||||
// kilocode_change end
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
@@ -44,6 +45,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
|
||||
KiloSidebarIndexing, // kilocode_change
|
||||
KiloSidebarPr, // kilocode_change
|
||||
KiloSidebarUsage, // kilocode_change
|
||||
KiloSandbox, // kilocode_change
|
||||
HomeFooter,
|
||||
HomeTips,
|
||||
SidebarContext,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui"
|
||||
import { createEffect, createSignal, on, type Accessor } from "solid-js"
|
||||
|
||||
const id = "internal:sandbox"
|
||||
|
||||
type Status = {
|
||||
directory: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export function indicator(status?: Status) {
|
||||
return status?.enabled ? "◆ Sandbox on" : undefined
|
||||
}
|
||||
|
||||
function session(api: TuiPluginApi) {
|
||||
if (api.route.current.name !== "session") return
|
||||
const sessionID = api.route.current.params?.sessionID
|
||||
if (typeof sessionID !== "string") return
|
||||
return sessionID
|
||||
}
|
||||
|
||||
async function ensureSession(api: TuiPluginApi) {
|
||||
const current = session(api)
|
||||
if (current) return current
|
||||
const result = await api.client.session.create({}, { throwOnError: true })
|
||||
const sessionID = result.data?.id
|
||||
if (sessionID) api.route.navigate("session", { sessionID })
|
||||
return sessionID
|
||||
}
|
||||
|
||||
function View(props: {
|
||||
api: TuiPluginApi
|
||||
sessionID: string
|
||||
status: Accessor<ReadonlyMap<string, Status>>
|
||||
load: (sessionID: string, force?: boolean) => Promise<void>
|
||||
}) {
|
||||
createEffect(
|
||||
on(
|
||||
() => props.api.state.config.experimental?.sandbox,
|
||||
() => void props.load(props.sessionID, true),
|
||||
),
|
||||
)
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.api.theme.current.success}>{indicator(props.status().get(props.sessionID)) ?? ""}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const [status, setStatus] = createSignal<ReadonlyMap<string, Status>>(new Map())
|
||||
const pending = new Set<string>()
|
||||
const loads = new Map<string, symbol>()
|
||||
const commit = (sessionID: string, value: Status) => {
|
||||
const next = new Map(status())
|
||||
next.set(sessionID, value)
|
||||
setStatus(next)
|
||||
}
|
||||
const set = (sessionID: string, value: Status) => {
|
||||
loads.delete(sessionID)
|
||||
commit(sessionID, value)
|
||||
}
|
||||
const load = async (sessionID: string, force = false) => {
|
||||
if (!force && status().has(sessionID)) return
|
||||
const token = Symbol()
|
||||
loads.set(sessionID, token)
|
||||
try {
|
||||
const result = await api.client.sandbox.status({ sessionID }, { throwOnError: true })
|
||||
if (result.data && loads.get(sessionID) === token) commit(sessionID, result.data)
|
||||
} catch (err) {
|
||||
api.ui.toast({ message: String(err), variant: "error", duration: 5000 })
|
||||
} finally {
|
||||
if (loads.get(sessionID) === token) loads.delete(sessionID)
|
||||
}
|
||||
}
|
||||
const changed = api.event.on("sandbox.status.changed", (event) => set(event.properties.sessionID, event.properties))
|
||||
api.lifecycle.onDispose(changed)
|
||||
|
||||
api.keymap.registerLayer({
|
||||
commands: [
|
||||
{
|
||||
namespace: "palette",
|
||||
name: "session.toggle.sandbox",
|
||||
title: "Toggle sandbox",
|
||||
category: "Session",
|
||||
slashName: "sandbox",
|
||||
async run() {
|
||||
const sessionID = await ensureSession(api)
|
||||
if (!sessionID || pending.has(sessionID)) return
|
||||
pending.add(sessionID)
|
||||
try {
|
||||
const result = await api.client.sandbox.toggle({ sessionID }, { throwOnError: true })
|
||||
const value = result.data
|
||||
if (!value) return
|
||||
set(sessionID, value)
|
||||
if (!value.enabled && !value.available) {
|
||||
api.ui.toast({
|
||||
message: value.reason ?? "Sandbox backend is unavailable",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
api.ui.toast({ message: `Sandbox ${value.enabled ? "enabled" : "disabled"}`, variant: "success" })
|
||||
api.ui.dialog.clear()
|
||||
} catch (err) {
|
||||
api.ui.toast({ message: String(err), variant: "error", duration: 5000 })
|
||||
} finally {
|
||||
pending.delete(sessionID)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
api.slots.register({
|
||||
order: 50,
|
||||
slots: {
|
||||
session_prompt_right(_ctx, props) {
|
||||
return <View api={api} sessionID={props.session_id} status={status} load={load} />
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: TuiPluginModule & { id: string } = { id, tui }
|
||||
|
||||
export default plugin
|
||||
@@ -48,6 +48,17 @@ export namespace KiloQuestion {
|
||||
}
|
||||
})
|
||||
|
||||
/** Publishes the terminal event when a pending question effect is interrupted. */
|
||||
export const finalize = <ID, Value>(input: {
|
||||
pending: Map<ID, Value>
|
||||
id: ID
|
||||
publishRejected: () => Effect.Effect<void>
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
if (!input.pending.delete(input.id)) return
|
||||
yield* input.publishRejected()
|
||||
})
|
||||
|
||||
/**
|
||||
* Auto-dismiss when a newer prompt is already queued on this session — a
|
||||
* tool that calls `Question.ask` after the queue event would otherwise block
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Schema } from "effect"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
|
||||
export const Changed = BusEvent.define(
|
||||
"sandbox.status.changed",
|
||||
Schema.Struct({
|
||||
sessionID: SessionID,
|
||||
directory: Schema.String,
|
||||
enabled: Schema.Boolean,
|
||||
available: Schema.Boolean,
|
||||
reason: Schema.optional(Schema.String),
|
||||
version: Schema.Int,
|
||||
}),
|
||||
)
|
||||
@@ -1,13 +1,40 @@
|
||||
import { readFileSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { run as runSandbox, type Profile } from "@kilocode/sandbox"
|
||||
import { backendSupport, run as runSandbox, unrestricted, type Profile } from "@kilocode/sandbox"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import type { InstanceContext } from "@/project/instance-context"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Changed } from "./event"
|
||||
import * as Network from "./network"
|
||||
|
||||
const overrides = new Map<string, { enabled: boolean; version: number }>()
|
||||
const locks = new Map<SessionID, { semaphore: Semaphore.Semaphore; refs: number }>()
|
||||
|
||||
function key(directory: string, sessionID: SessionID) {
|
||||
return directory + "\0" + sessionID
|
||||
}
|
||||
|
||||
function locked<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const entry = locks.get(sessionID) ?? { semaphore: Semaphore.makeUnsafe(1), refs: 0 }
|
||||
entry.refs++
|
||||
locks.set(sessionID, entry)
|
||||
return entry
|
||||
}),
|
||||
(entry) => entry.semaphore.withPermits(1)(effect),
|
||||
(entry) =>
|
||||
Effect.sync(() => {
|
||||
entry.refs--
|
||||
if (entry.refs === 0 && locks.get(sessionID) === entry) locks.delete(sessionID)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function root(path: string) {
|
||||
return { path, kind: "subtree" as const }
|
||||
}
|
||||
@@ -81,20 +108,91 @@ export function profile(ctx: InstanceContext, mode: Profile["network"]["mode"] =
|
||||
}
|
||||
}
|
||||
|
||||
export function execute<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
export const status = Effect.fn("SandboxPolicy.status")(function* (sessionID: SessionID) {
|
||||
const config = yield* Config.Service
|
||||
const cfg = yield* config.get()
|
||||
const directory = yield* InstanceState.directory
|
||||
const override = overrides.get(key(directory, sessionID))
|
||||
const enabled = override?.enabled ?? cfg.experimental?.sandbox ?? false
|
||||
return {
|
||||
directory,
|
||||
enabled: enabled && backendSupport.available,
|
||||
available: backendSupport.available,
|
||||
reason: backendSupport.reason,
|
||||
version: override?.version ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
function change<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
const directory = yield* InstanceState.directory
|
||||
const id = key(directory, sessionID)
|
||||
return yield* locked(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
yield* guard
|
||||
const current = yield* status(sessionID)
|
||||
if (!current.enabled && !current.available) return current
|
||||
const value = { ...current, enabled: !current.enabled, version: current.version + 1 }
|
||||
overrides.set(id, { enabled: value.enabled, version: value.version })
|
||||
yield* (yield* Bus.Service).publish(Changed, { sessionID, ...value })
|
||||
return value
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const toggle = Effect.fn("SandboxPolicy.toggle")((sessionID: SessionID) => change(sessionID, Effect.void))
|
||||
|
||||
export function toggleGuarded<E, R>(sessionID: SessionID, guard: Effect.Effect<unknown, E, R>) {
|
||||
return change(sessionID, guard)
|
||||
}
|
||||
|
||||
export const clear = Effect.fn("SandboxPolicy.clear")(function* (sessionID: SessionID) {
|
||||
yield* retire(sessionID, yield* InstanceState.directory, Effect.void)
|
||||
})
|
||||
|
||||
export function retire<A, E, R>(
|
||||
sessionID: SessionID,
|
||||
directory: string,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
): Effect.Effect<A, E, R> {
|
||||
return locked(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
overrides.delete(key(directory, sessionID))
|
||||
return yield* effect
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function dispose<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
return locked(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const suffix = "\0" + sessionID
|
||||
for (const id of overrides.keys()) {
|
||||
if (id.endsWith(suffix)) overrides.delete(id)
|
||||
}
|
||||
return yield* effect
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.gen(function* () {
|
||||
if (!(yield* status(sessionID)).enabled) return yield* unrestricted(effect)
|
||||
const config = yield* Config.Service
|
||||
const cfg = yield* config.get()
|
||||
if (!cfg.experimental?.sandbox) return yield* effect
|
||||
const mode = cfg.experimental.sandbox_restrict_network === false ? "allow" : "deny"
|
||||
const mode = cfg.experimental?.sandbox_restrict_network === false ? "allow" : "deny"
|
||||
return yield* runSandbox(profile(yield* InstanceState.context, mode), effect)
|
||||
})
|
||||
}
|
||||
|
||||
export function executeTool<A, E, R>(tool: { id: string }, effect: Effect.Effect<A, E, R>) {
|
||||
return execute(Network.tool(tool, effect))
|
||||
export function executeTool<A, E, R>(sessionID: SessionID, tool: { id: string }, effect: Effect.Effect<A, E, R>) {
|
||||
return execute(sessionID, Network.tool(tool, effect))
|
||||
}
|
||||
|
||||
export function executeMcp<A, E, R>(tool: object, effect: Effect.Effect<A, E, R>) {
|
||||
return execute(Network.mcp(tool, effect))
|
||||
export function executeMcp<A, E, R>(sessionID: SessionID, tool: object, effect: Effect.Effect<A, E, R>) {
|
||||
return execute(sessionID, Network.mcp(tool, effect))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
|
||||
import { ApiNotFoundError } from "@/server/routes/instance/httpapi/errors"
|
||||
|
||||
const root = "/session/:sessionID/sandbox"
|
||||
|
||||
export const SandboxStatus = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
enabled: Schema.Boolean,
|
||||
available: Schema.Boolean,
|
||||
reason: Schema.optional(Schema.String),
|
||||
version: Schema.Int,
|
||||
})
|
||||
|
||||
export const SandboxApi = HttpApi.make("sandbox")
|
||||
.add(
|
||||
HttpApiGroup.make("sandbox")
|
||||
.add(
|
||||
HttpApiEndpoint.get("status", root, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(SandboxStatus, "Session sandbox status"),
|
||||
error: ApiNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sandbox.status",
|
||||
summary: "Get session sandbox status",
|
||||
description: "Get the effective sandbox state for one session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("toggle", `${root}/toggle`, {
|
||||
params: { sessionID: SessionID },
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(SandboxStatus, "Updated session sandbox status"),
|
||||
error: ApiNotFoundError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "sandbox.toggle",
|
||||
summary: "Toggle session sandbox",
|
||||
description: "Toggle the ephemeral sandbox override for one session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "sandbox", description: "Kilo session sandbox routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "kilo HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Kilo HttpApi surface.",
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
|
||||
import { Session } from "@/session/session"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
|
||||
import * as SessionError from "@/server/routes/instance/httpapi/handlers/session-errors"
|
||||
|
||||
export const sandboxHandlers = HttpApiBuilder.group(InstanceHttpApi, "sandbox", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const exists = (sessionID: SessionID) => SessionError.mapStorageNotFound(session.get(sessionID))
|
||||
return handlers
|
||||
.handle("status", (ctx: { params: { sessionID: SessionID } }) =>
|
||||
exists(ctx.params.sessionID).pipe(Effect.andThen(SandboxPolicy.status(ctx.params.sessionID))),
|
||||
)
|
||||
.handle("toggle", (ctx: { params: { sessionID: SessionID } }) =>
|
||||
SandboxPolicy.toggleGuarded(ctx.params.sessionID, exists(ctx.params.sessionID)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -16,6 +16,7 @@ import { kiloGatewayHandlers } from "./handlers/kilo-gateway"
|
||||
import { kilocodeHandlers } from "./handlers/kilocode"
|
||||
import { networkHandlers } from "./handlers/network"
|
||||
import { remoteHandlers } from "./handlers/remote"
|
||||
import { sandboxHandlers } from "./handlers/sandbox"
|
||||
import { sessionImportHandlers } from "./handlers/session-import"
|
||||
import { suggestionHandlers } from "./handlers/suggestion"
|
||||
import { telemetryHandlers } from "./handlers/telemetry"
|
||||
@@ -31,6 +32,7 @@ export const provide = Layer.provide([
|
||||
kilocodeHandlers,
|
||||
networkHandlers,
|
||||
remoteHandlers,
|
||||
sandboxHandlers,
|
||||
sessionImportHandlers,
|
||||
suggestionHandlers,
|
||||
telemetryHandlers,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SessionID } from "@/session/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Effect } from "effect"
|
||||
import { KiloPartLifecycle } from "./part-lifecycle"
|
||||
|
||||
const task = "task"
|
||||
const stale = /^[ \t]*task_id:[^\r\n]*(?:(?:\r?\n){1,2}|$)/m
|
||||
@@ -17,6 +18,7 @@ export function writer(sessionID: SessionID, sync: SyncEvent.Interface) {
|
||||
return info
|
||||
},
|
||||
part(part: MessageV2.Part) {
|
||||
if (KiloPartLifecycle.transient(part)) return
|
||||
items.push({ type: "part", part: structuredClone(detachPart(part)), time: Date.now() })
|
||||
},
|
||||
commit() {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
|
||||
export namespace KiloPartLifecycle {
|
||||
export const key = "kilocode.lifecycle"
|
||||
|
||||
export function transient(part: MessageV2.Part) {
|
||||
return part.type === "text" && part.metadata?.[key] === "transient"
|
||||
}
|
||||
}
|
||||
@@ -40,13 +40,14 @@
|
||||
// All of this is Kilo-specific — the upstream snapshot module remains a thin
|
||||
// shim that calls into here.
|
||||
|
||||
import { Duration, Effect, Fiber } from "effect"
|
||||
import { Duration, Effect, Fiber, Option } from "effect"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Question } from "@/question"
|
||||
import type { MessageID, PartID, SessionID } from "@/session/schema"
|
||||
import { PartID as PartIDSchema } from "@/session/schema"
|
||||
import type { MessageV2 } from "@/session/message-v2"
|
||||
import { KiloPartLifecycle } from "@/kilocode/session/part-lifecycle"
|
||||
import { KilocodeConfig } from "@/kilocode/config/config"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -83,14 +84,18 @@ type SessionRuntime = {
|
||||
export namespace KiloSnapshotTrack {
|
||||
const log = Log.create({ service: "snapshot.track" })
|
||||
|
||||
export const TIMEOUT_MS = iife(() => {
|
||||
const raw = process.env["KILO_SNAPSHOT_TRACK_TIMEOUT_MS"]
|
||||
if (raw) {
|
||||
const parsed = Number(raw)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
return 10_000
|
||||
})
|
||||
const duration = (name: string, fallback: number) =>
|
||||
iife(() => {
|
||||
const raw = process.env[name]
|
||||
if (raw) {
|
||||
const parsed = Number(raw)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
return fallback
|
||||
})
|
||||
|
||||
export const TIMEOUT_MS = duration("KILO_SNAPSHOT_TRACK_TIMEOUT_MS", 10_000)
|
||||
export const TURN_TIMEOUT_MS = duration("KILO_SNAPSHOT_TURN_TIMEOUT_MS", 120_000)
|
||||
|
||||
// Wire values — also function as i18n keys via `labelKey`/`headerKey`.
|
||||
// The backend matches replies on `label`, so the canonical English strings
|
||||
@@ -140,6 +145,65 @@ export namespace KiloSnapshotTrack {
|
||||
asked: false,
|
||||
})
|
||||
|
||||
export const makeStates = () => {
|
||||
const states = new Map<string, State>()
|
||||
return (directory: string) => {
|
||||
const found = states.get(directory)
|
||||
if (found) return found
|
||||
const state = makeState()
|
||||
states.set(directory, state)
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProtectInput<A> {
|
||||
readonly inner: Effect.Effect<A>
|
||||
readonly state: State
|
||||
readonly fallback: A
|
||||
readonly operation: "track" | "patch"
|
||||
readonly timeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the turn-facing snapshot availability budget without waiting for
|
||||
* cancellation. Snapshot tracking and patching are optional metadata work;
|
||||
* once either exceeds this budget, later calls in the same directory bypass
|
||||
* the potentially poisoned lock owner for the lifetime of this service.
|
||||
*/
|
||||
export const protect = <A>(input: ProtectInput<A>): Effect.Effect<A> =>
|
||||
Effect.gen(function* () {
|
||||
if (input.state.disabledForSession) return input.fallback
|
||||
const timeoutMs = input.timeoutMs ?? TURN_TIMEOUT_MS
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.forkDetach(input.inner, { startImmediately: true }),
|
||||
(fiber) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* Fiber.join(fiber).pipe(
|
||||
Effect.timeoutOption(Duration.millis(timeoutMs)),
|
||||
Effect.catchCause((cause) => {
|
||||
input.state.disabledForSession = true
|
||||
log.error("snapshot turn operation failed; bypassing snapshots for this directory", {
|
||||
cause,
|
||||
operation: input.operation,
|
||||
})
|
||||
return Effect.succeed(Option.some(input.fallback))
|
||||
}),
|
||||
)
|
||||
if (Option.isSome(result)) return result.value
|
||||
input.state.disabledForSession = true
|
||||
log.warn("snapshot turn operation exceeded availability budget; bypassing snapshots for this directory", {
|
||||
operation: input.operation,
|
||||
timeoutMs,
|
||||
})
|
||||
return input.fallback
|
||||
}),
|
||||
(fiber) =>
|
||||
Effect.sync(() => {
|
||||
setTimeout(() => Effect.runFork(Fiber.interrupt(fiber)), 0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
/** Answer shape returned by `askUser`. Three-valued because dismiss !== disable. */
|
||||
export type Answer = "continue" | "disable" | "dismissed"
|
||||
|
||||
@@ -152,7 +216,7 @@ export namespace KiloSnapshotTrack {
|
||||
*/
|
||||
export interface Hooks {
|
||||
/** Ask the user. Returns "dismissed" if the question is rejected. */
|
||||
readonly ask: (input: { sessionID: SessionID }) => Promise<Answer>
|
||||
readonly ask: (input: { sessionID: SessionID }, signal?: AbortSignal) => Promise<Answer>
|
||||
/** Persist `"snapshot": false` to the project config without disposing the instance. */
|
||||
readonly persistDisable: () => Promise<void>
|
||||
/** Publish the synthetic progress part allocated by the wrapper. */
|
||||
@@ -383,7 +447,7 @@ export namespace KiloSnapshotTrack {
|
||||
input.state.owner = owner
|
||||
|
||||
const sessionID = input.sessionID
|
||||
const answer = yield* Effect.promise(() => hooks.ask({ sessionID }))
|
||||
const answer = yield* Effect.promise((signal) => hooks.ask({ sessionID }, signal))
|
||||
|
||||
if (answer === "continue") {
|
||||
log.info("user chose to keep waiting for snapshot; joining fiber")
|
||||
@@ -443,7 +507,7 @@ export namespace KiloSnapshotTrack {
|
||||
}
|
||||
|
||||
/** Build the synthetic progress part payload so both start/update share one shape. */
|
||||
const progressPart = (input: {
|
||||
export const progressPart = (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
partID: PartID
|
||||
@@ -455,6 +519,7 @@ export namespace KiloSnapshotTrack {
|
||||
type: "text",
|
||||
text: input.text,
|
||||
synthetic: true,
|
||||
metadata: { [KiloPartLifecycle.key]: "transient" },
|
||||
})
|
||||
|
||||
export const defaultHooks: Hooks = {
|
||||
@@ -503,46 +568,55 @@ export namespace KiloSnapshotTrack {
|
||||
)
|
||||
},
|
||||
|
||||
async ask(input) {
|
||||
const answers = await questionRt
|
||||
.runPromise((svc) =>
|
||||
svc.ask({
|
||||
sessionID: input.sessionID,
|
||||
blocking: true,
|
||||
questions: [
|
||||
{
|
||||
header: "Snapshot is slow",
|
||||
headerKey: "snapshot.slowRepo.header",
|
||||
question:
|
||||
"It is taking a long time to initialize the snapshot system, likely due to the size of the repository.\n\n" +
|
||||
"Do you want to disable Snapshots for this repository?",
|
||||
questionKey: "snapshot.slowRepo.question",
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
label: ANSWER_CONTINUE,
|
||||
labelKey: "snapshot.slowRepo.answer.continue",
|
||||
description:
|
||||
"Keep waiting for the snapshot to complete. Subsequent turns are fast once the initial snapshot is built.",
|
||||
descriptionKey: "snapshot.slowRepo.answer.continue.description",
|
||||
},
|
||||
{
|
||||
label: ANSWER_DISABLE,
|
||||
labelKey: "snapshot.slowRepo.answer.disable",
|
||||
description:
|
||||
"Turn off Kilo's snapshots for this project. You will lose undo/redo of Kilo file changes, but git still tracks everything.",
|
||||
descriptionKey: "snapshot.slowRepo.answer.disable.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
async ask(input, signal) {
|
||||
return questionRt
|
||||
.runPromise(
|
||||
(svc) =>
|
||||
svc.ask({
|
||||
sessionID: input.sessionID,
|
||||
blocking: true,
|
||||
questions: [
|
||||
{
|
||||
header: "Snapshot is slow",
|
||||
headerKey: "snapshot.slowRepo.header",
|
||||
question:
|
||||
"It is taking a long time to initialize the snapshot system, likely due to the size of the repository.\n\n" +
|
||||
"Do you want to disable Snapshots for this repository?",
|
||||
questionKey: "snapshot.slowRepo.question",
|
||||
custom: false,
|
||||
options: [
|
||||
{
|
||||
label: ANSWER_CONTINUE,
|
||||
labelKey: "snapshot.slowRepo.answer.continue",
|
||||
description:
|
||||
"Keep waiting for the snapshot to complete. Subsequent turns are fast once the initial snapshot is built.",
|
||||
descriptionKey: "snapshot.slowRepo.answer.continue.description",
|
||||
},
|
||||
{
|
||||
label: ANSWER_DISABLE,
|
||||
labelKey: "snapshot.slowRepo.answer.disable",
|
||||
description:
|
||||
"Turn off Kilo's snapshots for this project. You will lose undo/redo of Kilo file changes, but git still tracks everything.",
|
||||
descriptionKey: "snapshot.slowRepo.answer.disable.description",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ signal },
|
||||
)
|
||||
.catch(() => undefined)
|
||||
const pick = answers?.[0]?.[0]
|
||||
if (pick === ANSWER_CONTINUE) return "continue"
|
||||
if (pick === ANSWER_DISABLE) return "disable"
|
||||
return "dismissed"
|
||||
.then((answers): Answer => {
|
||||
const pick = answers[0]?.[0]
|
||||
if (pick === ANSWER_CONTINUE) return "continue"
|
||||
if (pick === ANSWER_DISABLE) return "disable"
|
||||
return "dismissed"
|
||||
})
|
||||
.catch((err): Answer => {
|
||||
if (!signal?.aborted && !(err instanceof Question.RejectedError)) {
|
||||
log.warn("snapshot question failed; treating as dismissed", { err })
|
||||
}
|
||||
return "dismissed"
|
||||
})
|
||||
},
|
||||
|
||||
async persistDisable() {
|
||||
|
||||
@@ -210,9 +210,13 @@ export const layer = Layer.effect(
|
||||
|
||||
return yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
// kilocode_change start - every asked question gets a terminal event when its waiter is interrupted
|
||||
KiloQuestion.finalize({
|
||||
pending,
|
||||
id,
|
||||
publishRejected: () => bus.publish(Event.Rejected, { sessionID: info.sessionID, requestID: info.id }),
|
||||
}),
|
||||
// kilocode_change end
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway"
|
||||
import { KilocodeApi } from "@/kilocode/server/httpapi/groups/kilocode"
|
||||
import { NetworkApi } from "@/kilocode/server/httpapi/groups/network"
|
||||
import { RemoteApi } from "@/kilocode/server/httpapi/groups/remote"
|
||||
import { SandboxApi } from "@/kilocode/server/httpapi/groups/sandbox"
|
||||
import { SessionImportApi } from "@/kilocode/server/httpapi/groups/session-import"
|
||||
import { SuggestionApi } from "@/kilocode/server/httpapi/groups/suggestion"
|
||||
import { TelemetryApi } from "@/kilocode/server/httpapi/groups/telemetry"
|
||||
@@ -75,6 +76,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
|
||||
.addHttpApi(KilocodeApi)
|
||||
.addHttpApi(NetworkApi)
|
||||
.addHttpApi(RemoteApi)
|
||||
.addHttpApi(SandboxApi)
|
||||
.addHttpApi(SessionImportApi)
|
||||
.addHttpApi(SuggestionApi)
|
||||
.addHttpApi(TelemetryApi)
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { BackgroundProcess } from "@/kilocode/background-process"
|
||||
import { KiloSession, kiloSessionFork } from "@/kilocode/session"
|
||||
import { SessionExport } from "@/kilocode/session-export"
|
||||
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
|
||||
import { baseKey, cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" // kilocode_change
|
||||
// kilocode_change end
|
||||
import { Effect, Layer, Option, Context, Schema, Types } from "effect"
|
||||
@@ -658,20 +659,27 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
yield* Effect.promise(() => KiloSession.removeSession(sessionID)).pipe(Effect.ignore)
|
||||
KiloSession.clearPlatformOverride(sessionID)
|
||||
if (hasInstance) {
|
||||
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore)
|
||||
void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) =>
|
||||
app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(() => {}),
|
||||
)
|
||||
}
|
||||
yield* SandboxPolicy.dispose(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => KiloSession.removeSession(sessionID)).pipe(Effect.ignore)
|
||||
KiloSession.clearPlatformOverride(sessionID)
|
||||
if (hasInstance) {
|
||||
yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)).pipe(Effect.ignore)
|
||||
void Promise.all([import("@/effect/app-runtime"), import("./run-state")]).then(([app, run]) =>
|
||||
app.AppRuntime.runPromise(run.SessionRunState.Service.use((svc) => svc.cancel(sessionID))).catch(
|
||||
() => {},
|
||||
),
|
||||
)
|
||||
}
|
||||
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
|
||||
// kilocode_change - capture final session-export workspace delta on close/delete
|
||||
const workspaceKey = hasInstance ? yield* InstanceState.directory : undefined // kilocode_change
|
||||
yield* Effect.promise(() => SessionExport.onSessionClose(sessionID, workspaceKey)) // kilocode_change
|
||||
yield* sync.remove(sessionID)
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
yield* sync.run(Event.Deleted, { sessionID, info: session }, { publish: hasInstance })
|
||||
// kilocode_change - capture final session-export workspace delta on close/delete
|
||||
const workspaceKey = hasInstance ? yield* InstanceState.directory : undefined // kilocode_change
|
||||
yield* Effect.promise(() => SessionExport.onSessionClose(sessionID, workspaceKey)) // kilocode_change
|
||||
yield* sync.remove(sessionID)
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
{ args },
|
||||
)
|
||||
// kilocode_change start
|
||||
const result = yield* SandboxPolicy.executeTool(item, item.execute(args, ctx))
|
||||
const result = yield* SandboxPolicy.executeTool(ctx.sessionID, item, item.execute(args, ctx))
|
||||
// kilocode_change end
|
||||
const output = {
|
||||
...result,
|
||||
@@ -134,6 +134,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
)
|
||||
// kilocode_change start
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* SandboxPolicy.executeMcp(
|
||||
ctx.sessionID,
|
||||
item,
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
|
||||
@@ -873,7 +873,7 @@ export const layer: Layer.Layer<Service, never, Requirements> =
|
||||
)
|
||||
|
||||
// kilocode_change start - service-local state and cache avoid leaking across Snapshot layer instances
|
||||
const trackState = KiloSnapshotTrack.makeState()
|
||||
const trackState = KiloSnapshotTrack.makeStates()
|
||||
const cache = new Map<string, Promise<FileDiff[]>>()
|
||||
const max = 100
|
||||
// kilocode_change end
|
||||
@@ -885,20 +885,34 @@ export const layer: Layer.Layer<Service, never, Requirements> =
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
// kilocode_change start - guard slow snapshots and surface progress to the active session
|
||||
// kilocode_change start - isolate turn-facing snapshot work from poisoned locks
|
||||
track: Effect.fn("Snapshot.track")(function* (opts) {
|
||||
return yield* KiloSnapshotTrack.wrap({
|
||||
inner: InstanceState.useEffect(state, (s) => s.track(opts)),
|
||||
state: trackState,
|
||||
snapshotInitialization: opts?.snapshotInitialization,
|
||||
sessionID: opts?.sessionID,
|
||||
messageID: opts?.messageID,
|
||||
const ctx = yield* InstanceState.context
|
||||
const guard = trackState(ctx.worktree)
|
||||
return yield* KiloSnapshotTrack.protect({
|
||||
inner: KiloSnapshotTrack.wrap({
|
||||
inner: InstanceState.useEffect(state, (s) => s.track(opts)),
|
||||
state: guard,
|
||||
snapshotInitialization: opts?.snapshotInitialization,
|
||||
sessionID: opts?.sessionID,
|
||||
messageID: opts?.messageID,
|
||||
}),
|
||||
state: guard,
|
||||
fallback: undefined,
|
||||
operation: "track",
|
||||
})
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const guard = trackState(ctx.worktree)
|
||||
return yield* KiloSnapshotTrack.protect({
|
||||
inner: InstanceState.useEffect(state, (s) => s.patch(hash)),
|
||||
state: guard,
|
||||
fallback: { hash, files: [] },
|
||||
operation: "patch",
|
||||
})
|
||||
}),
|
||||
// kilocode_change end
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Queue } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Question } from "../../src/question"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { disposeAllInstances } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(Bus.layer)), CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
it.instance(
|
||||
"publishes rejection when a pending question is interrupted",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
const bus = yield* Bus.Service
|
||||
const asked = yield* Queue.unbounded<{ properties: Question.Request }>()
|
||||
const rejected = yield* Queue.unbounded<{
|
||||
properties: { sessionID: SessionID; requestID: QuestionID }
|
||||
}>()
|
||||
const offAsked = yield* bus.subscribeCallback(Question.Event.Asked, (event) => Queue.offerUnsafe(asked, event))
|
||||
const offRejected = yield* bus.subscribeCallback(Question.Event.Rejected, (event) =>
|
||||
Queue.offerUnsafe(rejected, event),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => [offAsked(), offRejected()]))
|
||||
|
||||
const fiber = yield* question
|
||||
.ask({
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
questions: [
|
||||
{
|
||||
header: "Snapshot",
|
||||
question: "Keep waiting?",
|
||||
options: [{ label: "Continue", description: "Keep waiting" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
const request = yield* Queue.take(asked).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
yield* Fiber.interrupt(fiber)
|
||||
|
||||
const event = yield* Queue.take(rejected).pipe(Effect.timeout("2 seconds"))
|
||||
expect(event.properties).toEqual({
|
||||
sessionID: request.properties.sessionID,
|
||||
requestID: request.properties.id,
|
||||
})
|
||||
expect(yield* question.list()).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { expect } from "bun:test"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { backendSupport } from "@kilocode/sandbox"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
|
||||
import * as ToolNetwork from "@/kilocode/sandbox/network"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { TestConfig } from "../../fixture/config"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const sessionID = SessionID.make("ses_sandbox_config_network")
|
||||
const tool = ToolNetwork.builtin({ id: "webfetch" })
|
||||
const ctx = {
|
||||
directory: process.cwd(),
|
||||
worktree: process.cwd(),
|
||||
@@ -51,14 +55,19 @@ function server() {
|
||||
const restricted = testEffect(layer())
|
||||
const open = testEffect(layer(false))
|
||||
|
||||
restricted.live("keeps network restriction enabled by default", () => {
|
||||
restricted.live("keeps network restriction enabled by default when the sandbox is available", () => {
|
||||
const target = server()
|
||||
return Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const exit = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe(
|
||||
const exit = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
Effect.exit,
|
||||
)
|
||||
if (!backendSupport.available) {
|
||||
expect(Exit.isSuccess(exit)).toBe(true)
|
||||
expect(target.requests()).toBe(1)
|
||||
return
|
||||
}
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Sandbox denied outbound network access")
|
||||
expect(target.requests()).toBe(0)
|
||||
@@ -69,7 +78,7 @@ open.live("allows tool network traffic when network restriction is disabled", ()
|
||||
const target = server()
|
||||
return Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* SandboxPolicy.execute(http.get(target.server.url)).pipe(
|
||||
const response = yield* SandboxPolicy.executeTool(sessionID, tool, http.get(target.server.url)).pipe(
|
||||
Effect.provideService(InstanceRef, ctx),
|
||||
)
|
||||
expect(yield* response.text).toBe("sandbox-config-ok")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Config as ConfigV1 } from "@kilocode/sdk"
|
||||
import type { Config as ConfigV2 } from "@kilocode/sdk/v2"
|
||||
|
||||
const value = {
|
||||
experimental: {
|
||||
sandbox: true,
|
||||
sandbox_restrict_network: false,
|
||||
},
|
||||
}
|
||||
|
||||
test("both public SDK Config types expose sandbox policy fields", () => {
|
||||
const legacy = value satisfies ConfigV1
|
||||
const current = value satisfies ConfigV2
|
||||
expect(legacy.experimental).toEqual(current.experimental)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
|
||||
import { Session } from "@/session/session"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { provideInstance, tmpdirScoped } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Session.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
),
|
||||
Bus.layer,
|
||||
Config.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
describe("sandbox session cleanup", () => {
|
||||
it.live("clears every directory override when removing outside instance context", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const worktree = yield* tmpdirScoped({ git: true })
|
||||
const info = yield* provideInstance(dir)(session.create({ title: "sandbox-cleanup" }))
|
||||
const support = yield* provideInstance(dir)(SandboxPolicy.status(info.id))
|
||||
if (!support.available) {
|
||||
yield* session.remove(info.id)
|
||||
return
|
||||
}
|
||||
|
||||
yield* provideInstance(dir)(SandboxPolicy.toggle(info.id))
|
||||
yield* provideInstance(worktree)(SandboxPolicy.toggle(info.id))
|
||||
expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(true)
|
||||
expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(true)
|
||||
yield* session.remove(info.id)
|
||||
expect((yield* provideInstance(dir)(SandboxPolicy.status(info.id))).enabled).toBe(false)
|
||||
expect((yield* provideInstance(worktree)(SandboxPolicy.status(info.id))).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { enabled as sandboxed } from "@kilocode/sandbox"
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import * as Network from "@/kilocode/sandbox/network"
|
||||
import * as SandboxPolicy from "@/kilocode/sandbox/policy"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { TestInstance } from "../../fixture/fixture"
|
||||
import { testEffect } from "../../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Bus.layer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const tool = Network.builtin({ id: "read" })
|
||||
|
||||
function execute<A, E, R>(sessionID: SessionID, effect: Effect.Effect<A, E, R>) {
|
||||
return SandboxPolicy.executeTool(sessionID, tool, effect)
|
||||
}
|
||||
|
||||
it.instance(
|
||||
"uses config as the default without persisting session toggles",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const id = SessionID.make("ses_sandbox_config")
|
||||
const initial = yield* SandboxPolicy.status(id)
|
||||
expect(initial.enabled).toBe(initial.available)
|
||||
expect(initial.version).toBe(0)
|
||||
if (!initial.available) return
|
||||
|
||||
const disabled = yield* SandboxPolicy.toggle(id)
|
||||
expect(disabled.enabled).toBe(false)
|
||||
expect(disabled.version).toBe(1)
|
||||
expect((yield* (yield* Config.Service).get()).experimental?.sandbox).toBe(true)
|
||||
|
||||
yield* SandboxPolicy.clear(id)
|
||||
expect((yield* SandboxPolicy.status(id)).enabled).toBe(true)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
)
|
||||
|
||||
it.instance("runs unrestricted when config is off and no override exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = SessionID.make("ses_sandbox_default_off")
|
||||
expect((yield* SandboxPolicy.status(id)).enabled).toBe(false)
|
||||
expect(yield* execute(id, sandboxed)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"runs sandboxed when config is on and no override exists",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const id = SessionID.make("ses_sandbox_default_on")
|
||||
const status = yield* SandboxPolicy.status(id)
|
||||
expect(status.enabled).toBe(status.available)
|
||||
expect(yield* execute(id, sandboxed)).toBe(status.available)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"overrides config off for only one session",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const first = SessionID.make("ses_sandbox_override_off")
|
||||
const second = SessionID.make("ses_sandbox_config_stays_on")
|
||||
if (!(yield* SandboxPolicy.status(first)).available) return
|
||||
|
||||
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false)
|
||||
expect(yield* execute(first, sandboxed)).toBe(false)
|
||||
expect(yield* execute(second, sandboxed)).toBe(true)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
)
|
||||
|
||||
it.instance("overrides config off to sandbox only one session", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = SessionID.make("ses_sandbox_override_on")
|
||||
const second = SessionID.make("ses_sandbox_default_remains_off")
|
||||
if (!(yield* SandboxPolicy.status(first)).available) return
|
||||
|
||||
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true)
|
||||
expect(yield* execute(first, sandboxed)).toBe(true)
|
||||
expect(yield* execute(second, sandboxed)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("isolates concurrent session overrides and clears them", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = SessionID.make("ses_sandbox_first")
|
||||
const second = SessionID.make("ses_sandbox_second")
|
||||
const support = yield* SandboxPolicy.status(first)
|
||||
if (!support.available) {
|
||||
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(false)
|
||||
return
|
||||
}
|
||||
|
||||
expect((yield* SandboxPolicy.toggle(first)).enabled).toBe(true)
|
||||
expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
|
||||
expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(true)
|
||||
expect((yield* SandboxPolicy.toggle(second)).enabled).toBe(false)
|
||||
expect((yield* SandboxPolicy.status(first)).enabled).toBe(true)
|
||||
yield* SandboxPolicy.clear(first)
|
||||
expect((yield* SandboxPolicy.status(first)).enabled).toBe(false)
|
||||
expect((yield* SandboxPolicy.status(second)).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("does not activate an unavailable backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = SessionID.make("ses_sandbox_support")
|
||||
const result = yield* SandboxPolicy.toggle(id)
|
||||
if (result.available) return
|
||||
expect(result.enabled).toBe(false)
|
||||
expect(result.reason?.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("serializes concurrent toggles for a session", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = SessionID.make("ses_sandbox_concurrent")
|
||||
if (!(yield* SandboxPolicy.status(id)).available) return
|
||||
yield* Effect.all([SandboxPolicy.toggle(id), SandboxPolicy.toggle(id)], { concurrency: "unbounded" })
|
||||
expect((yield* SandboxPolicy.status(id)).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("prevents a queued toggle from restoring a retired override", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const id = SessionID.make("ses_sandbox_retire_race")
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const removal = yield* SandboxPolicy.retire(
|
||||
id,
|
||||
test.directory,
|
||||
Effect.gen(function* () {
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
).pipe(Effect.forkChild)
|
||||
yield* Deferred.await(entered)
|
||||
const pending = yield* SandboxPolicy.toggleGuarded(id, Effect.fail("deleted")).pipe(Effect.exit, Effect.forkChild)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(removal)
|
||||
expect(Exit.isFailure(yield* Fiber.join(pending))).toBe(true)
|
||||
expect((yield* SandboxPolicy.status(id)).enabled).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("uses nested session state instead of inheriting a parent profile", () =>
|
||||
Effect.gen(function* () {
|
||||
const parent = SessionID.make("ses_sandbox_parent")
|
||||
const child = SessionID.make("ses_sandbox_child")
|
||||
if (!(yield* SandboxPolicy.status(parent)).available) return
|
||||
yield* SandboxPolicy.toggle(parent)
|
||||
expect(yield* execute(parent, execute(child, sandboxed))).toBe(false)
|
||||
expect(yield* execute(child, execute(parent, sandboxed))).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("enforces writes only while the macOS session override is active", () =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform !== "darwin") return
|
||||
const test = yield* TestInstance
|
||||
const id = SessionID.make("ses_sandbox_process")
|
||||
if (!(yield* SandboxPolicy.status(id)).available) return
|
||||
const outside = path.join(path.dirname(test.directory), `outside-${path.basename(test.directory)}`)
|
||||
const inside = path.join(test.directory, "allowed.txt")
|
||||
const git = path.join(test.directory, ".git", "denied.txt")
|
||||
const external = path.join(outside, "denied.txt")
|
||||
yield* Effect.promise(() => fs.mkdir(path.dirname(git), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(outside, { recursive: true }))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(outside, { recursive: true, force: true })))
|
||||
const run = (file: string) =>
|
||||
ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.spawn(ChildProcess.make("/usr/bin/touch", [file])).pipe(Effect.flatMap((child) => child.exitCode)),
|
||||
)
|
||||
|
||||
expect((yield* SandboxPolicy.toggle(id)).enabled).toBe(true)
|
||||
expect(Number(yield* execute(id, run(inside)))).toBe(0)
|
||||
expect(Number(yield* execute(id, run(external)))).not.toBe(0)
|
||||
expect(Number(yield* execute(id, run(git)))).not.toBe(0)
|
||||
expect((yield* SandboxPolicy.toggle(id)).enabled).toBe(false)
|
||||
expect(Number(yield* execute(id, run(external)))).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { indicator } from "@/kilocode/plugins/sandbox"
|
||||
|
||||
const source = path.resolve(import.meta.dir, "../../../src/kilocode/plugins/sandbox.tsx")
|
||||
|
||||
describe("sandbox TUI", () => {
|
||||
test("shows an indicator only for an active available sandbox", () => {
|
||||
expect(indicator({ directory: "/repo", enabled: true, available: true, version: 1 })).toBe("◆ Sandbox on")
|
||||
expect(indicator({ directory: "/repo", enabled: false, available: true, version: 2 })).toBeUndefined()
|
||||
expect(
|
||||
indicator({ directory: "/repo", enabled: false, available: false, reason: "unavailable", version: 0 }),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps the prompt status contribution mounted while inactive", () => {
|
||||
const content = fs.readFileSync(source, "utf8")
|
||||
expect(content).toContain("<box flexShrink={0}>")
|
||||
expect(content).toContain('indicator(props.status().get(props.sessionID)) ?? ""')
|
||||
})
|
||||
|
||||
test("keeps the session override available independently of the persistent default", () => {
|
||||
const content = fs.readFileSync(source, "utf8")
|
||||
expect(content).not.toContain("enabled: () =>")
|
||||
expect(content).toContain("await ensureSession(api)")
|
||||
expect(content).toContain("api.client.session.create")
|
||||
expect(content).toContain('api.route.navigate("session", { sessionID })')
|
||||
expect(content).toContain("props.api.state.config.experimental?.sandbox")
|
||||
expect(content).toContain("void props.load(props.sessionID, true)")
|
||||
expect(content).toContain('api.event.on("sandbox.status.changed"')
|
||||
})
|
||||
})
|
||||
@@ -189,6 +189,33 @@ export const kiloScenarios: Scenario[] = [
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, (body) => check(body === true, "missing network reject should remain a no-op success")),
|
||||
http.protected
|
||||
.get("/session/{sessionID}/sandbox", "sandbox.status")
|
||||
.seeded((ctx) => ctx.session({ title: "Sandbox status" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/session/{sessionID}/sandbox", { sessionID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
object(body)
|
||||
check(typeof body.enabled === "boolean", "sandbox status should report enabled state")
|
||||
check(typeof body.available === "boolean", "sandbox status should report backend availability")
|
||||
check(typeof body.version === "number", "sandbox status should report its revision")
|
||||
}),
|
||||
http.protected
|
||||
.post("/session/{sessionID}/sandbox/toggle", "sandbox.toggle")
|
||||
.mutating()
|
||||
.seeded((ctx) => ctx.session({ title: "Sandbox toggle" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/session/{sessionID}/sandbox/toggle", { sessionID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, (body) => {
|
||||
object(body)
|
||||
check(typeof body.enabled === "boolean", "sandbox toggle should report enabled state")
|
||||
check(typeof body.available === "boolean", "sandbox toggle should report backend availability")
|
||||
check(typeof body.version === "number", "sandbox toggle should report its revision")
|
||||
}),
|
||||
http.protected.get("/remote/status", "remote.status").json(200, (body) => {
|
||||
object(body)
|
||||
check(body.enabled === false && body.connected === false, "remote should start disabled")
|
||||
|
||||
@@ -11,6 +11,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { Database, eq } from "../../src/storage/db"
|
||||
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { KiloPartLifecycle } from "../../src/kilocode/session/part-lifecycle"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -381,4 +382,48 @@ describe("Session.fork task detachment", () => {
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
|
||||
test(
|
||||
"drops transient UI parts while preserving durable synthetic context",
|
||||
async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const parent = await sessions.create({ title: "parent" })
|
||||
const user = await userMsg(parent.id)
|
||||
const parts = [
|
||||
{ text: "Initializing snapshot... but durable", synthetic: true },
|
||||
{ text: "<system-reminder>durable context</system-reminder>", synthetic: true },
|
||||
{
|
||||
text: "arbitrary live status",
|
||||
synthetic: true,
|
||||
metadata: { [KiloPartLifecycle.key]: "transient" },
|
||||
},
|
||||
]
|
||||
for (const part of parts) {
|
||||
await sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: user,
|
||||
sessionID: parent.id,
|
||||
type: "text",
|
||||
...part,
|
||||
} as MessageV2.TextPart)
|
||||
}
|
||||
|
||||
const forked = await Session.fork({ sessionID: parent.id })
|
||||
const source = await sessions.messages({ sessionID: parent.id })
|
||||
const copy = await sessions.messages({ sessionID: forked.id })
|
||||
const texts = copy.flatMap((msg) => msg.parts).flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
|
||||
expect(source.flatMap((msg) => msg.parts)).toHaveLength(3)
|
||||
expect(texts).toEqual([
|
||||
"Initializing snapshot... but durable",
|
||||
"<system-reminder>durable context</system-reminder>",
|
||||
])
|
||||
},
|
||||
})
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// touch the real Question module or write to the filesystem.
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Effect } from "effect"
|
||||
import type { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Deferred, Duration, Effect, Fiber } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { PartID, type MessageID, type SessionID } from "../../src/session/schema"
|
||||
import { KiloSnapshotTrack } from "../../src/kilocode/snapshot/track"
|
||||
import { awaitWithTimeout } from "../lib/effect"
|
||||
import { KiloPartLifecycle } from "../../src/kilocode/session/part-lifecycle"
|
||||
import { awaitWithTimeout, it } from "../lib/effect"
|
||||
|
||||
const SESSION = "ses_test" as SessionID
|
||||
const MESSAGE = "msg_test" as MessageID
|
||||
@@ -64,6 +66,81 @@ const makeHooks = (
|
||||
return { hooks, calls }
|
||||
}
|
||||
|
||||
describe("KiloSnapshotTrack.protect", () => {
|
||||
it.effect("returns at the availability deadline without waiting for cancellation", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = KiloSnapshotTrack.makeState()
|
||||
const started = yield* Deferred.make<void>()
|
||||
const fallback = { hash: "base", files: [] as string[] }
|
||||
let finalized = false
|
||||
const inner = Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
finalized = true
|
||||
}),
|
||||
),
|
||||
)
|
||||
const fiber = yield* KiloSnapshotTrack.protect({
|
||||
inner,
|
||||
state,
|
||||
fallback,
|
||||
operation: "patch",
|
||||
timeoutMs: 100,
|
||||
}).pipe(Effect.forkChild)
|
||||
|
||||
yield* Deferred.await(started)
|
||||
yield* TestClock.adjust(100)
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual(fallback)
|
||||
expect(finalized).toBe(false)
|
||||
expect(state.disabledForSession).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bypasses later operations after a deadline opens the directory circuit", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = KiloSnapshotTrack.makeState()
|
||||
const started = yield* Deferred.make<void>()
|
||||
let calls = 0
|
||||
const first = yield* KiloSnapshotTrack.protect({
|
||||
inner: Effect.sync(() => {
|
||||
calls += 1
|
||||
}).pipe(Effect.andThen(Deferred.succeed(started, undefined)), Effect.andThen(Effect.never)),
|
||||
state,
|
||||
fallback: undefined,
|
||||
operation: "track",
|
||||
timeoutMs: 100,
|
||||
}).pipe(Effect.forkChild)
|
||||
|
||||
yield* Deferred.await(started)
|
||||
yield* TestClock.adjust(100)
|
||||
expect(yield* Fiber.join(first)).toBeUndefined()
|
||||
|
||||
const second = yield* KiloSnapshotTrack.protect({
|
||||
inner: Effect.sync(() => {
|
||||
calls += 1
|
||||
return "unexpected"
|
||||
}),
|
||||
state,
|
||||
fallback: undefined,
|
||||
operation: "track",
|
||||
})
|
||||
expect(second).toBeUndefined()
|
||||
expect(calls).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
test("keeps circuit state isolated by directory", () => {
|
||||
const states = KiloSnapshotTrack.makeStates()
|
||||
const first = states("/repo/a")
|
||||
first.disabledForSession = true
|
||||
|
||||
expect(states("/repo/a")).toBe(first)
|
||||
expect(states("/repo/b").disabledForSession).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloSnapshotTrack.wrap", () => {
|
||||
test("returns the hash when inner resolves before the timeout", async () => {
|
||||
const state = KiloSnapshotTrack.makeState()
|
||||
@@ -483,6 +560,18 @@ describe("KiloSnapshotTrack.wrap", () => {
|
||||
})
|
||||
|
||||
describe("KiloSnapshotTrack progress indicator", () => {
|
||||
test("classifies persisted progress as transient", () => {
|
||||
const part = KiloSnapshotTrack.progressPart({
|
||||
sessionID: SESSION,
|
||||
messageID: MESSAGE,
|
||||
partID: PartID.make("prt_test"),
|
||||
text: "arbitrary status",
|
||||
})
|
||||
|
||||
expect(part.synthetic).toBe(true)
|
||||
expect(KiloPartLifecycle.transient(part)).toBe(true)
|
||||
})
|
||||
|
||||
// Strip the braille spinner frame (first Unicode codepoint, plus the
|
||||
// trailing space) so tests can assert on the stable descriptive text
|
||||
// without caring which animation frame landed.
|
||||
|
||||
@@ -376,7 +376,7 @@ describe("plugin.openai.ws-pool", () => {
|
||||
})
|
||||
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
|
||||
url: server.url,
|
||||
idleTimeout: 20,
|
||||
idleTimeout: 100, // kilocode_change - leave enough time for WebSocket callbacks on loaded CI runners
|
||||
streamRetries: 1,
|
||||
})
|
||||
|
||||
|
||||
@@ -1373,6 +1373,14 @@ export type Config = {
|
||||
* Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)
|
||||
*/
|
||||
openTelemetry?: boolean
|
||||
/**
|
||||
* Run agent tools inside a sandbox that restricts writes to project and Kilo state directories and can restrict outbound network access
|
||||
*/
|
||||
sandbox?: boolean
|
||||
/**
|
||||
* Restrict outbound network access for model-originated commands and first-party HTTP tools; local MCP servers and plugin hooks are not covered (default: true)
|
||||
*/
|
||||
sandbox_restrict_network?: boolean
|
||||
/**
|
||||
* Tools that should only be available to primary agents.
|
||||
*/
|
||||
|
||||
@@ -257,6 +257,10 @@ import type {
|
||||
RemoteEnableResponses,
|
||||
RemoteStatusErrors,
|
||||
RemoteStatusResponses,
|
||||
SandboxStatusErrors,
|
||||
SandboxStatusResponses,
|
||||
SandboxToggleErrors,
|
||||
SandboxToggleResponses,
|
||||
SessionAbortErrors,
|
||||
SessionAbortResponses,
|
||||
SessionChildrenErrors,
|
||||
@@ -7620,6 +7624,72 @@ export class Remote extends HeyApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class Sandbox extends HeyApiClient {
|
||||
/**
|
||||
* Get session sandbox status
|
||||
*
|
||||
* Get the effective sandbox state for one session.
|
||||
*/
|
||||
public status<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<SandboxStatusResponses, SandboxStatusErrors, ThrowOnError>({
|
||||
url: "/session/{sessionID}/sandbox",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle session sandbox
|
||||
*
|
||||
* Toggle the ephemeral sandbox override for one session.
|
||||
*/
|
||||
public toggle<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<SandboxToggleResponses, SandboxToggleErrors, ThrowOnError>({
|
||||
url: "/session/{sessionID}/sandbox/toggle",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Suggestion extends HeyApiClient {
|
||||
/**
|
||||
* List pending suggestions
|
||||
@@ -7993,6 +8063,11 @@ export class KiloClient extends HeyApiClient {
|
||||
return (this._remote ??= new Remote({ client: this.client }))
|
||||
}
|
||||
|
||||
private _sandbox?: Sandbox
|
||||
get sandbox(): Sandbox {
|
||||
return (this._sandbox ??= new Sandbox({ client: this.client }))
|
||||
}
|
||||
|
||||
private _suggestion?: Suggestion
|
||||
get suggestion(): Suggestion {
|
||||
return (this._suggestion ??= new Suggestion({ client: this.client }))
|
||||
|
||||
@@ -12,6 +12,7 @@ export type Event =
|
||||
| EventTuiCommandExecute
|
||||
| EventTuiToastShow1
|
||||
| EventTuiSessionSelect
|
||||
| EventSandboxStatusChanged
|
||||
| EventKilocodeAgentManagerStart
|
||||
| EventIndexingStatus
|
||||
| EventIndexingWarning
|
||||
@@ -930,6 +931,7 @@ export type GlobalEvent = {
|
||||
| EventTuiCommandExecute
|
||||
| EventTuiToastShow
|
||||
| EventTuiSessionSelect
|
||||
| EventSandboxStatusChanged
|
||||
| EventKilocodeAgentManagerStart
|
||||
| EventIndexingStatus
|
||||
| EventIndexingWarning
|
||||
@@ -2989,6 +2991,19 @@ export type EventGlobalConfigUpdated = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSandboxStatusChanged = {
|
||||
id: string
|
||||
type: "sandbox.status.changed"
|
||||
properties: {
|
||||
sessionID: string
|
||||
directory: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
}
|
||||
}
|
||||
|
||||
export type EventKilocodeAgentManagerStart = {
|
||||
id: string
|
||||
type: "kilocode.agent_manager.start"
|
||||
@@ -10720,6 +10735,86 @@ export type RemoteStatusResponses = {
|
||||
|
||||
export type RemoteStatusResponse = RemoteStatusResponses[keyof RemoteStatusResponses]
|
||||
|
||||
export type SandboxStatusData = {
|
||||
body?: never
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/session/{sessionID}/sandbox"
|
||||
}
|
||||
|
||||
export type SandboxStatusErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* NotFoundError
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type SandboxStatusError = SandboxStatusErrors[keyof SandboxStatusErrors]
|
||||
|
||||
export type SandboxStatusResponses = {
|
||||
/**
|
||||
* Session sandbox status
|
||||
*/
|
||||
200: {
|
||||
directory: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SandboxStatusResponse = SandboxStatusResponses[keyof SandboxStatusResponses]
|
||||
|
||||
export type SandboxToggleData = {
|
||||
body?: never
|
||||
path: {
|
||||
sessionID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/session/{sessionID}/sandbox/toggle"
|
||||
}
|
||||
|
||||
export type SandboxToggleErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
/**
|
||||
* NotFoundError
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type SandboxToggleError = SandboxToggleErrors[keyof SandboxToggleErrors]
|
||||
|
||||
export type SandboxToggleResponses = {
|
||||
/**
|
||||
* Updated session sandbox status
|
||||
*/
|
||||
200: {
|
||||
directory: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
}
|
||||
}
|
||||
|
||||
export type SandboxToggleResponse = SandboxToggleResponses[keyof SandboxToggleResponses]
|
||||
|
||||
export type KilocodeSessionImportProjectData = {
|
||||
body?: {
|
||||
id: string
|
||||
|
||||
@@ -15179,6 +15179,192 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/session/{sessionID}/sandbox": {
|
||||
"get": {
|
||||
"tags": ["sandbox"],
|
||||
"operationId": "sandbox.status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Session sandbox status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["directory", "enabled", "available", "version"],
|
||||
"additionalProperties": false,
|
||||
"description": "Session sandbox status"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "NotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Get the effective sandbox state for one session.",
|
||||
"summary": "Get session sandbox status",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sandbox.status({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/session/{sessionID}/sandbox/toggle": {
|
||||
"post": {
|
||||
"tags": ["sandbox"],
|
||||
"operationId": "sandbox.toggle",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Updated session sandbox status",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["directory", "enabled", "available", "version"],
|
||||
"additionalProperties": false,
|
||||
"description": "Updated session sandbox status"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "NotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Toggle the ephemeral sandbox override for one session.",
|
||||
"summary": "Toggle session sandbox",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sandbox.toggle({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilocode/session-import/project": {
|
||||
"post": {
|
||||
"tags": ["session-import"],
|
||||
@@ -16488,6 +16674,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.tui.session.select"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSandboxStatusChanged"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
|
||||
},
|
||||
@@ -19271,6 +19460,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.tui.session.select"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventSandboxStatusChanged"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/EventKilocodeAgent_managerStart"
|
||||
},
|
||||
@@ -25601,6 +25793,46 @@
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventSandboxStatusChanged": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["sandbox.status.changed"]
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"available": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "directory", "enabled", "available", "version"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "type", "properties"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EventKilocodeAgent_managerStart": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -30144,6 +30376,10 @@
|
||||
"name": "remote",
|
||||
"description": "Kilo remote connection routes."
|
||||
},
|
||||
{
|
||||
"name": "sandbox",
|
||||
"description": "Kilo session sandbox routes."
|
||||
},
|
||||
{
|
||||
"name": "session-import",
|
||||
"description": "Kilo legacy session import routes."
|
||||
|
||||
@@ -112,14 +112,14 @@ const structure = [
|
||||
...(!registry.includes("ToolNetwork.builtin(result)")
|
||||
? [" tool/registry.ts must distinguish built-in tools from untrusted custom tools"]
|
||||
: []),
|
||||
...(!session.includes("SandboxPolicy.executeTool(item,")
|
||||
? [" session/tools.ts must route built-in and custom tools through executeTool"]
|
||||
...(!/SandboxPolicy\.executeTool\(\s*ctx\.sessionID,\s*item,/.test(session)
|
||||
? [" session/tools.ts must route built-in and custom tools through session-aware executeTool"]
|
||||
: []),
|
||||
...(!mcp.includes("SandboxNetwork.remote(tool)")
|
||||
? [" mcp/index.ts must classify remote MCP delegated authority"]
|
||||
: []),
|
||||
...(!session.includes("SandboxPolicy.executeMcp(")
|
||||
? [" session/tools.ts must route MCP delegated authority through executeMcp"]
|
||||
...(!/SandboxPolicy\.executeMcp\(\s*ctx\.sessionID,\s*item,/.test(session)
|
||||
? [" session/tools.ts must route MCP delegated authority through session-aware executeMcp"]
|
||||
: []),
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user