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