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