fix(sessions): void the open ask_user form when a chat message arrives

An ordinary chat message answers the session's open ask in the user's
own words, so keeping the form open would invite answers to questions
the conversation has already moved past. The form now closes as
cancelled, browsers clear the live card, and the model is told without
being woken so the notice rides into the turn the message itself
starts. Ignored duplicate queued messages skip the void on purpose:
they must not void an ask posted after the queued original.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-28 23:14:21 +02:00
parent f9d0b2b8d5
commit 12f25282fc
7 changed files with 83 additions and 2 deletions
+1 -1
View File
@@ -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`.
+4
View File
@@ -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.
</p>
<p>
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.
</p>
<div class="callout warning">
<strong>Restart required:</strong> restart the session daemon after changing <code>askUser</code> or after
upgrading PI WEB to a version that introduces this tool. For the systemd user service, run
+2
View File
@@ -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
@@ -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);
+10 -1
View File
@@ -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));
@@ -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 });
+22
View File
@@ -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<void> {
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;