feat(sessions): add the ask_user tool

Register a core ask_user custom tool that posts a question set to the user's
browser and terminates the run instead of awaiting an answer. The tool is thin:
it shapes its TypeBox params into domain questions, lets PendingAskStore own
validation, and reports a superseded unanswered ask back to the model.

Gated by the askUser config key, threaded through PiSessionServiceDependencies
and sessiond. Unlike the delegation tools, ask_user is available to tracked
children too: the questions reach the user of the asking session.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 22:01:15 +02:00
parent 49393e1cb9
commit 07bdd7ad6b
5 changed files with 400 additions and 1 deletions
+143
View File
@@ -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);
});
});