Archived
Merge pull request #103 from jmfederico/feat/model-questions-ux
feat(sessions): add ask_user question forms
This commit is contained in:
@@ -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`.
|
||||
+49
-4
@@ -167,13 +167,13 @@
|
||||
Environment overrides include <code>PI_WEB_HOST</code>, <code>PI_WEB_PORT</code> / <code>PORT</code>,
|
||||
<code>PI_WEB_ALLOWED_HOSTS</code>, <code>PI_WEB_MAX_UPLOAD_BYTES</code>, <code>PI_WEB_AGENT_COMMAND</code>,
|
||||
<code>PI_WEB_AGENT_DIR</code>, <code>PI_WEB_AGENT_SESSION_DIR</code>, <code>PI_CODING_AGENT_DIR</code> /
|
||||
<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility, <code>PI_WEB_SPAWN_SESSIONS</code>, and
|
||||
<code>PI_WEB_SUBSESSIONS</code>.
|
||||
<code>PI_CODING_AGENT_SESSION_DIR</code> for Pi compatibility, <code>PI_WEB_SPAWN_SESSIONS</code>,
|
||||
<code>PI_WEB_SUBSESSIONS</code>, and <code>PI_WEB_ASK_USER</code>.
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
|
||||
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
|
||||
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code>: restart the session daemon on that machine.</li>
|
||||
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code> / <code>askUser</code>: restart the session daemon on that machine.</li>
|
||||
<li><code>pathAccess</code>: applies on the next request; existing file views may need a browser refresh.</li>
|
||||
<li><code>uploads.defaultFolder</code>: applies to newly opened Files upload dialogs and new direct drag/drop batches after config/workspace refresh.</li>
|
||||
<li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
|
||||
@@ -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
|
||||
<code>—</code> are runtime-only environment variables, not config-file keys. <code>Global</code> means
|
||||
machine-global. In Settings, selected-machine-safe global keys (<code>pathAccess</code>, <code>uploads</code>,
|
||||
<code>maxUploadBytes</code>, <code>agent</code>, <code>spawnSessions</code>, <code>subsessions</code>, and <code>plugins</code>)
|
||||
<code>maxUploadBytes</code>, <code>agent</code>, <code>spawnSessions</code>, <code>subsessions</code>, <code>askUser</code>, and <code>plugins</code>)
|
||||
are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine
|
||||
registry/tokens stay local.
|
||||
</p>
|
||||
@@ -367,6 +368,14 @@
|
||||
<td>Not supported locally; also requires <code>spawnSessions</code></td>
|
||||
<td>Restart session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Agent can post question forms</td>
|
||||
<td><code>askUser</code></td>
|
||||
<td><code>PI_WEB_ASK_USER</code></td>
|
||||
<td>Global/session daemon</td>
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart session daemon on that machine</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>PI WEB plugin enablement/settings</td>
|
||||
<td><code>plugins.<id>.enabled</code>, <code>plugins.<id>.settings</code></td>
|
||||
@@ -791,6 +800,42 @@
|
||||
session daemon on that machine after changing them.
|
||||
</p>
|
||||
<p>Environment override: <code>PI_WEB_SUBSESSIONS=0|1|true|false</code>.</p>
|
||||
|
||||
<h3><code>askUser</code> and <code>ask_user</code></h3>
|
||||
<p>
|
||||
<code>askUser</code> controls whether agents receive the core <code>ask_user</code> tool. It defaults to
|
||||
<code>true</code>; set it to <code>false</code>, or set <code>PI_WEB_ASK_USER=false</code>, to remove the
|
||||
tool. The environment override accepts <code>0|1|true|false</code> and takes precedence over the config file.
|
||||
</p>
|
||||
<p>
|
||||
Use <strong>Settings → Session daemon → Allow agents to ask questions</strong> to change
|
||||
<code>askUser</code> on the selected machine. An environment override makes the toggle read-only.
|
||||
</p>
|
||||
<p>
|
||||
The tool accepts one set of 1–20 questions. Each question has a unique <code>id</code>, its
|
||||
<code>question</code> text, optional supporting <code>detail</code>, up to 12 options with stable values and
|
||||
user-facing labels, and an optional <code>multiple</code> flag. The browser always adds a
|
||||
<strong>Custom</strong> free-text answer, including when the model supplies no options. No question is
|
||||
required: the user may leave any of them unanswered.
|
||||
</p>
|
||||
<p>
|
||||
Calling <code>ask_user</code> 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.
|
||||
</p>
|
||||
<p>
|
||||
PI WEB confirms a partial submission before sending it and names the unanswered questions. Only one ask
|
||||
can be open per session: a later <code>ask_user</code> 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.
|
||||
</p>
|
||||
<div class="callout warning">
|
||||
<strong>Restart required:</strong> restart the session daemon after changing <code>askUser</code> or after
|
||||
upgrading PI WEB to a version that introduces this tool. For the systemd user service, run
|
||||
<code>systemctl --user restart pi-web-sessiond</code>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
+19
-3
@@ -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, `<project>/.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.<id>.enabled`, `plugins.<id>.settings` | — | Global | Not core local config; plugins may read their own project files | Reload browser tab |
|
||||
| Keyboard shortcuts | `shortcuts.<actionId>` | — | 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.
|
||||
|
||||
Generated
+60
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import type { AskUserSubmission, DeleteWorkspaceFileResponse, FileSuggestion, MoveWorkspaceFileOptions, PiPackageInstallRequest, PiPackageRemoveRequest, PiPackageScope, PiPackageUpdateRequest, PiWebConfigValues, PromptAttachment, RunTerminalCommandInput, SessionBulkMutationRef, SessionCleanupRequest, SessionNotificationDismissThrough, SessionRef, SessionTreeNavigateRequest, SessionUnreadAcknowledgeRequest, TerminalCommandRun, TerminalCommandRunFilter, WriteWorkspaceFileOptions } from "../../../shared/apiTypes";
|
||||
import { resolveAppUrl } from "../appUrl";
|
||||
import { request } from "./http";
|
||||
import {
|
||||
arrayOf,
|
||||
parseAborted,
|
||||
parseAskUserCloseResponse,
|
||||
parseAccepted,
|
||||
parseArchived,
|
||||
parseAuthProvidersResponse,
|
||||
@@ -224,6 +225,8 @@ export const sessionsApi = {
|
||||
streamSnapshot: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "stream-snapshot", machineId), parseSessionStreamSnapshot),
|
||||
clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
|
||||
dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }),
|
||||
submitAsk: (session: SessionLookup, askId: string, submission: AskUserSubmission, machineId = "local") => request(sessionPath(session, "ask/submit", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId, answers: submission.answers }) }),
|
||||
cancelAsk: (session: SessionLookup, askId: string, machineId = "local") => request(sessionPath(session, "ask/cancel", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId }) }),
|
||||
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
|
||||
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
|
||||
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
|
||||
|
||||
@@ -36,6 +36,14 @@ describe("federated route contract", () => {
|
||||
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("notifications"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allowlists both ask routes without adding an ask WebSocket", () => {
|
||||
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("/ask/"))).toEqual([
|
||||
{ method: "POST", path: "/sessions/:sessionId/ask/submit" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/ask/cancel" },
|
||||
]);
|
||||
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("ask"))).toBe(false);
|
||||
});
|
||||
|
||||
it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => {
|
||||
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([
|
||||
{ method: "GET", path: "/sessions/unread" },
|
||||
@@ -96,6 +104,9 @@ describe("federated route contract", () => {
|
||||
ignoreParseFailure(sessionsApi.status(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.streamSnapshot(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.clearQueue(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.dismissWarning(session, "anthropicExtraUsage", machineId)),
|
||||
ignoreParseFailure(sessionsApi.submitAsk(session, "ask 1", { answers: [{ id: "q1", values: ["pg"] }] }, machineId)),
|
||||
ignoreParseFailure(sessionsApi.cancelAsk(session, "ask 1", machineId)),
|
||||
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
||||
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
|
||||
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<SessionStatus, "warnings"> | obj
|
||||
return { warnings: arrayOf(parseSessionWarning)(value) };
|
||||
}
|
||||
|
||||
function parseAskUserQuestionOption(value: unknown): AskUserQuestionOption {
|
||||
const record = requireRecord(value);
|
||||
return {
|
||||
value: requireBoundedNonEmptyString(record, "value", ASK_USER_ID_MAX_LENGTH),
|
||||
label: requireBoundedNonEmptyString(record, "label", ASK_USER_TEXT_MAX_LENGTH),
|
||||
...optionalField("detail", optionalBoundedNonEmptyString(record, "detail", ASK_USER_TEXT_MAX_LENGTH)),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAskUserQuestion(value: unknown): AskUserQuestion {
|
||||
const record = requireRecord(value);
|
||||
const options = boundedArrayOf(record["options"], parseAskUserQuestionOption, ASK_USER_OPTION_LIMIT, "options");
|
||||
assertUniqueStrings(options.map((option) => option.value), "ask option value");
|
||||
// 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<SessionStatus, "pendingAsk"> | object {
|
||||
if (value === undefined) return {};
|
||||
return { pendingAsk: parsePendingAskUser(value) };
|
||||
}
|
||||
|
||||
export function parseSessionAskOpenedEvent(value: unknown): { type: "ask.opened"; ask: PendingAskUser } {
|
||||
const record = requireRecord(value);
|
||||
if (record["type"] !== "ask.opened") throw new Error("Invalid ask opened event type");
|
||||
return { type: "ask.opened", ask: parsePendingAskUser(record["ask"]) };
|
||||
}
|
||||
|
||||
export function parseSessionAskClosedEvent(value: unknown): { type: "ask.closed"; askId: string; reason: AskUserCloseReason } {
|
||||
const record = requireRecord(value);
|
||||
if (record["type"] !== "ask.closed") throw new Error("Invalid ask closed event type");
|
||||
return {
|
||||
type: "ask.closed",
|
||||
askId: requireBoundedNonEmptyString(record, "askId", ASK_USER_ID_MAX_LENGTH),
|
||||
reason: parseAskUserCloseReason(record["reason"]),
|
||||
};
|
||||
}
|
||||
|
||||
function parseAskUserCloseReason(value: unknown): AskUserCloseReason {
|
||||
if (value !== "submitted" && value !== "superseded" && value !== "cancelled") throw new Error("Invalid ask close reason");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseAskUserQuestionRecord(value: unknown): AskUserQuestionRecord {
|
||||
const record = requireRecord(value);
|
||||
const question = parseAskUserQuestion(record["question"]);
|
||||
const values = boundedArrayOf(record["values"], parseNonEmptyString, ASK_USER_OPTION_LIMIT, "values");
|
||||
const offered = new Set(question.options.map((option) => option.value));
|
||||
if (values.some((selected) => !offered.has(selected))) throw new Error("Ask answer selected an option the question never offered");
|
||||
const otherText = optionalBoundedNonEmptyString(record, "otherText", ASK_USER_OTHER_TEXT_MAX_LENGTH);
|
||||
const answered = requireBoolean(record, "answered");
|
||||
// The record is the one thing both the model and the user read, so a flag that
|
||||
// disagrees with the answer it describes is rejected rather than displayed.
|
||||
if (answered !== (values.length > 0 || otherText !== undefined)) throw new Error("Ask answer contradicts its answered flag");
|
||||
return { question, answered, values, ...(otherText === undefined ? {} : { otherText }) };
|
||||
}
|
||||
|
||||
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<string, unknown>, key: stri
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoundedNonEmptyString(record: Record<string, unknown>, key: string, maxLength: number): string | undefined {
|
||||
const value = optionalString(record, key);
|
||||
if (value === undefined) return undefined;
|
||||
if (value === "") throw new Error(`Expected non-empty string field: ${key}`);
|
||||
if (value.length > maxLength) throw new Error(`String field exceeds limit: ${key}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveSafeInteger(record: Record<string, unknown>, key: string): number {
|
||||
const value = requireNonNegativeSafeInteger(record, key);
|
||||
if (value === 0) throw new Error(`Expected positive safe integer field: ${key}`);
|
||||
@@ -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),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PendingAskUser, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
|
||||
import type { ChatLine } from "./components/shared";
|
||||
import type { QualifiedContributionId } from "./plugins/ids";
|
||||
import type { SelectedSessionNotificationInbox } from "./sessionNotifications";
|
||||
@@ -31,6 +31,12 @@ export interface AppState {
|
||||
selectedSession: SessionInfo | undefined;
|
||||
status: SessionStatus | undefined;
|
||||
activity: SessionActivity | undefined;
|
||||
/**
|
||||
* The selected session's open `ask_user` question set, derived from the
|
||||
* daemon-owned {@link SessionStatus.pendingAsk} plus live ask events, and
|
||||
* dropped when the machine reports no `sessions.askUser` support.
|
||||
*/
|
||||
pendingAsk: PendingAskUser | undefined;
|
||||
/** Thinking levels available for the selected session's current model. */
|
||||
availableThinkingLevels: readonly string[];
|
||||
sessionStatuses: Record<string, SessionStatus>;
|
||||
@@ -144,6 +150,7 @@ export function initialAppState(): AppState {
|
||||
selectedSession: undefined,
|
||||
status: undefined,
|
||||
activity: undefined,
|
||||
pendingAsk: undefined,
|
||||
availableThinkingLevels: [],
|
||||
sessionStatuses: {},
|
||||
sessionActivities: {},
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
get length(): number {
|
||||
return this.values.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.values.clear();
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.values.keys())[index] ?? null;
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingStorage extends MemoryStorage {
|
||||
override getItem(): string | null {
|
||||
throw new Error("storage blocked");
|
||||
}
|
||||
|
||||
override setItem(): void {
|
||||
throw new Error("storage blocked");
|
||||
}
|
||||
|
||||
override removeItem(): void {
|
||||
throw new Error("storage blocked");
|
||||
}
|
||||
}
|
||||
|
||||
const singleSelect: AskUserQuestion = {
|
||||
id: "q1",
|
||||
question: "Which database?",
|
||||
options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }],
|
||||
};
|
||||
|
||||
const 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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ASK_USER_OTHER_TEXT_MAX_LENGTH, type AskUserAnswer, type AskUserQuestion, type AskUserSubmission } from "../../shared/apiTypes";
|
||||
|
||||
/**
|
||||
* What the user has entered for one question but has not submitted yet. Kept in
|
||||
* the browser because the daemon owns "there is an open ask" while the browser
|
||||
* owns "what I have typed so far".
|
||||
*/
|
||||
export interface AskDraftAnswer {
|
||||
values: string[];
|
||||
otherText?: string;
|
||||
}
|
||||
|
||||
/** Draft answers of one ask, keyed by question id. */
|
||||
export type AskDraftAnswers = Record<string, AskDraftAnswer>;
|
||||
|
||||
const draftStoragePrefix = "pi-web:ask-draft:";
|
||||
|
||||
function draftStorageKey(sessionId: string, askId: string): string {
|
||||
return `${draftStoragePrefix}${sessionId}:${askId}`;
|
||||
}
|
||||
|
||||
function browserStorage(): Storage | undefined {
|
||||
try {
|
||||
return typeof localStorage === "undefined" ? undefined : localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored draft. Any unreadable or malformed payload yields an empty
|
||||
* draft: a half-typed answer set is a convenience, never a reason to fail
|
||||
* rendering the questions.
|
||||
*/
|
||||
export function loadAskDraft(sessionId: string, askId: string, storage = browserStorage()): AskDraftAnswers {
|
||||
try {
|
||||
const stored = storage?.getItem(draftStorageKey(sessionId, askId));
|
||||
return stored === null || stored === undefined ? {} : draftAnswersFromJson(stored);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveAskDraft(sessionId: string, askId: string, answers: AskDraftAnswers, storage = browserStorage()): void {
|
||||
try {
|
||||
const entries = Object.entries(answers).filter(([, answer]) => answer.values.length > 0 || (answer.otherText ?? "") !== "");
|
||||
if (entries.length === 0) storage?.removeItem(draftStorageKey(sessionId, askId));
|
||||
else storage?.setItem(draftStorageKey(sessionId, askId), JSON.stringify(Object.fromEntries(entries)));
|
||||
} catch {
|
||||
// Ignore localStorage quota/privacy errors.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAskDraft(sessionId: string, askId: string, storage = browserStorage()): void {
|
||||
try {
|
||||
storage?.removeItem(draftStorageKey(sessionId, askId));
|
||||
} catch {
|
||||
// Ignore localStorage quota/privacy errors.
|
||||
}
|
||||
}
|
||||
|
||||
function draftAnswersFromJson(stored: string): AskDraftAnswers {
|
||||
const parsed: unknown = JSON.parse(stored);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
||||
const answers: AskDraftAnswers = {};
|
||||
for (const [id, value] of Object.entries(parsed)) {
|
||||
const answer = draftAnswerFromValue(value);
|
||||
if (answer !== undefined) answers[id] = answer;
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
function draftAnswerFromValue(value: unknown): AskDraftAnswer | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
const record: Record<string, unknown> = { ...value };
|
||||
const values = record["values"];
|
||||
const otherText = record["otherText"];
|
||||
if (!Array.isArray(values) || !values.every((entry) => typeof entry === "string")) return undefined;
|
||||
if (otherText !== undefined && typeof otherText !== "string") return undefined;
|
||||
return { values: [...values], ...(otherText === undefined ? {} : { otherText }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* How many questions the draft currently answers. A question counts as answered
|
||||
* exactly when {@link toSubmission} would send an answer for it, so the progress
|
||||
* the user reads matches what the model is told.
|
||||
*/
|
||||
export function answeredCount(questions: readonly AskUserQuestion[], answers: AskDraftAnswers): number {
|
||||
return questions.filter((question) => submittableAnswer(question, answers[question.id]) !== undefined).length;
|
||||
}
|
||||
|
||||
/** The questions left untouched, in the order they were asked. */
|
||||
export function unansweredQuestions(questions: readonly AskUserQuestion[], answers: AskDraftAnswers): AskUserQuestion[] {
|
||||
return questions.filter((question) => submittableAnswer(question, answers[question.id]) === undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* The submission for the current draft: one answer per answered question, and
|
||||
* nothing for the untouched ones, since an empty answer and an untouched
|
||||
* question mean the same thing to the daemon.
|
||||
*/
|
||||
export function toSubmission(questions: readonly AskUserQuestion[], answers: AskDraftAnswers): AskUserSubmission {
|
||||
const submitted: AskUserAnswer[] = [];
|
||||
for (const question of questions) {
|
||||
const answer = submittableAnswer(question, answers[question.id]);
|
||||
if (answer !== undefined) submitted.push(answer);
|
||||
}
|
||||
return { answers: submitted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one draft entry against the question it answers, or `undefined` when
|
||||
* it says nothing. The draft is browser-local storage that a previous version of
|
||||
* the app, another tab, or a user could have left in a shape the question no
|
||||
* longer accepts, so values the question does not offer are dropped and a
|
||||
* single-select question keeps only its first selection rather than sending a
|
||||
* submission the daemon would reject as a whole.
|
||||
*/
|
||||
function submittableAnswer(question: AskUserQuestion, answer: AskDraftAnswer | undefined): AskUserAnswer | undefined {
|
||||
if (answer === undefined) return undefined;
|
||||
const offered = new Set(question.options.map((option) => option.value));
|
||||
const values = [...new Set(answer.values)].filter((value) => offered.has(value));
|
||||
const otherText = normalizedOtherText(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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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" } }] },
|
||||
|
||||
@@ -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<ChatPart, { type: "askUserRecord" }> | 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<ChatPart, { type: "askUserRecord" }> | undefined {
|
||||
if (toolName !== "ask_user") return undefined;
|
||||
return parsedAskUserRecord(getProperty(details, "superseded"));
|
||||
}
|
||||
|
||||
function parsedAskUserRecord(value: unknown): Extract<ChatPart, { type: "askUserRecord" }> | 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<ChatPart, { type: "toolResult" }> {
|
||||
const toolCallId = getString(message, "toolCallId");
|
||||
const content = getProperty(message, "content");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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["parts"][number], { type: "askUserRecord" }>,
|
||||
): 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<ChatLine["parts"][number], { type: "askUserRecord" }>,
|
||||
): boolean {
|
||||
return line?.parts.some((part) => part.type === "askUserRecord"
|
||||
&& part.outcome.askId === record.outcome.askId
|
||||
&& part.outcome.reason === record.outcome.reason) === true;
|
||||
}
|
||||
|
||||
function updateToolExecution(
|
||||
|
||||
@@ -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<AskUserSubmitCallback>();
|
||||
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<AskUserSubmitCallback>();
|
||||
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<AskUserSubmitCallback>();
|
||||
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<AskUserSubmitCallback>();
|
||||
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<AskUserCard> {
|
||||
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<T>(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: [] };
|
||||
}
|
||||
@@ -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<void>;
|
||||
|
||||
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<this>): 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`
|
||||
<article class="card open-card" aria-labelledby="ask-user-heading">
|
||||
<header class="card-header">
|
||||
<h2 id="ask-user-heading">Questions</h2>
|
||||
<span class="header-status" role="status" aria-live="polite" aria-atomic="true">
|
||||
${count} of ${ask.questions.length} answered
|
||||
</span>
|
||||
</header>
|
||||
<form class="ask-form" @submit=${(event: SubmitEvent) => { this.handleSubmit(event, ask); }}>
|
||||
<div class="questions">
|
||||
${ask.questions.map((question, index) => this.renderQuestion(ask, question, index))}
|
||||
</div>
|
||||
<footer class="form-footer">
|
||||
${this.confirmingPartialSubmit && unanswered.length > 0
|
||||
? this.renderPartialSubmitConfirmation(ask, unanswered)
|
||||
: html`
|
||||
<button class="primary-action" type="submit" ?disabled=${this.submitting}>
|
||||
${this.submitting ? "Sending…" : "Send answers"}
|
||||
</button>
|
||||
`}
|
||||
</footer>
|
||||
</form>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderQuestion(ask: PendingAskUser, question: AskUserQuestion, index: number): TemplateResult {
|
||||
const answer = this.answers[question.id];
|
||||
const answered = answeredCount([question], this.answers) === 1;
|
||||
const detailId = question.detail === undefined ? undefined : this.questionDetailId(index);
|
||||
const freeTextOnly = question.options.length === 0;
|
||||
const customSelected = freeTextOnly || this.isOtherSelected(question, answer);
|
||||
const inputType = question.multiple === true ? "checkbox" : "radio";
|
||||
return html`
|
||||
<fieldset
|
||||
id=${this.questionFieldsetId(index)}
|
||||
class=${`question${answered ? " answered" : ""}`}
|
||||
aria-describedby=${ifDefined(detailId)}
|
||||
tabindex="-1"
|
||||
>
|
||||
<legend>
|
||||
<span class="question-number">${String(index + 1)}.</span>
|
||||
<span>${question.question}</span>
|
||||
</legend>
|
||||
${question.detail === undefined ? null : html`<p class="question-detail" id=${detailId}>${question.detail}</p>`}
|
||||
<div class="options">
|
||||
${question.options.map((option) => html`
|
||||
<label class="option">
|
||||
<input
|
||||
type=${inputType}
|
||||
name=${this.questionGroupName(ask, question)}
|
||||
value=${option.value}
|
||||
.checked=${answer?.values.includes(option.value) === true}
|
||||
@change=${(event: Event) => { this.changeOption(question, option.value, event); }}
|
||||
/>
|
||||
<span class="option-copy">
|
||||
<span class="option-label">${option.label}</span>
|
||||
${option.detail === undefined ? null : html`<span class="option-detail">${option.detail}</span>`}
|
||||
</span>
|
||||
</label>
|
||||
`)}
|
||||
${freeTextOnly ? null : html`
|
||||
<label class="option other-option">
|
||||
<input
|
||||
type=${inputType}
|
||||
name=${this.questionGroupName(ask, question)}
|
||||
value="__pi_web_other__"
|
||||
.checked=${customSelected}
|
||||
@change=${(event: Event) => { this.changeOther(question, index, event); }}
|
||||
/>
|
||||
<span class="option-copy"><span class="option-label">Custom</span></span>
|
||||
</label>
|
||||
`}
|
||||
${customSelected ? html`
|
||||
<label class="other-answer" for=${this.otherInputId(index)}>
|
||||
<span>Custom answer</span>
|
||||
<textarea
|
||||
id=${this.otherInputId(index)}
|
||||
rows="3"
|
||||
maxlength=${String(ASK_USER_OTHER_TEXT_MAX_LENGTH)}
|
||||
.value=${answer?.otherText ?? ""}
|
||||
@input=${(event: Event) => { this.changeOtherText(question, event); }}
|
||||
></textarea>
|
||||
</label>
|
||||
` : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPartialSubmitConfirmation(ask: PendingAskUser, unanswered: AskUserQuestion[]): TemplateResult {
|
||||
return html`
|
||||
<div class="partial-confirmation" role="group" aria-label="Confirm partial answers">
|
||||
<p>
|
||||
<strong>Send without answering:</strong>
|
||||
${unanswered.map((question, index) => html`${index === 0 ? " " : ", "}<button
|
||||
class="question-jump"
|
||||
type="button"
|
||||
@click=${() => { this.focusQuestion(ask.questions.indexOf(question)); }}
|
||||
>${question.question}</button>`)}?
|
||||
</p>
|
||||
<div class="confirmation-actions">
|
||||
<button class="secondary-action" type="button" @click=${() => { this.keepEditing(ask, unanswered); }}>Keep editing</button>
|
||||
<button class="primary-action send-anyway" type="button" ?disabled=${this.submitting} @click=${() => { this.submitAnswers(ask); }}>
|
||||
${this.submitting ? "Sending…" : "Send anyway"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRecord(outcome: AskUserOutcome): TemplateResult {
|
||||
const recordLabel = outcome.reason === "submitted"
|
||||
? "Answers sent"
|
||||
: outcome.reason === "superseded"
|
||||
? "Superseded"
|
||||
: "Cancelled";
|
||||
return html`
|
||||
<article class="card record-card" aria-labelledby="ask-user-record-heading">
|
||||
<header class="card-header">
|
||||
<h2 id="ask-user-record-heading">Questions</h2>
|
||||
<span class=${`header-status ${outcome.reason}`}>${recordLabel}</span>
|
||||
</header>
|
||||
<p class="record-summary">
|
||||
${outcome.reason === "superseded"
|
||||
? "A newer question set replaced this one. Draft answers shown below were not sent to the model."
|
||||
: outcome.summary}
|
||||
</p>
|
||||
<div class="record-questions">
|
||||
${outcome.questions.map((record, index) => this.renderQuestionRecord(outcome, record, index))}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderQuestionRecord(outcome: AskUserOutcome, record: AskUserQuestionRecord, index: number): TemplateResult {
|
||||
const answer = this.displayedRecordAnswer(outcome, record);
|
||||
return html`
|
||||
<section class="record-question" aria-labelledby=${this.recordQuestionHeadingId(index)}>
|
||||
<h3 id=${this.recordQuestionHeadingId(index)}>
|
||||
<span class="question-number">${String(index + 1)}.</span>
|
||||
${record.question.question}
|
||||
</h3>
|
||||
${record.question.detail === undefined ? null : html`<p class="question-detail">${record.question.detail}</p>`}
|
||||
${answer === undefined
|
||||
? html`<p class="unanswered-record">Unanswered</p>`
|
||||
: html`
|
||||
<ul class="record-answers">
|
||||
${answer.values.map((value) => html`<li>${this.optionLabel(record.question, value)}</li>`)}
|
||||
${answer.otherText === undefined ? null : html`<li><strong>Custom:</strong> <span class="other-record-text">${answer.otherText}</span></li>`}
|
||||
</ul>
|
||||
${answer.fromDraft ? html`<p class="draft-note">Draft answer · not sent</p>` : null}
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private changeOption(question: AskUserQuestion, value: string, event: Event): void {
|
||||
const input = event.currentTarget;
|
||||
if (!(input instanceof HTMLInputElement)) return;
|
||||
if (question.multiple !== true) {
|
||||
if (input.checked) this.setAnswer(question, { values: [value] });
|
||||
return;
|
||||
}
|
||||
const current = this.answers[question.id];
|
||||
const values = input.checked
|
||||
? [...new Set([...(current?.values ?? []), value])]
|
||||
: (current?.values ?? []).filter((selected) => selected !== value);
|
||||
this.setAnswer(question, {
|
||||
values,
|
||||
...(current?.otherText === undefined ? {} : { otherText: current.otherText }),
|
||||
});
|
||||
}
|
||||
|
||||
private changeOther(question: AskUserQuestion, index: number, event: Event): void {
|
||||
const input = event.currentTarget;
|
||||
if (!(input instanceof HTMLInputElement)) return;
|
||||
const current = this.answers[question.id];
|
||||
if (question.multiple === true) {
|
||||
this.setAnswer(question, {
|
||||
values: [...(current?.values ?? [])],
|
||||
...(input.checked ? { otherText: current?.otherText ?? "" } : {}),
|
||||
});
|
||||
} else if (input.checked) {
|
||||
this.setAnswer(question, { values: [], otherText: this.isOtherSelected(question, current) ? current?.otherText ?? "" : "" });
|
||||
}
|
||||
if (input.checked) void this.focusOtherInput(index);
|
||||
}
|
||||
|
||||
private changeOtherText(question: AskUserQuestion, event: Event): void {
|
||||
const input = event.currentTarget;
|
||||
if (!(input instanceof HTMLTextAreaElement)) return;
|
||||
const current = this.answers[question.id];
|
||||
this.setAnswer(question, {
|
||||
values: [...(current?.values ?? [])],
|
||||
otherText: input.value.slice(0, ASK_USER_OTHER_TEXT_MAX_LENGTH),
|
||||
});
|
||||
}
|
||||
|
||||
private setAnswer(question: AskUserQuestion, answer: AskDraftAnswer): void {
|
||||
const next: AskDraftAnswers = answer.values.length === 0 && answer.otherText === undefined
|
||||
? Object.fromEntries(Object.entries(this.answers).filter(([id]) => id !== question.id))
|
||||
: { ...this.answers, [question.id]: answer };
|
||||
this.answers = next;
|
||||
this.confirmingPartialSubmit = false;
|
||||
if (this.ask !== undefined && this.draftSessionId !== "") saveAskDraft(this.draftSessionId, this.ask.askId, next);
|
||||
}
|
||||
|
||||
private handleSubmit(event: SubmitEvent, ask: PendingAskUser): void {
|
||||
event.preventDefault();
|
||||
if (this.submitting) return;
|
||||
if (unansweredQuestions(ask.questions, this.answers).length > 0) {
|
||||
void this.showPartialSubmitConfirmation();
|
||||
return;
|
||||
}
|
||||
this.submitAnswers(ask);
|
||||
}
|
||||
|
||||
private submitAnswers(ask: PendingAskUser): void {
|
||||
if (this.submitting) return;
|
||||
this.submitting = true;
|
||||
const callback = this.onSubmit;
|
||||
if (callback === undefined) {
|
||||
this.submitting = false;
|
||||
return;
|
||||
}
|
||||
const askId = ask.askId;
|
||||
void Promise.resolve()
|
||||
.then(() => callback(askId, toSubmission(ask.questions, this.answers)))
|
||||
.catch(() => {
|
||||
// The parent controller owns the visible transport error. Keeping this
|
||||
// card and its draft intact is the only recovery needed at this boundary.
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.ask?.askId === askId) this.submitting = false;
|
||||
});
|
||||
}
|
||||
|
||||
private async showPartialSubmitConfirmation(): Promise<void> {
|
||||
this.confirmingPartialSubmit = true;
|
||||
await this.updateComplete;
|
||||
this.renderRoot.querySelector<HTMLElement>(".send-anyway")?.focus();
|
||||
}
|
||||
|
||||
private keepEditing(ask: PendingAskUser, unanswered: AskUserQuestion[]): void {
|
||||
this.confirmingPartialSubmit = false;
|
||||
const first = unanswered[0];
|
||||
if (first !== undefined) this.focusQuestion(ask.questions.indexOf(first));
|
||||
}
|
||||
|
||||
private focusQuestion(index: number): void {
|
||||
if (index < 0) return;
|
||||
void this.updateComplete.then(() => {
|
||||
const fieldset = this.renderRoot.querySelector<HTMLElement>(`#${this.questionFieldsetId(index)}`);
|
||||
const firstControl = fieldset?.querySelector<HTMLElement>("input, textarea");
|
||||
(firstControl ?? fieldset)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
private async focusOtherInput(index: number): Promise<void> {
|
||||
await this.updateComplete;
|
||||
this.renderRoot.querySelector<HTMLElement>(`#${this.otherInputId(index)}`)?.focus();
|
||||
}
|
||||
|
||||
private isOtherSelected(question: AskUserQuestion, answer: AskDraftAnswer | undefined): boolean {
|
||||
if (answer?.otherText === undefined) return false;
|
||||
return question.multiple === true || answer.values.length === 0;
|
||||
}
|
||||
|
||||
private displayedRecordAnswer(outcome: AskUserOutcome, record: AskUserQuestionRecord): DisplayedRecordAnswer | undefined {
|
||||
if (record.answered) {
|
||||
return {
|
||||
values: [...record.values],
|
||||
...(record.otherText === undefined ? {} : { otherText: record.otherText }),
|
||||
fromDraft: false,
|
||||
};
|
||||
}
|
||||
if (outcome.reason !== "superseded") return undefined;
|
||||
const answer = toSubmission([record.question], this.answers).answers[0];
|
||||
if (answer === undefined) return undefined;
|
||||
return {
|
||||
values: [...answer.values],
|
||||
...(answer.otherText === undefined ? {} : { otherText: answer.otherText }),
|
||||
fromDraft: true,
|
||||
};
|
||||
}
|
||||
|
||||
private optionLabel(question: AskUserQuestion, value: string): string {
|
||||
return question.options.find((option) => option.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
private currentModelIdentity(): string | undefined {
|
||||
if (this.outcome !== undefined) return `record:${this.draftSessionId}:${this.outcome.askId}`;
|
||||
if (this.ask !== undefined) return `open:${this.draftSessionId}:${this.ask.askId}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private loadCurrentDraft(): AskDraftAnswers {
|
||||
const askId = this.outcome?.askId ?? this.ask?.askId;
|
||||
if (askId === undefined || this.draftSessionId === "") return {};
|
||||
return loadAskDraft(this.draftSessionId, askId);
|
||||
}
|
||||
|
||||
private questionGroupName(ask: PendingAskUser, question: AskUserQuestion): string {
|
||||
return `ask-user:${ask.askId}:${question.id}`;
|
||||
}
|
||||
|
||||
private questionFieldsetId(index: number): string {
|
||||
return `ask-user-question-${String(index)}`;
|
||||
}
|
||||
|
||||
private questionDetailId(index: number): string {
|
||||
return `ask-user-question-detail-${String(index)}`;
|
||||
}
|
||||
|
||||
private otherInputId(index: number): string {
|
||||
return `ask-user-other-${String(index)}`;
|
||||
}
|
||||
|
||||
private recordQuestionHeadingId(index: number): string {
|
||||
return `ask-user-record-question-${String(index)}`;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0 0 14px;
|
||||
color: var(--pi-text);
|
||||
font: 14px system-ui, sans-serif;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--pi-border);
|
||||
border-radius: 10px;
|
||||
background: var(--pi-surface);
|
||||
}
|
||||
.card-header {
|
||||
position: sticky;
|
||||
top: var(--pi-chat-sticky-top, 0px);
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 22px;
|
||||
padding: 7px 10px 6px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--pi-border-muted) 35%, transparent);
|
||||
border-radius: 9px 9px 0 0;
|
||||
background: var(--pi-surface);
|
||||
box-shadow: 0 8px 18px var(--pi-shadow-soft);
|
||||
}
|
||||
h2, h3, p { margin-top: 0; }
|
||||
h2 {
|
||||
margin-bottom: 0;
|
||||
color: var(--pi-accent);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.header-status { flex: 0 1 auto; color: var(--pi-muted); font-size: 11px; text-align: end; }
|
||||
.header-status.submitted { color: var(--pi-success); }
|
||||
.header-status.superseded { color: var(--pi-warning); }
|
||||
.questions { display: grid; padding-top: 8px; }
|
||||
fieldset.question {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--pi-border-muted);
|
||||
padding: 16px;
|
||||
background: transparent;
|
||||
}
|
||||
fieldset.question:first-child { border-top: 0; }
|
||||
fieldset.question:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: -3px; }
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 5px;
|
||||
color: var(--pi-text);
|
||||
padding: 0;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.question-number { color: var(--pi-muted); }
|
||||
fieldset.question.answered .question-number { color: var(--pi-success); }
|
||||
.question-detail {
|
||||
margin: 4px 0 10px;
|
||||
color: var(--pi-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.options { display: grid; gap: 7px; }
|
||||
.option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 7px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.option:hover { border-color: var(--pi-border-muted); background: var(--pi-surface-hover); }
|
||||
.option:has(input:checked) { border-color: var(--pi-accent); background: var(--pi-selection-bg); }
|
||||
input { margin: 2px 0 0; accent-color: var(--pi-accent); }
|
||||
input:focus-visible, textarea:focus-visible, button:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
|
||||
.option-copy { min-width: 0; display: grid; gap: 2px; }
|
||||
.option-label { line-height: 1.35; }
|
||||
.option-detail { color: var(--pi-muted); font-size: 12px; line-height: 1.35; }
|
||||
.other-answer { display: grid; gap: 5px; color: var(--pi-muted); font-size: 12px; padding: 4px 8px 4px 32px; }
|
||||
.other-answer:only-child { padding-left: 0; padding-right: 0; }
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--pi-border);
|
||||
border-radius: 8px;
|
||||
background: var(--pi-bg);
|
||||
color: var(--pi-text);
|
||||
padding: 8px;
|
||||
font: var(--pi-control-font-size, 16px)/1.4 var(--pi-control-font-family, system-ui, sans-serif);
|
||||
}
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--pi-border-muted);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
button {
|
||||
border: 1px solid var(--pi-border);
|
||||
border-radius: 8px;
|
||||
background: var(--pi-surface);
|
||||
color: var(--pi-text);
|
||||
padding: 7px 10px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--pi-surface-hover); }
|
||||
button:disabled { cursor: wait; opacity: .65; }
|
||||
.primary-action { border-color: var(--pi-accent); background: var(--pi-accent); color: var(--pi-accent-contrast, white); font-weight: 650; }
|
||||
.primary-action:hover:not(:disabled) { background: color-mix(in srgb, var(--pi-accent) 86%, white); }
|
||||
.partial-confirmation { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 10px; }
|
||||
.partial-confirmation p { min-width: 0; margin: 0; color: var(--pi-warning); font-size: 12px; line-height: 1.4; }
|
||||
.question-jump {
|
||||
display: inline;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.confirmation-actions { flex: 0 0 auto; display: flex; gap: 7px; }
|
||||
.record-summary { margin: 0; color: var(--pi-muted); padding: 12px 16px; font-size: 12px; }
|
||||
.record-questions { display: grid; }
|
||||
.record-question { min-width: 0; padding: 14px 16px; }
|
||||
.record-question + .record-question { border-top: 1px solid var(--pi-border-muted); }
|
||||
.record-question h3 { display: flex; gap: 5px; margin-bottom: 8px; font-size: 14px; line-height: 1.35; }
|
||||
.record-answers { display: grid; gap: 4px; margin: 0; padding-left: 22px; line-height: 1.4; }
|
||||
.other-record-text { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.unanswered-record { margin: 0; color: var(--pi-muted); font-style: italic; }
|
||||
.draft-note { margin: 7px 0 0; color: var(--pi-warning); font-size: 11px; }
|
||||
@container (max-width: 580px) {
|
||||
fieldset.question, .record-question { padding: 14px 12px; }
|
||||
.record-summary { padding-inline: 12px; }
|
||||
.form-footer { align-items: stretch; flex-direction: column; padding: 12px; }
|
||||
.partial-confirmation { align-items: stretch; flex-direction: column; }
|
||||
.confirmation-actions { justify-content: flex-end; }
|
||||
.primary-action { min-height: 42px; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ask-user-card": AskUserCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AskUserOutcome } from "../../../shared/apiTypes";
|
||||
import { AskUserCard } from "./AskUserCard";
|
||||
import { ChatView } from "./ChatView";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("ChatView open ask_user form", () => {
|
||||
it("scrolls a newly opened form to its start and gives it a stable chat-scroll anchor", async () => {
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
document.body.append(view);
|
||||
await view.updateComplete;
|
||||
let askStartScrolls = 0;
|
||||
let bottomScrolls = 0;
|
||||
if (!Reflect.set(view, "scrollToOpenAsk", () => { askStartScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToOpenAsk");
|
||||
if (!Reflect.set(view, "scrollToBottom", () => { bottomScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
|
||||
|
||||
view.pendingAsk = {
|
||||
askId: "ask-open",
|
||||
askedAt: "2026-07-20T10:00:00.000Z",
|
||||
questions: [{ id: "editor", question: "Which editor?", options: [{ value: "vim", label: "Vim" }] }],
|
||||
};
|
||||
await view.updateComplete;
|
||||
|
||||
expect(askStartScrolls).toBe(1);
|
||||
expect(bottomScrolls).toBe(0);
|
||||
expect(view.shadowRoot?.querySelector("ask-user-card")?.getAttribute("data-scroll-anchor-id")).toBe("ask:ask-open");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView ask_user transcript records", () => {
|
||||
it("renders a projected outcome as the read-only question card with the machine-scoped draft key", async () => {
|
||||
const outcome: 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" }] },
|
||||
answered: true,
|
||||
values: ["vim"],
|
||||
},
|
||||
{
|
||||
question: { id: "region", question: "Which region?", options: [{ value: "eu", label: "Europe" }] },
|
||||
answered: false,
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
answeredCount: 1,
|
||||
unansweredIds: ["region"],
|
||||
summary: "Answered 1 of 2; unanswered: region",
|
||||
};
|
||||
const view = new ChatView();
|
||||
view.sessionId = "session-1";
|
||||
view.askDraftSessionId = "remote-a:session-1";
|
||||
view.messages = [{ role: "system", parts: [{ type: "askUserRecord", outcome }] }];
|
||||
document.body.append(view);
|
||||
await view.updateComplete;
|
||||
|
||||
const card = requiredElement(view.shadowRoot?.querySelector("ask-user-card"), "ask_user record card");
|
||||
expect(card).toBeInstanceOf(AskUserCard);
|
||||
expect(card.outcome).toEqual(outcome);
|
||||
expect(card.ask).toBeUndefined();
|
||||
expect(card.draftSessionId).toBe("remote-a:session-1");
|
||||
await card.updateComplete;
|
||||
|
||||
const cardRoot = requiredElement(card.shadowRoot, "ask_user record shadow root");
|
||||
expect(cardRoot.textContent).toContain("Answers sent");
|
||||
expect(cardRoot.textContent).toContain("Vim");
|
||||
expect(cardRoot.textContent).toContain("Which region?");
|
||||
expect(cardRoot.textContent).toContain("Unanswered");
|
||||
expect(cardRoot.querySelector("input, textarea, button, select")).toBeNull();
|
||||
expect(view.shadowRoot?.querySelector("article.ask-user-record-shell .msg-header")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function requiredElement<T>(value: T | null | undefined, label: string): T {
|
||||
if (value === null || value === undefined) throw new Error(`Expected ${label}`);
|
||||
return value;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { writeClipboardText } from "../clipboard";
|
||||
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
|
||||
import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
|
||||
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
|
||||
import type { QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
|
||||
import type { AskUserSubmission, PendingAskUser, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
|
||||
import {
|
||||
notificationAnnouncementLabel,
|
||||
notificationDismissLabel,
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "../sessionNotifications";
|
||||
import type { ChatLine, ChatPart } from "./shared";
|
||||
import { chatStyles, renderSessionWarningIcon } from "./shared";
|
||||
import "./AskUserCard";
|
||||
import "./ConversationMeter";
|
||||
import "./FormattedText";
|
||||
import "./ToolExecutionView";
|
||||
@@ -192,6 +193,9 @@ export class ChatView extends LitElement {
|
||||
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
|
||||
@property({ attribute: false }) status?: SessionStatus;
|
||||
@property({ attribute: false }) activity?: SessionActivity;
|
||||
@property({ attribute: false }) pendingAsk?: PendingAskUser;
|
||||
@property({ attribute: false }) askDraftSessionId = "";
|
||||
@property({ attribute: false }) onSubmitAsk?: (askId: string, submission: AskUserSubmission) => void | Promise<void>;
|
||||
@property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView;
|
||||
@property({ type: Boolean }) canClearServerQueue = false;
|
||||
@property({ attribute: false }) onClearServerQueue?: () => void;
|
||||
@@ -217,6 +221,7 @@ export class ChatView extends LitElement {
|
||||
private suppressLoadMoreRequests = false;
|
||||
private loadMoreCheckFrame: number | undefined;
|
||||
private scrollToBottomFrame: number | undefined;
|
||||
private scrollToOpenAskFrame: number | undefined;
|
||||
private conversationRailFrame: number | undefined;
|
||||
private groupedMessagesInput?: ChatLine[];
|
||||
private groupedMessagesStart = 0;
|
||||
@@ -275,6 +280,10 @@ export class ChatView extends LitElement {
|
||||
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
|
||||
if (this.loadMoreCheckFrame !== undefined) cancelAnimationFrame(this.loadMoreCheckFrame);
|
||||
if (this.scrollToBottomFrame !== undefined) cancelAnimationFrame(this.scrollToBottomFrame);
|
||||
if (this.scrollToOpenAskFrame !== undefined) {
|
||||
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
||||
this.scrollToOpenAskFrame = undefined;
|
||||
}
|
||||
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
|
||||
window.removeEventListener("resize", this.onViewportResize);
|
||||
window.removeEventListener("pagehide", this.onPageHide);
|
||||
@@ -301,6 +310,10 @@ export class ChatView extends LitElement {
|
||||
cancelAnimationFrame(this.restoreScrollFrame);
|
||||
this.restoreScrollFrame = undefined;
|
||||
}
|
||||
if (this.scrollToOpenAskFrame !== undefined) {
|
||||
cancelAnimationFrame(this.scrollToOpenAskFrame);
|
||||
this.scrollToOpenAskFrame = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected override willUpdate(changed: Map<string, unknown>): void {
|
||||
@@ -311,7 +324,7 @@ export class ChatView extends LitElement {
|
||||
this.pendingNotificationFocus = undefined;
|
||||
this.retainedEmptyNotificationTrayTargetKey = undefined;
|
||||
}
|
||||
if (changed.has("messages")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
|
||||
if (changed.has("messages") || changed.has("pendingAsk")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
|
||||
}
|
||||
|
||||
protected override update(changed: Map<string, unknown>): void {
|
||||
@@ -324,9 +337,13 @@ export class ChatView extends LitElement {
|
||||
if (changed.has("loadingMore") && !this.loadingMore) this.loadMoreRequested = false;
|
||||
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
|
||||
if (changed.has("sessionId")) this.restoreScrollPosition();
|
||||
if (!changed.has("sessionId") && changed.has("messages") && this.pinnedToBottom) this.scrollToBottom();
|
||||
const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk"));
|
||||
// The form uses the transcript scroller. Start a new long form at question
|
||||
// one rather than applying the usual live-tail scroll and landing at its end.
|
||||
if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk();
|
||||
else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom();
|
||||
if (changed.has("messages") || changed.has("messageStart") || changed.has("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
|
||||
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore")) this.continuePendingScrollRestore();
|
||||
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk")) this.continuePendingScrollRestore();
|
||||
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
|
||||
if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
|
||||
if (changed.has("zoomedImage")) this.syncImageZoomDialog();
|
||||
@@ -365,6 +382,7 @@ export class ChatView extends LitElement {
|
||||
)}
|
||||
${this.renderQueuedMessages()}
|
||||
${this.renderSessionActivity()}
|
||||
${this.renderOpenAsk()}
|
||||
</div>
|
||||
${this.renderActivityDock()}
|
||||
</div>
|
||||
@@ -638,6 +656,18 @@ export class ChatView extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderOpenAsk() {
|
||||
if (this.pendingAsk === undefined) return null;
|
||||
return html`
|
||||
<ask-user-card
|
||||
data-scroll-anchor-id=${`ask:${this.pendingAsk.askId}`}
|
||||
.ask=${this.pendingAsk}
|
||||
.draftSessionId=${this.askDraftSessionId}
|
||||
.onSubmit=${this.onSubmitAsk}
|
||||
></ask-user-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSessionActivity() {
|
||||
if (!this.isCompacting) return null;
|
||||
return html`
|
||||
@@ -714,10 +744,12 @@ export class ChatView extends LitElement {
|
||||
|
||||
private renderMessage(message: ChatLine, index: number) {
|
||||
const toolOnly = this.isToolExecutionOnlyMessage(message);
|
||||
const askUserRecordOnly = this.isAskUserRecordOnlyMessage(message);
|
||||
const shellClass = toolOnly ? "msg tool-execution-shell" : "msg ask-user-record-shell";
|
||||
return html`
|
||||
${this.renderScrollMarker(this.messageScrollMarkerId(index))}
|
||||
<article class=${toolOnly ? "msg tool-execution-shell" : `msg ${message.role}`} data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
|
||||
${toolOnly ? null : this.renderMessageHeader(message, String(index))}
|
||||
<article class=${toolOnly || askUserRecordOnly ? shellClass : `msg ${message.role}`} data-index=${index} data-scroll-anchor-id=${this.messageAnchorKey(index)}>
|
||||
${toolOnly || askUserRecordOnly ? null : this.renderMessageHeader(message, String(index))}
|
||||
${message.parts.map((part) => this.renderPart(part, message))}
|
||||
</article>
|
||||
`;
|
||||
@@ -738,6 +770,10 @@ export class ChatView extends LitElement {
|
||||
return message.role === "tool" && message.parts.length > 0 && message.parts.every((part) => part.type === "toolExecution");
|
||||
}
|
||||
|
||||
private isAskUserRecordOnlyMessage(message: ChatLine): boolean {
|
||||
return message.parts.length > 0 && message.parts.every((part) => part.type === "askUserRecord");
|
||||
}
|
||||
|
||||
private renderMessageGroup(messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) {
|
||||
const disclosureKey = this.groupDisclosureKey(startIndex, endIndex, defaultOpen);
|
||||
const open = this.disclosures.isOpen(disclosureKey, defaultOpen);
|
||||
@@ -857,6 +893,13 @@ export class ChatView extends LitElement {
|
||||
<small>read ${part.path}</small>
|
||||
</div>
|
||||
`;
|
||||
if (part.type === "askUserRecord") return html`
|
||||
<ask-user-card
|
||||
class="part"
|
||||
.outcome=${part.outcome}
|
||||
.draftSessionId=${this.askDraftSessionId}
|
||||
></ask-user-card>
|
||||
`;
|
||||
if (part.type === "image") {
|
||||
const { src, alt } = chatImagePartSource(part);
|
||||
return html`<img class="part chat-image" src=${src} alt=${alt} loading="lazy" role="button" tabindex="0" title="Click to enlarge" @load=${this.onImageLoad} @click=${() => { this.openImageZoom(src, alt); }} @keydown=${(event: KeyboardEvent) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); this.openImageZoom(src, alt); } }} />`;
|
||||
@@ -982,6 +1025,33 @@ export class ChatView extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private isNewPendingAsk(previous: unknown): boolean {
|
||||
return this.pendingAsk !== undefined
|
||||
&& (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId);
|
||||
}
|
||||
|
||||
private scrollToOpenAsk(): void {
|
||||
if (this.scrollToOpenAskFrame !== undefined) return;
|
||||
if (this.scrollToBottomFrame !== undefined) {
|
||||
cancelAnimationFrame(this.scrollToBottomFrame);
|
||||
this.scrollToBottomFrame = undefined;
|
||||
}
|
||||
this.scrollToOpenAskFrame = requestAnimationFrame(() => {
|
||||
this.scrollToOpenAskFrame = undefined;
|
||||
this.withSuppressedScrollSave(() => { this.alignOpenAskToTop(); });
|
||||
});
|
||||
}
|
||||
|
||||
private alignOpenAskToTop(): boolean {
|
||||
const chat = this.chat;
|
||||
const card = this.renderRoot.querySelector<HTMLElement>(".chat > ask-user-card");
|
||||
if (chat === undefined || card === null) return false;
|
||||
chat.scrollTop += card.getBoundingClientRect().top - chat.getBoundingClientRect().top;
|
||||
this.syncScrollMetrics();
|
||||
this.pinnedToBottom = this.isNearBottom();
|
||||
return true;
|
||||
}
|
||||
|
||||
restoreScrollPosition() {
|
||||
const sessionId = this.sessionId;
|
||||
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
|
||||
@@ -989,6 +1059,7 @@ export class ChatView extends LitElement {
|
||||
this.restoreScrollFrame = undefined;
|
||||
if (this.sessionId !== sessionId) return;
|
||||
this.withSuppressedScrollSave(() => {
|
||||
if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return;
|
||||
const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
|
||||
this.handleScrollRestoreResult(sessionId, result);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type AskUserSubmission, type Machine, type MachineHealth, type PiWebConfigValues, type PiWebShortcutConfig, type Project, type SessionCleanupExecuteResponse, type SessionCleanupPreviewResponse, type SessionCleanupRequest, type SessionInfo, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type TerminalCommandRun, type TerminalUiEvent, type Workspace } from "../api";
|
||||
import type { AppAction } from "../actions";
|
||||
import { initialAppState, type AppState } from "../appState";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
@@ -2104,6 +2104,8 @@ export class PiWebApp extends LitElement {
|
||||
void this.sessions.dismissWarning(dismissId);
|
||||
};
|
||||
|
||||
private readonly handleSubmitAsk = (askId: string, submission: AskUserSubmission): Promise<void> => this.sessions.submitAsk(askId, submission);
|
||||
|
||||
private readonly handleDismissNotification = (notificationId: string): void => {
|
||||
void this.notifications.dismissNotification(notificationId);
|
||||
};
|
||||
@@ -2129,7 +2131,7 @@ export class PiWebApp extends LitElement {
|
||||
|
||||
private renderChatView(state: AppState, session: SessionInfo) {
|
||||
return html`
|
||||
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .pendingAsk=${state.pendingAsk} .askDraftSessionId=${machineSessionKey(selectedMachineId(state), session.id)} .onSubmitAsk=${this.handleSubmitAsk} .notificationInbox=${selectedNotificationView(state.selectedNotificationInbox)} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onDismissWarning=${this.handleDismissWarning} .onDismissNotification=${this.handleDismissNotification} .onDismissAllNotifications=${this.handleDismissAllNotifications} .warningsVisible=${!this.sessionWarningVisibility.collapsed} .onToggleWarnings=${this.handleToggleWarnings} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PiWebConfigResponse } from "../../api";
|
||||
import { SettingsSessiondPanel } from "./SettingsSessiondPanel";
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("SettingsSessiondPanel Ask Questions setting", () => {
|
||||
it("lets the user disable ask_user with a daemon config patch", async () => {
|
||||
const panel = new SettingsSessiondPanel();
|
||||
const onSave = vi.fn();
|
||||
panel.configResponse = configResponse(true);
|
||||
panel.onSave = onSave;
|
||||
document.body.append(panel);
|
||||
await panel.updateComplete;
|
||||
|
||||
const toggle = askUserToggle(panel);
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(toggle.disabled).toBe(false);
|
||||
|
||||
toggle.click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({ askUser: false });
|
||||
});
|
||||
|
||||
it("keeps an environment-overridden setting read-only", async () => {
|
||||
const panel = new SettingsSessiondPanel();
|
||||
panel.configResponse = configResponse(true, true);
|
||||
document.body.append(panel);
|
||||
await panel.updateComplete;
|
||||
|
||||
const toggle = askUserToggle(panel);
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(toggle.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not offer an unsupported setting from an older selected machine", async () => {
|
||||
const panel = new SettingsSessiondPanel();
|
||||
panel.configResponse = configResponse(undefined);
|
||||
document.body.append(panel);
|
||||
await panel.updateComplete;
|
||||
|
||||
const toggle = askUserToggle(panel);
|
||||
expect(toggle.checked).toBe(false);
|
||||
expect(toggle.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function askUserToggle(panel: SettingsSessiondPanel): HTMLInputElement {
|
||||
const toggle = panel.shadowRoot?.querySelector<HTMLInputElement>('input[aria-label="Enable Ask Questions"]');
|
||||
if (toggle === undefined || toggle === null) throw new Error("Ask Questions toggle was not rendered");
|
||||
return toggle;
|
||||
}
|
||||
|
||||
function configResponse(askUser: boolean | undefined, askUserOverride = false): PiWebConfigResponse {
|
||||
const askUserConfig = askUser === undefined ? {} : { askUser };
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
exists: true,
|
||||
config: askUserConfig,
|
||||
effectiveConfig: askUserConfig,
|
||||
envOverrides: {
|
||||
host: false,
|
||||
port: false,
|
||||
allowedHosts: false,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
askUser: askUserOverride,
|
||||
agentCommand: false,
|
||||
agentDir: false,
|
||||
agentSessionDir: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -153,6 +153,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import "./SettingsPanelFrame";
|
||||
import type { SettingsNotice } from "./SettingsPanelFrame";
|
||||
import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft";
|
||||
import type { AgentProfileSettingsSupport } from "./settingsMachineTarget";
|
||||
import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||
import { agentDirFieldOverridden, agentProfileActivationState, askUserConfigPatch, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||
|
||||
@customElement("settings-sessiond-panel")
|
||||
export class SettingsSessiondPanel extends LitElement {
|
||||
@@ -47,6 +47,11 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
const subsessionsOverridden = config?.envOverrides.subsessions === true;
|
||||
// Beta, off by default; also requires spawn to be enabled.
|
||||
const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn;
|
||||
// Current servers always resolve this on-by-default setting. Absence means
|
||||
// an older selected machine cannot persist it yet.
|
||||
const askUserSupported = config?.effectiveConfig.askUser !== undefined;
|
||||
const askUserOverridden = config?.envOverrides.askUser === true;
|
||||
const effectiveAskUser = config?.effectiveConfig.askUser === true;
|
||||
const agentCommandOverridden = config?.envOverrides.agentCommand === true;
|
||||
const profileEditingSupported = this.agentProfileSupport.state === "supported";
|
||||
const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command;
|
||||
@@ -141,6 +146,25 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
</label>
|
||||
<small>Beta: agents can start child sessions they stay attached to (<code>spawn_subsession</code>, <code>list_subsessions</code>, <code>check_subsession</code>, <code>read_subsession</code>) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-heading">
|
||||
<span>Allow agents to ask questions</span>
|
||||
${askUserOverridden ? html`<span class="override-badge">environment override</span>` : null}
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="Enable Ask Questions"
|
||||
.checked=${effectiveAskUser}
|
||||
?disabled=${this.loading || this.saving || askUserOverridden || !askUserSupported}
|
||||
@change=${(event: Event) => { void this.toggleAskUser(event); }}
|
||||
>
|
||||
<span>Enable the <code>ask_user</code> tool</span>
|
||||
</label>
|
||||
<small>${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.`}</small>
|
||||
</div>
|
||||
<section class="effective-card" aria-label="Desired and active session daemon configuration summary">
|
||||
<h3>Desired after environment overrides</h3>
|
||||
<dl>
|
||||
@@ -151,6 +175,7 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
<div><dt>Profile status</dt><dd>${profileActivationLabel(profileActivation)}</dd></div>
|
||||
<div><dt>Spawn sessions</dt><dd>${effectiveSpawn ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
<div><dt>Subsessions</dt><dd>${effectiveSubsessions ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
<div><dt>Ask questions</dt><dd>${!askUserSupported ? html`<span class="muted">Unavailable</span>` : effectiveAskUser ? "Enabled" : html`<span class="muted">Disabled</span>`}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
`}
|
||||
@@ -198,6 +223,11 @@ export class SettingsSessiondPanel extends LitElement {
|
||||
await this.onSave?.(subsessionsConfigPatch(enabled));
|
||||
}
|
||||
|
||||
private async toggleAskUser(event: Event): Promise<void> {
|
||||
const enabled = event.target instanceof HTMLInputElement && event.target.checked;
|
||||
await this.onSave?.(askUserConfigPatch(enabled));
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host { display: block; }
|
||||
h3 { margin: 0; font-size: 13px; line-height: 1.3; }
|
||||
|
||||
@@ -258,6 +258,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebCo
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
...(baseConfig.askUser === undefined ? {} : { askUser: baseConfig.askUser }),
|
||||
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const configResponse: PiWebConfigResponse = {
|
||||
exists: true,
|
||||
config: { host: "127.0.0.1" },
|
||||
effectiveConfig: { host: "127.0.0.1" },
|
||||
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 },
|
||||
};
|
||||
|
||||
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
|
||||
|
||||
@@ -81,6 +81,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +64,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api";
|
||||
import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||
import { agentDirFieldOverridden, agentProfileActivationState, askUserConfigPatch, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig";
|
||||
|
||||
describe("session daemon settings config helpers", () => {
|
||||
it("builds daemon-only save patches for the sessiond toggles", () => {
|
||||
expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false });
|
||||
expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true });
|
||||
expect(askUserConfigPatch(false)).toEqual({ askUser: false });
|
||||
});
|
||||
|
||||
it("compares the desired effective profile with the daemon-owned active profile", () => {
|
||||
@@ -86,6 +87,7 @@ describe("session daemon settings config helpers", () => {
|
||||
allowedHosts: false,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
askUser: false,
|
||||
agentCommand: true,
|
||||
agentDir: false,
|
||||
agentDirSource: "pi-compatibility",
|
||||
@@ -121,6 +123,7 @@ function configResponse(
|
||||
allowedHosts: false,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
askUser: false,
|
||||
agentCommand: false,
|
||||
agentDir: false,
|
||||
agentSessionDir: false,
|
||||
|
||||
@@ -11,6 +11,10 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues {
|
||||
return { subsessions: enabled };
|
||||
}
|
||||
|
||||
export function askUserConfigPatch(enabled: boolean): PiWebConfigValues {
|
||||
return { askUser: enabled };
|
||||
}
|
||||
|
||||
export function agentProfileActivationState(
|
||||
config: PiWebConfigResponse | undefined,
|
||||
activeProfile: ActiveAgentProfileDescriptor | undefined,
|
||||
@@ -41,6 +45,7 @@ export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, se
|
||||
...base.envOverrides,
|
||||
spawnSessions: selectedMachine.envOverrides.spawnSessions,
|
||||
subsessions: selectedMachine.envOverrides.subsessions,
|
||||
askUser: selectedMachine.envOverrides.askUser,
|
||||
agentCommand: selectedMachine.envOverrides.agentCommand,
|
||||
agentDir: selectedMachine.envOverrides.agentDir,
|
||||
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { css, svg, type TemplateResult } from "lit";
|
||||
import type { AskUserOutcome } from "../../../shared/apiTypes";
|
||||
import type { SessionWarningSeverity } from "../api";
|
||||
|
||||
export function renderSessionWarningIcon(severity: SessionWarningSeverity, className: string): TemplateResult {
|
||||
@@ -54,6 +55,7 @@ export type ChatPart =
|
||||
| { type: "thinking"; text: string }
|
||||
| { type: "skillInvocation"; name: string; location: string; content: string }
|
||||
| { type: "skillRead"; name: string; path: string; toolCallId?: string }
|
||||
| { type: "askUserRecord"; outcome: AskUserOutcome }
|
||||
| { type: "toolCall"; toolCallId?: string; toolName: string; summary: string; args?: unknown }
|
||||
| ToolExecutionPart
|
||||
| { type: "toolResult"; toolCallId?: string; toolName: string; text: string; isError: boolean; content?: unknown; details?: unknown }
|
||||
@@ -363,7 +365,7 @@ export const chatStyles = css`
|
||||
.notification-header { gap: 4px; padding-inline: 8px; }
|
||||
.notification-list { padding-inline: 8px; }
|
||||
}
|
||||
.chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
|
||||
.chat { --pi-chat-sticky-top: -26px; height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 26px 16px 64px; box-sizing: border-box; }
|
||||
.scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; }
|
||||
.activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 20; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); }
|
||||
.activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); }
|
||||
@@ -374,7 +376,8 @@ export const chatStyles = css`
|
||||
.msg.assistant, .msg.tool-image-output { background: var(--pi-surface); }
|
||||
.msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); }
|
||||
.msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); }
|
||||
.msg.tool-execution-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); }
|
||||
.msg.tool-execution-shell, .msg.ask-user-record-shell { padding: 0; border: 0; background: transparent; color: var(--pi-text); }
|
||||
.msg.ask-user-record-shell ask-user-card { margin: 0 auto; }
|
||||
.msg.system { color: var(--pi-danger); }
|
||||
.msg.bash { border-color: var(--pi-success); background: var(--pi-success-bg); }
|
||||
.msg.skill { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); }
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { initialAppState } from "../appState";
|
||||
import { loadAskDraft, saveAskDraft } from "../askDrafts";
|
||||
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
|
||||
import type { AskUserCloseResponse, AskUserQuestion, PendingAskUser } from "../api";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, EmitSocket, emptyPage, FakeSocket, MemoryStorage, oldSession, sessionKey, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
|
||||
|
||||
const databaseQuestion: AskUserQuestion = { id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }] };
|
||||
const extrasQuestion: AskUserQuestion = { id: "q2", question: "Which extras?", options: [{ value: "metrics", label: "Metrics" }], allowOther: true, multiple: true };
|
||||
|
||||
function ask(askId: string): PendingAskUser {
|
||||
return { askId, askedAt: "2026-07-20T00:00:00.000Z", questions: [databaseQuestion, extrasQuestion] };
|
||||
}
|
||||
|
||||
function statusWithAsk(sessionId: string, pendingAsk: PendingAskUser): SessionStatus {
|
||||
return { ...status(sessionId), pendingAsk };
|
||||
}
|
||||
|
||||
function closeResponse(sessionStatus: SessionStatus, askId = "ask-1"): AskUserCloseResponse {
|
||||
return {
|
||||
result: "closed",
|
||||
outcome: {
|
||||
askId,
|
||||
reason: "submitted",
|
||||
askedAt: "2026-07-20T00:00:00.000Z",
|
||||
closedAt: "2026-07-20T00:01:00.000Z",
|
||||
questions: [
|
||||
{ question: databaseQuestion, answered: true, values: ["pg"] },
|
||||
{ question: extrasQuestion, answered: false, values: [] },
|
||||
],
|
||||
answeredCount: 1,
|
||||
unansweredIds: ["q2"],
|
||||
summary: "Answered 1 of 2; unanswered: q2",
|
||||
},
|
||||
sessionStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function capableState(patch: Partial<AppState> = {}): AppState {
|
||||
return {
|
||||
...initialAppState(),
|
||||
selectedWorkspace: workspace,
|
||||
selectedSession: oldSession,
|
||||
sessions: [oldSession],
|
||||
machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsAskUser] } },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function selectableApi(sessionStatus: SessionStatus): typeof defaultApi {
|
||||
return {
|
||||
...defaultApi,
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: () => Promise.resolve(sessionStatus),
|
||||
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
|
||||
thinkingLevels: () => Promise.resolve({ levels: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
interface LiveHarness {
|
||||
controller: SessionController;
|
||||
socket: EmitSocket;
|
||||
state: () => AppState;
|
||||
}
|
||||
|
||||
async function liveSession(patch: Partial<AppState> = {}, sessionStatus = status(oldSession.id)): Promise<LiveHarness> {
|
||||
const socket = new EmitSocket();
|
||||
let state = capableState({ selectedSession: undefined, ...patch });
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(statePatch) => { state = { ...state, ...statePatch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api: selectableApi(sessionStatus), socket },
|
||||
);
|
||||
await controller.selectSession(oldSession, { updateUrl: false });
|
||||
return { controller, socket, state: () => state };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, "localStorage", { value: new MemoryStorage(), configurable: true });
|
||||
});
|
||||
|
||||
describe("SessionController open ask state", () => {
|
||||
it("rehydrates the open ask from the daemon-owned status on selection", async () => {
|
||||
const pending = ask("ask-1");
|
||||
|
||||
const harness = await liveSession({}, statusWithAsk(oldSession.id, pending));
|
||||
|
||||
expect(harness.state().pendingAsk).toEqual(pending);
|
||||
});
|
||||
|
||||
it("opens and closes the card from live ask events", async () => {
|
||||
const harness = await liveSession();
|
||||
|
||||
harness.socket.emit({ type: "ask.opened", ask: ask("ask-1") });
|
||||
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
|
||||
|
||||
harness.socket.emit({ type: "ask.closed", askId: "ask-1", reason: "submitted" });
|
||||
expect(harness.state().pendingAsk).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the newer ask when the supersede close for the old one arrives after it", async () => {
|
||||
const harness = await liveSession();
|
||||
|
||||
harness.socket.emit({ type: "ask.opened", ask: ask("ask-1") });
|
||||
harness.socket.emit({ type: "ask.opened", ask: ask("ask-2") });
|
||||
harness.socket.emit({ type: "ask.closed", askId: "ask-1", reason: "superseded" });
|
||||
|
||||
expect(harness.state().pendingAsk?.askId).toBe("ask-2");
|
||||
});
|
||||
|
||||
it("drops an open ask on a machine that reports no ask support", async () => {
|
||||
const harness = await liveSession(
|
||||
{ machineRuntimes: { local: { machineId: "local", ok: true, checkedAt: "now", capabilities: [] } } },
|
||||
statusWithAsk(oldSession.id, ask("ask-1")),
|
||||
);
|
||||
expect(harness.state().pendingAsk).toBeUndefined();
|
||||
|
||||
harness.socket.emit({ type: "ask.opened", ask: ask("ask-1") });
|
||||
|
||||
expect(harness.state().pendingAsk).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still shows an open ask while capability discovery is unavailable", async () => {
|
||||
const harness = await liveSession({ machineRuntimes: {} }, statusWithAsk(oldSession.id, ask("ask-1")));
|
||||
|
||||
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
|
||||
});
|
||||
|
||||
it("applies a status that no longer carries an ask as the authoritative close", async () => {
|
||||
const harness = await liveSession({}, statusWithAsk(oldSession.id, ask("ask-1")));
|
||||
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
|
||||
|
||||
harness.controller.applySessionStatus(status(oldSession.id));
|
||||
|
||||
expect(harness.state().pendingAsk).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not adopt another session's open ask", async () => {
|
||||
const harness = await liveSession();
|
||||
|
||||
harness.controller.applySessionStatus(statusWithAsk("other-session", ask("ask-1")));
|
||||
|
||||
expect(harness.state().pendingAsk).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears the card when the session is deselected", async () => {
|
||||
const harness = await liveSession({}, statusWithAsk(oldSession.id, ask("ask-1")));
|
||||
expect(harness.state().pendingAsk?.askId).toBe("ask-1");
|
||||
|
||||
harness.controller.deselectSession({ updateUrl: false });
|
||||
|
||||
expect(harness.state().pendingAsk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionController ask submission", () => {
|
||||
it("submits answers, clears the draft, and applies the returned status", async () => {
|
||||
const submitCalls: { askId: string; answers: unknown; machineId: string }[] = [];
|
||||
const closedStatus = status(oldSession.id);
|
||||
let state = capableState({ status: statusWithAsk(oldSession.id, ask("ask-1")), pendingAsk: ask("ask-1") });
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
submitAsk: (_session, askId, submission, machineId) => {
|
||||
submitCalls.push({ askId, answers: submission.answers, machineId: machineId ?? "local" });
|
||||
return Promise.resolve(closeResponse(closedStatus));
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
saveAskDraft(sessionKey(oldSession.id), "ask-1", { q1: { values: ["pg"] } });
|
||||
|
||||
await controller.submitAsk("ask-1", { answers: [{ id: "q1", values: ["pg"] }] });
|
||||
|
||||
expect(submitCalls).toEqual([{ askId: "ask-1", answers: [{ id: "q1", values: ["pg"] }], machineId: "local" }]);
|
||||
expect(loadAskDraft(sessionKey(oldSession.id), "ask-1")).toEqual({});
|
||||
expect(state.pendingAsk).toBeUndefined();
|
||||
expect(state.status).toEqual(closedStatus);
|
||||
});
|
||||
|
||||
it("cancels an ask through its own route and clears the draft", async () => {
|
||||
const cancelCalls: string[] = [];
|
||||
let state = capableState({ pendingAsk: ask("ask-1") });
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
cancelAsk: (_session, askId) => {
|
||||
cancelCalls.push(askId);
|
||||
return Promise.resolve(closeResponse(status(oldSession.id)));
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
saveAskDraft(sessionKey(oldSession.id), "ask-1", { q1: { values: ["pg"] } });
|
||||
|
||||
await controller.cancelAsk("ask-1");
|
||||
|
||||
expect(cancelCalls).toEqual(["ask-1"]);
|
||||
expect(loadAskDraft(sessionKey(oldSession.id), "ask-1")).toEqual({});
|
||||
expect(state.pendingAsk).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trusts the status of a stale close and shows the superseding ask without an error", async () => {
|
||||
const supersedingAsk = ask("ask-2");
|
||||
let state = capableState({ pendingAsk: ask("ask-1") });
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
submitAsk: () => Promise.resolve({ result: "stale", sessionStatus: statusWithAsk(oldSession.id, supersedingAsk) }),
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.submitAsk("ask-1", { answers: [] });
|
||||
|
||||
expect(state.error).toBe("");
|
||||
expect(state.pendingAsk).toEqual(supersedingAsk);
|
||||
});
|
||||
|
||||
it("keeps the draft and reports the failure when the submit request fails", async () => {
|
||||
let state = capableState({ pendingAsk: ask("ask-1") });
|
||||
const api: typeof defaultApi = { ...defaultApi, submitAsk: () => Promise.reject(new Error("submit failed")) };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
saveAskDraft(sessionKey(oldSession.id), "ask-1", { q1: { values: ["pg"] } });
|
||||
|
||||
await controller.submitAsk("ask-1", { answers: [{ id: "q1", values: ["pg"] }] });
|
||||
|
||||
expect(state.error).toBe("Error: submit failed");
|
||||
expect(loadAskDraft(sessionKey(oldSession.id), "ask-1")).toEqual({ q1: { values: ["pg"] } });
|
||||
expect(state.pendingAsk?.askId).toBe("ask-1");
|
||||
});
|
||||
|
||||
it("does not submit for an archived session", async () => {
|
||||
const archived = { ...oldSession, archived: true as const };
|
||||
let state = capableState({ selectedSession: archived, sessions: [archived] });
|
||||
let submitted = false;
|
||||
const api: typeof defaultApi = {
|
||||
...defaultApi,
|
||||
submitAsk: () => {
|
||||
submitted = true;
|
||||
return Promise.resolve(closeResponse(status(oldSession.id)));
|
||||
},
|
||||
};
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ api, socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
await controller.submitAsk("ask-1", { answers: [] });
|
||||
|
||||
expect(submitted).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { api as defaultApi, type CommandResult, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api";
|
||||
import { api as defaultApi, type AskUserCloseResponse, type AskUserSubmission, type CommandResult, type PendingAskUser, type PromptAttachment, type QueuedSessionMessage, type SessionActivity, type SessionBulkFailure, type SessionCleanupExecuteResponse, type SessionInfo, type SessionRef, type SessionStatus, type SessionStreamSnapshot, type SessionTreeNavigateResult, type SessionTreeSummaryChoice, type Workspace } from "../api";
|
||||
import type { AppState } from "../appState";
|
||||
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
|
||||
import { textMessage } from "../chatMessages";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { clearDraft, moveDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { clearAskDraft } from "../askDrafts";
|
||||
import { ChatTranscriptStore } from "../chatTranscriptStore";
|
||||
import { isShellInput } from "../inputModes";
|
||||
import { fileCompletionInsertText } from "../promptCompletions";
|
||||
@@ -160,7 +161,7 @@ export class SessionController {
|
||||
// session must not cancel the in-flight upload indicator of the session
|
||||
// that is still sending; the per-session entry is cleared by send()'s
|
||||
// finally block when the request settles.
|
||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, availableThinkingLevels: [], treeDialog: undefined });
|
||||
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, availableThinkingLevels: [], treeDialog: undefined });
|
||||
}
|
||||
|
||||
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
|
||||
@@ -218,6 +219,7 @@ export class SessionController {
|
||||
...(options?.preserveTreeDialog === true ? {} : { treeDialog: undefined }),
|
||||
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
|
||||
activity: session.archived === true ? undefined : this.getState().sessionActivities[session.id],
|
||||
pendingAsk: session.archived === true ? undefined : this.selectedPendingAsk(this.getState().sessionStatuses[session.id], machineId),
|
||||
availableThinkingLevels: [],
|
||||
});
|
||||
let buffered: SessionUiEvent[] | undefined;
|
||||
@@ -226,7 +228,7 @@ export class SessionController {
|
||||
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState()));
|
||||
if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
|
||||
const history = this.transcripts.mergeHistory(transcriptKey, page);
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined });
|
||||
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined });
|
||||
this.onSelectedSessionReady?.({ machineId, session });
|
||||
if (options?.updateUrl !== false) this.updateUrl();
|
||||
return;
|
||||
@@ -663,7 +665,7 @@ export class SessionController {
|
||||
sessions: nextSessions,
|
||||
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
|
||||
sessionActivities: omitKeys(state.sessionActivities, affectedIds),
|
||||
...(selectedAffected ? { status: undefined, activity: undefined } : {}),
|
||||
...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined } : {}),
|
||||
});
|
||||
|
||||
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
|
||||
@@ -884,6 +886,36 @@ export class SessionController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Send the answers the user entered for the session's open ask. */
|
||||
submitAsk(askId: string, submission: AskUserSubmission): Promise<void> {
|
||||
return this.closeOpenAsk(askId, (session, machineId) => this.api.submitAsk(session, askId, submission, machineId));
|
||||
}
|
||||
|
||||
/** Close the session's open ask without answering it. */
|
||||
cancelAsk(askId: string): Promise<void> {
|
||||
return this.closeOpenAsk(askId, (session, machineId) => this.api.cancelAsk(session, askId, machineId));
|
||||
}
|
||||
|
||||
private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise<AskUserCloseResponse>): Promise<void> {
|
||||
const state = this.getState();
|
||||
const session = state.selectedSession;
|
||||
if (session === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) return;
|
||||
const machineId = selectedMachineId(state);
|
||||
const selectionSeq = this.selectionSeq;
|
||||
try {
|
||||
const response = await close(session, machineId);
|
||||
// The ask is gone either way: this call closed it, or it was already
|
||||
// closed elsewhere. Its draft has nothing left to protect, and the answers
|
||||
// now live in the daemon-owned outcome.
|
||||
clearAskDraft(machineSessionKey(machineId, session.id), askId);
|
||||
// Both outcomes carry the recomputed status, so no follow-up status
|
||||
// request is needed to learn what the session's open ask is now.
|
||||
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.applyStatus(response.sessionStatus);
|
||||
} catch (error) {
|
||||
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async stopActiveWork() {
|
||||
const session = this.getState().selectedSession;
|
||||
if (!session) return;
|
||||
@@ -1042,6 +1074,7 @@ export class SessionController {
|
||||
isLoadingEarlierMessages: false,
|
||||
status: undefined,
|
||||
activity,
|
||||
pendingAsk: undefined,
|
||||
availableThinkingLevels: [],
|
||||
treeDialog: undefined,
|
||||
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
|
||||
@@ -1082,7 +1115,7 @@ export class SessionController {
|
||||
sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
|
||||
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
|
||||
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
|
||||
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id] } : {}),
|
||||
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId) } : {}),
|
||||
error: "",
|
||||
});
|
||||
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||
@@ -1225,16 +1258,51 @@ export class SessionController {
|
||||
|
||||
private applyStatus(status: SessionStatus) {
|
||||
const state = this.getState();
|
||||
const isSelected = state.selectedSession?.id === status.sessionId;
|
||||
const clearsStaleActivity = state.sessionActivities[status.sessionId]?.phase === "active" && !isSessionActive(status);
|
||||
this.setState({
|
||||
sessionStatuses: { ...state.sessionStatuses, [status.sessionId]: status },
|
||||
...sessionMessageCountPatch(state, status.sessionId, status.messageCount),
|
||||
...(clearsStaleActivity ? { sessionActivities: omitSessionActivity(state.sessionActivities, status.sessionId) } : {}),
|
||||
status: state.selectedSession?.id === status.sessionId ? status : state.status,
|
||||
activity: state.selectedSession?.id === status.sessionId && clearsStaleActivity ? undefined : state.activity,
|
||||
status: isSelected ? status : state.status,
|
||||
activity: isSelected && clearsStaleActivity ? undefined : state.activity,
|
||||
// The daemon owns whether an ask is open, so every status it publishes is
|
||||
// authoritative for the selected session's card, including its removal.
|
||||
...(isSelected ? { pendingAsk: this.selectedPendingAsk(status, selectedMachineId(state)) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private applyOpenedAsk(ask: PendingAskUser): void {
|
||||
const state = this.getState();
|
||||
if (state.selectedSession === undefined) return;
|
||||
// A superseded ask keeps its draft: the read-only record of an ask the user
|
||||
// never submitted must still be able to show what they had typed.
|
||||
this.setState({ pendingAsk: this.selectedPendingAsk({ pendingAsk: ask }, selectedMachineId(state)) });
|
||||
}
|
||||
|
||||
private applyClosedAsk(askId: string): void {
|
||||
// A close for an ask that is not the one on screen is already reflected here
|
||||
// (typically the supersede half of an open), so it must not clear the card.
|
||||
if (this.getState().pendingAsk?.askId !== askId) return;
|
||||
this.setState({ pendingAsk: undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* The open ask to show for the selected session, or `undefined` when there is
|
||||
* none or the machine cannot serve it.
|
||||
*
|
||||
* COMPAT-CAP sessions.askUser: only a positive runtime answer without the
|
||||
* capability drops an ask. A machine that reports no support cannot have posted
|
||||
* one, so dropping it there is honest; while capability discovery is pending or
|
||||
* failed, hiding questions the daemon says are open would strand the model.
|
||||
*/
|
||||
private selectedPendingAsk(status: Pick<SessionStatus, "pendingAsk"> | undefined, machineId: string): PendingAskUser | undefined {
|
||||
if (status?.pendingAsk === undefined) return undefined;
|
||||
const runtime = this.getState().machineRuntimes[machineId];
|
||||
if (runtime?.ok === true && !supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsAskUser)) return undefined;
|
||||
return status.pendingAsk;
|
||||
}
|
||||
|
||||
private applySessionName(sessionId: string, name: string | undefined) {
|
||||
const rename = (session: SessionInfo) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
@@ -1285,6 +1353,16 @@ export class SessionController {
|
||||
}
|
||||
|
||||
this.flushPendingUpdates();
|
||||
// Ask frames are applied after the buffered status they were published with,
|
||||
// so the card follows the daemon's own open/close order.
|
||||
if (event.type === "ask.opened") {
|
||||
this.applyOpenedAsk(event.ask);
|
||||
return;
|
||||
}
|
||||
if (event.type === "ask.closed") {
|
||||
this.applyClosedAsk(event.askId);
|
||||
return;
|
||||
}
|
||||
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
|
||||
if (transcript) {
|
||||
this.setState({ messages: transcript });
|
||||
|
||||
@@ -114,6 +114,23 @@ describe("notification socket guards", () => {
|
||||
expect(parseSessionSocketEvent({ type: "session.startup", activity })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts validated ask frames and drops malformed ones", () => {
|
||||
const ask = {
|
||||
askId: "ask-1",
|
||||
askedAt: "2026-07-20T00:00:00.000Z",
|
||||
questions: [{ id: "q1", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true }],
|
||||
};
|
||||
|
||||
expect(parseSessionSocketEvent({ type: "ask.opened", ask })).toEqual({ type: "ask.opened", ask });
|
||||
expect(parseSessionSocketEvent({ type: "ask.closed", askId: "ask-1", reason: "superseded" }))
|
||||
.toEqual({ type: "ask.closed", askId: "ask-1", reason: "superseded" });
|
||||
expect(parseSessionSocketEvent({ type: "ask.opened", ask: { ...ask, questions: [] } })).toBeUndefined();
|
||||
expect(parseSessionSocketEvent({ type: "ask.opened" })).toBeUndefined();
|
||||
expect(parseSessionSocketEvent({ type: "ask.closed", askId: "ask-1", reason: "ignored" })).toBeUndefined();
|
||||
// Ask frames are per-session only, so they must not be accepted globally.
|
||||
expect(parseRealtimeSocketEvent({ type: "ask.opened", ask })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves existing event acceptance without treating unknown types as realtime events", () => {
|
||||
expect(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
|
||||
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { realtimeEvents, sessionEvents } from "./api";
|
||||
import { parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
|
||||
import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
|
||||
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
|
||||
|
||||
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
|
||||
@@ -155,6 +155,10 @@ export class RealtimeSocket {
|
||||
export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefined {
|
||||
const type = eventType(event);
|
||||
if (type === "notifications.inbox") return safelyParseNotificationEvent(() => parseSessionNotificationInboxEvent(event));
|
||||
// Ask frames drive an interactive form the user answers on the model's behalf,
|
||||
// so they are validated rather than accepted on their type alone.
|
||||
if (type === "ask.opened") return safelyParseValidatedEvent(() => parseSessionAskOpenedEvent(event));
|
||||
if (type === "ask.closed") return safelyParseValidatedEvent(() => parseSessionAskClosedEvent(event));
|
||||
return isLegacySessionUiEvent(event) ? event : undefined;
|
||||
}
|
||||
|
||||
|
||||
+40
-1
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, askUserEnabled, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
@@ -182,6 +182,26 @@ describe("PI WEB config persistence", () => {
|
||||
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
|
||||
});
|
||||
|
||||
it("resolves askUser in the effective config so the runtime has a single source of truth", async () => {
|
||||
expect(effectivePiWebConfig(testOptions()).config.askUser).toBe(true);
|
||||
|
||||
await writeFile(configPath, `${JSON.stringify({ askUser: false }, null, 2)}\n`, "utf8");
|
||||
|
||||
expect(effectivePiWebConfig(testOptions()).config.askUser).toBe(false);
|
||||
expect(effectivePiWebConfig({ ...testOptions(), env: { ...testOptions().env, PI_WEB_ASK_USER: "1" } }).config.askUser).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips the askUser key through save and load", () => {
|
||||
expect(savePiWebConfig({ askUser: false }, testOptions()).config).toEqual({ askUser: false });
|
||||
expect(loadPiWebConfig(testOptions()).config).toEqual({ askUser: false });
|
||||
});
|
||||
|
||||
it("rejects a non-boolean askUser key", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ askUser: "yes" }, null, 2)}\n`, "utf8");
|
||||
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config askUser must be a boolean");
|
||||
});
|
||||
|
||||
it("rejects upload defaults that are not workspace-relative", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8");
|
||||
|
||||
@@ -233,6 +253,25 @@ describe("subsessionsEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("askUserEnabled", () => {
|
||||
it("is on by default because the user is present for every ask", () => {
|
||||
expect(askUserEnabled({}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("honors an explicit config opt-out", () => {
|
||||
expect(askUserEnabled({}, { askUser: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the env var override the config in both directions", () => {
|
||||
expect(askUserEnabled({ PI_WEB_ASK_USER: "0" }, { askUser: true })).toBe(false);
|
||||
expect(askUserEnabled({ PI_WEB_ASK_USER: "true" }, { askUser: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an empty env value as unset", () => {
|
||||
expect(askUserEnabled({ PI_WEB_ASK_USER: "" }, { askUser: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offlineModeEnabled", () => {
|
||||
it("is off when no offline env var is set", () => {
|
||||
expect(offlineModeEnabled({})).toBe(false);
|
||||
|
||||
+25
-1
@@ -15,10 +15,11 @@ export interface LoadedPiWebConfig {
|
||||
config: PiWebConfig;
|
||||
}
|
||||
|
||||
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "agent"> {
|
||||
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "askUser" | "agent"> {
|
||||
uploads: NonNullable<PiWebConfig["uploads"]>;
|
||||
spawnSessions: boolean;
|
||||
subsessions: boolean;
|
||||
askUser: boolean;
|
||||
agent: Required<NonNullable<PiWebConfig["agent"]>>;
|
||||
}
|
||||
|
||||
@@ -156,6 +157,8 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options:
|
||||
spawnSessions: spawnSessionsEnabled(env, loaded.config),
|
||||
// Beta capability, resolved off by default.
|
||||
subsessions: subsessionsEnabled(env, loaded.config),
|
||||
// Always resolved (on by default); the user is present for every ask.
|
||||
askUser: askUserEnabled(env, loaded.config),
|
||||
agent: { command: agent.command, dir: agent.dir },
|
||||
},
|
||||
};
|
||||
@@ -178,6 +181,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
||||
delete existing["maxUploadBytes"];
|
||||
delete existing["spawnSessions"];
|
||||
delete existing["subsessions"];
|
||||
delete existing["askUser"];
|
||||
delete existing["agent"];
|
||||
const merged = { ...existing, ...piWebConfigRecord(normalized) };
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -204,6 +208,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
...(config.askUser !== undefined ? { askUser: config.askUser } : {}),
|
||||
...(config.agent !== undefined ? { agent: config.agent } : {}),
|
||||
};
|
||||
}
|
||||
@@ -220,6 +225,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
|
||||
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
|
||||
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
|
||||
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
|
||||
...(value["askUser"] !== undefined ? { askUser: parseAskUser(value["askUser"], path) } : {}),
|
||||
...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -266,6 +272,24 @@ export function subsessionsEnabled(env: NodeJS.ProcessEnv = process.env, config:
|
||||
return config.subsessions ?? false;
|
||||
}
|
||||
|
||||
function parseAskUser(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`PI WEB config askUser must be a boolean: ${path}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether LLMs may post a question set to the browser via the ask_user tool. On
|
||||
* by default: the questions land in the session the user is already watching and
|
||||
* nothing happens without them acting. Set the env var `PI_WEB_ASK_USER` or the
|
||||
* `askUser` config key to `false` to remove the tool. The env var takes
|
||||
* precedence over the config file.
|
||||
*/
|
||||
export function askUserEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean {
|
||||
const fromEnv = env["PI_WEB_ASK_USER"];
|
||||
if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true";
|
||||
return config.askUser ?? true;
|
||||
}
|
||||
|
||||
const OFFLINE_ENV_KEYS = ["PI_WEB_OFFLINE", "PI_OFFLINE"] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,6 +128,7 @@ function emptyConfigService(): PiWebConfigService {
|
||||
allowedHosts: false,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
askUser: false,
|
||||
agentCommand: false,
|
||||
agentDir: false,
|
||||
agentSessionDir: false,
|
||||
|
||||
@@ -208,7 +208,7 @@ export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigRespo
|
||||
exists: false,
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
|
||||
exists,
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SELECTED_MACHINE_CONFIG_KEYS = [
|
||||
"maxUploadBytes",
|
||||
"spawnSessions",
|
||||
"subsessions",
|
||||
"askUser",
|
||||
"agent",
|
||||
] as const satisfies readonly (keyof PiWebConfigValues)[];
|
||||
|
||||
@@ -131,6 +132,7 @@ function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "curr
|
||||
const maxUploadBytes = value["maxUploadBytes"];
|
||||
const spawnSessions = value["spawnSessions"];
|
||||
const subsessions = value["subsessions"];
|
||||
const askUser = value["askUser"];
|
||||
const agent = value["agent"];
|
||||
if (host !== undefined) {
|
||||
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
|
||||
@@ -154,6 +156,10 @@ function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "curr
|
||||
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
|
||||
config.subsessions = subsessions;
|
||||
}
|
||||
if (askUser !== undefined) {
|
||||
if (typeof askUser !== "boolean") throw new Error("PI WEB config askUser must be a boolean");
|
||||
config.askUser = askUser;
|
||||
}
|
||||
if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost);
|
||||
return config;
|
||||
}
|
||||
@@ -166,6 +172,7 @@ function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
...(config.askUser !== undefined ? { askUser: config.askUser } : {}),
|
||||
...(config.agent !== undefined ? { agent: config.agent } : {}),
|
||||
};
|
||||
}
|
||||
@@ -241,6 +248,8 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
|
||||
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
|
||||
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
|
||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||
// Older responses predate the ask_user tool; treat a missing flag as "not overridden".
|
||||
askUser: optionalResponseBoolean(record, "askUser", source) ?? false,
|
||||
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
|
||||
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
|
||||
...optionalAgentDirSource(record, source),
|
||||
@@ -288,6 +297,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {
|
||||
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
|
||||
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
|
||||
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
|
||||
askUser: isEnvSet(env["PI_WEB_ASK_USER"]),
|
||||
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
|
||||
agentDir: hasAgentDirEnvOverride(env, command),
|
||||
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
|
||||
|
||||
@@ -78,6 +78,7 @@ await runSessionDaemonStartup({
|
||||
logger: app.log,
|
||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||
subsessionsEnabled: config.subsessions,
|
||||
askUserEnabled: config.askUser,
|
||||
notificationStore,
|
||||
unreadStore,
|
||||
catalogRefreshStatus: catalogRefresher,
|
||||
|
||||
@@ -24,6 +24,7 @@ function daemonCollaborators(patch: Partial<SessionServiceDependencyInput> = {})
|
||||
unreadStore: new SessionUnreadStore(),
|
||||
catalogRefreshStatus: { isRefreshInFlight: () => false },
|
||||
subsessionsEnabled: false,
|
||||
askUserEnabled: true,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -87,4 +88,9 @@ describe("sessiond session service dependency assembly", () => {
|
||||
expect(withSpawnTargets.spawnTargets).toBe(spawnTargets);
|
||||
expect(withSpawnTargets.subsessionsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the ask-user preference through to the session service", () => {
|
||||
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: true })).askUserEnabled).toBe(true);
|
||||
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: false })).askUserEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface SessionServiceDependencyInput {
|
||||
spawnTargets?: NonNullable<PiSessionServiceDependencies["spawnTargets"]>;
|
||||
/** The operator's subsessions preference, which also requires spawning. */
|
||||
subsessionsEnabled: boolean;
|
||||
/** Whether agents may post structured question sets to the browser. */
|
||||
askUserEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +45,7 @@ export function sessionServiceDependencies(input: SessionServiceDependencyInput)
|
||||
// Tracked subsessions share the spawn capability's project-scope resolver,
|
||||
// so they stay off unless spawning is configured too.
|
||||
subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled,
|
||||
askUserEnabled: input.askUserEnabled,
|
||||
notificationStore: input.notificationStore,
|
||||
unreadStore: input.unreadStore,
|
||||
// Read-only, so session startup can tell a waiting user that provider
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAskUserToolDefinition, type AskUserInvocation } from "./askUserTool.js";
|
||||
import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js";
|
||||
|
||||
function ctxFor(sessionId: string): ExtensionContext {
|
||||
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => undefined };
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool uses.
|
||||
return { sessionManager } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
||||
const first = content[0];
|
||||
return first?.type === "text" ? first.text : "";
|
||||
}
|
||||
|
||||
/** The tool over a real store, so the schema and the store's contract stay aligned. */
|
||||
function toolOverStore(askIds: string[] = ["ask-1", "ask-2"]) {
|
||||
const remaining = [...askIds];
|
||||
const store = new PendingAskStore({
|
||||
now: () => new Date("2026-02-01T10:00:00.000Z"),
|
||||
createAskId: () => remaining.shift() ?? "ask-exhausted",
|
||||
});
|
||||
const open = vi.fn((input: AskUserInvocation) => Promise.resolve(store.open(input)));
|
||||
return { store, open, tool: createAskUserToolDefinition({ open }) };
|
||||
}
|
||||
|
||||
const twoQuestions = {
|
||||
questions: [
|
||||
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }] },
|
||||
{ id: "why", question: "Why?", options: [] },
|
||||
],
|
||||
};
|
||||
|
||||
describe("createAskUserToolDefinition", () => {
|
||||
it("advertises a non-blocking question set whose answers arrive as a follow-up", () => {
|
||||
const { tool } = toolOverStore();
|
||||
|
||||
expect(tool.name).toBe("ask_user");
|
||||
expect(tool.description).toBe("Post a set of questions to the user as a browser form and end this run. Answers arrive later as a follow-up message; the user may leave any question unanswered.");
|
||||
expect(tool.promptSnippet).toBe("ask_user: post a question set to the user; ends the run, answers return as a follow-up");
|
||||
expect(tool.promptGuidelines).toEqual([
|
||||
"When you need decisions from the user, post them together with ask_user instead of asking in prose one at a time. It ends the run and the answers, including the questions the user left unanswered, come back as a follow-up message that wakes you. Call it alone and last, and do not repost the same questions or poll for answers.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds the question set in its parameter schema", () => {
|
||||
const { tool } = toolOverStore();
|
||||
|
||||
expect(tool.parameters).toMatchObject({
|
||||
type: "object",
|
||||
required: ["questions"],
|
||||
properties: { questions: { type: "array", minItems: 1, maxItems: 20 } },
|
||||
});
|
||||
expect(tool.parameters).not.toHaveProperty("properties.questions.items.properties.allowOther");
|
||||
});
|
||||
|
||||
it("opens the ask for the calling session and terminates the run instead of awaiting the user", async () => {
|
||||
const { open, tool } = toolOverStore();
|
||||
|
||||
const result = await tool.execute("call-1", twoQuestions, undefined, undefined, ctxFor("session-1"));
|
||||
|
||||
expect(open).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
questions: [
|
||||
{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }, { value: "sqlite", label: "SQLite" }], allowOther: true },
|
||||
{ id: "why", question: "Why?", options: [], allowOther: true },
|
||||
],
|
||||
});
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(result.details).toMatchObject({ ask: { askId: "ask-1", questions: [{ id: "db" }, { id: "why" }] } });
|
||||
expect(firstText(result.content)).toBe("Posted 2 questions to the user as ask ask-1. Ending this run; the answers arrive as a follow-up message that wakes you, naming every question the user left unanswered. Do not repost these questions.");
|
||||
});
|
||||
|
||||
it("defaults a question without options to an empty option list so free text alone is expressible", async () => {
|
||||
const { open, tool } = toolOverStore();
|
||||
|
||||
await tool.execute("call-free", { questions: [{ id: "note", question: "Anything else?" }] }, undefined, undefined, ctxFor("session-1"));
|
||||
|
||||
expect(open).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
questions: [{ id: "note", question: "Anything else?", options: [], allowOther: true }],
|
||||
});
|
||||
});
|
||||
|
||||
it("adds custom answers while preserving detail, option detail, and multi-select", async () => {
|
||||
const { open, tool } = toolOverStore();
|
||||
|
||||
await tool.execute("call-rich", {
|
||||
questions: [{
|
||||
id: "targets",
|
||||
question: "Which targets?",
|
||||
detail: "Pick every platform we should build for.",
|
||||
options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }],
|
||||
multiple: true,
|
||||
}],
|
||||
}, undefined, undefined, ctxFor("session-1"));
|
||||
|
||||
expect(open).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
questions: [{
|
||||
id: "targets",
|
||||
question: "Which targets?",
|
||||
detail: "Pick every platform we should build for.",
|
||||
options: [{ value: "web", label: "Web", detail: "Chromium and Firefox" }, { value: "cli", label: "CLI" }],
|
||||
allowOther: true,
|
||||
multiple: true,
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it("tells the model which questions the superseded ask left unanswered", async () => {
|
||||
const { tool } = toolOverStore();
|
||||
await tool.execute("call-first", twoQuestions, undefined, undefined, ctxFor("session-1"));
|
||||
|
||||
const result = await tool.execute("call-second", { questions: [{ id: "again", question: "Still there?", options: [{ value: "yes", label: "Yes" }] }] }, undefined, undefined, ctxFor("session-1"));
|
||||
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(firstText(result.content)).toContain("Posted 1 question to the user as ask ask-2.");
|
||||
expect(firstText(result.content)).toContain("This replaced an earlier question set (ask-1) that the user never submitted.");
|
||||
expect(firstText(result.content)).toContain("Left unanswered: db, why.");
|
||||
expect(result.details).toMatchObject({ superseded: { askId: "ask-1", reason: "superseded", unansweredIds: ["db", "why"] } });
|
||||
});
|
||||
|
||||
it("says nothing about superseding when no earlier ask was open", async () => {
|
||||
const { tool } = toolOverStore();
|
||||
|
||||
const result = await tool.execute("call-only", twoQuestions, undefined, undefined, ctxFor("session-1"));
|
||||
|
||||
expect(firstText(result.content)).not.toContain("replaced an earlier question set");
|
||||
expect(result.details).not.toHaveProperty("superseded");
|
||||
});
|
||||
|
||||
it("propagates a rejected question set so the agent loop reports it to the model", async () => {
|
||||
const { tool } = toolOverStore();
|
||||
|
||||
await expect(tool.execute("call-dup", {
|
||||
questions: [
|
||||
{ id: "same", question: "First?", options: [{ value: "a", label: "A" }] },
|
||||
{ id: "same", question: "Second?", options: [{ value: "b", label: "B" }] },
|
||||
],
|
||||
}, undefined, undefined, ctxFor("session-1"))).rejects.toThrow(PendingAskValidationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Type, type Static } from "typebox";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
ASK_USER_ID_MAX_LENGTH,
|
||||
ASK_USER_OPTION_LIMIT,
|
||||
ASK_USER_QUESTION_LIMIT,
|
||||
ASK_USER_TEXT_MAX_LENGTH,
|
||||
type AskUserQuestion,
|
||||
type AskUserQuestionOption,
|
||||
} from "../../shared/apiTypes.js";
|
||||
import { renderSupersededAskText, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||
|
||||
/** One `ask_user` call: the questions to post, for the session that called the tool. */
|
||||
export interface AskUserInvocation {
|
||||
sessionId: string;
|
||||
questions: AskUserQuestion[];
|
||||
}
|
||||
|
||||
export interface AskUserToolDeps {
|
||||
/** Registers the ask as the session's open one; rejects malformed question sets. */
|
||||
open(input: AskUserInvocation): Promise<PendingAskOpenResult>;
|
||||
}
|
||||
|
||||
type AskUserToolDetails = PendingAskOpenResult;
|
||||
|
||||
const AskUserOptionParams = Type.Object({
|
||||
value: Type.String({
|
||||
maxLength: ASK_USER_ID_MAX_LENGTH,
|
||||
description: "Stable machine value reported back to you when the user picks this option.",
|
||||
}),
|
||||
label: Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "Short label the user reads, ideally a few words.",
|
||||
}),
|
||||
detail: Type.Optional(Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "Optional clarification shown under the label.",
|
||||
})),
|
||||
});
|
||||
|
||||
const AskUserQuestionParams = Type.Object({
|
||||
id: Type.String({
|
||||
maxLength: ASK_USER_ID_MAX_LENGTH,
|
||||
description: "Unique within this call; used as the answer key reported back to you.",
|
||||
}),
|
||||
question: Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "The question itself, as one plain-text line.",
|
||||
}),
|
||||
detail: Type.Optional(Type.String({
|
||||
maxLength: ASK_USER_TEXT_MAX_LENGTH,
|
||||
description: "Optional supporting context shown under the question.",
|
||||
})),
|
||||
options: Type.Optional(Type.Array(AskUserOptionParams, {
|
||||
maxItems: ASK_USER_OPTION_LIMIT,
|
||||
description: "Options to choose from. Omit when free text is the whole answer; the browser always adds a Custom choice.",
|
||||
})),
|
||||
multiple: Type.Optional(Type.Boolean({
|
||||
description: "Allow several options at once. Default: one answer per question.",
|
||||
})),
|
||||
});
|
||||
|
||||
const AskUserParams = Type.Object({
|
||||
questions: Type.Array(AskUserQuestionParams, {
|
||||
minItems: 1,
|
||||
maxItems: ASK_USER_QUESTION_LIMIT,
|
||||
description: "The questions to post, in the order the user should read them. Every question may be left unanswered.",
|
||||
}),
|
||||
});
|
||||
|
||||
/** Shapes one schema question into the domain question; the store owns validation. */
|
||||
function toQuestion(param: Static<typeof AskUserQuestionParams>): AskUserQuestion {
|
||||
const { detail, options, multiple } = param;
|
||||
return {
|
||||
id: param.id,
|
||||
question: param.question,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
options: (options ?? []).map(toOption),
|
||||
// Keep the compatibility marker on the daemon wire even though the model no
|
||||
// longer chooses whether a question accepts a custom answer.
|
||||
allowOther: true,
|
||||
...(multiple === undefined ? {} : { multiple }),
|
||||
};
|
||||
}
|
||||
|
||||
function toOption(param: Static<typeof AskUserOptionParams>): AskUserQuestionOption {
|
||||
const { detail } = param;
|
||||
return { value: param.value, label: param.label, ...(detail === undefined ? {} : { detail }) };
|
||||
}
|
||||
|
||||
function postedText(result: PendingAskOpenResult): string {
|
||||
const count = result.ask.questions.length;
|
||||
const posted = `Posted ${count.toString()} question${count === 1 ? "" : "s"} to the user as ask ${result.ask.askId}. Ending this run; the answers arrive as a follow-up message that wakes you, naming every question the user left unanswered. Do not repost these questions.`;
|
||||
return result.superseded === undefined ? posted : `${posted}\n\n${renderSupersededAskText(result.superseded)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom tool that posts a question set to the user's browser as interactive UI.
|
||||
*
|
||||
* It deliberately does **not** await the user. Awaiting would pin the agent run
|
||||
* for an unbounded human-scale wait, keep the session streaming, and leave a
|
||||
* dangling tool call if the runtime were replaced meanwhile. Instead the ask
|
||||
* becomes daemon-owned state, the tool terminates the run, and the submitted
|
||||
* answers return later as a follow-up message that wakes the session.
|
||||
*
|
||||
* Rejected question sets throw: the agent loop turns the thrown message into an
|
||||
* error tool result, so the model can fix the ask and post it again.
|
||||
*/
|
||||
export function createAskUserToolDefinition(deps: AskUserToolDeps) {
|
||||
return defineTool<typeof AskUserParams, AskUserToolDetails>({
|
||||
name: "ask_user",
|
||||
label: "Ask user",
|
||||
description: "Post a set of questions to the user as a browser form and end this run. Answers arrive later as a follow-up message; the user may leave any question unanswered.",
|
||||
promptSnippet: "ask_user: post a question set to the user; ends the run, answers return as a follow-up",
|
||||
promptGuidelines: [
|
||||
"When you need decisions from the user, post them together with ask_user instead of asking in prose one at a time. It ends the run and the answers, including the questions the user left unanswered, come back as a follow-up message that wakes you. Call it alone and last, and do not repost the same questions or poll for answers.",
|
||||
],
|
||||
parameters: AskUserParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const result = await deps.open({
|
||||
sessionId: ctx.sessionManager.getSessionId(),
|
||||
questions: params.questions.map(toQuestion),
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: postedText(result) }],
|
||||
details: result,
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ASK_USER_OPTION_LIMIT, ASK_USER_QUESTION_LIMIT, type AskUserQuestion } from "../../shared/apiTypes.js";
|
||||
import {
|
||||
PendingAskStore,
|
||||
PendingAskValidationError,
|
||||
renderAskUserAnswersText,
|
||||
renderSupersededAskText,
|
||||
} from "./pendingAskStore.js";
|
||||
|
||||
const sessionId = "session-1";
|
||||
|
||||
function testStore() {
|
||||
let askCount = 0;
|
||||
let tick = 0;
|
||||
return new PendingAskStore({
|
||||
createAskId: () => `ask-${(++askCount).toString()}`,
|
||||
now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)),
|
||||
});
|
||||
}
|
||||
|
||||
function question(id: string, overrides: Partial<AskUserQuestion> = {}): AskUserQuestion {
|
||||
return {
|
||||
id,
|
||||
question: `Question ${id}?`,
|
||||
options: [
|
||||
{ value: "yes", label: "Yes" },
|
||||
{ value: "no", label: "No" },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function openTwoQuestions(store: PendingAskStore) {
|
||||
return store.open({ sessionId, questions: [question("q1"), question("q2")] });
|
||||
}
|
||||
|
||||
describe("PendingAskStore validation", () => {
|
||||
it("normalizes an accepted ask and reports it as the session's pending ask", () => {
|
||||
const store = testStore();
|
||||
const result = store.open({
|
||||
sessionId,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
question: "Which database?",
|
||||
detail: "Only the primary store matters here.",
|
||||
options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }],
|
||||
allowOther: false,
|
||||
multiple: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.ask).toEqual({
|
||||
askId: "ask-1",
|
||||
askedAt: "2026-01-01T00:00:00.000Z",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
question: "Which database?",
|
||||
detail: "Only the primary store matters here.",
|
||||
options: [{ value: "pg", label: "Postgres", detail: "Existing cluster" }],
|
||||
allowOther: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.superseded).toBeUndefined();
|
||||
expect(store.pendingAsk(sessionId)).toEqual(result.ask);
|
||||
expect(store.pendingAsk("other-session")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects asks the user could not meaningfully answer", () => {
|
||||
const store = testStore();
|
||||
const reject = (questions: AskUserQuestion[]) => () => store.open({ sessionId, questions });
|
||||
|
||||
expect(reject([])).toThrow(PendingAskValidationError);
|
||||
expect(reject(Array.from({ length: ASK_USER_QUESTION_LIMIT + 1 }, (_, index) => question(`q${index.toString()}`))))
|
||||
.toThrow(/more than 20 questions/);
|
||||
expect(reject([question("q1"), question("q1")])).toThrow(/Duplicate question id q1/);
|
||||
expect(reject([question(" ")])).toThrow(/question id must not be empty/);
|
||||
expect(reject([question("q1", { question: " " })])).toThrow(/text of question q1 must not be empty/);
|
||||
expect(reject([question("q1", { options: [{ value: "a", label: "A" }, { value: "a", label: "Again" }] })]))
|
||||
.toThrow(/Duplicate option value a in question q1/);
|
||||
expect(reject([question("q1", { options: [{ value: "a", label: " " }] })]))
|
||||
.toThrow(/label of option a in question q1 must not be empty/);
|
||||
expect(reject([question("q1", {
|
||||
options: Array.from({ length: ASK_USER_OPTION_LIMIT + 1 }, (_, index) => ({ value: `v${index.toString()}`, label: "L" })),
|
||||
})])).toThrow(/more than 12 options/);
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts an optionless question and adds the custom-answer compatibility marker", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [] })] });
|
||||
expect(ask.questions[0]).toEqual({ id: "q1", question: "Question q1?", options: [], allowOther: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PendingAskStore submit", () => {
|
||||
it("reports answered and unanswered questions for a partial submit", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1"), question("q2"), question("q3")],
|
||||
});
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q2", values: ["no"] }] });
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "closed",
|
||||
outcome: {
|
||||
askId: "ask-1",
|
||||
reason: "submitted",
|
||||
askedAt: "2026-01-01T00:00:00.000Z",
|
||||
closedAt: "2026-01-01T00:00:01.000Z",
|
||||
questions: [
|
||||
{ question: ask.questions[0], answered: false, values: [] },
|
||||
{ question: ask.questions[1], answered: true, values: ["no"] },
|
||||
{ question: ask.questions[2], answered: false, values: [] },
|
||||
],
|
||||
answeredCount: 1,
|
||||
unansweredIds: ["q1", "q3"],
|
||||
summary: "Answered 1 of 3; unanswered: q1, q3",
|
||||
},
|
||||
});
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("summarizes a fully answered ask without an unanswered list", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: ["no"] }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "closed",
|
||||
outcome: { answeredCount: 2, unansweredIds: [], summary: "Answered 2 of 2; none left unanswered" },
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty answer as leaving the question untouched", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({ sessionId, questions: [question("q1"), question("q2")] });
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: [] }, { id: "q2", values: [], otherText: " " }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: "closed", outcome: { answeredCount: 0, unansweredIds: ["q1", "q2"] } });
|
||||
});
|
||||
|
||||
it("rejects several values for a single-select question and keeps the ask open", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(() => store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: ["yes", "no"] }] }))
|
||||
.toThrow(/Question q1 accepts a single answer/);
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe(ask.askId);
|
||||
});
|
||||
|
||||
it("accepts several values and coexisting custom text for a multi-select question", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1", { multiple: true })],
|
||||
});
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: ["yes", "no"], otherText: " maybe later " }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "closed",
|
||||
outcome: {
|
||||
questions: [{ answered: true, values: ["yes", "no"], otherText: "maybe later" }],
|
||||
answeredCount: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts custom text for every question", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "custom" }] });
|
||||
|
||||
expect(result).toMatchObject({ status: "closed", outcome: { answeredCount: 1 } });
|
||||
if (result.status !== "closed") throw new Error("expected the ask to close");
|
||||
expect(result.outcome.questions[0]).toMatchObject({ answered: true, values: [], otherText: "custom" });
|
||||
});
|
||||
|
||||
it("answers a free-text-only question with custom text alone", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({ sessionId, questions: [question("q1", { options: [] })] });
|
||||
|
||||
const result = store.submit(sessionId, ask.askId, { answers: [{ id: "q1", values: [], otherText: "a note" }] });
|
||||
|
||||
expect(result).toMatchObject({ status: "closed", outcome: { questions: [{ answered: true, values: [], otherText: "a note" }] } });
|
||||
});
|
||||
|
||||
it("rejects unknown, duplicated, and unoffered answers", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
const reject = (answers: Parameters<typeof store.submit>[2]["answers"]) => () => store.submit(sessionId, ask.askId, { answers });
|
||||
|
||||
expect(reject([{ id: "q9", values: ["yes"] }])).toThrow(/Unknown question id q9/);
|
||||
expect(reject([{ id: "q1", values: ["yes"] }, { id: "q1", values: ["no"] }])).toThrow(/Duplicate answer for question q1/);
|
||||
expect(reject([{ id: "q1", values: ["nope"] }])).toThrow(/Question q1 has no option nope/);
|
||||
expect(reject([{ id: "q1", values: ["yes", "yes"] }])).toThrow(/Duplicate value yes for question q1/);
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe(ask.askId);
|
||||
});
|
||||
|
||||
it("treats a submit or cancel for an ask that is no longer open as stale", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(store.submit(sessionId, "ask-other", { answers: [] })).toEqual({ status: "stale" });
|
||||
expect(store.cancel(sessionId, "ask-other")).toEqual({ status: "stale" });
|
||||
expect(store.submit("session-2", ask.askId, { answers: [] })).toEqual({ status: "stale" });
|
||||
|
||||
store.submit(sessionId, ask.askId, { answers: [] });
|
||||
expect(store.submit(sessionId, ask.askId, { answers: [] })).toEqual({ status: "stale" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PendingAskStore close transitions", () => {
|
||||
it("supersedes an unanswered ask and reports its questions as unanswered", () => {
|
||||
const store = testStore();
|
||||
const first = openTwoQuestions(store);
|
||||
|
||||
const second = store.open({ sessionId, questions: [question("q3")] });
|
||||
|
||||
expect(second.ask.askId).toBe("ask-2");
|
||||
expect(second.superseded).toEqual({
|
||||
askId: "ask-1",
|
||||
reason: "superseded",
|
||||
askedAt: "2026-01-01T00:00:00.000Z",
|
||||
closedAt: "2026-01-01T00:00:01.000Z",
|
||||
questions: [
|
||||
{ question: first.ask.questions[0], answered: false, values: [] },
|
||||
{ question: first.ask.questions[1], answered: false, values: [] },
|
||||
],
|
||||
answeredCount: 0,
|
||||
unansweredIds: ["q1", "q2"],
|
||||
summary: "Answered 0 of 2; unanswered: q1, q2",
|
||||
});
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe("ask-2");
|
||||
expect(store.submit(sessionId, first.ask.askId, { answers: [] })).toEqual({ status: "stale" });
|
||||
});
|
||||
|
||||
it("does not supersede an ask that belongs to another session", () => {
|
||||
const store = testStore();
|
||||
const first = openTwoQuestions(store);
|
||||
|
||||
const second = store.open({ sessionId: "session-2", questions: [question("q3")] });
|
||||
|
||||
expect(second.superseded).toBeUndefined();
|
||||
expect(store.pendingAsk(sessionId)?.askId).toBe(first.ask.askId);
|
||||
});
|
||||
|
||||
it("cancels an open ask as fully unanswered", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
expect(store.cancel(sessionId, ask.askId)).toMatchObject({
|
||||
status: "closed",
|
||||
outcome: { reason: "cancelled", answeredCount: 0, unansweredIds: ["q1", "q2"] },
|
||||
});
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forgets the open ask of a session that goes away without reporting an outcome", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
|
||||
store.forgetSession(sessionId);
|
||||
|
||||
expect(store.pendingAsk(sessionId)).toBeUndefined();
|
||||
expect(store.cancel(sessionId, ask.askId)).toEqual({ status: "stale" });
|
||||
});
|
||||
|
||||
it("keeps each session's open ask separate", () => {
|
||||
const store = testStore();
|
||||
const first = openTwoQuestions(store);
|
||||
const second = store.open({ sessionId: "session-2", questions: [question("q3")] });
|
||||
|
||||
expect([store.pendingAsk(sessionId)?.askId, store.pendingAsk("session-2")?.askId])
|
||||
.toEqual([first.ask.askId, second.ask.askId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ask outcome rendering", () => {
|
||||
it("names answered and unanswered questions in the model-facing text", () => {
|
||||
const store = testStore();
|
||||
const { ask } = store.open({
|
||||
sessionId,
|
||||
questions: [question("q1"), question("q2"), question("q3")],
|
||||
});
|
||||
const result = store.submit(sessionId, ask.askId, {
|
||||
answers: [{ id: "q1", values: ["yes"] }, { id: "q2", values: [], otherText: "something else" }],
|
||||
});
|
||||
if (result.status !== "closed") throw new Error("expected the ask to close");
|
||||
|
||||
expect(renderAskUserAnswersText(result.outcome)).toBe([
|
||||
"The user submitted answers to your questions.",
|
||||
"",
|
||||
"- q1: Question q1?",
|
||||
" Answered: selected yes",
|
||||
"- q2: Question q2?",
|
||||
` Answered: custom: "something else"`,
|
||||
"- q3: Question q3?",
|
||||
" Unanswered.",
|
||||
"",
|
||||
"Answered 2 of 3; unanswered: q3",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("tells the model a cancelled ask was closed before it was answered", () => {
|
||||
const store = testStore();
|
||||
const { ask } = openTwoQuestions(store);
|
||||
const result = store.cancel(sessionId, ask.askId);
|
||||
if (result.status !== "closed") throw new Error("expected the ask to close");
|
||||
|
||||
expect(renderAskUserAnswersText(result.outcome)).toContain("closed (cancelled) before it was fully answered");
|
||||
});
|
||||
|
||||
it("names the abandoned questions when an ask is superseded", () => {
|
||||
const store = testStore();
|
||||
openTwoQuestions(store);
|
||||
const superseded = store.open({ sessionId, questions: [question("q3")] }).superseded;
|
||||
if (superseded === undefined) throw new Error("expected a superseded outcome");
|
||||
|
||||
expect(renderSupersededAskText(superseded)).toBe([
|
||||
"This replaced an earlier question set (ask-1) that the user never submitted.",
|
||||
"Left unanswered: q1, q2.",
|
||||
].join("\n"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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,
|
||||
type AskUserAnswer,
|
||||
type AskUserCloseReason,
|
||||
type AskUserOutcome,
|
||||
type AskUserQuestion,
|
||||
type AskUserQuestionOption,
|
||||
type AskUserQuestionRecord,
|
||||
type AskUserSubmission,
|
||||
type PendingAskUser,
|
||||
} from "../../shared/apiTypes.js";
|
||||
|
||||
export interface PendingAskStoreOptions {
|
||||
now?: (() => Date) | undefined;
|
||||
createAskId?: (() => string) | undefined;
|
||||
}
|
||||
|
||||
/** A question set an agent wants to post to the user of one session. */
|
||||
export interface PendingAskOpenInput {
|
||||
sessionId: string;
|
||||
questions: AskUserQuestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A freshly opened ask, plus the outcome of the ask it replaced. A session holds
|
||||
* at most one open ask, so opening while one is still unanswered supersedes it —
|
||||
* and the caller must report that outcome to the model, naming the questions the
|
||||
* user never got to answer.
|
||||
*/
|
||||
export interface PendingAskOpenResult {
|
||||
ask: PendingAskUser;
|
||||
superseded?: AskUserOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of submitting or cancelling an ask. `"stale"` means the ask named by the
|
||||
* caller is no longer the session's open ask (already submitted, superseded, or
|
||||
* gone with its daemon-side session), which is an ordinary race a browser can
|
||||
* lose — not an error.
|
||||
*/
|
||||
export type PendingAskCloseResult =
|
||||
| { status: "closed"; outcome: AskUserOutcome }
|
||||
| { status: "stale" };
|
||||
|
||||
/** Rejected input: a question set is malformed, or an answer does not fit its question. */
|
||||
export class PendingAskValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "PendingAskValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
type RecordedAnswers = ReadonlyMap<string, AskUserAnswer>;
|
||||
|
||||
/**
|
||||
* Daemon-owned open-ask state: one unanswered question set per session.
|
||||
*
|
||||
* The store is pure domain logic — no Fastify, no Pi session, no I/O, no timers.
|
||||
* It validates asks and answers, owns the open/supersede/submit/cancel
|
||||
* transitions, and computes the answered-versus-unanswered outcome that both the
|
||||
* model-facing message and the browser record are rendered from. Callers publish
|
||||
* the returned asks and outcomes; the store never emits anything itself.
|
||||
*
|
||||
* State is deliberately daemon-lifetime and in-memory. An open ask is meaningful
|
||||
* only while the session runtime that posted it exists, and browsers rehydrate it
|
||||
* from `SessionStatus` rather than from disk.
|
||||
*/
|
||||
export class PendingAskStore {
|
||||
private readonly now: () => Date;
|
||||
private readonly createAskId: () => string;
|
||||
private readonly openBySessionId = new Map<string, PendingAskUser>();
|
||||
|
||||
constructor(options: PendingAskStoreOptions = {}) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.createAskId = options.createAskId ?? randomUUID;
|
||||
}
|
||||
|
||||
/** The session's open ask, for {@link SessionStatus} projection. */
|
||||
pendingAsk(sessionId: string): PendingAskUser | undefined {
|
||||
const ask = this.openBySessionId.get(requireSessionId(sessionId));
|
||||
return ask === undefined ? undefined : cloneAsk(ask);
|
||||
}
|
||||
|
||||
open(input: PendingAskOpenInput): PendingAskOpenResult {
|
||||
const sessionId = requireSessionId(input.sessionId);
|
||||
const questions = validateQuestions(input.questions);
|
||||
const askedAt = this.timestamp();
|
||||
const superseded = this.close(sessionId, "superseded", askedAt, new Map());
|
||||
const ask: PendingAskUser = {
|
||||
askId: requireId(this.createAskId(), "askId"),
|
||||
askedAt,
|
||||
questions,
|
||||
};
|
||||
this.openBySessionId.set(sessionId, ask);
|
||||
return {
|
||||
ask: cloneAsk(ask),
|
||||
...(superseded === undefined ? {} : { superseded }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the user replied and close the ask. Answers are validated against
|
||||
* the open ask, so a submission that does not fit its questions is rejected
|
||||
* rather than silently truncated; the ask stays open in that case.
|
||||
*/
|
||||
submit(sessionId: string, askId: string, submission: AskUserSubmission): PendingAskCloseResult {
|
||||
const ask = this.openBySessionId.get(requireSessionId(sessionId));
|
||||
if (ask?.askId !== askId) return { status: "stale" };
|
||||
// Validate before closing so a submission that does not fit its questions
|
||||
// leaves the ask open for the browser to correct.
|
||||
const answers = validateSubmission(ask, submission);
|
||||
return { status: "closed", outcome: this.requireClose(sessionId, "submitted", answers) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the ask without a submission. The outcome reports every question as
|
||||
* unanswered, because answers only ever reach the daemon through a submit.
|
||||
*/
|
||||
cancel(sessionId: string, askId: string): PendingAskCloseResult {
|
||||
const ask = this.openBySessionId.get(requireSessionId(sessionId));
|
||||
if (ask?.askId !== askId) return { status: "stale" };
|
||||
return { status: "closed", outcome: this.requireClose(sessionId, "cancelled", new Map()) };
|
||||
}
|
||||
|
||||
/** Drop the open ask of a session that is going away, without reporting an outcome. */
|
||||
forgetSession(sessionId: string): void {
|
||||
this.openBySessionId.delete(requireSessionId(sessionId));
|
||||
}
|
||||
|
||||
private requireClose(sessionId: string, reason: AskUserCloseReason, answers: RecordedAnswers): AskUserOutcome {
|
||||
const outcome = this.close(sessionId, reason, this.timestamp(), answers);
|
||||
if (outcome === undefined) throw new Error(`Pending ask of session ${sessionId} disappeared while closing`);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
private close(
|
||||
sessionId: string,
|
||||
reason: AskUserCloseReason,
|
||||
closedAt: string,
|
||||
answers: RecordedAnswers,
|
||||
): AskUserOutcome | undefined {
|
||||
const ask = this.openBySessionId.get(sessionId);
|
||||
if (ask === undefined) return undefined;
|
||||
this.openBySessionId.delete(sessionId);
|
||||
return askUserOutcome(ask, answers, reason, closedAt);
|
||||
}
|
||||
|
||||
private timestamp(): string {
|
||||
return this.now().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-facing text of a closed ask. The model reads this, so it must name the
|
||||
* unanswered questions as plainly as the answered ones.
|
||||
*/
|
||||
export function renderAskUserAnswersText(outcome: AskUserOutcome): string {
|
||||
const lead = outcome.reason === "submitted"
|
||||
? "The user submitted answers to your questions."
|
||||
: `The question set was closed (${outcome.reason}) before it was fully answered.`;
|
||||
return [lead, "", ...outcome.questions.map(questionLines).flat(), "", outcome.summary].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice for the model when a new ask replaced one the user never answered, so a
|
||||
* supersede is never a silent loss of the earlier questions.
|
||||
*/
|
||||
export function renderSupersededAskText(outcome: AskUserOutcome): string {
|
||||
return [
|
||||
`This replaced an earlier question set (${outcome.askId}) that the user never submitted.`,
|
||||
`Left unanswered: ${outcome.unansweredIds.join(", ")}.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function questionLines(record: AskUserQuestionRecord): string[] {
|
||||
const header = `- ${record.question.id}: ${record.question.question}`;
|
||||
if (!record.answered) return [header, " Unanswered."];
|
||||
const parts = [...record.values.map((value) => `selected ${value}`)];
|
||||
if (record.otherText !== undefined) parts.push(`custom: ${JSON.stringify(record.otherText)}`);
|
||||
return [header, ` Answered: ${parts.join("; ")}`];
|
||||
}
|
||||
|
||||
function askUserOutcome(
|
||||
ask: PendingAskUser,
|
||||
answers: RecordedAnswers,
|
||||
reason: AskUserCloseReason,
|
||||
closedAt: string,
|
||||
): AskUserOutcome {
|
||||
const questions = ask.questions.map((question) => questionRecord(question, answers.get(question.id)));
|
||||
const unansweredIds = questions.filter((record) => !record.answered).map((record) => record.question.id);
|
||||
const answeredCount = questions.length - unansweredIds.length;
|
||||
return {
|
||||
askId: ask.askId,
|
||||
reason,
|
||||
askedAt: ask.askedAt,
|
||||
closedAt,
|
||||
questions,
|
||||
answeredCount,
|
||||
unansweredIds,
|
||||
summary: summaryLine(questions.length, answeredCount, unansweredIds),
|
||||
};
|
||||
}
|
||||
|
||||
function questionRecord(question: AskUserQuestion, answer: AskUserAnswer | undefined): AskUserQuestionRecord {
|
||||
const values = answer?.values ?? [];
|
||||
const otherText = answer?.otherText;
|
||||
return {
|
||||
question: cloneQuestion(question),
|
||||
answered: values.length > 0 || otherText !== undefined,
|
||||
values: [...values],
|
||||
...(otherText === undefined ? {} : { otherText }),
|
||||
};
|
||||
}
|
||||
|
||||
function summaryLine(total: number, answeredCount: number, unansweredIds: string[]): string {
|
||||
const answered = `Answered ${answeredCount.toString()} of ${total.toString()}`;
|
||||
return unansweredIds.length === 0 ? `${answered}; none left unanswered` : `${answered}; unanswered: ${unansweredIds.join(", ")}`;
|
||||
}
|
||||
|
||||
function validateQuestions(questions: AskUserQuestion[]): AskUserQuestion[] {
|
||||
if (questions.length === 0) throw new PendingAskValidationError("An ask must contain at least one question");
|
||||
if (questions.length > ASK_USER_QUESTION_LIMIT) {
|
||||
throw new PendingAskValidationError(`An ask must not contain more than ${ASK_USER_QUESTION_LIMIT.toString()} questions`);
|
||||
}
|
||||
const seenIds = new Set<string>();
|
||||
return questions.map((question) => {
|
||||
const id = requireId(question.id, "question id");
|
||||
if (seenIds.has(id)) throw new PendingAskValidationError(`Duplicate question id ${id}`);
|
||||
seenIds.add(id);
|
||||
return validateQuestion(question, id);
|
||||
});
|
||||
}
|
||||
|
||||
function validateQuestion(question: AskUserQuestion, id: string): AskUserQuestion {
|
||||
if (question.options.length > ASK_USER_OPTION_LIMIT) {
|
||||
throw new PendingAskValidationError(`Question ${id} must not offer more than ${ASK_USER_OPTION_LIMIT.toString()} options`);
|
||||
}
|
||||
const seenValues = new Set<string>();
|
||||
const options = question.options.map((option) => {
|
||||
const value = requireId(option.value, `option value of question ${id}`);
|
||||
if (seenValues.has(value)) throw new PendingAskValidationError(`Duplicate option value ${value} in question ${id}`);
|
||||
seenValues.add(value);
|
||||
return validateOption(option, value, id);
|
||||
});
|
||||
const detail = question.detail;
|
||||
return {
|
||||
id,
|
||||
question: requireText(question.question, `text of question ${id}`),
|
||||
...(detail === undefined ? {} : { detail: requireText(detail, `detail of question ${id}`) }),
|
||||
options,
|
||||
// Every question accepts a custom answer. Retain the marker so older web
|
||||
// clients also expose the field when connected to this daemon.
|
||||
allowOther: true,
|
||||
...(question.multiple === true ? { multiple: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOption(option: AskUserQuestionOption, value: string, questionId: string): AskUserQuestionOption {
|
||||
const detail = option.detail;
|
||||
return {
|
||||
value,
|
||||
label: requireText(option.label, `label of option ${value} in question ${questionId}`),
|
||||
...(detail === undefined ? {} : { detail: requireText(detail, `detail of option ${value} in question ${questionId}`) }),
|
||||
};
|
||||
}
|
||||
|
||||
function validateSubmission(ask: PendingAskUser, submission: AskUserSubmission): Map<string, AskUserAnswer> {
|
||||
const questionsById = new Map(ask.questions.map((question) => [question.id, question]));
|
||||
const answers = new Map<string, AskUserAnswer>();
|
||||
for (const answer of submission.answers) {
|
||||
const question = questionsById.get(answer.id);
|
||||
if (question === undefined) throw new PendingAskValidationError(`Unknown question id ${answer.id}`);
|
||||
if (answers.has(answer.id)) throw new PendingAskValidationError(`Duplicate answer for question ${answer.id}`);
|
||||
const validated = validateAnswer(question, answer);
|
||||
// Untouched questions and explicitly empty answers are the same thing, so an
|
||||
// empty answer is dropped rather than recorded as answered.
|
||||
if (validated !== undefined) answers.set(answer.id, validated);
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
function validateAnswer(question: AskUserQuestion, answer: AskUserAnswer): AskUserAnswer | undefined {
|
||||
const optionValues = new Set(question.options.map((option) => option.value));
|
||||
const values: string[] = [];
|
||||
for (const value of answer.values) {
|
||||
if (!optionValues.has(value)) throw new PendingAskValidationError(`Question ${question.id} has no option ${value}`);
|
||||
if (values.includes(value)) throw new PendingAskValidationError(`Duplicate value ${value} for question ${question.id}`);
|
||||
values.push(value);
|
||||
}
|
||||
const otherText = normalizeOtherText(question, answer.otherText);
|
||||
const selectionCount = values.length + (otherText === undefined ? 0 : 1);
|
||||
if (question.multiple !== true && selectionCount > 1) {
|
||||
throw new PendingAskValidationError(`Question ${question.id} accepts a single answer`);
|
||||
}
|
||||
if (selectionCount === 0) return undefined;
|
||||
return { id: question.id, values, ...(otherText === undefined ? {} : { otherText }) };
|
||||
}
|
||||
|
||||
function normalizeOtherText(question: AskUserQuestion, otherText: string | undefined): string | undefined {
|
||||
if (otherText === undefined) return undefined;
|
||||
if (otherText.length > ASK_USER_OTHER_TEXT_MAX_LENGTH) {
|
||||
throw new PendingAskValidationError(`Other text of question ${question.id} exceeds its length limit`);
|
||||
}
|
||||
const trimmed = otherText.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function cloneAsk(ask: PendingAskUser): PendingAskUser {
|
||||
return { askId: ask.askId, askedAt: ask.askedAt, questions: ask.questions.map(cloneQuestion) };
|
||||
}
|
||||
|
||||
function cloneQuestion(question: AskUserQuestion): AskUserQuestion {
|
||||
return { ...question, options: question.options.map((option) => ({ ...option })) };
|
||||
}
|
||||
|
||||
function requireSessionId(sessionId: string): string {
|
||||
if (sessionId === "") throw new Error("sessionId must not be empty");
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function requireId(value: string, field: string): string {
|
||||
if (value.trim() === "") throw new PendingAskValidationError(`${field} must not be empty`);
|
||||
if (value.length > ASK_USER_ID_MAX_LENGTH) throw new PendingAskValidationError(`${field} exceeds its length limit`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value: string, field: string): string {
|
||||
if (value.trim() === "") throw new PendingAskValidationError(`${field} must not be empty`);
|
||||
if (value.length > ASK_USER_TEXT_MAX_LENGTH) throw new PendingAskValidationError(`${field} exceeds its length limit`);
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ASK_USER_ANSWERS_CUSTOM_TYPE } from "../../shared/apiTypes.js";
|
||||
import { createPiWebCustomToolDefinitions, PiSessionService } from "./piSessionService.js";
|
||||
import { PendingAskStore, PendingAskValidationError } from "./pendingAskStore.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
const ACTIVE_SESSION_ID = "session-1";
|
||||
|
||||
const questions = [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: true }];
|
||||
|
||||
/**
|
||||
* Service over a clocked store with sequential ask ids, so asks are named
|
||||
* `ask-1`, `ask-2`, … and timestamps are fixed. Pass `withActiveSession` when the
|
||||
* test needs a live runtime to deliver answers into.
|
||||
*/
|
||||
function askService(options: { withActiveSession?: boolean } = {}) {
|
||||
const store = new PendingAskStore({
|
||||
now: () => new Date("2026-02-01T10:00:00.000Z"),
|
||||
createAskId: (() => {
|
||||
let next = 0;
|
||||
return () => { next += 1; return `ask-${next.toString()}`; };
|
||||
})(),
|
||||
});
|
||||
const fake = fakeRuntime(ACTIVE_SESSION_ID);
|
||||
const events = new CapturingSessionEventHub();
|
||||
const service = new PiSessionService(events, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
sessionManager: sessionGateway(options.withActiveSession === true ? [sessionRecord(ACTIVE_SESSION_ID)] : []),
|
||||
archiveStore: emptyArchiveStore(),
|
||||
...(options.withActiveSession === true ? { createAgentRuntime: runtimeCreator(fake.runtime) } : {}),
|
||||
pendingAskStore: store,
|
||||
askUserEnabled: true,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
return { service, store, events, fake };
|
||||
}
|
||||
|
||||
function askEvents(events: CapturingSessionEventHub) {
|
||||
return events.sessionEvents
|
||||
.filter(({ event }) => event.type === "ask.opened" || event.type === "ask.closed")
|
||||
.map(({ sessionId, event }) => ({ sessionId, event }));
|
||||
}
|
||||
|
||||
|
||||
describe("ask_user registration", () => {
|
||||
it("offers ask_user whenever the capability is configured, including to restricted tracked children", () => {
|
||||
const askUser = { open: vi.fn() };
|
||||
|
||||
const unrestricted = createPiWebCustomToolDefinitions("/workspace", true, undefined, undefined, askUser);
|
||||
const restricted = createPiWebCustomToolDefinitions("/workspace", false, undefined, undefined, askUser);
|
||||
|
||||
expect(unrestricted.map((definition) => definition.name)).toEqual(["edit", "ask_user"]);
|
||||
expect(restricted.map((definition) => definition.name)).toEqual(["edit", "ask_user"]);
|
||||
});
|
||||
|
||||
it("omits ask_user when the capability is disabled", () => {
|
||||
const definitions = createPiWebCustomToolDefinitions("/workspace", true);
|
||||
|
||||
expect(definitions.map((definition) => definition.name)).toEqual(["edit"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiSessionService.openAsk", () => {
|
||||
it("registers the question set as the session's open ask", async () => {
|
||||
const { service, store } = askService();
|
||||
|
||||
const result = await service.openAsk({ sessionId: "session-1", questions });
|
||||
|
||||
expect(result.ask).toMatchObject({ askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z" });
|
||||
expect(result).not.toHaveProperty("superseded");
|
||||
expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-1" });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("supersedes the session's earlier unanswered ask and reports its outcome", async () => {
|
||||
const { service, store } = askService();
|
||||
await service.openAsk({ sessionId: "session-1", questions });
|
||||
|
||||
const result = await service.openAsk({ sessionId: "session-1", questions: [{ id: "again", question: "Still?", options: [], allowOther: true }] });
|
||||
|
||||
expect(result.ask.askId).toBe("ask-2");
|
||||
expect(result.superseded).toMatchObject({ askId: "ask-1", reason: "superseded", answeredCount: 0, unansweredIds: ["db"] });
|
||||
expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-2" });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("keeps asks of different sessions independent", async () => {
|
||||
const { service, store } = askService();
|
||||
|
||||
await service.openAsk({ sessionId: "session-1", questions });
|
||||
const other = await service.openAsk({ sessionId: "session-2", questions });
|
||||
|
||||
expect(other).not.toHaveProperty("superseded");
|
||||
expect(store.pendingAsk("session-1")).toMatchObject({ askId: "ask-1" });
|
||||
expect(store.pendingAsk("session-2")).toMatchObject({ askId: "ask-2" });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("opens an optionless question with a custom answer", async () => {
|
||||
const { service, store, events } = askService();
|
||||
|
||||
const result = await service.openAsk({ sessionId: "session-1", questions: [{ id: "empty", question: "Anything else?", options: [] }] });
|
||||
|
||||
expect(result.ask.questions).toEqual([{ id: "empty", question: "Anything else?", options: [], allowOther: true }]);
|
||||
expect(store.pendingAsk("session-1")).toEqual(result.ask);
|
||||
expect(askEvents(events)).toHaveLength(1);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("publishes ask.opened so a watching browser renders the card without refetching status", async () => {
|
||||
const { service, events } = askService();
|
||||
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||
|
||||
expect(askEvents(events)).toEqual([
|
||||
{ sessionId: ACTIVE_SESSION_ID, event: { type: "ask.opened", ask: { askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z", questions } } },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("publishes the supersede as a close before the replacement opens", async () => {
|
||||
const { service, events } = askService();
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions: [{ id: "again", question: "Still?", options: [], allowOther: true }] });
|
||||
|
||||
expect(askEvents(events).map(({ event }) => event)).toEqual([
|
||||
{ type: "ask.opened", ask: { askId: "ask-1", askedAt: "2026-02-01T10:00:00.000Z", questions } },
|
||||
{ type: "ask.closed", askId: "ask-1", reason: "superseded" },
|
||||
{ type: "ask.opened", ask: { askId: "ask-2", askedAt: "2026-02-01T10:00:00.000Z", questions: [{ id: "again", question: "Still?", options: [], allowOther: true }] } },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiSessionService ask status projection", () => {
|
||||
it("reports the open ask in status so a reloading browser rehydrates it", async () => {
|
||||
const { service } = askService({ withActiveSession: true });
|
||||
|
||||
const before = await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||
const during = await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||
await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "db", values: ["pg"] }] });
|
||||
const after = await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||
|
||||
expect(before).not.toHaveProperty("pendingAsk");
|
||||
expect(during.pendingAsk).toMatchObject({ askId: "ask-1", questions });
|
||||
expect(after).not.toHaveProperty("pendingAsk");
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("drops the open ask when the runtime that posted it is closed", async () => {
|
||||
const { service, store } = askService({ withActiveSession: true });
|
||||
await service.status(sessionRef(ACTIVE_SESSION_ID));
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||
|
||||
await service.stop(sessionRef(ACTIVE_SESSION_ID));
|
||||
|
||||
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toBeUndefined();
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiSessionService.submitAsk", () => {
|
||||
it("delivers a custom answer as a follow-up message that wakes the session", async () => {
|
||||
const { service, store, events, fake } = askService({ withActiveSession: true });
|
||||
await service.openAsk({
|
||||
sessionId: ACTIVE_SESSION_ID,
|
||||
questions: [{ id: "db", question: "Which database?", options: [{ value: "pg", label: "Postgres" }], allowOther: false }],
|
||||
});
|
||||
|
||||
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", {
|
||||
answers: [{ id: "db", values: [], otherText: "DuckDB" }],
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({ result: "closed", outcome: { askId: "ask-1", reason: "submitted", answeredCount: 1, unansweredIds: [] } });
|
||||
expect(response.sessionStatus.sessionId).toBe(ACTIVE_SESSION_ID);
|
||||
expect(fake.calls.sendCustomMessage).toHaveLength(1);
|
||||
const [delivered] = fake.calls.sendCustomMessage;
|
||||
expect(delivered?.message.customType).toBe(ASK_USER_ANSWERS_CUSTOM_TYPE);
|
||||
expect(delivered?.message.display).toBe(true);
|
||||
expect(delivered?.message.content).toContain("The user submitted answers to your questions.");
|
||||
expect(delivered?.message.content).toContain(`custom: "DuckDB"`);
|
||||
expect(delivered?.message.content).toContain("Answered 1 of 1");
|
||||
expect(delivered?.message.details).toMatchObject({ askId: "ask-1", reason: "submitted" });
|
||||
expect(delivered?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
||||
expect(askEvents(events).map(({ event }) => event.type)).toEqual(["ask.opened", "ask.closed"]);
|
||||
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toBeUndefined();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("names the questions the user left unanswered in the message the model reads", async () => {
|
||||
const { service, fake } = askService({ withActiveSession: true });
|
||||
await service.openAsk({
|
||||
sessionId: ACTIVE_SESSION_ID,
|
||||
questions: [...questions, { id: "cache", question: "Which cache?", options: [{ value: "redis", label: "Redis" }] }],
|
||||
});
|
||||
|
||||
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "db", values: ["pg"] }] });
|
||||
|
||||
expect(response.outcome).toMatchObject({ answeredCount: 1, unansweredIds: ["cache"] });
|
||||
expect(fake.calls.sendCustomMessage[0]?.message.content).toContain("Answered 1 of 2; unanswered: cache");
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reports a stale ask id without delivering anything or closing the open ask", async () => {
|
||||
const { service, store, events, fake } = askService({ withActiveSession: true });
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||
|
||||
const response = await service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-superseded", { answers: [] });
|
||||
|
||||
expect(response.result).toBe("stale");
|
||||
expect(response).not.toHaveProperty("outcome");
|
||||
expect(response.sessionStatus.pendingAsk).toMatchObject({ askId: "ask-1" });
|
||||
expect(fake.calls.sendCustomMessage).toEqual([]);
|
||||
expect(askEvents(events).map(({ event }) => event.type)).toEqual(["ask.opened"]);
|
||||
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toMatchObject({ askId: "ask-1" });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("leaves the ask open when the submitted answers do not fit its questions", async () => {
|
||||
const { service, store, fake } = askService({ withActiveSession: true });
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||
|
||||
await expect(service.submitAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1", { answers: [{ id: "nope", values: [] }] }))
|
||||
.rejects.toThrow(PendingAskValidationError);
|
||||
|
||||
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toMatchObject({ askId: "ask-1" });
|
||||
expect(fake.calls.sendCustomMessage).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiSessionService.cancelAsk", () => {
|
||||
it("tells the model every question went unanswered rather than leaving it waiting", async () => {
|
||||
const { service, store, events, fake } = askService({ withActiveSession: true });
|
||||
await service.openAsk({ sessionId: ACTIVE_SESSION_ID, questions });
|
||||
|
||||
const response = await service.cancelAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1");
|
||||
|
||||
expect(response).toMatchObject({ result: "closed", outcome: { reason: "cancelled", answeredCount: 0, unansweredIds: ["db"] } });
|
||||
expect(fake.calls.sendCustomMessage[0]?.message.content).toContain("closed (cancelled) before it was fully answered");
|
||||
expect(fake.calls.sendCustomMessage[0]?.options).toEqual({ triggerTurn: true, deliverAs: "followUp" });
|
||||
expect(askEvents(events).at(-1)?.event).toEqual({ type: "ask.closed", askId: "ask-1", reason: "cancelled" });
|
||||
expect(store.pendingAsk(ACTIVE_SESSION_ID)).toBeUndefined();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reports a stale cancel of an ask that is already gone", async () => {
|
||||
const { service, fake } = askService({ withActiveSession: true });
|
||||
|
||||
const response = await service.cancelAsk(sessionRef(ACTIVE_SESSION_ID), "ask-1");
|
||||
|
||||
expect(response.result).toBe("stale");
|
||||
expect(fake.calls.sendCustomMessage).toEqual([]);
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
@@ -33,8 +33,11 @@ import { deterministicSessionName, fallbackSessionName, generateShortSessionName
|
||||
import { computeEditPreview, type EditPreviewResult } from "./editPreview.js";
|
||||
import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachmentService.js";
|
||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
||||
import { ASK_USER_ANSWERS_CUSTOM_TYPE, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_LIMIT } from "../../shared/apiTypes.js";
|
||||
import type {
|
||||
AskUserCloseResponse,
|
||||
AskUserOutcome,
|
||||
AskUserSubmission,
|
||||
SavedPromptAttachment,
|
||||
SessionBulkArchiveResponse,
|
||||
SessionBulkDeleteArchivedResponse,
|
||||
@@ -54,6 +57,8 @@ import type { SessionRouteLookup, SessionRouteRef, SessionRouteService } from ".
|
||||
import { type AuthChange } from "./authService.js";
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
|
||||
import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js";
|
||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
import { buildTranscriptView } from "./subsessionTranscript.js";
|
||||
@@ -615,11 +620,15 @@ export function createPiWebCustomToolDefinitions(
|
||||
delegationEnabled: boolean,
|
||||
spawn?: SpawnSessionFn,
|
||||
subsessions?: SubsessionToolDeps,
|
||||
askUser?: AskUserToolDeps,
|
||||
) {
|
||||
return [
|
||||
createPiWebEditToolDefinition(cwd),
|
||||
...(delegationEnabled && spawn !== undefined ? [createSpawnSessionToolDefinition(cwd, { spawn })] : []),
|
||||
...(delegationEnabled && subsessions !== undefined ? createSubsessionToolDefinitions(cwd, subsessions) : []),
|
||||
// Asking the user is not delegation: the questions land in the session the
|
||||
// user is already watching, so tracked children may ask too.
|
||||
...(askUser === undefined ? [] : [createAskUserToolDefinition(askUser)]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -628,12 +637,13 @@ function createDefaultRuntimeFactory(
|
||||
sessionManagers: Pick<PiSessionManagerGateway, "open">,
|
||||
spawn?: SpawnSessionFn,
|
||||
subsessions?: SubsessionToolDeps,
|
||||
askUser?: AskUserToolDeps,
|
||||
): PiWebCreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
||||
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
||||
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
||||
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
||||
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions);
|
||||
const customTools = createPiWebCustomToolDefinitions(cwd, resolvedDelegationToolsEnabled, spawn, subsessions, askUser);
|
||||
const result = await createAgentSessionFromServices({
|
||||
services,
|
||||
sessionManager,
|
||||
@@ -691,6 +701,14 @@ export interface PiSessionServiceDependencies {
|
||||
* being exposed in releases.
|
||||
*/
|
||||
subsessionsEnabled?: boolean;
|
||||
/**
|
||||
* When true, `ask_user` is available to every session, so an agent can post a
|
||||
* question set to the browser. Independent of the delegation capabilities: the
|
||||
* questions reach the user of the asking session, not another session.
|
||||
*/
|
||||
askUserEnabled?: boolean;
|
||||
/** Daemon-lifetime open-ask state; defaults to an in-memory store in tests. */
|
||||
pendingAskStore?: PendingAskStore;
|
||||
/** Structured logger for notable runtime events (e.g. spawns). */
|
||||
logger?: PiSessionLogger;
|
||||
/** Clock seam for cleanup planning tests. */
|
||||
@@ -753,6 +771,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
private readonly notificationStore: SessionNotificationStore;
|
||||
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
|
||||
private readonly unreadStore: SessionUnreadStore;
|
||||
private readonly pendingAskStore: PendingAskStore;
|
||||
private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined;
|
||||
private readonly unreadPublicationRetryInitialMs: number;
|
||||
private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
|
||||
@@ -773,6 +792,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
|
||||
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
|
||||
this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
|
||||
this.catalogRefreshStatus = deps.catalogRefreshStatus;
|
||||
this.unreadPublicationRetryInitialMs = Math.max(
|
||||
0,
|
||||
@@ -792,6 +812,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile),
|
||||
read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile),
|
||||
},
|
||||
deps.askUserEnabled === true ? { open: (input) => this.openAsk(input) } : undefined,
|
||||
);
|
||||
this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime;
|
||||
this.workspaceActivity = deps.workspaceActivity;
|
||||
@@ -939,7 +960,10 @@ export class PiSessionService implements SessionRouteService {
|
||||
const pendingOpens = this.pendingSessionOpenPromises();
|
||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||
const activeSessions = Array.from(new Set(this.active.values()));
|
||||
for (const active of activeSessions) this.forgetUnreadActivity(active.runtime.session);
|
||||
for (const active of activeSessions) {
|
||||
this.forgetUnreadActivity(active.runtime.session);
|
||||
this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
|
||||
}
|
||||
this.active.clear();
|
||||
this.pendingSessionOpens.clear();
|
||||
this.activities.clear();
|
||||
@@ -1074,6 +1098,85 @@ export class PiSessionService implements SessionRouteService {
|
||||
return { sessionId: created.id, cwd: decision.cwd };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the question set an agent wants the user to answer as the session's
|
||||
* open ask. Deliberately does not wait for the user: `ask_user` terminates the
|
||||
* run and the submitted answers come back later as a follow-up message.
|
||||
*
|
||||
* Rejected question sets throw {@link PendingAskValidationError}, which the
|
||||
* agent loop reports to the model as an error tool result.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- async so a rejected question set becomes a rejection rather than a synchronous throw from a promise-returning method.
|
||||
async openAsk(input: AskUserInvocation): Promise<PendingAskOpenResult> {
|
||||
const result = this.pendingAskStore.open(input);
|
||||
// A supersede closes the earlier ask, so the browsers watching it must hear
|
||||
// that before they hear about its replacement.
|
||||
if (result.superseded !== undefined) this.publishAskClosed(input.sessionId, result.superseded);
|
||||
this.events.publish(input.sessionId, { type: "ask.opened", ask: result.ask });
|
||||
this.publishStatusForSessionId(input.sessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the user's answers to the session's open ask and hand them to the
|
||||
* model. The answers travel as a system-authored custom message rather than a
|
||||
* user message, so they are not attributed to the human in the transcript;
|
||||
* they still wake an idle session (`triggerTurn`) and queue behind in-flight
|
||||
* work (`deliverAs: "followUp"`), which is how the run that `ask_user`
|
||||
* terminated continues.
|
||||
*/
|
||||
async submitAsk(ref: PiSessionLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
// Checked before the store closes the ask so a refused delivery cannot
|
||||
// discard answers the user already submitted.
|
||||
this.assertTreeNavigationInactive(session, "answer questions");
|
||||
return this.closeAsk(session, this.pendingAskStore.submit(session.sessionId, askId, submission));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the open ask without answers. The model is still told, naming every
|
||||
* question as unanswered: it was promised a follow-up message and would
|
||||
* otherwise wait for one that never comes.
|
||||
*/
|
||||
async cancelAsk(ref: PiSessionLookup, askId: string): Promise<AskUserCloseResponse> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.assertTreeNavigationInactive(session, "dismiss questions");
|
||||
return this.closeAsk(session, this.pendingAskStore.cancel(session.sessionId, askId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish and deliver a closed ask. A stale close is reported rather than
|
||||
* thrown: losing the race against a supersede, another browser, or a session
|
||||
* that went away is ordinary, and the returned status tells the browser what
|
||||
* the session's open ask is now.
|
||||
*/
|
||||
private async closeAsk(session: PiAgentSession, result: PendingAskCloseResult): Promise<AskUserCloseResponse> {
|
||||
if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) };
|
||||
const { outcome } = result;
|
||||
this.publishAskClosed(session.sessionId, outcome);
|
||||
await this.runSessionEntryMutation(session, "deliver answers to your questions", () => session.sendCustomMessage(
|
||||
{ customType: ASK_USER_ANSWERS_CUSTOM_TYPE, content: renderAskUserAnswersText(outcome), display: true, details: outcome },
|
||||
{ triggerTurn: true, deliverAs: "followUp" },
|
||||
));
|
||||
this.publishStatus(session);
|
||||
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
|
||||
}
|
||||
|
||||
private publishAskClosed(sessionId: string, outcome: AskUserOutcome): void {
|
||||
this.events.publish(sessionId, { type: "ask.closed", askId: outcome.askId, reason: outcome.reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish status for a session known only by id, as the ask tools are: they
|
||||
* run inside the session's own runtime, so the active entry is the session.
|
||||
*/
|
||||
private publishStatusForSessionId(sessionId: string): void {
|
||||
const session = this.active.get(sessionId)?.runtime.session;
|
||||
if (session !== undefined) this.publishStatus(session);
|
||||
}
|
||||
|
||||
/** Summaries of the tracked subsessions spawned by `parentSessionId`. */
|
||||
async listSubsessions(parentSessionId: string, parentSessionFile?: string): Promise<SubsessionSummary[]> {
|
||||
const parentFile = nonEmptyString(parentSessionFile);
|
||||
@@ -2226,6 +2329,9 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
if (!active) return;
|
||||
this.forgetUnreadActivity(active.runtime.session);
|
||||
// An open ask is meaningful only while the runtime that posted it exists: no
|
||||
// one is left to receive the answers, so it is dropped without an outcome.
|
||||
this.pendingAskStore.forgetSession(sessionId);
|
||||
this.active.delete(sessionId);
|
||||
this.activities.delete(sessionId);
|
||||
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
||||
@@ -3087,6 +3193,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
const model = session.model === undefined ? undefined : modelToClientModel(session.model);
|
||||
const contextUsage = session.getContextUsage();
|
||||
const warnings = this.warningsForSession(session);
|
||||
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
persisted: sessionFileExists(session.sessionFile),
|
||||
@@ -3102,6 +3209,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
cost: stats.cost,
|
||||
...(contextUsage === undefined ? {} : { contextUsage }),
|
||||
...(warnings.length === 0 ? {} : { warnings }),
|
||||
...(pendingAsk === undefined ? {} : { pendingAsk }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import { resolve } from "node:path";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
|
||||
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
|
||||
import type {
|
||||
AskUserCloseResponse,
|
||||
AskUserSubmission,
|
||||
MessagePage,
|
||||
SessionBulkArchiveResponse,
|
||||
SessionBulkDeleteArchivedResponse,
|
||||
@@ -338,6 +340,100 @@ describe("session routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("parses ask submissions and reports both closed and stale outcomes", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const submitted = await routeApp.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/session-1/ask/submit",
|
||||
payload: {
|
||||
cwd: "/repo/./",
|
||||
askId: "ask-1",
|
||||
answers: [{ id: "db", values: ["pg"] }, { id: "cache", values: [], otherText: "redis" }],
|
||||
},
|
||||
});
|
||||
const cancelled = await routeApp.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/session-1/ask/cancel",
|
||||
payload: { askId: "ask-2" },
|
||||
});
|
||||
|
||||
expect(submitted.statusCode).toBe(200);
|
||||
expect(submitted.json()).toMatchObject({ result: "closed", sessionStatus: { sessionId: "session-1" } });
|
||||
expect(routeService.submitAskCalls).toEqual([{
|
||||
lookup: { id: "session-1", cwd: resolve("/repo") },
|
||||
askId: "ask-1",
|
||||
submission: { answers: [{ id: "db", values: ["pg"] }, { id: "cache", values: [], otherText: "redis" }] },
|
||||
}]);
|
||||
expect(cancelled.statusCode).toBe(200);
|
||||
expect(cancelled.json()).toMatchObject({ result: "stale" });
|
||||
expect(routeService.cancelAskCalls).toEqual([{ lookup: "session-1", askId: "ask-2" }]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed ask payloads before calling the service", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
const malformed: Record<string, unknown>[] = [
|
||||
{ answers: [] },
|
||||
{ askId: "", answers: [] },
|
||||
{ askId: "x".repeat(ASK_USER_ID_MAX_LENGTH + 1), answers: [] },
|
||||
{ askId: "ask-1" },
|
||||
{ askId: "ask-1", answers: {} },
|
||||
{ askId: "ask-1", answers: [{ values: ["pg"] }] },
|
||||
{ askId: "ask-1", answers: [{ id: "db" }] },
|
||||
{ askId: "ask-1", answers: [{ id: "db", values: [1] }] },
|
||||
{ askId: "ask-1", answers: [{ id: "db", values: [], otherText: 7 }] },
|
||||
{ askId: "ask-1", answers: [{ id: "db", values: [], otherText: "x".repeat(ASK_USER_OTHER_TEXT_MAX_LENGTH + 1) }] },
|
||||
{ askId: "ask-1", answers: new Array<unknown>(ASK_USER_QUESTION_LIMIT + 1).fill({ id: "db", values: [] }) },
|
||||
];
|
||||
|
||||
try {
|
||||
for (const payload of malformed) {
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/ask/submit", payload });
|
||||
expect(response.statusCode).toBe(400);
|
||||
}
|
||||
const cancelWithoutAskId = await routeApp.inject({ method: "POST", url: "/sessions/session-1/ask/cancel", payload: {} });
|
||||
|
||||
expect(cancelWithoutAskId.statusCode).toBe(400);
|
||||
expect(routeService.submitAskCalls).toEqual([]);
|
||||
expect(routeService.cancelAskCalls).toEqual([]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps a missing session on an ask submission to 404", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
routeService.askError = new Error("Session not found");
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/ask/submit", payload: { askId: "ask-1", answers: [] } });
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({ error: "Session not found" });
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects prompt payloads that omit text without opening a session", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
|
||||
|
||||
@@ -736,10 +832,25 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
||||
readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = [];
|
||||
readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = [];
|
||||
readonly startCalls: { cwd: string; startupToken: string | undefined }[] = [];
|
||||
askError: Error | undefined;
|
||||
reloadError: Error | undefined;
|
||||
clearQueueError: Error | undefined;
|
||||
|
||||
submitAsk(lookup: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse> {
|
||||
if (this.askError !== undefined) return Promise.reject(this.askError);
|
||||
this.submitAskCalls.push({ lookup, askId, submission });
|
||||
return Promise.resolve({ result: "closed", sessionStatus: idleStatus(lookup) });
|
||||
}
|
||||
|
||||
cancelAsk(lookup: SessionRouteLookup, askId: string): Promise<AskUserCloseResponse> {
|
||||
if (this.askError !== undefined) return Promise.reject(this.askError);
|
||||
this.cancelAskCalls.push({ lookup, askId });
|
||||
return Promise.resolve({ result: "stale", sessionStatus: idleStatus(lookup) });
|
||||
}
|
||||
|
||||
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
|
||||
this.cleanupPreviewCalls.push(request);
|
||||
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
|
||||
@@ -936,6 +1047,19 @@ function notificationSnapshot(ref: SessionRef): SessionNotificationInboxSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
function idleStatus(lookup: SessionRouteLookup): SessionStatus {
|
||||
return {
|
||||
sessionId: sessionIdFromLookup(lookup),
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionIdFromLookup(lookup: SessionRouteLookup): string {
|
||||
return typeof lookup === "string" ? lookup : lookup.id;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
|
||||
import { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH, SESSION_UNREAD_CWD_MAX_LENGTH, SESSION_UNREAD_SESSION_ID_MAX_LENGTH, type AskUserAnswer, type AskUserSubmission, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
|
||||
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
|
||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
@@ -270,6 +270,25 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; askId?: unknown; answers?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/ask/submit`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRecord(request.body);
|
||||
const askId = requireBoundedId(body["askId"], "askId");
|
||||
return await sessions.submitAsk(sessionLookupFromBody(request.params.sessionId, body), askId, askUserSubmissionFromBody(body));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; askId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/ask/cancel`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.cancelAsk(sessionLookupFromBody(request.params.sessionId, body), requireBoundedId(body["askId"], "askId"));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dismissId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/warnings/dismiss`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
@@ -497,6 +516,37 @@ function requireExactFields(record: Record<string, unknown>, fields: readonly st
|
||||
if (unexpected !== undefined) throw new Error(`${label} field contains unsupported property: ${unexpected}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape-check one submitted answer set. Only transport-level checks belong here:
|
||||
* whether the answers fit the questions that were actually asked is the pending
|
||||
* ask store's job, since only it knows the open ask.
|
||||
*/
|
||||
function askUserSubmissionFromBody(body: Record<string, unknown>): AskUserSubmission {
|
||||
const answers = body["answers"];
|
||||
if (!Array.isArray(answers)) throw new Error("answers field must be an array");
|
||||
if (answers.length > ASK_USER_QUESTION_LIMIT) throw new Error("answers field has too many entries");
|
||||
return { answers: answers.map(askUserAnswerFromValue) };
|
||||
}
|
||||
|
||||
function askUserAnswerFromValue(value: unknown): AskUserAnswer {
|
||||
const record = requireRecord(value);
|
||||
const values = record["values"];
|
||||
if (!Array.isArray(values)) throw new Error("values field must be an array");
|
||||
if (values.length > ASK_USER_OPTION_LIMIT) throw new Error("values field has too many entries");
|
||||
const otherText = record["otherText"];
|
||||
if (otherText !== undefined && typeof otherText !== "string") throw new Error("otherText field must be a string");
|
||||
if (typeof otherText === "string" && otherText.length > ASK_USER_OTHER_TEXT_MAX_LENGTH) throw new Error("otherText field is too long");
|
||||
return {
|
||||
id: requireBoundedId(record["id"], "id"),
|
||||
values: values.map((entry) => requireBoundedId(entry, "values entry")),
|
||||
...(otherText === undefined ? {} : { otherText }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireBoundedId(value: unknown, field: string): string {
|
||||
return requireNonEmptyBoundedString(value, field, ASK_USER_ID_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> {
|
||||
if (value === undefined || value === null) return {};
|
||||
return requireRecord(value);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
AskUserCloseResponse,
|
||||
AskUserSubmission,
|
||||
SavedPromptAttachment,
|
||||
SessionBulkArchiveResponse,
|
||||
SessionBulkDeleteArchivedResponse,
|
||||
@@ -56,6 +58,8 @@ export interface SessionRouteService {
|
||||
dismissNotification(ref: SessionRouteRef, request: Omit<SessionNotificationDismissRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
|
||||
dismissAllNotifications(ref: SessionRouteRef, request: Omit<SessionNotificationDismissAllRequest, "cwd">): SessionNotificationInboxSnapshot | Promise<SessionNotificationInboxSnapshot>;
|
||||
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||
submitAsk(ref: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse>;
|
||||
cancelAsk(ref: SessionRouteLookup, askId: string): Promise<AskUserCloseResponse>;
|
||||
dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>;
|
||||
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
|
||||
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
|
||||
|
||||
@@ -10,6 +10,7 @@ export const PI_WEB_CAPABILITIES = {
|
||||
sessionsPersistedState: "sessions.persistedState",
|
||||
sessionsNotifications: "sessions.notifications",
|
||||
sessionsUnread: "sessions.unread",
|
||||
sessionsAskUser: "sessions.askUser",
|
||||
promptAttachments: "prompt.attachments",
|
||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||
piPackagesManage: "piPackages.manage",
|
||||
@@ -97,6 +98,11 @@ export interface PiWebConfigValues {
|
||||
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
||||
*/
|
||||
subsessions?: boolean;
|
||||
/**
|
||||
* When true, LLMs can post a question set to the browser via the ask_user
|
||||
* tool. On by default; set to `false` to remove the tool from the runtime.
|
||||
*/
|
||||
askUser?: boolean;
|
||||
/** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
|
||||
agent?: PiWebAgentConfig;
|
||||
}
|
||||
@@ -161,6 +167,7 @@ export interface PiWebConfigEnvOverrides {
|
||||
allowedHosts: boolean;
|
||||
spawnSessions: boolean;
|
||||
subsessions: boolean;
|
||||
askUser: boolean;
|
||||
agentCommand: boolean;
|
||||
agentDir: boolean;
|
||||
/** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */
|
||||
@@ -441,6 +448,134 @@ export interface QueuedSessionMessage {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `customType` of the follow-up custom message that carries a closed ask back to
|
||||
* the model and into the transcript. Its `details` are an {@link AskUserOutcome}.
|
||||
*/
|
||||
export const ASK_USER_ANSWERS_CUSTOM_TYPE = "pi-web.ask.answers";
|
||||
|
||||
/** Largest question set one `ask_user` call may post. */
|
||||
export const ASK_USER_QUESTION_LIMIT = 20;
|
||||
/** Largest option list one question may offer. */
|
||||
export const ASK_USER_OPTION_LIMIT = 12;
|
||||
/** Length bound for ids: the ask id, question ids, and option values. */
|
||||
export const ASK_USER_ID_MAX_LENGTH = 128;
|
||||
/** Length bound for model-authored prose: questions, details, and option labels. */
|
||||
export const ASK_USER_TEXT_MAX_LENGTH = 1_000;
|
||||
/** Length bound for the free text a user types as a custom answer. */
|
||||
export const ASK_USER_OTHER_TEXT_MAX_LENGTH = 4_000;
|
||||
|
||||
/** One selectable option of an {@link AskUserQuestion}. */
|
||||
export interface AskUserQuestionOption {
|
||||
/** Stable machine value reported back to the model. */
|
||||
value: string;
|
||||
/** Short human label rendered in the browser. */
|
||||
label: string;
|
||||
/** Optional clarifying line rendered under the label. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One question of an `ask_user` set. Questions are never required: the user may
|
||||
* submit while leaving any of them untouched, and unanswered questions are
|
||||
* reported to the model as such.
|
||||
*/
|
||||
export interface AskUserQuestion {
|
||||
/** Unique within the ask; used as the answer key. */
|
||||
id: string;
|
||||
/** The question itself, as one plain-text line. */
|
||||
question: string;
|
||||
/** Optional supporting context rendered under the question. */
|
||||
detail?: string;
|
||||
/** Offered options; may be empty when only free text makes sense. */
|
||||
options: AskUserQuestionOption[];
|
||||
/**
|
||||
* Compatibility marker for older clients. Canonical questions set this to
|
||||
* true because every question offers a custom free-text answer.
|
||||
*/
|
||||
allowOther?: boolean;
|
||||
/** When true, several options may be selected at once. */
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open, unanswered question set of a session. Daemon-owned and reported in
|
||||
* {@link SessionStatus}, so a reconnecting or reloading browser rehydrates it
|
||||
* without depending on having seen the `ask.opened` event.
|
||||
*/
|
||||
export interface PendingAskUser {
|
||||
askId: string;
|
||||
askedAt: string;
|
||||
questions: AskUserQuestion[];
|
||||
}
|
||||
|
||||
/** Why an ask stopped being the session's open ask. */
|
||||
export type AskUserCloseReason = "submitted" | "superseded" | "cancelled";
|
||||
|
||||
/**
|
||||
* What the user replied to one question. Absent from a submission means the
|
||||
* question was left untouched; an empty `values` with no `otherText` means the
|
||||
* same thing.
|
||||
*/
|
||||
export interface AskUserAnswer {
|
||||
/** Matches an {@link AskUserQuestion.id} of the open ask. */
|
||||
id: string;
|
||||
/** Selected {@link AskUserQuestionOption.value} entries; several only when the question allows it. */
|
||||
values: string[];
|
||||
/** Free text typed as the question's custom answer. */
|
||||
otherText?: string;
|
||||
}
|
||||
|
||||
/** One submit of the open ask: answers for some or all of its questions. */
|
||||
export interface AskUserSubmission {
|
||||
answers: AskUserAnswer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One question of a closed ask paired with what came back for it. Carries the
|
||||
* question itself so the record renders without the original ask still existing.
|
||||
*/
|
||||
export interface AskUserQuestionRecord {
|
||||
question: AskUserQuestion;
|
||||
/** True when at least one option was selected or custom text was given. */
|
||||
answered: boolean;
|
||||
values: string[];
|
||||
otherText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The complete result of an ask, computed when it closes. Shared by the
|
||||
* model-facing follow-up message and the browser's read-only record, so both
|
||||
* report the same answered and unanswered questions.
|
||||
*/
|
||||
export interface AskUserOutcome {
|
||||
askId: string;
|
||||
reason: AskUserCloseReason;
|
||||
askedAt: string;
|
||||
closedAt: string;
|
||||
questions: AskUserQuestionRecord[];
|
||||
answeredCount: number;
|
||||
/** Ids of the questions left unanswered, in the order they were asked. */
|
||||
unansweredIds: string[];
|
||||
/** One line, for example `Answered 3 of 5; unanswered: q2, q5`. */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of the browser closing an ask by submitting or cancelling it.
|
||||
*
|
||||
* `"stale"` is an ordinary race rather than an error: the named ask was already
|
||||
* submitted, superseded by a newer one, or gone with its session runtime. The
|
||||
* browser drops its card and trusts `sessionStatus`, which is returned in both
|
||||
* cases so closing an ask needs no follow-up status request.
|
||||
*/
|
||||
export interface AskUserCloseResponse {
|
||||
result: "closed" | "stale";
|
||||
/** Present only when this call is the one that closed the ask. */
|
||||
outcome?: AskUserOutcome;
|
||||
sessionStatus: SessionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress of the session startup window, where the daemon is still
|
||||
* constructing the agent session and no `PiAgentSession` exists yet, so
|
||||
@@ -618,6 +753,11 @@ export interface SessionStatus {
|
||||
* there are none. See {@link SessionWarning}.
|
||||
*/
|
||||
warnings?: SessionWarning[];
|
||||
/**
|
||||
* The session's open `ask_user` question set, when one is waiting for the
|
||||
* user. Daemon-owned, so it survives browser reload and web/API restarts.
|
||||
*/
|
||||
pendingAsk?: PendingAskUser;
|
||||
}
|
||||
|
||||
export interface WorkspaceActivity {
|
||||
@@ -998,6 +1138,8 @@ type SessionUiEventBody =
|
||||
| { type: "command.output"; level: "info" | "success" | "error"; message: string; notificationId?: string }
|
||||
| SessionNotificationInboxEvent
|
||||
| { type: "session.error"; message: string }
|
||||
| { type: "ask.opened"; ask: PendingAskUser }
|
||||
| { type: "ask.closed"; askId: string; reason: AskUserCloseReason }
|
||||
| { type: "session.name"; sessionId: string; name?: string }
|
||||
| { type: "session.created"; session: SessionInfo }
|
||||
| { type: "pi.event"; eventType: string };
|
||||
|
||||
@@ -90,6 +90,26 @@ describe("PI WEB capabilities", () => {
|
||||
})).toContain(unread);
|
||||
});
|
||||
|
||||
it("renders the question card only when both runtimes support daemon-owned asks", () => {
|
||||
const askUser = PI_WEB_CAPABILITIES.sessionsAskUser;
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(askUser);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(askUser);
|
||||
expect(parseKnownPiWebCapabilities([askUser, "future.capability"])).toEqual([askUser]);
|
||||
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [askUser] },
|
||||
sessiond: { available: true, capabilities: [] },
|
||||
})).not.toContain(askUser);
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [] },
|
||||
sessiond: { available: true, capabilities: [askUser] },
|
||||
})).not.toContain(askUser);
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [askUser] },
|
||||
sessiond: { available: true, capabilities: [askUser] },
|
||||
})).toContain(askUser);
|
||||
});
|
||||
|
||||
it("keeps only known string capabilities when parsing runtime data", () => {
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
|
||||
|
||||
@@ -15,6 +15,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.sessionsNotifications,
|
||||
PI_WEB_CAPABILITIES.sessionsUnread,
|
||||
PI_WEB_CAPABILITIES.sessionsAskUser,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
||||
PI_WEB_CAPABILITIES.piPackagesManage,
|
||||
@@ -31,6 +32,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.sessionsNotifications,
|
||||
PI_WEB_CAPABILITIES.sessionsUnread,
|
||||
PI_WEB_CAPABILITIES.sessionsAskUser,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
@@ -43,6 +45,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsNotifications]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsUnread]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsAskUser]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
|
||||
|
||||
@@ -68,6 +68,8 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/ask/submit" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/ask/cancel" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/warnings/dismiss" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||
|
||||
Reference in New Issue
Block a user