mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.17.9
This commit is contained in:
@@ -1,5 +1,54 @@
|
||||
# @kilocode/cli
|
||||
|
||||
## 7.4.16
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#12392](https://github.com/Kilo-Org/kilocode/pull/12392) [`16988a5`](https://github.com/Kilo-Org/kilocode/commit/16988a558100615f20c68af8a53b6ad56fd70f58) - Add a `notify_user` tool that lets an agent send a push notification to the user's phone (Kilo mobile app) for explicitly requested pings and significant mid-run milestones. The tool sends a single `agent_notification` item over the session's existing authenticated ingest channel with a bounded readiness wait, returns a friendly failure when the session is not connected to Kilo cloud, and never prompts for permission. Delivery may still be suppressed server-side by the user's notification preference, per-session rate limits, or active presence in the session.
|
||||
|
||||
- [#12370](https://github.com/Kilo-Org/kilocode/pull/12370) [`b367105`](https://github.com/Kilo-Org/kilocode/commit/b367105c8d648c8e05b62c2d27a28a95a4772f61) Thanks [@hdcodedev](https://github.com/hdcodedev)! - Support deleting queued chat messages from the VS Code chat before they run.
|
||||
|
||||
- [#12327](https://github.com/Kilo-Org/kilocode/pull/12327) [`aa22680`](https://github.com/Kilo-Org/kilocode/commit/aa22680feef2d8b9e1a60ddae4280cedb2cf78f0) - `kilo remote` instances now advertise themselves on the relay heartbeat. Each heartbeat carries the host's hostname, the project directory name, and the CLI build version, and each session entry advertises the platform it was created on. The cloud relay learns about a freshly-connected instance immediately (no 10s wait for the first timer tick), and the advertisement is race-safe across the explicit `kilo remote` command and bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). Legacy CLIs that send neither field remain wire-compatible.
|
||||
|
||||
- [#12394](https://github.com/Kilo-Org/kilocode/pull/12394) [`e72238a`](https://github.com/Kilo-Org/kilocode/commit/e72238a6655bb495e24c588fa047b5b162da8f1e) - Support file attachments in remote CLI sessions.
|
||||
|
||||
- [#11849](https://github.com/Kilo-Org/kilocode/pull/11849) [`fe01f53`](https://github.com/Kilo-Org/kilocode/commit/fe01f53e2bddbadc51736ff81dcc2e022fe6f27f) - Run asynchronous Cloud Agent tasks with repository, model, mode, and organization defaults through `kilo cloud`. Add `--stream` to `kilo cloud start` to print admission output and then stream WebSocket events as JSONL until completion or inactivity ends the stream.
|
||||
|
||||
- [#12456](https://github.com/Kilo-Org/kilocode/pull/12456) [`3d648d7`](https://github.com/Kilo-Org/kilocode/commit/3d648d7fcdc186f86b2c63ab842e70acb1f0aee2) - Reference past chats inline with `@` in the prompt. Typing `@` now surfaces a "Past chats" option that opens a searchable picker of previous sessions (scoped to the current workspace/worktree, searched like the Agent Manager session search); selecting one attaches that session's transcript as context so the model can build on a prior conversation. Clicking the mention opens that session. Available in the CLI TUI and the VS Code extension.
|
||||
|
||||
- [#12434](https://github.com/Kilo-Org/kilocode/pull/12434) [`dcc0d64`](https://github.com/Kilo-Org/kilocode/commit/dcc0d64a3249bdd3aa27d564759253126ff9a5fe) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Show tokens-per-second text-generation throughput (TG) on each assistant message and in the usage sidebar, computed from step duration and tokens. The toggle "Show Token Throughput" in Display settings controls both surfaces. PP (prompt-processing) support lands in a follow-up once the upstream llama.cpp metadata wiring ships.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#12475](https://github.com/Kilo-Org/kilocode/pull/12475) [`c72817e`](https://github.com/Kilo-Org/kilocode/commit/c72817e67fe1349894ab21995195b00d46b39777) Thanks [@LCZcn96](https://github.com/LCZcn96)! - Load global skills reliably from projects outside Git repositories.
|
||||
|
||||
- [#12497](https://github.com/Kilo-Org/kilocode/pull/12497) [`23963e3`](https://github.com/Kilo-Org/kilocode/commit/23963e32c840fa98af4efd8443fc95082a1b8277) - Restore stream idle timeouts to opt-in provider configuration instead of aborting quiet model streams by default.
|
||||
|
||||
- [#12393](https://github.com/Kilo-Org/kilocode/pull/12393) [`9262f2b`](https://github.com/Kilo-Org/kilocode/commit/9262f2b49acbc1f2587fceb27abbfc8ebf9a45f1) - Remote CLI sessions no longer appear frozen on mobile when the connection to the session relay stalls; they now recover on their own instead of staying read-only until the CLI is restarted. Token acquisition and connection attempts are bounded by deadlines with a single fenced retry owner, and heartbeat session gathers are bounded so one stuck gather can no longer silently kill every future heartbeat.
|
||||
|
||||
- [#12485](https://github.com/Kilo-Org/kilocode/pull/12485) [`079fd04`](https://github.com/Kilo-Org/kilocode/commit/079fd04b413cf4e14cef40475abd1b7d08949383) Thanks [@rakshith1928](https://github.com/rakshith1928)! - Fix compaction failure against strict OpenAI-compatible providers during context compaction. The compaction path no longer leaks `maxOutputTokens` into provider options, which was rejected by strict upstreams with "Unsupported parameter(s)".
|
||||
|
||||
- [#11940](https://github.com/Kilo-Org/kilocode/pull/11940) [`0d830cb`](https://github.com/Kilo-Org/kilocode/commit/0d830cbd32ae78232d5acae97fb825a1d64ae661) Thanks [@rakshith1928](https://github.com/rakshith1928)! - Fix: inject `$schema` into config files using jsonc-parser, avoiding write-on-read for comment-first JSONC and preventing unnecessary file rewrites on every load
|
||||
|
||||
- [#12508](https://github.com/Kilo-Org/kilocode/pull/12508) [`0fe46ec`](https://github.com/Kilo-Org/kilocode/commit/0fe46ecb8da9ac133a31e757efba8ee5de7a3191) - Fix a fatal startup crash ("attempt to write a readonly database") when the local database or its WAL sidecar files lost write permission. Kilo now repairs the permissions automatically when it safely can, and otherwise reports the exact file to fix instead of an opaque error.
|
||||
|
||||
- [#12458](https://github.com/Kilo-Org/kilocode/pull/12458) [`182d18b`](https://github.com/Kilo-Org/kilocode/commit/182d18bb28824ce045de7eb635e44ec508617588) - Keep Plan and Architect mode source edits denied when agent-specific permissions request edit approval.
|
||||
|
||||
- [#12496](https://github.com/Kilo-Org/kilocode/pull/12496) [`2fcb137`](https://github.com/Kilo-Org/kilocode/commit/2fcb137ebcbf9101ca655804d0a61af2f222bbc5) - Preserve unexpected provider finish reasons and show the request and Gateway generation IDs when a response ends unexpectedly.
|
||||
|
||||
- [#12488](https://github.com/Kilo-Org/kilocode/pull/12488) [`c25f041`](https://github.com/Kilo-Org/kilocode/commit/c25f041eb3922defc4dadb9ad7b2f8c8edb74fbd) - Show the request ID when a model response ends without a finish reason.
|
||||
|
||||
- Updated dependencies [[`2fcb137`](https://github.com/Kilo-Org/kilocode/commit/2fcb137ebcbf9101ca655804d0a61af2f222bbc5), [`f715e2f`](https://github.com/Kilo-Org/kilocode/commit/f715e2f5fa4db5abe5c734e1c360e8da3367f3e5), [`dcc0d64`](https://github.com/Kilo-Org/kilocode/commit/dcc0d64a3249bdd3aa27d564759253126ff9a5fe)]:
|
||||
- @kilocode/sdk@7.5.0
|
||||
- @kilocode/kilo-gateway@7.4.16
|
||||
- @kilocode/plugin@7.4.16
|
||||
- @opencode-ai/tui@7.4.16
|
||||
- @opencode-ai/ui@7.4.16
|
||||
- @kilocode/kilo-indexing@7.4.16
|
||||
- @kilocode/kilo-telemetry@7.4.16
|
||||
- @kilocode/plugin-atomic-chat@7.4.16
|
||||
- @opencode-ai/server@7.4.16
|
||||
|
||||
## 7.4.15
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.4.15",
|
||||
"version": "7.4.16",
|
||||
"name": "@kilocode/cli",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,33 +2,13 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
|
||||
import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement"
|
||||
import { context } from "@/project/instance-context"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { Instance } from "@/kilocode/instance"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
function truncate(value: string, max: number) {
|
||||
return value.length > max ? value.slice(0, max) : value
|
||||
}
|
||||
|
||||
// kilocode_change start - K1 W1: extracted so the advertisement payload shape
|
||||
// is unit-testable as real behavior, rather than only through a source-text/
|
||||
// regex assertion on this file (the handler itself can't be driven end-to-end
|
||||
// — see the doc comment on `handler` below).
|
||||
export function buildInstanceAdvertisement(directory: string): {
|
||||
name: string
|
||||
projectName: string
|
||||
version: string
|
||||
} {
|
||||
return {
|
||||
name: truncate(os.hostname(), 64),
|
||||
projectName: truncate(path.basename(directory) || directory, 64),
|
||||
version: truncate(InstallationVersion, 32),
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
// Re-export so existing unit tests that import from this module keep working.
|
||||
export { buildInstanceAdvertisement }
|
||||
|
||||
export const RemoteCommand = cmd({
|
||||
command: "remote",
|
||||
@@ -41,6 +21,8 @@ export const RemoteCommand = cmd({
|
||||
// The process-wide `KILO_REMOTE_ATTACH_SESSION` guard was removed in K1
|
||||
// (in-process sessions only; no spawned children), so this is always
|
||||
// advertised for the explicit `kilo remote` command path.
|
||||
// enableRemote() also ensures a default advertisement; this explicit call
|
||||
// remains a legitimate replace (or no-op when identical) per the contract.
|
||||
KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory))
|
||||
|
||||
await KiloSessions.enableRemote()
|
||||
|
||||
@@ -28,7 +28,6 @@ import { Agent } from "@/agent/agent"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { FormatError, FormatUnknownError } from "../error"
|
||||
import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin"
|
||||
import { event as normalizeEvent } from "./run/event"
|
||||
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
|
||||
import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change
|
||||
import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change
|
||||
@@ -728,9 +727,9 @@ export const RunCommand = effectCmd({
|
||||
let retries = 0 // kilocode_change
|
||||
let error: string | undefined
|
||||
|
||||
for await (const payload of events.stream) {
|
||||
const event = normalizeEvent(payload)
|
||||
if (!event) continue
|
||||
// kilocode_change start - revert to upstream: consume native events without normalizing sync copies
|
||||
for await (const event of events.stream) {
|
||||
// kilocode_change end
|
||||
|
||||
if (
|
||||
event.type === "message.updated" &&
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
// Demo mode also handles permission and question replies locally, completing
|
||||
// or failing the synthetic tool parts as appropriate.
|
||||
import path from "path"
|
||||
import type { ToolPart } from "@kilocode/sdk/v2"
|
||||
import type { Event } from "./event"
|
||||
import type { Event, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunPrompt, StreamCommit } from "./types"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// kilocode_change - new file
|
||||
import type {
|
||||
Event as SDKEvent,
|
||||
GlobalEvent,
|
||||
SyncEventMessagePartRemoved,
|
||||
SyncEventMessagePartUpdated,
|
||||
SyncEventMessageRemoved,
|
||||
SyncEventMessageUpdated,
|
||||
} from "@kilocode/sdk/v2"
|
||||
|
||||
type MessageUpdated = {
|
||||
id: string
|
||||
type: "message.updated"
|
||||
properties: SyncEventMessageUpdated["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
type MessageRemoved = {
|
||||
id: string
|
||||
type: "message.removed"
|
||||
properties: SyncEventMessageRemoved["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
type MessagePartUpdated = {
|
||||
id: string
|
||||
type: "message.part.updated"
|
||||
properties: SyncEventMessagePartUpdated["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
type MessagePartRemoved = {
|
||||
id: string
|
||||
type: "message.part.removed"
|
||||
properties: SyncEventMessagePartRemoved["syncEvent"]["data"]
|
||||
}
|
||||
|
||||
export type Event = SDKEvent | MessageUpdated | MessageRemoved | MessagePartUpdated | MessagePartRemoved
|
||||
|
||||
export function event(payload: GlobalEvent["payload"]): Event | undefined {
|
||||
if (payload.type !== "sync") return payload
|
||||
|
||||
const sync = payload.syncEvent
|
||||
switch (sync.type) {
|
||||
case "message.updated.1":
|
||||
return { id: sync.id, type: "message.updated", properties: sync.data }
|
||||
case "message.removed.1":
|
||||
return { id: sync.id, type: "message.removed", properties: sync.data }
|
||||
case "message.part.updated.1":
|
||||
return { id: sync.id, type: "message.part.updated", properties: sync.data }
|
||||
case "message.part.removed.1":
|
||||
return { id: sync.id, type: "message.part.removed", properties: sync.data }
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,8 @@
|
||||
// `data.questions`. The footer shows whichever is first. When a reply
|
||||
// event arrives, the queue entry is removed and the footer falls back
|
||||
// to the next pending request or to the prompt view.
|
||||
import type { Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2"
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import type { RunInteractiveTerminalSnapshot } from "@/kilocode/cli/cmd/run/types" // kilocode_change
|
||||
import type { Event } from "./event"
|
||||
import * as Locale from "@/util/locale"
|
||||
import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" // kilocode_change
|
||||
import { toolView } from "./tool"
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
// The tick counter prevents stale idle events from resolving the wrong turn.
|
||||
// We also re-check live session status before resolving an idle event so a
|
||||
// delayed idle from an older turn cannot complete a newer busy turn.
|
||||
import type { GlobalEvent, KiloClient } from "@kilocode/sdk/v2"
|
||||
import { event as normalizeEvent, type Event } from "./event"
|
||||
import type { Event, GlobalEvent, KiloClient } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import {
|
||||
@@ -191,8 +190,10 @@ function globalPayloadEvent(value: unknown): Event | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payload = normalizeEvent(value.payload)
|
||||
return payload && isEvent(payload) ? payload : undefined
|
||||
// kilocode_change start - revert to upstream: ignore sync compatibility copies
|
||||
if (value.payload.type === "sync") return undefined
|
||||
return isEvent(value.payload) ? value.payload : undefined
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2"
|
||||
import type { Event } from "./event"
|
||||
import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@kilocode/sdk/v2" // kilocode_change - revert to upstream native Event type
|
||||
import * as Locale from "@/util/locale"
|
||||
import {
|
||||
bootstrapSessionData,
|
||||
|
||||
@@ -153,6 +153,11 @@ export type Info = ConfigV1.Info & {
|
||||
// kilocode_change start - derived provenance for markdown paths selected by config
|
||||
instruction_origins?: Record<string, KilocodeMarkdown.Source>
|
||||
skill_path_origins?: Record<string, KilocodeMarkdown.Source>
|
||||
// derived provenance for permission patterns: which config scope (global XDG vs local project)
|
||||
// last set each permission + pattern. Keyed per pattern (not just per key) because global and
|
||||
// project config can contribute different patterns under the same key. Lets the runtime explain
|
||||
// why a tool call was auto-approved.
|
||||
permission_origins?: Record<string, Record<string, "global" | "local">>
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
@@ -241,6 +246,7 @@ function writable(info: Info) {
|
||||
plugin_origins: _plugin_origins,
|
||||
instruction_origins: _instruction_origins,
|
||||
skill_path_origins: _skill_path_origins,
|
||||
permission_origins: _permission_origins,
|
||||
...next
|
||||
} = info
|
||||
// kilocode_change end
|
||||
@@ -542,6 +548,23 @@ export const layer = Layer.effect(
|
||||
if (next.skills?.paths?.length) {
|
||||
result.skill_path_origins = origins(result.skill_path_origins, next.skills.paths, trusted, source)
|
||||
}
|
||||
// record which scope last set each permission + pattern. A scalar value (e.g. bash: "allow")
|
||||
// maps to pattern "*"; an object records each of its patterns. Global and project config can
|
||||
// contribute different patterns under one key, so track per pattern; later merges win.
|
||||
if (scoped.permission && typeof scoped.permission === "object") {
|
||||
const map = { ...result.permission_origins }
|
||||
for (const [key, value] of Object.entries(scoped.permission)) {
|
||||
if (value === null) continue
|
||||
const patterns = typeof value === "string" ? { "*": value } : value
|
||||
const inner = { ...map[key] }
|
||||
for (const [pattern, action] of Object.entries(patterns)) {
|
||||
if (action === null) continue
|
||||
inner[pattern] = scope
|
||||
}
|
||||
map[key] = inner
|
||||
}
|
||||
result.permission_origins = map
|
||||
}
|
||||
return yield* mergePluginOrigins(source, scoped.plugin, scope)
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// kilocode_change - new file
|
||||
// Shared derivation for the spawn-capable instance advertisement payload.
|
||||
// Used by both `kilo remote` (explicit CLI) and `enableRemote()` (covers `/remote`
|
||||
// and KILO_REMOTE / remote_control auto-enable) so all enable paths advertise
|
||||
// identically.
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import type { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
|
||||
|
||||
function truncate(value: string, max: number) {
|
||||
return value.length > max ? value.slice(0, max) : value
|
||||
}
|
||||
|
||||
export function buildInstanceAdvertisement(directory: string): RemoteProtocol.InstanceAdvertisement {
|
||||
return {
|
||||
name: truncate(os.hostname(), 64),
|
||||
projectName: truncate(path.basename(directory) || directory, 64),
|
||||
version: truncate(InstallationVersion, 32),
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import simpleGit from "simple-git"
|
||||
import { RemoteWS } from "@/kilo-sessions/remote-ws"
|
||||
import { RemoteSender } from "@/kilo-sessions/remote-sender"
|
||||
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
|
||||
import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement"
|
||||
import { AttachedState } from "@/kilo-sessions/attached-state"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry"
|
||||
@@ -246,7 +247,25 @@ export namespace KiloSessions {
|
||||
const statusSyncs = new Map<string, { running: boolean; dirty: boolean }>()
|
||||
const STATUS_TIMEOUT_MS = 3_000
|
||||
|
||||
async function deriveStatus(sessionID: string): Promise<"idle" | "busy" | "question" | "permission" | "retry"> {
|
||||
// Shared attention/status resolution for ingest sync and the remote heartbeat.
|
||||
// Precedence: permission > question > SessionStatus (offline maps to retry).
|
||||
type DerivedSessionStatus = "idle" | "busy" | "question" | "permission" | "retry"
|
||||
|
||||
function resolveDerivedSessionStatus(input: {
|
||||
hasPermission: boolean
|
||||
hasQuestion: boolean
|
||||
statusType: SessionStatus.Info["type"] | undefined
|
||||
}): DerivedSessionStatus {
|
||||
if (input.hasPermission) return "permission"
|
||||
if (input.hasQuestion) return "question"
|
||||
if (input.statusType === "offline") return "retry"
|
||||
if (input.statusType === "busy" || input.statusType === "retry" || input.statusType === "idle") {
|
||||
return input.statusType
|
||||
}
|
||||
return "idle"
|
||||
}
|
||||
|
||||
async function deriveStatus(sessionID: string): Promise<DerivedSessionStatus> {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const permissions = (await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))).filter(
|
||||
(p) => p.sessionID === sessionID,
|
||||
@@ -259,8 +278,11 @@ export namespace KiloSessions {
|
||||
if (questions.length > 0) return "question"
|
||||
|
||||
const status = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(SessionID.make(sessionID))))
|
||||
if (status.type === "offline") return "retry"
|
||||
return status.type
|
||||
return resolveDerivedSessionStatus({
|
||||
hasPermission: false,
|
||||
hasQuestion: false,
|
||||
statusType: status.type,
|
||||
})
|
||||
}
|
||||
|
||||
async function deriveAndSyncStatus(sessionID: string) {
|
||||
@@ -456,9 +478,22 @@ export namespace KiloSessions {
|
||||
|
||||
export const node = LayerNode.suspend(() => LayerNode.make(layer, [Bus.node, Config.node, Session.node]))
|
||||
|
||||
// kilocode_change - DEF-1: default advertisement for every successful
|
||||
// enableRemote() entry (covers `/remote` after auto-enable already connected).
|
||||
// No-op when an advertisement is already set — must not re-set or fire an
|
||||
// extra heartbeat. Explicit setInstanceAdvertisement keeps replace semantics.
|
||||
function ensureDefaultInstanceAdvertisement() {
|
||||
if (instanceAdvertisement) return
|
||||
setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory))
|
||||
}
|
||||
|
||||
export async function enableRemote() {
|
||||
if (remote) return
|
||||
// ingestDisabled must not advertise. Every other successful entry — including
|
||||
// already-connected and coalescing early returns — must ensure advertisement
|
||||
// before returning, otherwise `/remote` after auto-enable never registers.
|
||||
if (ingestDisabled) return
|
||||
ensureDefaultInstanceAdvertisement()
|
||||
if (remote) return
|
||||
if (enabling) return enabling
|
||||
const seq = ++remoteSeq
|
||||
void Bus.publish(Instance.current, Event.RemoteStatusChanged, { enabled: true, connected: false })
|
||||
@@ -496,8 +531,16 @@ export namespace KiloSessions {
|
||||
branch().catch(() => undefined),
|
||||
])
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
|
||||
// Batch SessionStatus + attention lists once per heartbeat (not per session).
|
||||
// Permission/Question list() feeds the same precedence as deriveStatus().
|
||||
const [statusMap, permissions, questions] = await Promise.all([
|
||||
AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list())),
|
||||
AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())),
|
||||
AppRuntime.runPromise(Question.Service.use((svc) => svc.list())),
|
||||
])
|
||||
const statuses: Record<string, SessionStatus.Info> = Object.fromEntries(statusMap)
|
||||
const permissionSessions = new Set(permissions.map((p) => p.sessionID as string))
|
||||
const questionSessions = new Set(questions.map((q) => q.sessionID as string))
|
||||
// Advertise both presence-owned and pending-created ids so the relay learns about new
|
||||
// sessions before the next periodic heartbeat and the create_session response can be sent.
|
||||
const ids = new Set(Object.keys(statuses))
|
||||
@@ -509,7 +552,11 @@ export namespace KiloSessions {
|
||||
svc.get(SessionID.make(id)).pipe(
|
||||
Effect.map((session) => ({
|
||||
id,
|
||||
status: statuses[id]?.type ?? ("idle" as const),
|
||||
status: resolveDerivedSessionStatus({
|
||||
hasPermission: permissionSessions.has(id),
|
||||
hasQuestion: questionSessions.has(id),
|
||||
statusType: statuses[id]?.type,
|
||||
}),
|
||||
title: session.title,
|
||||
parentSessionId: session.parentID,
|
||||
gitUrl,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Permission } from "@/permission"
|
||||
|
||||
/**
|
||||
* Explains *why* a tool call was allowed so clients can surface auto-approval to users.
|
||||
*
|
||||
* A permission rule is a plain object that flows through `Permission.evaluate`'s `findLast`
|
||||
* unchanged, so we hang an optional, non-schema `source` marker on each rule when we assemble
|
||||
* the ruleset. `evaluate`/`resolve` return the matched rule object as-is, letting us read that
|
||||
* marker back out to report the winning source.
|
||||
*/
|
||||
export namespace PermissionProvenance {
|
||||
/** Where the deciding rule came from. */
|
||||
export type Source = "agent" | "global" | "project" | "yolo" | "session" | "manual" | "default"
|
||||
|
||||
/** A rule optionally carrying its origin. `source` is runtime-only, never persisted. */
|
||||
export type SourcedRule = Permission.Rule & { source?: Source }
|
||||
|
||||
/** True for the broad allow rule that auto-approve (YOLO) mode installs. */
|
||||
function isYolo(rule: Permission.Rule) {
|
||||
return rule.permission === "*" && rule.pattern === "*" && rule.action === "allow"
|
||||
}
|
||||
|
||||
/** The approval recorded onto a tool call's metadata. */
|
||||
export type Approval = {
|
||||
source: Source
|
||||
/** Agent name when `source` is "agent". */
|
||||
agent?: string
|
||||
/** The winning rule, omitted for manual replies and the ask fallback. */
|
||||
rule?: { permission: string; pattern: string; action: Permission.Action }
|
||||
}
|
||||
|
||||
export type Scope = "global" | "local"
|
||||
|
||||
/**
|
||||
* Scope that last set each config permission pattern (global XDG vs local project).
|
||||
*
|
||||
* Keyed by permission then pattern, because global and project config can each contribute
|
||||
* different patterns under the same key (e.g. global `bash: {"git status": allow}` and project
|
||||
* `bash: {"npm test": allow}` both live under `bash`). A per-key scope would misreport the
|
||||
* pattern the other scope contributed, so provenance is tracked per pattern.
|
||||
*/
|
||||
export type Origins = Record<string, Record<string, Scope>> | undefined
|
||||
|
||||
/** Origin of a config-derived or agent-default rule, matched by permission + pattern. */
|
||||
export function configSource(permission: string, pattern: string, origins: Origins): Source {
|
||||
const scope = origins?.[permission]?.[pattern]
|
||||
if (scope === "global") return "global"
|
||||
if (scope === "local") return "project"
|
||||
return "agent"
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag agent-owned rules with their config origin (global/project) or the agent default.
|
||||
* These come from the agent's merged permission set.
|
||||
*/
|
||||
export function tagAgent(ruleset: Permission.Ruleset, origins: Origins): SourcedRule[] {
|
||||
return ruleset.map((rule) => ({ ...rule, source: configSource(rule.permission, rule.pattern, origins) }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag session-scoped rules. The broad allow rule is auto-approve (YOLO) mode, which is stored
|
||||
* on the session; any other session rule is an explicit per-session runtime toggle.
|
||||
*/
|
||||
export function tagSession(ruleset: Permission.Ruleset): SourcedRule[] {
|
||||
return ruleset.map((rule) => ({ ...rule, source: isYolo(rule) ? "yolo" : "session" }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve an existing `approval` marker when a tool part's metadata is replaced.
|
||||
*
|
||||
* The approval is written once during `ask()`, but tools freely overwrite `state.metadata`
|
||||
* during execution and on completion. Carry the prior `approval` onto the replacement unless
|
||||
* the replacement sets its own.
|
||||
*/
|
||||
export function carryApproval(
|
||||
prev: Record<string, unknown> | undefined,
|
||||
next: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (!next || !prev?.approval || "approval" in next) return next
|
||||
return { ...next, approval: prev.approval }
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the winning rule of an auto-approval into an Approval payload.
|
||||
*
|
||||
* Rules assembled by `askPermission` are tagged with their true origin, so we read the tag
|
||||
* directly. An untagged winner can only come from the saved global approvals that `Permission.ask`
|
||||
* merges internally: the broad allow rule there is YOLO mode, otherwise fall back to config origin.
|
||||
*/
|
||||
export function classify(input: { rule?: Permission.Rule; agent: string; origins: Origins }): Approval {
|
||||
const rule = input.rule
|
||||
if (!rule) return { source: "default" }
|
||||
const source =
|
||||
(rule as SourcedRule).source ?? (isYolo(rule) ? "yolo" : configSource(rule.permission, rule.pattern, input.origins))
|
||||
return {
|
||||
source,
|
||||
...(source === "agent" ? { agent: input.agent } : {}),
|
||||
rule: { permission: rule.permission, pattern: rule.pattern, action: rule.action },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import type { LLMEvent, Usage } from "@opencode-ai/llm"
|
||||
import type { LLMEvent, ProviderMetadata, Usage } from "@opencode-ai/llm"
|
||||
import type { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { SessionRetry } from "@/session/retry"
|
||||
import { computeMetrics as computeMetricsHelper, type TokenRates } from "@/kilocode/session/metrics"
|
||||
@@ -137,6 +137,14 @@ export namespace KiloSessionProcessor {
|
||||
/** Returned shape for downstream consumers that prefer the namespace. */
|
||||
export type Metrics = TokenRates
|
||||
|
||||
export function generationID(meta: ProviderMetadata | undefined) {
|
||||
const value = meta?.gateway?.generationId
|
||||
if (typeof value !== "string") return
|
||||
const id = value.trim()
|
||||
if (!/^gen_[A-Za-z0-9_-]{1,200}$/.test(id)) return
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect-based offline handler for the retry schedule.
|
||||
* Shows offline status, waits for network reconnection or user rejection.
|
||||
|
||||
@@ -16,6 +16,7 @@ import { KiloSession } from "@/kilocode/session"
|
||||
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order"
|
||||
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionProvenance } from "@/kilocode/permission/provenance"
|
||||
import { Question } from "@/question"
|
||||
import { environmentDetails } from "@/kilocode/editor-context"
|
||||
import { Identifier } from "@/id/id"
|
||||
@@ -229,6 +230,7 @@ export namespace KiloSessionPrompt {
|
||||
permission: Pick<Permission.Interface, "ask">
|
||||
agents: Pick<Agent.Interface, "get">
|
||||
sessions: Pick<Session.Interface, "get">
|
||||
origins?: PermissionProvenance.Origins
|
||||
agent: Agent.Info
|
||||
session: Session.Info
|
||||
request: Omit<Permission.AskInput, "ruleset" | "hardRuleset">
|
||||
@@ -237,11 +239,21 @@ export namespace KiloSessionPrompt {
|
||||
const session = yield* input.sessions
|
||||
.get(input.session.id)
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(input.session)))
|
||||
yield* input.permission.ask({
|
||||
...input.request,
|
||||
ruleset: Permission.merge(agent.permission, guardPermissions({ agent, session })),
|
||||
hardRuleset: hardPermissions({ agent }),
|
||||
})
|
||||
|
||||
// kilocode_change start - tag every rule with its true origin before merging, so the winning
|
||||
// rule (chosen by findLast) reports the correct source instead of classify() having to guess.
|
||||
// guardPermissions re-appends agent.permission for ask/plan/architect modes and prepends
|
||||
// session.permission, so tag those inputs up front rather than the outer copy alone.
|
||||
const taggedAgent = PermissionProvenance.tagAgent(agent.permission, input.origins)
|
||||
const taggedSession = PermissionProvenance.tagSession(session.permission ?? [])
|
||||
const ruleset = Permission.merge(
|
||||
taggedAgent,
|
||||
guardPermissions({ agent: { name: agent.name, permission: taggedAgent }, session: { permission: taggedSession } }),
|
||||
)
|
||||
const outcome = yield* input.permission.ask({ ...input.request, ruleset, hardRuleset: hardPermissions({ agent }) })
|
||||
if (outcome.manual) return { source: "manual" } satisfies PermissionProvenance.Approval
|
||||
return PermissionProvenance.classify({ rule: outcome.rule, agent: agent.name, origins: input.origins })
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,15 @@ import type { ProviderMetadata } from "@opencode-ai/llm"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
export namespace KiloResponseMetadata {
|
||||
function vercelID(value: unknown) {
|
||||
if (typeof value !== "string") return
|
||||
const id = value.trim()
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(id)) return
|
||||
return id
|
||||
}
|
||||
|
||||
export function write(metadata: ProviderMetadata | undefined, headers: Record<string, string> | undefined) {
|
||||
const id = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "x-vercel-id")?.[1]
|
||||
const id = vercelID(Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "x-vercel-id")?.[1])
|
||||
if (!id) return metadata
|
||||
const kilo = isRecord(metadata?.kilo) ? metadata.kilo : {}
|
||||
return { ...metadata, kilo: { ...kilo, vercelID: id } }
|
||||
@@ -12,6 +19,6 @@ export namespace KiloResponseMetadata {
|
||||
export function read(metadata: ProviderMetadata | undefined) {
|
||||
const kilo = metadata?.kilo
|
||||
if (!isRecord(kilo)) return
|
||||
return typeof kilo.vercelID === "string" ? kilo.vercelID : undefined
|
||||
return vercelID(kilo.vercelID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ export namespace KilocodeTuiConfig {
|
||||
delete result.plugin_origins
|
||||
delete result.instruction_origins
|
||||
delete result.skill_path_origins
|
||||
delete result.permission_origins
|
||||
const keybinds: Record<string, string> = defaults
|
||||
? Object.fromEntries(KilocodeKeybinds.list().map((item) => [item.id, item.default]))
|
||||
: {}
|
||||
|
||||
@@ -73,8 +73,17 @@ export const AllowEverythingInput = z.object({
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - describe why a call was allowed so clients can explain auto-approval
|
||||
export interface AskOutcome {
|
||||
/** true when the user was prompted and replied; false when a rule auto-approved. */
|
||||
manual: boolean
|
||||
/** The winning rule (carries an optional `source` marker set at ruleset-build time). */
|
||||
rule?: Rule
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
|
||||
readonly ask: (input: AskInput) => Effect.Effect<AskOutcome, Error> // kilocode_change - was Effect<void>; returns the decision
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
// kilocode_change start
|
||||
@@ -191,6 +200,7 @@ export const layer = Layer.effect(
|
||||
const local = s.session[request.sessionID] ?? []
|
||||
// kilocode_change end
|
||||
let needsAsk = false
|
||||
let approvedRule: Rule | undefined // kilocode_change - remember the rule that auto-approved
|
||||
|
||||
// kilocode_change start - protect config access while honoring explicit global skill trust
|
||||
const isProtected = ConfigProtection.isRequest(request)
|
||||
@@ -225,12 +235,15 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
// kilocode_change start - override "allow" to "ask" for protected config paths
|
||||
if (rule.action === "allow" && (!isProtected || trusted)) continue
|
||||
if (rule.action === "allow" && (!isProtected || trusted)) {
|
||||
approvedRule = rule // remember the winning rule so callers can explain the auto-approval
|
||||
continue
|
||||
}
|
||||
// kilocode_change end
|
||||
needsAsk = true
|
||||
}
|
||||
|
||||
if (!needsAsk) return
|
||||
if (!needsAsk) return { manual: false, rule: approvedRule } // kilocode_change - report auto-approval
|
||||
|
||||
// kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903)
|
||||
if (yield* KiloHeadless.denies(request.sessionID).pipe(Effect.provideService(Database.Service, database))) {
|
||||
@@ -261,12 +274,15 @@ export const layer = Layer.effect(
|
||||
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
|
||||
pending.set(id, { info, ruleset, hardRuleset, deferred }) // kilocode_change
|
||||
yield* events.publish(Event.Asked, info) // kilocode_change - was bus.publish
|
||||
return yield* Effect.ensuring(
|
||||
// kilocode_change start - was `return yield* Effect.ensuring(...)`; report the manual decision to callers
|
||||
yield* Effect.ensuring(
|
||||
Deferred.await(deferred),
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
)
|
||||
return { manual: true } // the user was prompted and replied
|
||||
// kilocode_change end
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { Provider } from "@/provider/provider"
|
||||
import { Question } from "@/question"
|
||||
// kilocode_change start
|
||||
import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor"
|
||||
import { PermissionProvenance } from "@/kilocode/permission/provenance" // kilocode_change
|
||||
import { KiloSessionOverflow } from "@/kilocode/session/overflow"
|
||||
import { KiloRoutedModel } from "@/kilocode/session/routed-model"
|
||||
import { KiloResponseMetadata } from "@/kilocode/session/response-metadata"
|
||||
@@ -272,10 +273,13 @@ export const layer = Layer.effect(
|
||||
input: { title?: string; metadata?: Record<string, any> },
|
||||
) {
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
// approval provenance is written once during ask() and must survive later tool metadata writes
|
||||
if (!match || match.part.state.status !== "running") {
|
||||
const prev = ctx.toolmeta[toolCallID]
|
||||
ctx.toolmeta[toolCallID] = {
|
||||
...ctx.toolmeta[toolCallID],
|
||||
...prev,
|
||||
...input,
|
||||
metadata: PermissionProvenance.carryApproval(prev?.metadata, input.metadata),
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -286,7 +290,7 @@ export const layer = Layer.effect(
|
||||
state: {
|
||||
...part.state,
|
||||
title: input.title ?? part.state.title,
|
||||
metadata: input.metadata ?? part.state.metadata,
|
||||
metadata: PermissionProvenance.carryApproval(part.state.metadata, input.metadata) ?? part.state.metadata,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -304,13 +308,17 @@ export const layer = Layer.effect(
|
||||
) {
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match || match.part.state.status !== "running") return
|
||||
// kilocode_change start - preserve approval provenance recorded during permission checks
|
||||
const prior = isRecord(match.part.state.metadata) ? match.part.state.metadata : undefined
|
||||
const metadata = PermissionProvenance.carryApproval(prior, output.metadata) ?? output.metadata
|
||||
// kilocode_change end
|
||||
yield* session.updatePart({
|
||||
...match.part,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: match.part.state.input,
|
||||
output: output.output,
|
||||
metadata: output.metadata,
|
||||
metadata, // kilocode_change - merged to keep approval
|
||||
title: output.title,
|
||||
time: { start: match.part.state.time.start, end: Date.now() },
|
||||
attachments: output.attachments,
|
||||
@@ -869,6 +877,8 @@ export const layer = Layer.effect(
|
||||
modelID: ctx.model.id,
|
||||
selected: ctx.assistantMessage.modelID,
|
||||
})
|
||||
const generationID = KiloSessionProcessor.generationID(value.providerMetadata)
|
||||
const vercelID = KiloResponseMetadata.read(value.providerMetadata)
|
||||
// kilocode_change end
|
||||
// kilocode_change start - guard against finish-step without start-step:
|
||||
// ctx.stepStart is 0 until `start-step` fires, which would feed a
|
||||
@@ -922,6 +932,8 @@ export const layer = Layer.effect(
|
||||
type: "step-finish",
|
||||
time: { start: startDate, end: endDate, elapsed: elapsedMs }, // kilocode_change
|
||||
...(model ? { model } : {}), // kilocode_change
|
||||
...(generationID ? { generationID } : {}), // kilocode_change
|
||||
...(vercelID ? { vercelID } : {}), // kilocode_change
|
||||
...(metrics ? { metrics } : {}), // kilocode_change
|
||||
tokens: usage.tokens,
|
||||
cost: usage.cost,
|
||||
|
||||
@@ -51,7 +51,9 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
const truncate = yield* Truncate.Service
|
||||
// kilocode_change start - SWE-Pruner (experimental)
|
||||
const config = yield* Config.Service
|
||||
const swe = SwePruner.enabled(yield* config.get())
|
||||
const cfg = yield* config.get()
|
||||
const swe = SwePruner.enabled(cfg)
|
||||
const permissionOrigins = cfg.permission_origins
|
||||
// kilocode_change end
|
||||
|
||||
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||
@@ -69,6 +71,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
permission,
|
||||
agents,
|
||||
sessions,
|
||||
origins: permissionOrigins,
|
||||
agent: input.agent,
|
||||
session: input.session,
|
||||
request: {
|
||||
@@ -76,7 +79,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
sessionID: input.session.id,
|
||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||
},
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(
|
||||
// record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask
|
||||
Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })),
|
||||
Effect.asVoid,
|
||||
Effect.orDie,
|
||||
),
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ const discoverSkills = Effect.fnUntraced(function* (
|
||||
|
||||
// kilocode_change start
|
||||
const local = yield* fsys
|
||||
.up({ targets: externalDirs, start: directory, stop: worktree })
|
||||
.up({ targets: externalDirs, start: directory, stop: projectRoot })
|
||||
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||
const fallbacks = yield* primaryPaths(directory, worktree, externalDirs) // kilocode_change
|
||||
const upDirs = [...fallbacks, ...local]
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("structured output")
|
||||
const result = yield* opencode.run("say hi", { format: "json" })
|
||||
const result = yield* opencode.run("say hi", { format: "json", extraArgs: ["--auto"] })
|
||||
opencode.expectExit(result, 0)
|
||||
|
||||
const events = opencode.parseJsonEvents(result.stdout)
|
||||
@@ -83,9 +83,25 @@ describe("opencode run (non-interactive subprocess)", () => {
|
||||
expect(typeof evt.type).toBe("string")
|
||||
expect(typeof evt.sessionID).toBe("string")
|
||||
}
|
||||
// At least one `text` event should appear with the LLM's response.
|
||||
const text = events.find((e) => e.type === "text")
|
||||
expect(text).toBeDefined()
|
||||
expect(events.filter((event) => event.type === "step_start")).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === "text")).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === "step_finish")).toHaveLength(1)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
cliIt.live(
|
||||
"--format json emits each completed tool once",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.tool("glob", { pattern: "package.json" })
|
||||
yield* llm.text("tool complete")
|
||||
const result = yield* opencode.run("find package.json", { format: "json", extraArgs: ["--auto"] })
|
||||
opencode.expectExit(result, 0)
|
||||
|
||||
const events = opencode.parseJsonEvents(result.stdout)
|
||||
expect(events.filter((event) => event.type === "tool_use")).toHaveLength(1)
|
||||
expect(events.filter((event) => event.type === "text")).toHaveLength(1)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
@@ -88,20 +88,14 @@ function retry(sessionID: string, attempt: number, message: string) {
|
||||
function assistant(id: string, sessionID = "session-1"): SdkEvent {
|
||||
return {
|
||||
id: `evt-${id}`,
|
||||
type: "sync",
|
||||
syncEvent: {
|
||||
type: "message.updated.1",
|
||||
id: `evt-${id}`,
|
||||
seq: 1,
|
||||
aggregateID: sessionID,
|
||||
data: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID,
|
||||
info: assistantMessage({
|
||||
sessionID,
|
||||
info: assistantMessage({
|
||||
sessionID,
|
||||
id,
|
||||
parts: [],
|
||||
}).info,
|
||||
},
|
||||
id,
|
||||
parts: [],
|
||||
}).info,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -295,6 +289,18 @@ function textPart(id: string, messageID: string, text: string, sessionID = "sess
|
||||
}
|
||||
|
||||
function textUpdated(part: TextPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function syncTextUpdated(part: TextPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "sync",
|
||||
@@ -338,17 +344,11 @@ function reasoningUpdated(part: ReasoningPart): SdkEvent {
|
||||
function toolUpdated(part: SessionToolPart): SdkEvent {
|
||||
return {
|
||||
id: `evt-${part.id}-updated`,
|
||||
type: "sync",
|
||||
syncEvent: {
|
||||
type: "message.part.updated.1",
|
||||
id: `evt-${part.id}-updated`,
|
||||
seq: 1,
|
||||
aggregateID: part.sessionID,
|
||||
data: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: part.sessionID,
|
||||
part,
|
||||
time: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -468,6 +468,34 @@ function sdk(
|
||||
}
|
||||
|
||||
describe("run stream transport", () => {
|
||||
test("ignores the sync copy of a native message event", async () => {
|
||||
const src = globalFeed()
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ globalStream: src.stream }),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
const part = {
|
||||
...textPart("text-1", "msg-1", "Hello"),
|
||||
time: { start: 1, end: 2 },
|
||||
}
|
||||
|
||||
try {
|
||||
src.push(globalEvent(assistant("msg-1")))
|
||||
src.push(globalEvent(textUpdated(part)))
|
||||
src.push(globalEvent(syncTextUpdated(part)))
|
||||
|
||||
await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello"))
|
||||
expect(ui.commits.filter((item) => item.kind === "assistant" && item.text === "Hello")).toHaveLength(1)
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not replay persisted main-session history during bootstrap by default", async () => {
|
||||
const src = eventFeed()
|
||||
const ui = footer()
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// a source-text/regex assertion on the handler's structure.
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildInstanceAdvertisement } from "../../../../src/cli/cmd/remote"
|
||||
// Shared helper lives in kilo-sessions; remote.ts re-exports for the CLI path.
|
||||
import { buildInstanceAdvertisement } from "../../../../src/kilo-sessions/instance-advertisement"
|
||||
|
||||
describe("RemoteCommand instance advertisement (K1 W1)", () => {
|
||||
test("buildInstanceAdvertisement resolves name/projectName/version from the directory and installation version", () => {
|
||||
|
||||
@@ -276,18 +276,17 @@ multi.live("isolates the process-wide listener by instance directory", () => {
|
||||
)
|
||||
})
|
||||
|
||||
// kilocode_change start - K1 W1: instance advertisement + per-session platform.
|
||||
// kilocode_change start - K1 W1 / DEF-1: instance advertisement + per-session platform.
|
||||
//
|
||||
// The race is the heart of this slice: `enableRemote` is idempotent/coalescing
|
||||
// and can be called from either the explicit `kilo remote` command OR from
|
||||
// bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). The
|
||||
// module-level `instanceAdvertisement` flag must make the next heartbeat
|
||||
// carry `instance` regardless of which caller won the race, and the setter
|
||||
// must trigger an out-of-band heartbeat when called against an existing
|
||||
// connection (so the cloud learns about the instance without waiting for
|
||||
// the next 10s timer tick).
|
||||
// `enableRemote` is idempotent/coalescing and is called from `/remote`, the
|
||||
// explicit `kilo remote` command, and bootstrap auto-enable (`KILO_REMOTE=1` /
|
||||
// `remote_control`). Every successful entry must ensure a default instance
|
||||
// advertisement (including the already-connected early return — the common
|
||||
// `/remote`-after-auto-enable path). Explicit `setInstanceAdvertisement`
|
||||
// keeps replace semantics and fires one out-of-band heartbeat per set when
|
||||
// connected; `enableRemote` with an ad already set is a no-op (no extra HB).
|
||||
|
||||
describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => {
|
||||
let heartbeatCalls = 0
|
||||
let outOfBand: Promise<void> | undefined
|
||||
|
||||
@@ -375,27 +374,51 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
|
||||
}
|
||||
|
||||
test("flag is unset by default — heartbeats omit `instance`", async () => {
|
||||
test("enableRemote alone advertises the instance (covers /remote and auto-enable)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Contract: enableRemote entry with none set → derive and set.
|
||||
// No prior setInstanceAdvertisement (simulates /remote or auto-enable).
|
||||
await KiloSessions.enableRemote()
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.type).toBe("heartbeat")
|
||||
expect(payload.instance).toBeUndefined()
|
||||
expect(payload.instance).toBeDefined()
|
||||
expect(payload.instance!.projectName.length).toBeGreaterThan(0)
|
||||
expect(payload.instance!.name.length).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("setting the flag makes the next getSessions include `instance` (race: setter after enable)", async () => {
|
||||
test("enableRemote after already connected is a no-op for advertisement (no extra heartbeat)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Auto-enable connects first and advertises.
|
||||
await KiloSessions.enableRemote()
|
||||
const first = await capturedGetSessions()()
|
||||
expect(first.instance).toBeDefined()
|
||||
const before = heartbeatCalls
|
||||
// /remote calls enableRemote again; already-connected early return must
|
||||
// not re-set or fire an extra out-of-band heartbeat.
|
||||
await KiloSessions.enableRemote()
|
||||
expect(heartbeatCalls).toBe(before)
|
||||
const second = await capturedGetSessions()()
|
||||
expect(second.instance).toEqual(first.instance)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit set after enable replaces the payload (kilo remote race)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
// Race: the explicit `kilo remote` command now sets the flag, after
|
||||
// `enableRemote` already coalesced with bootstrap auto-enable.
|
||||
// Explicit set keeps replace semantics even when enableRemote already
|
||||
// derived a default advertisement.
|
||||
KiloSessions.setInstanceAdvertisement({
|
||||
name: "mbp-igor",
|
||||
projectName: "cloud",
|
||||
@@ -414,11 +437,10 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const beforePayload = await capturedGetSessions()()
|
||||
expect(beforePayload.instance).toBeUndefined()
|
||||
// enableRemote already set a default ad; explicit set replaces and fires
|
||||
// exactly one out-of-band heartbeat.
|
||||
const beforeHeartbeatCalls = heartbeatCalls
|
||||
KiloSessions.setInstanceAdvertisement({ name: "h", projectName: "p" })
|
||||
// The setter fires one out-of-band heartbeat — wait for it.
|
||||
await outOfBand
|
||||
expect(heartbeatCalls).toBe(beforeHeartbeatCalls + 1)
|
||||
const afterPayload = await capturedGetSessions()()
|
||||
@@ -427,7 +449,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("setter is idempotent — second call replaces the payload and still fires one out-of-band heartbeat", async () => {
|
||||
test("setter replaces payload and fires one out-of-band heartbeat per call", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
@@ -445,6 +467,38 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1)", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("explicit set before enableRemote is preserved (no re-set on enable)", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Contract: set before connect → flag stored; enable must not replace.
|
||||
KiloSessions.setInstanceAdvertisement({ name: "pre-set", projectName: "proj", version: "9.9.9" })
|
||||
await KiloSessions.enableRemote()
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.instance).toEqual({ name: "pre-set", projectName: "proj", version: "9.9.9" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("disableRemote does not clear the advertisement flag", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const before = await capturedGetSessions()()
|
||||
expect(before.instance).toBeDefined()
|
||||
KiloSessions.disableRemote()
|
||||
// Re-enable: ensureDefault must no-op (flag still set), and the new
|
||||
// connection's getSessions must still carry the same advertisement.
|
||||
await KiloSessions.enableRemote()
|
||||
const after = await capturedGetSessions()()
|
||||
expect(after.instance).toEqual(before.instance)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("per-session platform resolution matches meta() order — env var fallback", async () => {
|
||||
// The getSessions closure's platform field is computed as:
|
||||
// KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli"
|
||||
@@ -577,19 +631,22 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => {
|
||||
return chat.id
|
||||
}
|
||||
|
||||
for (const { label, status } of [
|
||||
{ label: "busy", status: { type: "busy" as const } },
|
||||
for (const { label, status, heartbeatStatus } of [
|
||||
{ label: "busy", status: { type: "busy" as const }, heartbeatStatus: "busy" },
|
||||
{
|
||||
label: "retry",
|
||||
status: { type: "retry" as const, attempt: 1, message: "retrying", next: 100 },
|
||||
heartbeatStatus: "retry",
|
||||
},
|
||||
{
|
||||
// SessionStatus.offline maps to heartbeat "retry" (same as deriveStatus).
|
||||
label: "offline",
|
||||
status: {
|
||||
type: "offline" as const,
|
||||
requestID: QuestionID.ascending(),
|
||||
message: "waiting for user",
|
||||
},
|
||||
heartbeatStatus: "retry",
|
||||
},
|
||||
]) {
|
||||
test(`clears ${label} SessionStatus so the detach heartbeat fence resolves`, async () => {
|
||||
@@ -607,7 +664,7 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => {
|
||||
|
||||
const getSessions = capturedGetSessions()
|
||||
const before = await getSessions()
|
||||
expect(before.sessions.some((s) => s.id === id && s.status === label)).toBe(true)
|
||||
expect(before.sessions.some((s) => s.id === id && s.status === heartbeatStatus)).toBe(true)
|
||||
|
||||
await KiloSessions.detachRemoteSession(id)
|
||||
|
||||
@@ -621,3 +678,308 @@ describe("KiloSessions.detachRemoteSession heartbeat fence (K1 W1)", () => {
|
||||
}, 30000)
|
||||
}
|
||||
})
|
||||
|
||||
// DEF-3 part 1: heartbeat per-session status must reflect pending
|
||||
// question/permission (same precedence as deriveStatus), with Permission and
|
||||
// Question list() called once per heartbeat — not once per session.
|
||||
describe("KiloSessions heartbeat attention status (DEF-3)", () => {
|
||||
beforeEach(() => {
|
||||
process.env["KILO_DISABLE_SESSION_INGEST"] = "0"
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
process.env["KILO_API_KEY"] = "tok"
|
||||
reset("tok")
|
||||
KiloSessions.resetInstanceAdvertisementForTests()
|
||||
|
||||
spyOn(RemoteSender, "create").mockImplementation(
|
||||
() =>
|
||||
({
|
||||
handle() {},
|
||||
dispose() {},
|
||||
}) as RemoteSender.Sender,
|
||||
)
|
||||
spyOn(RemoteWS, "connect").mockImplementation(
|
||||
(options) =>
|
||||
({
|
||||
connectionId: "test-conn",
|
||||
send() {},
|
||||
heartbeat: () => options.getSessions().then(() => undefined),
|
||||
close() {},
|
||||
get connected() {
|
||||
return true
|
||||
},
|
||||
}) as RemoteWS.Connection,
|
||||
)
|
||||
|
||||
clearInFlightCache("kilo-sessions:token")
|
||||
clearInFlightCache("kilo-sessions:token-valid:tok")
|
||||
|
||||
globalThis.fetch = mock(async (input) => {
|
||||
const url = String(input)
|
||||
if (url.endsWith("/api/user")) {
|
||||
return new Response(null, { status: 200 })
|
||||
}
|
||||
if (url.endsWith("/api/session")) {
|
||||
return Response.json({ id: "remote-test", ingestPath: "/api/ingest/test" })
|
||||
}
|
||||
throw new Error(`unexpected fetch in test: ${url}`)
|
||||
}) as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
const pub = spyOn(Bus, "publish").mockResolvedValue(undefined as never)
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
KiloSessions.disableRemote()
|
||||
},
|
||||
})
|
||||
pub.mockRestore()
|
||||
mock.restore()
|
||||
delete process.env["KILO_DISABLE_SESSION_INGEST"]
|
||||
delete process.env["KILO_SESSION_INGEST_URL"]
|
||||
delete process.env["KILO_PLATFORM"]
|
||||
delete process.env["KILO_API_KEY"]
|
||||
reset("tok")
|
||||
})
|
||||
|
||||
function capturedGetSessions(): () => Promise<RemoteProtocol.Heartbeat> {
|
||||
const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls
|
||||
const getSessions = calls[0]?.[0].getSessions
|
||||
if (!getSessions) throw new Error("RemoteWS.connect was not called")
|
||||
return getSessions as () => Promise<RemoteProtocol.Heartbeat>
|
||||
}
|
||||
|
||||
async function setupSession() {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Session } = await import("@/session/session")
|
||||
const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({})))
|
||||
return chat.id
|
||||
}
|
||||
|
||||
const questionPrompt = [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function waitForPermission(sessionID: string) {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pending = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
|
||||
if (pending.some((p) => p.sessionID === sessionID)) return
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
throw new Error(`timed out waiting for permission on ${sessionID}`)
|
||||
}
|
||||
|
||||
async function waitForQuestion(sessionID: string) {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Question } = await import("@/question")
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
if (pending.some((q) => q.sessionID === sessionID)) return
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
}
|
||||
throw new Error(`timed out waiting for question on ${sessionID}`)
|
||||
}
|
||||
|
||||
test("reports permission when a permission request is pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
const { PermissionV1 } = await import("@opencode-ai/core/v1/permission")
|
||||
const requestID = PermissionV1.ID.make("permission_hb_perm")
|
||||
|
||||
AppRuntime.runFork(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.ask({
|
||||
id: requestID,
|
||||
sessionID: id,
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
await waitForPermission(id)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true)
|
||||
|
||||
await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" })))
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("reports question when a structured question is pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Question } = await import("@/question")
|
||||
|
||||
AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt })))
|
||||
await waitForQuestion(id)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.sessions.some((s) => s.id === id && s.status === "question")).toBe(true)
|
||||
|
||||
const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
const req = pending.find((q) => q.sessionID === id)
|
||||
expect(req).toBeDefined()
|
||||
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req!.id)))
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("permission takes precedence over question", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
const { Question } = await import("@/question")
|
||||
const { PermissionV1 } = await import("@opencode-ai/core/v1/permission")
|
||||
const requestID = PermissionV1.ID.make("permission_hb_both")
|
||||
|
||||
AppRuntime.runFork(Question.Service.use((svc) => svc.ask({ sessionID: id, questions: questionPrompt })))
|
||||
AppRuntime.runFork(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.ask({
|
||||
id: requestID,
|
||||
sessionID: id,
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
ruleset: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
await waitForPermission(id)
|
||||
await waitForQuestion(id)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
expect(payload.sessions.some((s) => s.id === id && s.status === "permission")).toBe(true)
|
||||
|
||||
await AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply({ requestID, reply: "once" })))
|
||||
const pending = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
const req = pending.find((q) => q.sessionID === id)
|
||||
if (req) await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(req.id)))
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("idle/busy/retry unchanged when no attention is pending", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
const idleId = await setupSession()
|
||||
const busyId = await setupSession()
|
||||
const retryId = await setupSession()
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.set(busyId, { type: "busy" })))
|
||||
await AppRuntime.runPromise(
|
||||
SessionStatus.Service.use((svc) =>
|
||||
svc.set(retryId, { type: "retry", attempt: 1, message: "retrying", next: 100 }),
|
||||
),
|
||||
)
|
||||
|
||||
await KiloSessions.attachRemoteSession(idleId)
|
||||
await KiloSessions.attachRemoteSession(busyId)
|
||||
await KiloSessions.attachRemoteSession(retryId)
|
||||
|
||||
const payload = await capturedGetSessions()()
|
||||
const byId = Object.fromEntries(payload.sessions.map((s) => [s.id, s.status]))
|
||||
expect(byId[idleId]).toBe("idle")
|
||||
expect(byId[busyId]).toBe("busy")
|
||||
expect(byId[retryId]).toBe("retry")
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
|
||||
test("Permission and Question list() are called once per heartbeat across many sessions", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await KiloSessions.enableRemote()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const id = await setupSession()
|
||||
await KiloSessions.attachRemoteSession(id)
|
||||
}
|
||||
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
const { Permission } = await import("@/permission")
|
||||
const { Question } = await import("@/question")
|
||||
|
||||
// list is readonly on the interface; cast to count calls in place.
|
||||
type ListBag = { list: () => unknown }
|
||||
const permSvc = (await AppRuntime.runPromise(
|
||||
Permission.Service.use((svc) => Effect.succeed(svc)),
|
||||
)) as unknown as ListBag
|
||||
const qSvc = (await AppRuntime.runPromise(
|
||||
Question.Service.use((svc) => Effect.succeed(svc)),
|
||||
)) as unknown as ListBag
|
||||
|
||||
let permissionListCalls = 0
|
||||
let questionListCalls = 0
|
||||
const origPermList = permSvc.list.bind(permSvc)
|
||||
const origQList = qSvc.list.bind(qSvc)
|
||||
permSvc.list = () => {
|
||||
permissionListCalls += 1
|
||||
return origPermList()
|
||||
}
|
||||
qSvc.list = () => {
|
||||
questionListCalls += 1
|
||||
return origQList()
|
||||
}
|
||||
|
||||
try {
|
||||
await capturedGetSessions()()
|
||||
// Once per heartbeat, not once per session (4 sessions attached).
|
||||
expect(permissionListCalls).toBe(1)
|
||||
expect(questionListCalls).toBe(1)
|
||||
|
||||
permissionListCalls = 0
|
||||
questionListCalls = 0
|
||||
await capturedGetSessions()()
|
||||
expect(permissionListCalls).toBe(1)
|
||||
expect(questionListCalls).toBe(1)
|
||||
} finally {
|
||||
permSvc.list = origPermList
|
||||
qSvc.list = origQList
|
||||
}
|
||||
},
|
||||
})
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Skill } from "../../src/skill"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "../../src/event-v2-bridge"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Git } from "../../src/git"
|
||||
import { provideInstance, testInstanceStoreLayer, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const skills = (home: string) =>
|
||||
Skill.layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Discovery.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ home })),
|
||||
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: false, disableClaudeCodeSkills: false })),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer))
|
||||
|
||||
describe("non-Git global skills", () => {
|
||||
it.live("loads global skills when the project is below the home directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = path.join(tmp.path, "projects", "plain")
|
||||
const roots = [".agents", ".claude"] as const
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
const name = `${root.slice(1)}-global`
|
||||
const dir = path.join(tmp.path, root, "skills", name)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(dir, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: Global ${root} skill.
|
||||
---
|
||||
|
||||
# Global skill
|
||||
`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = yield* skill.all()
|
||||
|
||||
for (const root of roots) {
|
||||
const name = `${root.slice(1)}-global`
|
||||
expect(list.find((item) => item.name === name)?.location).toBe(
|
||||
path.join(tmp.path, root, "skills", name, "SKILL.md"),
|
||||
)
|
||||
}
|
||||
}).pipe(Effect.provide(skills(tmp.path)), provideInstance(project))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -115,7 +115,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -204,7 +204,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
|
||||
// curl was NOT in rules — still requires permission
|
||||
const curlFiber = yield* ask({
|
||||
@@ -255,7 +255,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -325,7 +325,7 @@ describe("saveAlwaysRules", () => {
|
||||
{ permission: "bash", pattern: "gh *", action: "ask" },
|
||||
],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -350,7 +350,7 @@ describe("saveAlwaysRules", () => {
|
||||
ruleset,
|
||||
hardRuleset: ruleset,
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -386,7 +386,7 @@ describe("saveAlwaysRules", () => {
|
||||
],
|
||||
hardRuleset: [{ permission: "*", pattern: "*", action: "deny" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -448,7 +448,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -486,7 +486,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result.manual).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -522,7 +522,7 @@ describe("saveAlwaysRules", () => {
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(allowed).toBeUndefined()
|
||||
expect(allowed.manual).toBe(false)
|
||||
|
||||
// "git status" should be denied (only matches broad deny)
|
||||
const exit = yield* ask({
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// kilocode_change - new file
|
||||
// Verifies that Config.permission_origins attributes each permission key to the scope
|
||||
// (global XDG vs local project) that last set it, which drives auto-approval provenance.
|
||||
|
||||
import { expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "../../../src/config/config"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Env } from "../../../src/env"
|
||||
import { Git } from "../../../src/git"
|
||||
import { Auth } from "../../../src/auth"
|
||||
import { Account } from "../../../src/account/account"
|
||||
import { provideTestInstance } from "../../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const infra = CrossSpawnSpawner.defaultLayer.pipe(
|
||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const emptyAuth = Layer.mock(Auth.Service)({ all: () => Effect.succeed({}) })
|
||||
const noopNpm = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
add: () => Effect.die("not implemented"),
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`))
|
||||
const testLayer = Config.layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
Layer.provide(Layer.succeed(HttpClient.HttpClient, unexpectedHttp)),
|
||||
)
|
||||
|
||||
test("project config permission keys are attributed to the local scope", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "a")
|
||||
const kilo = path.join(dir, ".kilo")
|
||||
await fs.mkdir(kilo, { recursive: true })
|
||||
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: { "echo *": "allow" } } }))
|
||||
|
||||
await provideTestInstance({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const cfg = await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
|
||||
)
|
||||
expect(cfg.permission?.bash).toEqual({ "echo *": "allow" })
|
||||
expect(cfg.permission_origins?.bash).toEqual({ "echo *": "local" })
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("a scalar project bash permission maps to the '*' pattern under the local scope", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "a")
|
||||
const kilo = path.join(dir, ".kilo")
|
||||
await fs.mkdir(kilo, { recursive: true })
|
||||
await Bun.write(path.join(kilo, "kilo.json"), JSON.stringify({ permission: { bash: "allow" } }))
|
||||
|
||||
await provideTestInstance({
|
||||
directory: dir,
|
||||
fn: async () => {
|
||||
const cfg = await Effect.runPromise(
|
||||
Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer)),
|
||||
)
|
||||
expect(cfg.permission_origins?.bash).toEqual({ "*": "local" })
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,228 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "../../../src/agent/agent"
|
||||
import { Session } from "../../../src/session/session"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PermissionProvenance } from "../../../src/kilocode/permission/provenance"
|
||||
import { KiloSessionPrompt } from "../../../src/kilocode/session/prompt"
|
||||
import { SessionID } from "../../../src/session/schema"
|
||||
|
||||
describe("PermissionProvenance", () => {
|
||||
test("configSource maps the scope of a permission + pattern", () => {
|
||||
expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "global" } })).toBe("global")
|
||||
expect(PermissionProvenance.configSource("edit", "*", { edit: { "*": "local" } })).toBe("project")
|
||||
expect(PermissionProvenance.configSource("edit", "*", undefined)).toBe("agent")
|
||||
// Different patterns under one key can come from different scopes.
|
||||
const mixed = { bash: { "git status": "global" as const, "npm test": "local" as const } }
|
||||
expect(PermissionProvenance.configSource("bash", "git status", mixed)).toBe("global")
|
||||
expect(PermissionProvenance.configSource("bash", "npm test", mixed)).toBe("project")
|
||||
// A pattern not present under the key falls back to the agent default.
|
||||
expect(PermissionProvenance.configSource("bash", "rm -rf", mixed)).toBe("agent")
|
||||
})
|
||||
|
||||
test("evaluate returns the winning rule object, preserving its source tag", () => {
|
||||
// The last matching rule wins; the returned object still carries the source we attached.
|
||||
const ruleset: PermissionProvenance.SourcedRule[] = [
|
||||
{ permission: "edit", pattern: "*", action: "ask", source: "agent" },
|
||||
{ permission: "edit", pattern: "src/*", action: "allow", source: "global" },
|
||||
]
|
||||
const winner = Permission.evaluate("edit", "src/index.ts", ruleset)
|
||||
expect((winner as PermissionProvenance.SourcedRule).source).toBe("global")
|
||||
})
|
||||
|
||||
test("classify reads a tagged rule's source and carries the agent name", () => {
|
||||
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
|
||||
expect(PermissionProvenance.classify({ rule, agent: "build", origins: undefined })).toEqual({
|
||||
source: "agent",
|
||||
agent: "build",
|
||||
rule: { permission: "edit", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("classify treats an untagged broad allow as yolo", () => {
|
||||
const out = PermissionProvenance.classify({
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
agent: "build",
|
||||
origins: undefined,
|
||||
})
|
||||
expect(out.source).toBe("yolo")
|
||||
})
|
||||
|
||||
test("classify falls back to config origins for an untagged rule", () => {
|
||||
const out = PermissionProvenance.classify({
|
||||
rule: { permission: "edit", pattern: "src/*", action: "allow" },
|
||||
agent: "build",
|
||||
origins: { edit: { "src/*": "local" } },
|
||||
})
|
||||
expect(out.source).toBe("project")
|
||||
})
|
||||
|
||||
test("classify without a rule reports the ask fallback", () => {
|
||||
expect(PermissionProvenance.classify({ agent: "build", origins: undefined })).toEqual({ source: "default" })
|
||||
})
|
||||
|
||||
test("tagAgent stamps each rule by permission + pattern, defaulting to agent", () => {
|
||||
const tagged = PermissionProvenance.tagAgent(
|
||||
[
|
||||
{ permission: "bash", pattern: "git status", action: "allow" },
|
||||
{ permission: "bash", pattern: "npm test", action: "allow" },
|
||||
{ permission: "edit", pattern: "*", action: "allow" },
|
||||
],
|
||||
// Global and project each contribute a different pattern under the same bash key.
|
||||
{ bash: { "git status": "global", "npm test": "local" } },
|
||||
)
|
||||
expect(tagged.map((r) => r.source)).toEqual(["global", "project", "agent"])
|
||||
})
|
||||
|
||||
test("tagSession marks the broad allow as yolo and other rules as session", () => {
|
||||
const tagged = PermissionProvenance.tagSession([
|
||||
{ permission: "*", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "git *", action: "allow" },
|
||||
])
|
||||
expect(tagged.map((r) => r.source)).toEqual(["yolo", "session"])
|
||||
})
|
||||
|
||||
test("a tagged agent rule wins over an untagged duplicate and is not misread as yolo", () => {
|
||||
// Regression: guardPermissions re-appends agent rules for ask/plan/architect; every rule that
|
||||
// reaches evaluate must be tagged so the broad agent allow is not mistaken for YOLO mode.
|
||||
const agent = PermissionProvenance.tagAgent([{ permission: "*", pattern: "*", action: "allow" }], undefined)
|
||||
const session = PermissionProvenance.tagSession([])
|
||||
const ruleset = [...agent, ...session, ...agent] // mirrors merge(tagged, guardPermissions(...)) for a mode
|
||||
const winner = Permission.evaluate("bash", "echo hi", ruleset)
|
||||
expect(PermissionProvenance.classify({ rule: winner, agent: "plan", origins: undefined })).toEqual({
|
||||
source: "agent",
|
||||
agent: "plan",
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("PermissionProvenance.carryApproval", () => {
|
||||
const approval = { source: "agent" as const, agent: "build" }
|
||||
|
||||
test("carries a prior approval onto a replacement that omits it", () => {
|
||||
// The tool overwrites metadata during execution; the approval written during ask() must survive.
|
||||
expect(PermissionProvenance.carryApproval({ approval }, { command: "echo hi" })).toEqual({
|
||||
command: "echo hi",
|
||||
approval,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not override an approval the replacement sets itself", () => {
|
||||
const next = { approval: { source: "yolo" as const } }
|
||||
expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next)
|
||||
})
|
||||
|
||||
test("leaves the replacement untouched when there is no prior approval", () => {
|
||||
const next = { command: "echo hi" }
|
||||
expect(PermissionProvenance.carryApproval({ command: "old" }, next)).toBe(next)
|
||||
})
|
||||
|
||||
test("returns the replacement as-is when it is undefined", () => {
|
||||
expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("askPermission returns provenance", () => {
|
||||
const sessionID = SessionID.make("ses_prov")
|
||||
const agent: Agent.Info = {
|
||||
name: "build",
|
||||
mode: "primary",
|
||||
permission: Permission.fromConfig({ edit: "allow" }),
|
||||
options: {},
|
||||
}
|
||||
const session = { id: sessionID, permission: [] } as unknown as Session.Info
|
||||
|
||||
const run = (outcome: Permission.AskOutcome, origins?: PermissionProvenance.Origins) =>
|
||||
Effect.gen(function* () {
|
||||
return yield* KiloSessionPrompt.askPermission({
|
||||
permission: yield* Permission.Service,
|
||||
agents: yield* Agent.Service,
|
||||
sessions: yield* Session.Service,
|
||||
origins,
|
||||
agent,
|
||||
session,
|
||||
request: { sessionID, permission: "edit", patterns: ["src/index.ts"], always: [], metadata: {} },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Permission.Service)({ ask: () => Effect.succeed(outcome) }),
|
||||
Layer.mock(Agent.Service)({ get: () => Effect.succeed(agent) }),
|
||||
Layer.mock(Session.Service)({ get: () => Effect.succeed(session) }),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
test("manual reply reports the manual source", async () => {
|
||||
expect(await run({ manual: true })).toEqual({ source: "manual" })
|
||||
})
|
||||
|
||||
test("agent-default rule classifies as agent with its name", async () => {
|
||||
const rule = { permission: "edit", pattern: "*", action: "allow" as const, source: "agent" as const }
|
||||
expect(await run({ manual: false, rule })).toEqual({
|
||||
source: "agent",
|
||||
agent: "build",
|
||||
rule: { permission: "edit", pattern: "*", action: "allow" },
|
||||
})
|
||||
})
|
||||
|
||||
test("untagged rule falls back to config origins", async () => {
|
||||
const out = await run(
|
||||
{ manual: false, rule: { permission: "edit", pattern: "src/*", action: "allow" } },
|
||||
{ edit: { "src/*": "local" } },
|
||||
)
|
||||
expect(out.source).toBe("project")
|
||||
})
|
||||
|
||||
test("global and project patterns under the same key are attributed independently", async () => {
|
||||
// global: bash "git status" allow; project: bash "npm test" allow -> both live under bash.
|
||||
const origins = { bash: { "git status": "global" as const, "npm test": "local" as const } }
|
||||
const fromGlobal = await run({ manual: false, rule: { permission: "bash", pattern: "git status", action: "allow" } }, origins)
|
||||
expect(fromGlobal.source).toBe("global")
|
||||
const fromProject = await run({ manual: false, rule: { permission: "bash", pattern: "npm test", action: "allow" } }, origins)
|
||||
expect(fromProject.source).toBe("project")
|
||||
})
|
||||
|
||||
test("every rule passed to ask is tagged, even the guardPermissions re-append for modes", async () => {
|
||||
// Regression guard: a plan/ask/architect agent's rules are duplicated by guardPermissions.
|
||||
// Capture the ruleset askPermission builds and confirm no rule reaches evaluate untagged.
|
||||
const captured: Permission.Ruleset[] = []
|
||||
const planAgent: Agent.Info = {
|
||||
name: "plan",
|
||||
mode: "primary",
|
||||
permission: Permission.fromConfig({ bash: "allow" }),
|
||||
options: {},
|
||||
}
|
||||
const planSession = { id: sessionID, permission: [{ permission: "edit", pattern: "*", action: "deny" }] } as unknown as Session.Info
|
||||
await Effect.gen(function* () {
|
||||
yield* KiloSessionPrompt.askPermission({
|
||||
permission: yield* Permission.Service,
|
||||
agents: yield* Agent.Service,
|
||||
sessions: yield* Session.Service,
|
||||
agent: planAgent,
|
||||
session: planSession,
|
||||
request: { sessionID, permission: "bash", patterns: ["echo hi"], always: [], metadata: {} },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.mock(Permission.Service)({
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
captured.push(req.ruleset)
|
||||
return { manual: false } as const
|
||||
}),
|
||||
}),
|
||||
Layer.mock(Agent.Service)({ get: () => Effect.succeed(planAgent) }),
|
||||
Layer.mock(Session.Service)({ get: () => Effect.succeed(planSession) }),
|
||||
),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
const ruleset = captured[0]
|
||||
expect(ruleset.length).toBeGreaterThan(0)
|
||||
expect(ruleset.every((rule) => (rule as PermissionProvenance.SourcedRule).source !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -103,6 +103,7 @@ const permission = Layer.mock(Permission.Service)({
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
approvals.push(input)
|
||||
return { manual: false } as const
|
||||
}),
|
||||
})
|
||||
const plugin = Layer.mock(Plugin.Service)({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { KiloSessionProcessor } from "../../src/kilocode/session/processor"
|
||||
|
||||
describe("session generation id", () => {
|
||||
test("extracts a bounded Gateway generation id", () => {
|
||||
expect(
|
||||
KiloSessionProcessor.generationID({
|
||||
gateway: {
|
||||
generationId: " gen_test-123 ",
|
||||
routing: { finalProvider: "novita" },
|
||||
marketCost: "0.1",
|
||||
},
|
||||
}),
|
||||
).toBe("gen_test-123")
|
||||
})
|
||||
|
||||
test("rejects arbitrary or oversized metadata values", () => {
|
||||
expect(KiloSessionProcessor.generationID({ gateway: { generationId: "request-secret" } })).toBeUndefined()
|
||||
expect(KiloSessionProcessor.generationID({ gateway: { generationId: `gen_${"a".repeat(201)}` } })).toBeUndefined()
|
||||
expect(KiloSessionProcessor.generationID({ gateway: { generationId: 42 } })).toBeUndefined()
|
||||
expect(KiloSessionProcessor.generationID({ openai: { responseId: "gen_response" } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -575,11 +575,19 @@ describe("session processor empty tool-calls", () => {
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: "stop",
|
||||
reason: "other",
|
||||
usage: usage(),
|
||||
providerMetadata: { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } },
|
||||
providerMetadata: {
|
||||
kilocode: { routedModelID: "openai/gpt-5.5-20260423" },
|
||||
kilo: { vercelID: "fra1::test" },
|
||||
gateway: {
|
||||
generationId: "gen_test",
|
||||
routing: { finalProvider: "openai" },
|
||||
marketCost: "0.1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
LLMEvent.finish({ reason: "stop", usage: usage() }),
|
||||
LLMEvent.finish({ reason: "other", usage: usage() }),
|
||||
)
|
||||
|
||||
const chat = yield* session.create({})
|
||||
@@ -632,6 +640,10 @@ describe("session processor empty tool-calls", () => {
|
||||
providerID: selection.providerID,
|
||||
modelID: ModelV2.ID.make("openai/gpt-5.5-20260423"),
|
||||
})
|
||||
expect(part?.generationID).toBe("gen_test")
|
||||
expect(part?.vercelID).toBe("fra1::test")
|
||||
expect(part).not.toHaveProperty("providerMetadata")
|
||||
expect(part).not.toHaveProperty("gateway")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
|
||||
@@ -36,4 +36,15 @@ describe("session response metadata", () => {
|
||||
test("does not add metadata when the header is absent", () => {
|
||||
expect(KiloResponseMetadata.write(undefined, { server: "vercel" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes valid Vercel IDs", () => {
|
||||
const metadata = KiloResponseMetadata.write(undefined, { "x-vercel-id": " fra1::abc-123_test " })
|
||||
expect(KiloResponseMetadata.read(metadata)).toBe("fra1::abc-123_test")
|
||||
})
|
||||
|
||||
test("rejects unsafe or oversized Vercel IDs", () => {
|
||||
expect(KiloResponseMetadata.write(undefined, { "x-vercel-id": "fra1::<script>" })).toBeUndefined()
|
||||
expect(KiloResponseMetadata.write(undefined, { "x-vercel-id": "x".repeat(201) })).toBeUndefined()
|
||||
expect(KiloResponseMetadata.read({ kilo: { vercelID: "fra1::abc\nsecret" } })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -571,7 +571,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -803,7 +803,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "ls", action: "allow" } }) // kilocode_change - the persisted "always" rule auto-approves; ask reports that decision
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
@@ -1118,7 +1118,7 @@ it.instance(
|
||||
always: [],
|
||||
ruleset: [{ permission: "bash", pattern: "*", action: "allow" }],
|
||||
})
|
||||
expect(result).toBeUndefined()
|
||||
expect(result).toEqual({ manual: false, rule: { permission: "bash", pattern: "*", action: "allow" } }) // kilocode_change - ask returns the auto-approval decision instead of void
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -281,7 +281,7 @@ describe("session.llm.ai-sdk adapter", () => {
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: "unknown",
|
||||
reason: "other", // kilocode_change
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
@@ -294,7 +294,7 @@ describe("session.llm.ai-sdk adapter", () => {
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
reason: "unknown",
|
||||
reason: "other", // kilocode_change
|
||||
usage: {
|
||||
inputTokens: 11,
|
||||
outputTokens: 6,
|
||||
|
||||
Reference in New Issue
Block a user