mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.17.9
This commit is contained in:
@@ -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