diff --git a/.changeset/ask-user-question-forms.md b/.changeset/ask-user-question-forms.md index e727851..5d34d82 100644 --- a/.changeset/ask-user-question-forms.md +++ b/.changeset/ask-user-question-forms.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer with mobile-safe text sizing. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`. +Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer with mobile-safe text sizing. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, sending an ordinary chat message voids the open form, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`. diff --git a/docs/config.html b/docs/config.html index aff0352..1e3de64 100644 --- a/docs/config.html +++ b/docs/config.html @@ -831,6 +831,10 @@ and its unanswered questions to the model, and turns the earlier card into a read-only transcript record. Submitted and cancelled asks likewise remain readable in the transcript.

+

+ Sending an ordinary chat message while a form is open voids the form: the card closes as cancelled and + the model is told its questions went unanswered as part of the turn the message itself starts. +

Restart required: restart the session daemon after changing askUser or after upgrading PI WEB to a version that introduces this tool. For the systemd user service, run diff --git a/docs/config.md b/docs/config.md index fbf97a2..d55a734 100644 --- a/docs/config.md +++ b/docs/config.md @@ -285,6 +285,8 @@ Calling `ask_user` posts the whole set as one browser form and ends the current PI WEB confirms a partial submission before sending it and names the unanswered questions. Only one ask can be open per session: a later `ask_user` call supersedes the earlier one, reports that fact and its unanswered questions to the model, and turns the earlier card into a read-only transcript record. Submitted and cancelled asks likewise remain readable in the transcript. +Sending an ordinary chat message while a form is open voids the form: the card closes as cancelled and the model is told its questions went unanswered as part of the turn the message itself starts. + Restart the session daemon after changing `askUser` or after upgrading PI WEB to a version that introduces this tool. For the systemd user service, run `systemctl --user restart pi-web-sessiond`. ### Plugin config diff --git a/src/server/sessions/pendingAskStore.test.ts b/src/server/sessions/pendingAskStore.test.ts index 1c92fce..6ca4868 100644 --- a/src/server/sessions/pendingAskStore.test.ts +++ b/src/server/sessions/pendingAskStore.test.ts @@ -271,6 +271,17 @@ describe("PendingAskStore close transitions", () => { expect(store.pendingAsk(sessionId)).toBeUndefined(); }); + it("cancels whatever ask is open without naming its id", () => { + const store = testStore(); + openTwoQuestions(store); + + const outcome = store.cancelOpen(sessionId); + + expect(outcome).toMatchObject({ askId: "ask-1", reason: "cancelled", answeredCount: 0, unansweredIds: ["q1", "q2"] }); + expect(store.pendingAsk(sessionId)).toBeUndefined(); + expect(store.cancelOpen(sessionId)).toBeUndefined(); + }); + it("forgets the open ask of a session that goes away without reporting an outcome", () => { const store = testStore(); const { ask } = openTwoQuestions(store); diff --git a/src/server/sessions/pendingAskStore.ts b/src/server/sessions/pendingAskStore.ts index 080e2a8..7cc9acd 100644 --- a/src/server/sessions/pendingAskStore.ts +++ b/src/server/sessions/pendingAskStore.ts @@ -118,7 +118,7 @@ export class PendingAskStore { } /** - * Close the ask without a submission. The outcome reports every question as + * Close the session's open ask without a submission. The outcome reports every question as * unanswered, because answers only ever reach the daemon through a submit. */ cancel(sessionId: string, askId: string): PendingAskCloseResult { @@ -127,6 +127,15 @@ export class PendingAskStore { return { status: "closed", outcome: this.requireClose(sessionId, "cancelled", new Map()) }; } + /** + * Close whatever ask the session currently has open, e.g. because the user sent + * an ordinary chat message instead of answering the form. Returns the outcome, + * or `undefined` when the session has no open ask. + */ + cancelOpen(sessionId: string): AskUserOutcome | undefined { + return this.close(requireSessionId(sessionId), "cancelled", this.timestamp(), new Map()); + } + /** Drop the open ask of a session that is going away, without reporting an outcome. */ forgetSession(sessionId: string): void { this.openBySessionId.delete(requireSessionId(sessionId)); diff --git a/src/server/sessions/piSessionService.askUser.test.ts b/src/server/sessions/piSessionService.askUser.test.ts index 4e30962..bf9d68a 100644 --- a/src/server/sessions/piSessionService.askUser.test.ts +++ b/src/server/sessions/piSessionService.askUser.test.ts @@ -233,6 +233,39 @@ describe("PiSessionService.submitAsk", () => { }); }); +describe("PiSessionService.prompt with an open ask", () => { + it("voids the open ask and tells the model without waking it, then sends the message", async () => { + const { service, store, events, fake } = askService({ withActiveSession: true }); + await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions }); + + await service.prompt(sessionRef(ACTIVE_SESSION_ID), "Use DuckDB"); + + expect(store.pendingAsk(ACTIVE_SESSION_ID)).toBeUndefined(); + expect(askEvents(events).map(({ event }) => event)).toEqual([ + { type: "ask.opened", ask: { askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z", questions } }, + { type: "ask.closed", askId: "ask-1", reason: "cancelled" }, + ]); + const [delivered] = fake.calls.sendCustomMessage; + expect(delivered?.message.customType).toBe(ASK_USER_ANSWERS_CUSTOM_TYPE); + expect(delivered?.message.content).toContain("closed (cancelled) before it was fully answered"); + expect(delivered?.message.content).toContain("unanswered: db"); + expect(delivered?.options).toEqual({ triggerTurn: false, deliverAs: "followUp" }); + expect(fake.calls.prompt.map((call) => call.text)).toEqual(["Use DuckDB"]); + await service.dispose(); + }); + + it("sends a plain message untouched when no ask is open", async () => { + const { service, events, fake } = askService({ withActiveSession: true }); + + await service.prompt(sessionRef(ACTIVE_SESSION_ID), "hello"); + + expect(fake.calls.sendCustomMessage).toEqual([]); + expect(fake.calls.prompt.map((call) => call.text)).toEqual(["hello"]); + expect(askEvents(events)).toEqual([]); + await service.dispose(); + }); +}); + describe("PiSessionService.cancelAsk", () => { it("tells the model every question went unanswered rather than leaving it waiting", async () => { const { service, store, events, fake } = askService({ withActiveSession: true }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 9b2ffc4..5682092 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1252,6 +1252,23 @@ export class PiSessionService implements SessionRouteService { this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason }); } + /** + * Void the session's open ask because the user sent a chat message instead of + * answering it. Every browser closes the card as cancelled, and the model is + * told — without being woken — so the notice rides into the turn the message + * itself triggers rather than becoming a turn of its own. + */ + private async voidOpenAskForUserMessage(session: PiAgentSession): Promise { + const outcome = this.pendingAskStore.cancelOpen(session.sessionId); + if (outcome === undefined) return; + this.publishAskClosed(session.sessionId, outcome); + await this.runSessionEntryMutation(session, "void the open questions", () => session.sendCustomMessage( + { customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome }, + { triggerTurn: false, deliverAs: "followUp" }, + )); + this.publishStatus(session); + } + /** * Publish status for a session known only by id, as the ask tools are: they * run inside the session's own runtime, so the active entry is the session. @@ -1741,6 +1758,11 @@ export class PiSessionService implements SessionRouteService { this.publishStatus(session); return; } + // A chat message answers the session's open ask in the user's own words, so + // the form is void: keeping it open would invite answers to questions the + // conversation has already moved past. Ignored duplicates skip this on + // purpose: they must not void an ask posted after the queued original. + await this.voidOpenAskForUserMessage(session); if (session.isCompacting) { this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp", images, echoUserMessage); return;