Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, test } from "bun:test"
import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client"

const child = {
id: "child-1",
version: 1,
cwd: "/repo",
title: "Review tests",
parent: { kind: "agent", sessionId: "parent-1" },
createdAt: "2026-11-08T00:00:00Z",
lastActivityAt: "2026-11-08T00:00:00Z",
status: "idle",
flags: ["waitingApproval"],
archived: false,
ephemeral: false,
activity: "working",
model: { provider: "test", model: "test" },
settings: { permissionProfile: "default" },
preview: "",
queuedCount: 1,
usage: {
total: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
reasoningTokens: 0,
totalTokens: 0,
callCount: 0,
meteredCallCount: 0,
failedCallCount: 0,
cancelledCallCount: 0,
},
byPurpose: [],
updatedAt: "2026-11-08T00:00:00Z",
},
}

class Transport implements DevoNativeTransport {
requests: Array<{ method: string; params: unknown }> = []
error: Error | undefined
async request(method: string, params?: unknown) {
this.requests.push({ method, params })
if (method === "initialize")
return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }
if (this.error) throw this.error
if (method === "session/read") return { session: child }
if (method === "session/list") {
return (params as { includeChildren?: boolean }).includeChildren
? { data: [child], nextCursor: "200" }
: { data: [], nextCursor: null }
}
throw new Error(`Unexpected ${method}`)
}
async respond() {}
subscribe(_listener: (event: DevoNativeTransportEvent) => void) {
return () => {}
}
connected() {
return true
}
}

describe("read-only child session discovery", () => {
test("roster forwards pagination and preserves Native flags without resuming or loading history", async () => {
const transport = new Transport()
const client = createDevoClient({ directory: "/repo", transport })
try {
const params = {
cwds: [],
includeChildren: true,
limit: 200,
cursor: "100",
}
expect(await client.session.roster(params)).toEqual({
data: [child],
nextCursor: "200",
})
expect(transport.requests.filter((r) => r.method !== "initialize")).toEqual([
{ method: "session/list", params },
])
} finally {
client.dispose()
}
})
test("cold child lookup reads its exact ID instead of the root-only roster", async () => {
const transport = new Transport()
const client = createDevoClient({ transport })
try {
const [first, second] = await Promise.all([
client.session.get({ sessionId: child.id }),
client.session.get({ sessionId: child.id }),
])
expect(first).toEqual(second)
expect(first.data).toMatchObject({
id: child.id,
parentId: "parent-1",
directory: "/repo",
})
expect(transport.requests.filter((r) => r.method !== "initialize")).toEqual([
{ method: "session/read", params: { sessionId: child.id } },
])
} finally {
client.dispose()
}
})
test("missing children return undefined; connection errors are not disguised as missing", async () => {
const transport = new Transport()
const client = createDevoClient({ transport })
try {
transport.error = Object.assign(new Error("gone"), {
code: "SessionNotFound",
})
expect(await client.session.get({ sessionId: "missing" })).toEqual({
data: undefined,
})
transport.error = new Error("connection lost")
await expect(client.session.get({ sessionId: "missing" })).rejects.toThrow("connection lost")
} finally {
client.dispose()
}
})
})
17 changes: 15 additions & 2 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export type {
ReferenceSearchSnapshot,
} from "./reference-search-session"

import type { SessionRosterParams, SessionRosterPage } from "./session-roster"
export type { Session as NativeSession } from "./generated/native"
export type { SessionRosterParams, SessionRosterPage } from "./session-roster"

export type JsonRpcId = number | string

export interface DevoNativeTransportEvent {
Expand Down Expand Up @@ -850,6 +854,9 @@ class NativeClient {
list: async () => ({ data: await this.listProjects() }),
}
session = {
/** A single read-only Native roster page. Does not resume sessions or load transcripts. */
roster: async (params: SessionRosterParams): Promise<SessionRosterPage> =>
await this.requestCanonical("session/list", params) as SessionRosterPage,
list: async (params?: { limit?: number; roots?: boolean; search?: string }) => ({
data: await this.listSessions(params),
}),
Expand Down Expand Up @@ -1679,8 +1686,14 @@ class NativeClient {
private async discoverSession(sessionId: string): Promise<Session | undefined> {
const pending = this.sessionDiscovery.get(sessionId)
if (pending) return pending
const discovery = this.listSessions()
.then((sessions) => sessions.find((session) => session.id === sessionId))
// Child sessions are absent from the default root-only roster. Resolve an
// exact ID without scanning the roster or resuming any work.
const discovery = this.requestCanonical("session/read", { sessionId })
.then((result) => this.rememberNativeSession(result.session))
.catch((error) => {
if (isSessionNotFoundError(error)) return undefined
throw error
})
.finally(() => {
this.sessionDiscovery.delete(sessionId)
})
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/packages/devo-ai-sdk/src/v2/session-roster.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Session } from "./generated/native"

/** Native session/list parameters. The protocol generator exports Session,
* but not the list RPC types; runtime validation still uses the Native schema. */
export interface SessionRosterParams {
cwds?: string[]
search?: string
cursor?: string
limit?: number
includeChildren?: boolean
}

export interface SessionRosterPage {
data: Session[]
nextCursor?: string | null
}
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/components/agent-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
setOpenInPreferred,
} from "../services/backend"
import { ChatView } from "./chat"
import { SubagentsButton } from "./subagents-button"
import { ContextUsageButton } from "./context-usage-button"
import { BottomPanelIcon, RightPanelIcon } from "./panel-icons"
import { ReviewSideRail } from "./review/review-side-rail"
Expand Down Expand Up @@ -415,6 +416,7 @@ export function SessionPanelHeader({
</div>

<div className="flex shrink-0 items-center gap-0.5">
<SubagentsButton key={agent.sessionId} agent={agent} />
<ContextUsageButton sessionId={agent.sessionId} />
<TerminalToggleButton />
<ChangesPanelToggleButton
Expand Down
160 changes: 160 additions & 0 deletions apps/desktop/src/renderer/components/subagents-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import type { NativeSession, SessionRosterPage } from "@devo-ai/sdk/v2/client"
import { Window } from "happy-dom"
import { act } from "react"
import type { Root } from "react-dom/client"
import type { Agent } from "../lib/types"

const dom = new Window({ url: "http://localhost" })
Object.assign(globalThis, {
window: dom,
document: dom.document,
navigator: dom.navigator,
Node: dom.Node,
Element: dom.Element,
HTMLElement: dom.HTMLElement,
HTMLInputElement: dom.HTMLInputElement,
ShadowRoot: dom.ShadowRoot,
Event: dom.Event,
MouseEvent: dom.MouseEvent,
KeyboardEvent: dom.KeyboardEvent,
MutationObserver: dom.MutationObserver,
ResizeObserver: dom.ResizeObserver,
getComputedStyle: dom.getComputedStyle.bind(dom),
requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom),
IS_REACT_ACT_ENVIRONMENT: true,
})
let page: SessionRosterPage = { data: [], nextCursor: null }
let calls = 0
const navigations: unknown[] = []
mock.module("../services/connection-manager", () => ({
getProjectClient: () => ({
session: {
roster: async () => {
calls++
return page
},
},
}),
}))
mock.module("@tanstack/react-router", () => ({
useNavigate: () => (route: unknown) => {
navigations.push(route)
},
}))
const { SubagentsButton, subagentStatus } = await import("./subagents-button")
const { createRoot } = await import("react-dom/client")
let root: Root | undefined
let mount: HTMLElement
const agent = {
sessionId: "parent",
directory: "/repo",
projectSlug: "repo-1",
} as Agent
const child = {
id: "child",
title: "Check runtime",
parent: { kind: "agent", sessionId: "parent" },
ephemeral: false,
flags: [],
status: "idle",
queuedCount: 0,
} as unknown as NativeSession

async function render() {
mount = document.createElement("div")
document.body.append(mount)
root = createRoot(mount)
await act(async () => root?.render(<SubagentsButton agent={agent} />))
}
function button(label: string) {
const element = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
if (!element) throw new Error(`Missing ${label}`)
return element
}
async function click(element: HTMLElement) {
await act(async () => {
element.click()
await Bun.sleep(30)
})
}
afterEach(async () => {
await act(async () => {
root?.unmount()
await Bun.sleep(30)
})
root = undefined
mount?.remove()
page = { data: [], nextCursor: null }
calls = 0
navigations.length = 0
})

describe("subagent labels", () => {
for (const [patch, label] of [
[{}, "Idle"],
[{ status: "active" }, "Working"],
[{ queuedCount: 2 }, "Queued"],
[{ status: "active", flags: ["waitingApproval"] }, "Needs approval"],
[{ flags: ["waitingUserInput"] }, "Needs input"],
[{ flags: ["compacting"] }, "Compacting"],
[{ flags: ["updatingGoal"] }, "Updating goal"],
] as const) {
test(label, () => expect(subagentStatus({ ...child, ...patch } as NativeSession)).toBe(label))
}
})

describe("subagent popover interactions", () => {
test("opens on demand, shows child state and navigates using the real session ID", async () => {
page = {
data: [{ ...child, flags: ["waitingApproval"] }],
nextCursor: null,
}
await render()
expect(calls).toBe(0)
await click(button("Subagents"))
expect(calls).toBe(1)
expect(document.body.textContent).toContain("Needs approval")
expect(document.body.textContent).toContain("Idle does not confirm completion")
const row = document.querySelector<HTMLButtonElement>(
"ul[aria-label='Subagent sessions'] button",
)
if (!row) throw new Error("Missing child row")
await click(row)
expect(navigations).toEqual([
{
to: "/project/$projectSlug/session/$sessionId",
params: { projectSlug: "repo-1", sessionId: "child" },
},
])
expect(button("Subagents").getAttribute("aria-expanded")).toBe("false")
})
test("close and Escape dismiss without navigation and restore trigger focus", async () => {
await render()
button("Subagents").focus()
await click(button("Subagents"))
await click(button("Close subagents"))
expect(button("Subagents").getAttribute("aria-expanded")).toBe("false")
expect(document.activeElement).toBe(button("Subagents"))
await click(button("Subagents"))
await act(async () => {
document.activeElement?.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
)
await Bun.sleep(30)
})
expect(button("Subagents").getAttribute("aria-expanded")).toBe("false")
expect(navigations).toEqual([])
})
test("refresh updates empty state and does not start model work", async () => {
await render()
await click(button("Subagents"))
expect(document.body.textContent).toContain("No saved subagents")
page = { data: [child], nextCursor: null }
await click(button("Refresh subagents"))
expect(document.body.textContent).toContain("Check runtime")
expect(calls).toBe(2)
expect(navigations).toEqual([])
})
})
Loading
Loading