mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-17 17:43:04 +08:00
* feat(cli): answer sessionless model catalog requests * feat(cli): add sessionless catalog changeset * chore(cli): format remote sender files * test(cli): simplify sessionless catalog coverage * fix(test): extend config overlay timeout The legacy overlay test can exceed 30 seconds on a cold Windows runner while its assertions remain valid.
4071 lines
128 KiB
TypeScript
4071 lines
128 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { afterEach, mock, spyOn } from "bun:test"
|
|
import { Effect } from "effect"
|
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
|
import { RemoteCommand } from "../../../src/kilo-sessions/remote-command"
|
|
import { RemoteModelCatalog } from "../../../src/kilo-sessions/remote-model-catalog"
|
|
import { RemoteSender } from "../../../src/kilo-sessions/remote-sender"
|
|
import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws"
|
|
import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol"
|
|
import type { SessionPrompt } from "../../../src/session/prompt"
|
|
import { Question } from "../../../src/question"
|
|
import { QuestionID } from "../../../src/question/schema"
|
|
import { Permission } from "../../../src/permission"
|
|
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
import { SessionID } from "../../../src/session/schema"
|
|
import { Session } from "../../../src/session/session"
|
|
import { Suggestion } from "../../../src/kilocode/suggestion"
|
|
import { KiloSessionPromptQueue } from "../../../src/kilocode/session/prompt-queue"
|
|
|
|
function fakeConn() {
|
|
const sent: any[] = []
|
|
return {
|
|
conn: {
|
|
send(msg: any) {
|
|
sent.push(msg)
|
|
},
|
|
close() {},
|
|
get connected() {
|
|
return true
|
|
},
|
|
} as RemoteWS.Connection,
|
|
sent,
|
|
}
|
|
}
|
|
|
|
function fakeBus() {
|
|
const handlers: ((event: any) => void)[] = []
|
|
const subscribe = (cb: (event: any) => void) => {
|
|
handlers.push(cb)
|
|
return () => {
|
|
const idx = handlers.indexOf(cb)
|
|
if (idx >= 0) handlers.splice(idx, 1)
|
|
}
|
|
}
|
|
return {
|
|
subscribe,
|
|
fire: (event: any) => handlers.forEach((h) => h(event)),
|
|
count: () => handlers.length,
|
|
}
|
|
}
|
|
|
|
const nolog = {
|
|
info: () => {},
|
|
error: () => {},
|
|
warn: () => {},
|
|
}
|
|
|
|
function permissions(items: Permission.Request[] = []) {
|
|
return {
|
|
list: async () => items,
|
|
reply: async () => {},
|
|
}
|
|
}
|
|
|
|
function questions(items: Question.Request[] = []) {
|
|
return {
|
|
list: async () => items,
|
|
reply: async (_input: Parameters<Question.Interface["reply"]>[0]) => {},
|
|
reject: async (_requestID: QuestionID) => {},
|
|
}
|
|
}
|
|
|
|
function prompts(calls: SessionPrompt.PromptInput[]) {
|
|
return async (input: SessionPrompt.PromptInput) => {
|
|
calls.push(input)
|
|
}
|
|
}
|
|
|
|
function info(id: SessionID, directory = "/workspace/project-a") {
|
|
return {
|
|
id,
|
|
slug: id,
|
|
projectID: ProjectV2.ID.make("project_test"),
|
|
directory,
|
|
title: "Test session",
|
|
version: "test",
|
|
time: { created: 0, updated: 0 },
|
|
} satisfies Session.Info
|
|
}
|
|
|
|
function catalogModel(providerID: string, modelID: string, name: string, reasoning = false) {
|
|
return {
|
|
id: ModelV2.ID.make(modelID),
|
|
providerID: ProviderV2.ID.make(providerID),
|
|
api: { id: "private-deployment", url: "https://private.example.com", npm: "file:///private/provider" },
|
|
name,
|
|
capabilities: {
|
|
temperature: true,
|
|
attachment: true,
|
|
reasoning,
|
|
toolcall: true,
|
|
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
interleaved: false,
|
|
},
|
|
cost: { input: 1, output: 2, cache: { read: 3, write: 4 } },
|
|
limit: { context: 100_000, output: 4_096 },
|
|
status: "active" as const,
|
|
options: { apiKey: "must-not-leak" },
|
|
headers: { authorization: "must-not-leak" },
|
|
release_date: "2026-01-01",
|
|
variants: { precise: { apiKey: "must-not-leak" } },
|
|
}
|
|
}
|
|
|
|
// kilocode_change start
|
|
afterEach(() => {
|
|
mock.restore()
|
|
})
|
|
// kilocode_change end
|
|
|
|
describe("RemoteSender", () => {
|
|
test("subscribe adds bus subscription, event forwarded", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_abc" })
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_abc", text: "hello" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toEqual({
|
|
type: "event",
|
|
sessionId: "ses_abc",
|
|
event: "message.updated",
|
|
data: { sessionID: "ses_abc", text: "hello" },
|
|
})
|
|
})
|
|
|
|
test("unsubscribe removes subscription, events stop", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_abc" })
|
|
sender.handle({ type: "unsubscribe", sessionId: "ses_abc" })
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_abc", text: "hello" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(0)
|
|
expect(bus.count()).toBe(0)
|
|
})
|
|
|
|
test("only forwards for subscribed sessions", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_a" })
|
|
|
|
bus.fire({
|
|
type: "session.updated",
|
|
properties: { sessionID: "ses_b", title: "other" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test("duplicate subscribe is idempotent", () => {
|
|
const { conn } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_a" })
|
|
sender.handle({ type: "subscribe", sessionId: "ses_a" })
|
|
|
|
expect(bus.count()).toBe(1)
|
|
})
|
|
|
|
test("single bus subscription for multiple sessions", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_a" })
|
|
sender.handle({ type: "subscribe", sessionId: "ses_b" })
|
|
|
|
expect(bus.count()).toBe(1)
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_a", text: "a" },
|
|
})
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_b", text: "b" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(2)
|
|
expect(sent[0].sessionId).toBe("ses_a")
|
|
expect(sent[1].sessionId).toBe("ses_b")
|
|
})
|
|
|
|
test("unsubscribe one session keeps bus alive for others", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_a" })
|
|
sender.handle({ type: "subscribe", sessionId: "ses_b" })
|
|
sender.handle({ type: "unsubscribe", sessionId: "ses_a" })
|
|
|
|
expect(bus.count()).toBe(1)
|
|
|
|
bus.fire({
|
|
type: "session.updated",
|
|
properties: { sessionID: "ses_b", title: "still here" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].sessionId).toBe("ses_b")
|
|
})
|
|
|
|
test("send_message sends ACK immediately before provide resolves", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
let resolveProvide: () => void
|
|
const provideStarted = new Promise<void>((r) => {
|
|
resolveProvide = r
|
|
})
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async (input: any) => {
|
|
resolveProvide!()
|
|
// Simulate long-running work — never resolves during this test
|
|
await new Promise(() => {})
|
|
return {} as any
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_1",
|
|
command: "send_message",
|
|
data: { sessionID: "ses_x", parts: [{ type: "text", text: "hi" }] },
|
|
})
|
|
|
|
// ACK is sent synchronously before provide even starts
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_1", result: {} })
|
|
|
|
// provide was still called
|
|
await provideStarted
|
|
})
|
|
|
|
test("send_message ACKs before the attachment materializer resolves", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
let resolveMaterialize!: (parts: any[]) => void
|
|
const materializeStarted = new Promise<void>((r) => {
|
|
// signal when the materializer has been invoked
|
|
r()
|
|
})
|
|
const materializeInvoked = Promise.withResolvers<void>()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
attachments: () => ({
|
|
materialize: (parts: readonly any[]) =>
|
|
new Promise<any[]>((resolve) => {
|
|
resolveMaterialize = resolve
|
|
materializeInvoked.resolve()
|
|
// never resolves on its own — proves the ACK is sent first
|
|
}),
|
|
dispose: async () => {},
|
|
}),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_attach_ack",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_attach",
|
|
parts: [{ type: "file", mime: "image/png", filename: "a.png", url: "https://r2.example/a.png" }],
|
|
},
|
|
})
|
|
|
|
// ACK is sent synchronously, BEFORE the materializer (or provide) completes
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_attach_ack", result: {} })
|
|
|
|
// The materializer IS invoked after the ACK, confirming the work is queued
|
|
// but does not block the synchronous response.
|
|
await materializeInvoked.promise
|
|
// Resolve to let the trailing microtask settle.
|
|
resolveMaterialize([])
|
|
await Promise.resolve()
|
|
await materializeStarted
|
|
})
|
|
|
|
test("does not create attachments when delayed send resumes after dispose", async () => {
|
|
const { conn } = fakeConn()
|
|
const bus = fakeBus()
|
|
const entered = Promise.withResolvers<void>()
|
|
const release = Promise.withResolvers<void>()
|
|
const finished = Promise.withResolvers<void>()
|
|
let factories = 0
|
|
let materialized = 0
|
|
let subscriptions = 0
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: (callback) => {
|
|
subscriptions++
|
|
return bus.subscribe(callback)
|
|
},
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
entered.resolve()
|
|
await release.promise
|
|
try {
|
|
return await input.fn()
|
|
} finally {
|
|
finished.resolve()
|
|
}
|
|
},
|
|
prompt: async () => {},
|
|
attachments: () => {
|
|
factories++
|
|
return {
|
|
materialize: async (parts) => {
|
|
materialized++
|
|
return parts
|
|
},
|
|
dispose: async () => {},
|
|
}
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_disposed_attachment",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_disposed_attachment",
|
|
parts: [{ type: "file", mime: "image/png", filename: "a.png", url: "https://example.com/a.png" }],
|
|
},
|
|
})
|
|
await entered.promise
|
|
sender.dispose()
|
|
release.resolve()
|
|
await finished.promise
|
|
|
|
expect(factories).toBe(0)
|
|
expect(materialized).toBe(0)
|
|
expect(subscriptions).toBe(1)
|
|
expect(bus.count()).toBe(0)
|
|
})
|
|
|
|
test("does not create first attachments when delayed send resumes after session deletion", async () => {
|
|
const { conn } = fakeConn()
|
|
const bus = fakeBus()
|
|
const entered = Promise.withResolvers<void>()
|
|
const release = Promise.withResolvers<void>()
|
|
const finished = Promise.withResolvers<void>()
|
|
const again = Promise.withResolvers<void>()
|
|
const cleaned = Promise.withResolvers<void>()
|
|
let factories = 0
|
|
let materialized = 0
|
|
let disposed = 0
|
|
let subscriptions = 0
|
|
const prompts: SessionPrompt.PromptInput["parts"][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: (callback) => {
|
|
subscriptions++
|
|
return bus.subscribe(callback)
|
|
},
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
entered.resolve()
|
|
await release.promise
|
|
try {
|
|
return await input.fn()
|
|
} finally {
|
|
finished.resolve()
|
|
}
|
|
},
|
|
prompt: async (input) => {
|
|
prompts.push(input.parts)
|
|
},
|
|
attachments: () => {
|
|
factories++
|
|
return {
|
|
materialize: async (parts) => {
|
|
materialized++
|
|
again.resolve()
|
|
return parts
|
|
},
|
|
dispose: async () => {
|
|
disposed++
|
|
cleaned.resolve()
|
|
},
|
|
}
|
|
},
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_deleted_attachment",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_deleted_attachment",
|
|
parts: [{ type: "file", mime: "image/png", filename: "a.png", url: "https://example.com/a.png" }],
|
|
},
|
|
})
|
|
await entered.promise
|
|
bus.fire({ type: Session.Event.Deleted.type, properties: { sessionID: "ses_deleted_attachment" } })
|
|
release.resolve()
|
|
await finished.promise
|
|
|
|
expect(factories).toBe(0)
|
|
expect(materialized).toBe(0)
|
|
expect(disposed).toBe(0)
|
|
expect(subscriptions).toBe(1)
|
|
expect(bus.count()).toBe(1)
|
|
expect(prompts[0]).toEqual([
|
|
{ type: "text", text: "attachment a.png could not be retrieved: attachment session is closed" },
|
|
])
|
|
expect(JSON.stringify(prompts[0])).not.toContain("example.com")
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_reused_attachment",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_deleted_attachment",
|
|
parts: [{ type: "file", mime: "image/png", filename: "a.png", url: "https://example.com/a.png" }],
|
|
},
|
|
})
|
|
await again.promise
|
|
expect(factories).toBe(1)
|
|
expect(materialized).toBe(1)
|
|
sender.dispose()
|
|
await cleaned.promise
|
|
expect(disposed).toBe(1)
|
|
})
|
|
|
|
test("keeps materialized scratch owned until an in-flight prompt settles after deletion", async () => {
|
|
const { conn } = fakeConn()
|
|
const bus = fakeBus()
|
|
const started = Promise.withResolvers<void>()
|
|
const release = Promise.withResolvers<void>()
|
|
const cleanupStarted = Promise.withResolvers<void>()
|
|
const cleanupRelease = Promise.withResolvers<void>()
|
|
const cleaned = Promise.withResolvers<void>()
|
|
const repeated = Promise.withResolvers<void>()
|
|
let factories = 0
|
|
let materialized = 0
|
|
let disposed = 0
|
|
const seen: SessionPrompt.PromptInput["parts"][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
prompt: async (input) => {
|
|
seen.push(input.parts)
|
|
if (seen.length > 1) {
|
|
repeated.resolve()
|
|
return
|
|
}
|
|
started.resolve()
|
|
await release.promise
|
|
},
|
|
attachments: () => {
|
|
factories++
|
|
return {
|
|
materialize: async () => {
|
|
materialized++
|
|
return [{ type: "text", text: "attachment saved to /tmp/scratch/file.bin" }]
|
|
},
|
|
dispose: async () => {
|
|
disposed++
|
|
cleanupStarted.resolve()
|
|
await cleanupRelease.promise
|
|
cleaned.resolve()
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_prompt_attachment",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_prompt_attachment",
|
|
parts: [
|
|
{ type: "file", mime: "application/octet-stream", filename: "a.bin", url: "https://example.com/a.bin" },
|
|
],
|
|
},
|
|
})
|
|
await started.promise
|
|
bus.fire({ type: Session.Event.Deleted.type, properties: { sessionID: "ses_prompt_attachment" } })
|
|
|
|
expect(seen[0]).toEqual([{ type: "text", text: "attachment saved to /tmp/scratch/file.bin" }])
|
|
expect(disposed).toBe(0)
|
|
release.resolve()
|
|
await cleanupStarted.promise
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_prompt_attachment_reused",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_prompt_attachment",
|
|
parts: [
|
|
{ type: "file", mime: "application/octet-stream", filename: "a.bin", url: "https://example.com/a.bin" },
|
|
],
|
|
},
|
|
})
|
|
await repeated.promise
|
|
expect(factories).toBe(1)
|
|
expect(materialized).toBe(1)
|
|
expect(disposed).toBe(1)
|
|
expect(seen[1]).toEqual([
|
|
{ type: "text", text: "attachment a.bin could not be retrieved: attachment session is closed" },
|
|
])
|
|
expect(JSON.stringify(seen[1])).not.toContain("example.com")
|
|
|
|
cleanupRelease.resolve()
|
|
await cleaned.promise
|
|
expect(disposed).toBe(1)
|
|
sender.dispose()
|
|
})
|
|
|
|
test("blocks a new attachment generation while idle-cache deletion cleanup runs", async () => {
|
|
const { conn } = fakeConn()
|
|
const bus = fakeBus()
|
|
const first = Promise.withResolvers<void>()
|
|
const cleanupStarted = Promise.withResolvers<void>()
|
|
const cleanupRelease = Promise.withResolvers<void>()
|
|
const cleaned = Promise.withResolvers<void>()
|
|
const repeated = Promise.withResolvers<void>()
|
|
let runs = 0
|
|
let factories = 0
|
|
let materialized = 0
|
|
const seen: SessionPrompt.PromptInput["parts"][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
try {
|
|
return await input.fn()
|
|
} finally {
|
|
runs++
|
|
if (runs === 1) first.resolve()
|
|
}
|
|
},
|
|
prompt: async (input) => {
|
|
seen.push(input.parts)
|
|
if (seen.length === 2) repeated.resolve()
|
|
},
|
|
attachments: () => {
|
|
factories++
|
|
return {
|
|
materialize: async () => {
|
|
materialized++
|
|
return [{ type: "text", text: "attachment saved to /tmp/scratch/file.bin" }]
|
|
},
|
|
dispose: async () => {
|
|
cleanupStarted.resolve()
|
|
await cleanupRelease.promise
|
|
cleaned.resolve()
|
|
},
|
|
}
|
|
},
|
|
})
|
|
const send = (id: string) =>
|
|
sender.handle({
|
|
type: "command",
|
|
id,
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_idle_cleanup",
|
|
parts: [
|
|
{ type: "file", mime: "application/octet-stream", filename: "a.bin", url: "https://example.com/a.bin" },
|
|
],
|
|
},
|
|
})
|
|
|
|
send("req_idle_first")
|
|
await first.promise
|
|
bus.fire({ type: Session.Event.Deleted.type, properties: { sessionID: "ses_idle_cleanup" } })
|
|
await cleanupStarted.promise
|
|
send("req_idle_repeated")
|
|
await repeated.promise
|
|
|
|
expect(factories).toBe(1)
|
|
expect(materialized).toBe(1)
|
|
expect(seen[1]).toEqual([
|
|
{ type: "text", text: "attachment a.bin could not be retrieved: attachment session is closed" },
|
|
])
|
|
expect(JSON.stringify(seen[1])).not.toContain("example.com")
|
|
cleanupRelease.resolve()
|
|
await cleaned.promise
|
|
sender.dispose()
|
|
})
|
|
|
|
test("send_message keeps client toggles persistent and terminal restriction ephemeral", async () => {
|
|
const { conn } = fakeConn()
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
prompt: prompts(calls),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_remote_tools",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hi" }],
|
|
tools: { bash: true },
|
|
},
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(calls[0]?.tools).toEqual({ bash: true })
|
|
expect(calls[0]?.ephemeralTools).toEqual({ interactive_terminal: false })
|
|
})
|
|
|
|
test("interrupt waits for session cancellation before responding", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
let finishCancel: () => void
|
|
const cancelled: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
cancel: async (sessionID) => {
|
|
cancelled.push(sessionID)
|
|
await new Promise<void>((resolve) => {
|
|
finishCancel = resolve
|
|
})
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_interrupt",
|
|
command: "interrupt",
|
|
sessionId: "ses_x",
|
|
data: {},
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(cancelled).toEqual(["ses_x"])
|
|
expect(sent).toEqual([])
|
|
|
|
finishCancel!()
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
expect(sent).toEqual([{ type: "response", id: "req_interrupt", result: {} }])
|
|
})
|
|
|
|
test("send_message with invalid data sends error response immediately", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async () => ({}) as any,
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_bad",
|
|
command: "send_message",
|
|
data: { invalid: true },
|
|
})
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].type).toBe("response")
|
|
expect(sent[0].id).toBe("req_bad")
|
|
expect(sent[0].error).toContain("invalid send_message data")
|
|
})
|
|
|
|
test("unknown command sends error response with matching id", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async () => ({}) as any,
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_unknown",
|
|
command: "unknown_command",
|
|
data: { foo: "bar" },
|
|
} as RemoteProtocol.Command)
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toEqual({
|
|
type: "response",
|
|
id: "req_unknown",
|
|
error: "unknown command: unknown_command",
|
|
})
|
|
})
|
|
|
|
test("list_models returns the effective catalog from the exact session directory", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
return input.fn()
|
|
},
|
|
catalog: {
|
|
get: async () =>
|
|
({
|
|
id: SessionID.make("ses_models"),
|
|
directory: "/workspace/project-a",
|
|
model: {
|
|
id: ModelV2.ID.make("deployment/model"),
|
|
providerID: ProviderV2.ID.make("custom"),
|
|
variant: "precise",
|
|
},
|
|
}) as any,
|
|
messages: async () => [],
|
|
providers: async () =>
|
|
({
|
|
custom: {
|
|
id: ProviderV2.ID.make("custom"),
|
|
name: "Custom Provider",
|
|
source: "config",
|
|
env: ["PRIVATE_API_KEY"],
|
|
key: "must-not-leak",
|
|
options: { apiKey: "must-not-leak" },
|
|
models: {
|
|
"deployment/model": catalogModel("custom", "deployment/model", "Deployment Model", true),
|
|
},
|
|
},
|
|
}) as any,
|
|
default: async () => ({
|
|
providerID: ProviderV2.ID.make("custom"),
|
|
modelID: ModelV2.ID.make("deployment/model"),
|
|
}),
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models",
|
|
command: "list_models",
|
|
sessionId: "ses_models",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(dirs).toEqual(["/workspace/project-a"])
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]?.type).toBe("response")
|
|
expect(sent[0]?.id).toBe("req_models")
|
|
const result = sent[0]?.result as RemoteModelCatalog.Response
|
|
expect(result.all).toHaveLength(1)
|
|
expect(result.all[0]?.id).toBe("custom")
|
|
expect(result.all[0]?.env).toEqual([])
|
|
expect(result.all[0]?.options).toEqual({})
|
|
expect(result.all[0]?.models["deployment/model"]?.variants).toEqual({ precise: {} })
|
|
expect(result.default).toEqual({ custom: "deployment/model" })
|
|
expect(result.connected).toEqual(["custom"])
|
|
expect(result.failed).toEqual([])
|
|
expect(result.currentModel).toEqual({
|
|
model: { providerID: "custom", modelID: "deployment/model" },
|
|
variant: "precise",
|
|
})
|
|
expect(result.defaultModel).toEqual({ providerID: "custom", modelID: "deployment/model" })
|
|
expect(result.truncated).toBe(false)
|
|
expect(JSON.stringify(result)).not.toContain("must-not-leak")
|
|
expect(JSON.stringify(result)).not.toContain("private.example.com")
|
|
})
|
|
|
|
test("list_models scopes provider discovery to each session directory", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const state = { directory: "" }
|
|
const messages: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => {
|
|
state.directory = input.directory
|
|
const result = await input.fn()
|
|
state.directory = ""
|
|
return result
|
|
},
|
|
catalog: {
|
|
get: async (sessionID) =>
|
|
({
|
|
id: sessionID,
|
|
directory: sessionID === SessionID.make("ses_first") ? "/workspace/first" : "/workspace/second",
|
|
}) as any,
|
|
messages: async (sessionID) => {
|
|
messages.push(sessionID)
|
|
return []
|
|
},
|
|
providers: async () => {
|
|
const id = state.directory === "/workspace/first" ? "first-provider" : "second-provider"
|
|
return {
|
|
[id]: {
|
|
id: ProviderV2.ID.make(id),
|
|
name: id,
|
|
source: "custom",
|
|
env: [],
|
|
options: {},
|
|
models: {
|
|
model: catalogModel(id, "model", "Model"),
|
|
},
|
|
},
|
|
} as any
|
|
},
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_first",
|
|
command: "list_models",
|
|
sessionId: "ses_first",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_second",
|
|
command: "list_models",
|
|
sessionId: "ses_second",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(sent.map((message) => message.result?.all[0]?.id)).toEqual(["first-provider", "second-provider"])
|
|
expect(messages).toEqual([SessionID.make("ses_first"), SessionID.make("ses_second")])
|
|
})
|
|
|
|
test("list_models tolerates unavailable provider default resolution", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
catalog: {
|
|
get: async () => ({ id: SessionID.make("ses_models"), directory: "/workspace/project-a" }) as any,
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => {
|
|
throw new Error("no provider default")
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_no_default",
|
|
command: "list_models",
|
|
sessionId: "ses_models",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "response",
|
|
id: "req_models_no_default",
|
|
result: {
|
|
all: [],
|
|
default: {},
|
|
connected: [],
|
|
failed: [],
|
|
protocolVersion: 1,
|
|
truncated: false,
|
|
},
|
|
},
|
|
])
|
|
})
|
|
|
|
test("list_models logs a warning when provider default resolution fails", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const warnings: any[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: { ...nolog, warn: (...args: any[]) => warnings.push(args) },
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
catalog: {
|
|
get: async () => ({ id: SessionID.make("ses_models"), directory: "/workspace/project-a" }) as any,
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => {
|
|
throw new Error("no provider default")
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_warn_default",
|
|
command: "list_models",
|
|
sessionId: "ses_models",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(sent[0]?.result?.defaultModel).toBeUndefined()
|
|
expect(warnings).toHaveLength(1)
|
|
expect(warnings[0]?.[0]).toBe("default model lookup failed")
|
|
expect(String(warnings[0]?.[1]?.error)).toContain("no provider default")
|
|
})
|
|
|
|
test("list_models never falls back to the process directory for an unknown session", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
return input.fn()
|
|
},
|
|
catalog: {
|
|
get: async () => {
|
|
throw new Error("session not found")
|
|
},
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_missing",
|
|
command: "list_models",
|
|
sessionId: "ses_missing",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(dirs).toEqual([])
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "response",
|
|
id: "req_models_missing",
|
|
error: "failed to list models",
|
|
},
|
|
])
|
|
})
|
|
|
|
test("list_models returns one generic error when provider discovery fails", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
catalog: {
|
|
get: async () => ({ id: SessionID.make("ses_models"), directory: "/workspace/project-a" }) as any,
|
|
messages: async () => [],
|
|
providers: async () => {
|
|
throw new Error("private provider failure with api-key")
|
|
},
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_failed",
|
|
command: "list_models",
|
|
sessionId: "ses_models",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "response",
|
|
id: "req_models_failed",
|
|
error: "failed to list models",
|
|
},
|
|
])
|
|
expect(JSON.stringify(sent)).not.toContain("api-key")
|
|
})
|
|
|
|
test("list_models rejects unsupported versions and undecodable session IDs", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_v2",
|
|
command: "list_models",
|
|
sessionId: "ses_models",
|
|
data: { protocolVersion: 2 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_invalid_session",
|
|
command: "list_models",
|
|
sessionId: "not-a-session-id",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_models_v2", error: "invalid list_models command" },
|
|
{ type: "response", id: "req_models_invalid_session", error: "invalid list_models command" },
|
|
])
|
|
})
|
|
|
|
test("list_models without a sessionId returns the instance catalog", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
return input.fn()
|
|
},
|
|
catalog: {
|
|
get: async () => {
|
|
throw new Error("catalog.get must not be called without a sessionId")
|
|
},
|
|
messages: async () => {
|
|
throw new Error("catalog.messages must not be called without a sessionId")
|
|
},
|
|
providers: async () =>
|
|
({
|
|
custom: {
|
|
id: ProviderV2.ID.make("custom"),
|
|
name: "Custom Provider",
|
|
source: "config",
|
|
env: ["PRIVATE_API_KEY"],
|
|
key: "must-not-leak",
|
|
options: { apiKey: "must-not-leak" },
|
|
models: {
|
|
"deployment/model": catalogModel("custom", "deployment/model", "Deployment Model", true),
|
|
},
|
|
},
|
|
}) as any,
|
|
default: async () => ({
|
|
providerID: ProviderV2.ID.make("custom"),
|
|
modelID: ModelV2.ID.make("deployment/model"),
|
|
}),
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_models_sessionless",
|
|
command: "list_models",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(dirs).toEqual(["/tmp/process-default"])
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]?.type).toBe("response")
|
|
expect(sent[0]?.id).toBe("req_models_sessionless")
|
|
const result = sent[0]?.result as RemoteModelCatalog.Response
|
|
expect(result.protocolVersion).toBe(1)
|
|
expect(result.all).toHaveLength(1)
|
|
expect(result.all[0]?.id).toBe("custom")
|
|
expect(result.defaultModel).toEqual({ providerID: "custom", modelID: "deployment/model" })
|
|
expect(result).not.toHaveProperty("currentModel")
|
|
expect(JSON.stringify(result)).not.toContain("must-not-leak")
|
|
})
|
|
|
|
test("send_message with agent is accepted", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
let resolveProvide: () => void
|
|
const provideStarted = new Promise<void>((r) => {
|
|
resolveProvide = r
|
|
})
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async (input: any) => {
|
|
resolveProvide!()
|
|
await new Promise(() => {})
|
|
return {} as any
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_model",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hello" }],
|
|
agent: "plan",
|
|
},
|
|
})
|
|
|
|
// ACK sent (not error) — model and agent were accepted by PromptInput validation
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_model", result: {} })
|
|
|
|
await provideStarted
|
|
})
|
|
|
|
// kilocode_change start
|
|
test("send_message normalizes string model without prefix", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
prompt: prompts(calls),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_model_string",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: "anthropic/claude-sonnet-4-20250514",
|
|
},
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_model_string", result: {} })
|
|
expect(calls).toEqual([
|
|
{
|
|
sessionID: SessionID.make("ses_x"),
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: {
|
|
providerID: ProviderV2.ID.make("kilo"),
|
|
modelID: ModelV2.ID.make("anthropic/claude-sonnet-4-20250514"),
|
|
},
|
|
ephemeralTools: { interactive_terminal: false },
|
|
},
|
|
])
|
|
})
|
|
|
|
test("send_message keeps kilocode-prefixed model unchanged before internal conversion", async () => {
|
|
const { conn } = fakeConn()
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
prompt: prompts(calls),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_model_kilocode",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: "kilocode/gpt-5-mini",
|
|
},
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(calls).toEqual([
|
|
{
|
|
sessionID: SessionID.make("ses_x"),
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: { providerID: ProviderV2.ID.make("kilo"), modelID: ModelV2.ID.make("gpt-5-mini") },
|
|
ephemeralTools: { interactive_terminal: false },
|
|
},
|
|
])
|
|
})
|
|
|
|
test("send_message preserves a structured provider and model", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
prompt: prompts(calls),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_model_structured",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: { providerID: "custom:edge", modelID: "deployment/model-v1" },
|
|
variant: "precise",
|
|
},
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_model_structured", result: {} })
|
|
expect(calls).toEqual([
|
|
{
|
|
sessionID: SessionID.make("ses_x"),
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: {
|
|
providerID: ProviderV2.ID.make("custom:edge"),
|
|
modelID: ModelV2.ID.make("deployment/model-v1"),
|
|
},
|
|
ephemeralTools: { interactive_terminal: false },
|
|
variant: "precise",
|
|
},
|
|
])
|
|
})
|
|
|
|
test("send_message rejects invalid structured model identities before ACK", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
prompt: prompts(calls),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_model_invalid",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: { providerID: "", modelID: "deployment/model-v1" },
|
|
},
|
|
})
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]?.error).toContain("invalid send_message data")
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
|
|
test("send_message leaves model and variant omitted for CLI precedence", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
prompt: prompts(calls),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_model_omitted",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hello" }],
|
|
agent: "configured-agent",
|
|
},
|
|
})
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_model_omitted", result: {} })
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]?.model).toBeUndefined()
|
|
expect(calls[0]?.variant).toBeUndefined()
|
|
})
|
|
|
|
test("send_message does not special-case kilo-prefixed model", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
prompt: prompts(calls),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_model_kilo",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: "ses_x",
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: "kilo/gpt-5-mini",
|
|
},
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 0))
|
|
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_model_kilo", result: {} })
|
|
expect(calls).toEqual([
|
|
{
|
|
sessionID: SessionID.make("ses_x"),
|
|
parts: [{ type: "text", text: "hello" }],
|
|
model: { providerID: ProviderV2.ID.make("kilo"), modelID: ModelV2.ID.make("kilo/gpt-5-mini") },
|
|
ephemeralTools: { interactive_terminal: false },
|
|
},
|
|
])
|
|
})
|
|
// kilocode_change end
|
|
|
|
test("question_reply sends response after work completes", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: Parameters<Question.Interface["reply"]>[0][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
question: {
|
|
...questions(),
|
|
reply: async (input) => {
|
|
calls.push(input)
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_q",
|
|
command: "question_reply",
|
|
data: { requestID: "que_r1", answers: [["yes"]] },
|
|
})
|
|
|
|
// Response not sent synchronously - waits for provide to finish.
|
|
expect(sent).toHaveLength(0)
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
expect(calls).toEqual([{ requestID: QuestionID.make("que_r1"), answers: [["yes"]] }])
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_q", result: {} })
|
|
})
|
|
|
|
test("permission_respond sends response after work completes", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: Permission.ReplyInput[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
permission: {
|
|
list: async () => [],
|
|
reply: async (input) => {
|
|
calls.push(input)
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_permission",
|
|
command: "permission_respond",
|
|
data: { requestID: PermissionV1.ID.make("permission_1"), reply: "once" },
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
expect(calls).toEqual([{ requestID: PermissionV1.ID.make("permission_1"), reply: "once" }])
|
|
expect(sent).toContainEqual({ type: "response", id: "req_permission", result: {} })
|
|
})
|
|
|
|
test("question_reply error sends error response", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
question: {
|
|
...questions(),
|
|
reply: async () => {
|
|
throw new Error("boom")
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_qe",
|
|
command: "question_reply",
|
|
data: { requestID: "que_r1", answers: [["yes"]] },
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].type).toBe("response")
|
|
expect(sent[0].id).toBe("req_qe")
|
|
expect(sent[0].error).toContain("boom")
|
|
})
|
|
|
|
test("question_reply reports unknown request errors", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
question: {
|
|
...questions(),
|
|
reply: async (input) => {
|
|
throw new Question.NotFoundError({ requestID: input.requestID })
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_q_missing",
|
|
command: "question_reply",
|
|
data: { requestID: "que_missing", answers: [["yes"]] },
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].type).toBe("response")
|
|
expect(sent[0].id).toBe("req_q_missing")
|
|
expect(sent[0].error).toContain("Question.NotFoundError")
|
|
})
|
|
|
|
test("suggestion_accept sends response after work completes", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const accept = spyOn(Suggestion, "accept").mockResolvedValue(true)
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_suggestion_accept",
|
|
command: "suggestion_accept",
|
|
data: { requestID: "sug_1", index: 1 },
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
expect(accept).toHaveBeenCalledWith({ requestID: "sug_1", index: 1 })
|
|
expect(sent).toContainEqual({ type: "response", id: "req_suggestion_accept", result: {} })
|
|
})
|
|
|
|
test("suggestion_dismiss with invalid data sends error response", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async () => ({}) as any,
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_suggestion_dismiss_bad",
|
|
command: "suggestion_dismiss",
|
|
data: { nope: true },
|
|
})
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].error).toContain("invalid suggestion_dismiss data")
|
|
})
|
|
|
|
test("question_reject sends response after work completes", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: QuestionID[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
question: {
|
|
...questions(),
|
|
reject: async (requestID) => {
|
|
calls.push(requestID)
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_qr",
|
|
command: "question_reject",
|
|
data: { requestID: "que_r1" },
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
expect(calls).toEqual([QuestionID.make("que_r1")])
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toEqual({ type: "response", id: "req_qr", result: {} })
|
|
})
|
|
|
|
test("question_reject reports unknown request errors", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
|
question: {
|
|
...questions(),
|
|
reject: async (requestID) => {
|
|
throw new Question.NotFoundError({ requestID })
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_qr_missing",
|
|
command: "question_reject",
|
|
data: { requestID: "que_missing" },
|
|
})
|
|
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].type).toBe("response")
|
|
expect(sent[0].id).toBe("req_qr_missing")
|
|
expect(sent[0].error).toContain("Question.NotFoundError")
|
|
})
|
|
|
|
test("question_reject with invalid data sends error response", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async () => ({}) as any,
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_qr_bad",
|
|
command: "question_reject",
|
|
data: { wrong: true },
|
|
} as any)
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].type).toBe("response")
|
|
expect(sent[0].id).toBe("req_qr_bad")
|
|
expect(sent[0].error).toContain("invalid question_reject data")
|
|
})
|
|
|
|
test("events without sessionID are not forwarded", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_a" })
|
|
|
|
bus.fire({ type: "server.connected", properties: {} })
|
|
bus.fire({ type: "lsp.updated", properties: undefined })
|
|
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test("dispose clears all subscriptions", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_a" })
|
|
sender.handle({ type: "subscribe", sessionId: "ses_b" })
|
|
|
|
sender.dispose()
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_a", text: "hello" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(0)
|
|
expect(bus.count()).toBe(0)
|
|
})
|
|
|
|
test("child session events forwarded when parent subscribed", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_parent" })
|
|
|
|
// Child session created with parentID
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_child", parentID: "ses_parent", title: "sub" }, sessionID: "ses_child" },
|
|
})
|
|
|
|
// Event on the child session should be forwarded
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_child", text: "from child" },
|
|
})
|
|
|
|
// session.created + message.updated
|
|
expect(sent).toHaveLength(2)
|
|
expect(sent[0].sessionId).toBe("ses_child")
|
|
expect(sent[0].parentSessionId).toBe("ses_parent")
|
|
expect(sent[0].event).toBe("session.created")
|
|
expect(sent[1].sessionId).toBe("ses_child")
|
|
expect(sent[1].parentSessionId).toBe("ses_parent")
|
|
expect(sent[1].event).toBe("message.updated")
|
|
})
|
|
|
|
test("child session events not forwarded when parent not subscribed", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_other" })
|
|
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_child", parentID: "ses_unrelated", title: "sub" }, sessionID: "ses_child" },
|
|
})
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_child", text: "from child" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test("unsubscribe parent cleans up child tracking", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_parent" })
|
|
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_child", parentID: "ses_parent", title: "sub" }, sessionID: "ses_child" },
|
|
})
|
|
|
|
sender.handle({ type: "unsubscribe", sessionId: "ses_parent" })
|
|
|
|
// Keep another session alive so bus stays subscribed
|
|
sender.handle({ type: "subscribe", sessionId: "ses_keep" })
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_child", text: "after unsub" },
|
|
})
|
|
|
|
expect(sent.filter((m: any) => m.event === "message.updated")).toHaveLength(0)
|
|
})
|
|
|
|
test("unsubscribe parent cleans up grandchild tracking", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_root" })
|
|
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_child", parentID: "ses_root", title: "child" }, sessionID: "ses_child" },
|
|
})
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "ses_grandchild", parentID: "ses_child", title: "grandchild" },
|
|
sessionID: "ses_grandchild",
|
|
},
|
|
})
|
|
|
|
sender.handle({ type: "unsubscribe", sessionId: "ses_root" })
|
|
sender.handle({ type: "subscribe", sessionId: "ses_keep" })
|
|
|
|
// Clear events from subscribe/session.created
|
|
sent.length = 0
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_child", text: "after unsub" },
|
|
})
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_grandchild", text: "after unsub" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
|
|
test("root session events do not include parentSessionId", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_root" })
|
|
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_root", text: "hello" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0].sessionId).toBe("ses_root")
|
|
expect(sent[0]).not.toHaveProperty("parentSessionId")
|
|
})
|
|
|
|
test("nested child events include root parentSessionId", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_root" })
|
|
|
|
// Root → child
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_child", parentID: "ses_root", title: "child" }, sessionID: "ses_child" },
|
|
})
|
|
|
|
// child → grandchild
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: {
|
|
info: { id: "ses_grandchild", parentID: "ses_child", title: "grandchild" },
|
|
sessionID: "ses_grandchild",
|
|
},
|
|
})
|
|
|
|
// Event on grandchild should have parentSessionId pointing to root
|
|
bus.fire({
|
|
type: "message.updated",
|
|
properties: { sessionID: "ses_grandchild", text: "from grandchild" },
|
|
})
|
|
|
|
// 3 events: session.created (child), session.created (grandchild), message.updated (grandchild)
|
|
expect(sent).toHaveLength(3)
|
|
expect(sent[0].parentSessionId).toBe("ses_root")
|
|
expect(sent[1].parentSessionId).toBe("ses_root")
|
|
expect(sent[2].parentSessionId).toBe("ses_root")
|
|
expect(sent[2].sessionId).toBe("ses_grandchild")
|
|
})
|
|
|
|
test("subscribe triggers backfill of existing children", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
})
|
|
|
|
// backfill calls Session.children which requires real DB context.
|
|
// Our provide mock just calls fn() directly, so Session.children will fail.
|
|
// The backfill logs the error silently and doesn't break normal operation.
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_parent" })
|
|
|
|
// Wait for async backfill to attempt (and fail silently in test context)
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
// Normal event forwarding still works
|
|
bus.fire({
|
|
type: "session.created",
|
|
properties: { info: { id: "ses_new_child", parentID: "ses_parent", title: "new" }, sessionID: "ses_new_child" },
|
|
})
|
|
|
|
expect(sent.filter((m: any) => m.event === "session.created")).toHaveLength(1)
|
|
expect(sent[0].sessionId).toBe("ses_new_child")
|
|
expect(sent[0].parentSessionId).toBe("ses_parent")
|
|
})
|
|
|
|
test("subscribe replays pending question for the subscribed session", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
|
|
spyOn(Suggestion, "list").mockResolvedValue([])
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
permission: permissions(),
|
|
question: questions([
|
|
{ id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] } as any,
|
|
{ id: "question_2", sessionID: "ses_other", questions: [{ type: "text", text: "Unrelated?" }] } as any,
|
|
]),
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_target" })
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
const questionEvents = sent.filter((m: any) => m.event === "question.asked")
|
|
expect(questionEvents).toHaveLength(1)
|
|
expect(questionEvents[0]).toEqual({
|
|
type: "event",
|
|
sessionId: "ses_target",
|
|
event: "question.asked",
|
|
data: { id: "question_1", sessionID: "ses_target", questions: [{ type: "text", text: "Continue?" }] },
|
|
})
|
|
})
|
|
|
|
test("subscribe replays pending permission for the subscribed session", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
|
|
spyOn(Suggestion, "list").mockResolvedValue([])
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
question: questions(),
|
|
permission: permissions([
|
|
{
|
|
id: "permission_1",
|
|
sessionID: "ses_target",
|
|
permission: "file.write",
|
|
patterns: ["src/**"],
|
|
metadata: {},
|
|
always: [],
|
|
} as any,
|
|
{
|
|
id: "permission_2",
|
|
sessionID: "ses_other",
|
|
permission: "file.read",
|
|
patterns: ["*"],
|
|
metadata: {},
|
|
always: [],
|
|
} as any,
|
|
]),
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_target" })
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
const permEvents = sent.filter((m: any) => m.event === "permission.asked")
|
|
expect(permEvents).toHaveLength(1)
|
|
expect(permEvents[0]).toEqual({
|
|
type: "event",
|
|
sessionId: "ses_target",
|
|
event: "permission.asked",
|
|
data: {
|
|
id: "permission_1",
|
|
sessionID: "ses_target",
|
|
permission: "file.write",
|
|
patterns: ["src/**"],
|
|
metadata: {},
|
|
always: [],
|
|
},
|
|
})
|
|
})
|
|
|
|
test("child permission replay includes the subscribed root session", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const child = {
|
|
id: SessionID.make("ses_child"),
|
|
parentID: SessionID.make("ses_root"),
|
|
directory: "/workspace/child",
|
|
} as Session.Info
|
|
spyOn(Suggestion, "list").mockResolvedValue([])
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/workspace/root",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) =>
|
|
sessionID === child.id ? child : ({ id: sessionID, directory: "/workspace/root" } as Session.Info),
|
|
children: async (sessionID) => (sessionID === SessionID.make("ses_root") ? [child] : []),
|
|
},
|
|
question: questions(),
|
|
permission: permissions([
|
|
{
|
|
id: "permission_child",
|
|
sessionID: "ses_child",
|
|
permission: "external_directory",
|
|
patterns: ["/workspace/child/**"],
|
|
metadata: {},
|
|
always: [],
|
|
} as any,
|
|
]),
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_root" })
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
expect(sent).toContainEqual({
|
|
type: "event",
|
|
sessionId: "ses_child",
|
|
parentSessionId: "ses_root",
|
|
event: "permission.asked",
|
|
data: {
|
|
id: "permission_child",
|
|
sessionID: "ses_child",
|
|
permission: "external_directory",
|
|
patterns: ["/workspace/child/**"],
|
|
metadata: {},
|
|
always: [],
|
|
},
|
|
})
|
|
})
|
|
|
|
test("subscribe does not replay state for sessions with no pending questions or permissions", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
|
|
spyOn(Suggestion, "list").mockResolvedValue([
|
|
{ id: "sug_1", sessionID: "ses_other", text: "Review?", actions: [] } as any,
|
|
])
|
|
// Queue snapshot is always replayed, even when empty
|
|
spyOn(KiloSessionPromptQueue, "snapshot").mockReturnValue([])
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
question: questions([{ id: "question_1", sessionID: "ses_other", questions: [] } as any]),
|
|
permission: permissions([
|
|
{
|
|
id: "permission_1",
|
|
sessionID: "ses_other",
|
|
permission: "file.write",
|
|
patterns: [],
|
|
metadata: {},
|
|
always: [],
|
|
} as any,
|
|
]),
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_target" })
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
// No question/permission/suggestion events for the subscribed session, but
|
|
// the queue snapshot replay always fires (here, an empty list) so a
|
|
// resubscribing client can reconcile stale "Queued" badges.
|
|
const replayed = sent.filter((m: any) => m.type === "event")
|
|
expect(replayed).toEqual([
|
|
{
|
|
type: "event",
|
|
sessionId: "ses_target",
|
|
event: "session.queue.changed",
|
|
data: { sessionID: "ses_target", queued: [] },
|
|
},
|
|
])
|
|
})
|
|
|
|
// Queue snapshot replay-on-subscribe coverage
|
|
test("subscribe always replays the current queue snapshot, including empty", async () => {
|
|
// A resubscribing/reconnecting client must see the authoritative queue
|
|
// state immediately, even when the session has no queued messages. This
|
|
// is what lets mobile reconcile a stale "Queued" badge away.
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
|
|
spyOn(Suggestion, "list").mockResolvedValue([])
|
|
spyOn(KiloSessionPromptQueue, "snapshot").mockReturnValue([])
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
question: questions(),
|
|
permission: permissions(),
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_target" })
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
const queueEvents = sent.filter((m: any) => m.event === "session.queue.changed")
|
|
expect(queueEvents).toEqual([
|
|
{
|
|
type: "event",
|
|
sessionId: "ses_target",
|
|
event: "session.queue.changed",
|
|
data: { sessionID: "ses_target", queued: [] },
|
|
},
|
|
])
|
|
expect(KiloSessionPromptQueue.snapshot).toHaveBeenCalledWith(SessionID.make("ses_target"))
|
|
})
|
|
|
|
test("subscribe replays a non-empty queue snapshot for the subscribed session", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
|
|
spyOn(Suggestion, "list").mockResolvedValue([])
|
|
spyOn(KiloSessionPromptQueue, "snapshot").mockReturnValue(["msg_a", "msg_b"] as any)
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
question: questions(),
|
|
permission: permissions(),
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_target" })
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
const queueEvents = sent.filter((m: any) => m.event === "session.queue.changed")
|
|
expect(queueEvents).toEqual([
|
|
{
|
|
type: "event",
|
|
sessionId: "ses_target",
|
|
event: "session.queue.changed",
|
|
data: { sessionID: "ses_target", queued: ["msg_a", "msg_b"] },
|
|
},
|
|
])
|
|
})
|
|
|
|
test("subscribe replays pending suggestion for the subscribed session", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const bus = fakeBus()
|
|
|
|
spyOn(Suggestion, "list").mockResolvedValue([
|
|
{
|
|
id: "sug_1",
|
|
sessionID: "ses_target",
|
|
text: "Continue?",
|
|
actions: [{ label: "Continue", prompt: "Continue with the task" }],
|
|
} as any,
|
|
{
|
|
id: "sug_2",
|
|
sessionID: "ses_other",
|
|
text: "Ignore",
|
|
actions: [{ label: "Skip", prompt: "skip" }],
|
|
} as any,
|
|
])
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: bus.subscribe,
|
|
provide: async (input: any) => input.fn(),
|
|
permission: permissions(),
|
|
question: questions(),
|
|
})
|
|
|
|
sender.handle({ type: "subscribe", sessionId: "ses_target" })
|
|
await new Promise((r) => setTimeout(r, 10))
|
|
|
|
const suggestionEvents = sent.filter((m: any) => m.event === "suggestion.shown")
|
|
expect(suggestionEvents).toHaveLength(1)
|
|
expect(suggestionEvents[0]).toEqual({
|
|
type: "event",
|
|
sessionId: "ses_target",
|
|
event: "suggestion.shown",
|
|
data: {
|
|
id: "sug_1",
|
|
sessionID: "ses_target",
|
|
text: "Continue?",
|
|
actions: [{ label: "Continue", prompt: "Continue with the task" }],
|
|
},
|
|
})
|
|
})
|
|
|
|
test("system message is handled without error", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
})
|
|
|
|
sender.handle({
|
|
type: "system",
|
|
event: "cli.connected",
|
|
data: { version: "1.0" },
|
|
})
|
|
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
// kilocode_change start - remote slash command discovery and execution
|
|
describe("RemoteSender slash commands", () => {
|
|
// Wraps a Connection to expose a promise that resolves when a response with the
|
|
// given id is sent. Keeps new tests deterministic without setTimeout polling.
|
|
// Wrappers chain so multiple pending responses can be awaited on the same
|
|
// connection; the original fakeConn.send still records each emission once.
|
|
function expectResponse(conn: RemoteWS.Connection, _sent: any[], id: string) {
|
|
const resolvers = Promise.withResolvers<RemoteProtocol.Outbound>()
|
|
const previous = conn.send.bind(conn)
|
|
const spy = (message: RemoteProtocol.Outbound) => {
|
|
if (message?.type === "response" && message.id === id) resolvers.resolve(message)
|
|
previous(message)
|
|
}
|
|
conn.send = spy
|
|
return {
|
|
promise: resolvers.promise,
|
|
restore: () => {
|
|
conn.send = previous
|
|
},
|
|
}
|
|
}
|
|
|
|
test("list_commands validates v1 and returns the bounded catalog from the target session directory", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const state = { directory: "" }
|
|
const first = expectResponse(conn, sent, "req_commands_first")
|
|
const second = expectResponse(conn, sent, "req_commands_second")
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
state.directory = input.directory
|
|
const result = await input.fn()
|
|
state.directory = ""
|
|
return result
|
|
},
|
|
commands: {
|
|
list: async () => ({
|
|
protocolVersion: 1 as const,
|
|
commands: [{ name: state.directory.endsWith("first") ? "first" : "second", hints: [] }],
|
|
}),
|
|
execute: async () => {},
|
|
},
|
|
catalog: {
|
|
get: async (sessionID) =>
|
|
({
|
|
id: sessionID,
|
|
directory: sessionID === SessionID.make("ses_first") ? "/workspace/first" : "/workspace/second",
|
|
}) as any,
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_commands_first",
|
|
command: "list_commands",
|
|
sessionId: "ses_first",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await first.promise
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_commands_second",
|
|
command: "list_commands",
|
|
sessionId: "ses_second",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await second.promise
|
|
|
|
first.restore()
|
|
second.restore()
|
|
|
|
expect(dirs).toEqual(["/workspace/first", "/workspace/second"])
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "response",
|
|
id: "req_commands_first",
|
|
result: { protocolVersion: 1, commands: [{ name: "first", hints: [] }] },
|
|
},
|
|
{
|
|
type: "response",
|
|
id: "req_commands_second",
|
|
result: { protocolVersion: 1, commands: [{ name: "second", hints: [] }] },
|
|
},
|
|
])
|
|
})
|
|
|
|
test("list_commands rejects unsupported protocol versions and missing session IDs before ACK", () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/test",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
commands: {
|
|
list: async () => ({ protocolVersion: 1 as const, commands: [] }),
|
|
execute: async () => {},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_commands_v2",
|
|
command: "list_commands",
|
|
sessionId: "ses_x",
|
|
data: { protocolVersion: 2 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_commands_no_session",
|
|
command: "list_commands",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_commands_invalid_session",
|
|
command: "list_commands",
|
|
sessionId: "not-a-session-id",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_commands_v2", error: "invalid list_commands request" },
|
|
{ type: "response", id: "req_commands_no_session", error: "invalid list_commands request" },
|
|
{ type: "response", id: "req_commands_invalid_session", error: "invalid list_commands request" },
|
|
])
|
|
})
|
|
|
|
test("send_command validates protocol, session, and catalog membership before ACK", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: unknown[] = []
|
|
const started = Promise.withResolvers<void>()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
commands: {
|
|
list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }),
|
|
execute: async (input) => {
|
|
calls.push(input)
|
|
started.resolve()
|
|
await new Promise(() => {})
|
|
},
|
|
},
|
|
catalog: {
|
|
get: async () => ({ id: SessionID.make("ses_commands"), directory: "/workspace/project-a" }) as any,
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
// Pre-ACK: invalid protocol
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_bad_version",
|
|
command: "send_command",
|
|
sessionId: "ses_commands",
|
|
data: { protocolVersion: 2, command: "review", arguments: "main" },
|
|
})
|
|
// Pre-ACK: missing session id
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_no_session",
|
|
command: "send_command",
|
|
data: { protocolVersion: 1, command: "review", arguments: "main" },
|
|
})
|
|
|
|
// Wait for the async catalog preflight that rejects the unknown command and ACKs the valid one.
|
|
const unknownResponse = expectResponse(conn, sent, "req_unknown")
|
|
const ackResponse = expectResponse(conn, sent, "req_send_command")
|
|
|
|
// Pre-ACK: command not in catalog
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_unknown",
|
|
command: "send_command",
|
|
sessionId: "ses_commands",
|
|
data: { protocolVersion: 1, command: "missing", arguments: "main" },
|
|
})
|
|
// Valid path: ACK first
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_send_command",
|
|
command: "send_command",
|
|
sessionId: "ses_commands",
|
|
data: {
|
|
protocolVersion: 1,
|
|
command: "review",
|
|
arguments: " main ",
|
|
messageID: "msg_remote",
|
|
},
|
|
})
|
|
|
|
expect(sent.slice(0, 2)).toEqual([
|
|
{ type: "response", id: "req_bad_version", error: "invalid send_command request" },
|
|
{ type: "response", id: "req_no_session", error: "invalid send_command request" },
|
|
])
|
|
|
|
await unknownResponse.promise
|
|
unknownResponse.restore()
|
|
await ackResponse.promise
|
|
ackResponse.restore()
|
|
|
|
expect(sent.find((m: any) => m.id === "req_unknown")).toEqual({
|
|
type: "response",
|
|
id: "req_unknown",
|
|
error: "unknown slash command",
|
|
})
|
|
expect(sent.find((m: any) => m.id === "req_send_command")).toEqual({
|
|
type: "response",
|
|
id: "req_send_command",
|
|
result: {},
|
|
})
|
|
|
|
await started.promise
|
|
expect(calls).toEqual([
|
|
{
|
|
sessionID: SessionID.make("ses_commands"),
|
|
protocolVersion: 1,
|
|
command: "review",
|
|
arguments: " main ",
|
|
messageID: "msg_remote",
|
|
catalog: { protocolVersion: 1, commands: [{ name: "review", hints: [] }] },
|
|
},
|
|
])
|
|
})
|
|
|
|
test("send_command returns a sanitized error without ACK when the session is missing", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
return input.fn()
|
|
},
|
|
commands: {
|
|
list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }),
|
|
execute: async () => {
|
|
throw new Error("must not execute")
|
|
},
|
|
},
|
|
catalog: {
|
|
get: async () => {
|
|
throw new Error("private lookup detail with secret")
|
|
},
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_missing_command")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_missing_command",
|
|
command: "send_command",
|
|
sessionId: "ses_missing",
|
|
data: { protocolVersion: 1, command: "review", arguments: "main" },
|
|
})
|
|
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(dirs).toEqual([])
|
|
expect(sent).toEqual([{ type: "response", id: "req_missing_command", error: "failed to send command" }])
|
|
expect(JSON.stringify(sent)).not.toContain("private lookup detail with secret")
|
|
})
|
|
|
|
test("send_command logs only the error class after ACK and does not leak arguments or tokens", async () => {
|
|
class CredentialLeakError extends Error {
|
|
override name = "CredentialLeakError"
|
|
}
|
|
const { conn, sent } = fakeConn()
|
|
const logEntries: unknown[][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: {
|
|
...nolog,
|
|
error: (...args: unknown[]) => logEntries.push(args),
|
|
},
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
commands: {
|
|
list: async () => ({ protocolVersion: 1 as const, commands: [{ name: "review", hints: [] }] }),
|
|
execute: async () => {
|
|
throw new CredentialLeakError("credential=must-not-leak")
|
|
},
|
|
},
|
|
catalog: {
|
|
get: async () => ({ id: SessionID.make("ses_commands"), directory: "/workspace/project-a" }) as any,
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
const ack = expectResponse(conn, sent, "req_failed_command")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_failed_command",
|
|
command: "send_command",
|
|
sessionId: "ses_commands",
|
|
data: {
|
|
protocolVersion: 1,
|
|
command: "review",
|
|
arguments: "secret=token-must-not-leak",
|
|
model: { providerID: "custom/edge", modelID: "deployment/model" },
|
|
},
|
|
})
|
|
|
|
await ack.promise
|
|
// The post-ACK throw is logged via the same adapter; give the microtask a
|
|
// chance to drain so the log entry is captured before assertions.
|
|
await Promise.resolve()
|
|
ack.restore()
|
|
|
|
expect(sent).toEqual([{ type: "response", id: "req_failed_command", result: {} }])
|
|
expect(logEntries).toHaveLength(1)
|
|
expect(logEntries[0]?.[0]).toBe("send command failed after ACK")
|
|
expect(logEntries[0]?.[1]).toEqual({
|
|
id: "req_failed_command",
|
|
operation: "send_command",
|
|
error: "CredentialLeakError",
|
|
})
|
|
const flattened = JSON.stringify(logEntries)
|
|
expect(flattened).not.toContain("token-must-not-leak")
|
|
expect(flattened).not.toContain("credential=must-not-leak")
|
|
expect(flattened).not.toContain("secret=")
|
|
})
|
|
|
|
test("send_command rejects process exit before ACK without invoking command or graceful exit", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const calls: unknown[] = []
|
|
let callbacks = 0
|
|
const commands = RemoteCommand.create({
|
|
exitAvailable: () => true,
|
|
list: async () => [],
|
|
command: async (input) => {
|
|
calls.push(input)
|
|
},
|
|
session: {
|
|
get: async () => {
|
|
throw new Error("unexpected command session lookup")
|
|
},
|
|
messages: async () => {
|
|
throw new Error("unexpected command message lookup")
|
|
},
|
|
},
|
|
agent: { default: async () => "unexpected-agent" },
|
|
provider: { default: async () => ({ providerID: "unexpected", modelID: "unexpected" }) },
|
|
revert: { cleanup: async () => {} },
|
|
compaction: { create: async () => {} },
|
|
prompt: { loop: async () => {} },
|
|
})
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
commands,
|
|
remoteExit: {
|
|
get: () => {
|
|
callbacks += 1
|
|
return async () => {}
|
|
},
|
|
},
|
|
catalog: {
|
|
get: async (id) => info(id),
|
|
messages: async () => [],
|
|
providers: async () => ({}),
|
|
default: async () => undefined,
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_send_exit")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_send_exit",
|
|
command: "send_command",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1, command: "exit", arguments: "" },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
await Promise.resolve()
|
|
|
|
expect(sent).toEqual([{ type: "response", id: "req_send_exit", error: "unknown slash command" }])
|
|
expect(calls).toEqual([])
|
|
expect(callbacks).toBe(0)
|
|
})
|
|
|
|
test("exit_cli rejects invalid, missing, unresolved, and unavailable sessions before ACK", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const lookups: string[] = []
|
|
// kilocode_change - K1 W1: the new exit_cli handler requires hasSession
|
|
// (owns-check) + detachSession + cancelPrompt + ownedCount seams. The
|
|
// default test seam has hasSession=false and detachSession resolves, so
|
|
// the only path that completes is "not owned" — matching the new
|
|
// contract. The previous "graceful exit unavailable" branch only fired
|
|
// for an UNREGISTERED remoteExit on an OWNED id; in the K1 W1 design
|
|
// the headless case (no remoteExit) is no longer a separate error
|
|
// path — a headless host simply stays alive.
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => {
|
|
lookups.push(id)
|
|
if (id === SessionID.make("ses_missing")) throw new Error("private missing-session detail")
|
|
return info(id)
|
|
},
|
|
children: async () => [],
|
|
},
|
|
hasSession: () => false,
|
|
detachSession: async () => {},
|
|
ownedCount: () => 0,
|
|
cancelPrompt: async () => {},
|
|
remoteExit: {
|
|
get: () => undefined,
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_no_session",
|
|
command: "exit_cli",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_invalid_session",
|
|
command: "exit_cli",
|
|
sessionId: "not-a-session-id",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_bad_protocol",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 2 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_extra",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1, extra: true },
|
|
})
|
|
|
|
const missing = expectResponse(conn, sent, "req_exit_missing")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_missing",
|
|
command: "exit_cli",
|
|
sessionId: "ses_missing",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await missing.promise
|
|
missing.restore()
|
|
|
|
const unavailable = expectResponse(conn, sent, "req_exit_unavailable")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_unavailable",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await unavailable.promise
|
|
unavailable.restore()
|
|
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_exit_no_session", error: "invalid exit_cli command" },
|
|
{ type: "response", id: "req_exit_invalid_session", error: "invalid exit_cli command" },
|
|
{ type: "response", id: "req_exit_bad_protocol", error: "invalid exit_cli command" },
|
|
{ type: "response", id: "req_exit_extra", error: "invalid exit_cli command" },
|
|
{ type: "response", id: "req_exit_missing", error: "session not owned by this CLI" },
|
|
{ type: "response", id: "req_exit_unavailable", error: "session not owned by this CLI" },
|
|
])
|
|
expect(lookups).toEqual([])
|
|
})
|
|
|
|
test("exit_cli ACKs before invoking the worker callback in a microtask", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const order: string[] = []
|
|
const tasks: VoidFunction[] = []
|
|
const original = globalThis.queueMicrotask
|
|
globalThis.queueMicrotask = (task) => {
|
|
tasks.push(task)
|
|
}
|
|
const send = conn.send.bind(conn)
|
|
conn.send = (message) => {
|
|
order.push("response")
|
|
send(message)
|
|
}
|
|
const invoked = Promise.withResolvers<void>()
|
|
const remoteExit = {
|
|
get: () => async () => {
|
|
order.push("callback")
|
|
invoked.resolve()
|
|
},
|
|
}
|
|
try {
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
// kilocode_change - K1 W1: owns the target so the new detach path
|
|
// runs; no other sessions remain (ownedCount=0) and the callback is
|
|
// registered, so the exit path completes and the microtask fires.
|
|
hasSession: () => true,
|
|
detachSession: async () => {},
|
|
ownedCount: () => 0,
|
|
cancelPrompt: async () => {},
|
|
remoteExit,
|
|
})
|
|
|
|
const ack = expectResponse(conn, sent, "req_exit")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await ack.promise
|
|
ack.restore()
|
|
|
|
expect(sent).toEqual([{ type: "response", id: "req_exit", result: {} }])
|
|
expect(order).toEqual(["response"])
|
|
expect(tasks).toHaveLength(1)
|
|
|
|
tasks[0]?.()
|
|
await invoked.promise
|
|
expect(order).toEqual(["response", "callback"])
|
|
} finally {
|
|
globalThis.queueMicrotask = original
|
|
}
|
|
})
|
|
|
|
test("concurrent exit_cli requests ACK while idempotent local Exit cleans up once", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const completed = Promise.withResolvers<void>()
|
|
const calls: string[] = []
|
|
let task: Promise<void> | undefined
|
|
const exit = () =>
|
|
(task ??= Promise.resolve().then(() => {
|
|
calls.push("cleanup")
|
|
completed.resolve()
|
|
}))
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
// kilocode_change - K1 W1: owns both targets; zero remaining after
|
|
// each detach; registered callback; the exit path completes.
|
|
hasSession: () => true,
|
|
detachSession: async () => {},
|
|
ownedCount: () => 0,
|
|
cancelPrompt: async () => {},
|
|
remoteExit: {
|
|
get: () => exit,
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_first",
|
|
command: "exit_cli",
|
|
sessionId: "ses_first",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_second",
|
|
command: "exit_cli",
|
|
sessionId: "ses_second",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await completed.promise
|
|
await Promise.resolve()
|
|
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_exit_first", result: {} },
|
|
{ type: "response", id: "req_exit_second", result: {} },
|
|
])
|
|
expect(calls).toEqual(["cleanup"])
|
|
})
|
|
|
|
test("exit_cli logs only the error class when graceful exit fails after ACK", async () => {
|
|
class CredentialLeakError extends Error {
|
|
override name = "CredentialLeakError"
|
|
}
|
|
const { conn, sent } = fakeConn()
|
|
const logs: unknown[][] = []
|
|
const logged = Promise.withResolvers<void>()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: {
|
|
...nolog,
|
|
error: (...args: unknown[]) => {
|
|
logs.push(args)
|
|
logged.resolve()
|
|
},
|
|
},
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
// kilocode_change - K1 W1: owns the target; zero remaining; the
|
|
// callback is the throwing one above.
|
|
hasSession: () => true,
|
|
detachSession: async () => {},
|
|
ownedCount: () => 0,
|
|
cancelPrompt: async () => {},
|
|
remoteExit: {
|
|
get: () => async () => {
|
|
throw new CredentialLeakError("token=must-not-leak")
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_failed",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await logged.promise
|
|
|
|
expect(sent).toEqual([{ type: "response", id: "req_exit_failed", result: {} }])
|
|
expect(logs).toEqual([
|
|
["exit CLI failed after ACK", { id: "req_exit_failed", operation: "exit_cli", error: "CredentialLeakError" }],
|
|
])
|
|
expect(JSON.stringify(logs)).not.toContain("must-not-leak")
|
|
expect(JSON.stringify(logs)).not.toContain("token=")
|
|
})
|
|
|
|
test("create_session creates a root session in the current directory, attaches in-process, and responds in order", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const createCalls: { input: unknown; calls: number } = { input: undefined, calls: 0 }
|
|
const attachCalls: SessionID[] = []
|
|
const order: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
return input.fn()
|
|
},
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async (input) => {
|
|
createCalls.calls += 1
|
|
createCalls.input = input
|
|
order.push("create")
|
|
return { id: SessionID.make("ses_new"), directory: "/workspace/project-a", parentID: undefined } as any
|
|
},
|
|
},
|
|
attachSession: async (input) => {
|
|
attachCalls.push(input)
|
|
order.push("attach")
|
|
return
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_create")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(dirs).toEqual(["/workspace/project-a"])
|
|
expect(createCalls.calls).toBe(1)
|
|
expect(createCalls.input).toEqual({})
|
|
expect(attachCalls).toEqual([SessionID.make("ses_new")])
|
|
expect(order).toEqual(["create", "attach"])
|
|
expect(sent).toEqual([{ type: "response", id: "req_create", result: { protocolVersion: 1, sessionID: "ses_new" } }])
|
|
})
|
|
|
|
test("create_session rejects unsupported protocol versions, extra fields, and invalid session IDs; absent sessionId is allowed", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const createCalls: unknown[] = []
|
|
const attachCalls: unknown[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (sessionID) => {
|
|
// Used only for the absent-sessionId path; not reached for invalid ids.
|
|
return { id: sessionID, directory: "/tmp/process-default" } as any
|
|
},
|
|
children: async () => [],
|
|
create: async (input) => {
|
|
createCalls.push(input)
|
|
return { id: SessionID.make("ses_unused"), directory: "/tmp/process-default" } as any
|
|
},
|
|
},
|
|
attachSession: async (input) => {
|
|
attachCalls.push(input)
|
|
return
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_v2",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 2 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_bad_session",
|
|
command: "create_session",
|
|
sessionId: "not-a-session-id",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_extra_field",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1, extra: true },
|
|
})
|
|
// Absent sessionId: must NOT be rejected; should reach the spawn path.
|
|
const noSession = expectResponse(conn, sent, "req_no_session")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_no_session",
|
|
command: "create_session",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await noSession.promise
|
|
noSession.restore()
|
|
|
|
expect(sent.slice(0, 3)).toEqual([
|
|
{ type: "response", id: "req_v2", error: "invalid create_session command" },
|
|
{ type: "response", id: "req_bad_session", error: "invalid create_session command" },
|
|
{ type: "response", id: "req_extra_field", error: "invalid create_session command" },
|
|
])
|
|
// Only the absent-sessionId request reached create + spawn.
|
|
expect(createCalls).toHaveLength(1)
|
|
expect(attachCalls).toHaveLength(1)
|
|
expect(attachCalls[0]).toEqual(SessionID.make("ses_unused"))
|
|
expect(sent[3]).toEqual({
|
|
type: "response",
|
|
id: "req_no_session",
|
|
result: { protocolVersion: 1, sessionID: "ses_unused" },
|
|
})
|
|
})
|
|
|
|
test("create_session returns a sanitized error and never reports success when creation throws", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const logEntries: unknown[][] = []
|
|
const attachCalls: unknown[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) },
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async () => {
|
|
throw new Error("private failure detail: token=must-not-leak")
|
|
},
|
|
},
|
|
attachSession: async (input) => {
|
|
attachCalls.push(input)
|
|
return
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_create_failed")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create_failed",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(sent).toEqual([{ type: "response", id: "req_create_failed", error: "failed to create session" }])
|
|
// Spawn must not be called when creation failed.
|
|
expect(attachCalls).toEqual([])
|
|
expect(logEntries).toHaveLength(1)
|
|
expect(logEntries[0]?.[0]).toBe("create session failed")
|
|
expect(logEntries[0]?.[1]).toEqual({ id: "req_create_failed", error: "Error" })
|
|
const flattened = JSON.stringify(logEntries)
|
|
expect(flattened).not.toContain("must-not-leak")
|
|
expect(flattened).not.toContain("token=")
|
|
})
|
|
|
|
test("create_session returns a sanitized error and rolls back the session when the attach fails", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const logEntries: unknown[][] = []
|
|
const removeCalls: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) },
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any,
|
|
remove: async (id) => {
|
|
removeCalls.push(id)
|
|
},
|
|
},
|
|
attachSession: async () => {
|
|
throw new Error("attach failed")
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_spawn_failed")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_spawn_failed",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(sent).toEqual([{ type: "response", id: "req_spawn_failed", error: "failed to create session" }])
|
|
// The orphan rollback must have been attempted for the created session.
|
|
expect(removeCalls).toEqual(["ses_new"])
|
|
expect(logEntries).toHaveLength(1)
|
|
expect(logEntries[0]?.[0]).toBe("create session failed")
|
|
const flattened = JSON.stringify(logEntries)
|
|
expect(flattened).not.toContain("must-not-leak")
|
|
expect(flattened).not.toContain("credential=")
|
|
})
|
|
|
|
test("create_session preserves the original attach error when the rollback itself fails", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const logEntries: unknown[][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) },
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any,
|
|
remove: async () => {
|
|
throw new Error("cleanup secondary failure")
|
|
},
|
|
},
|
|
attachSession: async () => {
|
|
throw new Error("attach failed")
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_spawn_then_cleanup_fail")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_spawn_then_cleanup_fail",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
// The caller sees the sanitized primary failure, not the cleanup error.
|
|
expect(sent).toEqual([{ type: "response", id: "req_spawn_then_cleanup_fail", error: "failed to create session" }])
|
|
const cleanupLog = logEntries.find((entry) => entry[0] === "create session cleanup failed")
|
|
expect(cleanupLog).toBeDefined()
|
|
const flattened = JSON.stringify(logEntries)
|
|
expect(flattened).not.toContain("must-not-leak")
|
|
expect(flattened).not.toContain("credential=")
|
|
})
|
|
|
|
test("create_session does not remove the created session when the spawn succeeds", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const removeCalls: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_new"), directory: "/workspace/project-a" }) as any,
|
|
remove: async (id) => {
|
|
removeCalls.push(id)
|
|
},
|
|
},
|
|
attachSession: async () => undefined,
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_create_success")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create_success",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(removeCalls).toEqual([])
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_create_success", result: { protocolVersion: 1, sessionID: "ses_new" } },
|
|
])
|
|
})
|
|
|
|
test("create_session runs in the current session's directory when sessionId is present", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const attachCalls: SessionID[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
return input.fn()
|
|
},
|
|
session: {
|
|
get: async (sessionID) => {
|
|
if (sessionID === SessionID.make("ses_alpha")) return { id: sessionID, directory: "/workspace/alpha" } as any
|
|
if (sessionID === SessionID.make("ses_beta")) return { id: sessionID, directory: "/workspace/beta" } as any
|
|
throw new Error("unknown session")
|
|
},
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_new"), directory: "/tmp" }) as any,
|
|
},
|
|
attachSession: async (input) => {
|
|
attachCalls.push(input)
|
|
return
|
|
},
|
|
})
|
|
|
|
const first = expectResponse(conn, sent, "req_create_alpha")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create_alpha",
|
|
command: "create_session",
|
|
sessionId: "ses_alpha",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await first.promise
|
|
first.restore()
|
|
|
|
const second = expectResponse(conn, sent, "req_create_beta")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create_beta",
|
|
command: "create_session",
|
|
sessionId: "ses_beta",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await second.promise
|
|
second.restore()
|
|
|
|
expect(dirs).toEqual(["/workspace/alpha", "/workspace/beta"])
|
|
expect(attachCalls).toEqual([SessionID.make("ses_new"), SessionID.make("ses_new")])
|
|
})
|
|
|
|
test("create_session with absent sessionId targets the instance's own launch directory (options.directory)", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const dirs: string[] = []
|
|
const attachCalls: SessionID[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/instance/launch/dir",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => {
|
|
dirs.push(input.directory)
|
|
return input.fn()
|
|
},
|
|
session: {
|
|
get: async () => {
|
|
throw new Error("session.get must not be called when sessionId is absent")
|
|
},
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_spawned"), directory: "/instance/launch/dir" }) as any,
|
|
},
|
|
attachSession: async (input) => {
|
|
attachCalls.push(input)
|
|
return
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_no_session")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_no_session",
|
|
command: "create_session",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(dirs).toEqual(["/instance/launch/dir"])
|
|
expect(attachCalls).toEqual([SessionID.make("ses_spawned")])
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_no_session", result: { protocolVersion: 1, sessionID: "ses_spawned" } },
|
|
])
|
|
})
|
|
|
|
test("create_session dispatches an attach for each call", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const attachCalls: SessionID[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_same"), directory: "/workspace/project-a" }) as any,
|
|
},
|
|
attachSession: async (input) => {
|
|
attachCalls.push(input)
|
|
return
|
|
},
|
|
})
|
|
|
|
const first = expectResponse(conn, sent, "req_create_same_first")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create_same_first",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await first.promise
|
|
first.restore()
|
|
|
|
const second = expectResponse(conn, sent, "req_create_same_second")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create_same_second",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await second.promise
|
|
second.restore()
|
|
|
|
// Each request is a separate create_session call, so the attach seam is
|
|
// invoked twice with the freshly-pre-created session id each time.
|
|
expect(attachCalls).toEqual([SessionID.make("ses_same"), SessionID.make("ses_same")])
|
|
})
|
|
|
|
test("create_session in-process attaches the new session via the attach seam", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_spawned"), directory: "/workspace/project-a" }) as any,
|
|
},
|
|
// The K2 contract: no `attachSession` seam exists on the handler. The
|
|
// sender must rely entirely on the attach seam — and the child is
|
|
// responsible for the on-boot attach via the KILO_REMOTE_ATTACH_SESSION
|
|
// init branch in kilo-sessions.ts.
|
|
attachSession: async () => undefined,
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_no_attach")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_no_attach",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
// Only the success response was sent — no in-process attach event was
|
|
// emitted and no heartbeat fired (the attach seam absorbed both).
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_no_attach", result: { protocolVersion: 1, sessionID: "ses_spawned" } },
|
|
])
|
|
})
|
|
|
|
test("create_session returns a sanitized error and never reports success when current session.get throws", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const logEntries: unknown[][] = []
|
|
const createCalls: unknown[] = []
|
|
const attachCalls: unknown[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: { ...nolog, error: (...args: unknown[]) => logEntries.push(args) },
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async () => {
|
|
throw new Error("private lookup failure: token=must-not-leak and path=/workspace/private")
|
|
},
|
|
children: async () => [],
|
|
create: async (input) => {
|
|
createCalls.push(input)
|
|
return { id: SessionID.make("ses_unused") } as any
|
|
},
|
|
},
|
|
attachSession: async (input) => {
|
|
attachCalls.push(input)
|
|
return
|
|
},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_create_get_failed")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_create_get_failed",
|
|
command: "create_session",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(sent).toEqual([{ type: "response", id: "req_create_get_failed", error: "failed to create session" }])
|
|
expect(createCalls).toEqual([])
|
|
expect(attachCalls).toEqual([])
|
|
expect(logEntries).toHaveLength(1)
|
|
expect(logEntries[0]?.[0]).toBe("create session failed")
|
|
expect(logEntries[0]?.[1]).toEqual({ id: "req_create_get_failed", error: "Error" })
|
|
const flattened = JSON.stringify(logEntries)
|
|
expect(flattened).not.toContain("must-not-leak")
|
|
expect(flattened).not.toContain("token=")
|
|
expect(flattened).not.toContain("/workspace/private")
|
|
expect(flattened).not.toContain("ses_current")
|
|
})
|
|
|
|
// K1 W1: session-detach + remaining-count semantics. The handler:
|
|
// - refuses to ACK when the CLI does not own the target ("session not owned by this CLI")
|
|
// - detaches only the target, awaits the heartbeat fence, then ACKs
|
|
// - invokes RemoteExit when remaining === 0 AND a callback is registered
|
|
// - leaves the host alive when remaining === 0 AND no callback is registered
|
|
// - leaves the process alive when remaining > 0 (regardless of callback)
|
|
// - rolls back (no ACK) when the detach fence itself fails
|
|
|
|
test("exit_cli refuses to ACK when the target is not owned", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
hasSession: () => false,
|
|
detachSession: async () => {
|
|
throw new Error("detach must not run when not owned")
|
|
},
|
|
ownedCount: () => 0,
|
|
cancelPrompt: async () => {},
|
|
remoteExit: { get: () => undefined },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_no_own",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await Promise.resolve()
|
|
expect(sent).toEqual([{ type: "response", id: "req_no_own", error: "session not owned by this CLI" }])
|
|
})
|
|
|
|
test("exit_cli detaches only the target and ACKs after the heartbeat fence", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const order: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
hasSession: () => true,
|
|
cancelPrompt: async () => {
|
|
order.push("cancel")
|
|
},
|
|
detachSession: async (id) => {
|
|
order.push(`detach:${id}`)
|
|
},
|
|
// One session remains (e.g. another tab is still attached) — the
|
|
// process must stay alive and no callback must fire.
|
|
ownedCount: () => 1,
|
|
remoteExit: {
|
|
get: () => async () => {
|
|
order.push("EXIT")
|
|
},
|
|
},
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_one",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(order).toEqual(["cancel", "detach:ses_current"])
|
|
expect(sent).toEqual([{ type: "response", id: "req_one", result: {} }])
|
|
expect(order).not.toContain("EXIT")
|
|
})
|
|
|
|
test("exit_cli invokes RemoteExit after ACK when zero sessions remain and a callback is registered (interactive TUI)", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const order: string[] = []
|
|
const invoked = Promise.withResolvers<void>()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
hasSession: () => true,
|
|
cancelPrompt: async () => {},
|
|
detachSession: async (id) => {
|
|
order.push(`detach:${id}`)
|
|
},
|
|
ownedCount: () => 0,
|
|
remoteExit: {
|
|
get: () => async () => {
|
|
order.push("EXIT")
|
|
invoked.resolve()
|
|
},
|
|
},
|
|
})
|
|
const ack = expectResponse(conn, sent, "req_last")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_last",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await ack.promise
|
|
expect(sent).toEqual([{ type: "response", id: "req_last", result: {} }])
|
|
await invoked.promise
|
|
expect(order).toEqual(["detach:ses_current", "EXIT"])
|
|
})
|
|
|
|
test("exit_cli keeps the headless host alive when zero sessions remain and no callback is registered (kilo remote)", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const order: string[] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
hasSession: () => true,
|
|
cancelPrompt: async () => {},
|
|
detachSession: async (id) => {
|
|
order.push(`detach:${id}`)
|
|
},
|
|
ownedCount: () => 0,
|
|
// headless: no callback registered
|
|
remoteExit: { get: () => undefined },
|
|
})
|
|
const ack = expectResponse(conn, sent, "req_headless")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_headless",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await ack.promise
|
|
expect(sent).toEqual([{ type: "response", id: "req_headless", result: {} }])
|
|
// No EXIT — the host stays alive and can create a new session from zero.
|
|
expect(order).toEqual(["detach:ses_current"])
|
|
})
|
|
|
|
test("exit_cli rolls back without ACK when the detach heartbeat fence fails", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
hasSession: () => true,
|
|
cancelPrompt: async () => {},
|
|
detachSession: async () => {
|
|
throw new Error("relay down")
|
|
},
|
|
ownedCount: () => 1,
|
|
remoteExit: { get: () => async () => {} },
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_rollback",
|
|
command: "exit_cli",
|
|
sessionId: "ses_current",
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(sent).toEqual([{ type: "response", id: "req_rollback", error: "failed to exit session" }])
|
|
})
|
|
|
|
// Item 8 locking gap: after exiting one of two attached sessions, the
|
|
// survivor must still accept send_message (and the host must not exit).
|
|
test("survivor session keeps accepting send_message after sibling exit_cli", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const exitID = SessionID.make("ses_exit")
|
|
const keepID = SessionID.make("ses_keep")
|
|
const owned = new Set<SessionID>([exitID, keepID])
|
|
const cancelled: SessionID[] = []
|
|
const detached: SessionID[] = []
|
|
const calls: SessionPrompt.PromptInput[] = []
|
|
let exitInvoked = false
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/workspace/project-a",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (id) => info(id),
|
|
children: async () => [],
|
|
},
|
|
hasSession: (id) => owned.has(id),
|
|
cancelPrompt: async (id) => {
|
|
cancelled.push(id)
|
|
},
|
|
detachSession: async (id) => {
|
|
detached.push(id)
|
|
owned.delete(id)
|
|
},
|
|
ownedCount: () => owned.size,
|
|
remoteExit: {
|
|
get: () => async () => {
|
|
exitInvoked = true
|
|
},
|
|
},
|
|
prompt: prompts(calls),
|
|
provide: async (input: any) => input.fn(),
|
|
})
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_exit_sibling",
|
|
command: "exit_cli",
|
|
sessionId: exitID,
|
|
data: { protocolVersion: 1 },
|
|
})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(cancelled).toEqual([exitID])
|
|
expect(detached).toEqual([exitID])
|
|
expect(owned.has(keepID)).toBe(true)
|
|
expect(owned.has(exitID)).toBe(false)
|
|
expect(exitInvoked).toBe(false)
|
|
expect(sent).toEqual([{ type: "response", id: "req_exit_sibling", result: {} }])
|
|
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_survivor_send",
|
|
command: "send_message",
|
|
data: {
|
|
sessionID: keepID,
|
|
parts: [{ type: "text", text: "still here" }],
|
|
},
|
|
})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]?.sessionID).toBe(keepID)
|
|
expect(sent).toContainEqual({ type: "response", id: "req_survivor_send", result: {} })
|
|
})
|
|
|
|
test("create_session forwards agent, model (with variant), and orgId metadata", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const createCalls: unknown[] = []
|
|
const org = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/workspace/project-a" }) as any,
|
|
children: async () => [],
|
|
create: async (input) => {
|
|
createCalls.push(input)
|
|
return { id: SessionID.make("ses_new"), directory: "/workspace/project-a" } as any
|
|
},
|
|
},
|
|
attachSession: async () => {},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_inherit")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_inherit",
|
|
command: "create_session",
|
|
data: {
|
|
protocolVersion: 1,
|
|
agent: "build",
|
|
model: { providerID: "kilo", modelID: "kilo-auto/efficient", variant: "high" },
|
|
orgId: org,
|
|
},
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(createCalls).toEqual([
|
|
{
|
|
agent: "build",
|
|
model: {
|
|
id: ModelV2.ID.make("kilo-auto/efficient"),
|
|
providerID: ProviderV2.ID.make("kilo"),
|
|
variant: "high",
|
|
},
|
|
metadata: { orgId: org },
|
|
},
|
|
])
|
|
expect(sent).toEqual([
|
|
{ type: "response", id: "req_inherit", result: { protocolVersion: 1, sessionID: "ses_new" } },
|
|
])
|
|
})
|
|
|
|
test("create_session accepts each optional field alone", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const createCalls: unknown[] = []
|
|
const org = "11111111-2222-4333-8444-555555555555"
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp/process-default",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/tmp" }) as any,
|
|
children: async () => [],
|
|
create: async (input) => {
|
|
createCalls.push(input)
|
|
return { id: SessionID.make(`ses_${createCalls.length}`), directory: "/tmp" } as any
|
|
},
|
|
},
|
|
attachSession: async () => {},
|
|
})
|
|
|
|
for (const [id, data] of [
|
|
["req_agent", { protocolVersion: 1, agent: "plan" }],
|
|
["req_model", { protocolVersion: 1, model: { providerID: "kilo", modelID: "m1" } }],
|
|
["req_org", { protocolVersion: 1, orgId: org }],
|
|
] as const) {
|
|
const response = expectResponse(conn, sent, id)
|
|
sender.handle({ type: "command", id, command: "create_session", data })
|
|
await response.promise
|
|
response.restore()
|
|
}
|
|
|
|
expect(createCalls).toEqual([
|
|
{ agent: "plan" },
|
|
{ model: { id: ModelV2.ID.make("m1"), providerID: ProviderV2.ID.make("kilo") } },
|
|
{ metadata: { orgId: org } },
|
|
])
|
|
})
|
|
|
|
test("create_session still rejects unknown fields under strict schema", async () => {
|
|
const { conn, sent } = fakeConn()
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/tmp" }) as any,
|
|
children: async () => [],
|
|
create: async () => ({ id: SessionID.make("ses_x"), directory: "/tmp" }) as any,
|
|
},
|
|
attachSession: async () => {},
|
|
})
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_strict",
|
|
command: "create_session",
|
|
data: { protocolVersion: 1, agent: "build", unknown: true },
|
|
})
|
|
expect(sent).toEqual([{ type: "response", id: "req_strict", error: "invalid create_session command" }])
|
|
})
|
|
|
|
test("create_session production default forwards agent, model, metadata into Session.Service.create", async () => {
|
|
// Omits options.session.create so the production default path runs
|
|
// (global runtime + Session.Service.use → svc.create(input)).
|
|
// Stub Session.Service.use so create yields a requirement-free Effect;
|
|
// the real global runtime then executes it without spying the runtime
|
|
// (that would trip the promise-facades allowlist).
|
|
const { conn, sent } = fakeConn()
|
|
const createCalls: unknown[] = []
|
|
const org = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
|
const createdId = SessionID.make("ses_prod_default")
|
|
const created = {
|
|
id: createdId,
|
|
slug: "prod",
|
|
projectID: ProjectV2.ID.make("project_test"),
|
|
directory: "/tmp",
|
|
title: "New session",
|
|
version: "test",
|
|
time: { created: 0, updated: 0 },
|
|
} satisfies Session.Info
|
|
|
|
const useSpy = spyOn(Session.Service, "use").mockImplementation(((fn: (svc: Session.Interface) => unknown) =>
|
|
fn({
|
|
create: (input?: Parameters<Session.Interface["create"]>[0]) => {
|
|
createCalls.push(input)
|
|
return Effect.succeed(created)
|
|
},
|
|
} as Session.Interface)) as typeof Session.Service.use)
|
|
|
|
try {
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => ({ id: sessionID, directory: "/tmp" }) as any,
|
|
children: async () => [],
|
|
// no create → production default
|
|
},
|
|
attachSession: async () => {},
|
|
})
|
|
|
|
const response = expectResponse(conn, sent, "req_prod_default")
|
|
sender.handle({
|
|
type: "command",
|
|
id: "req_prod_default",
|
|
command: "create_session",
|
|
data: {
|
|
protocolVersion: 1,
|
|
agent: "build",
|
|
model: { providerID: "kilo", modelID: "kilo-auto/efficient", variant: "high" },
|
|
orgId: org,
|
|
},
|
|
})
|
|
await response.promise
|
|
response.restore()
|
|
|
|
expect(createCalls).toEqual([
|
|
{
|
|
agent: "build",
|
|
model: {
|
|
id: ModelV2.ID.make("kilo-auto/efficient"),
|
|
providerID: ProviderV2.ID.make("kilo"),
|
|
variant: "high",
|
|
},
|
|
metadata: { orgId: org },
|
|
},
|
|
])
|
|
expect(sent).toEqual([
|
|
{
|
|
type: "response",
|
|
id: "req_prod_default",
|
|
result: { protocolVersion: 1, sessionID: createdId },
|
|
},
|
|
])
|
|
} finally {
|
|
useSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
test("system session.renamed applies setTitle and marks adoption", async () => {
|
|
const { conn } = fakeConn()
|
|
const titles: { sessionID: string; title: string }[] = []
|
|
const warnings: unknown[][] = []
|
|
const sid = SessionID.make("ses_renamed")
|
|
const { clear, consumeRenameAdoption } = await import("../../../src/kilo-sessions/rename-adoptions")
|
|
clear(sid)
|
|
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp",
|
|
log: { ...nolog, warn: (...args: unknown[]) => warnings.push(args) },
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => {
|
|
if (sessionID !== sid) throw new Error("unknown")
|
|
return { id: sessionID, directory: "/workspace" } as any
|
|
},
|
|
children: async () => [],
|
|
setTitle: async (input) => {
|
|
titles.push(input)
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "system",
|
|
event: "session.renamed",
|
|
data: { sessionId: sid, title: "From cloud" },
|
|
})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(titles).toEqual([{ sessionID: sid, title: "From cloud" }])
|
|
expect(consumeRenameAdoption(sid, "From cloud")).toBe(true)
|
|
expect(consumeRenameAdoption(sid, "From cloud")).toBe(false)
|
|
})
|
|
|
|
test("system session.renamed tolerates malformed payload and unknown events", async () => {
|
|
const { conn } = fakeConn()
|
|
const titles: unknown[] = []
|
|
const warnings: unknown[][] = []
|
|
const infos: unknown[][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp",
|
|
log: {
|
|
info: (...args: unknown[]) => infos.push(args),
|
|
error: () => {},
|
|
warn: (...args: unknown[]) => warnings.push(args),
|
|
},
|
|
subscribe: fakeBus().subscribe,
|
|
session: {
|
|
get: async () => {
|
|
throw new Error("should not get")
|
|
},
|
|
children: async () => [],
|
|
setTitle: async (input) => {
|
|
titles.push(input)
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({ type: "system", event: "session.renamed", data: { title: "missing id" } })
|
|
sender.handle({ type: "system", event: "session.renamed", data: null })
|
|
sender.handle({ type: "system", event: "other.event", data: {} })
|
|
await Promise.resolve()
|
|
|
|
expect(titles).toEqual([])
|
|
expect(warnings.some((w) => w[0] === "malformed session.renamed")).toBe(true)
|
|
expect(infos.some((i) => i[0] === "system event" && (i[1] as any)?.event === "other.event")).toBe(true)
|
|
})
|
|
|
|
test("system session.renamed skips when session.get fails", async () => {
|
|
const { conn } = fakeConn()
|
|
const titles: unknown[] = []
|
|
const warnings: unknown[][] = []
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp",
|
|
log: { ...nolog, warn: (...args: unknown[]) => warnings.push(args) },
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async () => {
|
|
throw new Error("not found")
|
|
},
|
|
children: async () => [],
|
|
setTitle: async (input) => {
|
|
titles.push(input)
|
|
},
|
|
},
|
|
})
|
|
sender.handle({
|
|
type: "system",
|
|
event: "session.renamed",
|
|
data: { sessionId: "ses_missing", title: "Nope" },
|
|
})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(titles).toEqual([])
|
|
expect(warnings.some((w) => w[0] === "session.renamed apply failed")).toBe(true)
|
|
})
|
|
|
|
test("system session.renamed clears adoption mark when setTitle fails", async () => {
|
|
const { conn } = fakeConn()
|
|
const sid = SessionID.make("ses_rename_fail")
|
|
const { clear, consumeRenameAdoption, markRenameAdopted } = await import(
|
|
"../../../src/kilo-sessions/rename-adoptions"
|
|
)
|
|
clear(sid)
|
|
|
|
let sawMarkInsideSetTitle = false
|
|
const sender = RemoteSender.create({
|
|
conn,
|
|
directory: "/tmp",
|
|
log: nolog,
|
|
subscribe: fakeBus().subscribe,
|
|
provide: async <R>(input: { directory: string; fn: () => R }) => input.fn(),
|
|
session: {
|
|
get: async (sessionID) => {
|
|
if (sessionID !== sid) throw new Error("unknown")
|
|
return { id: sessionID, directory: "/workspace" } as any
|
|
},
|
|
children: async () => [],
|
|
setTitle: async () => {
|
|
// Mark must already be present (mark-before-write). Consume proves it,
|
|
// then re-mark so the production catch path still has something to clear.
|
|
expect(consumeRenameAdoption(sid, "Cloud title")).toBe(true)
|
|
sawMarkInsideSetTitle = true
|
|
markRenameAdopted(sid, "Cloud title")
|
|
throw new Error("setTitle boom")
|
|
},
|
|
},
|
|
})
|
|
|
|
sender.handle({
|
|
type: "system",
|
|
event: "session.renamed",
|
|
data: { sessionId: sid, title: "Cloud title" },
|
|
})
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
|
|
expect(sawMarkInsideSetTitle).toBe(true)
|
|
// Production catch must clear the re-mark so a later local write is not skipped.
|
|
expect(consumeRenameAdoption(sid, "Cloud title")).toBe(false)
|
|
})
|
|
})
|
|
// kilocode_change end
|