23c7d053f2
Add an inactivity watchdog to RemoteWS that force-closes and reconnects the WebSocket when no inbound messages arrive within a configurable timeout window (default 30s). Introduce a heartbeat_ack inbound message type so the server can respond to heartbeat pings, resetting the activity timer.
259 lines
7.5 KiB
TypeScript
259 lines
7.5 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
import { RemoteProtocol } from "../../src/kilo-sessions/remote-protocol"
|
|
|
|
describe("RemoteProtocol", () => {
|
|
// --- Outbound (CLI → DO) ---
|
|
|
|
test("valid heartbeat parses", () => {
|
|
const msg = {
|
|
type: "heartbeat",
|
|
sessions: [{ id: "ses_1", status: "busy", title: "Fix auth" }],
|
|
}
|
|
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.sessions).toHaveLength(1)
|
|
expect(result.data.sessions[0].id).toBe("ses_1")
|
|
}
|
|
})
|
|
|
|
test("heartbeat with parentSessionId parses", () => {
|
|
const msg = {
|
|
type: "heartbeat",
|
|
sessions: [{ id: "ses_child", status: "busy", title: "Sub task", parentSessionId: "ses_root" }],
|
|
}
|
|
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.sessions[0].parentSessionId).toBe("ses_root")
|
|
}
|
|
})
|
|
|
|
test("valid event parses", () => {
|
|
const msg = {
|
|
type: "event",
|
|
sessionId: "ses_1",
|
|
event: "message.updated",
|
|
data: { text: "hello" },
|
|
}
|
|
const result = RemoteProtocol.Event.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.sessionId).toBe("ses_1")
|
|
expect(result.data.event).toBe("message.updated")
|
|
}
|
|
})
|
|
|
|
test("valid response parses", () => {
|
|
const msg = { type: "response", id: "req_1", result: { ok: true } }
|
|
const result = RemoteProtocol.Response.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.id).toBe("req_1")
|
|
expect(result.data.result).toEqual({ ok: true })
|
|
expect(result.data.error).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
test("response with error parses", () => {
|
|
const msg = { type: "response", id: "req_2", error: "not found" }
|
|
const result = RemoteProtocol.Response.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.error).toBe("not found")
|
|
expect(result.data.result).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
// --- Inbound (DO → CLI) ---
|
|
|
|
test("valid subscribe parses", () => {
|
|
const msg = { type: "subscribe", sessionId: "ses_1" }
|
|
const result = RemoteProtocol.Subscribe.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.sessionId).toBe("ses_1")
|
|
}
|
|
})
|
|
|
|
test("valid unsubscribe parses", () => {
|
|
const msg = { type: "unsubscribe", sessionId: "ses_1" }
|
|
const result = RemoteProtocol.Unsubscribe.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.sessionId).toBe("ses_1")
|
|
}
|
|
})
|
|
|
|
test("valid command parses", () => {
|
|
const msg = {
|
|
type: "command",
|
|
id: "cmd_1",
|
|
command: "send_message",
|
|
sessionId: "ses_1",
|
|
data: { text: "hi" },
|
|
}
|
|
const result = RemoteProtocol.Command.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.id).toBe("cmd_1")
|
|
expect(result.data.command).toBe("send_message")
|
|
expect(result.data.sessionId).toBe("ses_1")
|
|
}
|
|
})
|
|
|
|
test("command without sessionId parses", () => {
|
|
const msg = {
|
|
type: "command",
|
|
id: "cmd_2",
|
|
command: "list_sessions",
|
|
data: null,
|
|
}
|
|
const result = RemoteProtocol.Command.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.sessionId).toBeUndefined()
|
|
}
|
|
})
|
|
|
|
test("valid system parses", () => {
|
|
const msg = {
|
|
type: "system",
|
|
event: "cli.connected",
|
|
data: { pid: 1234 },
|
|
}
|
|
const result = RemoteProtocol.System.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.event).toBe("cli.connected")
|
|
}
|
|
})
|
|
|
|
// --- Discriminated unions ---
|
|
|
|
test("outbound union picks heartbeat", () => {
|
|
const msg = { type: "heartbeat", sessions: [] }
|
|
const result = RemoteProtocol.Outbound.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("heartbeat")
|
|
}
|
|
})
|
|
|
|
test("outbound union picks event", () => {
|
|
const msg = {
|
|
type: "event",
|
|
sessionId: "ses_1",
|
|
event: "session.updated",
|
|
data: {},
|
|
}
|
|
const result = RemoteProtocol.Outbound.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("event")
|
|
}
|
|
})
|
|
|
|
test("outbound union picks response", () => {
|
|
const msg = { type: "response", id: "r1" }
|
|
const result = RemoteProtocol.Outbound.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("response")
|
|
}
|
|
})
|
|
|
|
test("inbound union picks subscribe", () => {
|
|
const msg = { type: "subscribe", sessionId: "ses_1" }
|
|
const result = RemoteProtocol.Inbound.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("subscribe")
|
|
}
|
|
})
|
|
|
|
test("inbound union picks command", () => {
|
|
const msg = {
|
|
type: "command",
|
|
id: "c1",
|
|
command: "ping",
|
|
data: null,
|
|
}
|
|
const result = RemoteProtocol.Inbound.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("command")
|
|
}
|
|
})
|
|
|
|
test("inbound union picks system", () => {
|
|
const msg = { type: "system", event: "shutdown", data: null }
|
|
const result = RemoteProtocol.Inbound.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("system")
|
|
}
|
|
})
|
|
|
|
// --- Rejection ---
|
|
|
|
test("outbound rejects unknown type", () => {
|
|
const msg = { type: "bogus", data: 1 }
|
|
const result = RemoteProtocol.Outbound.safeParse(msg)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
test("inbound rejects unknown type", () => {
|
|
const msg = { type: "bogus", data: 1 }
|
|
const result = RemoteProtocol.Inbound.safeParse(msg)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
test("heartbeat rejects missing sessions", () => {
|
|
const msg = { type: "heartbeat" }
|
|
const result = RemoteProtocol.Heartbeat.safeParse(msg)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
test("event rejects missing sessionId", () => {
|
|
const msg = { type: "event", event: "x", data: null }
|
|
const result = RemoteProtocol.Event.safeParse(msg)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
test("command rejects missing id", () => {
|
|
const msg = { type: "command", command: "ping", data: null }
|
|
const result = RemoteProtocol.Command.safeParse(msg)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
test("subscribe rejects missing sessionId", () => {
|
|
const msg = { type: "subscribe" }
|
|
const result = RemoteProtocol.Subscribe.safeParse(msg)
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
test("session info rejects missing fields", () => {
|
|
const result = RemoteProtocol.SessionInfo.safeParse({ id: "x" })
|
|
expect(result.success).toBe(false)
|
|
})
|
|
|
|
test("valid heartbeat_ack parses", () => {
|
|
const msg = { type: "heartbeat_ack" }
|
|
const result = RemoteProtocol.HeartbeatAck.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("heartbeat_ack")
|
|
}
|
|
})
|
|
|
|
test("inbound union picks heartbeat_ack", () => {
|
|
const msg = { type: "heartbeat_ack" }
|
|
const result = RemoteProtocol.Inbound.safeParse(msg)
|
|
expect(result.success).toBe(true)
|
|
if (result.success) {
|
|
expect(result.data.type).toBe("heartbeat_ack")
|
|
}
|
|
})
|
|
})
|