feat(tui): add agent picker and agents list rpc

This commit is contained in:
Peter Steinberger
2026-01-09 00:53:11 +01:00
parent a5f0f62e0d
commit 714e170c16
14 changed files with 471 additions and 20 deletions

View File

@@ -3,6 +3,12 @@ import {
type AgentEvent,
AgentEventSchema,
AgentParamsSchema,
type AgentSummary,
AgentSummarySchema,
type AgentsListParams,
AgentsListParamsSchema,
type AgentsListResult,
AgentsListResultSchema,
type AgentWaitParams,
AgentWaitParamsSchema,
type ChatAbortParams,
@@ -163,6 +169,9 @@ export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
AgentWaitParamsSchema,
);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(
AgentsListParamsSchema,
);
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
NodePairRequestParamsSchema,
);
@@ -332,6 +341,9 @@ export {
ProvidersStatusParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
AgentSummarySchema,
AgentsListParamsSchema,
AgentsListResultSchema,
ModelsListParamsSchema,
SkillsStatusParamsSchema,
SkillsInstallParamsSchema,
@@ -394,6 +406,9 @@ export type {
ProvidersStatusParams,
WebLoginStartParams,
WebLoginWaitParams,
AgentSummary,
AgentsListParams,
AgentsListResult,
SkillsStatusParams,
SkillsInstallParams,
SkillsUpdateParams,

View File

@@ -314,6 +314,7 @@ export const SessionsListParamsSchema = Type.Object(
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@@ -590,6 +591,29 @@ export const ModelChoiceSchema = Type.Object(
{ additionalProperties: false },
);
export const AgentSummarySchema = Type.Object(
{
id: NonEmptyString,
name: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const AgentsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const AgentsListResultSchema = Type.Object(
{
defaultId: NonEmptyString,
mainKey: NonEmptyString,
scope: Type.Union([Type.Literal("per-sender"), Type.Literal("global")]),
agents: Type.Array(AgentSummarySchema),
},
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
@@ -927,6 +951,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
AgentSummary: AgentSummarySchema,
AgentsListParams: AgentsListParamsSchema,
AgentsListResult: AgentsListResultSchema,
ModelChoice: ModelChoiceSchema,
ModelsListParams: ModelsListParamsSchema,
ModelsListResult: ModelsListResultSchema,
@@ -1000,6 +1027,9 @@ export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
export type AgentSummary = Static<typeof AgentSummarySchema>;
export type AgentsListParams = Static<typeof AgentsListParamsSchema>;
export type AgentsListResult = Static<typeof AgentsListResultSchema>;
export type ModelChoice = Static<typeof ModelChoiceSchema>;
export type ModelsListParams = Static<typeof ModelsListParamsSchema>;
export type ModelsListResult = Static<typeof ModelsListResultSchema>;

View File

@@ -1,5 +1,6 @@
import { ErrorCodes, errorShape } from "./protocol/index.js";
import { agentHandlers } from "./server-methods/agent.js";
import { agentsHandlers } from "./server-methods/agents.js";
import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
@@ -45,6 +46,7 @@ const handlers: GatewayRequestHandlers = {
...sendHandlers,
...usageHandlers,
...agentHandlers,
...agentsHandlers,
};
export async function handleGatewayRequest(

View File

@@ -0,0 +1,29 @@
import { loadConfig } from "../../config/config.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateAgentsListParams,
} from "../protocol/index.js";
import { listAgentsForGateway } from "../session-utils.js";
import type { GatewayRequestHandlers } from "./types.js";
export const agentsHandlers: GatewayRequestHandlers = {
"agents.list": ({ params, respond }) => {
if (!validateAgentsListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`,
),
);
return;
}
const cfg = loadConfig();
const result = listAgentsForGateway(cfg);
respond(true, result, undefined);
},
};

View File

@@ -0,0 +1,49 @@
import { describe, expect, test } from "vitest";
import {
connectOk,
installGatewayTestHooks,
rpcReq,
startServerWithClient,
testState,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway server agents", () => {
test("lists configured agents via agents.list RPC", async () => {
testState.routingConfig = {
defaultAgentId: "work",
agents: {
work: { name: "Work" },
home: { name: "Home" },
},
};
const { ws } = await startServerWithClient();
const hello = await connectOk(ws);
expect(
(hello as unknown as { features?: { methods?: string[] } }).features
?.methods,
).toEqual(expect.arrayContaining(["agents.list"]));
const res = await rpcReq<{
defaultId: string;
mainKey: string;
scope: string;
agents: Array<{ id: string; name?: string }>;
}>(ws, "agents.list", {});
expect(res.ok).toBe(true);
expect(res.payload?.defaultId).toBe("work");
expect(res.payload?.mainKey).toBe("main");
expect(res.payload?.scope).toBe("per-sender");
expect(res.payload?.agents.map((agent) => agent.id)).toEqual([
"work",
"home",
]);
const work = res.payload?.agents.find((agent) => agent.id === "work");
const home = res.payload?.agents.find((agent) => agent.id === "home");
expect(work?.name).toBe("Work");
expect(home?.name).toBe("Home");
});
});

View File

@@ -320,4 +320,84 @@ describe("gateway server sessions", () => {
ws.close();
await server.close();
});
test("filters sessions by agentId", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-sessions-agents-"),
);
testState.sessionConfig = {
store: path.join(dir, "{agentId}", "sessions.json"),
};
testState.routingConfig = {
defaultAgentId: "home",
agents: {
home: {},
work: {},
},
};
const homeDir = path.join(dir, "home");
const workDir = path.join(dir, "work");
await fs.mkdir(homeDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await fs.writeFile(
path.join(homeDir, "sessions.json"),
JSON.stringify(
{
"agent:home:main": {
sessionId: "sess-home-main",
updatedAt: Date.now(),
},
"agent:home:discord:group:dev": {
sessionId: "sess-home-group",
updatedAt: Date.now() - 1000,
},
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(workDir, "sessions.json"),
JSON.stringify(
{
"agent:work:main": {
sessionId: "sess-work-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { ws } = await startServerWithClient();
await connectOk(ws);
const homeSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "home",
});
expect(homeSessions.ok).toBe(true);
expect(homeSessions.payload?.sessions.map((s) => s.key).sort()).toEqual([
"agent:home:discord:group:dev",
"agent:home:main",
]);
const workSessions = await rpcReq<{
sessions: Array<{ key: string }>;
}>(ws, "sessions.list", {
includeGlobal: false,
includeUnknown: false,
agentId: "work",
});
expect(workSessions.ok).toBe(true);
expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual([
"agent:work:main",
]);
});
});

View File

@@ -227,6 +227,7 @@ const METHODS = [
"wizard.status",
"talk.mode",
"models.list",
"agents.list",
"skills.status",
"skills.install",
"skills.update",

View File

@@ -17,8 +17,10 @@ import {
resolveSessionTranscriptPath,
resolveStorePath,
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import {
DEFAULT_MAIN_KEY,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
@@ -56,6 +58,11 @@ export type GatewaySessionRow = {
lastAccountId?: string;
};
export type GatewayAgentRow = {
id: string;
name?: string;
};
export type SessionsListResult = {
ts: number;
path: string;
@@ -237,6 +244,39 @@ function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
return sorted;
}
export function listAgentsForGateway(cfg: ClawdbotConfig): {
defaultId: string;
mainKey: string;
scope: SessionScope;
agents: GatewayAgentRow[];
} {
const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
const mainKey =
(cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
const scope = cfg.session?.scope ?? "per-sender";
const configured = cfg.routing?.agents;
const configuredById = new Map<string, { name?: string }>();
if (configured && typeof configured === "object") {
for (const [key, value] of Object.entries(configured)) {
if (!value || typeof value !== "object") continue;
configuredById.set(normalizeAgentId(key), {
name:
typeof value.name === "string" && value.name.trim()
? value.name.trim()
: undefined,
});
}
}
const agents = listConfiguredAgentIds(cfg).map((id) => {
const meta = configuredById.get(id);
return {
id,
name: meta?.name,
};
});
return { defaultId, mainKey, scope, agents };
}
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
if (key === "global" || key === "unknown") return key;
if (key.startsWith("agent:")) return key;
@@ -394,6 +434,8 @@ export function listSessionsFromStore(params: {
const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
const agentId =
typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
const activeMinutes =
typeof opts.activeMinutes === "number" &&
Number.isFinite(opts.activeMinutes)
@@ -404,6 +446,12 @@ export function listSessionsFromStore(params: {
.filter(([key]) => {
if (!includeGlobal && key === "global") return false;
if (!includeUnknown && key === "unknown") return false;
if (agentId) {
if (key === "global" || key === "unknown") return false;
const parsed = parseAgentSessionKey(key);
if (!parsed) return false;
return normalizeAgentId(parsed.agentId) === agentId;
}
return true;
})
.filter(([key, entry]) => {

View File

@@ -85,6 +85,7 @@ export const agentCommand = hoisted.agentCommand;
export const testState = {
agentConfig: undefined as Record<string, unknown> | undefined,
routingConfig: undefined as Record<string, unknown> | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record<string, unknown> | undefined,
allowFrom: undefined as string[] | undefined,
@@ -246,6 +247,7 @@ vi.mock("../config/config.js", async () => {
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
},
routing: testState.routingConfig,
whatsapp: {
allowFrom: testState.allowFrom,
},
@@ -354,6 +356,7 @@ export function installGatewayTestHooks() {
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
testState.routingConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();