From e8a3ce11fdaf26dd395629f4d5ee3463f7e7ad2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 28 Aug 2026 20:31:54 +0200 Subject: [PATCH 1/2] feat(remote): advertise instance kind and process identity --- .changeset/mobile-instance-metadata.md | 5 + packages/opencode/src/cli/cmd/remote.ts | 2 +- .../kilo-sessions/instance-advertisement.ts | 15 +- .../src/kilo-sessions/kilo-sessions.ts | 6 +- .../src/kilo-sessions/remote-protocol.ts | 6 + .../test/kilocode/cli/cmd/remote.test.ts | 25 ++- .../test/kilocode/kilo-sessions.test.ts | 153 +++++++++++++++--- .../kilocode/sessions/remote-protocol.test.ts | 66 ++++++++ .../test/kilocode/sessions/remote-ws.test.ts | 105 ++++++------ 9 files changed, 307 insertions(+), 76 deletions(-) create mode 100644 .changeset/mobile-instance-metadata.md diff --git a/.changeset/mobile-instance-metadata.md b/.changeset/mobile-instance-metadata.md new file mode 100644 index 00000000000..628e3337781 --- /dev/null +++ b/.changeset/mobile-instance-metadata.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Show terminal and remote instances with their start time and current Git branch in the instance picker. diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 5198a00e4fa..3132cc7bb2b 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -26,7 +26,7 @@ export const RemoteCommand = cmd({ // advertised for the explicit `kilo remote` command path. // enableRemote() also ensures a default advertisement; this explicit call // remains a legitimate replace (or no-op when identical) per the contract. - KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory)) + KiloSessions.setInstanceAdvertisement(buildInstanceAdvertisement(Instance.directory, "remote")) await KiloSessions.enableRemote() console.log("Remote connection enabled.") diff --git a/packages/opencode/src/kilo-sessions/instance-advertisement.ts b/packages/opencode/src/kilo-sessions/instance-advertisement.ts index 97c5ffb6e89..4920846d275 100644 --- a/packages/opencode/src/kilo-sessions/instance-advertisement.ts +++ b/packages/opencode/src/kilo-sessions/instance-advertisement.ts @@ -1,21 +1,30 @@ // kilocode_change - new file // Shared derivation for the spawn-capable instance advertisement payload. // Used by both `kilo remote` (explicit CLI) and `enableRemote()` (covers `/remote` -// and KILO_REMOTE / remote_control auto-enable) so all enable paths advertise -// identically. +// and KILO_REMOTE / remote_control auto-enable) so all enable paths share +// the same process identity. import { InstallationVersion } from "@opencode-ai/core/installation/version" import os from "node:os" import path from "node:path" +import { performance } from "node:perf_hooks" import type { RemoteProtocol } from "@/kilo-sessions/remote-protocol" +// Use process startup, not the first advertisement or a later reconnect. +const started = new Date(performance.timeOrigin).toISOString() + function truncate(value: string, max: number) { return value.length > max ? value.slice(0, max) : value } -export function buildInstanceAdvertisement(directory: string): RemoteProtocol.InstanceAdvertisement { +export function buildInstanceAdvertisement( + directory: string, + kind: RemoteProtocol.InstanceAdvertisement["kind"] = "cli", +): RemoteProtocol.InstanceAdvertisement { return { name: truncate(os.hostname(), 64), projectName: truncate(path.basename(directory) || directory, 64), version: truncate(InstallationVersion, 32), + kind, + startedAt: started, } } diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 948983ee8d4..e4db485b276 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -771,7 +771,11 @@ export namespace KiloSessions { for (const row of sessions) await syncPrLinkTriple(row.id, pr.triple) } const advertised = pr.prLink ? sessions.map((row) => ({ ...row, prLink: pr.prLink })) : sessions - const instance = instanceAdvertisement + const instance = instanceAdvertisement && { + ...instanceAdvertisement, + // Reuse the current session branch without splitting a surrogate pair. + gitBranch: gitBranch?.slice(0, 24).replace(/[\uD800-\uDBFF]$/, ""), + } return { type: "heartbeat", sessions: advertised, ...(instance ? { instance } : {}) } } diff --git a/packages/opencode/src/kilo-sessions/remote-protocol.ts b/packages/opencode/src/kilo-sessions/remote-protocol.ts index 14dcd8d6377..b949e105d28 100644 --- a/packages/opencode/src/kilo-sessions/remote-protocol.ts +++ b/packages/opencode/src/kilo-sessions/remote-protocol.ts @@ -37,6 +37,12 @@ export namespace RemoteProtocol { name: z.string().min(1).max(64), // os.hostname(), truncated projectName: z.string().min(1).max(64), // basename(Instance.directory), truncated version: z.string().max(32).optional(), // InstallationVersion, truncated + // Older CLIs advertise only name, projectName, and optional version. + // Keep metadata optional until those CLI versions and retained relay + // attachments are confirmed retired. + kind: z.enum(["cli", "remote"]).optional(), + startedAt: z.iso.datetime({ precision: 3 }).length(24).optional(), + gitBranch: z.string().max(24).optional(), }) export type InstanceAdvertisement = z.infer diff --git a/packages/opencode/test/kilocode/cli/cmd/remote.test.ts b/packages/opencode/test/kilocode/cli/cmd/remote.test.ts index e571148694a..6d9f590b17a 100644 --- a/packages/opencode/test/kilocode/cli/cmd/remote.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/remote.test.ts @@ -1,12 +1,8 @@ // kilocode_change - new file // K1 W1: verify `buildInstanceAdvertisement`'s payload shape as real behavior. // -// The `RemoteCommand` handler itself is a CLI entry point that calls -// `bootstrap(process.cwd(), async () => { ... })` and then awaits an abort -// signal that never resolves in a test — it cannot be driven end-to-end. -// `buildInstanceAdvertisement` is extracted from the handler specifically so -// the advertised payload is independently testable as real behavior, not via -// a source-text/regex assertion on the handler's structure. +// The command handler's enablement path is covered in kilo-sessions.test.ts. +// These tests exercise the shared builder without the CLI lifecycle. import { describe, expect, test } from "bun:test" // Shared helper lives in kilo-sessions; remote.ts re-exports for the CLI path. @@ -21,6 +17,23 @@ describe("RemoteCommand instance advertisement (K1 W1)", () => { expect(typeof advertisement.version).toBe("string") }) + test("keeps process identity across builder calls, directories, and kinds", async () => { + const first = buildInstanceAdvertisement("/projects/first") + // Advance past the timestamp resolution, not an asynchronous readiness boundary. + await Bun.sleep(2) + const second = buildInstanceAdvertisement("/projects/second", "remote") + expect(first.kind).toBe("cli") + expect(first.projectName).toBe("first") + expect(second.kind).toBe("remote") + expect(second.projectName).toBe("second") + expect(second.startedAt).toBe(first.startedAt) + expect(second.name).toBe(first.name) + expect(first.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + const start = Date.now() - process.uptime() * 1000 + expect(Date.parse(first.startedAt!)).toBeGreaterThanOrEqual(start - 100) + expect(Date.parse(first.startedAt!)).toBeLessThanOrEqual(start + 100) + }) + test("buildInstanceAdvertisement truncates an overlong project directory name to 64 chars", () => { const longName = "a".repeat(100) const advertisement = buildInstanceAdvertisement(`/Users/igor/projects/${longName}`) diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index f64cc2d0329..981d14360bd 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -292,10 +292,12 @@ multi.live("isolates the process-wide listener by instance directory", () => { describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { let heartbeatCalls = 0 let outOfBand: Promise | undefined + let snapshot: RemoteProtocol.InstanceAdvertisement | undefined beforeEach(() => { heartbeatCalls = 0 outOfBand = undefined + snapshot = undefined process.env["KILO_DISABLE_SESSION_INGEST"] = "0" delete process.env["KILO_SESSION_INGEST_URL"] process.env["KILO_API_KEY"] = "tok" @@ -316,7 +318,9 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { send() {}, heartbeat: () => { heartbeatCalls += 1 - const p = options.getSessions().then(() => undefined) + const p = options.getSessions().then((payload) => { + snapshot = payload.instance + }) outOfBand = p return p }, @@ -367,14 +371,16 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { reset("tok") }) - // Reads the `getSessions` closure that kilo-sessions.ts passed to - // RemoteWS.connect when enableRemote() ran. The mock stores calls - // on the spy's `.mock.calls` array; we extract the Options object. - function capturedGetSessions(): () => Promise { + // Read the latest connection so reconnect tests exercise the new closure. + function captured() { const calls = (RemoteWS.connect as unknown as { mock: { calls: { 0: RemoteWS.Options }[] } }).mock.calls - const getSessions = calls[0]?.[0].getSessions - if (!getSessions) throw new Error("RemoteWS.connect was not called") - return getSessions as () => Promise + const options = calls.at(-1)?.[0] + if (!options) throw new Error("RemoteWS.connect was not called") + return options + } + + function capturedGetSessions() { + return captured().getSessions as () => Promise } test("enableRemote alone advertises the instance (covers /remote and auto-enable)", async () => { @@ -390,6 +396,34 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { expect(payload.instance).toBeDefined() expect(payload.instance!.projectName.length).toBeGreaterThan(0) expect(payload.instance!.name.length).toBeGreaterThan(0) + expect(payload.instance?.kind).toBe("cli") + expect(payload.instance?.startedAt).toBeDefined() + expect(payload.instance?.gitBranch).toBeDefined() + }, + }) + }) + + test("explicit remote command advertises remote before enablement", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + const bootstrap = await import("../../src/cli/bootstrap") + const { RemoteCommand } = await import("../../src/cli/cmd/remote") + spyOn(bootstrap, "bootstrap").mockImplementation(async (_directory, cb) => cb()) + const enable = KiloSessions.enableRemote + const stop = new Error("stop before the command waits for shutdown") + spyOn(KiloSessions, "enableRemote").mockImplementation(async () => { + await enable() + throw stop + }) + const handler = RemoteCommand.handler + if (typeof handler !== "function") throw new Error("remote command handler is missing") + const result = await Promise.resolve(handler({ _: [], $0: "kilo" })).catch((err: unknown) => err) + expect(result).toBe(stop) + const payload = await capturedGetSessions()() + expect(payload.instance?.kind).toBe("remote") + expect(payload.instance?.startedAt).toBeDefined() }, }) }) @@ -421,15 +455,19 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { fn: async () => { await KiloSessions.enableRemote() // Explicit set keeps replace semantics even when enableRemote already - // derived a default advertisement. + // derived a default advertisement. Do not invent metadata for a legacy ad. KiloSessions.setInstanceAdvertisement({ name: "mbp-igor", projectName: "cloud", version: "1.2.3", }) - const payload = await capturedGetSessions()() - expect(payload.type).toBe("heartbeat") - expect(payload.instance).toEqual({ name: "mbp-igor", projectName: "cloud", version: "1.2.3" }) + await outOfBand + expect(snapshot).toEqual({ + name: "mbp-igor", + projectName: "cloud", + version: "1.2.3", + gitBranch: expect.any(String), + }) }, }) }) @@ -446,8 +484,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { KiloSessions.setInstanceAdvertisement({ name: "h", projectName: "p" }) await outOfBand expect(heartbeatCalls).toBe(beforeHeartbeatCalls + 1) - const afterPayload = await capturedGetSessions()() - expect(afterPayload.instance).toEqual({ name: "h", projectName: "p" }) + expect(snapshot).toEqual({ name: "h", projectName: "p", gitBranch: expect.any(String) }) }, }) }) @@ -464,22 +501,29 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { KiloSessions.setInstanceAdvertisement({ name: "second", projectName: "p" }) await outOfBand expect(heartbeatCalls).toBe(before + 1) - const payload = await capturedGetSessions()() - expect(payload.instance).toEqual({ name: "second", projectName: "p" }) + expect(snapshot).toEqual({ name: "second", projectName: "p", gitBranch: expect.any(String) }) }, }) }) - test("explicit set before enableRemote is preserved (no re-set on enable)", async () => { + test("explicit metadata before enableRemote is preserved except for the current branch", async () => { await using tmp = await tmpdir({ git: true }) await provide({ directory: tmp.path, fn: async () => { - // Contract: set before connect → flag stored; enable must not replace. - KiloSessions.setInstanceAdvertisement({ name: "pre-set", projectName: "proj", version: "9.9.9" }) + const instance = { + name: "pre-set", + projectName: "proj", + version: "9.9.9", + kind: "remote" as const, + startedAt: "2020-01-02T03:04:05.678Z", + gitBranch: "stale", + } + KiloSessions.setInstanceAdvertisement(instance) await KiloSessions.enableRemote() const payload = await capturedGetSessions()() - expect(payload.instance).toEqual({ name: "pre-set", projectName: "proj", version: "9.9.9" }) + expect(payload.instance?.gitBranch).not.toBe("stale") + expect(payload.instance).toEqual({ ...instance, gitBranch: expect.any(String) }) }, }) }) @@ -502,6 +546,75 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { }) }) + test("reconnect heartbeat refreshes the branch without replacing process identity", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + const first = await capturedGetSessions()() + const { AppRuntime } = await import("../../src/effect/app-runtime") + const { Vcs } = await import("../../src/project/vcs") + const vcs = await AppRuntime.runPromise(Vcs.Service.use((svc) => Effect.succeed(svc))) + spyOn(vcs, "branch").mockReturnValue(Effect.succeed("feature/reconnected")) + captured().onDisconnect?.() + captured().onOpen?.() + await outOfBand + expect(snapshot).toEqual({ ...first.instance, gitBranch: "feature/reconnected" }) + }, + }) + }) + + test("refreshes and bounds only instance branches while preserving process identity", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + const { AppRuntime } = await import("../../src/effect/app-runtime") + const { Vcs } = await import("../../src/project/vcs") + const vcs = await AppRuntime.runPromise(Vcs.Service.use((svc) => Effect.succeed(svc))) + const branch = spyOn(vcs, "branch").mockReturnValue(Effect.succeed("main")) + await KiloSessions.enableRemote() + const first = await capturedGetSessions()() + const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({}))) + KiloSessions.setAttachedSessions([chat.id]) + for (const [input, expected] of [ + ["feature/current", "feature/current"], + ["a".repeat(25), "a".repeat(24)], + ['"\\\n\u0001'.repeat(7), '"\\\n\u0001'.repeat(6)], + ["界".repeat(25), "界".repeat(24)], + ["\u{10400}".repeat(13), "\u{10400}".repeat(12)], + ["a".repeat(23) + "\u{10400}", "a".repeat(23)], + ["a".repeat(22) + "\u{10400}b", "a".repeat(22) + "\u{10400}"], + ["", ""], + [undefined, undefined], + ]) { + branch.mockReturnValue(Effect.succeed(input)) + const payload = await capturedGetSessions()() + expect(payload.instance).toEqual({ ...first.instance, gitBranch: expected }) + expect(payload.sessions.find((row) => row.id === chat.id)).toMatchObject({ id: chat.id, gitBranch: input }) + } + branch.mockReturnValue(Effect.die(new Error("branch unavailable"))) + const payload = await capturedGetSessions()() + expect(payload.instance).toEqual({ ...first.instance, gitBranch: undefined }) + }, + }) + }) + + test("omits the whole instance when no advertisement is present", async () => { + await using tmp = await tmpdir({ git: true }) + await provide({ + directory: tmp.path, + fn: async () => { + await KiloSessions.enableRemote() + KiloSessions.resetInstanceAdvertisementForTests() + const payload = await capturedGetSessions()() + expect(payload).not.toHaveProperty("instance") + expect(payload.sessions).toEqual([]) + }, + }) + }) + test("per-session platform resolution matches meta() order — env var fallback", async () => { // The getSessions closure's platform field is computed as: // KiloSession.resolvePlatform(id) || process.env["KILO_PLATFORM"] || "cli" diff --git a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts index fc786fec139..7413df5d538 100644 --- a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts @@ -297,6 +297,72 @@ describe("RemoteProtocol", () => { } }) + test.each(["cli", "remote"])("heartbeat preserves full %s instance metadata and capabilities", (kind) => { + const msg = { + type: "heartbeat", + protocolVersion: "1.0.0", + sessions: [{ id: "s1", status: "busy", title: "Task", gitBranch: "feature/session-branch-is-not-truncated" }], + instance: { + name: "host", + projectName: "project", + version: "1.2.3", + kind, + startedAt: "2024-02-29T12:34:56.789Z", + gitBranch: "feature/current", + }, + capabilities: { attachments: true, sessionClone: true }, + } + expect(RemoteProtocol.Outbound.parse(JSON.parse(JSON.stringify(msg)))).toEqual(msg) + }) + + test("legacy instance field limits remain accepted", () => { + const instance = { name: "h".repeat(64), projectName: "p".repeat(64), version: "v".repeat(32) } + expect(RemoteProtocol.InstanceAdvertisement.parse(instance)).toEqual(instance) + }) + + test.each(["terminal", "REMOTE", "", null, 1])("rejects invalid instance kind %j", (kind) => { + expect(RemoteProtocol.InstanceAdvertisement.safeParse({ name: "h", projectName: "p", kind }).success).toBe(false) + }) + + test.each([ + "2026-08-28T12:34:56Z", + "2026-08-28T12:34:56.78Z", + "2026-08-28T12:34:56.7890Z", + "2026-08-28T12:34:56.789+00:00", + "2026-08-28T12:34:56.789", + "2026-02-29T12:34:56.789Z", + "2026-08-32T12:34:56.789Z", + "2026-13-01T12:34:56.789Z", + "2026-08-28T24:00:00.000Z", + "2026-08-28T12:60:00.000Z", + "2026-08-28T12:34:60.000Z", + "2026-08-28t12:34:56.789z", + "not-a-timestamp", + null, + 0, + ])("rejects invalid instance start time %j", (startedAt) => { + expect(RemoteProtocol.InstanceAdvertisement.safeParse({ name: "h", projectName: "p", startedAt }).success).toBe( + false, + ) + }) + + test.each(["a".repeat(24), '"\\\n\u0001'.repeat(6), "界".repeat(24), "\u{10400}".repeat(12)])( + "accepts 24 UTF-16 branch units and rejects one more: %j", + (gitBranch) => { + const instance = { name: "h", projectName: "p", gitBranch } + expect(RemoteProtocol.InstanceAdvertisement.parse(JSON.parse(JSON.stringify(instance)))).toEqual(instance) + expect(RemoteProtocol.InstanceAdvertisement.safeParse({ ...instance, gitBranch: gitBranch + "a" }).success).toBe( + false, + ) + }, + ) + + test("instance branch can be empty but cannot be null", () => { + const instance = { name: "h", projectName: "p", gitBranch: "" } + expect(RemoteProtocol.InstanceAdvertisement.parse(instance)).toEqual(instance) + expect(RemoteProtocol.InstanceAdvertisement.safeParse({ ...instance, gitBranch: null }).success).toBe(false) + }) + test("instance advertisement version is optional", () => { const msg = { type: "heartbeat", diff --git a/packages/opencode/test/kilocode/sessions/remote-ws.test.ts b/packages/opencode/test/kilocode/sessions/remote-ws.test.ts index 4882c991c88..f65eeee70e5 100644 --- a/packages/opencode/test/kilocode/sessions/remote-ws.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-ws.test.ts @@ -816,7 +816,7 @@ describe("RemoteWS", () => { heartbeat: 60_000, timers: clock, now: () => clock.now, - timeout: 300_000, + timeout: 300_000, connectTimeout: 1000, }) @@ -1103,7 +1103,7 @@ describe("RemoteWS", () => { heartbeat: 60_000, timers: clock, now: () => clock.now, - timeout: 300_000, + timeout: 300_000, onClose: (c) => codes.push(c), }) @@ -1277,12 +1277,17 @@ describe("RemoteWS", () => { test("AC4a: degraded heartbeat preserves the last known-good non-empty session list", async () => { await withFakeWebSocket(async (clock) => { let mode: "fresh" | "wedge" = "fresh" - const knownGoodSessions = [ - { id: "s1", status: "active" as const, title: "One" }, - ] as RemoteWS.SessionInfo[] + const knownGoodSessions = [{ id: "s1", status: "active" as const, title: "One" }] as RemoteWS.SessionInfo[] + const instance = { + name: "host", + projectName: "project", + kind: "cli" as const, + startedAt: "2026-08-28T12:34:56.789Z", + gitBranch: "main", + } const getSessions = () => mode === "fresh" - ? Promise.resolve({ sessions: knownGoodSessions }) + ? Promise.resolve({ sessions: knownGoodSessions, instance }) : new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) conn = RemoteWS.connect({ @@ -1308,6 +1313,8 @@ describe("RemoteWS", () => { const payload1 = JSON.parse(socket.sent[0]) expect(payload1.type).toBe("heartbeat") expect(payload1.sessions).toEqual(knownGoodSessions) + expect(payload1.instance).toEqual(instance) + expect(payload1.capabilities).toEqual({ attachments: true, sessionClone: true }) // Cycle 2: wedged gather times out → degraded send carries the same list. mode = "wedge" @@ -1318,8 +1325,18 @@ describe("RemoteWS", () => { const payload2 = JSON.parse(socket.sent[1]) expect(payload2.type).toBe("heartbeat") expect(payload2.sessions).toEqual(knownGoodSessions) + expect(payload2).not.toHaveProperty("instance") + expect(payload2.capabilities).toEqual({ attachments: true, sessionClone: true }) + expect(payload2.protocolVersion).toBe(payload1.protocolVersion) - // Connection still live + // Recovery must advertise fresh metadata, not the cached instance. + mode = "fresh" + instance.gitBranch = "feature/recovered" + await conn.heartbeat() + const recovered = JSON.parse(socket.sent.at(-1)!) + expect(recovered.instance).toEqual({ ...payload1.instance, gitBranch: "feature/recovered" }) + expect(recovered.sessions).toEqual(knownGoodSessions) + expect(recovered.capabilities).toEqual({ attachments: true, sessionClone: true }) expect(conn.connected).toBe(true) }) }) @@ -1372,9 +1389,7 @@ describe("RemoteWS", () => { test("AC4c: after a timed-out cycle, a later fresh-gather cycle sends fresh sessions", async () => { await withFakeWebSocket(async (clock) => { let mode: "wedge" | "fresh" = "wedge" - const freshSessions = [ - { id: "fresh", status: "active" as const, title: "Fresh" }, - ] as RemoteWS.SessionInfo[] + const freshSessions = [{ id: "fresh", status: "active" as const, title: "Fresh" }] as RemoteWS.SessionInfo[] const getSessions = () => mode === "wedge" ? new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) @@ -1422,9 +1437,7 @@ describe("RemoteWS", () => { let calls = 0 let mode: "wedge" | "fresh" = "wedge" const wedgeResolvers: Array<(v: { sessions: RemoteWS.SessionInfo[] }) => void> = [] - const freshSessions = [ - { id: "fresh", status: "active" as const, title: "Fresh" }, - ] as RemoteWS.SessionInfo[] + const freshSessions = [{ id: "fresh", status: "active" as const, title: "Fresh" }] as RemoteWS.SessionInfo[] const getSessions = () => { calls++ if (mode === "wedge") { @@ -1494,9 +1507,7 @@ describe("RemoteWS", () => { await withFakeWebSocket(async (clock) => { let calls = 0 let mode: "reject" | "fresh" = "reject" - const freshSessions = [ - { id: "fresh", status: "active" as const, title: "Fresh" }, - ] as RemoteWS.SessionInfo[] + const freshSessions = [{ id: "fresh", status: "active" as const, title: "Fresh" }] as RemoteWS.SessionInfo[] const getSessions = () => { calls++ return mode === "reject" @@ -1547,9 +1558,7 @@ describe("RemoteWS", () => { await withFakeWebSocket(async (clock) => { let calls = 0 let mode: "throw" | "fresh" = "throw" - const freshSessions = [ - { id: "fresh", status: "active" as const, title: "Fresh" }, - ] as RemoteWS.SessionInfo[] + const freshSessions = [{ id: "fresh", status: "active" as const, title: "Fresh" }] as RemoteWS.SessionInfo[] const getSessions = () => { calls++ if (mode === "throw") { @@ -1619,9 +1628,7 @@ describe("RemoteWS", () => { test("AC6a: heartbeat() does not resolve on degraded, resolves on the next fresh send", async () => { await withFakeWebSocket(async (clock) => { let mode: "wedge" | "fresh" = "wedge" - const freshSessions = [ - { id: "fresh", status: "active" as const, title: "Fresh" }, - ] as RemoteWS.SessionInfo[] + const freshSessions = [{ id: "fresh", status: "active" as const, title: "Fresh" }] as RemoteWS.SessionInfo[] const getSessions = () => mode === "wedge" ? new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) @@ -1678,9 +1685,7 @@ describe("RemoteWS", () => { test("AC6b: pending heartbeat() survives disconnect+reconnect and resolves on fresh send over the new socket", async () => { await withFakeWebSocket(async (clock) => { let mode: "wedge" | "fresh" = "wedge" - const freshSessions = [ - { id: "fresh", status: "active" as const, title: "Fresh" }, - ] as RemoteWS.SessionInfo[] + const freshSessions = [{ id: "fresh", status: "active" as const, title: "Fresh" }] as RemoteWS.SessionInfo[] const getSessions = () => mode === "wedge" ? new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) @@ -1745,8 +1750,7 @@ describe("RemoteWS", () => { test("AC6c: pending heartbeat() rejects (does not hang) when close() is called", async () => { await withFakeWebSocket(async (clock) => { - const getSessions = () => - new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) // wedge + const getSessions = () => new Promise<{ sessions: RemoteWS.SessionInfo[] }>(() => {}) // wedge conn = RemoteWS.connect({ url: "ws://example.test", @@ -1806,8 +1810,7 @@ describe("RemoteWS", () => { const targetSession = { id: "target", status: "active" as const, title: "Target" } const listWithout = [otherSession] as RemoteWS.SessionInfo[] const listWith = [otherSession, targetSession] as RemoteWS.SessionInfo[] - const getSessions = () => - Promise.resolve({ sessions: mode === "without" ? listWithout : listWith }) + const getSessions = () => Promise.resolve({ sessions: mode === "without" ? listWithout : listWith }) conn = RemoteWS.connect({ url: "ws://example.test", @@ -2004,15 +2007,20 @@ describe("RemoteWS", () => { // to see the instance. The periodic 10s timer is the fallback for // other code paths. - test("propagates instance advertisement from getSessions to the heartbeat payload", async () => { + test("propagates current instance metadata and capabilities across reconnects", async () => { await withFakeWebSocket(async (clock) => { + const instance = { + name: "mbp-igor", + projectName: "cloud", + version: "1.2.3", + kind: "remote" as const, + startedAt: "2026-08-28T12:34:56.789Z", + gitBranch: "main", + } conn = RemoteWS.connect({ url: "ws://example.test", getToken: async () => "tok", - getSessions: async () => ({ - sessions: [], - instance: { name: "mbp-igor", projectName: "cloud", version: "1.2.3" }, - }), + getSessions: async () => ({ sessions: [], instance }), log: nolog(), heartbeat: 60_000, timers: clock, @@ -2021,18 +2029,24 @@ describe("RemoteWS", () => { }) await flush() - const socket = FakeWebSocket.instances[0] + const socket = FakeWebSocket.instances.at(-1)! socket.open() - await flushLong() - // No immediate heartbeat on first open; the periodic timer would - // eventually fire (60_000 in this test) but the test fires one - // explicitly to verify the payload flow. - fireHeartbeat() - await flushLong() + await conn.heartbeat() + const first = JSON.parse(socket.sent.at(-1)!) + expect(first.instance).toEqual(instance) + expect(first.capabilities).toEqual({ attachments: true, sessionClone: true }) - expect(socket.sent.length).toBe(1) - const parsed = JSON.parse(socket.sent[0]) - expect(parsed.instance).toEqual({ name: "mbp-igor", projectName: "cloud", version: "1.2.3" }) + socket.disconnect(1000, "transient") + instance.gitBranch = "feature/reconnected" + clock.advance(1000) + await flush() + const next = FakeWebSocket.instances.at(-1)! + next.open() + await conn.heartbeat() + const second = JSON.parse(next.sent.at(-1)!) + expect(second.instance).toEqual({ ...first.instance, gitBranch: "feature/reconnected" }) + expect(second.capabilities).toEqual({ attachments: true, sessionClone: true }) + expect(second.protocolVersion).toBe(first.protocolVersion) }) }) @@ -2058,8 +2072,9 @@ describe("RemoteWS", () => { expect(socket.sent.length).toBe(1) const parsed = JSON.parse(socket.sent[0]) - expect(parsed.instance).toBeUndefined() + expect(parsed).not.toHaveProperty("instance") expect(parsed.protocolVersion).toBeDefined() + expect(parsed.capabilities).toEqual({ attachments: true, sessionClone: true }) }) }) From bd5f1eb311ba977913490470aa468949da21e83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sun, 30 Aug 2026 03:20:57 +0200 Subject: [PATCH 2/2] test(remote): fix metadata fixtures and runtime classification --- .changeset/mobile-instance-metadata.md | 2 +- packages/opencode/test/kilocode/kilo-sessions.test.ts | 2 ++ .../test/kilocode/sessions/remote-protocol.test.ts | 2 +- script/check-opencode-promise-facades.ts | 7 +++++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.changeset/mobile-instance-metadata.md b/.changeset/mobile-instance-metadata.md index 628e3337781..f1c1d2799d9 100644 --- a/.changeset/mobile-instance-metadata.md +++ b/.changeset/mobile-instance-metadata.md @@ -2,4 +2,4 @@ "kilo-code": minor --- -Show terminal and remote instances with their start time and current Git branch in the instance picker. +Advertise optional instance kind, process start time, and current Git branch in CLI heartbeats. diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index 981d14360bd..7de56217383 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -553,6 +553,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { fn: async () => { await KiloSessions.enableRemote() const first = await capturedGetSessions()() + if (!first.instance) throw new Error("initial heartbeat is missing its instance advertisement") const { AppRuntime } = await import("../../src/effect/app-runtime") const { Vcs } = await import("../../src/project/vcs") const vcs = await AppRuntime.runPromise(Vcs.Service.use((svc) => Effect.succeed(svc))) @@ -576,6 +577,7 @@ describe("KiloSessions.setInstanceAdvertisement (K1 W1 / DEF-1)", () => { const branch = spyOn(vcs, "branch").mockReturnValue(Effect.succeed("main")) await KiloSessions.enableRemote() const first = await capturedGetSessions()() + if (!first.instance) throw new Error("initial heartbeat is missing its instance advertisement") const chat = await AppRuntime.runPromise(Session.Service.use((svc) => svc.create({}))) KiloSessions.setAttachedSessions([chat.id]) for (const [input, expected] of [ diff --git a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts index 7413df5d538..3eec91b6db8 100644 --- a/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-protocol.test.ts @@ -299,7 +299,7 @@ describe("RemoteProtocol", () => { test.each(["cli", "remote"])("heartbeat preserves full %s instance metadata and capabilities", (kind) => { const msg = { - type: "heartbeat", + type: "heartbeat" as const, protocolVersion: "1.0.0", sessions: [{ id: "s1", status: "busy", title: "Task", gitBranch: "feature/session-branch-is-not-truncated" }], instance: { diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 4f0376731a7..d0b6a801ef2 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -43,7 +43,7 @@ const testAllow: Record = { reason: "disk-backed instance integration test cleanup", }, "kilocode/kilo-sessions.test.ts": { - count: 31, + count: 36, reason: "K1 W1: real integration test for SessionStatus→detach→heartbeat-fence; " + "the test creates a session and sets its status via the global AppRuntime, " + @@ -53,7 +53,10 @@ const testAllow: Record = { "Permission.Service, so a test can only assert it by raising and replying to " + "real requests through that same runtime. Scoped layers cannot express this — " + "the global-runtime coupling is exactly what is under test. " + - "PR-link advertise tests extend this with session creation through the same global AppRuntime.", + "PR-link advertise tests extend this with session creation through the same global AppRuntime. " + + "Instance metadata tests control the global Vcs.Service read by the production heartbeat " + + "to verify refresh, reconnect, bounds, and failure, and create a session through the same " + + "Session.Service to verify that instance bounds leave the full session branch unchanged.", }, "kilocode/session/platform-attribution.test.ts": { count: 2, reason: "existing runtime integration test" }, "kilocode/session-prompt-queue.test.ts": { count: 6, reason: "prompt queue legacy instance bridge regression" },