feat(sessions): deliver ask_user answers

Integrate the pending-ask store into the session service so an open ask is
visible, observable, and closable.

- `statusFromSession` projects `pendingAsk`, so a browser rehydrates an open
  ask from `GET /sessions/:sessionId/status` after reload or a web/API restart.
- `openAsk` publishes `ask.opened`, and publishes `ask.closed` first when the
  new ask supersedes an unanswered one.
- `submitAsk` / `cancelAsk` close the ask and hand the outcome to the model as
  a `pi-web.ask.answers` follow-up custom message (`triggerTurn`,
  `deliverAs: "followUp"`), the same delivery subsession notices use. A stale
  ask id is reported, not thrown: losing the race against a supersede or
  another browser is ordinary. Cancel still reports every question as
  unanswered so the model is not left waiting for a promised message.
- The open ask is forgotten when its runtime closes; nothing is left to
  receive the answers.
- `POST /sessions/:sessionId/ask/{submit,cancel}` behind the existing
  `/api/sessions/*` daemon proxy, allowlisted for machine federation.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 22:38:15 +02:00
parent 07bdd7ad6b
commit 51ebfe4c00
7 changed files with 451 additions and 12 deletions
+81 -4
View File
@@ -33,8 +33,11 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
import type {
AskUserCloseResponse,
AskUserOutcome,
AskUserSubmission,
SavedPromptAttachment,
SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse,
@@ -55,7 +58,7 @@ import { type AuthChange } from "./authService.js";
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
import { PendingAskStore, type PendingAskOpenResult } from "./pendingAskStore.js";
import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
import { buildTranscriptView } from "./subsessionTranscript.js";
@@ -952,7 +955,10 @@ export class PiSessionService implements SessionRouteService {
const pendingOpens = this.pendingSessionOpenPromises();
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const activeSessions = Array.from(new Set(this.active.values()));
for (const active of activeSessions) this.forgetUnreadActivity(active.runtime.session);
for (const active of activeSessions) {
this.forgetUnreadActivity(active.runtime.session);
this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
}
this.active.clear();
this.pendingSessionOpens.clear();
this.activities.clear();
@@ -1096,7 +1102,73 @@ export class PiSessionService implements SessionRouteService {
*/
// eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
async openAsk(input: AskUserInvocation): Promise<PendingAskOpenResult> {
return this.pendingAskStore.open(input);
const result = this.pendingAskStore.open(input);
// A supersede closes the earlier ask, so the browsers watching it must hear
// that before they hear about its replacement.
if (result.superseded !== undefined) this.publishAskClosed(input.sessionId, result.superseded);
this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
this.publishStatusForSessionId(input.sessionId);
return result;
}
/**
* Record the user's answers to the session's open ask and hand them to the
* model. The answers travel as a system-authored custom message rather than a
* user message, so they are not attributed to the human in the transcript;
* they still wake an idle session (`triggerTurn`) and queue behind in-flight
* work (`deliverAs: "followUp"`), which is how the run that `ask_user`
* terminated continues.
*/
async submitAsk(ref: PiSessionLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
// Checked before the store closes the ask so a refused delivery cannot
// discard answers the user already submitted.
this.assertTreeNavigationInactive(session, "answer questions");
return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
}
/**
* Close the open ask without answers. The model is still told, naming every
* question as unanswered: it was promised a follow-up message and would
* otherwise wait for one that never comes.
*/
async cancelAsk(ref: PiSessionLookup, askId: string): Promise<AskUserCloseResponse> {
await this.assertWritable(ref);
const session = await this.getOrOpen(ref);
this.assertTreeNavigationInactive(session, "dismiss questions");
return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
}
/**
* Publish and deliver a closed ask. A stale close is reported rather than
* thrown: losing the race against a supersede, another browser, or a session
* that went away is ordinary, and the returned status tells the browser what
* the session's open ask is now.
*/
private async closeAsk(session: PiAgentSession, result: PendingAskCloseResult): Promise<AskUserCloseResponse> {
if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) };
const { outcome } = result;
this.publishAskClosed(session.sessionId, outcome);
await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage(
{ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome },
{ triggerTurn: true, deliverAs: "followUp" },
));
this.publishStatus(session);
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
}
private publishAskClosed(sessionId: string, outcome: AskUserOutcome): void {
this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
}
/**
* 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.
*/
private publishStatusForSessionId(sessionId: string): void {
const session = this.active.get(sessionId)?.runtime.session;
if (session !== undefined) this.publishStatus(session);
}
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
@@ -2251,6 +2323,9 @@ export class PiSessionService implements SessionRouteService {
}
if (!active) return;
this.forgetUnreadActivity(active.runtime.session);
// An open ask is meaningful only while the runtime that posted it exists: no
// one is left to receive the answers, so it is dropped without an outcome.
this.pendingAskStore.forgetSession(sessionId);
this.active.delete(sessionId);
this.activities.delete(sessionId);
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
@@ -3107,6 +3182,7 @@ export class PiSessionService implements SessionRouteService {
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
const contextUsage = session.getContextUsage();
const warnings = this.warningsForSession(session);
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
return {
sessionId: session.sessionId,
persisted: sessionFileExists(session.sessionFile),
@@ -3122,6 +3198,7 @@ export class PiSessionService implements SessionRouteService {
cost: stats.cost,
...(contextUsage === undefined ? {} : { contextUsage }),
...(warnings.length === 0 ? {} : { warnings }),
...(pendingAsk === undefined ? {} : { pendingAsk }),
};
}