From 343d10c1b41107a4b780b6a4b3a712eb6abd3e69 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Tue, 9 Jun 2026 22:42:54 -0500 Subject: [PATCH] fix: discover agents across all accessible drives, not just the default Shift+Tab agent cycling only showed AI_CHAT pages from PAGESPACE_DRIVE because discovery walked a single drive. listAgentsAllDrives lists every drive the token can access (default drive's agents first), skips drives whose page tree fails to load, and disambiguates duplicate display names with a short page-id suffix since pi model ids are the display names. Co-Authored-By: Claude Fable 5 --- extensions/pagespace.ts | 9 ++--- src/api.ts | 60 ++++++++++++++++++++++++-------- test/unit/agents.test.ts | 74 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 test/unit/agents.test.ts diff --git a/extensions/pagespace.ts b/extensions/pagespace.ts index 6aa1459..7d437c1 100644 --- a/extensions/pagespace.ts +++ b/extensions/pagespace.ts @@ -53,11 +53,12 @@ export default async function (pi: ExtensionAPI) { config.conversationId = globalThis.crypto.randomUUID(); const api = new PageSpaceApi(config); - // Auto-discover agents from the page tree when PAGESPACE_MODEL_PAGES is not explicitly set. - // Uses AI_CHAT page titles as display names — accurate, no dependence on /api/v1/models name field. - if (!process.env.PAGESPACE_MODEL_PAGES?.trim() && config.defaultDriveSlug) { + // Auto-discover agents from every drive the token can access (default drive's agents first) + // when PAGESPACE_MODEL_PAGES is not explicitly set. Uses AI_CHAT page titles as display names — + // accurate, no dependence on /api/v1/models name field. + if (!process.env.PAGESPACE_MODEL_PAGES?.trim()) { try { - const discovered = await api.listAgentsByDriveSlug(config.defaultDriveSlug); + const discovered = await api.listAgentsAllDrives(config.defaultDriveSlug); if (discovered.length > 0) { const primary = config.modelPageId; const rest = discovered.filter((m) => m.id !== primary); diff --git a/src/api.ts b/src/api.ts index cd18650..374192c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -47,6 +47,42 @@ export interface TaskRecord { [key: string]: unknown; } +export interface AgentSpec { + id: string; + name: string; +} + +/** All AI_CHAT pages in a page tree, depth-first, with page titles as names. Pure. */ +export function collectAgentPages(pages: Page[]): AgentSpec[] { + const out: AgentSpec[] = []; + const walk = (nodes: Page[]) => { + for (const p of nodes) { + if (p.type === "AI_CHAT") out.push({ id: p.id, name: p.title }); + if (p.children) walk(p.children); + } + }; + walk(pages); + return out; +} + +/** Preferred drive first, others in given order. Pure. */ +export function orderDrivesPreferredFirst(drives: Drive[], preferredSlug?: string): Drive[] { + if (!preferredSlug) return drives; + return [...drives].sort((a, b) => (a.slug === preferredSlug ? -1 : b.slug === preferredSlug ? 1 : 0)); +} + +/** + * Disambiguate duplicate agent names by suffixing a short page-id fragment — pi model ids are + * the display names, so duplicates across drives would otherwise collide in the registry. Pure. + */ +export function dedupeAgentNames(agents: AgentSpec[]): AgentSpec[] { + const counts = new Map(); + for (const a of agents) counts.set(a.name, (counts.get(a.name) ?? 0) + 1); + return agents.map((a) => + (counts.get(a.name) ?? 0) > 1 ? { ...a, name: `${a.name} (${a.id.slice(0, 8)})` } : a, + ); +} + /** Transient HTTP statuses worth retrying (rate-limit + gateway/availability). */ const RETRIABLE_STATUS = new Set([429, 502, 503, 504]); @@ -119,21 +155,15 @@ export class PageSpaceApi { return this.request("GET", `/api/drives/${driveId}/pages`); } - /** All AI_CHAT pages in the drive identified by slug, with their page titles as names. */ - async listAgentsByDriveSlug(driveSlug: string): Promise<{ id: string; name: string }[]> { - const drives = await this.listDrives(); - const drive = drives.find((d) => d.slug === driveSlug); - if (!drive) return []; - const pages = await this.listPages(drive.id); - const out: { id: string; name: string }[] = []; - const walk = (nodes: Page[]) => { - for (const p of nodes) { - if (p.type === "AI_CHAT") out.push({ id: p.id, name: p.title }); - if (p.children) walk(p.children); - } - }; - walk(pages); - return out; + /** + * All AI_CHAT pages across every drive the token can access, preferred drive's agents first. + * A drive whose page tree fails to load is skipped — discovery degrades, never throws. + */ + async listAgentsAllDrives(preferredSlug?: string): Promise { + const drives = orderDrivesPreferredFirst(await this.listDrives(), preferredSlug); + const trees = await Promise.allSettled(drives.map((d) => this.listPages(d.id))); + const agents = trees.flatMap((t) => (t.status === "fulfilled" ? collectAgentPages(t.value) : [])); + return dedupeAgentNames(agents); } createPage(input: { diff --git a/test/unit/agents.test.ts b/test/unit/agents.test.ts new file mode 100644 index 0000000..a54fe92 --- /dev/null +++ b/test/unit/agents.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + collectAgentPages, + dedupeAgentNames, + orderDrivesPreferredFirst, + type Drive, + type Page, +} from "../../src/api.ts"; + +const page = (id: string, title: string, type: string, children?: Page[]): Page => ({ + id, + title, + type, + parentId: null, + children, +}); + +test("collectAgentPages: finds AI_CHAT pages at any depth, in tree order, titles as names", () => { + const tree: Page[] = [ + page("a", "Top Agent", "AI_CHAT"), + page("f", "Folder", "FOLDER", [ + page("doc", "Doc", "DOCUMENT"), + page("b", "Nested Agent", "AI_CHAT", [page("c", "Deep Agent", "AI_CHAT")]), + ]), + ]; + assert.deepEqual(collectAgentPages(tree), [ + { id: "a", name: "Top Agent" }, + { id: "b", name: "Nested Agent" }, + { id: "c", name: "Deep Agent" }, + ]); +}); + +test("collectAgentPages: no agents → empty list", () => { + assert.deepEqual(collectAgentPages([page("d", "Doc", "DOCUMENT")]), []); +}); + +test("orderDrivesPreferredFirst: preferred drive moves to front, others keep order", () => { + const drives: Drive[] = [ + { id: "1", name: "A", slug: "a" }, + { id: "2", name: "B", slug: "b" }, + { id: "3", name: "C", slug: "c" }, + ]; + assert.deepEqual( + orderDrivesPreferredFirst(drives, "b").map((d) => d.slug), + ["b", "a", "c"], + ); + // No preference (or unknown slug) → unchanged order, original array untouched. + assert.deepEqual( + orderDrivesPreferredFirst(drives).map((d) => d.slug), + ["a", "b", "c"], + ); + assert.deepEqual( + orderDrivesPreferredFirst(drives, "zzz").map((d) => d.slug), + ["a", "b", "c"], + ); + assert.deepEqual( + drives.map((d) => d.slug), + ["a", "b", "c"], + ); +}); + +test("dedupeAgentNames: duplicate names get a short id suffix; unique names untouched", () => { + const agents = [ + { id: "abcdefgh1234", name: "Curator" }, + { id: "ijklmnop5678", name: "Curator" }, + { id: "qrstuvwx9012", name: "Writer" }, + ]; + assert.deepEqual(dedupeAgentNames(agents), [ + { id: "abcdefgh1234", name: "Curator (abcdefgh)" }, + { id: "ijklmnop5678", name: "Curator (ijklmnop)" }, + { id: "qrstuvwx9012", name: "Writer" }, + ]); +});