diff --git a/src/server/sessiond.ts b/src/server/sessiond.ts index 0a67a5e..819ba8f 100644 --- a/src/server/sessiond.ts +++ b/src/server/sessiond.ts @@ -77,6 +77,7 @@ await runSessionDaemonStartup({ logger: app.log, ...(spawnTargets === undefined ? {} : { spawnTargets }), subsessionsEnabled: spawnTargets !== undefined && config.subsessions, + askUserEnabled: config.askUser, notificationStore, unreadStore, // Read-only, so session startup can tell a waiting user that provider diff --git a/src/server/sessions/askUserTool.test.ts b/src/server/sessions/askUserTool.test.ts new file mode 100644 index 0000000..612d4e8 --- /dev/null +++ b/src/server/sessions/askUserTool.test.ts @@ -0,0 +1,143 @@ +import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { createAskUserToolDefinition, type AskUserInvocation } from "./askUserTool.js"; +import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js"; + +function ctxFor(sessionId: string): ExtensionContext { + const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => undefined }; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool uses. + return { sessionManager } as unknown as ExtensionContext; +} + +function firstText(content: readonly (TextContent | ImageContent)[]): string { + const first = content[0]; + return first?.type === "text" ? first.text : ""; +} + +/** The tool over a real store, so the schema and the store's contract stay aligned. */ +function toolOverStore(askIds: string[] = ["ask-1", "ask-2"]) { + const remaining = [...askIds]; + const store = new PendingAskStore({ + now: () => new Date("2026-02-01T10:00:00.000Z"), + createAskId: () => remaining.shift() ?? "ask-exhausted", + }); + const open = vi.fn((input: AskUserInvocation) => Promise.resolve(store.open(input))); + return { store, open, tool: createAskUserToolDefinition({ open }) }; +} + +const twoQuestions = { + questions: [ + { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] }, + { id: "why", question: "Why?", options: [], allowOther: true }, + ], +}; + +describe("createAskUserToolDefinition", () => { + it("advertises a non-blocking question set whose answers arrive as a follow-up", () => { + const { tool } = toolOverStore(); + + expect(tool.name).toBe("ask_user"); + expect(tool.description).toBe("Post a set of questions to the user as a browser form and end this run. Answers arrive later as a follow-up message; the user may leave any question unanswered."); + expect(tool.promptSnippet).toBe("ask_user: post a question set to the user; ends the run, answers return as a follow-up"); + expect(tool.promptGuidelines).toEqual([ + "When you need decisions from the user, post them together with ask_user instead of asking in prose one at a time. It ends the run and the answers, including the questions the user left unanswered, come back as a follow-up message that wakes you. Call it alone and last, and do not repost the same questions or poll for answers.", + ]); + }); + + it("bounds the question set in its parameter schema", () => { + const { tool } = toolOverStore(); + + expect(tool.parameters).toMatchObject({ + type: "object", + required: ["questions"], + properties: { questions: { type: "array", minItems: 1, maxItems: 20 } }, + }); + }); + + it("opens the ask for the calling session and terminates the run instead of awaiting the user", async () => { + const { open, tool } = toolOverStore(); + + const result = await tool.execute("call-1", twoQuestions, undefined, undefined, ctxFor("session-1")); + + expect(open).toHaveBeenCalledWith({ + sessionId: "session-1", + questions: [ + { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] }, + { id: "why", question: "Why?", options: [], allowOther: true }, + ], + }); + expect(result.terminate).toBe(true); + expect(result.details).toMatchObject({ ask: { askId: "ask-1", questions: [{ id: "db" }, { id: "why" }] } }); + expect(firstText(result.content)).toBe("Posted 2 questions to the user as ask ask-1. Ending this run; the answers arrive as a follow-up message that wakes you, naming every question the user left unanswered. Do not repost these questions."); + }); + + it("defaults a question without options to an empty option list so free text alone is expressible", async () => { + const { open, tool } = toolOverStore(); + + await tool.execute("call-free", { questions: [{ id: "note", question: "Anything else?", allowOther: true }] }, undefined, undefined, ctxFor("session-1")); + + expect(open).toHaveBeenCalledWith({ + sessionId: "session-1", + questions: [{ id: "note", question: "Anything else?", options: [], allowOther: true }], + }); + }); + + it("forwards per-question detail, option detail, and multi-select without inventing defaults", async () => { + const { open, tool } = toolOverStore(); + + await tool.execute("call-rich", { + questions: [{ + id: "targets", + question: "Which targets?", + detail: "Pick every platform we should build for.", + options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }], + multiple: true, + }], + }, undefined, undefined, ctxFor("session-1")); + + expect(open).toHaveBeenCalledWith({ + sessionId: "session-1", + questions: [{ + id: "targets", + question: "Which targets?", + detail: "Pick every platform we should build for.", + options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }], + multiple: true, + }], + }); + }); + + it("tells the model which questions the superseded ask left unanswered", async () => { + const { tool } = toolOverStore(); + await tool.execute("call-first", twoQuestions, undefined, undefined, ctxFor("session-1")); + + const result = await tool.execute("call-second", { questions: [{ id: "again", question: "Still there?", options: [{ value: "yes", label: "Yes" }] }] }, undefined, undefined, ctxFor("session-1")); + + expect(result.terminate).toBe(true); + expect(firstText(result.content)).toContain("Posted 1 question to the user as ask ask-2."); + expect(firstText(result.content)).toContain("This replaced an earlier question set (ask-1) that the user never submitted."); + expect(firstText(result.content)).toContain("Left unanswered: db, why."); + expect(result.details).toMatchObject({ superseded: { askId: "ask-1", reason: "superseded", unansweredIds: ["db", "why"] } }); + }); + + it("says nothing about superseding when no earlier ask was open", async () => { + const { tool } = toolOverStore(); + + const result = await tool.execute("call-only", twoQuestions, undefined, undefined, ctxFor("session-1")); + + expect(firstText(result.content)).not.toContain("replaced an earlier question set"); + expect(result.details).not.toHaveProperty("superseded"); + }); + + it("propagates a rejected question set so the agent loop reports it to the model", async () => { + const { tool } = toolOverStore(); + + await expect(tool.execute("call-dup", { + questions: [ + { id: "same", question: "First?", options: [{ value: "a", label: "A" }] }, + { id: "same", question: "Second?", options: [{ value: "b", label: "B" }] }, + ], + }, undefined, undefined, ctxFor("session-1"))).rejects.toThrow(PendingAskValidationError); + }); +}); diff --git a/src/server/sessions/askUserTool.ts b/src/server/sessions/askUserTool.ts new file mode 100644 index 0000000..7d05ad8 --- /dev/null +++ b/src/server/sessions/askUserTool.ts @@ -0,0 +1,132 @@ +import { Type, type Static } from "typebox"; +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { + ASK_USER_ID_MAX_LENGTH, + ASK_USER_OPTION_LIMIT, + ASK_USER_QUESTION_LIMIT, + ASK_USER_TEXT_MAX_LENGTH, + type AskUserQuestion, + type AskUserQuestionOption, +} from "../../shared/apiTypes.js"; +import { renderSupersededAskText, type PendingAskOpenResult } from "./pendingAskStore.js"; + +/** One `ask_user` call: the questions to post, for the session that called the tool. */ +export interface AskUserInvocation { + sessionId: string; + questions: AskUserQuestion[]; +} + +export interface AskUserToolDeps { + /** Registers the ask as the session's open one; rejects question sets the user could not answer. */ + open(input: AskUserInvocation): Promise; +} + +type AskUserToolDetails = PendingAskOpenResult; + +const AskUserOptionParams = Type.Object({ + value: Type.String({ + maxLength: ASK_USER_ID_MAX_LENGTH, + description: "Stable machine value reported back to you when the user picks this option.", + }), + label: Type.String({ + maxLength: ASK_USER_TEXT_MAX_LENGTH, + description: "Short label the user reads, ideally a few words.", + }), + detail: Type.Optional(Type.String({ + maxLength: ASK_USER_TEXT_MAX_LENGTH, + description: "Optional clarification shown under the label.", + })), +}); + +const AskUserQuestionParams = Type.Object({ + id: Type.String({ + maxLength: ASK_USER_ID_MAX_LENGTH, + description: "Unique within this call; used as the answer key reported back to you.", + }), + question: Type.String({ + maxLength: ASK_USER_TEXT_MAX_LENGTH, + description: "The question itself, as one plain-text line.", + }), + detail: Type.Optional(Type.String({ + maxLength: ASK_USER_TEXT_MAX_LENGTH, + description: "Optional supporting context shown under the question.", + })), + options: Type.Optional(Type.Array(AskUserOptionParams, { + maxItems: ASK_USER_OPTION_LIMIT, + description: "Options to choose from. Omit only when free text is the whole answer, and then set allowOther.", + })), + allowOther: Type.Optional(Type.Boolean({ + description: "Offer a free-text field alongside the options.", + })), + multiple: Type.Optional(Type.Boolean({ + description: "Allow several options at once. Default: one answer per question.", + })), +}); + +const AskUserParams = Type.Object({ + questions: Type.Array(AskUserQuestionParams, { + minItems: 1, + maxItems: ASK_USER_QUESTION_LIMIT, + description: "The questions to post, in the order the user should read them. Every question may be left unanswered.", + }), +}); + +/** Shapes one schema question into the domain question; the store owns validation. */ +function toQuestion(param: Static): AskUserQuestion { + const { detail, options, allowOther, multiple } = param; + return { + id: param.id, + question: param.question, + ...(detail === undefined ? {} : { detail }), + options: (options ?? []).map(toOption), + ...(allowOther === undefined ? {} : { allowOther }), + ...(multiple === undefined ? {} : { multiple }), + }; +} + +function toOption(param: Static): AskUserQuestionOption { + const { detail } = param; + return { value: param.value, label: param.label, ...(detail === undefined ? {} : { detail }) }; +} + +function postedText(result: PendingAskOpenResult): string { + const count = result.ask.questions.length; + const posted = `Posted ${count.toString()} question${count === 1 ? "" : "s"} to the user as ask ${result.ask.askId}. Ending this run; the answers arrive as a follow-up message that wakes you, naming every question the user left unanswered. Do not repost these questions.`; + return result.superseded === undefined ? posted : `${posted}\n\n${renderSupersededAskText(result.superseded)}`; +} + +/** + * Custom tool that posts a question set to the user's browser as interactive UI. + * + * It deliberately does **not** await the user. Awaiting would pin the agent run + * for an unbounded human-scale wait, keep the session streaming, and leave a + * dangling tool call if the runtime were replaced meanwhile. Instead the ask + * becomes daemon-owned state, the tool terminates the run, and the submitted + * answers return later as a follow-up message that wakes the session. + * + * Rejected question sets throw: the agent loop turns the thrown message into an + * error tool result, so the model can fix the ask and post it again. + */ +export function createAskUserToolDefinition(deps: AskUserToolDeps) { + return defineTool({ + name: "ask_user", + label: "Ask user", + description: "Post a set of questions to the user as a browser form and end this run. Answers arrive later as a follow-up message; the user may leave any question unanswered.", + promptSnippet: "ask_user: post a question set to the user; ends the run, answers return as a follow-up", + promptGuidelines: [ + "When you need decisions from the user, post them together with ask_user instead of asking in prose one at a time. It ends the run and the answers, including the questions the user left unanswered, come back as a follow-up message that wakes you. Call it alone and last, and do not repost the same questions or poll for answers.", + ], + parameters: AskUserParams, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const result = await deps.open({ + sessionId: ctx.sessionManager.getSessionId(), + questions: params.questions.map(toQuestion), + }); + return { + content: [{ type: "text", text: postedText(result) }], + details: result, + terminate: true, + }; + }, + }); +} diff --git a/src/server/sessions/piSessionService.askUser.test.ts b/src/server/sessions/piSessionService.askUser.test.ts new file mode 100644 index 0000000..99c9c09 --- /dev/null +++ b/src/server/sessions/piSessionService.askUser.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPiWebCustomToolDefinitions, PiSessionService } from "./piSessionService.js"; +import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js"; +import { CapturingSessionEventHub, emptyArchiveStore, sessionGateway, testModelRuntime } from "./piSessionService.testSupport.js"; + +const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; + +const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }]; + +function askService() { + const store = new PendingAskStore({ + now: () => new Date("2026-02-01T10:00:00.000Z"), + createAskId: (() => { + let next = 0; + return () => { next += 1; return `ask-${next.toString()}`; }; + })(), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + sessionManager: sessionGateway([]), + archiveStore: emptyArchiveStore(), + pendingAskStore: store, + askUserEnabled: true, + heartbeatIntervalMs: 60_000, + }); + return { service, store }; +} + +describe("ask_user registration", () => { + it("offers ask_user whenever the capability is configured, including to restricted tracked children", () => { + const askUser = { open: vi.fn() }; + + const unrestricted = createPiWebCustomToolDefinitions("/workspace", true, undefined, undefined, askUser); + const restricted = createPiWebCustomToolDefinitions("/workspace", false, undefined, undefined, askUser); + + expect(unrestricted.map((definition) => definition.name)).toEqual(["edit", "ask_user"]); + expect(restricted.map((definition) => definition.name)).toEqual(["edit", "ask_user"]); + }); + + it("omits ask_user when the capability is disabled", () => { + const definitions = createPiWebCustomToolDefinitions("/workspace", true); + + expect(definitions.map((definition) => definition.name)).toEqual(["edit"]); + }); +}); + +describe("PiSessionService.openAsk", () => { + it("registers the question set as the session's open ask", async () => { + const { service, store } = askService(); + + const result = await service.openAsk({ sessionId: "session-1", questions }); + + expect(result.ask).toMatchObject({ askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z" }); + expect(result).not.toHaveProperty("superseded"); + expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-1" }); + await service.dispose(); + }); + + it("supersedes the session's earlier unanswered ask and reports its outcome", async () => { + const { service, store } = askService(); + await service.openAsk({ sessionId: "session-1", questions }); + + const result = await service.openAsk({ sessionId: "session-1", questions: [{ id: "again", question: "Still?", options: [], allowOther: true }] }); + + expect(result.ask.askId).toBe("ask-2"); + expect(result.superseded).toMatchObject({ askId: "ask-1", reason: "superseded", answeredCount: 0, unansweredIds: ["db"] }); + expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-2" }); + await service.dispose(); + }); + + it("keeps asks of different sessions independent", async () => { + const { service, store } = askService(); + + await service.openAsk({ sessionId: "session-1", questions }); + const other = await service.openAsk({ sessionId: "session-2", questions }); + + expect(other).not.toHaveProperty("superseded"); + expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-1" }); + expect(store.pendingAsk("session-2")).toMatchObject({ askId: "ask-2" }); + await service.dispose(); + }); + + it("rejects an unanswerable question set without opening it", async () => { + const { service, store } = askService(); + + await expect(service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "No way to answer?", options: [] }] })) + .rejects.toThrow(PendingAskValidationError); + expect(store.pendingAsk("session-1")).toBeUndefined(); + await service.dispose(); + }); +}); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index da3522b..b4946b2 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -54,6 +54,8 @@ import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from ". import { type AuthChange } from "./authService.js"; import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js"; import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; +import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js"; +import { PendingAskStore, type PendingAskOpenResult } from "./pendingAskStore.js"; import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js"; import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js"; import { buildTranscriptView } from "./subsessionTranscript.js"; @@ -610,11 +612,15 @@ export function createPiWebCustomToolDefinitions( delegationEnabled: boolean, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps, + askUser?: AskUserToolDeps, ) { return [ createPiWebEditToolDefinition(cwd), ...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []), ...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []), + // Asking the user is not delegation: the questions land in the session the + // user is already watching, so tracked children may ask too. + ...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]), ]; } @@ -623,12 +629,13 @@ function createDefaultRuntimeFactory( sessionManagers: Pick, spawn?: SpawnSessionFn, subsessions?: SubsessionToolDeps, + askUser?: AskUserToolDeps, ): PiWebCreateAgentSessionRuntimeFactory { return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime }); const resolvedDelegationToolsEnabled = delegationToolsEnabled ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); - const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions); + const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser); const result = await createAgentSessionFromServices({ services, sessionManager, @@ -686,6 +693,14 @@ export interface PiSessionServiceDependencies { * being exposed in releases. */ subsessionsEnabled?: boolean; + /** + * When true, `ask_user` is available to every session, so an agent can post a + * question set to the browser. Independent of the delegation capabilities: the + * questions reach the user of the asking session, not another session. + */ + askUserEnabled?: boolean; + /** Daemon-lifetime open-ask state; defaults to an in-memory store in tests. */ + pendingAskStore?: PendingAskStore; /** Structured logger for notable runtime events (e.g. spawns). */ logger?: PiSessionLogger; /** Clock seam for cleanup planning tests. */ @@ -748,6 +763,7 @@ export class PiSessionService implements SessionRouteService { private readonly notificationStore: SessionNotificationStore; private readonly notificationGenerationBySession = new WeakMap(); private readonly unreadStore: SessionUnreadStore; + private readonly pendingAskStore: PendingAskStore; private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined; private readonly unreadPublicationRetryInitialMs: number; private readonly pendingUnreadMutations: SessionUnreadMutation[] = []; @@ -768,6 +784,7 @@ export class PiSessionService implements SessionRouteService { this.now = deps.now ?? (() => new Date()); this.notificationStore = deps.notificationStore ?? new SessionNotificationStore(); this.unreadStore = deps.unreadStore ?? new SessionUnreadStore(); + this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore(); this.catalogRefreshStatus = deps.catalogRefreshStatus; this.unreadPublicationRetryInitialMs = Math.max( 0, @@ -787,6 +804,7 @@ export class PiSessionService implements SessionRouteService { check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile), read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile), }, + deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined, ); this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime; this.workspaceActivity = deps.workspaceActivity; @@ -1068,6 +1086,19 @@ export class PiSessionService implements SessionRouteService { return { sessionId: created.id, cwd: decision.cwd }; } + /** + * Register the question set an agent wants the user to answer as the session's + * open ask. Deliberately does not wait for the user: `ask_user` terminates the + * run and the submitted answers come back later as a follow-up message. + * + * Rejected question sets throw {@link PendingAskValidationError}, which the + * agent loop reports to the model as an error tool result. + */ + // eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method. + async openAsk(input: AskUserInvocation): Promise { + return this.pendingAskStore.open(input); + } + /** Summaries of the tracked subsessions spawned by `parentSessionId`. */ async listSubsessions(parentSessionId: string, parentSessionFile?: string): Promise { const parentFile = nonEmptyString(parentSessionFile);