diff --git a/.changeset/ask-user-question-forms.md b/.changeset/ask-user-question-forms.md
new file mode 100644
index 0000000..e727851
--- /dev/null
+++ b/.changeset/ask-user-question-forms.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer with mobile-safe text sizing. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`.
diff --git a/docs/config.html b/docs/config.html
index cb53e8a..da45276 100644
--- a/docs/config.html
+++ b/docs/config.html
@@ -167,13 +167,13 @@
Environment overrides include PI_WEB_HOST, PI_WEB_PORT / PORT,
PI_WEB_ALLOWED_HOSTS, PI_WEB_MAX_UPLOAD_BYTES, PI_WEB_AGENT_COMMAND,
PI_WEB_AGENT_DIR, PI_WEB_AGENT_SESSION_DIR, PI_CODING_AGENT_DIR /
- PI_CODING_AGENT_SESSION_DIR for Pi compatibility, PI_WEB_SPAWN_SESSIONS, and
- PI_WEB_SUBSESSIONS.
+ PI_CODING_AGENT_SESSION_DIR for Pi compatibility, PI_WEB_SPAWN_SESSIONS,
+ PI_WEB_SUBSESSIONS, and PI_WEB_ASK_USER.
host / port: restart the gateway web/API service or process.
maxUploadBytes: restart both the web/API process and the session daemon on that machine.
-
agent.command / agent.dir / spawnSessions / subsessions: restart the session daemon on that machine.
+
agent.command / agent.dir / spawnSessions / subsessions / askUser: restart the session daemon on that machine.
pathAccess: applies on the next request; existing file views may need a browser refresh.
uploads.defaultFolder: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
plugins: reload the browser tab after changing PI WEB plugin enablement.
@@ -212,6 +212,7 @@
},
"spawnSessions": true,
"subsessions": false,
+ "askUser": true,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
@@ -269,7 +270,7 @@
it, and whether project-local config overrides or merges with global config. Rows with JSON key
— are runtime-only environment variables, not config-file keys. Global means
machine-global. In Settings, selected-machine-safe global keys (pathAccess, uploads,
- maxUploadBytes, agent, spawnSessions, subsessions, and plugins)
+ maxUploadBytes, agent, spawnSessions, subsessions, askUser, and plugins)
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
registry/tokens stay local.
@@ -367,6 +368,14 @@
Not supported locally; also requires spawnSessions
Restart session daemon on that machine
+
+
Agent can post question forms
+
askUser
+
PI_WEB_ASK_USER
+
Global/session daemon
+
Not supported locally
+
Restart session daemon on that machine
+
PI WEB plugin enablement/settings
plugins.<id>.enabled, plugins.<id>.settings
@@ -791,6 +800,42 @@
session daemon on that machine after changing them.
+ askUser controls whether agents receive the core ask_user tool. It defaults to
+ true; set it to false, or set PI_WEB_ASK_USER=false, to remove the
+ tool. The environment override accepts 0|1|true|false and takes precedence over the config file.
+
+
+ Use Settings → Session daemon → Allow agents to ask questions to change
+ askUser on the selected machine. An environment override makes the toggle read-only.
+
+
+ The tool accepts one set of 1–20 questions. Each question has a unique id, its
+ question text, optional supporting detail, up to 12 options with stable values and
+ user-facing labels, and an optional multiple flag. The browser always adds a
+ Custom free-text answer, including when the model supplies no options. No question is
+ required: the user may leave any of them unanswered.
+
+
+ Calling ask_user posts the whole set as one browser form and ends the current agent run
+ instead of waiting for the user. The open form is owned by the session daemon, so it survives a browser
+ disconnect, browser reload, or web/API restart while that daemon keeps running. When the user submits,
+ the answers arrive as a follow-up that wakes the session; each question is reported with its selected
+ option values or free text, or explicitly as unanswered.
+
+
+ PI WEB confirms a partial submission before sending it and names the unanswered questions. Only one ask
+ can be open per session: a later ask_user call supersedes the earlier one, reports that fact
+ and its unanswered questions to the model, and turns the earlier card into a read-only transcript record.
+ Submitted and cancelled asks likewise remain readable in the transcript.
+
+
+ Restart required: restart the session daemon after changing askUser or after
+ upgrading PI WEB to a version that introduces this tool. For the systemd user service, run
+ systemctl --user restart pi-web-sessiond.
+
diff --git a/docs/config.md b/docs/config.md
index 9af66f2..f28cf05 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -33,13 +33,13 @@ defaults → global config file → environment overrides
Supported project-local settings are then applied for that project's workspaces. For upload defaults, `/.pi-web/config.json` overrides the global value.
-Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR` for Pi compatibility, `PI_WEB_SPAWN_SESSIONS`, and `PI_WEB_SUBSESSIONS`.
+Environment overrides include `PI_WEB_HOST`, `PI_WEB_PORT` / `PORT`, `PI_WEB_ALLOWED_HOSTS`, `PI_WEB_MAX_UPLOAD_BYTES`, `PI_WEB_AGENT_COMMAND`, `PI_WEB_AGENT_DIR`, `PI_WEB_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR` / `PI_CODING_AGENT_SESSION_DIR` for Pi compatibility, `PI_WEB_SPAWN_SESSIONS`, `PI_WEB_SUBSESSIONS`, and `PI_WEB_ASK_USER`.
Process restarts depend on the key:
- `host` / `port`: restart the gateway web/API service or process.
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
-- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions`: restart the session daemon on that machine.
+- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `askUser`: restart the session daemon on that machine.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh.
- `uploads.defaultFolder`: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.
- `plugins`: reload the browser tab after changing PI WEB plugin enablement.
@@ -65,6 +65,7 @@ Process restarts depend on the key:
},
"spawnSessions": true,
"subsessions": false,
+ "askUser": true,
"plugins": {
"workspace-tasks": { "enabled": true },
"updates": { "enabled": true },
@@ -101,7 +102,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui
## Configuration matrix
-Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
+Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, `askUser`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.
| Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart |
| --- | --- | --- | --- | --- | --- |
@@ -116,6 +117,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Agent profile state directory | `agent.dir` | `PI_WEB_AGENT_DIR` (`PI_CODING_AGENT_DIR` for Pi compatibility) | Global/session daemon | Not supported locally | Restart session daemon on that machine; affects auth, models, settings, sessions, Pi packages, and Pi-package-backed PI WEB plugins |
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Tracked subsessions (beta) | `subsessions` | `PI_WEB_SUBSESSIONS` | Global/session daemon | Not supported locally; also requires `spawnSessions` | Restart session daemon on that machine |
+| Agent can post question forms | `askUser` | `PI_WEB_ASK_USER` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Plugin enablement/settings | `plugins..enabled`, `plugins..settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
| Keyboard shortcuts | `shortcuts.` | — | Global | Not supported locally | Applies after settings save/config refresh |
| Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
@@ -269,6 +271,20 @@ A completion notice wakes an idle parent or queues behind in-flight work. Each n
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
+#### `askUser` and `ask_user`
+
+`askUser` controls whether agents receive the core `ask_user` tool. It defaults to `true`; set it to `false`, or set `PI_WEB_ASK_USER=false`, to remove the tool. The environment override accepts `0|1|true|false` and takes precedence over the config file.
+
+Use **Settings → Session daemon → Allow agents to ask questions** to change `askUser` on the selected machine. An environment override makes the toggle read-only.
+
+The tool accepts one set of 1–20 questions. Each question has a unique `id`, its `question` text, optional supporting `detail`, up to 12 options with stable values and user-facing labels, and an optional `multiple` flag. The browser always adds a **Custom** free-text answer, including when the model supplies no options. No question is required: the user may leave any of them unanswered.
+
+Calling `ask_user` posts the whole set as one browser form and ends the current agent run instead of waiting for the user. The open form is owned by the session daemon, so it survives a browser disconnect, browser reload, or web/API restart while that daemon keeps running. When the user submits, the answers arrive as a follow-up that wakes the session; each question is reported with its selected option values or free text, or explicitly as unanswered.
+
+PI WEB confirms a partial submission before sending it and names the unanswered questions. Only one ask can be open per session: a later `ask_user` call supersedes the earlier one, reports that fact and its unanswered questions to the model, and turns the earlier card into a read-only transcript record. Submitted and cancelled asks likewise remain readable in the transcript.
+
+Restart the session daemon after changing `askUser` or after upgrading PI WEB to a version that introduces this tool. For the systemd user service, run `systemctl --user restart pi-web-sessiond`.
+
### Plugin config
The `plugins` key is only for PI WEB browser plugin enablement/settings on the machine whose config you are editing. It does not install, remove, or update Pi packages; use **Settings → Pi packages** or Pi's package manager for package operations. In a federated setup, **Settings → PI WEB plugins** and **Settings → Pi packages** both target the currently selected machine, and each panel labels where changes will be saved or run.
diff --git a/package-lock.json b/package-lock.json
index 2092f3f..25bc991 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -50,6 +50,7 @@
"@types/ws": "^8.18.1",
"eslint": "^10.6.0",
"globals": "^17.7.0",
+ "happy-dom": "^20.11.1",
"knip": "^6.25.0",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
@@ -5549,6 +5550,13 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -6211,6 +6219,19 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -6428,6 +6449,16 @@
"node": ">=8.6"
}
},
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
@@ -7377,6 +7408,25 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/happy-dom": {
+ "version": "20.11.1",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz",
+ "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9898,6 +9948,16 @@
"node": ">= 8"
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
diff --git a/package.json b/package.json
index 729c4f5..4328f0d 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
"@types/ws": "^8.18.1",
"eslint": "^10.6.0",
"globals": "^17.7.0",
+ "happy-dom": "^20.11.1",
"knip": "^6.25.0",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
diff --git a/src/client/src/api.ts b/src/client/src/api.ts
index 92022f3..0e595bf 100644
--- a/src/client/src/api.ts
+++ b/src/client/src/api.ts
@@ -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";
diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts
index 26f990a..137adc2 100644
--- a/src/client/src/api/clients.test.ts
+++ b/src/client/src/api/clients.test.ts
@@ -598,7 +598,7 @@ function piWebConfigResponse(config: PiWebConfigValues) {
exists: true,
config,
effectiveConfig: config,
- envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
+ envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
};
}
diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts
index e58012e..09ff57f 100644
--- a/src/client/src/api/clients.ts
+++ b/src/client/src/api/clients.ts
@@ -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 }) }),
diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts
index 96b357c..84b5871 100644
--- a/src/client/src/api/federatedRouteContract.test.ts
+++ b/src/client/src/api/federatedRouteContract.test.ts
@@ -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)),
diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts
index 10ccf61..41f06db 100644
--- a/src/client/src/api/parsers.test.ts
+++ b/src/client/src/api/parsers.test.ts
@@ -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", () => {
@@ -61,13 +61,13 @@ describe("API parsers", () => {
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
- envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
+ envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
})).toEqual({
path: "/tmp/config.json",
exists: true,
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
- envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
+ envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
});
});
@@ -722,8 +722,129 @@ describe("API parsers", () => {
delta: { kind: "cleared", reason: "future-reason" },
})).toThrow("Invalid notification clear reason");
});
+
+ it("parses an open ask and normalizes every question to allow custom answers", () => {
+ 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" }], allowOther: true },
+ { 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("validates an ask before rendering it", () => {
+ 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: [] }] } }).pendingAsk?.questions[0])
+ .toEqual({ id: "q1", question: "Anything?", options: [], allowOther: true });
+ expect(() => parseSessionStatus({ ...statusWire(), pendingAsk: { ...ask, questions: [{ id: "q1", question: "Anything?", options: [], allowOther: "yes" }] } })).toThrow("Expected optional boolean field: allowOther");
+ 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" }], allowOther: false },
+ { 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 = [
"user",
diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts
index c9d54e0..97e23f0 100644
--- a/src/client/src/api/parsers.ts
+++ b/src/client/src/api/parsers.ts
@@ -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,131 @@ function optionalWarnings(value: unknown): Pick | 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");
+ // Validate the legacy wire field when present, but normalize every question to
+ // the current invariant: the browser always offers a custom answer.
+ parseOptionalBoolean(record["allowOther"], "allowOther");
+ const multiple = parseOptionalBoolean(record["multiple"], "multiple");
+ 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: true,
+ ...(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 | 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 }) };
+}
+
+export 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 +347,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
...optionalContextUsage(record["contextUsage"]),
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
...optionalWarnings(record["warnings"]),
+ ...optionalPendingAsk(record["pendingAsk"]),
};
}
@@ -364,6 +490,14 @@ function requireBoundedNonEmptyString(record: Record, key: stri
return value;
}
+function optionalBoundedNonEmptyString(record: Record, 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, key: string): number {
const value = requireNonNegativeSafeInteger(record, key);
if (value === 0) throw new Error(`Expected positive safe integer field: ${key}`);
@@ -975,6 +1109,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
...optionalField("agent", optionalAgent(record["agent"])),
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
+ ...optionalField("askUser", optionalBoolean(record, "askUser")),
};
}
@@ -1055,6 +1190,8 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
allowedHosts: requireBoolean(record, "allowedHosts"),
spawnSessions: requireBoolean(record, "spawnSessions"),
subsessions: requireBoolean(record, "subsessions"),
+ // Older servers predate the ask_user tool; a missing flag means "not overridden".
+ askUser: optionalBoolean(record, "askUser") ?? false,
agentCommand: optionalBoolean(record, "agentCommand") ?? false,
agentDir: optionalBoolean(record, "agentDir") ?? false,
...optionalAgentDirSource(record),
diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts
index b288968..f48f2b4 100644
--- a/src/client/src/appState.ts
+++ b/src/client/src/appState.ts
@@ -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;
@@ -144,6 +150,7 @@ export function initialAppState(): AppState {
selectedSession: undefined,
status: undefined,
activity: undefined,
+ pendingAsk: undefined,
availableThinkingLevels: [],
sessionStatuses: {},
sessionActivities: {},
diff --git a/src/client/src/askDrafts.test.ts b/src/client/src/askDrafts.test.ts
new file mode 100644
index 0000000..75826a7
--- /dev/null
+++ b/src/client/src/askDrafts.test.ts
@@ -0,0 +1,190 @@
+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();
+
+ 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 multiSelect: AskUserQuestion = {
+ id: "q2",
+ question: "Which extras?",
+ options: [{ value: "metrics", label: "Metrics" }, { value: "tracing", label: "Tracing" }],
+ multiple: true,
+};
+
+const freeTextOnly: AskUserQuestion = {
+ id: "q3",
+ question: "Anything else?",
+ options: [],
+};
+
+const questions = [singleSelect, multiSelect, 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 custom 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 custom text for every single-select question when no option is selected", () => {
+ expect(toSubmission([singleSelect], { q1: { values: [], otherText: "neither" } })).toEqual({
+ answers: [{ id: "q1", values: [], otherText: "neither" }],
+ });
+ });
+
+ it("bounds custom 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);
+ });
+});
diff --git a/src/client/src/askDrafts.ts b/src/client/src/askDrafts.ts
new file mode 100644
index 0000000..064135f
--- /dev/null
+++ b/src/client/src/askDrafts.ts
@@ -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;
+
+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 = { ...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(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(otherText: string | undefined): string | undefined {
+ if (otherText === undefined) return undefined;
+ const trimmed = otherText.trim().slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH);
+ return trimmed === "" ? undefined : trimmed;
+}
diff --git a/src/client/src/chatGroups.ts b/src/client/src/chatGroups.ts
index c536035..e9d2c70 100644
--- a/src/client/src/chatGroups.ts
+++ b/src/client/src/chatGroups.ts
@@ -67,6 +67,6 @@ function toolNameFromParts(parts: ChatPart[]): string | undefined {
function isReadablePart(message: ChatLine, part: ChatPart): boolean {
if (message.source === "compaction" || message.source === "branch_summary") return false;
- if (part.type === "skillInvocation" || part.type === "skillRead" || part.type === "image") return true;
+ if (part.type === "skillInvocation" || part.type === "skillRead" || part.type === "image" || part.type === "askUserRecord") return true;
return part.type === "text" && (message.role === "user" || message.role === "assistant" || message.role === "system" || message.role === "bash");
}
diff --git a/src/client/src/chatMessages.test.ts b/src/client/src/chatMessages.test.ts
index 7d71f6f..9c664a4 100644
--- a/src/client/src/chatMessages.test.ts
+++ b/src/client/src/chatMessages.test.ts
@@ -1,6 +1,39 @@
import { describe, expect, it } from "vitest";
+import { ASK_USER_ANSWERS_CUSTOM_TYPE, type AskUserOutcome } from "../../shared/apiTypes";
+import { groupChatMessages } from "./chatGroups";
import { appendText, appendThinking, normalizeMessage, normalizeMessages, textMessage } from "./chatMessages";
+const askUserOutcome: AskUserOutcome = {
+ askId: "ask-1",
+ reason: "submitted",
+ askedAt: "2026-07-20T10:00:00.000Z",
+ closedAt: "2026-07-20T10:05:00.000Z",
+ questions: [
+ {
+ question: { id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true },
+ answered: true,
+ values: ["pg"],
+ },
+ {
+ question: { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }], allowOther: true },
+ answered: false,
+ values: [],
+ },
+ ],
+ answeredCount: 1,
+ unansweredIds: ["cache"],
+ summary: "Answered 1 of 2; unanswered: cache",
+};
+
+const supersededAskUserOutcome: AskUserOutcome = {
+ ...askUserOutcome,
+ reason: "superseded",
+ questions: askUserOutcome.questions.map((record) => ({ question: record.question, answered: false, values: [] })),
+ answeredCount: 0,
+ unansweredIds: ["db", "cache"],
+ summary: "Answered 0 of 2; unanswered: db, cache",
+};
+
describe("chat message normalization", () => {
it("normalizes simple text messages and drops empty content", () => {
expect(normalizeMessages([
@@ -20,6 +53,45 @@ describe("chat message normalization", () => {
expect(normalizeMessages([{ role: "user", content: "raw" }, line])).toEqual([textMessage("user", "raw"), line]);
});
+ it("projects ask_user answer messages into visible read-only record parts", () => {
+ const normalized = normalizeMessage({
+ role: "custom",
+ customType: ASK_USER_ANSWERS_CUSTOM_TYPE,
+ content: "model-facing answer text",
+ details: askUserOutcome,
+ });
+ const recordLine = { role: "system" as const, parts: [{ type: "askUserRecord" as const, outcome: askUserOutcome }] };
+
+ expect(normalized).toEqual([recordLine]);
+ expect(groupChatMessages(normalized)).toEqual([{ kind: "message", index: 0, message: recordLine }]);
+ });
+
+ it("falls back to model-facing text when an ask_user answer record is malformed", () => {
+ expect(normalizeMessage({
+ role: "custom",
+ customType: ASK_USER_ANSWERS_CUSTOM_TYPE,
+ content: "Answered 0 of 1; unanswered: db",
+ details: { askId: "missing-the-rest" },
+ })).toEqual([textMessage("system", "Answered 0 of 1; unanswered: db")]);
+ });
+
+ it("projects a superseded ask from the later ask_user tool result", () => {
+ const normalized = normalizeMessages([
+ { role: "assistant", content: [{ type: "toolCall", id: "ask-call", name: "ask_user", arguments: { questions: [] } }] },
+ {
+ role: "toolResult",
+ toolCallId: "ask-call",
+ toolName: "ask_user",
+ content: [{ type: "text", text: "Posted a newer question set." }],
+ details: { ask: { askId: "ask-2" }, superseded: supersededAskUserOutcome },
+ isError: false,
+ },
+ ]);
+
+ expect(normalized[1]).toEqual({ role: "tool", parts: [{ type: "askUserRecord", outcome: supersededAskUserOutcome }] });
+ expect(groupChatMessages(normalized).map((group) => group.kind)).toEqual(["group", "message"]);
+ });
+
it("normalizes tool calls and tool results", () => {
expect(normalizeMessage({ role: "assistant", content: [{ type: "toolCall", name: "bash", arguments: { command: "npm test" } }] })).toEqual([
{ role: "assistant", parts: [{ type: "toolCall", toolName: "bash", summary: "npm test", args: { command: "npm test" } }] },
diff --git a/src/client/src/chatMessages.ts b/src/client/src/chatMessages.ts
index 5294b14..4e24069 100644
--- a/src/client/src/chatMessages.ts
+++ b/src/client/src/chatMessages.ts
@@ -1,3 +1,5 @@
+import { ASK_USER_ANSWERS_CUSTOM_TYPE } from "../../shared/apiTypes";
+import { parseAskUserOutcome } from "./api/parsers";
import type { ChatLine, ChatPart, ToolExecutionPart, ToolPreview } from "./components/shared";
export function normalizeMessages(messages: unknown[]): ChatLine[] {
@@ -44,8 +46,13 @@ export function appendThinking(messages: ChatLine[], text: string): ChatLine[] {
export function normalizeMessage(message: unknown): ChatLine[] {
if (isChatLine(message)) return [message];
if (getString(message, "role") === "bashExecution") return [withMessageMeta(normalizeBashExecution(message), message)];
- const role = normalizeRole(getString(message, "role"));
- const parts = normalizeContent(getProperty(message, "content"), message);
+ const rawRole = getString(message, "role");
+ const role = normalizeRole(rawRole);
+ const contentParts = normalizeContent(getProperty(message, "content"), message);
+ const supersededRecord = rawRole === "toolResult"
+ ? askUserRecordFromToolDetails(getString(message, "toolName") ?? "", getProperty(message, "details"))
+ : undefined;
+ const parts = supersededRecord === undefined ? contentParts : [...contentParts, supersededRecord];
const skillLines = role === "user" ? normalizeSkillInvocation(parts) : undefined;
if (skillLines !== undefined) return skillLines.map((line) => withMessageMeta(line, message));
const source = normalizeSource(message);
@@ -148,6 +155,8 @@ function normalizeRole(role: unknown): ChatLine["role"] {
}
function normalizeContent(content: unknown, message: unknown): ChatPart[] {
+ const askUserRecord = askUserRecordPart(message);
+ if (askUserRecord !== undefined) return [askUserRecord];
if (typeof content === "string") return content !== "" ? [{ type: "text", text: content }] : [];
if (!Array.isArray(content)) return objectFallback(content);
@@ -179,6 +188,27 @@ function normalizeContent(content: unknown, message: unknown): ChatPart[] {
: part);
}
+function askUserRecordPart(message: unknown): Extract | undefined {
+ if (getString(message, "role") !== "custom" || getString(message, "customType") !== ASK_USER_ANSWERS_CUSTOM_TYPE) return undefined;
+ return parsedAskUserRecord(getProperty(message, "details"));
+}
+
+/** Project the superseded ask carried by an `ask_user` tool result, if any. */
+export function askUserRecordFromToolDetails(toolName: string, details: unknown): Extract | undefined {
+ if (toolName !== "ask_user") return undefined;
+ return parsedAskUserRecord(getProperty(details, "superseded"));
+}
+
+function parsedAskUserRecord(value: unknown): Extract | undefined {
+ try {
+ return { type: "askUserRecord", outcome: parseAskUserOutcome(value) };
+ } catch {
+ // A malformed legacy/session entry must not make the whole transcript fail.
+ // Fall back to its model-facing text through the ordinary normalizer.
+ return undefined;
+ }
+}
+
function toolResultPartFromText(text: string, message: unknown): Extract {
const toolCallId = getString(message, "toolCallId");
const content = getProperty(message, "content");
diff --git a/src/client/src/chatTranscript.test.ts b/src/client/src/chatTranscript.test.ts
index 6e37c98..5b69977 100644
--- a/src/client/src/chatTranscript.test.ts
+++ b/src/client/src/chatTranscript.test.ts
@@ -1,9 +1,41 @@
import { describe, expect, it } from "vitest";
+import { ASK_USER_ANSWERS_CUSTOM_TYPE, type AskUserOutcome } from "../../shared/apiTypes";
import { groupChatMessages } from "./chatGroups";
import { normalizeMessages, textMessage } from "./chatMessages";
import { applyTranscriptEvent, seedStreamingPartial } from "./chatTranscript";
import type { ChatLine } from "./components/shared";
+const askUserOutcome: AskUserOutcome = {
+ askId: "ask-1",
+ reason: "submitted",
+ askedAt: "2026-07-20T10:00:00.000Z",
+ closedAt: "2026-07-20T10:05:00.000Z",
+ questions: [
+ {
+ question: { id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }], allowOther: true },
+ answered: true,
+ values: ["vim"],
+ },
+ {
+ question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }], allowOther: true },
+ answered: false,
+ values: [],
+ },
+ ],
+ answeredCount: 1,
+ unansweredIds: ["region"],
+ summary: "Answered 1 of 2; unanswered: region",
+};
+
+const supersededAskUserOutcome: AskUserOutcome = {
+ ...askUserOutcome,
+ reason: "superseded",
+ questions: askUserOutcome.questions.map((record) => ({ question: record.question, answered: false, values: [] })),
+ answeredCount: 0,
+ unansweredIds: ["editor", "region"],
+ summary: "Answered 0 of 2; unanswered: editor, region",
+};
+
const finalAssistant = {
role: "assistant",
content: [
@@ -27,6 +59,65 @@ describe("applyTranscriptEvent", () => {
]);
});
+ it("projects finalized ask_user answers identically to rehydrated history", () => {
+ const rawMessage = {
+ role: "custom",
+ customType: ASK_USER_ANSWERS_CUSTOM_TYPE,
+ content: "The user submitted answers to your questions.",
+ details: askUserOutcome,
+ };
+ const hydrated = normalizeMessages([rawMessage]);
+ const live = applyTranscriptEvent([], { type: "message.end", message: rawMessage });
+
+ expect(live).toEqual(hydrated);
+ expect(live).toEqual([{
+ role: "system",
+ parts: [{ type: "askUserRecord", outcome: askUserOutcome }],
+ }]);
+ expect(applyTranscriptEvent(live ?? [], { type: "message.end", message: rawMessage })).toEqual(live);
+
+ const nextOutcome = { ...askUserOutcome, askId: "ask-2" };
+ const nextRawMessage = { ...rawMessage, details: nextOutcome };
+ expect(applyTranscriptEvent(live ?? [], { type: "message.end", message: nextRawMessage })).toEqual([
+ ...hydrated,
+ ...normalizeMessages([nextRawMessage]),
+ ]);
+ });
+
+ it("keeps superseded ask records identical across live tool events and hydrated history", () => {
+ const args = { questions: [{ id: "next", question: "Try again?", options: [] }] };
+ const details = { ask: { askId: "ask-2" }, superseded: supersededAskUserOutcome };
+ const finalResult = {
+ role: "toolResult",
+ toolCallId: "ask-call",
+ toolName: "ask_user",
+ content: [{ type: "text", text: "Posted a newer question set." }],
+ details,
+ isError: false,
+ };
+ const hydrated = normalizeMessages([
+ { role: "assistant", content: [{ type: "toolCall", id: "ask-call", name: "ask_user", arguments: args }] },
+ finalResult,
+ ]);
+ let live: ChatLine[] = [];
+
+ live = applyTranscriptEvent(live, { type: "tool.start", toolName: "ask_user", toolCallId: "ask-call", summary: "", args }) ?? live;
+ live = applyTranscriptEvent(live, {
+ type: "tool.end",
+ toolName: "ask_user",
+ toolCallId: "ask-call",
+ text: "Posted a newer question set.",
+ content: finalResult.content,
+ details,
+ isError: false,
+ }) ?? live;
+ live = applyTranscriptEvent(live, { type: "message.end", message: finalResult }) ?? live;
+
+ expect(live).toEqual(hydrated);
+ expect(live.filter((line) => line.parts.some((part) => part.type === "askUserRecord"))).toHaveLength(1);
+ expect(groupChatMessages(live).map((group) => group.kind)).toEqual(["group", "message"]);
+ });
+
it("replaces the streamed assistant message with the finalized history shape", () => {
const streamed: ChatLine[] = [
textMessage("user", "question"),
diff --git a/src/client/src/chatTranscript.ts b/src/client/src/chatTranscript.ts
index a577558..9d99af2 100644
--- a/src/client/src/chatTranscript.ts
+++ b/src/client/src/chatTranscript.ts
@@ -1,4 +1,4 @@
-import { appendText, appendThinking, normalizeMessage, normalizeMessages, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages";
+import { appendText, appendThinking, askUserRecordFromToolDetails, normalizeMessage, normalizeMessages, previewFromDetails, summarizeArgs, textMessage } from "./chatMessages";
import type { ChatLine, ToolExecutionPart } from "./components/shared";
import { appendShellChunk, finalizeShellMessage, shellStartMessage } from "./shellMessages";
import type { SessionUiEvent } from "./sessionSocket";
@@ -82,12 +82,27 @@ function applyFinalMessage(messages: ChatLine[], rawMessage: unknown): ChatLine[
function applyFinalLine(messages: ChatLine[], displayEnded: ChatLine): ChatLine[] {
const skillReadIndexes = findMatchingSkillReadIndexes(messages, displayEnded);
if (skillReadIndexes.length > 0) return replaceSkillReadLines(messages, skillReadIndexes, displayEnded);
+ const askUserRecord = displayEnded.parts.find((part) => part.type === "askUserRecord");
+ if (askUserRecord !== undefined) return reconcileFinalAskUserRecord(messages, displayEnded, askUserRecord);
const last = messages.at(-1);
if (last?.role !== displayEnded.role) return [...messages, displayEnded];
if (displayEnded.role === "assistant" || sameMessageText(last, displayEnded)) return [...messages.slice(0, -1), displayEnded];
return [...messages, displayEnded];
}
+function reconcileFinalAskUserRecord(
+ messages: ChatLine[],
+ displayEnded: ChatLine,
+ record: Extract,
+): ChatLine[] {
+ for (let index = messages.length - 1; index >= 0; index--) {
+ if (lineHasAskUserRecord(messages[index], record)) {
+ return [...messages.slice(0, index), displayEnded, ...messages.slice(index + 1)];
+ }
+ }
+ return [...messages, displayEnded];
+}
+
function withoutToolCalls(message: ChatLine): ChatLine {
return { ...message, parts: message.parts.filter((part) => part.type !== "toolCall") };
}
@@ -143,21 +158,45 @@ function finalizeToolExecution(messages: ChatLine[], result: ToolResultUpdate):
...(preview === undefined ? {} : { preview }),
};
}, (line) => reconcileToolResultPresentation(line, presentation));
- if (updated !== messages) return updated;
- const preview = previewFromDetails(details);
- const part: ToolExecutionPart = {
- type: "toolExecution",
- ...(toolCallId === undefined || toolCallId === "" ? {} : { toolCallId }),
- toolName,
- summary: summarizeArgs(content),
- status: isError ? "error" : "success",
- resultText: text,
- ...(content === undefined ? {} : { content }),
- ...(details === undefined ? {} : { details }),
- ...(preview === undefined ? {} : { preview }),
- };
- return [...messages, reconcileToolResultPresentation({ role: "tool", parts: [part] }, presentation)];
+ let finalized = updated;
+ if (updated === messages) {
+ const preview = previewFromDetails(details);
+ const part: ToolExecutionPart = {
+ type: "toolExecution",
+ ...(toolCallId === undefined || toolCallId === "" ? {} : { toolCallId }),
+ toolName,
+ summary: summarizeArgs(content),
+ status: isError ? "error" : "success",
+ resultText: text,
+ ...(content === undefined ? {} : { content }),
+ ...(details === undefined ? {} : { details }),
+ ...(preview === undefined ? {} : { preview }),
+ };
+ finalized = [...messages, reconcileToolResultPresentation({ role: "tool", parts: [part] }, presentation)];
+ }
+ return reconcileAskUserToolRecord(finalized, toolName, details, presentation.meta);
+}
+
+function reconcileAskUserToolRecord(messages: ChatLine[], toolName: string, details: unknown, meta: ChatLine["meta"] | undefined): ChatLine[] {
+ const record = askUserRecordFromToolDetails(toolName, details);
+ if (record === undefined) return messages;
+ for (let index = messages.length - 1; index >= 0; index--) {
+ const line = messages[index];
+ if (!lineHasAskUserRecord(line, record)) continue;
+ if (meta === undefined || line === undefined) return messages;
+ return [...messages.slice(0, index), { ...line, meta }, ...messages.slice(index + 1)];
+ }
+ return [...messages, { role: "tool", parts: [record], ...(meta === undefined ? {} : { meta }) }];
+}
+
+function lineHasAskUserRecord(
+ line: ChatLine | undefined,
+ record: Extract,
+): boolean {
+ return line?.parts.some((part) => part.type === "askUserRecord"
+ && part.outcome.askId === record.outcome.askId
+ && part.outcome.reason === record.outcome.reason) === true;
}
function updateToolExecution(
diff --git a/src/client/src/components/AskUserCard.test.ts b/src/client/src/components/AskUserCard.test.ts
new file mode 100644
index 0000000..31a3f3b
--- /dev/null
+++ b/src/client/src/components/AskUserCard.test.ts
@@ -0,0 +1,242 @@
+// @vitest-environment happy-dom
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { AskUserOutcome, AskUserQuestion, PendingAskUser } from "../../../shared/apiTypes";
+import { saveAskDraft } from "../askDrafts";
+import { AskUserCard, type AskUserSubmitCallback } from "./AskUserCard";
+
+afterEach(() => {
+ document.body.replaceChildren();
+ localStorage.clear();
+});
+
+describe("ask-user-card live form", () => {
+ it("uses native labelled groups and updates progress for a keyboard-focusable single select", async () => {
+ const card = await mountOpenAsk(openAsk([
+ question("editor", "Choose an editor", [option("vim", "Vim"), option("code", "VS Code")], { detail: "Used for examples." }),
+ question("platforms", "Target platforms", [option("web", "Web"), option("desktop", "Desktop")], { multiple: true }),
+ ]));
+ const root = renderRoot(card);
+ expect(card).toBeInstanceOf(HTMLElement);
+ expect(customElements.get("ask-user-card")).toBe(AskUserCard);
+ const fieldsets = root.querySelectorAll("fieldset");
+ const legends = root.querySelectorAll("legend");
+ const vim = inputWithValue(root, "vim");
+ const code = inputWithValue(root, "code");
+ const web = inputWithValue(root, "web");
+
+ expect(fieldsets).toHaveLength(2);
+ expect(legends[0]?.textContent).toContain("Choose an editor");
+ expect(fieldsets[0]?.getAttribute("aria-describedby")).toBe("ask-user-question-detail-0");
+ expect(root.querySelector("#ask-user-question-detail-0")?.textContent).toBe("Used for examples.");
+ expect(vim.type).toBe("radio");
+ expect(code.name).toBe(vim.name);
+ expect(web.type).toBe("checkbox");
+ expect(web.name).not.toBe(vim.name);
+ expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("0 of 2 answered");
+
+ // Focus and interaction run through the rendered native control rather than
+ // extracting Lit handlers, so this exercises the form's browser boundary.
+ vim.focus();
+ expect(root.activeElement).toBe(vim);
+ vim.click();
+ await card.updateComplete;
+
+ expect(vim.checked).toBe(true);
+ expect(code.checked).toBe(false);
+ expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("1 of 2 answered");
+ });
+
+ it("accumulates several checkbox values for a multi-select question", async () => {
+ const onSubmit = vi.fn();
+ const card = await mountOpenAsk(openAsk([
+ question("platforms", "Target platforms", [option("web", "Web"), option("desktop", "Desktop")], { multiple: true }),
+ ]), onSubmit);
+ const root = renderRoot(card);
+
+ inputWithValue(root, "web").click();
+ inputWithValue(root, "desktop").click();
+ await card.updateComplete;
+
+ expect(root.querySelector("[aria-live='polite']")?.textContent).toContain("1 of 1 answered");
+ buttonWithText(root, "Send answers").click();
+ await Promise.resolve();
+
+ expect(onSubmit).toHaveBeenCalledWith("ask-1", {
+ answers: [{ id: "platforms", values: ["web", "desktop"] }],
+ });
+ });
+
+ it("always offers and focuses a labelled custom field while preserving multi-select options", async () => {
+ const onSubmit = vi.fn();
+ const card = await mountOpenAsk(openAsk([
+ question("stack", "Pick the stack", [option("lit", "Lit"), option("react", "React")], { multiple: true }),
+ ]), onSubmit);
+ const root = renderRoot(card);
+
+ inputWithValue(root, "lit").click();
+ inputWithValue(root, "__pi_web_other__").click();
+ await card.updateComplete;
+ await Promise.resolve();
+
+ const textarea = requiredElement(root.querySelector("textarea"), "custom textarea");
+ const label = requiredElement(textarea.closest("label"), "custom label");
+ expect(label.textContent).toContain("Custom answer");
+ expect(getComputedStyle(textarea).fontSize).toBe("16px");
+ expect(root.activeElement).toBe(textarea);
+
+ textarea.value = "Svelte";
+ textarea.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
+ await card.updateComplete;
+ buttonWithText(root, "Send answers").click();
+ await Promise.resolve();
+
+ expect(onSubmit).toHaveBeenCalledWith("ask-1", {
+ answers: [{ id: "stack", values: ["lit"], otherText: "Svelte" }],
+ });
+ });
+
+ it("shows and submits the custom field directly when no options were supplied", async () => {
+ const onSubmit = vi.fn();
+ const card = await mountOpenAsk(openAsk([
+ question("notes", "Anything else?", []),
+ ]), onSubmit);
+ const root = renderRoot(card);
+ const textarea = requiredElement(root.querySelector("textarea"), "custom textarea");
+
+ expect(root.querySelector("input")).toBeNull();
+ expect(requiredElement(textarea.closest("label"), "custom label").textContent).toContain("Custom answer");
+ textarea.value = "Keep the first version small.";
+ textarea.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
+ await card.updateComplete;
+ buttonWithText(root, "Send answers").click();
+ await Promise.resolve();
+
+ expect(onSubmit).toHaveBeenCalledWith("ask-1", {
+ answers: [{ id: "notes", values: [], otherText: "Keep the first version small." }],
+ });
+ });
+
+ it("names unanswered questions before allowing a partial submit", async () => {
+ const onSubmit = vi.fn();
+ const card = await mountOpenAsk(openAsk([
+ question("editor", "Choose an editor", [option("vim", "Vim")]),
+ question("deploy", "Choose a deployment target", [option("cloud", "Cloud")]),
+ question("notes", "Add implementation notes", []),
+ ]), onSubmit);
+ const root = renderRoot(card);
+
+ inputWithValue(root, "vim").click();
+ await card.updateComplete;
+ buttonWithText(root, "Send answers").click();
+ await card.updateComplete;
+ await Promise.resolve();
+
+ const confirmation = requiredElement(root.querySelector("[aria-label='Confirm partial answers']"), "partial confirmation");
+ expect(confirmation.textContent).toContain("Send without answering:");
+ expect(confirmation.textContent).toContain("Choose a deployment target");
+ expect(confirmation.textContent).toContain("Add implementation notes");
+ expect(onSubmit).not.toHaveBeenCalled();
+ expect(root.activeElement).toBe(buttonWithText(root, "Send anyway"));
+
+ buttonWithText(root, "Send anyway").click();
+ await Promise.resolve();
+ expect(onSubmit).toHaveBeenCalledWith("ask-1", {
+ answers: [{ id: "editor", values: ["vim"] }],
+ });
+ });
+});
+
+describe("ask-user-card record mode", () => {
+ it("has no answer controls and displays draft answers retained for a superseded ask", async () => {
+ const draftSessionId = "remote-a:session-1";
+ saveAskDraft(draftSessionId, "ask-old", {
+ speed: { values: ["fast"] },
+ rationale: { values: [], otherText: "It keeps the feedback loop short." },
+ });
+ const outcome: AskUserOutcome = {
+ askId: "ask-old",
+ reason: "superseded",
+ askedAt: "2026-07-20T10:00:00.000Z",
+ closedAt: "2026-07-20T10:05:00.000Z",
+ questions: [
+ unansweredRecord(question("speed", "Preferred pace", [option("fast", "Fast"), option("careful", "Careful")])),
+ unansweredRecord(question("rationale", "Why?", [])),
+ unansweredRecord(question("region", "Deployment region", [option("eu", "Europe")])),
+ ],
+ answeredCount: 0,
+ unansweredIds: ["speed", "rationale", "region"],
+ summary: "Answered 0 of 3; unanswered: speed, rationale, region",
+ };
+ const card = new AskUserCard();
+ card.draftSessionId = draftSessionId;
+ card.outcome = outcome;
+ document.body.append(card);
+ await card.updateComplete;
+ const root = renderRoot(card);
+
+ expect(root.querySelector("input, textarea, button, select")).toBeNull();
+ expect(root.textContent).toContain("Superseded");
+ expect(root.textContent).toContain("Fast");
+ expect(root.textContent).toContain("It keeps the feedback loop short.");
+ expect(root.textContent).toContain("Draft answer · not sent");
+ expect(root.textContent).toContain("Deployment region");
+ expect(root.textContent).toContain("Unanswered");
+ });
+});
+
+async function mountOpenAsk(ask: PendingAskUser, onSubmit?: AskUserSubmitCallback): Promise {
+ const card = new AskUserCard();
+ card.ask = ask;
+ card.draftSessionId = "local:session-1";
+ if (onSubmit !== undefined) card.onSubmit = onSubmit;
+ document.body.append(card);
+ await card.updateComplete;
+ return card;
+}
+
+function renderRoot(card: AskUserCard): ShadowRoot {
+ return requiredElement(card.shadowRoot, "ask-user-card shadow root");
+}
+
+function inputWithValue(root: ShadowRoot, value: string): HTMLInputElement {
+ const input = [...root.querySelectorAll("input")].find((candidate) => candidate.value === value);
+ return requiredElement(input, `input with value ${value}`);
+}
+
+function buttonWithText(root: ShadowRoot, text: string): HTMLButtonElement {
+ const button = [...root.querySelectorAll("button")].find((candidate) => candidate.textContent.trim() === text);
+ return requiredElement(button, `button named ${text}`);
+}
+
+function requiredElement(value: T | null | undefined, label: string): T {
+ if (value === null || value === undefined) throw new Error(`Expected ${label}`);
+ return value;
+}
+
+function openAsk(questions: AskUserQuestion[]): PendingAskUser {
+ return { askId: "ask-1", askedAt: "2026-07-20T10:00:00.000Z", questions };
+}
+
+function question(
+ id: string,
+ text: string,
+ options: AskUserQuestion["options"],
+ settings: { detail?: string; multiple?: boolean } = {},
+): AskUserQuestion {
+ return {
+ id,
+ question: text,
+ options,
+ ...(settings.detail === undefined ? {} : { detail: settings.detail }),
+ ...(settings.multiple === undefined ? {} : { multiple: settings.multiple }),
+ };
+}
+
+function option(value: string, label: string): AskUserQuestion["options"][number] {
+ return { value, label };
+}
+
+function unansweredRecord(questionValue: AskUserQuestion): AskUserOutcome["questions"][number] {
+ return { question: questionValue, answered: false, values: [] };
+}
diff --git a/src/client/src/components/AskUserCard.ts b/src/client/src/components/AskUserCard.ts
new file mode 100644
index 0000000..599123f
--- /dev/null
+++ b/src/client/src/components/AskUserCard.ts
@@ -0,0 +1,555 @@
+import { LitElement, css, html, type PropertyValues, type TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { ifDefined } from "lit/directives/if-defined.js";
+import {
+ ASK_USER_OTHER_TEXT_MAX_LENGTH,
+ type AskUserOutcome,
+ type AskUserQuestion,
+ type AskUserQuestionRecord,
+ type AskUserSubmission,
+ type PendingAskUser,
+} from "../../../shared/apiTypes";
+import {
+ answeredCount,
+ loadAskDraft,
+ saveAskDraft,
+ toSubmission,
+ unansweredQuestions,
+ type AskDraftAnswer,
+ type AskDraftAnswers,
+} from "../askDrafts";
+
+export type AskUserSubmitCallback = (askId: string, submission: AskUserSubmission) => void | Promise;
+
+interface DisplayedRecordAnswer {
+ values: string[];
+ otherText?: string;
+ fromDraft: boolean;
+}
+
+/**
+ * One question set posted by `ask_user`.
+ *
+ * The live mode owns only browser-local draft state; the daemon remains the
+ * source of truth for whether the ask is open. Record mode consumes the closed
+ * daemon outcome and, for a superseded ask, can recover the unsent local draft
+ * so text the user had entered is not silently hidden.
+ */
+@customElement("ask-user-card")
+export class AskUserCard extends LitElement {
+ @property({ attribute: false }) ask?: PendingAskUser;
+ @property({ attribute: false }) outcome?: AskUserOutcome;
+ /** Machine-scoped session cache key used by the ask draft store. */
+ @property({ attribute: false }) draftSessionId = "";
+ @property({ attribute: false }) onSubmit?: AskUserSubmitCallback;
+
+ @state() private answers: AskDraftAnswers = {};
+ @state() private confirmingPartialSubmit = false;
+ @state() private submitting = false;
+ private modelIdentity: string | undefined;
+
+ protected override willUpdate(changed: PropertyValues): void {
+ if (!changed.has("ask") && !changed.has("outcome") && !changed.has("draftSessionId")) return;
+ const identity = this.currentModelIdentity();
+ if (identity === this.modelIdentity) return;
+ this.modelIdentity = identity;
+ this.answers = this.loadCurrentDraft();
+ this.confirmingPartialSubmit = false;
+ this.submitting = false;
+ }
+
+ override render(): TemplateResult | null {
+ if (this.outcome !== undefined) return this.renderRecord(this.outcome);
+ if (this.ask !== undefined) return this.renderOpenAsk(this.ask);
+ return null;
+ }
+
+ private renderOpenAsk(ask: PendingAskUser): TemplateResult {
+ const count = answeredCount(ask.questions, this.answers);
+ const unanswered = unansweredQuestions(ask.questions, this.answers);
+ return html`
+
+
+
+ ${outcome.reason === "superseded"
+ ? "A newer question set replaced this one. Draft answers shown below were not sent to the model."
+ : outcome.summary}
+
+
+ Allow agents to ask questions
+ ${askUserOverridden ? html`environment override` : null}
+
+
+ ${askUserSupported
+ ? html`Agents can post a structured question form and pause until the user responds. On by default.`
+ : html`This machine does not expose the Ask Questions setting. Update and restart PI WEB on that machine to configure it.`}
+
Desired after environment overrides
@@ -151,6 +175,7 @@ export class SettingsSessiondPanel extends LitElement {