feat(client): carry the open ask_user question set into browser state

Parse the daemon-owned `pendingAsk` in `parseSessionStatus` and validate
`ask.opened` / `ask.closed` frames rather than accepting them on their type,
since they drive a form the user answers on the model's behalf.

Add `submitAsk` / `cancelAsk` through `request()` + `sessionPath()`, both
returning the recomputed session status so closing an ask needs no follow-up
status request, and derive `pendingAsk` in `SessionController` from status plus
live events with `sessions.askUser` capability gating.

New `askDrafts.ts` keeps what the user has typed in browser-local storage under
`pi-web:ask-draft:<sessionId>:<askId>` and owns the pure answer-state helpers,
so the daemon owns "there is an open ask" and the browser owns "what I have
typed so far".
This commit is contained in:
Federico Jaramillo Martinez
2026-07-26 23:26:43 +02:00
parent 51ebfe4c00
commit 862ae73fcb
12 changed files with 996 additions and 14 deletions
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads";
export type { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, FileContentMediaType, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, Machine, MachineHealth, MachineKind, MachineRuntime, MachineStatus, MessagePage, ModelSelectionResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, OAuthFlowState, PiPackageInfo, PiPackageInstallRequest, PiPackageMutationAction, PiPackageMutationResponse, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiPackagesResponse, PiWebAgentDirEnvSource, PiWebCapability, PiWebComponentStatus, PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues, PiWebDockerMode, PiWebInstallationInfo, PiWebInstallationKind, PiWebPluginConfig, PiWebPluginConfigMap, PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope, PiWebPluginSettings, PiWebReleaseStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebShortcutConfig, PiWebStatusMessage, PiWebStatusResponse, PiWebUploadsConfig, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef, SessionBulkMutationRequest, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionCleanupProjectSummary, SessionCleanupRequest, SessionCleanupThresholds, SessionCleanupTotals, SessionInfo, SessionModel, SessionRef, SessionStatus, SessionStreamSnapshot, SessionUnreadAcknowledgeRequest, SessionUnreadCatalogSnapshot, SessionUnreadEvent, SessionUnreadSummary, SessionTreeNavigateRequest, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot, SessionTreeSummaryChoice, SessionWarning, SessionWarningSeverity, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes";
+4 -1
View File
@@ -1,9 +1,10 @@
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import type { AskUserSubmission, DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
import { resolveAppUrl } from "../appUrl";
import { request } from "./http";
import {
arrayOf,
parseAborted,
parseAskUserCloseResponse,
parseAccepted,
parseArchived,
parseAuthProvidersResponse,
@@ -224,6 +225,8 @@ export const sessionsApi = {
streamSnapshot: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "stream-snapshot", machineId), parseSessionStreamSnapshot),
clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }),
submitAsk: (session: SessionLookup, askId: string, submission: AskUserSubmission, machineId = "local") => request(sessionPath(session, "ask/submit", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId, answers: submission.answers }) }),
cancelAsk: (session: SessionLookup, askId: string, machineId = "local") => request(sessionPath(session, "ask/cancel", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId }) }),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
@@ -36,6 +36,14 @@ describe("federated route contract", () => {
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("notifications"))).toBe(false);
});
it("allowlists both ask routes without adding an ask WebSocket", () => {
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("/ask/"))).toEqual([
{ method: "POST", path: "/sessions/:sessionId/ask/submit" },
{ method: "POST", path: "/sessions/:sessionId/ask/cancel" },
]);
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("ask"))).toBe(false);
});
it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => {
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([
{ method: "GET", path: "/sessions/unread" },
@@ -96,6 +104,9 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.streamSnapshot(session, machineId)),
ignoreParseFailure(sessionsApi.clearQueue(session, machineId)),
ignoreParseFailure(sessionsApi.dismissWarning(session, "anthropicExtraUsage", machineId)),
ignoreParseFailure(sessionsApi.submitAsk(session, "ask 1", { answers: [{ id: "q1", values: ["pg"] }] }, machineId)),
ignoreParseFailure(sessionsApi.cancelAsk(session, "ask 1", machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)),
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
+121 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes";
import { parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStartupProgressEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
import { ASK_USER_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes";
import { parseAskUserCloseResponse, parseAuthProvidersResponse, parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionNotificationInboxEvent, parseSessionNotificationInboxSnapshot, parseSessionStartupProgressEvent, parseSessionStatus, parseSessionStreamSnapshot, parseSessionTreeNavigateResult, parseSessionTreeSnapshot, parseSessionUnreadCatalogSnapshot, parseSessionUnreadEvent, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
@@ -711,7 +711,126 @@ describe("API parsers", () => {
delta: { kind: "cleared", reason: "future-reason" },
})).toThrow("Invalid notification clear reason");
});
it("parses an open ask with options, details, other, and multi-select", () => {
const parsed = parseSessionStatus({ ...statusWire(), pendingAsk: pendingAskWire() });
expect(parsed.pendingAsk).toEqual({
askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z",
questions: [
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }] },
{ id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true },
],
});
});
it("omits the pending ask entirely when the field is absent", () => {
expect(parseSessionStatus(statusWire()).pendingAsk).toBeUndefined();
});
it("rejects an ask that cannot be rendered or answered honestly", () => {
const ask = pendingAskWire();
const first = ask.questions[0];
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [] } })).toThrow("Pending ask has no questions");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [first, first] } })).toThrow("Duplicate ask question id");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, askId: "" } })).toThrow("Expected non-empty string field: askId");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [] }] } })).toThrow("Ask question offers no way to answer");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Which?", options: [{ value: "a", label: "A" }, { value: "a", label: "Also A" }] }] } })).toThrow("Duplicate ask option value");
expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "x".repeat(ASK_USER_TEXT_MAX_LENGTH + 1), options: [{ value: "a", label: "A" }] }] } })).toThrow("String field exceeds limit: question");
});
it("parses a closed ask response carrying the outcome and recomputed status", () => {
const response = parseAskUserCloseResponse({
result: "closed",
outcome: askOutcomeWire(),
sessionStatus: statusWire(),
});
expect(response.result).toBe("closed");
expect(response.outcome).toMatchObject({
askId: "ask-1",
reason: "submitted",
answeredCount: 1,
unansweredIds: ["q2"],
summary: "Answered 1 of 2; unanswered: q2",
});
expect(response.outcome?.questions[0]).toMatchObject({ answered: true, values: ["pg"] });
expect(response.sessionStatus.sessionId).toBe("s1");
});
it("parses a stale close as an ordinary race with no outcome", () => {
const response = parseAskUserCloseResponse({ result: "stale", sessionStatus: statusWire() });
expect(response).toEqual({ result: "stale", sessionStatus: parseSessionStatus(statusWire()) });
});
it("rejects close responses whose outcome contradicts itself", () => {
const outcome = askOutcomeWire();
expect(() => parseAskUserCloseResponse({ result: "closed", sessionStatus: statusWire() })).toThrow("Ask close response outcome mismatch");
expect(() => parseAskUserCloseResponse({ result: "stale", outcome, sessionStatus: statusWire() })).toThrow("Ask close response outcome mismatch");
expect(() => parseAskUserCloseResponse({ result: "closed", outcome: { ...outcome, answeredCount: 2 }, sessionStatus: statusWire() })).toThrow("Ask outcome answered count mismatch");
expect(() => parseAskUserCloseResponse({ result: "closed", outcome: { ...outcome, unansweredIds: [] }, sessionStatus: statusWire() })).toThrow("Ask outcome unanswered ids mismatch");
expect(() => parseAskUserCloseResponse({ result: "closed", outcome: { ...outcome, reason: "ignored" }, sessionStatus: statusWire() })).toThrow("Invalid ask close reason");
expect(() => parseAskUserCloseResponse({
result: "closed",
outcome: { ...outcome, questions: [{ ...askAnsweredRecordWire(), answered: false }, askUnansweredRecordWire()] },
sessionStatus: statusWire(),
})).toThrow("Ask answer contradicts its answered flag");
expect(() => parseAskUserCloseResponse({
result: "closed",
outcome: { ...outcome, questions: [{ ...askAnsweredRecordWire(), values: ["mysql"] }, askUnansweredRecordWire()] },
sessionStatus: statusWire(),
})).toThrow("Ask answer selected an option the question never offered");
});
});
function statusWire() {
return {
sessionId: "s1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
function pendingAskWire() {
return {
askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z",
questions: [
{ id: "q1", question: "Which database?", detail: "Pick the primary store", options: [{ value: "pg", label: "Postgres", detail: "Relational" }, { value: "sqlite", label: "SQLite" }] },
{ id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true },
],
};
}
function askAnsweredRecordWire() {
const ask = pendingAskWire();
return { question: ask.questions[0], answered: true, values: ["pg"] };
}
function askUnansweredRecordWire() {
const ask = pendingAskWire();
return { question: ask.questions[1], answered: false, values: [] };
}
function askOutcomeWire() {
return {
askId: "ask-1",
reason: "submitted",
askedAt: "2026-07-20T00:00:00.000Z",
closedAt: "2026-07-20T00:01:00.000Z",
questions: [askAnsweredRecordWire(), askUnansweredRecordWire()],
answeredCount: 1,
unansweredIds: ["q2"],
summary: "Answered 1 of 2; unanswered: q2",
};
}
function sessionTreeWire() {
const kinds = [
+136 -1
View File
@@ -1,4 +1,4 @@
import { SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_COMPLETED_AT_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_LIMIT, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type ArchiveSessionsResponse, type AskUserCloseReason, type AskUserCloseResponse, type AskUserOutcome, type AskUserQuestion, type AskUserQuestionOption, type AskUserQuestionRecord, type PendingAskUser, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type FileContentResponse, type FileSuggestion, type FileTreeEntry, type FileTreeResponse, type GitDiffResponse, type GitFileState, type GitStatusFile, type GitStatusResponse, type Machine, type MachineHealth, type MachineKind, type MachineRuntime, type MachineStatus, type MessagePage, type ModelSelectionResponse, type MoveWorkspaceFileResponse, type OAuthFlowState, type PiWebAgentDirEnvSource, type PiWebCapability, type PiWebComponentStatus, type PiWebConfigEnvOverrides, type PiWebConfigResponse, type PiWebConfigValues, type PiWebInstallationInfo, type PiWebPluginConfigMap, type PiWebPluginInfo, type PiWebPluginsResponse, type PiWebPluginScope, type PiWebReleaseStatus, type PiWebRuntimeComponent, type PiWebRuntimeResponse, type PiWebServiceComponent, type PiWebShortcutConfig, type PiWebStatusMessage, type PiWebStatusResponse, type PiWebStatusSeverity, type Project, type QueuedSessionMessage, type SavedPromptAttachment, type SessionBulkArchiveResponse, type SessionBulkDeleteArchivedResponse, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupProjectSummary, type SessionCleanupThresholds, type SessionCleanupTotals, type SessionInfo, type SessionModel, type SessionNotification, type SessionNotificationClearReason, type SessionNotificationDismissThrough, type SessionNotificationInboxDelta, type SessionNotificationInboxEvent, type SessionNotificationInboxSnapshot, type SessionNotificationSeverity, type SessionNotificationSummary, type SessionStatus, type SessionStreamSnapshot, type SessionUnreadCatalogSnapshot, type SessionUnreadEvent, type SessionUnreadSummary, type SessionWarning, type SessionWarningSeverity, type SlashCommand, type TerminalCommandRun, type TerminalCommandRunStatus, type TerminalInfo, type ThinkingLevelsResponse, type WriteWorkspaceFileResponse, type Workspace, type WorkspaceActivity, type WorkspaceActivityResponse } from "../../../shared/apiTypes";
import type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -205,6 +205,132 @@ function optionalWarnings(value: unknown): Pick<SessionStatus, "warnings"> | obj
return { warnings: arrayOf(parseSessionWarning)(value) };
}
function parseAskUserQuestionOption(value: unknown): AskUserQuestionOption {
const record = requireRecord(value);
return {
value: requireBoundedNonEmptyString(record, "value", ASK_USER_ID_MAX_LENGTH),
label: requireBoundedNonEmptyString(record, "label", ASK_USER_TEXT_MAX_LENGTH),
...optionalField("detail", optionalBoundedNonEmptyString(record, "detail", ASK_USER_TEXT_MAX_LENGTH)),
};
}
function parseAskUserQuestion(value: unknown): AskUserQuestion {
const record = requireRecord(value);
const options = boundedArrayOf(record["options"], parseAskUserQuestionOption, ASK_USER_OPTION_LIMIT, "options");
assertUniqueStrings(options.map((option) => option.value), "ask option value");
const allowOther = parseOptionalBoolean(record["allowOther"], "allowOther");
const multiple = parseOptionalBoolean(record["multiple"], "multiple");
// A question offering neither options nor a free-text field cannot be answered
// at all, which would make reporting it as unanswered meaningless.
if (options.length === 0 && allowOther !== true) throw new Error("Ask question offers no way to answer");
return {
id: requireBoundedNonEmptyString(record, "id", ASK_USER_ID_MAX_LENGTH),
question: requireBoundedNonEmptyString(record, "question", ASK_USER_TEXT_MAX_LENGTH),
...optionalField("detail", optionalBoundedNonEmptyString(record, "detail", ASK_USER_TEXT_MAX_LENGTH)),
options,
...(allowOther === undefined ? {} : { allowOther }),
...(multiple === undefined ? {} : { multiple }),
};
}
/**
* Validate the session's open question set. A malformed ask must be dropped
* rather than rendered: the card asks the user to answer on the model's behalf,
* so questions or options the daemon did not really send must never appear.
*/
function parsePendingAskUser(value: unknown): PendingAskUser {
const record = requireRecord(value);
const questions = boundedArrayOf(record["questions"], parseAskUserQuestion, ASK_USER_QUESTION_LIMIT, "questions");
if (questions.length === 0) throw new Error("Pending ask has no questions");
assertUniqueStrings(questions.map((question) => question.id), "ask question id");
return {
askId: requireBoundedNonEmptyString(record, "askId", ASK_USER_ID_MAX_LENGTH),
askedAt: requireNonEmptyString(record, "askedAt"),
questions,
};
}
function optionalPendingAsk(value: unknown): Pick<SessionStatus, "pendingAsk"> | object {
if (value === undefined) return {};
return { pendingAsk: parsePendingAskUser(value) };
}
export function parseSessionAskOpenedEvent(value: unknown): { type: "ask.opened"; ask: PendingAskUser } {
const record = requireRecord(value);
if (record["type"] !== "ask.opened") throw new Error("Invalid ask opened event type");
return { type: "ask.opened", ask: parsePendingAskUser(record["ask"]) };
}
export function parseSessionAskClosedEvent(value: unknown): { type: "ask.closed"; askId: string; reason: AskUserCloseReason } {
const record = requireRecord(value);
if (record["type"] !== "ask.closed") throw new Error("Invalid ask closed event type");
return {
type: "ask.closed",
askId: requireBoundedNonEmptyString(record, "askId", ASK_USER_ID_MAX_LENGTH),
reason: parseAskUserCloseReason(record["reason"]),
};
}
function parseAskUserCloseReason(value: unknown): AskUserCloseReason {
if (value !== "submitted" && value !== "superseded" && value !== "cancelled") throw new Error("Invalid ask close reason");
return value;
}
function parseAskUserQuestionRecord(value: unknown): AskUserQuestionRecord {
const record = requireRecord(value);
const question = parseAskUserQuestion(record["question"]);
const values = boundedArrayOf(record["values"], parseNonEmptyString, ASK_USER_OPTION_LIMIT, "values");
const offered = new Set(question.options.map((option) => option.value));
if (values.some((selected) => !offered.has(selected))) throw new Error("Ask answer selected an option the question never offered");
const otherText = optionalBoundedNonEmptyString(record, "otherText", ASK_USER_OTHER_TEXT_MAX_LENGTH);
const answered = requireBoolean(record, "answered");
// The record is the one thing both the model and the user read, so a flag that
// disagrees with the answer it describes is rejected rather than displayed.
if (answered !== (values.length > 0 || otherText !== undefined)) throw new Error("Ask answer contradicts its answered flag");
return { question, answered, values, ...(otherText === undefined ? {} : { otherText }) };
}
function parseAskUserOutcome(value: unknown): AskUserOutcome {
const record = requireRecord(value);
const questions = boundedArrayOf(record["questions"], parseAskUserQuestionRecord, ASK_USER_QUESTION_LIMIT, "questions");
const answeredCount = requireNonNegativeSafeInteger(record, "answeredCount");
const unansweredIds = arrayOfString(record["unansweredIds"], "unansweredIds");
const unanswered = questions.filter((entry) => !entry.answered).map((entry) => entry.question.id);
if (answeredCount !== questions.length - unanswered.length) throw new Error("Ask outcome answered count mismatch");
if (unansweredIds.length !== unanswered.length || unansweredIds.some((id, index) => id !== unanswered[index])) {
throw new Error("Ask outcome unanswered ids mismatch");
}
return {
askId: requireBoundedNonEmptyString(record, "askId", ASK_USER_ID_MAX_LENGTH),
reason: parseAskUserCloseReason(record["reason"]),
askedAt: requireNonEmptyString(record, "askedAt"),
closedAt: requireNonEmptyString(record, "closedAt"),
questions,
answeredCount,
unansweredIds,
summary: requireNonEmptyString(record, "summary"),
};
}
export function parseAskUserCloseResponse(value: unknown): AskUserCloseResponse {
const record = requireRecord(value);
const result = record["result"];
if (result !== "closed" && result !== "stale") throw new Error("Invalid ask close result");
const outcome = record["outcome"] === undefined ? undefined : parseAskUserOutcome(record["outcome"]);
// Only the call that actually closed the ask carries an outcome; a stale close
// reports none and is trusted for the session status alone.
if ((result === "closed") !== (outcome !== undefined)) throw new Error("Ask close response outcome mismatch");
return {
result,
...(outcome === undefined ? {} : { outcome }),
sessionStatus: parseSessionStatus(record["sessionStatus"]),
};
}
function assertUniqueStrings(values: readonly string[], label: string): void {
if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`);
}
export function parseSessionStatus(value: unknown): SessionStatus {
const record = requireRecord(value);
return {
@@ -222,6 +348,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
...optionalContextUsage(record["contextUsage"]),
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
...optionalWarnings(record["warnings"]),
...optionalPendingAsk(record["pendingAsk"]),
};
}
@@ -347,6 +474,14 @@ function requireBoundedNonEmptyString(record: Record<string, unknown>, key: stri
return value;
}
function optionalBoundedNonEmptyString(record: Record<string, unknown>, key: string, maxLength: number): string | undefined {
const value = optionalString(record, key);
if (value === undefined) return undefined;
if (value === "") throw new Error(`Expected non-empty string field: ${key}`);
if (value.length > maxLength) throw new Error(`String field exceeds limit: ${key}`);
return value;
}
function requirePositiveSafeInteger(record: Record<string, unknown>, key: string): number {
const value = requireNonNegativeSafeInteger(record, key);
if (value === 0) throw new Error(`Expected positive safe integer field: ${key}`);
+8 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PendingAskUser, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids";
import type { SelectedSessionNotificationInbox } from "./sessionNotifications";
@@ -31,6 +31,12 @@ export interface AppState {
selectedSession: SessionInfo | undefined;
status: SessionStatus | undefined;
activity: SessionActivity | undefined;
/**
* The selected session's open `ask_user` question set, derived from the
* daemon-owned {@link SessionStatus.pendingAsk} plus live ask events, and
* dropped when the machine reports no `sessions.askUser` support.
*/
pendingAsk: PendingAskUser | undefined;
/** Thinking levels available for the selected session's current model. */
availableThinkingLevels: readonly string[];
sessionStatuses: Record<string, SessionStatus>;
@@ -144,6 +150,7 @@ export function initialAppState(): AppState {
selectedSession: undefined,
status: undefined,
activity: undefined,
pendingAsk: undefined,
availableThinkingLevels: [],
sessionStatuses: {},
sessionActivities: {},
+194
View File
@@ -0,0 +1,194 @@
import { afterEach, describe, expect, it } from "vitest";
import { ASK_USER_OTHER_TEXT_MAX_LENGTH, type AskUserQuestion } from "../../shared/apiTypes";
import { answeredCount, clearAskDraft, loadAskDraft, saveAskDraft, toSubmission, unansweredQuestions, type AskDraftAnswers } from "./askDrafts";
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number {
return this.values.size;
}
clear(): void {
this.values.clear();
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
key(index: number): string | null {
return Array.from(this.values.keys())[index] ?? null;
}
removeItem(key: string): void {
this.values.delete(key);
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
}
class ThrowingStorage extends MemoryStorage {
override getItem(): string | null {
throw new Error("storage blocked");
}
override setItem(): void {
throw new Error("storage blocked");
}
override removeItem(): void {
throw new Error("storage blocked");
}
}
const singleSelect: AskUserQuestion = {
id: "q1",
question: "Which database?",
options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }],
};
const multiSelectWithOther: AskUserQuestion = {
id: "q2",
question: "Which extras?",
options: [{ value: "metrics", label: "Metrics" }, { value: "tracing", label: "Tracing" }],
allowOther: true,
multiple: true,
};
const freeTextOnly: AskUserQuestion = {
id: "q3",
question: "Anything else?",
options: [],
allowOther: true,
};
const questions = [singleSelect, multiSelectWithOther, freeTextOnly];
afterEach(() => {
Object.defineProperty(globalThis, "localStorage", { value: undefined, configurable: true });
});
describe("ask draft storage", () => {
it("round-trips a draft under a session- and ask-scoped key", () => {
const storage = new MemoryStorage();
const answers: AskDraftAnswers = { q1: { values: ["pg"] }, q2: { values: ["metrics"], otherText: "audit log" } };
saveAskDraft("local:s1", "ask-1", answers, storage);
expect(storage.getItem("pi-web:ask-draft:local:s1:ask-1")).not.toBeNull();
expect(loadAskDraft("local:s1", "ask-1", storage)).toEqual(answers);
// Another ask of the same session, and the same ask of another session, are
// separate drafts.
expect(loadAskDraft("local:s1", "ask-2", storage)).toEqual({});
expect(loadAskDraft("local:s2", "ask-1", storage)).toEqual({});
});
it("removes the entry rather than storing a draft that says nothing", () => {
const storage = new MemoryStorage();
saveAskDraft("local:s1", "ask-1", { q1: { values: ["pg"] } }, storage);
saveAskDraft("local:s1", "ask-1", { q1: { values: [], otherText: "" } }, storage);
expect(storage.getItem("pi-web:ask-draft:local:s1:ask-1")).toBeNull();
expect(loadAskDraft("local:s1", "ask-1", storage)).toEqual({});
});
it("clears a draft once its ask is closed", () => {
const storage = new MemoryStorage();
saveAskDraft("local:s1", "ask-1", { q1: { values: ["pg"] } }, storage);
clearAskDraft("local:s1", "ask-1", storage);
expect(loadAskDraft("local:s1", "ask-1", storage)).toEqual({});
});
it("treats unreadable and malformed drafts as empty instead of failing", () => {
const storage = new MemoryStorage();
storage.setItem("pi-web:ask-draft:local:s1:ask-1", "{not json");
expect(loadAskDraft("local:s1", "ask-1", storage)).toEqual({});
storage.setItem("pi-web:ask-draft:local:s1:ask-1", JSON.stringify({ q1: { values: "pg" }, q2: { values: ["metrics"] }, q3: 7 }));
expect(loadAskDraft("local:s1", "ask-1", storage)).toEqual({ q2: { values: ["metrics"] } });
const throwing = new ThrowingStorage();
expect(loadAskDraft("local:s1", "ask-1", throwing)).toEqual({});
expect(() => { saveAskDraft("local:s1", "ask-1", { q1: { values: ["pg"] } }, throwing); }).not.toThrow();
expect(() => { clearAskDraft("local:s1", "ask-1", throwing); }).not.toThrow();
});
it("does nothing when the browser has no storage at all", () => {
expect(loadAskDraft("local:s1", "ask-1", undefined)).toEqual({});
expect(() => { saveAskDraft("local:s1", "ask-1", { q1: { values: ["pg"] } }, undefined); }).not.toThrow();
expect(() => { clearAskDraft("local:s1", "ask-1", undefined); }).not.toThrow();
});
});
describe("ask answer state", () => {
it("counts and names answers exactly as the submission reports them", () => {
const answers: AskDraftAnswers = { q1: { values: ["pg"] }, q3: { values: [], otherText: " ship it " } };
expect(answeredCount(questions, answers)).toBe(2);
expect(unansweredQuestions(questions, answers).map((question) => question.id)).toEqual(["q2"]);
expect(toSubmission(questions, answers)).toEqual({
answers: [{ id: "q1", values: ["pg"] }, { id: "q3", values: [], otherText: "ship it" }],
});
});
it("treats untouched, empty, and whitespace-only answers as unanswered", () => {
const answers: AskDraftAnswers = { q1: { values: [] }, q3: { values: [], otherText: " " } };
expect(answeredCount(questions, answers)).toBe(0);
expect(unansweredQuestions(questions, answers).map((question) => question.id)).toEqual(["q1", "q2", "q3"]);
expect(toSubmission(questions, answers)).toEqual({ answers: [] });
});
it("submits answers in the order the questions were asked", () => {
const answers: AskDraftAnswers = { q3: { values: [], otherText: "later" }, q1: { values: ["sqlite"] } };
expect(toSubmission(questions, answers).answers.map((answer) => answer.id)).toEqual(["q1", "q3"]);
});
it("keeps several values and other text together for a multi-select question", () => {
const answers: AskDraftAnswers = { q2: { values: ["metrics", "tracing"], otherText: "profiling" } };
expect(toSubmission(questions, answers)).toEqual({
answers: [{ id: "q2", values: ["metrics", "tracing"], otherText: "profiling" }],
});
});
it("drops draft entries the question would reject rather than losing the whole submission", () => {
// Drafts are browser-local and survive a superseding ask, another tab, or an
// older app version, so an entry that no longer fits its question is
// narrowed instead of poisoning every other answer.
const answers: AskDraftAnswers = {
q1: { values: ["mysql", "pg", "pg"], otherText: "custom" },
q2: { values: ["metrics", "mongo"] },
q3: { values: ["nope"], otherText: "note" },
};
expect(toSubmission(questions, answers)).toEqual({
answers: [
{ id: "q1", values: ["pg"] },
{ id: "q2", values: ["metrics"] },
{ id: "q3", values: [], otherText: "note" },
],
});
});
it("keeps other text for a single-select question that has no selected option", () => {
const singleWithOther: AskUserQuestion = { ...singleSelect, allowOther: true };
expect(toSubmission([singleWithOther], { q1: { values: [], otherText: "neither" } })).toEqual({
answers: [{ id: "q1", values: [], otherText: "neither" }],
});
});
it("bounds other text at the shared limit", () => {
const answers: AskDraftAnswers = { q3: { values: [], otherText: "a".repeat(ASK_USER_OTHER_TEXT_MAX_LENGTH + 10) } };
expect(toSubmission(questions, answers).answers[0]?.otherText).toHaveLength(ASK_USER_OTHER_TEXT_MAX_LENGTH);
});
});
+137
View File
@@ -0,0 +1,137 @@
import { ASK_USER_OTHER_TEXT_MAX_LENGTH, type AskUserAnswer, type AskUserQuestion, type AskUserSubmission } from "../../shared/apiTypes";
/**
* What the user has entered for one question but has not submitted yet. Kept in
* the browser because the daemon owns "there is an open ask" while the browser
* owns "what I have typed so far".
*/
export interface AskDraftAnswer {
values: string[];
otherText?: string;
}
/** Draft answers of one ask, keyed by question id. */
export type AskDraftAnswers = Record<string, AskDraftAnswer>;
const draftStoragePrefix = "pi-web:ask-draft:";
function draftStorageKey(sessionId: string, askId: string): string {
return `${draftStoragePrefix}${sessionId}:${askId}`;
}
function browserStorage(): Storage | undefined {
try {
return typeof localStorage === "undefined" ? undefined : localStorage;
} catch {
return undefined;
}
}
/**
* Read the stored draft. Any unreadable or malformed payload yields an empty
* draft: a half-typed answer set is a convenience, never a reason to fail
* rendering the questions.
*/
export function loadAskDraft(sessionId: string, askId: string, storage = browserStorage()): AskDraftAnswers {
try {
const stored = storage?.getItem(draftStorageKey(sessionId, askId));
return stored === null || stored === undefined ? {} : draftAnswersFromJson(stored);
} catch {
return {};
}
}
export function saveAskDraft(sessionId: string, askId: string, answers: AskDraftAnswers, storage = browserStorage()): void {
try {
const entries = Object.entries(answers).filter(([, answer]) => answer.values.length > 0 || (answer.otherText ?? "") !== "");
if (entries.length === 0) storage?.removeItem(draftStorageKey(sessionId, askId));
else storage?.setItem(draftStorageKey(sessionId, askId), JSON.stringify(Object.fromEntries(entries)));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
export function clearAskDraft(sessionId: string, askId: string, storage = browserStorage()): void {
try {
storage?.removeItem(draftStorageKey(sessionId, askId));
} catch {
// Ignore localStorage quota/privacy errors.
}
}
function draftAnswersFromJson(stored: string): AskDraftAnswers {
const parsed: unknown = JSON.parse(stored);
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
const answers: AskDraftAnswers = {};
for (const [id, value] of Object.entries(parsed)) {
const answer = draftAnswerFromValue(value);
if (answer !== undefined) answers[id] = answer;
}
return answers;
}
function draftAnswerFromValue(value: unknown): AskDraftAnswer | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const record: Record<string, unknown> = { ...value };
const values = record["values"];
const otherText = record["otherText"];
if (!Array.isArray(values) || !values.every((entry) => typeof entry === "string")) return undefined;
if (otherText !== undefined && typeof otherText !== "string") return undefined;
return { values: [...values], ...(otherText === undefined ? {} : { otherText }) };
}
/**
* How many questions the draft currently answers. A question counts as answered
* exactly when {@link toSubmission} would send an answer for it, so the progress
* the user reads matches what the model is told.
*/
export function answeredCount(questions: readonly AskUserQuestion[], answers: AskDraftAnswers): number {
return questions.filter((question) => submittableAnswer(question, answers[question.id]) !== undefined).length;
}
/** The questions left untouched, in the order they were asked. */
export function unansweredQuestions(questions: readonly AskUserQuestion[], answers: AskDraftAnswers): AskUserQuestion[] {
return questions.filter((question) => submittableAnswer(question, answers[question.id]) === undefined);
}
/**
* The submission for the current draft: one answer per answered question, and
* nothing for the untouched ones, since an empty answer and an untouched
* question mean the same thing to the daemon.
*/
export function toSubmission(questions: readonly AskUserQuestion[], answers: AskDraftAnswers): AskUserSubmission {
const submitted: AskUserAnswer[] = [];
for (const question of questions) {
const answer = submittableAnswer(question, answers[question.id]);
if (answer !== undefined) submitted.push(answer);
}
return { answers: submitted };
}
/**
* Normalize one draft entry against the question it answers, or `undefined` when
* it says nothing. The draft is browser-local storage that a previous version of
* the app, another tab, or a user could have left in a shape the question no
* longer accepts, so values the question does not offer are dropped and a
* single-select question keeps only its first selection rather than sending a
* submission the daemon would reject as a whole.
*/
function submittableAnswer(question: AskUserQuestion, answer: AskDraftAnswer | undefined): AskUserAnswer | undefined {
if (answer === undefined) return undefined;
const offered = new Set(question.options.map((option) => option.value));
const values = [...new Set(answer.values)].filter((value) => offered.has(value));
const otherText = normalizedOtherText(question, answer.otherText);
if (question.multiple !== true && values.length + (otherText === undefined ? 0 : 1) > 1) {
const single = values[0];
if (single !== undefined) return { id: question.id, values: [single] };
return otherText === undefined ? undefined : { id: question.id, values: [], otherText };
}
if (values.length === 0 && otherText === undefined) return undefined;
return { id: question.id, values, ...(otherText === undefined ? {} : { otherText }) };
}
function normalizedOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined {
if (otherText === undefined || question.allowOther !== true) return undefined;
const trimmed = otherText.trim().slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH);
return trimmed === "" ? undefined : trimmed;
}
@@ -0,0 +1,277 @@
import { beforeEach, describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { loadAskDraft, saveAskDraft } from "../askDrafts";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { AskUserCloseResponse, AskUserQuestion, PendingAskUser } from "../api";
import { SessionController } from "./sessionController";
import { defaultApi, EmitSocket, emptyPage, FakeSocket, MemoryStorage, oldSession, sessionKey, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
const databaseQuestion: AskUserQuestion = { id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] };
const extrasQuestion: AskUserQuestion = { id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true };
function ask(askId: string): PendingAskUser {
return { askId, askedAt: "2026-07-20T00:00:00.000Z", questions: [databaseQuestion, extrasQuestion] };
}
function statusWithAsk(sessionId: string, pendingAsk: PendingAskUser): SessionStatus {
return { ...status(sessionId), pendingAsk };
}
function closeResponse(sessionStatus: SessionStatus, askId = "ask-1"): AskUserCloseResponse {
return {
result: "closed",
outcome: {
askId,
reason: "submitted",
askedAt: "2026-07-20T00:00:00.000Z",
closedAt: "2026-07-20T00:01:00.000Z",
questions: [
{ question: databaseQuestion, answered: true, values: ["pg"] },
{ question: extrasQuestion, answered: false, values: [] },
],
answeredCount: 1,
unansweredIds: ["q2"],
summary: "Answered 1 of 2; unanswered: q2",
},
sessionStatus,
};
}
function capableState(patch: Partial<AppState> = {}): AppState {
return {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsAskUser] } },
...patch,
};
}
function selectableApi(sessionStatus: SessionStatus): typeof defaultApi {
return {
...defaultApi,
messages: () => Promise.resolve(emptyPage),
status: () => Promise.resolve(sessionStatus),
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
thinkingLevels: () => Promise.resolve({ levels: [] }),
};
}
interface LiveHarness {
controller: SessionController;
socket: EmitSocket;
state: () => AppState;
}
async function liveSession(patch: Partial<AppState> = {}, sessionStatus = status(oldSession.id)): Promise<LiveHarness> {
const socket = new EmitSocket();
let state = capableState({ selectedSession: undefined, ...patch });
const controller = new SessionController(
() => state,
(statePatch) => { state = { ...state, ...statePatch }; },
() => undefined,
undefined,
{ api: selectableApi(sessionStatus), socket },
);
await controller.selectSession(oldSession, { updateUrl: false });
return { controller, socket, state: () => state };
}
beforeEach(() => {
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
});
describe("SessionController open ask state", () => {
it("rehydrates the open ask from the daemon-owned status on selection", async () => {
const pending = ask("ask-1");
const harness = await liveSession({}, statusWithAsk(oldSession.id, pending));
expect(harness.state().pendingAsk).toEqual(pending);
});
it("opens and closes the card from live ask events", async () => {
const harness = await liveSession();
harness.socket.emit({ type: "ask.opened", ask: ask("ask-1") });
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
harness.socket.emit({ type: "ask.closed", askId: "ask-1", reason: "submitted" });
expect(harness.state().pendingAsk).toBeUndefined();
});
it("keeps the newer ask when the supersede close for the old one arrives after it", async () => {
const harness = await liveSession();
harness.socket.emit({ type: "ask.opened", ask: ask("ask-1") });
harness.socket.emit({ type: "ask.opened", ask: ask("ask-2") });
harness.socket.emit({ type: "ask.closed", askId: "ask-1", reason: "superseded" });
expect(harness.state().pendingAsk?.askId).toBe("ask-2");
});
it("drops an open ask on a machine that reports no ask support", async () => {
const harness = await liveSession(
{ machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } },
statusWithAsk(oldSession.id, ask("ask-1")),
);
expect(harness.state().pendingAsk).toBeUndefined();
harness.socket.emit({ type: "ask.opened", ask: ask("ask-1") });
expect(harness.state().pendingAsk).toBeUndefined();
});
it("still shows an open ask while capability discovery is unavailable", async () => {
const harness = await liveSession({ machineRuntimes: {} }, statusWithAsk(oldSession.id, ask("ask-1")));
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
});
it("applies a status that no longer carries an ask as the authoritative close", async () => {
const harness = await liveSession({}, statusWithAsk(oldSession.id, ask("ask-1")));
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
harness.controller.applySessionStatus(status(oldSession.id));
expect(harness.state().pendingAsk).toBeUndefined();
});
it("does not adopt another session's open ask", async () => {
const harness = await liveSession();
harness.controller.applySessionStatus(statusWithAsk("other-session", ask("ask-1")));
expect(harness.state().pendingAsk).toBeUndefined();
});
it("clears the card when the session is deselected", async () => {
const harness = await liveSession({}, statusWithAsk(oldSession.id, ask("ask-1")));
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
harness.controller.deselectSession({ updateUrl: false });
expect(harness.state().pendingAsk).toBeUndefined();
});
});
describe("SessionController ask submission", () => {
it("submits answers, clears the draft, and applies the returned status", async () => {
const submitCalls: { askId: string; answers: unknown; machineId: string }[] = [];
const closedStatus = status(oldSession.id);
let state = capableState({ status: statusWithAsk(oldSession.id, ask("ask-1")), pendingAsk: ask("ask-1") });
const api: typeof defaultApi = {
...defaultApi,
submitAsk: (_session, askId, submission, machineId) => {
submitCalls.push({ askId, answers: submission.answers, machineId: machineId ?? "local" });
return Promise.resolve(closeResponse(closedStatus));
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
saveAskDraft(sessionKey(oldSession.id), "ask-1", { q1: { values: ["pg"] } });
await controller.submitAsk("ask-1", { answers: [{ id: "q1", values: ["pg"] }] });
expect(submitCalls).toEqual([{ askId: "ask-1", answers: [{ id: "q1", values: ["pg"] }], machineId: "local" }]);
expect(loadAskDraft(sessionKey(oldSession.id), "ask-1")).toEqual({});
expect(state.pendingAsk).toBeUndefined();
expect(state.status).toEqual(closedStatus);
});
it("cancels an ask through its own route and clears the draft", async () => {
const cancelCalls: string[] = [];
let state = capableState({ pendingAsk: ask("ask-1") });
const api: typeof defaultApi = {
...defaultApi,
cancelAsk: (_session, askId) => {
cancelCalls.push(askId);
return Promise.resolve(closeResponse(status(oldSession.id)));
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
saveAskDraft(sessionKey(oldSession.id), "ask-1", { q1: { values: ["pg"] } });
await controller.cancelAsk("ask-1");
expect(cancelCalls).toEqual(["ask-1"]);
expect(loadAskDraft(sessionKey(oldSession.id), "ask-1")).toEqual({});
expect(state.pendingAsk).toBeUndefined();
});
it("trusts the status of a stale close and shows the superseding ask without an error", async () => {
const supersedingAsk = ask("ask-2");
let state = capableState({ pendingAsk: ask("ask-1") });
const api: typeof defaultApi = {
...defaultApi,
submitAsk: () => Promise.resolve({ result: "stale", sessionStatus: statusWithAsk(oldSession.id, supersedingAsk) }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.submitAsk("ask-1", { answers: [] });
expect(state.error).toBe("");
expect(state.pendingAsk).toEqual(supersedingAsk);
});
it("keeps the draft and reports the failure when the submit request fails", async () => {
let state = capableState({ pendingAsk: ask("ask-1") });
const api: typeof defaultApi = { ...defaultApi, submitAsk: () => Promise.reject(new Error("submit failed")) };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
saveAskDraft(sessionKey(oldSession.id), "ask-1", { q1: { values: ["pg"] } });
await controller.submitAsk("ask-1", { answers: [{ id: "q1", values: ["pg"] }] });
expect(state.error).toBe("Error: submit failed");
expect(loadAskDraft(sessionKey(oldSession.id), "ask-1")).toEqual({ q1: { values: ["pg"] } });
expect(state.pendingAsk?.askId).toBe("ask-1");
});
it("does not submit for an archived session", async () => {
const archived = { ...oldSession, archived: true as const };
let state = capableState({ selectedSession: archived, sessions: [archived] });
let submitted = false;
const api: typeof defaultApi = {
...defaultApi,
submitAsk: () => {
submitted = true;
return Promise.resolve(closeResponse(status(oldSession.id)));
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.submitAsk("ask-1", { answers: [] });
expect(submitted).toBe(false);
});
});
@@ -1,9 +1,10 @@
import { api as defaultApi, type CommandResult, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api";
import { api as defaultApi, type AskUserCloseResponse, type AskUserSubmission, type CommandResult, type PendingAskUser, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api";
import type { AppState } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages";
import { machineSessionKey } from "../machineKeys";
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
import { clearAskDraft } from "../askDrafts";
import { ChatTranscriptStore } from "../chatTranscriptStore";
import { isShellInput } from "../inputModes";
import { fileCompletionInsertText } from "../promptCompletions";
@@ -160,7 +161,7 @@ export class SessionController {
// session must not cancel the in-flight upload indicator of the session
// that is still sending; the per-session entry is cleared by send()'s
// finally block when the request settles.
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, availableThinkingLevels: [], treeDialog: undefined });
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, availableThinkingLevels: [], treeDialog: undefined });
}
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
@@ -218,6 +219,7 @@ export class SessionController {
...(options?.preserveTreeDialog === true ? {} : { treeDialog: undefined }),
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
pendingAsk: session.archived === true ? undefined : this.selectedPendingAsk(this.getState().sessionStatuses[session.id], machineId),
availableThinkingLevels: [],
});
let buffered: SessionUiEvent[] | undefined;
@@ -226,7 +228,7 @@ export class SessionController {
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(transcriptKey, page);
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined });
this.onSelectedSessionReady?.({ machineId, session });
if (options?.updateUrl !== false) this.updateUrl();
return;
@@ -663,7 +665,7 @@ export class SessionController {
sessions: nextSessions,
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
sessionActivities: omitKeys(state.sessionActivities, affectedIds),
...(selectedAffected ? { status: undefined, activity: undefined } : {}),
...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined } : {}),
});
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
@@ -884,6 +886,36 @@ export class SessionController {
}
}
/** Send the answers the user entered for the session's open ask. */
submitAsk(askId: string, submission: AskUserSubmission): Promise<void> {
return this.closeOpenAsk(askId, (session, machineId) => this.api.submitAsk(session, askId, submission, machineId));
}
/** Close the session's open ask without answering it. */
cancelAsk(askId: string): Promise<void> {
return this.closeOpenAsk(askId, (session, machineId) => this.api.cancelAsk(session, askId, machineId));
}
private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise<AskUserCloseResponse>): Promise<void> {
const state = this.getState();
const session = state.selectedSession;
if (session === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) return;
const machineId = selectedMachineId(state);
const selectionSeq = this.selectionSeq;
try {
const response = await close(session, machineId);
// The ask is gone either way: this call closed it, or it was already
// closed elsewhere. Its draft has nothing left to protect, and the answers
// now live in the daemon-owned outcome.
clearAskDraft(machineSessionKey(machineId, session.id), askId);
// Both outcomes carry the recomputed status, so no follow-up status
// request is needed to learn what the session's open ask is now.
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.applyStatus(response.sessionStatus);
} catch (error) {
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
}
}
async stopActiveWork() {
const session = this.getState().selectedSession;
if (!session) return;
@@ -1042,6 +1074,7 @@ export class SessionController {
isLoadingEarlierMessages: false,
status: undefined,
activity,
pendingAsk: undefined,
availableThinkingLevels: [],
treeDialog: undefined,
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
@@ -1082,7 +1115,7 @@ export class SessionController {
sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}),
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId) } : {}),
error: "",
});
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
@@ -1225,16 +1258,51 @@ export class SessionController {
private applyStatus(status: SessionStatus) {
const state = this.getState();
const isSelected = state.selectedSession?.id === status.sessionId;
const clearsStaleActivity = state.sessionActivities[status.sessionId]?.phase === "active" && !isSessionActive(status);
this.setState({
sessionStatuses: { ...state.sessionStatuses, [status.sessionId]: status },
...sessionMessageCountPatch(state, status.sessionId, status.messageCount),
...(clearsStaleActivity ? { sessionActivities: omitSessionActivity(state.sessionActivities, status.sessionId) } : {}),
status: state.selectedSession?.id === status.sessionId ? status : state.status,
activity: state.selectedSession?.id === status.sessionId && clearsStaleActivity ? undefined : state.activity,
status: isSelected ? status : state.status,
activity: isSelected && clearsStaleActivity ? undefined : state.activity,
// The daemon owns whether an ask is open, so every status it publishes is
// authoritative for the selected session's card, including its removal.
...(isSelected ? { pendingAsk: this.selectedPendingAsk(status, selectedMachineId(state)) } : {}),
});
}
private applyOpenedAsk(ask: PendingAskUser): void {
const state = this.getState();
if (state.selectedSession === undefined) return;
// A superseded ask keeps its draft: the read-only record of an ask the user
// never submitted must still be able to show what they had typed.
this.setState({ pendingAsk: this.selectedPendingAsk({ pendingAsk: ask }, selectedMachineId(state)) });
}
private applyClosedAsk(askId: string): void {
// A close for an ask that is not the one on screen is already reflected here
// (typically the supersede half of an open), so it must not clear the card.
if (this.getState().pendingAsk?.askId !== askId) return;
this.setState({ pendingAsk: undefined });
}
/**
* The open ask to show for the selected session, or `undefined` when there is
* none or the machine cannot serve it.
*
* COMPAT-CAP sessions.askUser: only a positive runtime answer without the
* capability drops an ask. A machine that reports no support cannot have posted
* one, so dropping it there is honest; while capability discovery is pending or
* failed, hiding questions the daemon says are open would strand the model.
*/
private selectedPendingAsk(status: Pick<SessionStatus, "pendingAsk"> | undefined, machineId: string): PendingAskUser | undefined {
if (status?.pendingAsk === undefined) return undefined;
const runtime = this.getState().machineRuntimes[machineId];
if (runtime?.ok === true && !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsAskUser)) return undefined;
return status.pendingAsk;
}
private applySessionName(sessionId: string, name: string | undefined) {
const rename = (session: SessionInfo) => {
if (session.id !== sessionId) return session;
@@ -1285,6 +1353,16 @@ export class SessionController {
}
this.flushPendingUpdates();
// Ask frames are applied after the buffered status they were published with,
// so the card follows the daemon's own open/close order.
if (event.type === "ask.opened") {
this.applyOpenedAsk(event.ask);
return;
}
if (event.type === "ask.closed") {
this.applyClosedAsk(event.askId);
return;
}
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
if (transcript) {
this.setState({ messages: transcript });
+17
View File
@@ -103,6 +103,23 @@ describe("notification socket guards", () => {
expect(parseSessionSocketEvent({ type: "session.startup", cwd: "/repo", activity })).toBeUndefined();
});
it("accepts validated ask frames and drops malformed ones", () => {
const ask = {
askId: "ask-1",
askedAt: "2026-07-20T00:00:00.000Z",
questions: [{ id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] }],
};
expect(parseSessionSocketEvent({ type: "ask.opened", ask })).toEqual({ type: "ask.opened", ask });
expect(parseSessionSocketEvent({ type: "ask.closed", askId: "ask-1", reason: "superseded" }))
.toEqual({ type: "ask.closed", askId: "ask-1", reason: "superseded" });
expect(parseSessionSocketEvent({ type: "ask.opened", ask: { ...ask, questions: [] } })).toBeUndefined();
expect(parseSessionSocketEvent({ type: "ask.opened" })).toBeUndefined();
expect(parseSessionSocketEvent({ type: "ask.closed", askId: "ask-1", reason: "ignored" })).toBeUndefined();
// Ask frames are per-session only, so they must not be accepted globally.
expect(parseRealtimeSocketEvent({ type: "ask.opened", ask })).toBeUndefined();
});
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
+5 -1
View File
@@ -1,5 +1,5 @@
import { realtimeEvents, sessionEvents } from "./api";
import { parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
@@ -155,6 +155,10 @@ export class RealtimeSocket {
export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefined {
const type = eventType(event);
if (type === "notifications.inbox") return safelyParseNotificationEvent(() => parseSessionNotificationInboxEvent(event));
// Ask frames drive an interactive form the user answers on the model's behalf,
// so they are validated rather than accepted on their type alone.
if (type === "ask.opened") return safelyParseValidatedEvent(() => parseSessionAskOpenedEvent(event));
if (type === "ask.closed") return safelyParseValidatedEvent(() => parseSessionAskClosedEvent(event));
return isLegacySessionUiEvent(event) ? event : undefined;
}