mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
feat: report active CLI and VS Code app and session presence (#12159)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Report active CLI and VS Code app and session presence.
|
||||
@@ -1,17 +1,21 @@
|
||||
import { expect, test } from "bun:test"
|
||||
|
||||
function setup() {
|
||||
const calls: Array<{ url: string; method: string; body: unknown }> = []
|
||||
const win = {
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(input, init)
|
||||
calls.push({ url: req.url, method: req.method, body: await req.json() })
|
||||
return new Response(JSON.stringify({ permission: { edit: { "*": "allow" } } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
},
|
||||
}
|
||||
// client.ts binds window.fetch once at import time, so every test must share the
|
||||
// same window whose fetch writes into a swappable calls array.
|
||||
let calls: Array<{ url: string; method: string; body: unknown }> = []
|
||||
|
||||
const win = {
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(input, init)
|
||||
calls.push({ url: req.url, method: req.method, body: await req.json() })
|
||||
return new Response(JSON.stringify({ permission: { edit: { "*": "allow" } } }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
function setup() {
|
||||
calls = []
|
||||
Object.defineProperty(globalThis, "window", { value: win, configurable: true })
|
||||
return calls
|
||||
}
|
||||
@@ -46,3 +50,24 @@ test("config writes include the selected directory", async () => {
|
||||
unset: [["indexing", "model"]],
|
||||
})
|
||||
})
|
||||
|
||||
test("viewed snapshots post the presence payload against the selected directory", async () => {
|
||||
const calls = setup()
|
||||
const client = await import("./client")
|
||||
const query = { url: "http://kilo:secret@127.0.0.1:4097", dir: "/tmp/project" }
|
||||
const viewer = { id: "11111111-1111-4111-8111-111111111111", active: false }
|
||||
|
||||
await client.viewProjectSessions(query, viewer, ["ses_selected", "ses_terminal"], [])
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const viewed = calls[0]
|
||||
expect(viewed.method).toBe("POST")
|
||||
expect(new URL(viewed.url).pathname).toBe("/session/viewed")
|
||||
expect(new URL(viewed.url).searchParams.get("directory")).toBe("/tmp/project")
|
||||
expect(viewed.body).toEqual({
|
||||
viewer: { id: "11111111-1111-4111-8111-111111111111", active: false },
|
||||
attached: ["ses_selected", "ses_terminal"],
|
||||
visible: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -628,9 +628,14 @@ export async function removeProjectPty(input: Query, pty: string) {
|
||||
return demand("Remove terminal", result)
|
||||
}
|
||||
|
||||
export async function viewProjectSessions(input: ProjectQuery, focused: string[], open: string[]) {
|
||||
export async function viewProjectSessions(
|
||||
input: ProjectQuery,
|
||||
viewer: { id: string; active: boolean },
|
||||
attached: string[],
|
||||
visible: string[],
|
||||
) {
|
||||
const sdk = client(input)
|
||||
const result = await sdk.session.viewed({ directory: input.dir, focused, open })
|
||||
const result = await sdk.session.viewed({ directory: input.dir, viewer, attached, visible })
|
||||
return demand("Viewed sessions", result)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
normalizeConsoleDiffStyle,
|
||||
normalizeContextSidebarWidth,
|
||||
} from "../config/state/console"
|
||||
import { sender } from "./project-console-presence-sender"
|
||||
import { GhosttyTerminal } from "./terminal/GhosttyTerminal"
|
||||
|
||||
const ui = new Set(["3017", "3018"])
|
||||
@@ -147,6 +148,7 @@ function terminalKey(url: string, item: ProjectTerminalItem) {
|
||||
export function ProjectConsoleRoute() {
|
||||
const loc = useLocation()
|
||||
const params = useParams()
|
||||
const viewerId = crypto.randomUUID()
|
||||
const search = createMemo(() => new URLSearchParams(loc.search))
|
||||
const fallback = () => base(search())
|
||||
const [url, setUrl] = createSignal(fallback())
|
||||
@@ -660,19 +662,37 @@ export function ProjectConsoleRoute() {
|
||||
if (item) clearUnread(item)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
let lastInput: { url: string; dir: string } | undefined
|
||||
const queue = sender((err) => console.warn(`Viewed sessions: ${errMsg(err)}`))
|
||||
|
||||
function sendSnapshot(force = false) {
|
||||
const base = query()
|
||||
const data = snap()
|
||||
if (!base || !data) return
|
||||
const focused = activeSessionID()
|
||||
const open = terminals().flatMap((item) => {
|
||||
const selected = activeSessionID()
|
||||
const ids = new Set<string>()
|
||||
if (selected) ids.add(selected)
|
||||
for (const item of terminals()) {
|
||||
const id = sessionID(item)
|
||||
return id ? [id] : []
|
||||
})
|
||||
void viewProjectSessions({ url: base.url, dir: data.project.worktree }, focused ? [focused] : [], open).catch(
|
||||
() => {},
|
||||
if (id) ids.add(id)
|
||||
}
|
||||
const input = { url: base.url, dir: data.project.worktree }
|
||||
const key = input.url + "|" + input.dir + "|" + [...ids].sort().join(",")
|
||||
lastInput = input
|
||||
queue.push(
|
||||
{
|
||||
key,
|
||||
run: async () => {
|
||||
await viewProjectSessions(input, { id: viewerId, active: false }, [...ids], [])
|
||||
},
|
||||
},
|
||||
force,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => sendSnapshot())
|
||||
|
||||
const checkin = window.setInterval(() => sendSnapshot(true), 60_000)
|
||||
|
||||
createEffect(() => {
|
||||
const base = query()
|
||||
@@ -704,6 +724,19 @@ export function ProjectConsoleRoute() {
|
||||
onCleanup(() => {
|
||||
if (events.timer) window.clearTimeout(events.timer)
|
||||
if (resize.timer) window.clearTimeout(resize.timer)
|
||||
window.clearInterval(checkin)
|
||||
if (lastInput) {
|
||||
const input = lastInput
|
||||
queue.push(
|
||||
{
|
||||
key: input.url + "|" + input.dir + "|",
|
||||
run: async () => {
|
||||
await viewProjectSessions(input, { id: viewerId, active: false }, [], [])
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sender } from "./project-console-presence-sender"
|
||||
|
||||
function deferred() {
|
||||
const state: { resolve?: () => void } = {}
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
state.resolve = resolve
|
||||
})
|
||||
return { promise, resolve: () => state.resolve?.() }
|
||||
}
|
||||
|
||||
async function drain() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe("project console presence sender", () => {
|
||||
test("sends snapshots in order", async () => {
|
||||
const first = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
|
||||
queue.push({ key: "first", run: () => (calls.push("first"), first.promise) })
|
||||
queue.push({ key: "second", run: async () => void calls.push("second") })
|
||||
|
||||
expect(calls).toEqual(["first"])
|
||||
first.resolve()
|
||||
await drain()
|
||||
expect(calls).toEqual(["first", "second"])
|
||||
})
|
||||
|
||||
test("deduplicates the last successful snapshot unless forced", async () => {
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
const item = { key: "same", run: async () => void calls.push("same") }
|
||||
|
||||
queue.push(item)
|
||||
await drain()
|
||||
queue.push(item)
|
||||
await drain()
|
||||
expect(calls).toEqual(["same"])
|
||||
|
||||
queue.push(item, true)
|
||||
await drain()
|
||||
expect(calls).toEqual(["same", "same"])
|
||||
})
|
||||
|
||||
test("does not queue a reactive duplicate of an in-flight snapshot", async () => {
|
||||
const wait = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
const item = { key: "same", run: () => (calls.push("same"), wait.promise) }
|
||||
|
||||
queue.push(item)
|
||||
queue.push(item)
|
||||
wait.resolve()
|
||||
await drain()
|
||||
|
||||
expect(calls).toEqual(["same"])
|
||||
})
|
||||
|
||||
test("retains a forced renewal while the same snapshot is in flight", async () => {
|
||||
const wait = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
const item = { key: "same", run: () => (calls.push("same"), calls.length === 1 ? wait.promise : Promise.resolve()) }
|
||||
|
||||
queue.push(item)
|
||||
queue.push(item, true)
|
||||
expect(calls).toEqual(["same"])
|
||||
|
||||
wait.resolve()
|
||||
await drain()
|
||||
expect(calls).toEqual(["same", "same"])
|
||||
})
|
||||
|
||||
test("replaces an obsolete pending snapshot with the latest state", async () => {
|
||||
const first = deferred()
|
||||
const calls: string[] = []
|
||||
const queue = sender(() => {})
|
||||
|
||||
queue.push({ key: "first", run: () => (calls.push("first"), first.promise) })
|
||||
queue.push({ key: "second", run: async () => void calls.push("second") })
|
||||
queue.push({ key: "third", run: async () => void calls.push("third") })
|
||||
|
||||
first.resolve()
|
||||
await drain()
|
||||
expect(calls).toEqual(["first", "third"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
type Snapshot = {
|
||||
key: string
|
||||
run: () => Promise<void>
|
||||
}
|
||||
|
||||
export function sender(report: (err: unknown) => void) {
|
||||
let current: Snapshot | undefined
|
||||
let next: Snapshot | undefined
|
||||
let last: string | undefined
|
||||
|
||||
async function drain() {
|
||||
const item = next
|
||||
if (!item) return
|
||||
next = undefined
|
||||
current = item
|
||||
try {
|
||||
await item.run()
|
||||
last = item.key
|
||||
} catch (err) {
|
||||
report(err)
|
||||
}
|
||||
current = undefined
|
||||
if (next) void drain()
|
||||
}
|
||||
|
||||
return {
|
||||
push(item: Snapshot, force = false) {
|
||||
if (!force && item.key === last && !current) return
|
||||
if (!force && item.key === current?.key && !next) return
|
||||
next = item
|
||||
if (!current) void drain()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Contract test for the presence snapshot logic in ProjectConsoleRoute.tsx.
|
||||
*
|
||||
* The route is a large Solid component that cannot be mounted in a unit test,
|
||||
* so these source assertions pin the load-bearing presence behaviour instead:
|
||||
* the console is a dashboard viewer (always inactive, never reports visible
|
||||
* sessions), the attached union covers the selected session plus every terminal
|
||||
* session, the sender serializes snapshots and forced check-ins, and cleanup
|
||||
* reuses the exact url+dir the last regular snapshot used to queue a final empty
|
||||
* snapshot.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROUTE_FILE = path.resolve(import.meta.dir, "./ProjectConsoleRoute.tsx")
|
||||
|
||||
/** Collapse whitespace so multi-line expressions match regardless of formatting. */
|
||||
function flat(source: string) {
|
||||
return source.replace(/\s+/g, " ").replace(/\( /g, "(").replace(/ \)/g, ")").replace(/,\)/g, ")")
|
||||
}
|
||||
|
||||
describe("project console presence contract", () => {
|
||||
test("snapshots always report an inactive viewer with no visible sessions", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain("const viewerId = crypto.randomUUID()")
|
||||
expect(flat(content)).toContain(
|
||||
"run: async () => { await viewProjectSessions(input, { id: viewerId, active: false }, [...ids], []) }",
|
||||
)
|
||||
expect(content).not.toContain("active: true")
|
||||
})
|
||||
|
||||
test("attached union includes the selected session and every terminal session", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain("const selected = activeSessionID()")
|
||||
expect(content).toContain("const ids = new Set<string>()")
|
||||
expect(content).toContain("if (selected) ids.add(selected)")
|
||||
expect(flat(content)).toContain(
|
||||
"for (const item of terminals()) { const id = sessionID(item) if (id) ids.add(id) }",
|
||||
)
|
||||
})
|
||||
|
||||
test("snapshots record the url+dir they were sent with", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain("let lastInput: { url: string; dir: string } | undefined")
|
||||
expect(content).toContain("const input = { url: base.url, dir: data.project.worktree }")
|
||||
expect(content).toContain("lastInput = input")
|
||||
})
|
||||
|
||||
test("routes reactive snapshots and forced check-ins through the serialized sender", () => {
|
||||
const content = fs.readFileSync(ROUTE_FILE, "utf-8")
|
||||
expect(content).toContain('import { sender } from "./project-console-presence-sender"')
|
||||
expect(content).toContain("const queue = sender")
|
||||
expect(content).toContain("function sendSnapshot(force = false)")
|
||||
expect(content).toContain("queue.push(")
|
||||
expect(content).toContain("createEffect(() => sendSnapshot())")
|
||||
expect(content).toContain("const checkin = window.setInterval(() => sendSnapshot(true), 60_000)")
|
||||
expect(content).toContain("window.clearInterval(checkin)")
|
||||
})
|
||||
|
||||
test("cleanup queues a final empty snapshot using the last snapshot's url+dir", () => {
|
||||
const content = flat(fs.readFileSync(ROUTE_FILE, "utf-8"))
|
||||
expect(content).toContain(
|
||||
'window.clearInterval(checkin) if (lastInput) { const input = lastInput queue.push({ key: input.url + "|" + input.dir + "|", run: async () => { await viewProjectSessions(input, { id: viewerId, active: false }, [], []) }, }, true) }',
|
||||
)
|
||||
expect(content).not.toContain("dir: base.dir")
|
||||
})
|
||||
})
|
||||
@@ -502,8 +502,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
private focusSession(id?: string): void {
|
||||
this.streams.focus(id)
|
||||
if (id) this.connectionService.registerFocused(this.instanceId, id)
|
||||
else this.connectionService.unregisterFocused(this.instanceId)
|
||||
this.registerPresence()
|
||||
}
|
||||
|
||||
/**
|
||||
* Report presence for this provider: the focused session is visible, and
|
||||
* open local tab sessions (plus the focused one) stay attached even while
|
||||
* the view is hidden.
|
||||
*/
|
||||
private registerPresence(): void {
|
||||
if (this.opts.disableViewedRegistration) return
|
||||
const focused = this.streams.focused
|
||||
this.connectionService.registerVisible(this.instanceId, focused ? [focused] : [])
|
||||
const attached = new Set(this.openSessionIds)
|
||||
if (focused) attached.add(focused)
|
||||
this.connectionService.registerAttached(this.instanceId, [...attached])
|
||||
}
|
||||
|
||||
public setStreamVisibility(active: boolean): void {
|
||||
@@ -735,9 +748,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
this.setupWebviewMessageHandler(panel.webview)
|
||||
this.viewStateDisposable?.dispose()
|
||||
this.viewStateDisposable = this.visibleTaskStreams.bindPanel(panel, () =>
|
||||
this.focusSession(panel.active ? this.currentSession?.id : undefined),
|
||||
)
|
||||
this.viewStateDisposable = this.visibleTaskStreams.bindPanel(panel, () => {
|
||||
if (this.opts.disableViewedRegistration) return
|
||||
const id = this.contextSessionID
|
||||
this.streams.focus(panel.visible ? id : undefined)
|
||||
this.connectionService.registerVisible(this.instanceId, panel.visible && id ? [id] : [])
|
||||
})
|
||||
this.initializeConnection()
|
||||
}
|
||||
|
||||
@@ -832,7 +848,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
for (const [key, session] of this.draftSessions) {
|
||||
if (next.has(session.sid) || session.expires <= now) this.draftSessions.delete(key)
|
||||
}
|
||||
this.connectionService.registerOpen(this.instanceId, ids)
|
||||
this.registerPresence()
|
||||
this.recoverPendingPrompts()
|
||||
}
|
||||
|
||||
@@ -4401,8 +4417,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
*/
|
||||
dispose(): void {
|
||||
this.unsubscribeRemote?.()
|
||||
this.focusSession()
|
||||
this.connectionService.registerOpen(this.instanceId, [])
|
||||
this.streams.focus(undefined)
|
||||
this.connectionService.unregisterVisible(this.instanceId)
|
||||
this.connectionService.unregisterAttached(this.instanceId)
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
this.unsubscribeEvent?.()
|
||||
|
||||
@@ -27,6 +27,7 @@ import { startVscodeRunTask } from "./run/task"
|
||||
import { RunController } from "./run/controller"
|
||||
import { handleRunMessage } from "./run/message"
|
||||
import { forkSession } from "./fork-session"
|
||||
import { AgentManagerVisiblePresence } from "./am-visible-presence"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { WorktreeDiffController } from "./worktree-diff-controller"
|
||||
import { WorktreeImporter } from "./worktree-importer"
|
||||
@@ -83,10 +84,13 @@ export class AgentManagerProvider implements Disposable {
|
||||
private closing: Promise<void> | undefined
|
||||
private onVisibilityChange: ((visible: boolean) => void) | undefined
|
||||
|
||||
/** Session ID most recently loaded via a `loadMessages` message from the webview.
|
||||
* Updated synchronously — unlike the session provider's currentSession which depends on
|
||||
* an async `session.get` round-trip and can be stale during rapid tab switches. */
|
||||
/** Session ID most recently loaded via `loadMessages`; updated synchronously. */
|
||||
private activeSessionId: string | undefined
|
||||
private visiblePresence = new AgentManagerVisiblePresence(
|
||||
(ids) => this.connectionService.registerVisible("agent-manager", ids),
|
||||
() => this.panel?.visible ?? false,
|
||||
(ids) => this.connectionService.registerAttached("agent-manager", ids),
|
||||
)
|
||||
constructor(
|
||||
private readonly host: Host,
|
||||
private readonly connectionService: KiloConnectionService,
|
||||
@@ -257,6 +261,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.onVisibilityChange?.(ctx.visible)
|
||||
ctx.onDidChangeVisibility((visible) => {
|
||||
this.statsPoller.setVisible(visible)
|
||||
this.visiblePresence.flush()
|
||||
})
|
||||
|
||||
ctx.sessions.onFollowupAdopted((session, directory) => {
|
||||
@@ -276,8 +281,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.prBridge.poller.stop()
|
||||
this.diffs.stop()
|
||||
this.activeSessionId = undefined
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.connectionService.registerOpen("agent-manager", [])
|
||||
this.visiblePresence.clear()
|
||||
this.panel = undefined
|
||||
this.onVisibilityChange?.(false)
|
||||
}
|
||||
@@ -509,7 +513,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
if (m.type === "loadMessages") {
|
||||
this.activeSessionId = m.sessionID
|
||||
this.connectionService.registerFocused("agent-manager", m.sessionID)
|
||||
this.terminalManager.syncOnSessionSwitch(m.sessionID)
|
||||
this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined)
|
||||
return msg
|
||||
@@ -517,7 +520,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
if (m.type === "clearSession") {
|
||||
this.activeSessionId = undefined
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.visiblePresence.setDisplayed(null)
|
||||
void Promise.resolve().then(() => {
|
||||
if (!this.panel || !this.state) return
|
||||
for (const id of this.state.worktreeSessionIds()) {
|
||||
@@ -535,8 +538,8 @@ export class AgentManagerProvider implements Disposable {
|
||||
return msg
|
||||
}
|
||||
|
||||
if (m.type === "agentManager.openSessions") {
|
||||
this.connectionService.registerOpen("agent-manager", m.sessionIDs)
|
||||
if (m.type === "agentManager.openSessions" || m.type === "agentManager.visibleSession") {
|
||||
this.visiblePresence.handle(m)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1475,10 +1478,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keybindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private sendKeybindings(): void {
|
||||
const keybindings = this.host.extensionKeybindings()
|
||||
const bindings = buildKeybindingMap(keybindings, process.platform === "darwin")
|
||||
@@ -1982,8 +1981,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.unsubTool?.()
|
||||
this.unsubStatus?.()
|
||||
this.unsubFont?.()
|
||||
this.connectionService.unregisterFocused("agent-manager")
|
||||
this.connectionService.registerOpen("agent-manager", [])
|
||||
this.visiblePresence.clear()
|
||||
this.diffs.stop()
|
||||
this.naming.dispose()
|
||||
this.statsPoller.stop()
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AgentManagerVisiblePresence } from "./am-visible-presence"
|
||||
|
||||
function setup(initialVisible = true) {
|
||||
const calls: string[][] = []
|
||||
const attached: string[][] = []
|
||||
let visible = initialVisible
|
||||
const presence = new AgentManagerVisiblePresence(
|
||||
(ids) => calls.push(ids),
|
||||
() => visible,
|
||||
(ids) => attached.push(ids),
|
||||
)
|
||||
return {
|
||||
calls,
|
||||
attached,
|
||||
presence,
|
||||
setVisible(value: boolean) {
|
||||
visible = value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("AgentManagerVisiblePresence", () => {
|
||||
test("registers the displayed id while the panel is visible", () => {
|
||||
const { calls, presence } = setup(true)
|
||||
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("flush registers empty when the panel is hidden", () => {
|
||||
const { calls, presence, setVisible } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
setVisible(false)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("flush clears attached when the panel is hidden", () => {
|
||||
const { attached, presence, setVisible } = setup(true)
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1", "ses_2"] })
|
||||
|
||||
setVisible(false)
|
||||
presence.flush()
|
||||
|
||||
expect(attached.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("flush re-registers attached when the panel becomes visible again", () => {
|
||||
const { attached, presence, setVisible } = setup(true)
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1", "ses_2"] })
|
||||
|
||||
setVisible(false)
|
||||
presence.flush()
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(attached.at(-1)).toEqual(["ses_1", "ses_2"])
|
||||
})
|
||||
|
||||
test("setDisplayed(null) registers empty even while visible", () => {
|
||||
const { calls, presence } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
presence.setDisplayed(null)
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("flush after visibility returns re-registers the retained id", () => {
|
||||
const { calls, presence, setVisible } = setup(false)
|
||||
presence.setDisplayed("ses_1")
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("setDisplayed(null) prevents a stale id from re-registering on a later flush", () => {
|
||||
const { calls, presence, setVisible } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
|
||||
setVisible(false)
|
||||
presence.setDisplayed(null)
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
})
|
||||
|
||||
test("handle routes openSessions to attached and visibleSession to visible", () => {
|
||||
const { calls, attached, presence } = setup(true)
|
||||
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1", "ses_2"] })
|
||||
presence.handle({ type: "agentManager.visibleSession", sessionID: "ses_1" })
|
||||
|
||||
expect(attached.at(-1)).toEqual(["ses_1", "ses_2"])
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("handle while hidden stores state but registers empty", () => {
|
||||
const { calls, attached, presence, setVisible } = setup(false)
|
||||
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1"] })
|
||||
presence.handle({ type: "agentManager.visibleSession", sessionID: "ses_1" })
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
expect(attached.at(-1)).toEqual([])
|
||||
|
||||
setVisible(true)
|
||||
presence.flush()
|
||||
|
||||
expect(calls.at(-1)).toEqual(["ses_1"])
|
||||
expect(attached.at(-1)).toEqual(["ses_1"])
|
||||
})
|
||||
|
||||
test("clear empties both the visible and attached registrations", () => {
|
||||
const { calls, attached, presence } = setup(true)
|
||||
presence.setDisplayed("ses_1")
|
||||
presence.handle({ type: "agentManager.openSessions", sessionIDs: ["ses_1"] })
|
||||
|
||||
presence.clear()
|
||||
|
||||
expect(calls.at(-1)).toEqual([])
|
||||
expect(attached.at(-1)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Vscode-free presence state for the Agent Manager.
|
||||
*
|
||||
* Owns the displayed session id and the open-tab session set. Both are gated
|
||||
* on panel visibility: when the panel is hidden (retainContextWhenHidden
|
||||
* keeps the webview alive), flush() clears both registrations so the retained
|
||||
* webview's reactive updates cannot keep stale sessions attached or visible.
|
||||
* When the panel returns, flush() re-registers from stored state. */
|
||||
|
||||
type Register = (ids: string[]) => void
|
||||
|
||||
type PresenceMessage =
|
||||
| { type: "agentManager.openSessions"; sessionIDs: string[] }
|
||||
| { type: "agentManager.visibleSession"; sessionID: string | null }
|
||||
|
||||
export class AgentManagerVisiblePresence {
|
||||
private id: string | null = null
|
||||
private open: string[] = []
|
||||
constructor(
|
||||
private readonly register: Register,
|
||||
private readonly panelVisible: () => boolean,
|
||||
private readonly registerAttached: Register,
|
||||
) {}
|
||||
|
||||
setDisplayed(id: string | null): void {
|
||||
this.id = id
|
||||
this.flush()
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
if (this.panelVisible()) {
|
||||
this.register(this.id ? [this.id] : [])
|
||||
this.registerAttached(this.open)
|
||||
} else {
|
||||
this.register([])
|
||||
this.registerAttached([])
|
||||
}
|
||||
}
|
||||
|
||||
handle(m: PresenceMessage): void {
|
||||
if (m.type === "agentManager.openSessions") this.open = m.sessionIDs
|
||||
else this.id = m.sessionID
|
||||
this.flush()
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.id = null
|
||||
this.open = []
|
||||
this.register([])
|
||||
this.registerAttached([])
|
||||
}
|
||||
}
|
||||
@@ -564,6 +564,11 @@ interface OpenSessionsIn {
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
interface VisibleSessionIn {
|
||||
type: "agentManager.visibleSession"
|
||||
sessionID: string | null
|
||||
}
|
||||
|
||||
interface OpenFileIn {
|
||||
type: "agentManager.openFile"
|
||||
sessionId: string
|
||||
@@ -809,6 +814,7 @@ export type AgentManagerInMessage =
|
||||
| RefreshPRIn
|
||||
| OpenPRIn
|
||||
| OpenSessionsIn
|
||||
| VisibleSessionIn
|
||||
| OpenFileIn
|
||||
| GenericOpenFileIn
|
||||
| PreviewImageIn
|
||||
|
||||
@@ -96,6 +96,7 @@ export class VscodeHost implements Host {
|
||||
snapshotInitialization: SNAPSHOT_INITIALIZATION,
|
||||
slimEditMetadata: true,
|
||||
worktreeDirectories: () => opts.worktreeDirectories?.() ?? [],
|
||||
disableViewedRegistration: true,
|
||||
})
|
||||
if (this.diffVirtual) {
|
||||
provider.setDiffVirtualProvider(this.diffVirtual)
|
||||
|
||||
@@ -5,4 +5,6 @@ export type KiloProviderOptions = {
|
||||
slimEditMetadata?: boolean
|
||||
tabTitle?: (title: string) => void
|
||||
worktreeDirectories?: () => string[]
|
||||
/** Composite hosts (Agent Manager) own viewed/presence registration themselves. */
|
||||
disableViewedRegistration?: boolean
|
||||
}
|
||||
|
||||
@@ -40,9 +40,9 @@ describe("KiloConnectionService clients", () => {
|
||||
})
|
||||
|
||||
describe("KiloConnectionService viewed sessions", () => {
|
||||
test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => {
|
||||
test("keeps Agent Manager sessions when sidebar visibility changes during a flush", async () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
const calls: Array<{ focused: string[]; open?: string[] }> = []
|
||||
const calls: Array<{ viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }> = []
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
@@ -50,10 +50,9 @@ describe("KiloConnectionService viewed sessions", () => {
|
||||
let active = 0
|
||||
let max = 0
|
||||
|
||||
;(service as any).remoteService = { getState: () => ({ enabled: true }) }
|
||||
;(service as any).client = {
|
||||
session: {
|
||||
viewed: async (input: { focused: string[]; open?: string[] }) => {
|
||||
viewed: async (input: { viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }) => {
|
||||
calls.push(input)
|
||||
active += 1
|
||||
max = Math.max(max, active)
|
||||
@@ -63,23 +62,91 @@ describe("KiloConnectionService viewed sessions", () => {
|
||||
},
|
||||
}
|
||||
|
||||
service.registerFocused("agent-manager", "am-1")
|
||||
service.registerOpen("agent-manager", ["am-1", "am-2"])
|
||||
service.registerVisible("agent-manager", ["am-1"])
|
||||
service.registerAttached("agent-manager", ["am-1", "am-2"])
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toEqual([{ focused: ["am-1"], open: ["am-2"] }])
|
||||
expect(calls).toHaveLength(1)
|
||||
expect([...calls[0].visible].sort()).toEqual(["am-1"])
|
||||
expect([...calls[0].attached].sort()).toEqual(["am-1", "am-2"])
|
||||
|
||||
service.registerFocused("sidebar", "side-1")
|
||||
service.registerVisible("sidebar", ["side-1"])
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
release()
|
||||
await Bun.sleep(10)
|
||||
expect(max).toBe(1)
|
||||
expect(calls[1]).toEqual({ focused: ["am-1", "side-1"], open: ["am-2"] })
|
||||
expect([...calls[1].visible].sort()).toEqual(["am-1", "side-1"])
|
||||
expect([...calls[1].attached].sort()).toEqual(["am-1", "am-2", "side-1"])
|
||||
|
||||
service.unregisterFocused("sidebar")
|
||||
service.registerVisible("sidebar", [])
|
||||
await Bun.sleep(175)
|
||||
expect(calls[2]).toEqual({ focused: ["am-1"], open: ["am-2"] })
|
||||
expect([...calls[2].visible].sort()).toEqual(["am-1"])
|
||||
expect([...calls[2].attached].sort()).toEqual(["am-1", "am-2"])
|
||||
})
|
||||
|
||||
test("window focus gates viewer.active but not attachment", async () => {
|
||||
const window = vscode.window as unknown as {
|
||||
state: { focused: boolean }
|
||||
onDidChangeWindowState: (listener: (ws: { focused: boolean }) => void) => { dispose(): void }
|
||||
}
|
||||
const original = window.onDidChangeWindowState
|
||||
let listener: ((ws: { focused: boolean }) => void) | undefined
|
||||
window.onDidChangeWindowState = (cb) => {
|
||||
listener = cb
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
|
||||
try {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
const calls: Array<{ viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }> = []
|
||||
;(service as any).client = {
|
||||
session: {
|
||||
viewed: async (input: (typeof calls)[number]) => {
|
||||
calls.push(input)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
service.registerVisible("sidebar", ["ses-1"])
|
||||
service.registerAttached("sidebar", ["ses-1", "ses-2"])
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].viewer.active).toBe(true)
|
||||
|
||||
listener!({ focused: false })
|
||||
await Bun.sleep(175)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1].viewer.active).toBe(false)
|
||||
expect([...calls[1].visible].sort()).toEqual(["ses-1"])
|
||||
expect([...calls[1].attached].sort()).toEqual(["ses-1", "ses-2"])
|
||||
} finally {
|
||||
window.onDidChangeWindowState = original
|
||||
}
|
||||
})
|
||||
|
||||
test("sends snapshots while remote control is disabled", async () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
const calls: Array<{ viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }> = []
|
||||
;(service as any).client = {
|
||||
session: {
|
||||
viewed: async (input: (typeof calls)[number]) => {
|
||||
calls.push(input)
|
||||
},
|
||||
},
|
||||
}
|
||||
service.setRemoteService({
|
||||
getState: () => ({ enabled: false, connected: false }),
|
||||
onChange: () => () => {},
|
||||
} as any)
|
||||
|
||||
service.registerVisible("sidebar", ["ses-1"])
|
||||
service.registerAttached("agent-manager", ["ses-2"])
|
||||
await Bun.sleep(175)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect([...calls[0].visible].sort()).toEqual(["ses-1"])
|
||||
expect([...calls[0].attached].sort()).toEqual(["ses-1", "ses-2"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ function isNotFound(err: unknown) {
|
||||
return false
|
||||
}
|
||||
|
||||
function sameSet(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const id of a) if (!b.has(id)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Poll /global/health every 10 seconds.
|
||||
// This provides a second detection channel for server death independent of the SSE heartbeat.
|
||||
const HEALTH_POLL_INTERVAL_MS = 10_000
|
||||
@@ -111,10 +117,14 @@ export class KiloConnectionService {
|
||||
*/
|
||||
private readonly messageSessionIdsByMessageId: Map<string, string> = new Map()
|
||||
|
||||
/** Provider key → single focused session ID. */
|
||||
private readonly focused: Map<string, string> = new Map()
|
||||
/** Provider key → all open (background) session IDs. */
|
||||
private readonly opened: Map<string, string[]> = new Map()
|
||||
private readonly viewerId = crypto.randomUUID()
|
||||
private active = true
|
||||
private windowStateDisposable: vscode.Disposable | null = null
|
||||
private checkinTimer: ReturnType<typeof setInterval> | null = null
|
||||
/** Provider key → attached (retained for remote control) session IDs. */
|
||||
private readonly attached: Map<string, Set<string>> = new Map()
|
||||
/** Provider key → visibly rendered session IDs. */
|
||||
private readonly visible: Map<string, Set<string>> = new Map()
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private viewedSending = false
|
||||
private viewedDirty = false
|
||||
@@ -129,6 +139,11 @@ export class KiloConnectionService {
|
||||
} satisfies Pick<vscode.Memento, "get" | "update">)
|
||||
this.sandboxPreference = new SandboxPreference(state)
|
||||
this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code))
|
||||
this.active = vscode.window.state.focused
|
||||
this.windowStateDisposable = vscode.window.onDidChangeWindowState((ws) => {
|
||||
this.active = ws.focused
|
||||
this.flushViewed()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,14 +308,15 @@ export class KiloConnectionService {
|
||||
for (const [mid, sid] of this.messageSessionIdsByMessageId) {
|
||||
if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid)
|
||||
}
|
||||
for (const [key, sid] of this.focused) {
|
||||
if (sid === sessionId) this.focused.delete(key)
|
||||
for (const [key, ids] of this.attached) {
|
||||
if (!ids.has(sessionId)) continue
|
||||
ids.delete(sessionId)
|
||||
if (ids.size === 0) this.attached.delete(key)
|
||||
}
|
||||
for (const [key, ids] of this.opened) {
|
||||
if (!ids.includes(sessionId)) continue
|
||||
const next = ids.filter((id) => id !== sessionId)
|
||||
if (next.length === 0) this.opened.delete(key)
|
||||
else this.opened.set(key, next)
|
||||
for (const [key, ids] of this.visible) {
|
||||
if (!ids.has(sessionId)) continue
|
||||
ids.delete(sessionId)
|
||||
if (ids.size === 0) this.visible.delete(key)
|
||||
}
|
||||
this.flushViewed()
|
||||
}
|
||||
@@ -584,38 +600,49 @@ export class KiloConnectionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the session a provider is actively viewing (focused).
|
||||
* After any change the aggregated set is sent to the server (debounced).
|
||||
* Register the sessions a provider retains for remote control (attached).
|
||||
* Sent to the server (debounced) regardless of remote-control enablement.
|
||||
*/
|
||||
registerFocused(key: string, sessionID: string): void {
|
||||
if (this.focused.get(key) === sessionID) return
|
||||
this.focused.set(key, sessionID)
|
||||
registerAttached(key: string, ids: string[]): void {
|
||||
const next = new Set(ids)
|
||||
const prev = this.attached.get(key)
|
||||
if (prev && sameSet(prev, next)) return
|
||||
this.attached.set(key, next)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a provider's focused session (e.g. on dispose, hidden, or clearSession).
|
||||
* Unregister a provider's attached sessions (e.g. on dispose or clear).
|
||||
*/
|
||||
unregisterFocused(key: string): void {
|
||||
if (!this.focused.has(key)) return
|
||||
this.focused.delete(key)
|
||||
unregisterAttached(key: string): void {
|
||||
if (!this.attached.has(key)) return
|
||||
this.attached.delete(key)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the open (background tab) session IDs for a provider.
|
||||
* Sessions that appear in both focused and open are reported as focused only.
|
||||
* Register the sessions a provider visibly renders (visible).
|
||||
* Visible sessions are also reported as attached.
|
||||
*/
|
||||
registerOpen(key: string, ids: string[]): void {
|
||||
const prev = this.opened.get(key)
|
||||
if (prev && prev.length === ids.length && prev.every((v, i) => v === ids[i])) return
|
||||
this.opened.set(key, ids)
|
||||
registerVisible(key: string, ids: string[]): void {
|
||||
const next = new Set(ids)
|
||||
const prev = this.visible.get(key)
|
||||
if (prev && sameSet(prev, next)) return
|
||||
this.visible.set(key, next)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/** Debounced: send the aggregated focused + open session IDs to the server. */
|
||||
/**
|
||||
* Unregister a provider's visible sessions (e.g. on hide, clear, or dispose).
|
||||
*/
|
||||
unregisterVisible(key: string): void {
|
||||
if (!this.visible.has(key)) return
|
||||
this.visible.delete(key)
|
||||
this.flushViewed()
|
||||
}
|
||||
|
||||
/** Debounced: send the aggregated attached + visible snapshot to the server. Works even when remote control is disabled. */
|
||||
flushViewed(): void {
|
||||
if (!this.isRemoteEnabled()) return
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null
|
||||
@@ -624,28 +651,21 @@ export class KiloConnectionService {
|
||||
}
|
||||
|
||||
private sendViewed(): void {
|
||||
if (!this.isRemoteEnabled()) {
|
||||
this.viewedDirty = false
|
||||
return
|
||||
}
|
||||
if (this.viewedSending) {
|
||||
this.viewedDirty = true
|
||||
return
|
||||
}
|
||||
if (!this.client) return
|
||||
|
||||
const focus = new Set(this.focused.values())
|
||||
const open = new Set<string>()
|
||||
for (const ids of this.opened.values()) {
|
||||
for (const id of ids) {
|
||||
if (!focus.has(id)) open.add(id)
|
||||
}
|
||||
}
|
||||
const visible = new Set<string>()
|
||||
for (const ids of this.visible.values()) for (const id of ids) visible.add(id)
|
||||
const attached = new Set<string>(visible)
|
||||
for (const ids of this.attached.values()) for (const id of ids) attached.add(id)
|
||||
|
||||
this.viewedSending = true
|
||||
this.viewedDirty = false
|
||||
void this.client.session
|
||||
.viewed({ focused: [...focus], open: [...open] })
|
||||
.viewed({ viewer: { id: this.viewerId, active: this.active }, attached: [...attached], visible: [...visible] })
|
||||
.catch((err) => console.warn("[Kilo New] ConnectionService: viewed flush failed:", err))
|
||||
.finally(() => {
|
||||
this.viewedSending = false
|
||||
@@ -674,12 +694,23 @@ export class KiloConnectionService {
|
||||
this.permissionDirectories.clear()
|
||||
this.questionDirectories.clear()
|
||||
this.questionRevision += 1
|
||||
this.focused.clear()
|
||||
this.opened.clear()
|
||||
if (this.client?.session?.viewed) {
|
||||
void this.client.session
|
||||
.viewed({ viewer: { id: this.viewerId, active: false }, attached: [], visible: [] })
|
||||
.catch(() => {})
|
||||
}
|
||||
this.attached.clear()
|
||||
this.visible.clear()
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
if (this.checkinTimer) {
|
||||
clearInterval(this.checkinTimer)
|
||||
this.checkinTimer = null
|
||||
}
|
||||
this.windowStateDisposable?.dispose()
|
||||
this.windowStateDisposable = null
|
||||
this.viewedDirty = false
|
||||
this.unsubRemote?.()
|
||||
this.unsubRemote = null
|
||||
@@ -747,6 +778,7 @@ export class KiloConnectionService {
|
||||
|
||||
private resetConnection(): void {
|
||||
this.stopHealthPoll()
|
||||
this.stopCheckin()
|
||||
const sse = this.sseClient
|
||||
this.sseClient = null
|
||||
sse?.disconnect()
|
||||
@@ -837,6 +869,7 @@ export class KiloConnectionService {
|
||||
resolveConnected?.()
|
||||
resolveConnected = null
|
||||
rejectConnected = null
|
||||
this.flushViewed()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -851,10 +884,24 @@ export class KiloConnectionService {
|
||||
|
||||
await connectedPromise
|
||||
|
||||
this.startCheckin()
|
||||
// Start the independent health poll once we are confirmed connected.
|
||||
this.startHealthPoll(config.baseUrl, config.password)
|
||||
}
|
||||
|
||||
private startCheckin(): void {
|
||||
this.stopCheckin()
|
||||
this.checkinTimer = setInterval(() => this.flushViewed(), 60_000)
|
||||
this.checkinTimer.unref?.()
|
||||
}
|
||||
|
||||
private stopCheckin(): void {
|
||||
if (this.checkinTimer) {
|
||||
clearInterval(this.checkinTimer)
|
||||
this.checkinTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private handlePermissionEvent(event: SSEPayload, directory?: string): void {
|
||||
if (event.type === "permission.asked" && directory) {
|
||||
this.recordPermissionDirectory(event.properties.id, directory)
|
||||
|
||||
@@ -80,6 +80,8 @@ const mockVscode = {
|
||||
},
|
||||
window: {
|
||||
activeTextEditor: undefined,
|
||||
state: { focused: true },
|
||||
onDidChangeWindowState: () => ({ dispose: noop }),
|
||||
activeNotebookEditor: undefined,
|
||||
visibleTextEditors: [],
|
||||
visibleNotebookEditors: [],
|
||||
|
||||
@@ -216,8 +216,12 @@ describe("Agent Manager Provider Messages", () => {
|
||||
|
||||
it("clears remote session registrations when the panel closes", () => {
|
||||
const body = getMethodBody("attachPanel")
|
||||
expect(body).toContain('this.connectionService.unregisterFocused("agent-manager")')
|
||||
expect(body).toContain('this.connectionService.registerOpen("agent-manager", [])')
|
||||
// Presence must be cleared via visiblePresence.clear() — a direct
|
||||
// registerVisible("agent-manager", []) would leave a stale displayed id
|
||||
// that re-registers on the next flush after the panel reopens.
|
||||
expect(body).toContain("this.visiblePresence.clear()")
|
||||
expect(body).not.toContain('this.connectionService.registerVisible("agent-manager"')
|
||||
expect(body).not.toContain('this.connectionService.registerAttached("agent-manager"')
|
||||
expect(body).toContain("this.activeSessionId = undefined")
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { visible } from "../../webview-ui/agent-manager/remote-sessions"
|
||||
|
||||
const APP = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/AgentManagerApp.tsx")
|
||||
|
||||
function flat(source: string) {
|
||||
return source.replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
test("reports a real session only while its chat surface is displayed", () => {
|
||||
expect(visible("ses_1", false)).toBe("ses_1")
|
||||
expect(visible("ses_1", true)).toBeNull()
|
||||
})
|
||||
|
||||
test("does not report synthetic pending or cloud preview IDs", () => {
|
||||
expect(visible("pending:1", false)).toBeNull()
|
||||
expect(visible("cloud:1", false)).toBeNull()
|
||||
})
|
||||
|
||||
test("blocks visible presence while setup or an empty pane covers chat", () => {
|
||||
const source = flat(fs.readFileSync(APP, "utf-8"))
|
||||
expect(source).toContain(
|
||||
"visible( session.currentSessionID(), !!terms.activeId() || reviewActive() || history() || !!overlay() || contextEmpty(), )",
|
||||
)
|
||||
expect(source).toContain("<Show when={overlay()}>")
|
||||
})
|
||||
@@ -199,9 +199,10 @@ function createConnection(client: ReturnType<typeof createClient>) {
|
||||
recordMessageSessionId: () => undefined,
|
||||
notifyNotificationDismissed: () => undefined,
|
||||
pruneSession: () => undefined,
|
||||
registerFocused: () => undefined,
|
||||
unregisterFocused: () => undefined,
|
||||
registerOpen: () => undefined,
|
||||
registerVisible: () => undefined,
|
||||
unregisterVisible: () => undefined,
|
||||
registerAttached: () => undefined,
|
||||
unregisterAttached: () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Source contract tests for session-presence registration.
|
||||
*
|
||||
* Static analysis — reads KiloProvider.ts, AgentManagerProvider.ts,
|
||||
* vscode-host.ts, and connection-service.ts and verifies the locked
|
||||
* viewed/presence behavior from the presence plan:
|
||||
*
|
||||
* - Editor panels register visible keyed on `panel.visible` and the
|
||||
* synchronous `contextSessionID`; attachment persists while hidden.
|
||||
* - Embedded Agent Manager providers skip generic viewed registration
|
||||
* (`disableViewedRegistration`) so sessions are not double-reported.
|
||||
* - The connection service resends the full snapshot on backend reconnect.
|
||||
* - Agent Manager visible presence is routed through
|
||||
* AgentManagerVisiblePresence so cleanup cannot leave a stale displayed id.
|
||||
*
|
||||
* Protects against accidental removal during Kilo development.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
|
||||
const AGENT_MANAGER_PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
|
||||
const VSCODE_HOST_FILE = path.join(ROOT, "src/agent-manager/vscode-host.ts")
|
||||
const CONNECTION_SERVICE_FILE = path.join(ROOT, "src/services/cli-backend/connection-service.ts")
|
||||
|
||||
function readFile(filePath: string): string {
|
||||
return fs.readFileSync(filePath, "utf-8")
|
||||
}
|
||||
|
||||
describe("KiloProvider editor-panel visible registration contract", () => {
|
||||
const source = readFile(KILOPROVIDER_FILE)
|
||||
// The bindPanel callback installed in resolveWebviewPanel.
|
||||
const match = source.match(
|
||||
/this\.viewStateDisposable = this\.visibleTaskStreams\.bindPanel\(panel, \(\) => \{([\s\S]*?)\n {4}\}\)/,
|
||||
)
|
||||
|
||||
it("binds a view-state callback on the panel", () => {
|
||||
expect(match).not.toBeNull()
|
||||
})
|
||||
|
||||
it("registers visible keyed on panel.visible and the synchronous contextSessionID", () => {
|
||||
// `panel.active` would drop visible-but-inactive split editors, and
|
||||
// `this.currentSession?.id` is populated asynchronously — rapid A→B
|
||||
// navigation must report B without awaiting B's metadata fetch.
|
||||
const body = match![1]
|
||||
expect(body).toContain("this.contextSessionID")
|
||||
expect(body).toContain("panel.visible")
|
||||
expect(body).toContain("this.connectionService.registerVisible(this.instanceId,")
|
||||
expect(body).not.toContain("panel.active")
|
||||
expect(body).not.toContain("this.currentSession")
|
||||
})
|
||||
|
||||
it("does not clear attachment when the panel is hidden", () => {
|
||||
// Hidden editor tabs stay reachable for remote control: the panel
|
||||
// view-state callback must never touch the attached registration
|
||||
// (directly or via focusSession) — attachment is cleared only by the
|
||||
// dispose/clear/delete paths.
|
||||
const body = match![1]
|
||||
expect(body).toContain("this.streams.focus(panel.visible ? id : undefined)")
|
||||
expect(body).not.toContain("registerAttached")
|
||||
expect(body).not.toContain("focusSession")
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider disableViewedRegistration contract", () => {
|
||||
const kiloProvider = readFile(KILOPROVIDER_FILE)
|
||||
const vscodeHost = readFile(VSCODE_HOST_FILE)
|
||||
|
||||
it("registerPresence skips viewed registration when the option is set", () => {
|
||||
const match = kiloProvider.match(/private registerPresence\(\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
const body = match![1]
|
||||
const guard = body.indexOf("if (this.opts.disableViewedRegistration) return")
|
||||
const visible = body.indexOf("this.connectionService.registerVisible(this.instanceId,")
|
||||
const attached = body.indexOf("this.connectionService.registerAttached(this.instanceId,")
|
||||
expect(guard).toBeGreaterThanOrEqual(0)
|
||||
expect(visible).toBeGreaterThan(guard)
|
||||
expect(attached).toBeGreaterThan(guard)
|
||||
})
|
||||
|
||||
it("focusSession and trackOpenSessions report through registerPresence", () => {
|
||||
// Both the focused session (visible) and the open local tabs (attached)
|
||||
// funnel into one snapshot so neither write can clobber the other.
|
||||
const focus = kiloProvider.match(/private focusSession\(id\?: string\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(focus).not.toBeNull()
|
||||
expect(focus![1]).toContain("this.registerPresence()")
|
||||
const track = kiloProvider.match(/private trackOpenSessions\(ids: string\[\]\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(track).not.toBeNull()
|
||||
expect(track![1]).toContain("this.registerPresence()")
|
||||
})
|
||||
|
||||
it("registerPresence attaches the open local tabs plus the focused session", () => {
|
||||
const match = kiloProvider.match(/private registerPresence\(\): void \{([\s\S]*?)\n {2}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
const body = match![1]
|
||||
expect(body).toContain("const attached = new Set(this.openSessionIds)")
|
||||
expect(body).toContain("if (focused) attached.add(focused)")
|
||||
})
|
||||
|
||||
it("the editor-panel view-state callback honors the same option", () => {
|
||||
const match = kiloProvider.match(
|
||||
/this\.viewStateDisposable = this\.visibleTaskStreams\.bindPanel\(panel, \(\) => \{([\s\S]*?)\n {4}\}\)/,
|
||||
)
|
||||
expect(match).not.toBeNull()
|
||||
const guard = match![1].indexOf("if (this.opts.disableViewedRegistration) return")
|
||||
const visible = match![1].indexOf("registerVisible")
|
||||
expect(guard).toBeGreaterThanOrEqual(0)
|
||||
expect(visible).toBeGreaterThan(guard)
|
||||
})
|
||||
|
||||
it("embedded Agent Manager providers disable generic viewed registration", () => {
|
||||
// Each Agent Manager panel hosts a full KiloProvider; the "agent-manager"
|
||||
// keys own presence there, so the embedded provider must not
|
||||
// double-register under its own instanceId.
|
||||
expect(vscodeHost).toContain("disableViewedRegistration: true")
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService connection snapshot contract", () => {
|
||||
const source = readFile(CONNECTION_SERVICE_FILE)
|
||||
|
||||
it("sends the accumulated snapshot on initial connection and reconnect", () => {
|
||||
const start = source.indexOf('if (sseState === "connected")')
|
||||
const end = source.indexOf('if (!didConnect && sseState === "disconnected")', start)
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const body = source.slice(start, end)
|
||||
expect(body).toContain("this.flushViewed()")
|
||||
expect(body).not.toContain("if (isReconnect)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("AgentManagerProvider visible-presence contract", () => {
|
||||
const source = readFile(AGENT_MANAGER_PROVIDER_FILE)
|
||||
|
||||
it("routes all agent-manager visible registration through AgentManagerVisiblePresence", () => {
|
||||
// Exactly one direct registerVisible("agent-manager", ...) call site — the
|
||||
// presence callback. Cleanup paths that bypassed it (registering [] without
|
||||
// clearing the displayed id) let a stale id re-register on the next flush.
|
||||
const sites = source.match(/registerVisible\("agent-manager"/g) ?? []
|
||||
expect(sites).toHaveLength(1)
|
||||
expect(source).toMatch(
|
||||
/new AgentManagerVisiblePresence\(\s*\(ids\) => this\.connectionService\.registerVisible\("agent-manager", ids\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("async shutdown clears both the visible and attached registrations", () => {
|
||||
// clear() resets the displayed id and empties the attached set, so a
|
||||
// stale id cannot re-register on a later flush.
|
||||
const match = source.match(/private async disposeAsync\(\): Promise<void> \{([\s\S]*?)\n {2}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toContain("this.visiblePresence.clear()")
|
||||
})
|
||||
|
||||
it("routes the webview presence messages to visiblePresence.handle", () => {
|
||||
// The webview reports the open tab set (→ attached) and the actually
|
||||
// displayed real session id (null for terminal/review/pending/empty
|
||||
// tabs, → visible); both flow through the presence helper.
|
||||
expect(source).toMatch(
|
||||
/if \(m\.type === "agentManager\.openSessions" \|\| m\.type === "agentManager\.visibleSession"\) \{\s*this\.visiblePresence\.handle\(m\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it("does not let background message loads override webview visibility", () => {
|
||||
const match = source.match(/if \(m\.type === "loadMessages"\) \{([\s\S]*?)\n {4}\}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).not.toContain("visiblePresence.setDisplayed")
|
||||
})
|
||||
|
||||
it("recomputes visible presence when panel visibility changes", () => {
|
||||
// A hidden Agent Manager panel must drop its session from visible (while
|
||||
// keeping it attached); reappearing must re-register the retained id.
|
||||
const match = source.match(/ctx\.onDidChangeVisibility\(\(visible\) => \{([\s\S]*?)\n {4}\}\)/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toContain("this.visiblePresence.flush()")
|
||||
})
|
||||
})
|
||||
@@ -194,9 +194,9 @@ describe("KiloProvider pruneDeletedSession contract", () => {
|
||||
})
|
||||
|
||||
it("unfocuses the streams when the deleted id matches the focused session", () => {
|
||||
// Without this, connectionService.focused still reports the deleted id to
|
||||
// the backend (viewed.focused), and focusSession() never calls
|
||||
// unregisterFocused for this instance.
|
||||
// Without this, connectionService still reports the deleted id to the
|
||||
// backend as visible, and focusSession() never clears the visible
|
||||
// registration for this instance.
|
||||
const match = source.match(/pruneDeletedSession\(sessionID: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toMatch(/if \(this\.streams\.focused === sessionID\) this\.focusSession\(undefined\)/)
|
||||
@@ -542,15 +542,15 @@ describe("Cloud import parts cleanup contract", () => {
|
||||
describe("KiloConnectionService pruneSession contract", () => {
|
||||
const source = readFile(CONNECTION_SERVICE_FILE)
|
||||
|
||||
it("drops the deleted session from focused and opened Maps", () => {
|
||||
it("drops the deleted session from attached and visible Maps", () => {
|
||||
// KiloProvider's pruneDeletedSession calls connectionService.pruneSession.
|
||||
// Without clearing focused/opened entries whose value is the deleted id,
|
||||
// the backend keeps receiving viewed.focused with the dead session id and
|
||||
// any background tab opener stays registered for it.
|
||||
// Without clearing attached/visible entries whose value is the deleted id,
|
||||
// the backend keeps receiving the dead session id and any background tab
|
||||
// opener stays registered for it.
|
||||
const match = source.match(/pruneSession\(sessionId: string\): void \{([\s\S]*?)\n \}/)
|
||||
expect(match).not.toBeNull()
|
||||
expect(match![1]).toMatch(/this\.focused\.delete\(key\)/)
|
||||
expect(match![1]).toMatch(/this\.opened\.(?:set|delete)/)
|
||||
expect(match![1]).toMatch(/this\.attached\.(?:set|delete)/)
|
||||
expect(match![1]).toMatch(/this\.visible\.(?:set|delete)/)
|
||||
expect(match![1]).toMatch(/this\.flushViewed\(\)/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -111,7 +111,7 @@ import {
|
||||
} from "../src/utils/draft-store"
|
||||
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
|
||||
import { createTabOrderSync } from "./tab-order-sync"
|
||||
import { reportRemoteSessions } from "./remote-sessions"
|
||||
import { reportRemoteSessions, reportVisibleSession, visible } from "./remote-sessions"
|
||||
import { ConstrainDragYAxis } from "../src/components/chat/TabDnd"
|
||||
import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal"
|
||||
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
|
||||
@@ -768,6 +768,21 @@ const AgentManagerContent: Component = () => {
|
||||
return false
|
||||
})
|
||||
|
||||
const overlay = createMemo((): SetupState | null => {
|
||||
const state = setup()
|
||||
const sel = selection()
|
||||
if (state.active && (!state.worktreeId || sel === state.worktreeId)) return state
|
||||
if (typeof sel !== "string" || sel === LOCAL) return null
|
||||
const busy = busyWorktrees().get(sel)
|
||||
if (busy?.reason !== "setting-up") return null
|
||||
const tree = worktrees().find((item) => item.id === sel)
|
||||
return {
|
||||
active: true,
|
||||
message: busy.message ?? "",
|
||||
branch: busy.branch ?? tree?.branch,
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sel = selection()
|
||||
if (sel === null) {
|
||||
@@ -796,7 +811,13 @@ const AgentManagerContent: Component = () => {
|
||||
if (reviewActive()) return REVIEW_TAB_ID
|
||||
return session.currentSessionID() ?? activePendingId()
|
||||
})
|
||||
|
||||
const visibleSession = createMemo(() =>
|
||||
visible(
|
||||
session.currentSessionID(),
|
||||
!!terms.activeId() || reviewActive() || history() || !!overlay() || contextEmpty(),
|
||||
),
|
||||
)
|
||||
reportVisibleSession(vscode, visibleSession)
|
||||
const worktreeLabel = (wt: WorktreeState): string => {
|
||||
if (wt.label) return wt.label
|
||||
return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch)
|
||||
@@ -2914,54 +2935,29 @@ const AgentManagerContent: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{(() => {
|
||||
// Show setup overlay: either the transient ready/error state for the selected worktree,
|
||||
// or if the selected worktree is still being set up (from busyWorktrees map)
|
||||
const overlayState = (): SetupState | null => {
|
||||
const s = setup()
|
||||
const sel = selection()
|
||||
// Transient ready/error overlay for the selected worktree (or worktree-less setup)
|
||||
if (s.active && (!s.worktreeId || sel === s.worktreeId)) return s
|
||||
// Persistent setup-in-progress for the currently selected worktree
|
||||
if (typeof sel === "string" && sel !== LOCAL) {
|
||||
const busy = busyWorktrees().get(sel)
|
||||
if (busy?.reason === "setting-up") {
|
||||
const wt = worktrees().find((w) => w.id === sel)
|
||||
return {
|
||||
active: true,
|
||||
message: busy.message ?? "",
|
||||
branch: busy.branch ?? wt?.branch,
|
||||
} satisfies SetupState
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Show when={overlayState()}>
|
||||
{(state) => (
|
||||
<div class="am-setup-overlay">
|
||||
<div class="am-setup-card">
|
||||
<Icon name="branch" size="large" />
|
||||
<div class="am-setup-title">
|
||||
{state().error ? t("agentManager.setup.failed") : t("agentManager.setup.settingUp")}
|
||||
</div>
|
||||
<Show when={state().branch}>
|
||||
<div class="am-setup-branch">{state().branch}</div>
|
||||
</Show>
|
||||
<div class="am-setup-status">
|
||||
<Show when={!state().error} fallback={<Icon name="circle-x" size="small" />}>
|
||||
<Spinner class="am-setup-spinner" />
|
||||
</Show>
|
||||
<span>
|
||||
{state().errorCode ? t(`agentManager.setup.error.${state().errorCode}`) : state().message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={overlay()}>
|
||||
{(state) => (
|
||||
<div class="am-setup-overlay">
|
||||
<div class="am-setup-card">
|
||||
<Icon name="branch" size="large" />
|
||||
<div class="am-setup-title">
|
||||
{state().error ? t("agentManager.setup.failed") : t("agentManager.setup.settingUp")}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
<Show when={state().branch}>
|
||||
<div class="am-setup-branch">{state().branch}</div>
|
||||
</Show>
|
||||
<div class="am-setup-status">
|
||||
<Show when={!state().error} fallback={<Icon name="circle-x" size="small" />}>
|
||||
<Spinner class="am-setup-spinner" />
|
||||
</Show>
|
||||
<span>
|
||||
{state().errorCode ? t(`agentManager.setup.error.${state().errorCode}`) : state().message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={history()}>
|
||||
<HistoryView
|
||||
onSelectSession={(id) => {
|
||||
|
||||
@@ -5,8 +5,17 @@ type Bridge = {
|
||||
postMessage(message: { type: "agentManager.openSessions"; sessionIDs: string[] }): void
|
||||
}
|
||||
|
||||
type VisibleBridge = {
|
||||
postMessage(message: { type: "agentManager.visibleSession"; sessionID: string | null }): void
|
||||
}
|
||||
|
||||
type Managed = { id: string; worktreeId: string | null }
|
||||
|
||||
export function visible(id: string | undefined, blocked: boolean): string | null {
|
||||
if (blocked || !id?.startsWith("ses")) return null
|
||||
return id
|
||||
}
|
||||
|
||||
export function reportRemoteSessions(
|
||||
vscode: Bridge,
|
||||
local: Accessor<string[]>,
|
||||
@@ -20,3 +29,12 @@ export function reportRemoteSessions(
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Report the actually displayed real session id, or null when a terminal,
|
||||
// review, pending, or empty tab is shown. Drives only visible presence;
|
||||
// retained attached tabs are unaffected.
|
||||
export function reportVisibleSession(vscode: VisibleBridge, visible: Accessor<string | null>): void {
|
||||
createEffect(() => {
|
||||
vscode.postMessage({ type: "agentManager.visibleSession", sessionID: visible() })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -980,6 +980,11 @@ export interface SidebarOpenSessionsMessage {
|
||||
sessionIDs: string[]
|
||||
}
|
||||
|
||||
export interface AgentManagerVisibleSessionMessage {
|
||||
type: "agentManager.visibleSession"
|
||||
sessionID: string | null
|
||||
}
|
||||
|
||||
export interface RequestAutoApproveStateMessage {
|
||||
type: "requestAutoApproveState"
|
||||
}
|
||||
@@ -1366,6 +1371,7 @@ export type WebviewMessage =
|
||||
| SetDefaultBaseBranchRequest
|
||||
| AgentManagerOpenSessionsMessage
|
||||
| SidebarOpenSessionsMessage
|
||||
| AgentManagerVisibleSessionMessage
|
||||
| RequestAutoApproveStateMessage
|
||||
| ToggleAutoApproveMessage
|
||||
| RequestSandboxStatusMessage
|
||||
|
||||
@@ -38,6 +38,12 @@ async function provide<R>(input: { directory: string; fn: () => R }): Promise<R>
|
||||
return provide(input)
|
||||
}
|
||||
|
||||
function same(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false
|
||||
for (const id of a) if (!b.has(id)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export namespace KiloSessions {
|
||||
export const Event = {
|
||||
RemoteStatusChanged: BusEvent.define(
|
||||
@@ -203,8 +209,7 @@ export namespace KiloSessions {
|
||||
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined
|
||||
let enabling: Promise<void> | undefined
|
||||
let remoteSeq = 0
|
||||
const focused = new Set<string>()
|
||||
const opened = new Set<string>()
|
||||
const attached = new Set<string>()
|
||||
const statusSyncs = new Map<string, { running: boolean; dirty: boolean }>()
|
||||
const STATUS_TIMEOUT_MS = 3_000
|
||||
|
||||
@@ -416,8 +421,7 @@ export namespace KiloSessions {
|
||||
const statusMap = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
|
||||
const statuses: Record<string, SessionStatus.Info> = Object.fromEntries(statusMap)
|
||||
const ids = new Set(Object.keys(statuses))
|
||||
for (const id of focused) ids.add(id)
|
||||
for (const id of opened) ids.add(id)
|
||||
for (const id of attached) ids.add(id)
|
||||
const results = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
Effect.all(
|
||||
@@ -438,11 +442,7 @@ export namespace KiloSessions {
|
||||
),
|
||||
)
|
||||
const sessions = results.filter((r): r is NonNullable<typeof r> => !!r)
|
||||
return {
|
||||
sessions,
|
||||
focused: focused.size > 0 ? [...focused] : undefined,
|
||||
open: opened.size > 0 ? [...opened] : undefined,
|
||||
}
|
||||
return { sessions }
|
||||
}
|
||||
|
||||
const conn = RemoteWS.connect({
|
||||
@@ -515,15 +515,11 @@ export namespace KiloSessions {
|
||||
connected: remote?.conn.connected ?? false,
|
||||
}
|
||||
}
|
||||
export function setViewedSessions(input: { focused: readonly string[]; open?: readonly string[] }) {
|
||||
focused.clear()
|
||||
opened.clear()
|
||||
for (const id of input.focused) {
|
||||
focused.add(id)
|
||||
}
|
||||
for (const id of input.open ?? []) {
|
||||
opened.add(id)
|
||||
}
|
||||
export function setAttachedSessions(ids: readonly string[]) {
|
||||
const next = new Set(ids)
|
||||
if (same(next, attached)) return
|
||||
attached.clear()
|
||||
for (const id of next) attached.add(id)
|
||||
if (remote) void remote.conn.heartbeat().catch((err) => log.warn("heartbeat failed", { error: String(err) }))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ export namespace RemoteProtocol {
|
||||
export const Heartbeat = z.object({
|
||||
type: z.literal("heartbeat"),
|
||||
sessions: z.array(SessionInfo),
|
||||
focused: z.array(z.string()).optional(),
|
||||
open: z.array(z.string()).optional(),
|
||||
protocolVersion: z.string().optional(), // lets relay detect CLI capabilities without probing commands
|
||||
})
|
||||
export type Heartbeat = z.infer<typeof Heartbeat>
|
||||
|
||||
@@ -7,7 +7,7 @@ export namespace RemoteWS {
|
||||
export type Options = {
|
||||
url: string
|
||||
getToken: () => Promise<string | undefined>
|
||||
getSessions: () => Promise<{ sessions: SessionInfo[]; focused?: string[]; open?: string[] }>
|
||||
getSessions: () => Promise<{ sessions: SessionInfo[] }>
|
||||
log: {
|
||||
info: (...args: any[]) => void
|
||||
error: (...args: any[]) => void
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
ChatToken,
|
||||
ContentBlock,
|
||||
ConversationActivityEvent,
|
||||
ConversationLeftEvent,
|
||||
ConversationListItem,
|
||||
ConversationRenamedEvent,
|
||||
ConversationStatusEvent,
|
||||
@@ -29,7 +30,7 @@ import type {
|
||||
TypingMember,
|
||||
} from "./types"
|
||||
import { KiloChatClient } from "./kilo-chat-client"
|
||||
import { EventServiceClient } from "./event-service-client"
|
||||
import { EventServiceClient } from "@/kilocode/event-service/client"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "claw-chat" })
|
||||
@@ -282,7 +283,7 @@ export async function connect(input: ConnectInput): Promise<ClawChatClient> {
|
||||
emit(conversationsListeners, conversations)
|
||||
})
|
||||
|
||||
events.on("conversation.left", (ctx, e) => {
|
||||
events.on("conversation.left", (ctx, e: ConversationLeftEvent) => {
|
||||
if (ctx !== sandboxCtx) return
|
||||
conversations = conversations.filter((c) => c.conversationId !== e.conversationId)
|
||||
emit(conversationsListeners, conversations)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* via thin integration points so the upstream diff stays minimal.
|
||||
*/
|
||||
|
||||
import { createEffect, on } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import { useBindings } from "@tui/keymap"
|
||||
@@ -77,37 +77,72 @@ export function useSessionEffects(deps: {
|
||||
sync: ReturnType<typeof useSync>
|
||||
}) {
|
||||
const pty = process.env.KILO_PTY_ID
|
||||
const state = { prev: "" }
|
||||
const viewerId = crypto.randomUUID()
|
||||
const renderer = useRenderer()
|
||||
const session = createMemo(() => (deps.route.data.type === "session" ? deps.route.data.sessionID : undefined))
|
||||
let active = true
|
||||
const meta = { prev: "" }
|
||||
|
||||
function send() {
|
||||
const id = session()
|
||||
const ids = id ? [id] : []
|
||||
deps.sdk.client.session.viewed({ viewer: { id: viewerId, active }, attached: ids, visible: ids }).catch(() => {})
|
||||
}
|
||||
|
||||
createEffect(() => send())
|
||||
|
||||
const onFocus = () => {
|
||||
active = true
|
||||
send()
|
||||
}
|
||||
const onBlur = () => {
|
||||
active = false
|
||||
send()
|
||||
}
|
||||
renderer.on("focus", onFocus)
|
||||
renderer.on("blur", onBlur)
|
||||
|
||||
// The server prepends `server.connected` to every SSE (re)connect; a restarted
|
||||
// backend has an empty viewer map, so resend the snapshot immediately instead
|
||||
// of waiting for the 60s check-in.
|
||||
const offConnected = deps.sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "server.connected") send()
|
||||
})
|
||||
|
||||
const timer = setInterval(send, 60_000)
|
||||
|
||||
// Notify server which session the user is viewing
|
||||
createEffect(() => {
|
||||
const sessionID = deps.route.data.type === "session" ? deps.route.data.sessionID : undefined
|
||||
deps.sdk.client.session.viewed({ focused: sessionID ? [sessionID] : [] }).catch(() => {})
|
||||
|
||||
const sessionID = session()
|
||||
if (!pty) return
|
||||
const session = sessionID ? deps.sync.session.get(sessionID) : undefined
|
||||
const key = [sessionID ?? "", session?.title ?? ""].join("\n")
|
||||
if (key === state.prev) return
|
||||
state.prev = key
|
||||
|
||||
const s = sessionID ? deps.sync.session.get(sessionID) : undefined
|
||||
const key = [sessionID ?? "", s?.title ?? ""].join("\n")
|
||||
if (key === meta.prev) return
|
||||
meta.prev = key
|
||||
deps.sdk.client.pty
|
||||
.update({
|
||||
ptyID: pty,
|
||||
sessionID: sessionID ?? null,
|
||||
...(session?.title ? { title: session.title } : {}),
|
||||
...(s?.title ? { title: s.title } : {}),
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
// Evict per-session data from store when navigating away
|
||||
createEffect(
|
||||
on(
|
||||
() => (deps.route.data.type === "session" ? deps.route.data.sessionID : undefined),
|
||||
(current, prev) => {
|
||||
if (prev && prev !== current) deps.sync.session.evict(prev)
|
||||
},
|
||||
),
|
||||
on(session, (current, prev) => {
|
||||
if (prev && prev !== current) deps.sync.session.evict(prev)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
renderer.off("focus", onFocus)
|
||||
renderer.off("blur", onBlur)
|
||||
offConnected()
|
||||
clearInterval(timer)
|
||||
active = false
|
||||
deps.sdk.client.session
|
||||
.viewed({ viewer: { id: viewerId, active: false }, attached: [], visible: [] })
|
||||
.catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+38
-35
@@ -1,9 +1,6 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Event Service WebSocket client for the TUI.
|
||||
* Generic Event Service WebSocket client.
|
||||
*
|
||||
* Minimal inline port of `@kilocode/event-service` (cloud monorepo).
|
||||
* Connects via a two-step ticket flow:
|
||||
* 1. POST `/connect-ticket` with `Authorization: Bearer <JWT>` to mint a
|
||||
* single-use ticket (30 s TTL).
|
||||
@@ -11,10 +8,15 @@
|
||||
* `kilo.events.v1`.
|
||||
*
|
||||
* Uses the global `WebSocket` constructor (Bun, Node 22+, browsers).
|
||||
*
|
||||
* Disconnect invalidation: every `connect()` and `disconnect()` bumps a
|
||||
* generation counter. `connectOnce()` captures the generation at entry and,
|
||||
* after the ticket mint resolves, refuses to construct a socket if the
|
||||
* generation changed or the client was disposed. `disconnect()` also aborts
|
||||
* an in-flight ticket request and the pending handshake, so a ticket response
|
||||
* arriving after disposal can never create a socket.
|
||||
*/
|
||||
|
||||
import type { KiloChatEventMap, KiloChatEventName } from "./types"
|
||||
|
||||
const WS_SUBPROTOCOL = "kilo.events.v1"
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000
|
||||
const PING_INTERVAL_MS = 15_000
|
||||
@@ -44,12 +46,9 @@ export class HandshakeTimeoutError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Close codes that signal the server rejected us for auth/policy reasons
|
||||
// and reconnecting with the same token is pointless. Everything else
|
||||
// (including 1006 "abnormal closure" from flaky networks) is transient.
|
||||
function isAuthCloseCode(code: number): boolean {
|
||||
if (code === 1008) return true // Policy Violation
|
||||
if (code === 4401 || code === 4403) return true // Custom auth rejection
|
||||
if (code === 1008) return true
|
||||
if (code === 4401 || code === 4403) return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -59,13 +58,10 @@ export type EventServiceConfig = {
|
||||
url: string
|
||||
getToken: () => Promise<string>
|
||||
onUnauthorized?: () => void
|
||||
onServerError?: (error: unknown) => void
|
||||
handshakeTimeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The event-service base URL is configured as a WebSocket URL (`wss://…` /
|
||||
* `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip
|
||||
* the trailing slash and swap the protocol so `fetch()` accepts the URL.
|
||||
*/
|
||||
function toHttpBase(wsBase: string): string {
|
||||
const trimmed = wsBase.replace(/\/$/, "")
|
||||
if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6)
|
||||
@@ -77,16 +73,20 @@ export class EventServiceClient {
|
||||
private readonly url: string
|
||||
private readonly getToken: () => Promise<string>
|
||||
private readonly onUnauthorized: (() => void) | undefined
|
||||
private readonly onServerError: ((error: unknown) => void) | undefined
|
||||
private readonly handshakeTimeoutMs: number
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private connected = false
|
||||
private destroyed = false
|
||||
private generation = 0
|
||||
private reconnectAttempts = 0
|
||||
private hasConnectedBefore = false
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private abortHandshake: ((err: Error) => void) | null = null
|
||||
private tickets = new Set<AbortController>()
|
||||
|
||||
private eventHandlers = new Map<string, Set<EventHandler>>()
|
||||
private activeContexts = new Set<string>()
|
||||
@@ -96,9 +96,12 @@ export class EventServiceClient {
|
||||
this.url = config.url
|
||||
this.getToken = config.getToken
|
||||
this.onUnauthorized = config.onUnauthorized
|
||||
this.onServerError = config.onServerError
|
||||
this.handshakeTimeoutMs = config.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
const gen = ++this.generation
|
||||
this.destroyed = false
|
||||
this.reconnectAttempts = 0
|
||||
if (this.reconnectTimer !== null) {
|
||||
@@ -108,13 +111,17 @@ export class EventServiceClient {
|
||||
try {
|
||||
await this.connectOnce()
|
||||
} catch (err) {
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
if (this.handleAuthFailure(err)) return
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.generation++
|
||||
this.destroyed = true
|
||||
for (const ctrl of this.tickets) ctrl.abort()
|
||||
this.tickets.clear()
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
@@ -149,9 +156,9 @@ export class EventServiceClient {
|
||||
}
|
||||
}
|
||||
|
||||
on<N extends KiloChatEventName>(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void {
|
||||
on<T = unknown>(event: string, handler: (context: string, payload: T) => void): () => void {
|
||||
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
|
||||
const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N])
|
||||
const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as T)
|
||||
set.add(wrapped)
|
||||
this.eventHandlers.set(event, set)
|
||||
return () => {
|
||||
@@ -181,6 +188,7 @@ export class EventServiceClient {
|
||||
}
|
||||
|
||||
private async connectOnce(): Promise<void> {
|
||||
const gen = this.generation
|
||||
if (this.ws) {
|
||||
const old = this.ws
|
||||
this.ws = null
|
||||
@@ -188,7 +196,9 @@ export class EventServiceClient {
|
||||
}
|
||||
|
||||
const token = await this.getToken()
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
const ticket = await this.fetchTicket(token)
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL])
|
||||
@@ -215,9 +225,10 @@ export class EventServiceClient {
|
||||
this.handshakeTimer = null
|
||||
if (this.ws === ws) ws.close(1000, "handshake-timeout")
|
||||
settleReject(new HandshakeTimeoutError())
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
}, this.handshakeTimeoutMs)
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
if (this.ws !== ws) return
|
||||
const isReconnect = this.hasConnectedBefore
|
||||
this.connected = true
|
||||
this.hasConnectedBefore = true
|
||||
@@ -231,6 +242,7 @@ export class EventServiceClient {
|
||||
})
|
||||
|
||||
ws.addEventListener("message", (event: MessageEvent) => {
|
||||
if (this.ws !== ws) return
|
||||
this.handleMessage(String(event.data))
|
||||
})
|
||||
|
||||
@@ -240,10 +252,6 @@ export class EventServiceClient {
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
this.clearHandshakeTimer()
|
||||
// A handshake failure always fires `close` after `error`, so we
|
||||
// settle here with a classification based on the close code:
|
||||
// explicit auth/policy codes → fatal; anything else → transient
|
||||
// and the caller (`connect`) will schedule a reconnect.
|
||||
if (!wasConnected) {
|
||||
if (isAuthCloseCode(event.code)) {
|
||||
settleReject(new WebSocketAuthError())
|
||||
@@ -257,23 +265,13 @@ export class EventServiceClient {
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
})
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
// Swallowed: the `close` event fires right after and carries the
|
||||
// close code we need to distinguish auth failures from network
|
||||
// blips. Settling here loses that context.
|
||||
})
|
||||
ws.addEventListener("error", () => {})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a single-use connection ticket. The event-service issues a 30 s ticket
|
||||
* scoped to the bearer JWT; the WebSocket upgrade then consumes it.
|
||||
*
|
||||
* `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only
|
||||
* accepts `http(s)`, so we rewrite the protocol before the HTTP call.
|
||||
*/
|
||||
private async fetchTicket(token: string): Promise<string> {
|
||||
const ctrl = new AbortController()
|
||||
this.tickets.add(ctrl)
|
||||
const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(toHttpBase(this.url) + "/connect-ticket", {
|
||||
@@ -300,6 +298,7 @@ export class EventServiceClient {
|
||||
throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
this.tickets.delete(ctrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +334,7 @@ export class EventServiceClient {
|
||||
}
|
||||
if (m.type === "error") {
|
||||
console.warn("[Kilo] event-service server error", m)
|
||||
this.onServerError?.(m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +370,10 @@ export class EventServiceClient {
|
||||
this.reconnectAttempts++
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
if (this.destroyed) return
|
||||
const gen = this.generation
|
||||
this.connectOnce().catch((err) => {
|
||||
if (this.destroyed || this.generation !== gen) return
|
||||
if (this.handleAuthFailure(err)) return
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// Presence context strings mirror the (private) cloud Event Service context
|
||||
// scheme. The cloud package is private, so the literals are duplicated here and
|
||||
// guarded by contract tests; a full drift guard lands later.
|
||||
|
||||
export const CONTEXT_PREFIX = "/presence/"
|
||||
export const CLI_SESSION_PREFIX = "/presence/cli-session/"
|
||||
|
||||
export type Platform = "cli" | "vscode"
|
||||
|
||||
export function platformContext(platform: Platform): string {
|
||||
return `${CONTEXT_PREFIX}${platform}`
|
||||
}
|
||||
|
||||
export function cliSessionContext(sessionId: string): string {
|
||||
return `${CLI_SESSION_PREFIX}${sessionId}`
|
||||
}
|
||||
|
||||
// Event Service enforces a 256-char context limit.
|
||||
export const MAX_CONTEXT_LENGTH = 256
|
||||
// CLI_SESSION_PREFIX is 22 chars, so a session id must be <= 234 to keep the
|
||||
// full context within the 256-char limit.
|
||||
export const MAX_SESSION_ID_LENGTH = MAX_CONTEXT_LENGTH - CLI_SESSION_PREFIX.length
|
||||
|
||||
// Event Service socket limit is 200 contexts.
|
||||
export const MAX_CONTEXTS = 200
|
||||
// Reserve one slot for the platform context; visible session contexts cap at 199.
|
||||
export const MAX_VISIBLE_SESSIONS = MAX_CONTEXTS - 1
|
||||
|
||||
// Per-viewer rejection thresholds: the service rejects oversized snapshots.
|
||||
export const MAX_ATTACHED_PER_VIEWER = 1000
|
||||
export const MAX_VISIBLE_PER_VIEWER = 199
|
||||
|
||||
// Viewer lease TTL: a viewer expires exactly 120s after its last update.
|
||||
export const VIEWER_TTL_MS = 120_000
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
MAX_ATTACHED_PER_VIEWER,
|
||||
MAX_SESSION_ID_LENGTH,
|
||||
MAX_VISIBLE_PER_VIEWER,
|
||||
MAX_VISIBLE_SESSIONS,
|
||||
VIEWER_TTL_MS,
|
||||
cliSessionContext,
|
||||
platformContext,
|
||||
type Platform,
|
||||
} from "./context"
|
||||
|
||||
export type ViewerSnapshot = {
|
||||
viewer: { id: string; active: boolean }
|
||||
attached: readonly string[]
|
||||
visible: readonly string[]
|
||||
}
|
||||
|
||||
export type ViewerState = {
|
||||
id: string
|
||||
active: boolean
|
||||
attached: string[]
|
||||
visible: string[]
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
export type ValidationError =
|
||||
| { kind: "missing_viewer" }
|
||||
| { kind: "bad_viewer_id" }
|
||||
| { kind: "attached_too_many" }
|
||||
| { kind: "visible_too_many" }
|
||||
| { kind: "bad_session_id"; id: string }
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
function validSessionId(sid: unknown): boolean {
|
||||
return typeof sid === "string" && sid.startsWith("ses") && sid.length <= MAX_SESSION_ID_LENGTH
|
||||
}
|
||||
|
||||
// Deduplicate preserving first-seen order.
|
||||
export function dedupe(ids: readonly string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
out.push(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ValidationResult =
|
||||
| { ok: true; viewer: { id: string; active: boolean }; attached: string[]; visible: string[] }
|
||||
| { ok: false; error: ValidationError }
|
||||
|
||||
export function validateSnapshot(input: {
|
||||
viewer?: { id?: unknown; active?: unknown }
|
||||
attached?: unknown
|
||||
visible?: unknown
|
||||
}): ValidationResult {
|
||||
const v = input.viewer
|
||||
if (!v || typeof v !== "object") return { ok: false, error: { kind: "missing_viewer" } }
|
||||
const id = v.id
|
||||
if (typeof id !== "string" || !UUID_RE.test(id)) return { ok: false, error: { kind: "bad_viewer_id" } }
|
||||
|
||||
const rawAttached: readonly unknown[] = Array.isArray(input.attached) ? input.attached : []
|
||||
const rawVisible: readonly unknown[] = Array.isArray(input.visible) ? input.visible : []
|
||||
|
||||
// Reject on raw input size first to bound dedupe work.
|
||||
if (rawAttached.length > MAX_ATTACHED_PER_VIEWER) return { ok: false, error: { kind: "attached_too_many" } }
|
||||
if (rawVisible.length > MAX_VISIBLE_PER_VIEWER) return { ok: false, error: { kind: "visible_too_many" } }
|
||||
|
||||
for (const sid of rawAttached) {
|
||||
if (!validSessionId(sid)) {
|
||||
return { ok: false, error: { kind: "bad_session_id", id: typeof sid === "string" ? sid : String(sid) } }
|
||||
}
|
||||
}
|
||||
for (const sid of rawVisible) {
|
||||
if (!validSessionId(sid)) {
|
||||
return { ok: false, error: { kind: "bad_session_id", id: typeof sid === "string" ? sid : String(sid) } }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
viewer: { id, active: v.active === true },
|
||||
attached: dedupe(rawAttached as readonly string[]),
|
||||
visible: dedupe(rawVisible as readonly string[]),
|
||||
}
|
||||
}
|
||||
|
||||
// Union of every viewer's attached ids (retained regardless of active).
|
||||
export function attachedUnion(viewers: readonly ViewerState[]): string[] {
|
||||
const all: string[] = []
|
||||
for (const v of viewers) all.push(...v.attached)
|
||||
return dedupe(all)
|
||||
}
|
||||
|
||||
// Union of ACTIVE viewers' visible ids, deduped and lexicographically capped at
|
||||
// MAX_VISIBLE_SESSIONS. Returns the retained ids and how many were omitted.
|
||||
export function visibleUnion(viewers: readonly ViewerState[]): { ids: string[]; omitted: number } {
|
||||
const all: string[] = []
|
||||
for (const v of viewers) {
|
||||
if (!v.active) continue
|
||||
all.push(...v.visible)
|
||||
}
|
||||
const union = dedupe(all)
|
||||
union.sort()
|
||||
const retained = union.slice(0, MAX_VISIBLE_SESSIONS)
|
||||
return { ids: retained, omitted: union.length - retained.length }
|
||||
}
|
||||
|
||||
// Viewer ids expired at `now` (now >= lastSeen + VIEWER_TTL_MS).
|
||||
export function expiredViewerIds(viewers: readonly ViewerState[], now: number): string[] {
|
||||
const out: string[] = []
|
||||
for (const v of viewers) {
|
||||
if (now >= v.lastSeen + VIEWER_TTL_MS) out.push(v.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Earliest upcoming expiry deadline strictly greater than `now`, or undefined.
|
||||
export function nextExpiryDeadline(viewers: readonly ViewerState[], now: number): number | undefined {
|
||||
let min: number | undefined
|
||||
for (const v of viewers) {
|
||||
const deadline = v.lastSeen + VIEWER_TTL_MS
|
||||
if (deadline <= now) continue
|
||||
if (min === undefined || deadline < min) min = deadline
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// Reconcile desired Event Service contexts: removals first, then additions.
|
||||
export function reconcileContexts(
|
||||
prev: ReadonlySet<string>,
|
||||
next: ReadonlySet<string>,
|
||||
): { remove: string[]; add: string[] } {
|
||||
const remove: string[] = []
|
||||
const add: string[] = []
|
||||
for (const c of prev) if (!next.has(c)) remove.push(c)
|
||||
for (const c of next) if (!prev.has(c)) add.push(c)
|
||||
return { remove, add }
|
||||
}
|
||||
|
||||
// Desired Event Service context set for a platform and the capped visible ids.
|
||||
// The platform context is published only when at least one viewer is active.
|
||||
export function desiredContexts(platform: Platform, active: boolean, visibleIds: readonly string[]): Set<string> {
|
||||
const out = new Set<string>()
|
||||
if (active) out.add(platformContext(platform))
|
||||
for (const id of visibleIds) out.add(cliSessionContext(id))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { EventServiceClient } from "@/kilocode/event-service/client"
|
||||
import { KILO_EVENT_SERVICE_URL } from "@kilocode/kilo-gateway"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import type { Platform } from "./context"
|
||||
import {
|
||||
attachedUnion,
|
||||
desiredContexts,
|
||||
dedupe,
|
||||
expiredViewerIds,
|
||||
nextExpiryDeadline,
|
||||
reconcileContexts,
|
||||
validateSnapshot,
|
||||
visibleUnion,
|
||||
type ViewerSnapshot,
|
||||
type ViewerState,
|
||||
} from "./policy"
|
||||
|
||||
const log = Log.create({ service: "kilo-viewers" })
|
||||
|
||||
function inferPlatform(): Platform | undefined {
|
||||
const p = process.env.KILO_PLATFORM
|
||||
if (p === "vscode") return "vscode"
|
||||
if (p === "cli") return "cli"
|
||||
if (p === undefined || p === "") return "cli"
|
||||
return undefined
|
||||
}
|
||||
|
||||
function extract(auth: Auth.Info | undefined): { token: string | undefined; identity: string | undefined } {
|
||||
const envKey = process.env.KILO_API_KEY?.trim()
|
||||
if (auth?.type === "api" && auth.key.length > 0) return { token: auth.key, identity: "api" }
|
||||
if (auth?.type === "oauth" && auth.access.length > 0)
|
||||
return { token: auth.access, identity: `oauth:${auth.accountId ?? "no-acct"}` }
|
||||
if (auth?.type === "wellknown" && auth.token.length > 0) return { token: auth.token, identity: "wellknown" }
|
||||
if (envKey) return { token: envKey, identity: "env" }
|
||||
return { token: undefined, identity: undefined }
|
||||
}
|
||||
|
||||
function sameArr(a: readonly string[], b: readonly string[]): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
const set = new Set(a)
|
||||
for (const id of b) if (!set.has(id)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export namespace KiloViewers {
|
||||
export interface Interface {
|
||||
readonly update: (snapshot: ViewerSnapshot) => Effect.Effect<void>
|
||||
readonly invalidateAuth: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@kilocode/KiloViewers") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const KiloSessions = (yield* Effect.promise(() => import("@/kilo-sessions/kilo-sessions"))).KiloSessions
|
||||
|
||||
const platform = inferPlatform()
|
||||
const killSwitch = process.env.KILO_DISABLE_PRESENCE === "1"
|
||||
// Same endpoint the server envelope hands KiloClaw; KILO_EVENT_SERVICE_URL
|
||||
// is a presence-specific override on top of the gateway's EVENT_SERVICE_URL.
|
||||
const url = process.env.KILO_EVENT_SERVICE_URL || KILO_EVENT_SERVICE_URL
|
||||
|
||||
const s = {
|
||||
viewers: new Map<string, ViewerState>(),
|
||||
prevAttached: [] as string[],
|
||||
prevContexts: new Set<string>(),
|
||||
identity: undefined as string | undefined,
|
||||
token: undefined as string | undefined,
|
||||
client: undefined as EventServiceClient | undefined,
|
||||
timer: null as ReturnType<typeof setTimeout> | null,
|
||||
}
|
||||
|
||||
function presenceEnabled(): boolean {
|
||||
return !killSwitch && !!url && !!platform && !!s.token
|
||||
}
|
||||
|
||||
function disconnectClient() {
|
||||
if (s.client) {
|
||||
s.client.disconnect()
|
||||
s.client = undefined
|
||||
}
|
||||
s.prevContexts = new Set()
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
log.warn("rebuilding presence connection")
|
||||
disconnectClient()
|
||||
apply(Date.now())
|
||||
}
|
||||
|
||||
function onServerError(err: unknown) {
|
||||
const e = err as Record<string, unknown>
|
||||
const code = typeof e.code === "string" ? e.code : typeof e.error === "string" ? e.error : ""
|
||||
if (code === "too_many_contexts") rebuild()
|
||||
}
|
||||
|
||||
function pruneExpired(now: number) {
|
||||
const expired = expiredViewerIds([...s.viewers.values()], now)
|
||||
for (const id of expired) s.viewers.delete(id)
|
||||
}
|
||||
|
||||
function pushAttached() {
|
||||
const union = attachedUnion([...s.viewers.values()])
|
||||
if (!sameArr(union, s.prevAttached)) {
|
||||
s.prevAttached = union
|
||||
KiloSessions.setAttachedSessions(union)
|
||||
}
|
||||
}
|
||||
|
||||
function reconcilePresence() {
|
||||
if (!presenceEnabled()) {
|
||||
if (s.client) disconnectClient()
|
||||
return
|
||||
}
|
||||
const active = [...s.viewers.values()].some((v) => v.active)
|
||||
const { ids, omitted } = visibleUnion([...s.viewers.values()])
|
||||
if (omitted > 0) log.warn("omitted visible session contexts", { omitted })
|
||||
const desired = desiredContexts(platform as Platform, active, ids)
|
||||
if (!s.client) {
|
||||
// Don't hold an idle socket open: connect only once there is a context
|
||||
// to assert (inactive-only viewers keep attachment but publish nothing).
|
||||
if (desired.size === 0) return
|
||||
if (!s.token) return
|
||||
s.client = new EventServiceClient({
|
||||
url: url as string,
|
||||
getToken: () => Promise.resolve(s.token!),
|
||||
onUnauthorized: () => disconnectClient(),
|
||||
onServerError,
|
||||
})
|
||||
s.client.subscribe([...desired])
|
||||
s.prevContexts = desired
|
||||
void s.client.connect().catch((err) => log.warn("presence connect failed", { error: String(err) }))
|
||||
return
|
||||
}
|
||||
if (desired.size === 0) {
|
||||
disconnectClient()
|
||||
return
|
||||
}
|
||||
const { remove, add } = reconcileContexts(s.prevContexts, desired)
|
||||
if (remove.length) s.client.unsubscribe(remove)
|
||||
if (add.length) s.client.subscribe(add)
|
||||
s.prevContexts = desired
|
||||
}
|
||||
|
||||
function apply(now: number) {
|
||||
pruneExpired(now)
|
||||
pushAttached()
|
||||
reconcilePresence()
|
||||
rescheduleExpiry(now)
|
||||
}
|
||||
|
||||
function rescheduleExpiry(now: number) {
|
||||
if (s.timer) {
|
||||
clearTimeout(s.timer)
|
||||
s.timer = null
|
||||
}
|
||||
const deadline = nextExpiryDeadline([...s.viewers.values()], now)
|
||||
if (deadline === undefined) return
|
||||
const delay = Math.max(deadline - now, 0)
|
||||
s.timer = setTimeout(() => {
|
||||
s.timer = null
|
||||
apply(Date.now())
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const readAuth = auth.get("kilo").pipe(Effect.orElseSucceed((): Auth.Info | undefined => undefined))
|
||||
|
||||
const update = Effect.fn("KiloViewers.update")(function* (snapshot: ViewerSnapshot) {
|
||||
const info = yield* readAuth
|
||||
const { token, identity } = extract(info)
|
||||
if (identity !== s.identity) {
|
||||
disconnectClient()
|
||||
s.identity = identity
|
||||
}
|
||||
s.token = token
|
||||
|
||||
const result = validateSnapshot(snapshot)
|
||||
if (!result.ok) {
|
||||
log.warn("rejected viewer snapshot", { error: result.error.kind })
|
||||
return
|
||||
}
|
||||
s.viewers.set(result.viewer.id, {
|
||||
id: result.viewer.id,
|
||||
active: result.viewer.active,
|
||||
attached: dedupe(result.attached),
|
||||
visible: dedupe(result.visible),
|
||||
lastSeen: Date.now(),
|
||||
})
|
||||
apply(Date.now())
|
||||
})
|
||||
|
||||
const invalidateAuth = Effect.fn("KiloViewers.invalidateAuth")(function* () {
|
||||
disconnectClient()
|
||||
s.identity = undefined
|
||||
s.token = undefined
|
||||
const info = yield* readAuth
|
||||
const { token, identity } = extract(info)
|
||||
s.token = token
|
||||
s.identity = identity
|
||||
apply(Date.now())
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
if (s.timer) {
|
||||
clearTimeout(s.timer)
|
||||
s.timer = null
|
||||
}
|
||||
disconnectClient()
|
||||
s.viewers.clear()
|
||||
KiloSessions.setAttachedSessions([])
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({ update, invalidateAuth })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { fenceLayer } from "@/server/routes/instance/httpapi/middleware/fence"
|
||||
import * as AnacondaDesktop from "@/kilocode/anaconda-desktop/service"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
|
||||
import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change
|
||||
import { agentBuilderHandlers } from "./handlers/agent-builder"
|
||||
import { anacondaDesktopHandlers } from "./handlers/anaconda-desktop"
|
||||
import { backgroundProcessHandlers } from "./handlers/background-process"
|
||||
@@ -64,6 +65,7 @@ export function provideListener(opts?: CorsOptions) {
|
||||
corsVaryFix,
|
||||
fenceLayer,
|
||||
cors,
|
||||
KiloViewers.defaultLayer, // kilocode_change
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
Layer.succeed(CorsConfig)(opts),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { ModelCache } from "@/provider/model-cache"
|
||||
import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const disposeAllInstancesAfterProviderAuthCallback = Effect.fn(
|
||||
@@ -9,6 +10,13 @@ export const disposeAllInstancesAfterProviderAuthCallback = Effect.fn(
|
||||
yield* store.disposeAll()
|
||||
})
|
||||
|
||||
// kilocode_change start - drop the old presence socket; callers invoke this for the "kilo" provider only
|
||||
export const invalidatePresence = Effect.fn("KiloServer.invalidatePresence")(function* () {
|
||||
const viewers = yield* KiloViewers.Service
|
||||
yield* viewers.invalidateAuth()
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
export const invalidateAfterProviderAuthChange = Effect.fn("KiloServer.invalidateAfterProviderAuthChange")(function* (
|
||||
providerID: string,
|
||||
) {
|
||||
|
||||
@@ -75,9 +75,16 @@ export const PermissionResponsePayload = Schema.Struct({
|
||||
response: PermissionV1.Reply,
|
||||
})
|
||||
// kilocode_change start
|
||||
const PresenceSessionId = Schema.String.check(Schema.isStartsWith("ses"), Schema.isMaxLength(234)).pipe(
|
||||
Schema.brand("SessionID"),
|
||||
)
|
||||
export const ViewedPayload = Schema.Struct({
|
||||
focused: Schema.optional(Schema.Array(Schema.String)),
|
||||
open: Schema.optional(Schema.Array(Schema.String)),
|
||||
viewer: Schema.Struct({
|
||||
id: Schema.String.check(Schema.isUUID()),
|
||||
active: Schema.Boolean,
|
||||
}),
|
||||
attached: Schema.Array(PresenceSessionId).check(Schema.isMaxLength(1000)),
|
||||
visible: Schema.Array(PresenceSessionId).check(Schema.isMaxLength(199)),
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
@@ -454,6 +461,7 @@ export const SessionApi = HttpApi.make("session")
|
||||
query: WorkspaceRoutingQuery,
|
||||
payload: ViewedPayload,
|
||||
success: described(Schema.Boolean, "Viewed sessions updated"),
|
||||
error: HttpApiError.BadRequest,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "session.viewed",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { invalidateAfterProviderAuthChange } from "@/kilocode/server/provider-auth-lifecycle" // kilocode_change
|
||||
import {
|
||||
invalidateAfterProviderAuthChange,
|
||||
invalidatePresence,
|
||||
} from "@/kilocode/server/provider-auth-lifecycle" // kilocode_change
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
@@ -16,6 +19,9 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han
|
||||
payload: Auth.Info
|
||||
}) {
|
||||
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
|
||||
// kilocode_change start - drop old presence socket before instance disposal on Kilo auth changes
|
||||
if (ctx.params.providerID === "kilo") yield* invalidatePresence()
|
||||
// kilocode_change end
|
||||
yield* invalidateAfterProviderAuthChange(ctx.params.providerID) // kilocode_change
|
||||
return true
|
||||
})
|
||||
@@ -24,6 +30,9 @@ export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (han
|
||||
params: { providerID: ProviderV2.ID }
|
||||
}) {
|
||||
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
|
||||
// kilocode_change start - drop old presence socket before instance disposal on Kilo auth changes
|
||||
if (ctx.params.providerID === "kilo") yield* invalidatePresence()
|
||||
// kilocode_change end
|
||||
yield* invalidateAfterProviderAuthChange(ctx.params.providerID) // kilocode_change
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -5,7 +5,10 @@ import { Provider } from "@/provider/provider"
|
||||
|
||||
import { mapValues, pickBy } from "remeda" // kilocode_change
|
||||
import { ModelCache } from "@/provider/model-cache" // kilocode_change
|
||||
import { disposeAllInstancesAfterProviderAuthCallback } from "@/kilocode/server/provider-auth-lifecycle" // kilocode_change
|
||||
import {
|
||||
disposeAllInstancesAfterProviderAuthCallback,
|
||||
invalidatePresence,
|
||||
} from "@/kilocode/server/provider-auth-lifecycle" // kilocode_change
|
||||
import { providerMetadata } from "@/kilocode/provider/metadata" // kilocode_change
|
||||
import { filterPromptTrainingModels } from "@/kilocode/provider/model-filter" // kilocode_change
|
||||
import { overlay as overlayAnacondaDesktop } from "@/kilocode/anaconda-desktop/provider" // kilocode_change
|
||||
@@ -126,6 +129,9 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
||||
code: ctx.payload.code,
|
||||
}),
|
||||
)
|
||||
// kilocode_change start - drop old-user presence before instance disposal on Kilo OAuth callback
|
||||
if (ctx.params.providerID === "kilo") yield* invalidatePresence()
|
||||
// kilocode_change end
|
||||
yield* disposeAllInstancesAfterProviderAuthCallback() // kilocode_change
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Image } from "@/image/image" // kilocode_change - classify user image v
|
||||
import { KiloSessionHttpApi } from "@/kilocode/server/httpapi/session-fork" // kilocode_change
|
||||
import { BlockedError as AgentRequirementError } from "@/kilocode/agent-requirements" // kilocode_change
|
||||
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
@@ -62,6 +63,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
const todoSvc = yield* Todo.Service
|
||||
const summary = yield* SessionSummary.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const viewers = yield* KiloViewers.Service // kilocode_change
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
|
||||
@@ -421,8 +423,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
|
||||
// kilocode_change start
|
||||
const viewed = Effect.fn("SessionHttpApi.viewed")(function* (ctx: { payload: typeof ViewedPayload.Type }) {
|
||||
const { KiloSessions } = yield* Effect.promise(() => import("@/kilo-sessions/kilo-sessions"))
|
||||
KiloSessions.setViewedSessions({ focused: ctx.payload.focused ?? [], open: ctx.payload.open ?? [] })
|
||||
yield* viewers.update(ctx.payload)
|
||||
return true
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { Question } from "@/question"
|
||||
import { Notebook } from "@/kilocode/notebook/service" // kilocode_change
|
||||
import { KiloViewers } from "@/kilocode/presence/service" // kilocode_change
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import { LLM } from "@/session/llm"
|
||||
@@ -251,6 +252,7 @@ export function createRoutes(
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Notebook.defaultLayer, // kilocode_change
|
||||
KiloViewers.defaultLayer, // kilocode_change
|
||||
Ripgrep.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { EventServiceClient } from "@/kilocode/event-service/client"
|
||||
|
||||
const OriginalWebSocket = globalThis.WebSocket
|
||||
const OriginalFetch = globalThis.fetch
|
||||
const OriginalSetTimeout = globalThis.setTimeout
|
||||
const OriginalClearTimeout = globalThis.clearTimeout
|
||||
|
||||
type WsListener = (event: unknown) => void
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly OPEN = 1
|
||||
readonly url: string
|
||||
readonly protocols: string | string[] | undefined
|
||||
readyState = 0
|
||||
readonly sent: string[] = []
|
||||
closedWith: { code?: number; reason?: string } | null = null
|
||||
private readonly listeners = new Map<string, Set<WsListener>>()
|
||||
|
||||
constructor(url: string, protocols?: string | string[]) {
|
||||
this.url = url
|
||||
this.protocols = protocols
|
||||
sockets.push(this)
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: WsListener): void {
|
||||
const set = this.listeners.get(type) ?? new Set<WsListener>()
|
||||
set.add(listener)
|
||||
this.listeners.set(type, set)
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string): void {
|
||||
if (this.readyState === 3) return
|
||||
this.readyState = 3
|
||||
this.closedWith = { code, reason }
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
this.readyState = 1
|
||||
for (const l of this.listeners.get("open") ?? []) l({})
|
||||
}
|
||||
|
||||
emitMessage(data: string): void {
|
||||
for (const l of this.listeners.get("message") ?? []) l({ data })
|
||||
}
|
||||
|
||||
emitClose(code: number, reason = ""): void {
|
||||
if (this.readyState !== 3) this.readyState = 3
|
||||
for (const l of this.listeners.get("close") ?? []) l({ code, reason })
|
||||
}
|
||||
|
||||
emitError(): void {
|
||||
for (const l of this.listeners.get("error") ?? []) l({})
|
||||
}
|
||||
}
|
||||
|
||||
const sockets: FakeWebSocket[] = []
|
||||
let client: EventServiceClient | undefined
|
||||
|
||||
function useFakeWebSocket(): void {
|
||||
Object.defineProperty(globalThis, "WebSocket", { value: FakeWebSocket, configurable: true, writable: true })
|
||||
}
|
||||
|
||||
function ticketBody(ticket = "t"): Response {
|
||||
return new Response(JSON.stringify({ ticket }), { status: 200, headers: { "content-type": "application/json" } })
|
||||
}
|
||||
|
||||
function statusBody(status: number): Response {
|
||||
return new Response("", { status })
|
||||
}
|
||||
|
||||
function installFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>): void {
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : new Request(input).url
|
||||
return Promise.resolve(handler(url, init))
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
}
|
||||
|
||||
function installTimers() {
|
||||
const callbacks = new Map<number, () => void>()
|
||||
const scheduled: { delay: number }[] = []
|
||||
let nextId = 1
|
||||
globalThis.setTimeout = ((cb: () => void, delay?: number) => {
|
||||
const id = nextId++
|
||||
callbacks.set(id, cb)
|
||||
scheduled.push({ delay: delay ?? 0 })
|
||||
return id
|
||||
}) as unknown as typeof setTimeout
|
||||
globalThis.clearTimeout = ((id?: unknown) => {
|
||||
if (typeof id === "number") callbacks.delete(id)
|
||||
}) as unknown as typeof clearTimeout
|
||||
return {
|
||||
flush() {
|
||||
const cbs = [...callbacks.values()]
|
||||
callbacks.clear()
|
||||
for (const cb of cbs) cb()
|
||||
},
|
||||
size() {
|
||||
return callbacks.size
|
||||
},
|
||||
scheduled,
|
||||
}
|
||||
}
|
||||
|
||||
async function drain(n = 30): Promise<void> {
|
||||
for (let i = 0; i < n; i++) await Promise.resolve()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
client?.disconnect()
|
||||
client = undefined
|
||||
sockets.length = 0
|
||||
Object.defineProperty(globalThis, "WebSocket", { value: OriginalWebSocket, configurable: true, writable: true })
|
||||
globalThis.fetch = OriginalFetch
|
||||
globalThis.setTimeout = OriginalSetTimeout
|
||||
globalThis.clearTimeout = OriginalClearTimeout
|
||||
})
|
||||
|
||||
describe("EventServiceClient transport", () => {
|
||||
test("401 ticket response is fatal and fires onUnauthorized", async () => {
|
||||
useFakeWebSocket()
|
||||
installFetch(() => statusBody(401))
|
||||
let unauthorized = false
|
||||
client = new EventServiceClient({
|
||||
url: "wss://events.test",
|
||||
getToken: async () => "tok",
|
||||
onUnauthorized: () => (unauthorized = true),
|
||||
})
|
||||
await client.connect()
|
||||
expect(unauthorized).toBe(true)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
expect(sockets.length).toBe(0)
|
||||
})
|
||||
|
||||
test("403 ticket response is fatal and fires onUnauthorized", async () => {
|
||||
useFakeWebSocket()
|
||||
installFetch(() => statusBody(403))
|
||||
let unauthorized = false
|
||||
client = new EventServiceClient({
|
||||
url: "wss://events.test",
|
||||
getToken: async () => "tok",
|
||||
onUnauthorized: () => (unauthorized = true),
|
||||
})
|
||||
await client.connect()
|
||||
expect(unauthorized).toBe(true)
|
||||
expect(sockets.length).toBe(0)
|
||||
})
|
||||
|
||||
test("handshake timeout closes the socket with handshake-timeout and is transient", async () => {
|
||||
useFakeWebSocket()
|
||||
installFetch(() => ticketBody())
|
||||
const timers = installTimers()
|
||||
client = new EventServiceClient({
|
||||
url: "wss://events.test",
|
||||
getToken: async () => "tok",
|
||||
handshakeTimeoutMs: 40,
|
||||
})
|
||||
void client.connect()
|
||||
await drain()
|
||||
expect(sockets.length).toBe(1)
|
||||
expect(timers.scheduled.some((s) => s.delay === 40)).toBe(true)
|
||||
timers.flush()
|
||||
expect(sockets[0].closedWith?.code).toBe(1000)
|
||||
expect(sockets[0].closedWith?.reason).toBe("handshake-timeout")
|
||||
await drain()
|
||||
expect(timers.size()).toBe(1)
|
||||
})
|
||||
|
||||
test("transient close code schedules a reconnect that opens a new socket", async () => {
|
||||
useFakeWebSocket()
|
||||
installFetch(() => ticketBody())
|
||||
const timers = installTimers()
|
||||
client = new EventServiceClient({
|
||||
url: "wss://events.test",
|
||||
getToken: async () => "tok",
|
||||
handshakeTimeoutMs: 5000,
|
||||
})
|
||||
const p = client.connect()
|
||||
await drain()
|
||||
expect(sockets.length).toBe(1)
|
||||
sockets[0].emitOpen()
|
||||
await p
|
||||
expect(client.isConnected()).toBe(true)
|
||||
sockets[0].emitClose(1006)
|
||||
expect(timers.size()).toBe(1)
|
||||
timers.flush()
|
||||
await drain()
|
||||
expect(sockets.length).toBe(2)
|
||||
sockets[1].emitOpen()
|
||||
await drain()
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
|
||||
test("reconnect replays active contexts and fires onReconnect", async () => {
|
||||
useFakeWebSocket()
|
||||
installFetch(() => ticketBody())
|
||||
const timers = installTimers()
|
||||
client = new EventServiceClient({
|
||||
url: "wss://events.test",
|
||||
getToken: async () => "tok",
|
||||
handshakeTimeoutMs: 5000,
|
||||
})
|
||||
let reconnectFired = false
|
||||
client.onReconnect(() => (reconnectFired = true))
|
||||
const p = client.connect()
|
||||
await drain()
|
||||
sockets[0].emitOpen()
|
||||
await p
|
||||
expect(reconnectFired).toBe(false)
|
||||
client.subscribe(["ctx-1"])
|
||||
expect(sockets[0].sent).toContain(JSON.stringify({ type: "context.subscribe", contexts: ["ctx-1"] }))
|
||||
sockets[0].emitClose(1006)
|
||||
timers.flush()
|
||||
await drain()
|
||||
expect(sockets.length).toBe(2)
|
||||
sockets[1].emitOpen()
|
||||
await drain()
|
||||
expect(reconnectFired).toBe(true)
|
||||
expect(JSON.parse(sockets[1].sent[0])).toEqual({ type: "context.subscribe", contexts: ["ctx-1"] })
|
||||
})
|
||||
|
||||
test("unsubscribe sends while connected and disconnect closes the socket", async () => {
|
||||
useFakeWebSocket()
|
||||
installFetch(() => ticketBody())
|
||||
client = new EventServiceClient({ url: "wss://events.test", getToken: async () => "tok" })
|
||||
const p = client.connect()
|
||||
await drain()
|
||||
sockets[0].emitOpen()
|
||||
await p
|
||||
client.subscribe(["ctx-1"])
|
||||
client.unsubscribe(["ctx-1"])
|
||||
expect(sockets[0].sent).toContain(JSON.stringify({ type: "context.unsubscribe", contexts: ["ctx-1"] }))
|
||||
client.disconnect()
|
||||
expect(client.isConnected()).toBe(false)
|
||||
expect(sockets[0].closedWith).not.toBeNull()
|
||||
})
|
||||
|
||||
test("disposal during token lookup does not start a ticket request", async () => {
|
||||
useFakeWebSocket()
|
||||
let resolveToken!: (token: string) => void
|
||||
const token = new Promise<string>((resolve) => (resolveToken = resolve))
|
||||
let requests = 0
|
||||
installFetch(() => {
|
||||
requests++
|
||||
return ticketBody()
|
||||
})
|
||||
client = new EventServiceClient({ url: "wss://events.test", getToken: () => token })
|
||||
|
||||
const pending = client.connect()
|
||||
await drain()
|
||||
client.disconnect()
|
||||
resolveToken("tok")
|
||||
await pending
|
||||
|
||||
expect(requests).toBe(0)
|
||||
expect(sockets.length).toBe(0)
|
||||
})
|
||||
|
||||
test("disconnect aborts ticket requests from concurrent connect attempts", async () => {
|
||||
useFakeWebSocket()
|
||||
installTimers()
|
||||
const signals: AbortSignal[] = []
|
||||
installFetch(
|
||||
(_url, init) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
const signal = init?.signal
|
||||
if (!signal) return
|
||||
signals.push(signal)
|
||||
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")))
|
||||
}),
|
||||
)
|
||||
client = new EventServiceClient({ url: "wss://events.test", getToken: async () => "tok" })
|
||||
|
||||
void client.connect()
|
||||
await drain()
|
||||
void client.connect()
|
||||
await drain()
|
||||
expect(signals).toHaveLength(2)
|
||||
|
||||
client.disconnect()
|
||||
await drain()
|
||||
expect(signals.every((signal) => signal.aborted)).toBe(true)
|
||||
})
|
||||
|
||||
test("disposal during ticket minting never creates a socket", async () => {
|
||||
useFakeWebSocket()
|
||||
let resolveFetch!: (r: Response) => void
|
||||
const fetchPromise = new Promise<Response>((r) => (resolveFetch = r))
|
||||
installFetch(() => fetchPromise)
|
||||
client = new EventServiceClient({ url: "wss://events.test", getToken: async () => "tok" })
|
||||
const p = client.connect()
|
||||
await drain()
|
||||
expect(sockets.length).toBe(0)
|
||||
client.disconnect()
|
||||
resolveFetch(ticketBody())
|
||||
await p
|
||||
expect(sockets.length).toBe(0)
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
test("disposal suppresses a late unauthorized ticket response", async () => {
|
||||
useFakeWebSocket()
|
||||
let resolveFetch!: (r: Response) => void
|
||||
const fetchPromise = new Promise<Response>((r) => (resolveFetch = r))
|
||||
installFetch(() => fetchPromise)
|
||||
let unauthorized = false
|
||||
client = new EventServiceClient({
|
||||
url: "wss://events.test",
|
||||
getToken: async () => "tok",
|
||||
onUnauthorized: () => (unauthorized = true),
|
||||
})
|
||||
const p = client.connect()
|
||||
await drain()
|
||||
client.disconnect()
|
||||
resolveFetch(statusBody(401))
|
||||
await p
|
||||
expect(unauthorized).toBe(false)
|
||||
})
|
||||
|
||||
test("disposal during handshake closes the socket and ignores a late open", async () => {
|
||||
useFakeWebSocket()
|
||||
installFetch(() => ticketBody())
|
||||
client = new EventServiceClient({
|
||||
url: "wss://events.test",
|
||||
getToken: async () => "tok",
|
||||
handshakeTimeoutMs: 5000,
|
||||
})
|
||||
const p = client.connect()
|
||||
await drain()
|
||||
expect(sockets.length).toBe(1)
|
||||
expect(sockets[0].readyState).toBe(0)
|
||||
client.disconnect()
|
||||
await drain()
|
||||
sockets[0].emitOpen()
|
||||
await drain()
|
||||
expect(client.isConnected()).toBe(false)
|
||||
expect(sockets[0].closedWith).not.toBeNull()
|
||||
expect(sockets.length).toBe(1)
|
||||
await p
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
CLI_SESSION_PREFIX,
|
||||
MAX_CONTEXT_LENGTH,
|
||||
MAX_SESSION_ID_LENGTH,
|
||||
cliSessionContext,
|
||||
platformContext,
|
||||
} from "../../../src/kilocode/presence/context"
|
||||
import {
|
||||
attachedUnion,
|
||||
dedupe,
|
||||
desiredContexts,
|
||||
expiredViewerIds,
|
||||
nextExpiryDeadline,
|
||||
reconcileContexts,
|
||||
validateSnapshot,
|
||||
visibleUnion,
|
||||
type ViewerState,
|
||||
} from "../../../src/kilocode/presence/policy"
|
||||
|
||||
const UUID = "00000000-0000-4000-8000-000000000000"
|
||||
|
||||
describe("presence context builders", () => {
|
||||
test("platformContext maps a platform to its presence context", () => {
|
||||
expect(platformContext("cli")).toBe("/presence/cli")
|
||||
expect(platformContext("vscode")).toBe("/presence/vscode")
|
||||
})
|
||||
|
||||
test("cliSessionContext prefixes the session id", () => {
|
||||
expect(cliSessionContext("ses_123")).toBe("/presence/cli-session/ses_123")
|
||||
})
|
||||
|
||||
test("CLI_SESSION_PREFIX is 22 chars and the budget derivation holds", () => {
|
||||
expect(CLI_SESSION_PREFIX.length).toBe(22)
|
||||
expect(MAX_CONTEXT_LENGTH - CLI_SESSION_PREFIX.length).toBe(MAX_SESSION_ID_LENGTH)
|
||||
expect(MAX_SESSION_ID_LENGTH).toBe(234)
|
||||
})
|
||||
|
||||
test("session id budget lands exactly on the 256-char context limit", () => {
|
||||
expect(cliSessionContext("x".repeat(234)).length).toBe(256)
|
||||
expect(cliSessionContext("x".repeat(235)).length).toBe(257)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dedupe", () => {
|
||||
test("preserves first-seen order and drops duplicates", () => {
|
||||
expect(dedupe(["a", "b", "a", "c", "b"])).toEqual(["a", "b", "c"])
|
||||
expect(dedupe([])).toEqual([])
|
||||
expect(dedupe(["x", "x"])).toEqual(["x"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateSnapshot", () => {
|
||||
test("accepts a well-formed snapshot and dedupes arrays", () => {
|
||||
const r = validateSnapshot({
|
||||
viewer: { id: UUID, active: true },
|
||||
attached: ["ses_s1", "ses_s1", "ses_s2"],
|
||||
visible: ["ses_v1", "ses_v1"],
|
||||
})
|
||||
expect(r).toEqual({ ok: true, viewer: { id: UUID, active: true }, attached: ["ses_s1", "ses_s2"], visible: ["ses_v1"] })
|
||||
})
|
||||
|
||||
test("missing viewer yields missing_viewer", () => {
|
||||
expect(validateSnapshot({})).toEqual({ ok: false, error: { kind: "missing_viewer" } })
|
||||
expect(validateSnapshot({ viewer: undefined })).toEqual({ ok: false, error: { kind: "missing_viewer" } })
|
||||
})
|
||||
|
||||
test("non-UUID viewer id yields bad_viewer_id", () => {
|
||||
expect(validateSnapshot({ viewer: { id: "not-a-uuid" } })).toEqual({ ok: false, error: { kind: "bad_viewer_id" } })
|
||||
expect(validateSnapshot({ viewer: { id: "" } })).toEqual({ ok: false, error: { kind: "bad_viewer_id" } })
|
||||
})
|
||||
|
||||
test("UUID with an invalid variant nibble yields bad_viewer_id", () => {
|
||||
expect(validateSnapshot({ viewer: { id: "11111111-1111-1111-1111-111111111111" } })).toEqual({
|
||||
ok: false,
|
||||
error: { kind: "bad_viewer_id" },
|
||||
})
|
||||
})
|
||||
|
||||
test("session id missing the ses prefix yields bad_session_id", () => {
|
||||
expect(validateSnapshot({ viewer: { id: UUID }, attached: ["no-prefix"] })).toEqual({
|
||||
ok: false,
|
||||
error: { kind: "bad_session_id", id: "no-prefix" },
|
||||
})
|
||||
})
|
||||
|
||||
test("attached over the per-viewer cap yields attached_too_many", () => {
|
||||
const attached = Array.from({ length: 1001 }, (_, i) => `ses_${i}`)
|
||||
expect(validateSnapshot({ viewer: { id: UUID }, attached })).toEqual({
|
||||
ok: false,
|
||||
error: { kind: "attached_too_many" },
|
||||
})
|
||||
})
|
||||
|
||||
test("visible over the per-viewer cap yields visible_too_many", () => {
|
||||
const visible = Array.from({ length: 200 }, (_, i) => `ses_${i}`)
|
||||
expect(validateSnapshot({ viewer: { id: UUID }, visible })).toEqual({
|
||||
ok: false,
|
||||
error: { kind: "visible_too_many" },
|
||||
})
|
||||
})
|
||||
|
||||
test("oversized session id yields bad_session_id with the offending id", () => {
|
||||
const long = "ses_" + "x".repeat(231)
|
||||
expect(validateSnapshot({ viewer: { id: UUID }, attached: [long] })).toEqual({
|
||||
ok: false,
|
||||
error: { kind: "bad_session_id", id: long },
|
||||
})
|
||||
})
|
||||
|
||||
test("active is coerced strictly to a boolean", () => {
|
||||
const t = validateSnapshot({ viewer: { id: UUID, active: true }, attached: [], visible: [] })
|
||||
expect(t.ok && t.viewer.active).toBe(true)
|
||||
const str = validateSnapshot({ viewer: { id: UUID, active: "true" }, attached: [], visible: [] })
|
||||
expect(str.ok && str.viewer.active).toBe(false)
|
||||
})
|
||||
|
||||
test("non-array attached and visible coerce to empty arrays", () => {
|
||||
expect(validateSnapshot({ viewer: { id: UUID }, attached: null, visible: 42 })).toEqual({
|
||||
ok: true,
|
||||
viewer: { id: UUID, active: false },
|
||||
attached: [],
|
||||
visible: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("attachedUnion", () => {
|
||||
test("unions attached across viewers including inactive ones", () => {
|
||||
const viewers: ViewerState[] = [
|
||||
{ id: "u1", active: true, attached: ["a", "b"], visible: [], lastSeen: 0 },
|
||||
{ id: "u2", active: false, attached: ["b", "c"], visible: [], lastSeen: 0 },
|
||||
]
|
||||
expect(attachedUnion(viewers)).toEqual(["a", "b", "c"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("visibleUnion", () => {
|
||||
test("only active viewers contribute, deduped and capped at 199", () => {
|
||||
const ids = Array.from({ length: 201 }, (_, i) => `s${String(i).padStart(4, "0")}`)
|
||||
const viewers: ViewerState[] = [
|
||||
{ id: "u1", active: true, attached: [], visible: ids, lastSeen: 0 },
|
||||
{ id: "u2", active: false, attached: [], visible: ["z_hidden"], lastSeen: 0 },
|
||||
]
|
||||
const r = visibleUnion(viewers)
|
||||
expect(r.ids.length).toBe(199)
|
||||
expect(r.omitted).toBe(2)
|
||||
expect(r.ids).not.toContain("z_hidden")
|
||||
const sorted = [...ids].sort()
|
||||
expect(r.ids).toEqual(sorted.slice(0, 199))
|
||||
})
|
||||
})
|
||||
|
||||
describe("expiredViewerIds", () => {
|
||||
test("expires at exactly lastSeen + TTL and not one ms earlier", () => {
|
||||
const now = 1_000_000
|
||||
const viewers: ViewerState[] = [
|
||||
{ id: "expired", active: true, attached: [], visible: [], lastSeen: now - 120_000 },
|
||||
{ id: "alive", active: true, attached: [], visible: [], lastSeen: now - 119_999 },
|
||||
]
|
||||
expect(expiredViewerIds(viewers, now)).toEqual(["expired"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("nextExpiryDeadline", () => {
|
||||
test("returns the earliest future deadline", () => {
|
||||
const now = 1_000_000
|
||||
const viewers: ViewerState[] = [
|
||||
{ id: "a", active: true, attached: [], visible: [], lastSeen: now - 50_000 },
|
||||
{ id: "b", active: true, attached: [], visible: [], lastSeen: now - 10_000 },
|
||||
]
|
||||
expect(nextExpiryDeadline(viewers, now)).toBe(now + 70_000)
|
||||
})
|
||||
|
||||
test("returns undefined when all viewers are expired", () => {
|
||||
const now = 1_000_000
|
||||
const viewers: ViewerState[] = [
|
||||
{ id: "a", active: true, attached: [], visible: [], lastSeen: now - 120_000 },
|
||||
]
|
||||
expect(nextExpiryDeadline(viewers, now)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for no viewers", () => {
|
||||
expect(nextExpiryDeadline([], 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("reconcileContexts", () => {
|
||||
test("removals are prev minus next, additions are next minus prev", () => {
|
||||
const prev = new Set(["a", "b", "c"])
|
||||
const next = new Set(["b", "c", "d"])
|
||||
expect(reconcileContexts(prev, next)).toEqual({ remove: ["a"], add: ["d"] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("desiredContexts", () => {
|
||||
test("active includes platform context plus each visible session context", () => {
|
||||
const ctx = desiredContexts("cli", true, ["s1", "s2"])
|
||||
expect(ctx.size).toBe(3)
|
||||
expect(ctx.has("/presence/cli")).toBe(true)
|
||||
expect(ctx.has("/presence/cli-session/s1")).toBe(true)
|
||||
expect(ctx.has("/presence/cli-session/s2")).toBe(true)
|
||||
})
|
||||
|
||||
test("inactive omits platform context but keeps session contexts", () => {
|
||||
const ctx = desiredContexts("vscode", false, ["s1"])
|
||||
expect(ctx.size).toBe(1)
|
||||
expect(ctx.has("/presence/vscode")).toBe(false)
|
||||
expect(ctx.has("/presence/cli-session/s1")).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, mock, setSystemTime, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "@/auth"
|
||||
|
||||
// Each KiloViewers.layer construction reads these env vars (post-refactor), so
|
||||
// setting them here controls presence wiring per test.
|
||||
process.env.KILO_EVENT_SERVICE_URL = "wss://test-presence"
|
||||
process.env.KILO_PLATFORM = "cli"
|
||||
|
||||
const attachedCalls: string[][] = []
|
||||
|
||||
type Call = { type: "subscribe" | "unsubscribe" | "connect" | "disconnect"; contexts: string[] }
|
||||
|
||||
// Ordering log shared across FakeClient instances, so ordering can be asserted
|
||||
// across a disconnect on one client and a connect on its replacement.
|
||||
const sequence: string[] = []
|
||||
let clientSeq = 0
|
||||
|
||||
class FakeClient {
|
||||
calls: Call[] = []
|
||||
id = ++clientSeq
|
||||
constructor() {}
|
||||
async connect(): Promise<void> {
|
||||
this.calls.push({ type: "connect", contexts: [] })
|
||||
sequence.push(`connect:${this.id}`)
|
||||
}
|
||||
disconnect(): void {
|
||||
this.calls.push({ type: "disconnect", contexts: [] })
|
||||
sequence.push(`disconnect:${this.id}`)
|
||||
}
|
||||
subscribe(contexts: string[]): void {
|
||||
this.calls.push({ type: "subscribe", contexts: [...contexts] })
|
||||
}
|
||||
unsubscribe(contexts: string[]): void {
|
||||
this.calls.push({ type: "unsubscribe", contexts: [...contexts] })
|
||||
}
|
||||
onReconnect(): () => void {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const realSessions = await import("@/kilo-sessions/kilo-sessions")
|
||||
const realSetAttached = realSessions.KiloSessions.setAttachedSessions
|
||||
mock.module("@/kilo-sessions/kilo-sessions", () => ({
|
||||
...realSessions,
|
||||
KiloSessions: {
|
||||
...realSessions.KiloSessions,
|
||||
setAttachedSessions: (ids: readonly string[]) => {
|
||||
attachedCalls.push([...ids])
|
||||
realSetAttached(ids)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
let current = new FakeClient()
|
||||
mock.module("@/kilocode/event-service/client", () => ({
|
||||
EventServiceClient: class {
|
||||
constructor() {
|
||||
// The service constructs one client per layer; expose it for assertions.
|
||||
current = new FakeClient()
|
||||
}
|
||||
async connect() {
|
||||
await current.connect()
|
||||
}
|
||||
disconnect() {
|
||||
current.disconnect()
|
||||
}
|
||||
subscribe(c: string[]) {
|
||||
current.subscribe(c)
|
||||
}
|
||||
unsubscribe(c: string[]) {
|
||||
current.unsubscribe(c)
|
||||
}
|
||||
onReconnect() {
|
||||
return current.onReconnect()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const { KiloViewers } = await import("@/kilocode/presence/service")
|
||||
|
||||
const authLayer = Layer.succeed(
|
||||
Auth.Service,
|
||||
Auth.Service.of({
|
||||
get: () => Effect.succeed({ type: "api", key: "tok" } as unknown as Auth.Info),
|
||||
all: () => Effect.succeed({} as never),
|
||||
set: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = KiloViewers.layer.pipe(Layer.provide(authLayer))
|
||||
|
||||
const uid = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
function run(body: (viewers: {
|
||||
update: (s: {
|
||||
viewer: { id: string; active: boolean }
|
||||
attached: readonly string[]
|
||||
visible: readonly string[]
|
||||
}) => Effect.Effect<void>
|
||||
invalidateAuth: () => Effect.Effect<void>
|
||||
}) => Effect.Effect<void>, l: typeof layer = layer) {
|
||||
return Effect.gen(function* () {
|
||||
const v = yield* KiloViewers.Service
|
||||
yield* body(v)
|
||||
}).pipe(Effect.provide(l), Effect.runPromise)
|
||||
}
|
||||
|
||||
function subscribeCalls(): string[][] {
|
||||
return current.calls.filter((c) => c.type === "subscribe").map((c) => c.contexts)
|
||||
}
|
||||
|
||||
function unsubscribeCalls(): string[][] {
|
||||
return current.calls.filter((c) => c.type === "unsubscribe").map((c) => c.contexts)
|
||||
}
|
||||
|
||||
describe("KiloViewers.Service presence contexts", () => {
|
||||
test("active viewer subscribes platform plus its visible session context", async () => {
|
||||
attachedCalls.length = 0
|
||||
current = new FakeClient()
|
||||
await run((v) =>
|
||||
v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] }),
|
||||
)
|
||||
const subs = subscribeCalls()
|
||||
expect(subs.length).toBe(1)
|
||||
expect(subs[0]).toContain("/presence/cli")
|
||||
expect(subs[0]).toContain("/presence/cli-session/ses_a")
|
||||
expect(attachedCalls).toEqual([["ses_a"], []])
|
||||
})
|
||||
|
||||
test("inactive viewer opens no presence socket but keeps attachment", async () => {
|
||||
attachedCalls.length = 0
|
||||
current = new FakeClient()
|
||||
await run((v) =>
|
||||
v.update({ viewer: { id: uid, active: false }, attached: ["ses_a"], visible: ["ses_a"] }),
|
||||
)
|
||||
expect(subscribeCalls().length).toBe(0)
|
||||
expect(current.calls.some((c) => c.type === "connect")).toBe(false)
|
||||
expect(attachedCalls).toEqual([["ses_a"], []])
|
||||
})
|
||||
|
||||
test("replacing the visible set unsubscribes old contexts before subscribing new ones", async () => {
|
||||
attachedCalls.length = 0
|
||||
current = new FakeClient()
|
||||
const olds = Array.from({ length: 199 }, (_, i) => `ses_old_${i}`)
|
||||
const next = Array.from({ length: 199 }, (_, i) => `ses_new_${i}`)
|
||||
await run((v) =>
|
||||
Effect.gen(function* () {
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: olds, visible: olds })
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: next, visible: next })
|
||||
}),
|
||||
)
|
||||
const order = current.calls.filter((c) => c.type === "subscribe" || c.type === "unsubscribe")
|
||||
const firstUnsubIdx = order.findIndex((c) => c.type === "unsubscribe")
|
||||
const lastSubIdx = order.map((c) => c.type).lastIndexOf("subscribe")
|
||||
expect(firstUnsubIdx).toBeGreaterThan(-1)
|
||||
expect(lastSubIdx).toBeGreaterThan(firstUnsubIdx)
|
||||
const unsub = unsubscribeCalls().at(-1)!
|
||||
const sub = subscribeCalls().at(-1)!
|
||||
expect(unsub.length).toBe(199)
|
||||
expect(sub.length).toBe(199)
|
||||
expect(sub.every((c) => c.startsWith("/presence/cli-session/ses_new_"))).toBe(true)
|
||||
expect(unsub.every((c) => c.startsWith("/presence/cli-session/ses_old_"))).toBe(true)
|
||||
})
|
||||
|
||||
test("kill switch blocks the presence socket but attached union still reaches KiloSessions", async () => {
|
||||
attachedCalls.length = 0
|
||||
current = new FakeClient()
|
||||
const prev = process.env.KILO_DISABLE_PRESENCE
|
||||
process.env.KILO_DISABLE_PRESENCE = "1"
|
||||
try {
|
||||
await run((v) =>
|
||||
v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] }),
|
||||
)
|
||||
expect(subscribeCalls().length).toBe(0)
|
||||
expect(current.calls.some((c) => c.type === "connect")).toBe(false)
|
||||
expect(attachedCalls).toEqual([["ses_a"], []])
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.KILO_DISABLE_PRESENCE
|
||||
else process.env.KILO_DISABLE_PRESENCE = prev
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const uidB = "22222222-2222-4222-8222-222222222222"
|
||||
|
||||
describe("KiloViewers.Service viewer lifecycle", () => {
|
||||
test("viewer expires at lastSeen + 120s", async () => {
|
||||
attachedCalls.length = 0
|
||||
current = new FakeClient()
|
||||
const base = 1_700_000_000_000
|
||||
try {
|
||||
setSystemTime(base)
|
||||
await run((v) =>
|
||||
Effect.gen(function* () {
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] })
|
||||
setSystemTime(base + 119_999)
|
||||
yield* v.update({ viewer: { id: uidB, active: true }, attached: ["ses_b"], visible: ["ses_b"] })
|
||||
setSystemTime(base + 120_000)
|
||||
yield* v.update({ viewer: { id: uidB, active: true }, attached: ["ses_b"], visible: ["ses_b"] })
|
||||
}),
|
||||
)
|
||||
expect(attachedCalls.length).toBe(4)
|
||||
// One tick before the TTL the first viewer is still present.
|
||||
expect(attachedCalls[1]).toContain("ses_a")
|
||||
expect(attachedCalls[1]).toContain("ses_b")
|
||||
// At exactly lastSeen + TTL it is pruned (boundary inclusive).
|
||||
expect(attachedCalls[2]).toEqual(["ses_b"])
|
||||
expect(attachedCalls[3]).toEqual([])
|
||||
} finally {
|
||||
setSystemTime()
|
||||
}
|
||||
})
|
||||
|
||||
test("account switch disconnects the old client before connecting the new one", async () => {
|
||||
attachedCalls.length = 0
|
||||
sequence.length = 0
|
||||
current = new FakeClient()
|
||||
let authInfo = { type: "api", key: "tok1" } as unknown as Auth.Info
|
||||
const mutableAuthLayer = Layer.succeed(
|
||||
Auth.Service,
|
||||
Auth.Service.of({
|
||||
get: () => Effect.sync(() => authInfo),
|
||||
all: () => Effect.succeed({} as never),
|
||||
set: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
let first: FakeClient | undefined
|
||||
let second: FakeClient | undefined
|
||||
await run(
|
||||
(v) =>
|
||||
Effect.gen(function* () {
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] })
|
||||
first = current
|
||||
authInfo = { type: "wellknown", key: "wk", token: "tok2" } as unknown as Auth.Info
|
||||
yield* v.invalidateAuth()
|
||||
second = current
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] })
|
||||
}),
|
||||
KiloViewers.layer.pipe(Layer.provide(mutableAuthLayer)),
|
||||
)
|
||||
expect(first).toBeDefined()
|
||||
expect(second).toBeDefined()
|
||||
expect(second).not.toBe(first)
|
||||
const oldConnect = sequence.indexOf(`connect:${first!.id}`)
|
||||
const oldDisconnect = sequence.indexOf(`disconnect:${first!.id}`)
|
||||
const newConnect = sequence.indexOf(`connect:${second!.id}`)
|
||||
expect(oldConnect).toBeGreaterThan(-1)
|
||||
expect(oldDisconnect).toBeGreaterThan(oldConnect)
|
||||
expect(newConnect).toBeGreaterThan(oldDisconnect)
|
||||
})
|
||||
|
||||
test("scope disposal runs the finalizer", async () => {
|
||||
attachedCalls.length = 0
|
||||
current = new FakeClient()
|
||||
await run((v) =>
|
||||
v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] }),
|
||||
)
|
||||
const types = current.calls.map((c) => c.type)
|
||||
expect(types).toContain("connect")
|
||||
expect(types.at(-1)).toBe("disconnect")
|
||||
expect(types.indexOf("disconnect")).toBeGreaterThan(types.indexOf("connect"))
|
||||
expect(attachedCalls).toEqual([["ses_a"], []])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "@/auth"
|
||||
|
||||
const attachedCalls: string[][] = []
|
||||
|
||||
const realSessions = await import("@/kilo-sessions/kilo-sessions")
|
||||
const realSetAttached = realSessions.KiloSessions.setAttachedSessions
|
||||
mock.module("@/kilo-sessions/kilo-sessions", () => ({
|
||||
...realSessions,
|
||||
KiloSessions: {
|
||||
...realSessions.KiloSessions,
|
||||
setAttachedSessions: (ids: readonly string[]) => {
|
||||
attachedCalls.push([...ids])
|
||||
realSetAttached(ids)
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const { KiloViewers } = await import("@/kilocode/presence/service")
|
||||
|
||||
const authLayer = Layer.succeed(
|
||||
Auth.Service,
|
||||
Auth.Service.of({
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.succeed({} as never),
|
||||
set: () => Effect.void,
|
||||
remove: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = KiloViewers.layer.pipe(Layer.provide(authLayer))
|
||||
|
||||
const uid = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
function run(body: (viewers: {
|
||||
update: (s: {
|
||||
viewer: { id: string; active: boolean }
|
||||
attached: readonly string[]
|
||||
visible: readonly string[]
|
||||
}) => Effect.Effect<void>
|
||||
invalidateAuth: () => Effect.Effect<void>
|
||||
}) => Effect.Effect<void>) {
|
||||
return Effect.gen(function* () {
|
||||
const v = yield* KiloViewers.Service
|
||||
yield* body(v)
|
||||
}).pipe(Effect.provide(layer), Effect.runPromise)
|
||||
}
|
||||
|
||||
describe("KiloViewers.Service", () => {
|
||||
test("pushes the attached union to KiloSessions on change", async () => {
|
||||
attachedCalls.length = 0
|
||||
await run((v) => v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] }))
|
||||
expect(attachedCalls).toEqual([["ses_a"], []])
|
||||
})
|
||||
|
||||
test("does not re-push an unchanged attached union", async () => {
|
||||
attachedCalls.length = 0
|
||||
await run((v) =>
|
||||
Effect.gen(function* () {
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] })
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] })
|
||||
}),
|
||||
)
|
||||
expect(attachedCalls).toEqual([["ses_a"], []])
|
||||
})
|
||||
|
||||
test("unions attached sessions across viewers", async () => {
|
||||
attachedCalls.length = 0
|
||||
await run((v) =>
|
||||
Effect.gen(function* () {
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] })
|
||||
yield* v.update({ viewer: { id: "22222222-2222-4222-8222-222222222222", active: false }, attached: ["ses_b"], visible: [] })
|
||||
}),
|
||||
)
|
||||
expect(attachedCalls).toEqual([["ses_a"], ["ses_a", "ses_b"], []])
|
||||
})
|
||||
|
||||
test("invalidateAuth does not throw and clears presence state", async () => {
|
||||
attachedCalls.length = 0
|
||||
await run((v) =>
|
||||
Effect.gen(function* () {
|
||||
yield* v.update({ viewer: { id: uid, active: true }, attached: ["ses_a"], visible: ["ses_a"] })
|
||||
yield* v.invalidateAuth()
|
||||
}),
|
||||
)
|
||||
expect(attachedCalls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
@@ -687,8 +687,56 @@ export const kiloScenarios: Scenario[] = [
|
||||
.status(401),
|
||||
http.protected
|
||||
.post("/session/viewed", "session.viewed")
|
||||
.at((ctx) => ({ path: "/session/viewed", headers: ctx.headers(), body: { focused: [], open: [] } }))
|
||||
.at((ctx) => ({
|
||||
path: "/session/viewed",
|
||||
headers: ctx.headers(),
|
||||
body: {
|
||||
viewer: { id: "11111111-1111-4111-8111-111111111111", active: true },
|
||||
attached: [],
|
||||
visible: [],
|
||||
},
|
||||
}))
|
||||
.json(200, (body) => check(body === true, "session viewed should return true")),
|
||||
http.protected
|
||||
.post("/session/viewed", "session.viewed")
|
||||
.at((ctx) => ({ path: "/session/viewed", headers: ctx.headers(), body: { attached: [], visible: [] } }))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/session/viewed", "session.viewed")
|
||||
.at((ctx) => ({
|
||||
path: "/session/viewed",
|
||||
headers: ctx.headers(),
|
||||
body: {
|
||||
viewer: { id: "not-a-uuid", active: true },
|
||||
attached: [],
|
||||
visible: [],
|
||||
},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/session/viewed", "session.viewed")
|
||||
.at((ctx) => ({
|
||||
path: "/session/viewed",
|
||||
headers: ctx.headers(),
|
||||
body: {
|
||||
viewer: { id: "11111111-1111-4111-8111-111111111111", active: true },
|
||||
attached: ["ses_" + "x".repeat(231)],
|
||||
visible: [],
|
||||
},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/session/viewed", "session.viewed")
|
||||
.at((ctx) => ({
|
||||
path: "/session/viewed",
|
||||
headers: ctx.headers(),
|
||||
body: {
|
||||
viewer: { id: "11111111-1111-4111-8111-111111111111", active: true },
|
||||
attached: Array.from({ length: 1001 }, () => "ses_1"),
|
||||
visible: [],
|
||||
},
|
||||
}))
|
||||
.status(400),
|
||||
http.protected
|
||||
.post("/telemetry/capture", "telemetry.capture")
|
||||
.at((ctx) => ({
|
||||
|
||||
@@ -29,6 +29,16 @@ describe("RemoteProtocol", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("heartbeat serializes sessions only", () => {
|
||||
const msg = { type: "heartbeat", sessions: [{ id: "ses_1", status: "idle", title: "t" }] }
|
||||
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data).not.toHaveProperty("focused")
|
||||
expect(result.data).not.toHaveProperty("open")
|
||||
}
|
||||
})
|
||||
|
||||
test("valid event parses", () => {
|
||||
const msg = {
|
||||
type: "event",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Contract test for the TUI presence snapshot in kilocode/cli/cmd/tui/app.tsx.
|
||||
*
|
||||
* `useSessionEffects` must run inside a SolidJS owner with the @opentui/solid
|
||||
* renderer context, so mounting it in a unit test would require mocking the TUI
|
||||
* framework internals. These source-contract assertions pin the load-bearing
|
||||
* presence behaviour instead: the snapshot payload shape (route session as both
|
||||
* attached and visible), focus/blur toggling only `viewer.active`, the 60s
|
||||
* check-in, the backend-reconnect resend, and the cleanup path (listeners and
|
||||
* timer removed, reconnect listener unsubscribed, final empty inactive snapshot).
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const APP_FILE = path.resolve(import.meta.dir, "../../src/kilocode/cli/cmd/tui/app.tsx")
|
||||
|
||||
/** The useSessionEffects function body, so assertions don't match unrelated code. */
|
||||
function effects() {
|
||||
const content = fs.readFileSync(APP_FILE, "utf-8")
|
||||
const start = content.indexOf("export function useSessionEffects")
|
||||
const end = content.indexOf("export function getTerminalTitle")
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return content.slice(start, end)
|
||||
}
|
||||
|
||||
/** The onCleanup block of useSessionEffects (it is the last statement of the hook). */
|
||||
function cleanup() {
|
||||
const body = effects()
|
||||
const start = body.indexOf("onCleanup(() => {")
|
||||
expect(start).toBeGreaterThan(-1)
|
||||
return body.slice(start)
|
||||
}
|
||||
|
||||
/** Collapse whitespace so multi-line expressions match regardless of formatting. */
|
||||
function flat(source: string) {
|
||||
return source.replace(/\s+/g, " ").replace(/\( /g, "(").replace(/ \)/g, ")").replace(/,\)/g, ")")
|
||||
}
|
||||
|
||||
describe("TUI session presence contract", () => {
|
||||
test("snapshot sends the route session as both attached and visible", () => {
|
||||
const body = effects()
|
||||
expect(body).toContain('deps.route.data.type === "session" ? deps.route.data.sessionID : undefined')
|
||||
expect(body).toContain("const ids = id ? [id] : []")
|
||||
expect(flat(body)).toContain(
|
||||
"deps.sdk.client.session.viewed({ viewer: { id: viewerId, active }, attached: ids, visible: ids }).catch(() => {})",
|
||||
)
|
||||
})
|
||||
|
||||
test("focus sets active=true, blur sets active=false, both resend the snapshot", () => {
|
||||
const body = flat(effects())
|
||||
expect(body).toContain("const onFocus = () => { active = true send() }")
|
||||
expect(body).toContain("const onBlur = () => { active = false send() }")
|
||||
expect(body).toContain('renderer.on("focus", onFocus)')
|
||||
expect(body).toContain('renderer.on("blur", onBlur)')
|
||||
})
|
||||
|
||||
test("60s check-in interval exists and is cleared on cleanup", () => {
|
||||
expect(effects()).toContain("const timer = setInterval(send, 60_000)")
|
||||
expect(cleanup()).toContain("clearInterval(timer)")
|
||||
})
|
||||
|
||||
test("server.connected resends the snapshot and is unsubscribed on cleanup", () => {
|
||||
expect(flat(effects())).toContain(
|
||||
'const offConnected = deps.sdk.event.on("event", (event) => { if (event.payload.type === "server.connected") send() })',
|
||||
)
|
||||
expect(cleanup()).toContain("offConnected()")
|
||||
})
|
||||
|
||||
test("cleanup removes focus/blur listeners and sends a final inactive empty snapshot", () => {
|
||||
const tail = cleanup()
|
||||
expect(tail).toContain('renderer.off("focus", onFocus)')
|
||||
expect(tail).toContain('renderer.off("blur", onBlur)')
|
||||
expect(flat(tail)).toContain(".viewed({ viewer: { id: viewerId, active: false }, attached: [], visible: [] })")
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state")
|
||||
process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache")
|
||||
process.env.KILO_DISABLE_SHARE = "true"
|
||||
process.env.KILO_DISABLE_SESSION_INGEST = "true" // kilocode_change - isolate the exerciser from async Kilo session sync
|
||||
process.env.KILO_DISABLE_PRESENCE = "1" // kilocode_change - presence now has a default Event Service URL; never open real sockets from the exerciser
|
||||
export const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode")
|
||||
export const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "kilo") // kilocode_change
|
||||
|
||||
|
||||
@@ -5066,8 +5066,12 @@ export class Session2 extends HeyApiClient {
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
focused?: Array<string>
|
||||
open?: Array<string>
|
||||
viewer?: {
|
||||
id: string
|
||||
active: boolean
|
||||
}
|
||||
attached?: Array<string>
|
||||
visible?: Array<string>
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -5078,8 +5082,9 @@ export class Session2 extends HeyApiClient {
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "focused" },
|
||||
{ in: "body", key: "open" },
|
||||
{ in: "body", key: "viewer" },
|
||||
{ in: "body", key: "attached" },
|
||||
{ in: "body", key: "visible" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -9804,8 +9804,12 @@ export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses]
|
||||
|
||||
export type SessionViewedData = {
|
||||
body?: {
|
||||
focused?: Array<string>
|
||||
open?: Array<string>
|
||||
viewer: {
|
||||
id: string
|
||||
active: boolean
|
||||
}
|
||||
attached: Array<string>
|
||||
visible: Array<string>
|
||||
}
|
||||
path?: never
|
||||
query?: {
|
||||
@@ -9817,9 +9821,9 @@ export type SessionViewedData = {
|
||||
|
||||
export type SessionViewedErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
* BadRequest | InvalidRequestError
|
||||
*/
|
||||
400: BadRequestError
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
}
|
||||
|
||||
export type SessionViewedError = SessionViewedErrors[keyof SessionViewedErrors]
|
||||
|
||||
+39
-10
@@ -8798,11 +8798,18 @@
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BadRequestError"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8816,19 +8823,41 @@
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"focused": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
"viewer": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000)$",
|
||||
"format": "uuid"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["id", "active"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"open": {
|
||||
"attached": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
"type": "string",
|
||||
"pattern": "^ses",
|
||||
"maxLength": 234
|
||||
},
|
||||
"maxItems": 1000
|
||||
},
|
||||
"visible": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^ses",
|
||||
"maxLength": 234
|
||||
},
|
||||
"maxItems": 199
|
||||
}
|
||||
},
|
||||
"required": ["viewer", "attached", "visible"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user