From 12f25282fc4d9b04b71c8ce93e45ed476cbece02 Mon Sep 17 00:00:00 2001
From: Federico Jaramillo Martinez
+ 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. +
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