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
+51 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify";
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type AskUserAnswer, type AskUserSubmission, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
@@ -266,6 +266,25 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; askId?: unknown; answers?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/ask/submit`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const askId = requireBoundedId(body["askId"], "askId");
return await sessions.submitAsk(sessionLookupFromBody(request.params.sessionId, body), askId, askUserSubmissionFromBody(body));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; askId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/ask/cancel`, async (request, reply) => {
try {
const body = requireRecord(request.body);
return await sessions.cancelAsk(sessionLookupFromBody(request.params.sessionId, body), requireBoundedId(body["askId"], "askId"));
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dismissId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/warnings/dismiss`, async (request, reply) => {
try {
const body = optionalRecord(request.body);
@@ -493,6 +512,37 @@ function requireExactFields(record: Record<string, unknown>, fields: readonly st
if (unexpected !== undefined) throw new Error(`${label} field contains unsupported property: ${unexpected}`);
}
/**
* Shape-check one submitted answer set. Only transport-level checks belong here:
* whether the answers fit the questions that were actually asked is the pending
* ask store's job, since only it knows the open ask.
*/
function askUserSubmissionFromBody(body: Record<string, unknown>): AskUserSubmission {
const answers = body["answers"];
if (!Array.isArray(answers)) throw new Error("answers field must be an array");
if (answers.length > ASK_USER_QUESTION_LIMIT) throw new Error("answers field has too many entries");
return { answers: answers.map(askUserAnswerFromValue) };
}
function askUserAnswerFromValue(value: unknown): AskUserAnswer {
const record = requireRecord(value);
const values = record["values"];
if (!Array.isArray(values)) throw new Error("values field must be an array");
if (values.length > ASK_USER_OPTION_LIMIT) throw new Error("values field has too many entries");
const otherText = record["otherText"];
if (otherText !== undefined && typeof otherText !== "string") throw new Error("otherText field must be a string");
if (typeof otherText === "string" && otherText.length > ASK_USER_OTHER_TEXT_MAX_LENGTH) throw new Error("otherText field is too long");
return {
id: requireBoundedId(record["id"], "id"),
values: values.map((entry) => requireBoundedId(entry, "values entry")),
...(otherText === undefined ? {} : { otherText }),
};
}
function requireBoundedId(value: unknown, field: string): string {
return requireNonEmptyBoundedString(value, field, ASK_USER_ID_MAX_LENGTH);
}
function optionalRecord(value: unknown): Record<string, unknown> {
if (value === undefined || value === null) return {};
return requireRecord(value);