Archived
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user