diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 4385a534..fdb7ad1f 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -7,6 +7,7 @@ import { createProcessTool } from "./tools/process.js"; import { createGlobTool } from "./tools/glob.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web/index.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn.js"; +import { createSessionsListTool } from "./tools/sessions-list.js"; import { createMemorySearchTool } from "./tools/memory-search.js"; import { createCronTool } from "./tools/cron/index.js"; import { filterTools } from "./tools/policy.js"; @@ -133,6 +134,10 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< }); tools.push(sessionsSpawnTool as AgentTool); + // Add sessions_list tool + const sessionsListTool = createSessionsListTool({ sessionId }); + tools.push(sessionsListTool as AgentTool); + return tools; } diff --git a/src/agent/tools/groups.ts b/src/agent/tools/groups.ts index b2430cb4..f61c9037 100644 --- a/src/agent/tools/groups.ts +++ b/src/agent/tools/groups.ts @@ -34,7 +34,7 @@ export const TOOL_GROUPS: Record = { "group:memory": ["memory_search"], // Subagent tools - "group:subagent": ["sessions_spawn"], + "group:subagent": ["sessions_spawn", "sessions_list"], // Cron/scheduling tools "group:cron": ["cron"], diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 5e4902ea..e225c54c 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -8,6 +8,7 @@ export { createProcessTool } from "./process.js"; export { createGlobTool } from "./glob.js"; export { createWebFetchTool, createWebSearchTool } from "./web/index.js"; export { createCronTool } from "./cron/index.js"; +export { createSessionsListTool } from "./sessions-list.js"; // Tool groups export { diff --git a/src/agent/tools/sessions-list.test.ts b/src/agent/tools/sessions-list.test.ts new file mode 100644 index 00000000..a0bf6c1a --- /dev/null +++ b/src/agent/tools/sessions-list.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { SubagentRunRecord } from "../subagent/types.js"; + +// Mock the registry module before importing the tool +vi.mock("../subagent/registry.js", () => ({ + listSubagentRuns: vi.fn(), + getSubagentRun: vi.fn(), +})); + +import { createSessionsListTool } from "./sessions-list.js"; +import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js"; + +const mockListSubagentRuns = vi.mocked(listSubagentRuns); +const mockGetSubagentRun = vi.mocked(getSubagentRun); + +function makeRecord(overrides: Partial = {}): SubagentRunRecord { + return { + runId: "run-001", + childSessionId: "child-001", + requesterSessionId: "parent-001", + task: "Test task", + cleanup: "delete", + createdAt: 1700000000000, + ...overrides, + }; +} + +describe("sessions_list tool", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns empty message when no runs exist", async () => { + mockListSubagentRuns.mockReturnValue([]); + const tool = createSessionsListTool({ sessionId: "parent-001" }); + const result = await tool.execute("call-1", {}); + + expect(result.content[0]).toEqual({ + type: "text", + text: "No subagent runs for this session.", + }); + expect(result.details).toEqual({ runs: [] }); + }); + + it("lists multiple runs with correct status mapping", async () => { + const now = Date.now(); + const runs: SubagentRunRecord[] = [ + makeRecord({ + runId: "run-aaa", + label: "Code Review", + startedAt: now - 45000, + }), + makeRecord({ + runId: "run-bbb", + label: "Test Analysis", + startedAt: now - 60000, + endedAt: now - 30000, + outcome: { status: "ok" }, + }), + makeRecord({ + runId: "run-ccc", + label: "Lint Check", + startedAt: now - 60000, + endedAt: now, + outcome: { status: "error", error: "timeout" }, + }), + ]; + mockListSubagentRuns.mockReturnValue(runs); + + const tool = createSessionsListTool({ sessionId: "parent-001" }); + const result = await tool.execute("call-1", {}); + + const text = result.content[0]!; + expect(text.type).toBe("text"); + expect((text as { text: string }).text).toContain("3 total"); + expect((text as { text: string }).text).toContain("[running]"); + expect((text as { text: string }).text).toContain("[ok]"); + expect((text as { text: string }).text).toContain("[error]"); + expect((text as { text: string }).text).toContain("Code Review"); + expect((text as { text: string }).text).toContain("Test Analysis"); + expect((text as { text: string }).text).toContain("Lint Check"); + + expect(result.details!.runs).toHaveLength(3); + expect(result.details!.runs[0]!.status).toBe("running"); + expect(result.details!.runs[1]!.status).toBe("ok"); + expect(result.details!.runs[2]!.status).toBe("error"); + }); + + it("returns detail for a specific runId", async () => { + const now = Date.now(); + const record = makeRecord({ + runId: "run-detail", + label: "Deep Analysis", + task: "Analyze the authentication module thoroughly", + startedAt: now - 90000, + endedAt: now - 10000, + outcome: { status: "ok" }, + findings: "Found 2 potential issues in token validation.", + findingsCaptured: true, + }); + mockGetSubagentRun.mockReturnValue(record); + + const tool = createSessionsListTool({ sessionId: "parent-001" }); + const result = await tool.execute("call-1", { runId: "run-detail" }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("Run: run-detail"); + expect(text).toContain("Label: Deep Analysis"); + expect(text).toContain("Status: ok"); + expect(text).toContain("Found 2 potential issues"); + expect(text).toContain("Duration:"); + + expect(result.details!.runs).toHaveLength(1); + expect(result.details!.runs[0]!.runId).toBe("run-detail"); + }); + + it("returns not found for unknown runId", async () => { + mockGetSubagentRun.mockReturnValue(undefined); + + const tool = createSessionsListTool({ sessionId: "parent-001" }); + const result = await tool.execute("call-1", { runId: "nonexistent" }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("Run not found"); + expect(result.details).toEqual({ runs: [] }); + }); + + it("rejects runId belonging to a different requester", async () => { + const record = makeRecord({ + runId: "run-other", + requesterSessionId: "other-parent", + }); + mockGetSubagentRun.mockReturnValue(record); + + const tool = createSessionsListTool({ sessionId: "parent-001" }); + const result = await tool.execute("call-1", { runId: "run-other" }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("Run not found"); + expect(result.details).toEqual({ runs: [] }); + }); + + it("handles missing sessionId gracefully", async () => { + const tool = createSessionsListTool({}); + const result = await tool.execute("call-1", {}); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("No session ID available"); + expect(result.details).toEqual({ runs: [] }); + }); + + it("shows findings status for running task", async () => { + const now = Date.now(); + const record = makeRecord({ + runId: "run-running", + label: "Still Running", + startedAt: now - 30000, + // no endedAt + }); + mockGetSubagentRun.mockReturnValue(record); + + const tool = createSessionsListTool({ sessionId: "parent-001" }); + const result = await tool.execute("call-1", { runId: "run-running" }); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain("Status: running"); + expect(text).toContain("Findings: (still running)"); + }); +}); diff --git a/src/agent/tools/sessions-list.ts b/src/agent/tools/sessions-list.ts new file mode 100644 index 00000000..1106b21e --- /dev/null +++ b/src/agent/tools/sessions-list.ts @@ -0,0 +1,187 @@ +/** + * sessions_list tool — allows an agent to view its spawned subagent runs. + * + * Lists all subagent runs for the current session, or shows details for a + * specific run when a runId is provided. + */ + +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { listSubagentRuns, getSubagentRun } from "../subagent/registry.js"; +import type { SubagentRunRecord } from "../subagent/types.js"; + +const SessionsListSchema = Type.Object({ + runId: Type.Optional( + Type.String({ description: "Optional run ID to get details for a specific run. If omitted, lists all runs." }), + ), +}); + +type SessionsListArgs = { + runId?: string; +}; + +export type SessionsListResult = { + runs: Array<{ + runId: string; + label?: string; + task: string; + status: "running" | "ok" | "error" | "timeout" | "unknown"; + startedAt?: number; + endedAt?: number; + findings?: string; + }>; +}; + +export interface CreateSessionsListToolOptions { + /** Session ID of the current (requester) agent */ + sessionId?: string; +} + +function resolveStatus(record: SubagentRunRecord): "running" | "ok" | "error" | "timeout" | "unknown" { + if (!record.endedAt) return "running"; + return record.outcome?.status ?? "unknown"; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes < 60) return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`; +} + +function formatRunSummary(record: SubagentRunRecord, index: number, now: number): string { + const status = resolveStatus(record); + const displayName = record.label || record.task.slice(0, 60); + const statusTag = `[${status}]`.padEnd(10); + + let timing = ""; + if (status === "running" && record.startedAt) { + timing = `started ${formatElapsed(now - record.startedAt)} ago`; + } else if (record.startedAt && record.endedAt) { + timing = `completed in ${formatElapsed(record.endedAt - record.startedAt)}`; + } + + const parts = [`#${index + 1} ${statusTag} "${displayName}"`]; + if (timing) parts.push(`(${record.runId.slice(0, 8)}…, ${timing})`); + else parts.push(`(${record.runId.slice(0, 8)}…)`); + + return parts.join(" "); +} + +function formatRunDetail(record: SubagentRunRecord, now: number): string { + const status = resolveStatus(record); + const lines: string[] = [ + `Run: ${record.runId}`, + ]; + + if (record.label) lines.push(`Label: ${record.label}`); + lines.push(`Task: ${record.task}`); + lines.push(`Status: ${status}${record.outcome?.error ? ` — ${record.outcome.error}` : ""}`); + lines.push(`Child Session: ${record.childSessionId}`); + lines.push(`Created: ${new Date(record.createdAt).toISOString()} (${formatElapsed(now - record.createdAt)} ago)`); + + if (record.startedAt) { + lines.push(`Started: ${new Date(record.startedAt).toISOString()} (${formatElapsed(now - record.startedAt)} ago)`); + } + if (record.endedAt) { + lines.push(`Ended: ${new Date(record.endedAt).toISOString()}`); + if (record.startedAt) { + lines.push(`Duration: ${formatElapsed(record.endedAt - record.startedAt)}`); + } + } + + if (record.findingsCaptured) { + lines.push(`Findings: ${record.findings || "(no output)"}`); + } else if (record.endedAt) { + lines.push("Findings: (not yet captured)"); + } else { + lines.push("Findings: (still running)"); + } + + if (record.announced) lines.push("Announced: yes"); + + return lines.join("\n"); +} + +function toResultRun(record: SubagentRunRecord) { + return { + runId: record.runId, + label: record.label, + task: record.task, + status: resolveStatus(record), + startedAt: record.startedAt, + endedAt: record.endedAt, + findings: record.findings, + }; +} + +export function createSessionsListTool( + options: CreateSessionsListToolOptions, +): AgentTool { + return { + name: "sessions_list", + label: "List Subagent Runs", + description: + "List all subagent runs spawned by this session and their current status. " + + "Optionally pass a runId to get detailed information about a specific run.", + parameters: SessionsListSchema, + execute: async (_toolCallId, args) => { + const { runId } = args as SessionsListArgs; + const requesterSessionId = options.sessionId; + + if (!requesterSessionId) { + return { + content: [{ type: "text", text: "No session ID available. Cannot list subagent runs." }], + details: { runs: [] }, + }; + } + + const now = Date.now(); + + // Detail mode: specific run + if (runId) { + const record = getSubagentRun(runId); + if (!record) { + return { + content: [{ type: "text", text: `Run not found: ${runId}` }], + details: { runs: [] }, + }; + } + if (record.requesterSessionId !== requesterSessionId) { + return { + content: [{ type: "text", text: `Run not found: ${runId}` }], + details: { runs: [] }, + }; + } + return { + content: [{ type: "text", text: formatRunDetail(record, now) }], + details: { runs: [toResultRun(record)] }, + }; + } + + // List mode: all runs for this session + const runs = listSubagentRuns(requesterSessionId); + + if (runs.length === 0) { + return { + content: [{ type: "text", text: "No subagent runs for this session." }], + details: { runs: [] }, + }; + } + + const lines = [`Subagent runs for this session: ${runs.length} total`, ""]; + for (let i = 0; i < runs.length; i++) { + lines.push(formatRunSummary(runs[i]!, i, now)); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { runs: runs.map(toResultRun) }, + }; + }, + }; +}