Merge pull request #109 from jmfederico/fix/issue-106-extension-dialogs

feat(sessions): answerable Pi extension dialogs (ctx.ui confirm/select/input)
This commit is contained in:
Federico Jaramillo Martinez
2026-07-29 09:07:26 +02:00
committed by GitHub
42 changed files with 4411 additions and 45 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Support Pi extension dialogs in the browser: `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()` now render as cards inline in the session transcript and resolve with the user's actual answer — including dialogs opened from `session_start` hooks while the session is still starting and from in-flight `tool_call` hooks, which previously resolved `false` immediately despite `hasUI === true`. Answers travel over a dedicated session-daemon channel rather than the prompt queue, so a dialog parked inside a `tool_call` hook cannot deadlock the run. Open dialogs survive browser reloads, the first answer wins across browser tabs, and unanswered dialogs settle safely on run abort, runtime replacement, or timeout. Adds the `extensionDialogsTimeoutMs` config key (default 5 minutes, `0` waits forever) as the unattended-dialog safety valve; dialog support is always on. Other `ExtensionUIContext` surfaces (widgets, status, editor, `custom`) remain unimplemented.
+37 -1
View File
@@ -102,6 +102,7 @@
<a href="#pi-extension-provider-baseline">Pi extension providers</a> <a href="#pi-extension-provider-baseline">Pi extension providers</a>
<a href="#catalog-refresh">Model catalog refresh</a> <a href="#catalog-refresh">Model catalog refresh</a>
<a href="#session-tools">Session tools</a> <a href="#session-tools">Session tools</a>
<a href="#extension-dialogs">Extension dialogs</a>
<a href="#completion-tools">Completion tools</a> <a href="#completion-tools">Completion tools</a>
</aside> </aside>
@@ -173,7 +174,7 @@
<ul> <ul>
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li> <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>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> / <code>askUser</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> / <code>extensionDialogsTimeoutMs</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>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>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> <li><code>plugins</code>: reload the browser tab after changing PI WEB plugin enablement.</li>
@@ -213,6 +214,7 @@
"spawnSessions": true, "spawnSessions": true,
"subsessions": false, "subsessions": false,
"askUser": true, "askUser": true,
"extensionDialogsTimeoutMs": 300000,
"plugins": { "plugins": {
"workspace-tasks": { "enabled": true }, "workspace-tasks": { "enabled": true },
"updates": { "enabled": true }, "updates": { "enabled": true },
@@ -376,6 +378,14 @@
<td>Not supported locally</td> <td>Not supported locally</td>
<td>Restart session daemon on that machine</td> <td>Restart session daemon on that machine</td>
</tr> </tr>
<tr>
<td>Extension dialog auto-cancel timeout</td>
<td><code>extensionDialogsTimeoutMs</code></td>
<td></td>
<td>Global/session daemon</td>
<td>Not supported locally</td>
<td>Restart session daemon on that machine</td>
</tr>
<tr> <tr>
<td>PI WEB plugin enablement/settings</td> <td>PI WEB plugin enablement/settings</td>
<td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td> <td><code>plugins.&lt;id&gt;.enabled</code>, <code>plugins.&lt;id&gt;.settings</code></td>
@@ -842,6 +852,32 @@
</div> </div>
</section> </section>
<section id="extension-dialogs">
<h2>Extension dialogs</h2>
<p>
Pi extensions can ask the user questions from <code>ctx.ui.confirm()</code>,
<code>ctx.ui.select()</code>, and <code>ctx.ui.input()</code> — including from
<code>session_start</code> hooks and in-flight <code>tool_call</code> hooks. PI WEB renders these dialogs
inline in the session transcript and answers them through a dedicated session-daemon channel, never the
prompt queue, so a dialog parked inside a <code>tool_call</code> hook cannot deadlock the run. Dialog
support is always on; there is no enable flag. See
<a href="plugins#pi-extension-dialogs">Pi extension dialogs in PI WEB</a> for behavior details and author
guidance.
</p>
<p>
<code>extensionDialogsTimeoutMs</code> is the unattended-dialog safety valve: how long the session daemon
waits for an answer before settling the dialog with its kind's cancel value (<code>false</code> for
confirm, <code>undefined</code> for select and input). It defaults to <code>300000</code> (5 minutes);
set it to <code>0</code> to wait forever. An extension's own <code>timeout</code> option still applies,
and the effective deadline is the sooner of the two.
</p>
<div class="callout warning">
<strong>Restart required:</strong> <code>extensionDialogsTimeoutMs</code> is edited directly in the global
config file. Restart the session daemon after changing it — for the systemd user service, run
<code>systemctl --user restart pi-web-sessiond</code>.
</div>
</section>
<section id="completion-tools"> <section id="completion-tools">
<h2>Optional completion tools</h2> <h2>Optional completion tools</h2>
+11 -1
View File
@@ -39,7 +39,7 @@ Process restarts depend on the key:
- `host` / `port`: restart the gateway web/API service or process. - `host` / `port`: restart the gateway web/API service or process.
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine. - `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `askUser`: restart the session daemon on that machine. - `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `askUser` / `extensionDialogsTimeoutMs`: restart the session daemon on that machine.
- `pathAccess`: applies on the next request; existing file views may need a browser refresh. - `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. - `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. - `plugins`: reload the browser tab after changing PI WEB plugin enablement.
@@ -66,6 +66,7 @@ Process restarts depend on the key:
"spawnSessions": true, "spawnSessions": true,
"subsessions": false, "subsessions": false,
"askUser": true, "askUser": true,
"extensionDialogsTimeoutMs": 300000,
"plugins": { "plugins": {
"workspace-tasks": { "enabled": true }, "workspace-tasks": { "enabled": true },
"updates": { "enabled": true }, "updates": { "enabled": true },
@@ -118,6 +119,7 @@ Rows with JSON key `—` are runtime-only environment variables, not config-file
| Agent can spawn sessions | `spawnSessions` | `PI_WEB_SPAWN_SESSIONS` | Global/session daemon | Not supported locally | Restart session daemon on that machine | | 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 | | 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 | | Agent can post question forms | `askUser` | `PI_WEB_ASK_USER` | Global/session daemon | Not supported locally | Restart session daemon on that machine |
| Extension dialog auto-cancel timeout | `extensionDialogsTimeoutMs` | — | 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 | | 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 | | 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 | | Project config version | `version` | — | Project | Project-local only; must be `1` when present | Next project-config read |
@@ -289,6 +291,14 @@ Sending an ordinary chat message while a form is open voids the form: the card c
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`. 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`.
### Extension dialogs
Pi extensions can ask the user questions from `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()` — including from `session_start` hooks and in-flight `tool_call` hooks. PI WEB renders these dialogs inline in the session transcript and answers them through a dedicated session-daemon channel, never the prompt queue, so a dialog parked inside a `tool_call` hook cannot deadlock the run. Dialog support is always on; there is no enable flag. See [Pi extension dialogs in PI WEB](https://pi-web.dev/plugins#pi-extension-dialogs) for behavior details and author guidance.
`extensionDialogsTimeoutMs` is the unattended-dialog safety valve: how long the session daemon waits for an answer before settling the dialog with its kind's cancel value (`false` for confirm, `undefined` for select and input). It defaults to `300000` (5 minutes); set it to `0` to wait forever. An extension's own `timeout` option still applies, and the effective deadline is the sooner of the two.
The key is edited directly in the global config file. Restart the session daemon after changing it — for the systemd user service, run `systemctl --user restart pi-web-sessiond`.
### Plugin config ### 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. 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.
+27
View File
@@ -91,6 +91,7 @@
<strong>On this page</strong> <strong>On this page</strong>
<a href="#extend">What can be extended</a> <a href="#extend">What can be extended</a>
<a href="#packages-vs-plugins">Pi packages, extensions, and plugins</a> <a href="#packages-vs-plugins">Pi packages, extensions, and plugins</a>
<a href="#pi-extension-dialogs">Pi extension dialogs</a>
<a href="#ask-ai">What to ask AI to build</a> <a href="#ask-ai">What to ask AI to build</a>
<a href="#example">Canonical example</a> <a href="#example">Canonical example</a>
<a href="#built-in-plugins">Built-in plugins</a> <a href="#built-in-plugins">Built-in plugins</a>
@@ -174,6 +175,32 @@
</p> </p>
</section> </section>
<section id="pi-extension-dialogs">
<h2>Pi extension dialogs in PI WEB</h2>
<p>
Pi extensions running under PI WEB's session daemon can ask the user questions with
<code>ctx.ui.confirm()</code>, <code>ctx.ui.select()</code>, and <code>ctx.ui.input()</code>. For these
three methods <code>ctx.hasUI</code> is true in fact: the call renders a dialog card inline in the
session transcript — including from <code>session_start</code> hooks while the session is still starting
and from in-flight <code>tool_call</code> hooks — and resolves with the user's actual answer.
</p>
<p>
Answers travel over a dedicated session-daemon channel, never the prompt queue, so a parked
<code>tool_call</code> hook cannot deadlock the run. Open dialogs survive browser reloads, the first
answer wins across tabs, and unanswered dialogs settle safely: aborting the run or replacing the runtime
resolves them immediately with the kind's cancel value (<code>false</code> for confirm,
<code>undefined</code> for select and input), and the effective deadline — the sooner of the extension's
own <code>timeout</code> and the daemon's <code>extensionDialogsTimeoutMs</code> safety valve (default 5
minutes, <code>0</code> waits forever) — does the same when no one answers. Other
<code>ExtensionUIContext</code> surfaces (widgets, status, editor, <code>custom</code>) remain no-ops
despite <code>hasUI === true</code>.
</p>
<p>
For the full behavior notes and author guidance, read <a href="plugins.md">plugins.md</a>; for the
timeout key, see <a href="config#extension-dialogs">Extension dialogs</a> in the configuration reference.
</p>
</section>
<section id="ask-ai"> <section id="ask-ai">
<h2>What to ask AI to build</h2> <h2>What to ask AI to build</h2>
<p> <p>
+14
View File
@@ -29,6 +29,20 @@ Use **Settings → PI WEB plugins** to enable or disable discovered PI WEB brows
After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate daemon-start policy; see [Pi extension provider baseline](https://pi-web.dev/config#pi-extension-provider-baseline). After installing, removing, or updating a Pi package, type `/reload` in each idle PI WEB session on the target machine to refresh ordinary Pi resources such as extensions, skills, prompt templates, themes, and context/system prompt files. Reload the browser page separately for newly discovered or changed PI WEB browser plugins. A provider-registering Pi extension follows a separate daemon-start policy; see [Pi extension provider baseline](https://pi-web.dev/config#pi-extension-provider-baseline).
## Pi extension dialogs in PI WEB
Pi extensions running under PI WEB's session daemon can ask the user questions with `ctx.ui.confirm()`, `ctx.ui.select()`, and `ctx.ui.input()`. PI WEB reports `ctx.hasUI === true`, and for these three dialog methods that is true in fact: the call renders a dialog card inline in the session transcript and the returned Promise resolves with the user's actual answer — a boolean for confirm, the chosen option for select, the typed text for input.
- **Works from hooks, without the prompt queue.** Answers travel over a dedicated session-daemon channel, so a dialog opened inside an in-flight `tool_call` hook parks safely — the agent loop waits for the hook and the run continues with the answer. Consent-gating a tool from a `tool_call` hook is a supported pattern.
- **`session_start` dialogs are reachable.** A dialog opened from a `session_start` hook is answerable while the session is still starting, both when creating a session and when opening an existing one; startup completes once the dialog settles.
- **Survives browser reloads; first answer wins.** Reloading the browser re-renders open dialogs from the session status. With several tabs on the same session, the first answer settles the dialog and the other tabs re-render the settled card.
- **Settled cards stay until dismissed.** An answered or closed dialog leaves its outcome card in the transcript so the user can see what became of it — answers travel to the extension alone, so the card is the only record of the exchange. The card is browser-local: only a browser that saw the dialog open renders it, and switching sessions or reloading drops it.
- **Timeouts.** The extension's own `timeout` option applies, and the daemon adds an unattended-dialog safety valve, `extensionDialogsTimeoutMs` (default 5 minutes, `0` waits forever — see [Extension dialogs](https://pi-web.dev/config#extension-dialogs)). The effective deadline is the sooner of the two. A dialog that closes without an answer resolves with its kind's cancel value: `false` for confirm, `undefined` for select and input.
- **Abort and runtime replacement.** Aborting the current run settles a dialog opened during that run immediately, at abort-request time, with its cancel value. Replacing the session runtime (`/reload`, session disposal) settles any still-open dialog the same way; hooks on the new runtime open fresh dialogs. The extension's own `AbortSignal` is honored: aborting it dismisses the dialog and resolves with the cancel value.
- **Other UI surfaces are still no-ops.** `ExtensionUIContext` methods beyond the three dialogs (widgets, status, editor, `custom`) remain unimplemented under PI WEB even though `hasUI` is `true`; do not rely on `hasUI` alone to detect them.
One browser-local caveat: reloading the browser while a new session is still being created loses the browser-local pending-start row, so the dialog card disappears from view. The daemon-side dialog still settles at its deadline and the session appears in the sidebar once creation completes.
## Trust model ## Trust model
Plugins run as JavaScript in the browser app. Treat them as trusted code: Plugins run as JavaScript in the browser app. Treat them as trusted code:
+1 -1
View File
@@ -2,4 +2,4 @@ export { activityApi, api, configApi, filesApi, gitApi, machinesApi, piPackagesA
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export { DEFAULT_WORKSPACE_UPLOADS_FOLDER, effectiveWorkspaceUploadFolder, uploadWorkspaceFile, uploadWorkspaceFiles, workspaceEffectiveUploadFolder, workspaceUploadPath, WorkspaceUploadBatchError, WorkspaceUploadCancelledError } from "./api/workspaceUploads"; 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 { UploadWorkspaceFileOptions, UploadWorkspaceFilesOptions, WorkspaceFileUploadProgress, WorkspaceUploadBatchFileProgress, WorkspaceUploadBatchProgress, WorkspaceUploadFileFailure, WorkspaceUploadFileInput, WorkspaceUploadFolderConfig, WorkspaceUploadTask, WorkspaceUploadXhr, WorkspaceUploadXhrFactory } from "./api/workspaceUploads";
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"; export type { ActiveAgentProfileDescriptor, ArchiveSessionsResponse, AskUserCloseResponse, AskUserQuestion, AskUserSubmission, PendingAskUser, PendingExtensionDialog, AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, ExtensionDialogCloseReason, ExtensionDialogCloseResponse, ExtensionDialogKind, ExtensionDialogOutcome, 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";
+45
View File
@@ -321,6 +321,34 @@ describe("session API compatibility", () => {
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" }); expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" });
}); });
it("answers and cancels extension dialogs through encoded machine routes", async () => {
const answered = {
result: "closed",
outcome: { dialogId: "dialog 1", reason: "answered", answer: true, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" },
sessionStatus: dialogStatusWire(),
};
const cancelled = { result: "stale", sessionStatus: dialogStatusWire() };
const fetchMock = stubSequenceFetch([jsonResponse(answered), jsonResponse(cancelled)]);
const ref = { id: "s /?", cwd: "/repo with spaces" };
await expect(sessionsApi.answerDialog(ref, "dialog 1", true, "remote /?")).resolves.toEqual({
result: "closed",
outcome: { dialogId: "dialog 1", reason: "answered", answer: true, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" },
sessionStatus: parsedDialogStatus(),
});
await expect(sessionsApi.cancelDialog(ref, "dialog 1", "remote /?")).resolves.toEqual({ result: "stale", sessionStatus: parsedDialogStatus() });
expect(fetchMock).toHaveBeenCalledTimes(2);
const [answerUrl, answerInit] = fetchCall(fetchMock, 0);
expect(answerUrl).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/dialogs/answer");
expect(answerInit?.method).toBe("POST");
expect(JSON.parse(requestBody(answerInit))).toEqual({ cwd: "/repo with spaces", dialogId: "dialog 1", value: true });
const [cancelUrl, cancelInit] = fetchCall(fetchMock, 1);
expect(cancelUrl).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/dialogs/cancel");
expect(cancelInit?.method).toBe("POST");
expect(JSON.parse(requestBody(cancelInit))).toEqual({ cwd: "/repo with spaces", dialogId: "dialog 1" });
});
it("posts session tree navigation through an encoded cwd-scoped machine route", async () => { it("posts session tree navigation through an encoded cwd-scoped machine route", async () => {
const fetchMock = stubJsonFetch({ cancelled: false, editorText: "edit this" }); const fetchMock = stubJsonFetch({ cancelled: false, editorText: "edit this" });
const navigation = { targetId: "entry /?", expectedLeafId: "leaf-1", summary: { mode: "custom" as const, instructions: "focus on tests" } }; const navigation = { targetId: "entry /?", expectedLeafId: "leaf-1", summary: { mode: "custom" as const, instructions: "focus on tests" } };
@@ -592,6 +620,23 @@ function sessionInfoResponse(id: string) {
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" }; return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
} }
function dialogStatusWire() {
return {
sessionId: "s /?",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
// The parsed status normalizes the wire shape (queuedMessages defaults to []).
function parsedDialogStatus() {
return { ...dialogStatusWire(), queuedMessages: [] };
}
function piWebConfigResponse(config: PiWebConfigValues) { function piWebConfigResponse(config: PiWebConfigValues) {
return { return {
path: "/tmp/pi-web/config.json", path: "/tmp/pi-web/config.json",
+4 -1
View File
@@ -1,4 +1,4 @@
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 type { AskUserSubmission, DeleteWorkspaceFileResponse, ExtensionDialogAnswer, 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 { resolveAppUrl } from "../appUrl";
import { request } from "./http"; import { request } from "./http";
import { import {
@@ -13,6 +13,7 @@ import {
parseDeleted, parseDeleted,
parseDeleteWorkspaceFileResponse, parseDeleteWorkspaceFileResponse,
parseDetached, parseDetached,
parseExtensionDialogCloseResponse,
parseFileContentResponse, parseFileContentResponse,
parseFileSuggestion, parseFileSuggestion,
parseFileTreeResponse, parseFileTreeResponse,
@@ -227,6 +228,8 @@ export const sessionsApi = {
dismissWarning: (session: SessionLookup, dismissId: string, machineId = "local") => request(sessionPath(session, "warnings/dismiss", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { dismissId }) }), 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 }) }), 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 }) }), cancelAsk: (session: SessionLookup, askId: string, machineId = "local") => request(sessionPath(session, "ask/cancel", machineId), parseAskUserCloseResponse, { method: "POST", body: sessionBody(session, { askId }) }),
answerDialog: (session: SessionLookup, dialogId: string, value: ExtensionDialogAnswer, machineId = "local") => request(sessionPath(session, "dialogs/answer", machineId), parseExtensionDialogCloseResponse, { method: "POST", body: sessionBody(session, { dialogId, value }) }),
cancelDialog: (session: SessionLookup, dialogId: string, machineId = "local") => request(sessionPath(session, "dialogs/cancel", machineId), parseExtensionDialogCloseResponse, { method: "POST", body: sessionBody(session, { dialogId }) }),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse), 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 }) }), 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 }) }), cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
@@ -44,6 +44,14 @@ describe("federated route contract", () => {
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("ask"))).toBe(false); expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("ask"))).toBe(false);
}); });
it("allowlists both extension dialog routes on the existing session WebSocket", () => {
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("/dialogs/"))).toEqual([
{ method: "POST", path: "/sessions/:sessionId/dialogs/answer" },
{ method: "POST", path: "/sessions/:sessionId/dialogs/cancel" },
]);
expect(FEDERATED_WEBSOCKET_ROUTES.some((path) => path.includes("dialogs"))).toBe(false);
});
it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => { it("allowlists daemon-authoritative unread HTTP routes on the existing global socket", () => {
expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([ expect(FEDERATED_HTTP_ROUTES.filter((route) => route.path.includes("unread"))).toEqual([
{ method: "GET", path: "/sessions/unread" }, { method: "GET", path: "/sessions/unread" },
@@ -107,6 +115,8 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.dismissWarning(session, "anthropicExtraUsage", machineId)), ignoreParseFailure(sessionsApi.dismissWarning(session, "anthropicExtraUsage", machineId)),
ignoreParseFailure(sessionsApi.submitAsk(session, "ask 1", { answers: [{ id: "q1", values: ["pg"] }] }, machineId)), ignoreParseFailure(sessionsApi.submitAsk(session, "ask 1", { answers: [{ id: "q1", values: ["pg"] }] }, machineId)),
ignoreParseFailure(sessionsApi.cancelAsk(session, "ask 1", machineId)), ignoreParseFailure(sessionsApi.cancelAsk(session, "ask 1", machineId)),
ignoreParseFailure(sessionsApi.answerDialog(session, "dialog 1", true, machineId)),
ignoreParseFailure(sessionsApi.cancelDialog(session, "dialog 1", machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)), ignoreParseFailure(sessionsApi.models(session, machineId)),
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)), ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)), ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
+105 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { ASK_USER_TEXT_MAX_LENGTH, SESSION_NOTIFICATION_LIMIT, SESSION_NOTIFICATION_MESSAGE_BYTES, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../../shared/apiTypes"; import { ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_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"; import { parseAskUserCloseResponse, parseAuthProvidersResponse, parseCommandResult, parseExtensionDialogCloseResponse, 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", () => { describe("API parsers", () => {
it("preserves additive interactive API-key flow hints and defaults legacy options", () => { it("preserves additive interactive API-key flow hints and defaults legacy options", () => {
@@ -796,6 +796,65 @@ describe("API parsers", () => {
sessionStatus: statusWire(), sessionStatus: statusWire(),
})).toThrow("Ask answer selected an option the question never offered"); })).toThrow("Ask answer selected an option the question never offered");
}); });
it("parses open extension dialogs on the session status, oldest first", () => {
const parsed = parseSessionStatus({ ...statusWire(), pendingDialogs: [confirmDialogWire(), selectDialogWire(), inputDialogWire()] });
expect(parsed.pendingDialogs).toEqual([
{ dialogId: "dialog-1", kind: "confirm", title: "Delete the build cache?", message: "This cannot be undone", askedAt: "2026-07-20T00:00:00.000Z", runScoped: true },
{ dialogId: "dialog-2", kind: "select", title: "Pick a database", options: ["Postgres", "SQLite"], askedAt: "2026-07-20T00:01:00.000Z", timeoutAt: "2026-07-20T00:06:00.000Z", runScoped: false },
{ dialogId: "dialog-3", kind: "input", title: "Name the branch", placeholder: "feature/...", askedAt: "2026-07-20T00:02:00.000Z", runScoped: false },
]);
});
it("omits pending dialogs entirely when the field is absent", () => {
expect(parseSessionStatus(statusWire()).pendingDialogs).toBeUndefined();
});
it("validates an extension dialog before rendering it", () => {
const dialog = confirmDialogWire();
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, kind: "modal" }] })).toThrow("Invalid extension dialog kind");
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, title: "" }] })).toThrow("Expected non-empty string field: title");
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, title: "x".repeat(EXTENSION_DIALOG_TEXT_MAX_LENGTH + 1) }] })).toThrow("String field exceeds limit: title");
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, runScoped: "yes" }] })).toThrow("Expected boolean field: runScoped");
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...dialog, timeoutAt: "" }] })).toThrow("Expected non-empty string field: timeoutAt");
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...selectDialogWire(), options: [] }] })).toThrow("Select dialog has no options");
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [{ ...selectDialogWire(), options: ["a", "a"] }] })).toThrow("Duplicate dialog option");
expect(() => parseSessionStatus({ ...statusWire(), pendingDialogs: [dialog, { ...inputDialogWire(), dialogId: "dialog-1" }] })).toThrow("Duplicate dialog id");
});
it("parses a closed dialog response carrying the outcome and recomputed status", () => {
const response = parseExtensionDialogCloseResponse({
result: "closed",
outcome: dialogOutcomeWire(),
sessionStatus: statusWire(),
});
expect(response.result).toBe("closed");
expect(response.outcome).toEqual({
dialogId: "dialog-1",
reason: "answered",
answer: true,
askedAt: "2026-07-20T00:00:00.000Z",
closedAt: "2026-07-20T00:01:00.000Z",
});
expect(response.sessionStatus.sessionId).toBe("s1");
});
it("parses a stale dialog close as an ordinary race with no outcome", () => {
const response = parseExtensionDialogCloseResponse({ result: "stale", sessionStatus: statusWire() });
expect(response).toEqual({ result: "stale", sessionStatus: parseSessionStatus(statusWire()) });
});
it("rejects dialog close responses whose outcome contradicts itself", () => {
const outcome = dialogOutcomeWire();
expect(() => parseExtensionDialogCloseResponse({ result: "closed", sessionStatus: statusWire() })).toThrow("Dialog close response outcome mismatch");
expect(() => parseExtensionDialogCloseResponse({ result: "stale", outcome, sessionStatus: statusWire() })).toThrow("Dialog close response outcome mismatch");
expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, reason: "timeout" }, sessionStatus: statusWire() })).toThrow("Dialog outcome answer mismatch");
expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, answer: 1 }, sessionStatus: statusWire() })).toThrow("Invalid extension dialog answer");
expect(() => parseExtensionDialogCloseResponse({ result: "closed", outcome: { ...outcome, reason: "ignored" }, sessionStatus: statusWire() })).toThrow("Invalid extension dialog close reason");
});
}); });
function statusWire() { function statusWire() {
@@ -845,6 +904,50 @@ function askOutcomeWire() {
}; };
} }
function confirmDialogWire() {
return {
dialogId: "dialog-1",
kind: "confirm",
title: "Delete the build cache?",
message: "This cannot be undone",
askedAt: "2026-07-20T00:00:00.000Z",
runScoped: true,
};
}
function selectDialogWire() {
return {
dialogId: "dialog-2",
kind: "select",
title: "Pick a database",
options: ["Postgres", "SQLite"],
askedAt: "2026-07-20T00:01:00.000Z",
timeoutAt: "2026-07-20T00:06:00.000Z",
runScoped: false,
};
}
function inputDialogWire() {
return {
dialogId: "dialog-3",
kind: "input",
title: "Name the branch",
placeholder: "feature/...",
askedAt: "2026-07-20T00:02:00.000Z",
runScoped: false,
};
}
function dialogOutcomeWire() {
return {
dialogId: "dialog-1",
reason: "answered",
answer: true,
askedAt: "2026-07-20T00:00:00.000Z",
closedAt: "2026-07-20T00:01:00.000Z",
};
}
function sessionTreeWire() { function sessionTreeWire() {
const kinds = [ const kinds = [
"user", "user",
+118 -1
View File
@@ -1,4 +1,4 @@
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 { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, ASK_USER_TEXT_MAX_LENGTH, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, EXTENSION_DIALOG_OPTION_LIMIT, EXTENSION_DIALOG_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 PendingExtensionDialog, type AuthProviderOption, type AuthProviderStatus, type AuthProvidersResponse, type AuthStatusSource, type AuthType, type CommandOption, type CommandResult, type DeleteWorkspaceFileResponse, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogKind, type ExtensionDialogOutcome, 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 type { PiPackageInfo, PiPackageMutationAction, PiPackageMutationResponse, PiPackageScope, PiPackagesResponse, SessionActivity, SessionStartupProgressEvent, SessionTreeNavigateResult, SessionTreeNode, SessionTreeNodeKind, SessionTreeSnapshot } from "../../../shared/apiTypes";
import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile"; import { parseActiveAgentProfileDescriptor } from "../../../shared/activeAgentProfile";
import { parseKnownPiWebCapabilities } from "../../../shared/capabilities"; import { parseKnownPiWebCapabilities } from "../../../shared/capabilities";
@@ -332,10 +332,126 @@ export function parseAskUserCloseResponse(value: unknown): AskUserCloseResponse
}; };
} }
export function parseExtensionDialogCloseResponse(value: unknown): ExtensionDialogCloseResponse {
const record = requireRecord(value);
const result = record["result"];
if (result !== "closed" && result !== "stale") throw new Error("Invalid dialog close result");
const outcome = record["outcome"] === undefined ? undefined : parseExtensionDialogOutcome(record["outcome"]);
// Only the call that actually closed the dialog carries an outcome; a stale
// close reports none and is trusted for the session status alone.
if ((result === "closed") !== (outcome !== undefined)) throw new Error("Dialog close response outcome mismatch");
return {
result,
...(outcome === undefined ? {} : { outcome }),
sessionStatus: parseSessionStatus(record["sessionStatus"]),
};
}
function assertUniqueStrings(values: readonly string[], label: string): void { function assertUniqueStrings(values: readonly string[], label: string): void {
if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`); if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`);
} }
function parseExtensionDialogKind(value: unknown): ExtensionDialogKind {
if (value !== "confirm" && value !== "select" && value !== "input") throw new Error("Invalid extension dialog kind");
return value;
}
function parseExtensionDialogCloseReason(value: unknown): ExtensionDialogCloseReason {
if (value !== "answered" && value !== "cancelled" && value !== "timeout" && value !== "aborted" && value !== "session-ended") {
throw new Error("Invalid extension dialog close reason");
}
return value;
}
function parseExtensionDialogAnswer(value: unknown): ExtensionDialogAnswer {
if (typeof value === "boolean") return value;
if (typeof value === "string" && value.length <= EXTENSION_DIALOG_INPUT_MAX_LENGTH) return value;
throw new Error("Invalid extension dialog answer");
}
function parseExtensionDialogOption(value: unknown): string {
const option = parseNonEmptyString(value);
if (option.length > EXTENSION_DIALOG_TEXT_MAX_LENGTH) throw new Error("String field exceeds limit: option");
return option;
}
/**
* Validate one open extension dialog. A malformed dialog must be dropped rather
* than rendered: the card parks an extension's blocking wait on the user's
* answer, so a choice list or prompt the daemon did not really send must never
* appear.
*/
function parsePendingExtensionDialog(value: unknown): PendingExtensionDialog {
const record = requireRecord(value);
const kind = parseExtensionDialogKind(record["kind"]);
const options = record["options"] === undefined
? undefined
: boundedArrayOf(record["options"], parseExtensionDialogOption, EXTENSION_DIALOG_OPTION_LIMIT, "options");
if (options !== undefined) assertUniqueStrings(options, "dialog option");
if (kind === "select" && (options === undefined || options.length === 0)) throw new Error("Select dialog has no options");
return {
dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH),
kind,
title: requireBoundedNonEmptyString(record, "title", EXTENSION_DIALOG_TEXT_MAX_LENGTH),
...optionalField("message", optionalBoundedNonEmptyString(record, "message", EXTENSION_DIALOG_TEXT_MAX_LENGTH)),
...(options === undefined ? {} : { options }),
...optionalField("placeholder", optionalBoundedNonEmptyString(record, "placeholder", EXTENSION_DIALOG_TEXT_MAX_LENGTH)),
askedAt: requireNonEmptyString(record, "askedAt"),
...optionalField("timeoutAt", optionalNonEmptyString(record, "timeoutAt")),
runScoped: requireBoolean(record, "runScoped"),
};
}
function optionalPendingDialogs(value: unknown): Pick<SessionStatus, "pendingDialogs"> | object {
if (value === undefined) return {};
const dialogs = arrayOf(parsePendingExtensionDialog)(value);
assertUniqueStrings(dialogs.map((dialog) => dialog.dialogId), "dialog id");
return { pendingDialogs: dialogs };
}
export function parseSessionDialogOpenedEvent(value: unknown): { type: "dialog.opened"; dialog: PendingExtensionDialog } {
const record = requireRecord(value);
if (record["type"] !== "dialog.opened") throw new Error("Invalid dialog opened event type");
return { type: "dialog.opened", dialog: parsePendingExtensionDialog(record["dialog"]) };
}
export function parseSessionDialogClosedEvent(value: unknown): { type: "dialog.closed"; dialogId: string; reason: ExtensionDialogCloseReason; answer?: ExtensionDialogAnswer } {
const record = requireRecord(value);
if (record["type"] !== "dialog.closed") throw new Error("Invalid dialog closed event type");
const reason = parseExtensionDialogCloseReason(record["reason"]);
const answer = record["answer"] === undefined ? undefined : parseExtensionDialogAnswer(record["answer"]);
// Only an answered close carries a value; any other combination cannot be
// rendered honestly as the dialog's result.
if ((reason === "answered") !== (answer !== undefined)) throw new Error("Dialog closed event answer mismatch");
return {
type: "dialog.closed",
dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH),
reason,
...(answer === undefined ? {} : { answer }),
};
}
export function parseExtensionDialogOutcome(value: unknown): ExtensionDialogOutcome {
const record = requireRecord(value);
const reason = parseExtensionDialogCloseReason(record["reason"]);
const answer = record["answer"] === undefined ? undefined : parseExtensionDialogAnswer(record["answer"]);
if ((reason === "answered") !== (answer !== undefined)) throw new Error("Dialog outcome answer mismatch");
return {
dialogId: requireBoundedNonEmptyString(record, "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH),
reason,
...(answer === undefined ? {} : { answer }),
askedAt: requireNonEmptyString(record, "askedAt"),
closedAt: requireNonEmptyString(record, "closedAt"),
};
}
function optionalNonEmptyString(record: Record<string, unknown>, key: string): string | undefined {
const value = optionalString(record, key);
if (value === undefined) return undefined;
if (value === "") throw new Error(`Expected non-empty string field: ${key}`);
return value;
}
export function parseSessionStatus(value: unknown): SessionStatus { export function parseSessionStatus(value: unknown): SessionStatus {
const record = requireRecord(value); const record = requireRecord(value);
return { return {
@@ -354,6 +470,7 @@ export function parseSessionStatus(value: unknown): SessionStatus {
...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")), ...optionalField("thinkingLevel", optionalString(record, "thinkingLevel")),
...optionalWarnings(record["warnings"]), ...optionalWarnings(record["warnings"]),
...optionalPendingAsk(record["pendingAsk"]), ...optionalPendingAsk(record["pendingAsk"]),
...optionalPendingDialogs(record["pendingDialogs"]),
}; };
} }
+26 -1
View File
@@ -1,4 +1,4 @@
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 { AuthProviderOption, CommandOption, CommandResult, ExtensionDialogAnswer, ExtensionDialogCloseReason, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MachineHealth, MachineRuntime, OAuthFlowState, PendingAskUser, PendingExtensionDialog, PiWebStatusResponse, Project, QueuedSessionMessage, SessionActivity, SessionInfo, SessionStatus, SessionTreeSnapshot, TerminalCommandRun, Workspace, WorkspaceActivity } from "./api";
import type { ChatLine } from "./components/shared"; import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/ids"; import type { QualifiedContributionId } from "./plugins/ids";
import type { SelectedSessionNotificationInbox } from "./sessionNotifications"; import type { SelectedSessionNotificationInbox } from "./sessionNotifications";
@@ -37,6 +37,21 @@ export interface AppState {
* dropped when the machine reports no `sessions.askUser` support. * dropped when the machine reports no `sessions.askUser` support.
*/ */
pendingAsk: PendingAskUser | undefined; pendingAsk: PendingAskUser | undefined;
/**
* The selected session's open extension dialogs, derived from the
* daemon-owned {@link SessionStatus.pendingDialogs} plus live dialog events.
* Oldest first; unlike an ask, opening never supersedes, so several dialogs
* may wait at once.
*/
pendingDialogs: PendingExtensionDialog[];
/**
* Dialogs that closed while their session was selected, kept with the close
* reason and any answer so the settled card can show what became of the
* dialog. The card stays until the user dismisses it. The wire outcome is
* deliberately small, so only a browser that saw the dialog open can show
* the closed card; deselection and reloads drop these.
*/
closedDialogs: ClosedExtensionDialog[];
/** Thinking levels available for the selected session's current model. */ /** Thinking levels available for the selected session's current model. */
availableThinkingLevels: readonly string[]; availableThinkingLevels: readonly string[];
sessionStatuses: Record<string, SessionStatus>; sessionStatuses: Record<string, SessionStatus>;
@@ -76,6 +91,14 @@ export interface AppState {
error: string; error: string;
} }
/** A closed extension dialog paired with the record the browser rendered while it was open. */
export interface ClosedExtensionDialog {
dialog: PendingExtensionDialog;
reason: ExtensionDialogCloseReason;
/** Present only when `reason` is `"answered"`. */
answer?: ExtensionDialogAnswer;
}
export type AuthDialogState = export type AuthDialogState =
| { step: "method" } | { step: "method" }
| { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] } | { step: "providers"; mode: "login"; authType?: "oauth" | "api_key"; providers: AuthProviderOption[] }
@@ -151,6 +174,8 @@ export function initialAppState(): AppState {
status: undefined, status: undefined,
activity: undefined, activity: undefined,
pendingAsk: undefined, pendingAsk: undefined,
pendingDialogs: [],
closedDialogs: [],
availableThinkingLevels: [], availableThinkingLevels: [],
sessionStatuses: {}, sessionStatuses: {},
sessionActivities: {}, sessionActivities: {},
@@ -0,0 +1,126 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PendingExtensionDialog } from "../api";
import type { ClosedExtensionDialog } from "../appState";
import { ChatView } from "./ChatView";
import { ExtensionDialogCard } from "./ExtensionDialogCard";
afterEach(() => {
document.body.replaceChildren();
});
describe("ChatView open extension dialogs", () => {
it("renders the oldest pending dialog at the transcript foot with a stable chat-scroll anchor", async () => {
const view = await mountView();
const oldest = openDialog("dlg-1", "Allow file writes?");
view.pendingDialogs = [oldest, openDialog("dlg-2", "Pick a region", { kind: "select", options: ["eu", "us"] })];
await view.updateComplete;
const card = requiredElement(view.shadowRoot?.querySelector<ExtensionDialogCard>(".chat > extension-dialog-card.open-dialog-card"), "open dialog card");
expect(card).toBeInstanceOf(ExtensionDialogCard);
expect(card.getAttribute("data-scroll-anchor-id")).toBe("dialog:dlg-1");
expect(card.dialog).toBe(oldest);
expect(view.shadowRoot?.querySelector(".queued-dialogs")?.textContent).toContain("1 more extension dialog queued");
});
it("renders no queued affordance for a single pending dialog", async () => {
const view = await mountView();
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
await view.updateComplete;
expect(view.shadowRoot?.querySelector(".chat > extension-dialog-card.open-dialog-card")).not.toBeNull();
expect(view.shadowRoot?.querySelector(".queued-dialogs")).toBeNull();
});
it("scrolls a newly opened dialog to its start", async () => {
const view = await mountView();
let dialogStartScrolls = 0;
let bottomScrolls = 0;
if (!Reflect.set(view, "scrollToOpenDialog", () => { dialogStartScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToOpenDialog");
if (!Reflect.set(view, "scrollToBottom", () => { bottomScrolls += 1; })) throw new Error("Could not observe ChatView.scrollToBottom");
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
await view.updateComplete;
expect(dialogStartScrolls).toBe(1);
expect(bottomScrolls).toBe(0);
});
it("forwards the answer and cancel callbacks to the open dialog card", async () => {
const view = await mountView();
const onAnswerDialog = vi.fn();
const onCancelDialog = vi.fn();
view.onAnswerDialog = onAnswerDialog;
view.onCancelDialog = onCancelDialog;
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
await view.updateComplete;
const card = requiredElement(view.shadowRoot?.querySelector<ExtensionDialogCard>("extension-dialog-card.open-dialog-card"), "open dialog card");
void card.onAnswer?.("dlg-1", true);
void card.onCancel?.("dlg-1");
expect(onAnswerDialog).toHaveBeenCalledWith("dlg-1", true);
expect(onCancelDialog).toHaveBeenCalledWith("dlg-1");
});
});
describe("ChatView closed extension dialogs", () => {
it("renders closed dialogs transiently above the open one and forwards the dismiss callback", async () => {
const view = await mountView();
const onDismissClosedDialog = vi.fn();
view.onDismissClosedDialog = onDismissClosedDialog;
const closed = closedDialog("dlg-0", "Allow reads?", "answered", true);
view.closedDialogs = [closed];
view.pendingDialogs = [openDialog("dlg-1", "Allow file writes?")];
await view.updateComplete;
const cards = [...(view.shadowRoot?.querySelectorAll<ExtensionDialogCard>(".chat > extension-dialog-card") ?? [])];
expect(cards).toHaveLength(2);
const closedCard = requiredElement(cards[0], "closed dialog card");
expect(closedCard.classList.contains("closed-dialog-card")).toBe(true);
expect(closedCard.getAttribute("data-scroll-anchor-id")).toBe("closed-dialog:dlg-0");
expect(closedCard.outcome).toBe(closed);
expect(cards[1]?.classList.contains("open-dialog-card")).toBe(true);
closedCard.onDismiss?.("dlg-0");
expect(onDismissClosedDialog).toHaveBeenCalledWith("dlg-0");
});
});
async function mountView(): Promise<ChatView> {
const view = new ChatView();
view.sessionId = "session-1";
document.body.append(view);
await view.updateComplete;
return view;
}
function requiredElement<T>(value: T | null | undefined, label: string): T {
if (value === null || value === undefined) throw new Error(`Expected ${label}`);
return value;
}
function openDialog(dialogId: string, title: string, overrides: Partial<PendingExtensionDialog> = {}): PendingExtensionDialog {
return {
dialogId,
kind: "confirm",
title,
askedAt: "2026-07-27T10:00:00.000Z",
runScoped: false,
...overrides,
};
}
function closedDialog(
dialogId: string,
title: string,
reason: ClosedExtensionDialog["reason"],
answer?: ClosedExtensionDialog["answer"],
): ClosedExtensionDialog {
return {
dialog: openDialog(dialogId, title),
reason,
...(answer === undefined ? {} : { answer }),
};
}
+87 -4
View File
@@ -7,7 +7,8 @@ import { writeClipboardText } from "../clipboard";
import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring"; import { capturePrependScrollAnchor, PREPEND_RESTORE_SETTLE_FRAMES, restorePrependScrollAnchor, type PrependScrollAnchor } from "../chatScrollAnchoring";
import { shouldRequestEarlierMessages } from "../chatHistoryLoading"; import { shouldRequestEarlierMessages } from "../chatHistoryLoading";
import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition"; import { ChatScrollController, distanceFromScrollBottom, findFirstVisibleArticle, isNearScrollBottom, type ChatAnchorScrollPosition, type ChatScrollRestoreResult } from "../chatScrollPosition";
import type { AskUserSubmission, PendingAskUser, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api"; import type { AskUserSubmission, PendingAskUser, PendingExtensionDialog, QueuedSessionMessage, SessionActivity, SessionStatus, SessionWarningSeverity } from "../api";
import type { ClosedExtensionDialog } from "../appState";
import { import {
notificationAnnouncementLabel, notificationAnnouncementLabel,
notificationDismissLabel, notificationDismissLabel,
@@ -27,6 +28,8 @@ import {
import type { ChatLine, ChatPart } from "./shared"; import type { ChatLine, ChatPart } from "./shared";
import { chatStyles, renderSessionWarningIcon } from "./shared"; import { chatStyles, renderSessionWarningIcon } from "./shared";
import "./AskUserCard"; import "./AskUserCard";
import "./ExtensionDialogCard";
import type { ExtensionDialogAnswerCallback, ExtensionDialogCancelCallback, ExtensionDialogDismissCallback } from "./ExtensionDialogCard";
import "./ConversationMeter"; import "./ConversationMeter";
import "./FormattedText"; import "./FormattedText";
import "./ToolExecutionView"; import "./ToolExecutionView";
@@ -196,6 +199,11 @@ export class ChatView extends LitElement {
@property({ attribute: false }) pendingAsk?: PendingAskUser; @property({ attribute: false }) pendingAsk?: PendingAskUser;
@property({ attribute: false }) askDraftSessionId = ""; @property({ attribute: false }) askDraftSessionId = "";
@property({ attribute: false }) onSubmitAsk?: (askId: string, submission: AskUserSubmission) => void | Promise<void>; @property({ attribute: false }) onSubmitAsk?: (askId: string, submission: AskUserSubmission) => void | Promise<void>;
@property({ attribute: false }) pendingDialogs: PendingExtensionDialog[] = [];
@property({ attribute: false }) closedDialogs: ClosedExtensionDialog[] = [];
@property({ attribute: false }) onAnswerDialog?: ExtensionDialogAnswerCallback;
@property({ attribute: false }) onCancelDialog?: ExtensionDialogCancelCallback;
@property({ attribute: false }) onDismissClosedDialog?: ExtensionDialogDismissCallback;
@property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView; @property({ attribute: false }) notificationInbox?: SelectedSessionNotificationView;
@property({ type: Boolean }) canClearServerQueue = false; @property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void; @property({ attribute: false }) onClearServerQueue?: () => void;
@@ -222,6 +230,7 @@ export class ChatView extends LitElement {
private loadMoreCheckFrame: number | undefined; private loadMoreCheckFrame: number | undefined;
private scrollToBottomFrame: number | undefined; private scrollToBottomFrame: number | undefined;
private scrollToOpenAskFrame: number | undefined; private scrollToOpenAskFrame: number | undefined;
private scrollToOpenDialogFrame: number | undefined;
private conversationRailFrame: number | undefined; private conversationRailFrame: number | undefined;
private groupedMessagesInput?: ChatLine[]; private groupedMessagesInput?: ChatLine[];
private groupedMessagesStart = 0; private groupedMessagesStart = 0;
@@ -284,6 +293,10 @@ export class ChatView extends LitElement {
cancelAnimationFrame(this.scrollToOpenAskFrame); cancelAnimationFrame(this.scrollToOpenAskFrame);
this.scrollToOpenAskFrame = undefined; this.scrollToOpenAskFrame = undefined;
} }
if (this.scrollToOpenDialogFrame !== undefined) {
cancelAnimationFrame(this.scrollToOpenDialogFrame);
this.scrollToOpenDialogFrame = undefined;
}
if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame); if (this.conversationRailFrame !== undefined) cancelAnimationFrame(this.conversationRailFrame);
window.removeEventListener("resize", this.onViewportResize); window.removeEventListener("resize", this.onViewportResize);
window.removeEventListener("pagehide", this.onPageHide); window.removeEventListener("pagehide", this.onPageHide);
@@ -314,6 +327,10 @@ export class ChatView extends LitElement {
cancelAnimationFrame(this.scrollToOpenAskFrame); cancelAnimationFrame(this.scrollToOpenAskFrame);
this.scrollToOpenAskFrame = undefined; this.scrollToOpenAskFrame = undefined;
} }
if (this.scrollToOpenDialogFrame !== undefined) {
cancelAnimationFrame(this.scrollToOpenDialogFrame);
this.scrollToOpenDialogFrame = undefined;
}
} }
protected override willUpdate(changed: Map<string, unknown>): void { protected override willUpdate(changed: Map<string, unknown>): void {
@@ -324,7 +341,7 @@ export class ChatView extends LitElement {
this.pendingNotificationFocus = undefined; this.pendingNotificationFocus = undefined;
this.retainedEmptyNotificationTrayTargetKey = undefined; this.retainedEmptyNotificationTrayTargetKey = undefined;
} }
if (changed.has("messages") || changed.has("pendingAsk")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom()); if (changed.has("messages") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) this.pinnedToBottom = this.pinnedToBottom && (this.didChatHeightChange() || this.isNearBottom());
} }
protected override update(changed: Map<string, unknown>): void { protected override update(changed: Map<string, unknown>): void {
@@ -338,12 +355,14 @@ export class ChatView extends LitElement {
if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false; if (changed.has("hasMore") && !this.hasMore) this.loadMoreRequested = false;
if (changed.has("sessionId")) this.restoreScrollPosition(); if (changed.has("sessionId")) this.restoreScrollPosition();
const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk")); const openedAsk = changed.has("pendingAsk") && this.isNewPendingAsk(changed.get("pendingAsk"));
const openedDialog = changed.has("pendingDialogs") && this.isNewOpenDialog(changed.get("pendingDialogs"));
// The form uses the transcript scroller. Start a new long form at question // 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. // one rather than applying the usual live-tail scroll and landing at its end.
if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk(); if (!changed.has("sessionId") && openedAsk && this.pinnedToBottom) this.scrollToOpenAsk();
else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk")) && this.pinnedToBottom) this.scrollToBottom(); else if (!changed.has("sessionId") && openedDialog && this.pinnedToBottom) this.scrollToOpenDialog();
else if (!changed.has("sessionId") && (changed.has("messages") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) && 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("messageTotal") || changed.has("hasMore") || changed.has("loadingMore")) this.scheduleConversationRailUpdate();
if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk")) this.continuePendingScrollRestore(); if (changed.has("messages") || changed.has("messageStart") || changed.has("hasMore") || changed.has("loadingMore") || changed.has("pendingAsk") || changed.has("pendingDialogs") || changed.has("closedDialogs")) this.continuePendingScrollRestore();
if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded(); if (changed.has("messages") || changed.has("hasMore") || changed.has("loadingMore")) this.requestLoadMoreIfNeeded();
if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget(); if (changed.has("notificationInbox") && this.pendingNotificationFocus !== undefined) this.focusPendingNotificationTarget();
if (changed.has("zoomedImage")) this.syncImageZoomDialog(); if (changed.has("zoomedImage")) this.syncImageZoomDialog();
@@ -383,6 +402,7 @@ export class ChatView extends LitElement {
${this.renderQueuedMessages()} ${this.renderQueuedMessages()}
${this.renderSessionActivity()} ${this.renderSessionActivity()}
${this.renderOpenAsk()} ${this.renderOpenAsk()}
${this.renderExtensionDialogs()}
</div> </div>
${this.renderActivityDock()} ${this.renderActivityDock()}
</div> </div>
@@ -668,6 +688,38 @@ export class ChatView extends LitElement {
`; `;
} }
private renderExtensionDialogs() {
const open = this.pendingDialogs[0];
if (open === undefined && this.closedDialogs.length === 0) return null;
const queuedCount = this.pendingDialogs.length - 1;
return html`
${repeat(
this.closedDialogs,
(closed) => closed.dialog.dialogId,
(closed) => html`
<extension-dialog-card
class="closed-dialog-card"
data-scroll-anchor-id=${`closed-dialog:${closed.dialog.dialogId}`}
.outcome=${closed}
.onDismiss=${this.onDismissClosedDialog}
></extension-dialog-card>
`,
)}
${open === undefined ? null : html`
<extension-dialog-card
class="open-dialog-card"
data-scroll-anchor-id=${`dialog:${open.dialogId}`}
.dialog=${open}
.onAnswer=${this.onAnswerDialog}
.onCancel=${this.onCancelDialog}
></extension-dialog-card>
${queuedCount > 0
? html`<p class="queued-dialogs" role="status">${String(queuedCount)} more extension ${queuedCount === 1 ? "dialog" : "dialogs"} queued</p>`
: null}
`}
`;
}
private renderSessionActivity() { private renderSessionActivity() {
if (!this.isCompacting) return null; if (!this.isCompacting) return null;
return html` return html`
@@ -1030,6 +1082,14 @@ export class ChatView extends LitElement {
&& (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId); && (typeof previous !== "object" || previous === null || Reflect.get(previous, "askId") !== this.pendingAsk.askId);
} }
private isNewOpenDialog(previous: unknown): boolean {
const oldest = this.pendingDialogs[0];
if (oldest === undefined) return false;
if (!Array.isArray(previous)) return true;
const previousOldest: unknown = previous[0];
return typeof previousOldest !== "object" || previousOldest === null || Reflect.get(previousOldest, "dialogId") !== oldest.dialogId;
}
private scrollToOpenAsk(): void { private scrollToOpenAsk(): void {
if (this.scrollToOpenAskFrame !== undefined) return; if (this.scrollToOpenAskFrame !== undefined) return;
if (this.scrollToBottomFrame !== undefined) { if (this.scrollToBottomFrame !== undefined) {
@@ -1052,6 +1112,28 @@ export class ChatView extends LitElement {
return true; return true;
} }
private scrollToOpenDialog(): void {
if (this.scrollToOpenDialogFrame !== undefined) return;
if (this.scrollToBottomFrame !== undefined) {
cancelAnimationFrame(this.scrollToBottomFrame);
this.scrollToBottomFrame = undefined;
}
this.scrollToOpenDialogFrame = requestAnimationFrame(() => {
this.scrollToOpenDialogFrame = undefined;
this.withSuppressedScrollSave(() => { this.alignOpenDialogToTop(); });
});
}
private alignOpenDialogToTop(): boolean {
const chat = this.chat;
const card = this.renderRoot.querySelector<HTMLElement>(".chat > extension-dialog-card.open-dialog-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() { restoreScrollPosition() {
const sessionId = this.sessionId; const sessionId = this.sessionId;
if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame); if (this.restoreScrollFrame !== undefined) cancelAnimationFrame(this.restoreScrollFrame);
@@ -1060,6 +1142,7 @@ export class ChatView extends LitElement {
if (this.sessionId !== sessionId) return; if (this.sessionId !== sessionId) return;
this.withSuppressedScrollSave(() => { this.withSuppressedScrollSave(() => {
if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return; if (this.pendingAsk !== undefined && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenAskToTop()) return;
if (this.pendingDialogs.length > 0 && this.scrollController.readPosition(sessionId) === undefined && this.alignOpenDialogToTop()) return;
const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() }); const result = this.scrollController.restorePosition(sessionId, this.chat, this.scrollAnchorElements(), { fallbackToBottom: this.shouldFallbackToBottomForMissingAnchor() });
this.handleScrollRestoreResult(sessionId, result); this.handleScrollRestoreResult(sessionId, result);
}); });
@@ -0,0 +1,324 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PendingExtensionDialog } from "../../../shared/apiTypes";
import type { ClosedExtensionDialog } from "../appState";
import {
ExtensionDialogCard,
extensionDialogCloseLabel,
extensionDialogCloseSummary,
extensionDialogCountdownText,
type ExtensionDialogAnswerCallback,
type ExtensionDialogCancelCallback,
type ExtensionDialogDismissCallback,
} from "./ExtensionDialogCard";
afterEach(() => {
vi.useRealTimers();
document.body.replaceChildren();
localStorage.clear();
});
describe("extension-dialog-card confirm dialog", () => {
it("renders the title and message and answers Yes/No or cancels through the rendered buttons", async () => {
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
const onCancel = vi.fn<ExtensionDialogCancelCallback>();
const card = await mountOpenDialog(openDialog({ message: "The extension wants to write files." }), { onAnswer, onCancel });
const root = renderRoot(card);
expect(root.querySelector("h2")?.textContent).toBe("Allow file writes?");
expect(root.querySelector(".dialog-message")?.textContent).toBe("The extension wants to write files.");
expect(root.querySelector("input, select, textarea")).toBeNull();
buttonWithText(root, "Yes").click();
await flushClose(card);
expect(onAnswer).toHaveBeenCalledWith("dlg-1", true);
buttonWithText(root, "No").click();
await flushClose(card);
expect(onAnswer).toHaveBeenCalledWith("dlg-1", false);
buttonWithText(root, "Cancel").click();
await flushClose(card);
expect(onCancel).toHaveBeenCalledWith("dlg-1");
expect(onCancel).toHaveBeenCalledOnce();
});
it("disables the answer controls while a close is in flight", async () => {
let resolveAnswer: (() => void) | undefined;
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>(() => new Promise<void>((resolve) => { resolveAnswer = resolve; }));
const card = await mountOpenDialog(openDialog(), { onAnswer });
const root = renderRoot(card);
const yes = buttonWithText(root, "Yes");
yes.click();
await card.updateComplete;
expect(yes.disabled).toBe(true);
expect(buttonWithText(root, "No").disabled).toBe(true);
expect(buttonWithText(root, "Cancel").disabled).toBe(true);
resolveAnswer?.();
await flushClose(card);
expect(yes.disabled).toBe(false);
expect(onAnswer).toHaveBeenCalledOnce();
});
});
describe("extension-dialog-card select dialog", () => {
it("answers with the clicked option", async () => {
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
const card = await mountOpenDialog(openDialog({
kind: "select",
title: "Deploy where?",
options: ["Staging", "Production"],
}), { onAnswer });
const root = renderRoot(card);
expect(buttonsWithText(root, "Yes")).toHaveLength(0);
buttonWithText(root, "Production").click();
await Promise.resolve();
expect(onAnswer).toHaveBeenCalledWith("dlg-1", "Production");
expect(onAnswer).toHaveBeenCalledOnce();
});
});
describe("extension-dialog-card input dialog", () => {
it("sends the typed text and keeps the placeholder and length bound", async () => {
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
const card = await mountOpenDialog(openDialog({
kind: "input",
title: "Name the branch",
placeholder: "feature/…",
}), { onAnswer });
const root = renderRoot(card);
const input = requiredElement(root.querySelector("input"), "dialog input");
expect(input.placeholder).toBe("feature/…");
expect(input.maxLength).toBe(4000);
input.value = "feature/dialogs";
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
await card.updateComplete;
buttonWithText(root, "Send").click();
await Promise.resolve();
expect(onAnswer).toHaveBeenCalledWith("dlg-1", "feature/dialogs");
});
it("sends an empty string without typing", async () => {
const onAnswer = vi.fn<ExtensionDialogAnswerCallback>();
const card = await mountOpenDialog(openDialog({ kind: "input", title: "Notes?" }), { onAnswer });
const root = renderRoot(card);
const send = buttonWithText(root, "Send");
expect(send.disabled).toBe(false);
send.click();
await Promise.resolve();
expect(onAnswer).toHaveBeenCalledWith("dlg-1", "");
});
it("keeps a half-typed answer when the same dialog is re-projected from a status refresh", async () => {
const card = await mountOpenDialog(openDialog({ kind: "input", title: "Notes?" }));
const root = renderRoot(card);
const input = requiredElement(root.querySelector("input"), "dialog input");
input.value = "half typed";
input.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
await card.updateComplete;
card.dialog = { ...openDialog({ kind: "input", title: "Notes?" }) };
await card.updateComplete;
expect(requiredElement(root.querySelector("input"), "dialog input").value).toBe("half typed");
});
});
describe("extension-dialog-card countdown", () => {
it("shows the remaining time and ticks down each second", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
const root = renderRoot(card);
const countdown = requiredElement(root.querySelector(".countdown"), "countdown");
expect(countdown.textContent).toBe("Auto-cancels in 1m 30s");
await vi.advanceTimersByTimeAsync(30_000);
await card.updateComplete;
expect(countdown.textContent).toBe("Auto-cancels in 1m 0s");
});
it("is decorative: no live region announcing every second", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
const root = renderRoot(card);
// A ticking live region would queue a screen-reader announcement per
// second; the daemon-owned dialog.closed event is the real signal.
expect(requiredElement(root.querySelector(".countdown"), "countdown").getAttribute("role")).toBeNull();
expect(root.querySelector("[aria-live]")).toBeNull();
});
it("renders no countdown when the dialog waits forever", async () => {
vi.useFakeTimers();
const card = await mountOpenDialog(openDialog());
const root = renderRoot(card);
expect(root.querySelector(".countdown")).toBeNull();
});
it("stops ticking once the dialog closes", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
card.outcome = closedDialog("timeout");
await card.updateComplete;
const before = renderRoot(card).textContent;
await vi.advanceTimersByTimeAsync(5_000);
await card.updateComplete;
expect(renderRoot(card).textContent).toBe(before);
expect(renderRoot(card).querySelector("[role='status']")).toBeNull();
});
});
describe("extension-dialog-card closed outcome", () => {
it("shows the given answer and dismisses through the dismiss control", async () => {
const onDismiss = vi.fn<ExtensionDialogDismissCallback>();
const card = new ExtensionDialogCard();
card.outcome = closedDialog("answered", true);
card.onDismiss = onDismiss;
document.body.append(card);
await card.updateComplete;
const root = renderRoot(card);
expect(root.querySelector(".header-status")?.textContent).toBe("Answered");
expect(root.querySelector(".closed-summary")?.textContent).toBe("Answered: Yes");
expect(root.querySelector("input, select, textarea")).toBeNull();
expect(buttonsWithText(root, "Yes")).toHaveLength(0);
buttonWithText(root, "Dismiss").click();
expect(onDismiss).toHaveBeenCalledWith("dlg-1");
});
it("shows the timeout outcome without an answer", async () => {
const card = new ExtensionDialogCard();
card.outcome = closedDialog("timeout");
document.body.append(card);
await card.updateComplete;
const root = renderRoot(card);
expect(root.querySelector(".header-status")?.textContent).toBe("Timed out");
expect(root.querySelector(".closed-summary")?.textContent).toContain("timed out");
});
});
describe("extensionDialogCountdownText", () => {
const now = Date.parse("2026-07-27T10:00:00.000Z");
it("is undefined without a deadline or with an unparseable one", () => {
expect(extensionDialogCountdownText(undefined, now)).toBeUndefined();
expect(extensionDialogCountdownText("not-a-date", now)).toBeUndefined();
});
it("formats seconds, minutes, and hours", () => {
expect(extensionDialogCountdownText("2026-07-27T10:00:45.000Z", now)).toBe("Auto-cancels in 45s");
expect(extensionDialogCountdownText("2026-07-27T10:05:00.000Z", now)).toBe("Auto-cancels in 5m 0s");
expect(extensionDialogCountdownText("2026-07-27T11:02:00.000Z", now)).toBe("Auto-cancels in 1h 2m");
});
it("never rounds the minutes up to 60 near an hour boundary", () => {
expect(extensionDialogCountdownText("2026-07-27T11:59:55.000Z", now)).toBe("Auto-cancels in 1h 59m");
expect(extensionDialogCountdownText("2026-07-27T12:59:40.000Z", now)).toBe("Auto-cancels in 2h 59m");
});
it("stays display-only once the deadline has passed", () => {
expect(extensionDialogCountdownText("2026-07-27T09:59:59.000Z", now)).toBe("Auto-cancel imminent");
});
});
describe("extensionDialogCloseLabel and extensionDialogCloseSummary", () => {
it("labels every close reason", () => {
expect(extensionDialogCloseLabel("answered")).toBe("Answered");
expect(extensionDialogCloseLabel("cancelled")).toBe("Cancelled");
expect(extensionDialogCloseLabel("timeout")).toBe("Timed out");
expect(extensionDialogCloseLabel("aborted")).toBe("Aborted");
expect(extensionDialogCloseLabel("session-ended")).toBe("Session ended");
});
it("summarizes answers by kind", () => {
expect(extensionDialogCloseSummary(closedDialog("answered", false))).toBe("Answered: No");
expect(extensionDialogCloseSummary(closedDialog("answered", "Staging"))).toBe("Answered: Staging");
expect(extensionDialogCloseSummary(closedDialog("answered", ""))).toBe("Answered with an empty response.");
});
it("summarizes closes without an answer", () => {
expect(extensionDialogCloseSummary(closedDialog("cancelled"))).toBe("Dismissed without an answer.");
expect(extensionDialogCloseSummary(closedDialog("timeout"))).toContain("timed out");
expect(extensionDialogCloseSummary(closedDialog("aborted"))).toContain("run ended");
expect(extensionDialogCloseSummary(closedDialog("session-ended"))).toContain("session ended");
});
});
async function mountOpenDialog(
dialog: PendingExtensionDialog,
callbacks: { onAnswer?: ExtensionDialogAnswerCallback; onCancel?: ExtensionDialogCancelCallback } = {},
): Promise<ExtensionDialogCard> {
const card = new ExtensionDialogCard();
card.dialog = dialog;
if (callbacks.onAnswer !== undefined) card.onAnswer = callbacks.onAnswer;
if (callbacks.onCancel !== undefined) card.onCancel = callbacks.onCancel;
document.body.append(card);
await card.updateComplete;
return card;
}
function renderRoot(card: ExtensionDialogCard): ShadowRoot {
return requiredElement(card.shadowRoot, "extension-dialog-card shadow root");
}
function buttonWithText(root: ShadowRoot, text: string): HTMLButtonElement {
const matches = buttonsWithText(root, text);
if (matches.length !== 1) throw new Error(`Expected exactly one button named ${text}, found ${String(matches.length)}`);
const match = matches[0];
return requiredElement(match, `button named ${text}`);
}
function buttonsWithText(root: ShadowRoot, text: string): HTMLButtonElement[] {
return [...root.querySelectorAll("button")].filter((candidate) => candidate.textContent.trim() === text);
}
async function flushClose(card: ExtensionDialogCard): Promise<void> {
// The card's close promise chain settles over several microtasks; a macrotask
// flush waits for all of them plus the state change they schedule.
await new Promise((resolve) => { setTimeout(resolve, 0); });
await card.updateComplete;
}
function requiredElement<T>(value: T | null | undefined, label: string): T {
if (value === null || value === undefined) throw new Error(`Expected ${label}`);
return value;
}
function openDialog(overrides: Partial<PendingExtensionDialog> = {}): PendingExtensionDialog {
return {
dialogId: "dlg-1",
kind: "confirm",
title: "Allow file writes?",
askedAt: "2026-07-27T10:00:00.000Z",
runScoped: false,
...overrides,
};
}
function closedDialog(reason: ClosedExtensionDialog["reason"], answer?: ClosedExtensionDialog["answer"]): ClosedExtensionDialog {
return {
dialog: openDialog(),
reason,
...(answer === undefined ? {} : { answer }),
};
}
@@ -0,0 +1,384 @@
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 {
EXTENSION_DIALOG_INPUT_MAX_LENGTH,
type ExtensionDialogAnswer,
type ExtensionDialogCloseReason,
type PendingExtensionDialog,
} from "../../../shared/apiTypes";
import type { ClosedExtensionDialog } from "../appState";
export type ExtensionDialogAnswerCallback = (dialogId: string, value: ExtensionDialogAnswer) => void | Promise<void>;
export type ExtensionDialogCancelCallback = (dialogId: string) => void | Promise<void>;
export type ExtensionDialogDismissCallback = (dialogId: string) => void;
const COUNTDOWN_TICK_MS = 1_000;
/** Header status label for a closed extension dialog. */
export function extensionDialogCloseLabel(reason: ExtensionDialogCloseReason): string {
switch (reason) {
case "answered": return "Answered";
case "cancelled": return "Cancelled";
case "timeout": return "Timed out";
case "aborted": return "Aborted";
case "session-ended": return "Session ended";
}
}
/** One-line summary of what a closed dialog resolved to, for the outcome card. */
export function extensionDialogCloseSummary(closed: ClosedExtensionDialog): string {
switch (closed.reason) {
case "answered": {
const answer = closed.answer;
// An answered close without an answer value breaks the wire contract;
// the card still renders rather than crashing the transcript.
if (answer === undefined) return "Closed without an answer.";
if (typeof answer === "boolean") return `Answered: ${answer ? "Yes" : "No"}`;
return answer === "" ? "Answered with an empty response." : `Answered: ${answer}`;
}
case "cancelled": return "Dismissed without an answer.";
case "timeout": return "No answer was given before the dialog timed out.";
case "aborted": return "The run ended before this dialog was answered.";
case "session-ended": return "The session ended before this dialog was answered.";
}
}
/**
* Remaining-time label for an open dialog's auto-cancel deadline. Display
* only: the daemon owns the real timeout and publishes `dialog.closed`, so a
* card whose countdown reaches zero simply waits for that event.
*/
export function extensionDialogCountdownText(timeoutAt: string | undefined, nowMs: number): string | undefined {
if (timeoutAt === undefined) return undefined;
const deadline = Date.parse(timeoutAt);
if (!Number.isFinite(deadline)) return undefined;
const remainingMs = deadline - nowMs;
if (remainingMs <= 0) return "Auto-cancel imminent";
const seconds = Math.ceil(remainingMs / 1000);
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
// Floor, not round: rounding yields "1h 60m" in the last half-minute of an hour.
const minutes = Math.floor((seconds % 3600) / 60);
return `Auto-cancels in ${String(hours)}h ${String(minutes)}m`;
}
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
return `Auto-cancels in ${String(minutes)}m ${String(seconds % 60)}s`;
}
return `Auto-cancels in ${String(seconds)}s`;
}
/**
* One extension dialog opened by `ctx.ui.confirm()`, `ctx.ui.select()`, or
* `ctx.ui.input()`.
*
* The card owns only browser-local form state (the half-typed input, the
* in-flight close flag, the display-only countdown); the daemon remains the
* source of truth for whether the dialog is open. Closed mode renders the
* settled outcome — a browser-local record that stays until dismissed — for a
* browser that saw the dialog open.
*/
@customElement("extension-dialog-card")
export class ExtensionDialogCard extends LitElement {
@property({ attribute: false }) dialog?: PendingExtensionDialog;
@property({ attribute: false }) outcome?: ClosedExtensionDialog;
@property({ attribute: false }) onAnswer?: ExtensionDialogAnswerCallback;
@property({ attribute: false }) onCancel?: ExtensionDialogCancelCallback;
@property({ attribute: false }) onDismiss?: ExtensionDialogDismissCallback;
@state() private inputValue = "";
@state() private closing = false;
@state() private countdownNow = 0;
private dialogIdentity: string | undefined;
private countdownTimer: number | undefined;
override connectedCallback(): void {
super.connectedCallback();
this.syncCountdownTimer();
}
override disconnectedCallback(): void {
this.stopCountdownTimer();
super.disconnectedCallback();
}
protected override willUpdate(changed: PropertyValues<this>): void {
if (!changed.has("dialog") && !changed.has("outcome")) return;
// Identity is keyed by dialogId, not object identity: status refreshes
// re-project the same open dialog as a new object and must not wipe a
// half-typed answer or an in-flight close.
const identity = this.currentIdentity();
if (identity !== this.dialogIdentity) {
this.dialogIdentity = identity;
this.inputValue = "";
this.closing = false;
}
this.syncCountdownTimer();
}
override render(): TemplateResult | null {
if (this.outcome !== undefined) return this.renderClosed(this.outcome);
if (this.dialog !== undefined) return this.renderOpen(this.dialog);
return null;
}
private renderOpen(dialog: PendingExtensionDialog): TemplateResult {
const countdown = extensionDialogCountdownText(dialog.timeoutAt, this.countdownNow === 0 ? Date.now() : this.countdownNow);
return html`
<article class="card open-card" aria-labelledby="extension-dialog-heading">
<header class="card-header">
<h2 id="extension-dialog-heading">${dialog.title}</h2>
${countdown === undefined
? null
// Decorative only — no live region: a polite region would queue one
// announcement per second. The daemon-owned dialog.closed event is
// the real signal, and the settled card announces the outcome.
: html`<span class="header-status countdown">${countdown}</span>`}
</header>
${this.renderOpenBody(dialog)}
</article>
`;
}
private renderOpenBody(dialog: PendingExtensionDialog): TemplateResult {
if (dialog.kind === "select") return this.renderSelectBody(dialog);
if (dialog.kind === "input") return this.renderInputBody(dialog);
return this.renderConfirmBody(dialog);
}
private renderConfirmBody(dialog: PendingExtensionDialog): TemplateResult {
return html`
${dialog.message === undefined ? null : html`<p class="dialog-message">${dialog.message}</p>`}
<footer class="dialog-footer">
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.cancelDialog(dialog); }}>Cancel</button>
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.answerDialog(dialog, false); }}>No</button>
<button class="primary-action" type="button" ?disabled=${this.closing} @click=${() => { this.answerDialog(dialog, true); }}>Yes</button>
</footer>
`;
}
private renderSelectBody(dialog: PendingExtensionDialog): TemplateResult {
return html`
<div class="dialog-options" role="group" aria-label="Choices">
${(dialog.options ?? []).map((option) => html`
<button class="option-button" type="button" ?disabled=${this.closing} @click=${() => { this.answerDialog(dialog, option); }}>${option}</button>
`)}
</div>
<footer class="dialog-footer">
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.cancelDialog(dialog); }}>Cancel</button>
</footer>
`;
}
private renderInputBody(dialog: PendingExtensionDialog): TemplateResult {
return html`
<form class="dialog-input-form" @submit=${(event: SubmitEvent) => { this.submitInput(event, dialog); }}>
<input
class="dialog-input"
type="text"
name="dialog-answer"
aria-label="Your answer"
placeholder=${ifDefined(dialog.placeholder)}
maxlength=${String(EXTENSION_DIALOG_INPUT_MAX_LENGTH)}
.value=${this.inputValue}
?disabled=${this.closing}
@input=${(event: Event) => { this.changeInput(event); }}
/>
<footer class="dialog-footer">
<button class="secondary-action" type="button" ?disabled=${this.closing} @click=${() => { this.cancelDialog(dialog); }}>Cancel</button>
<button class="primary-action" type="submit" ?disabled=${this.closing}>${this.closing ? "Sending…" : "Send"}</button>
</footer>
</form>
`;
}
private renderClosed(closed: ClosedExtensionDialog): TemplateResult {
return html`
<article class="card closed-card" aria-labelledby="extension-dialog-closed-heading">
<header class="card-header">
<h2 id="extension-dialog-closed-heading">${closed.dialog.title}</h2>
<span class=${`header-status ${closed.reason}`}>${extensionDialogCloseLabel(closed.reason)}</span>
</header>
<p class="closed-summary">${extensionDialogCloseSummary(closed)}</p>
<footer class="dialog-footer">
<button class="secondary-action" type="button" @click=${() => { this.dismissClosed(closed); }}>Dismiss</button>
</footer>
</article>
`;
}
private answerDialog(dialog: PendingExtensionDialog, value: ExtensionDialogAnswer): void {
this.closeWith(dialog, () => this.onAnswer?.(dialog.dialogId, value));
}
private cancelDialog(dialog: PendingExtensionDialog): void {
this.closeWith(dialog, () => this.onCancel?.(dialog.dialogId));
}
private submitInput(event: SubmitEvent, dialog: PendingExtensionDialog): void {
event.preventDefault();
// An empty string is a valid input answer, so Send stays enabled.
this.answerDialog(dialog, this.inputValue);
}
private closeWith(dialog: PendingExtensionDialog, close: () => void | Promise<void>): void {
if (this.closing) return;
this.closing = true;
const dialogId = dialog.dialogId;
void Promise.resolve()
.then(close)
.catch(() => {
// The parent controller owns the visible transport error. Keeping this
// card usable is the only recovery needed at this boundary.
})
.finally(() => {
if (this.dialog?.dialogId === dialogId) this.closing = false;
});
}
private changeInput(event: Event): void {
const input = event.currentTarget;
if (!(input instanceof HTMLInputElement)) return;
this.inputValue = input.value;
}
private dismissClosed(closed: ClosedExtensionDialog): void {
this.onDismiss?.(closed.dialog.dialogId);
}
private currentIdentity(): string | undefined {
if (this.outcome !== undefined) return `closed:${this.outcome.dialog.dialogId}`;
if (this.dialog !== undefined) return `open:${this.dialog.dialogId}`;
return undefined;
}
private syncCountdownTimer(): void {
const needsTick = this.isConnected && this.outcome === undefined && this.dialog?.timeoutAt !== undefined;
if (needsTick && this.countdownTimer === undefined) {
this.countdownNow = Date.now();
this.countdownTimer = window.setInterval(() => { this.countdownNow = Date.now(); }, COUNTDOWN_TICK_MS);
return;
}
if (!needsTick) this.stopCountdownTimer();
}
private stopCountdownTimer(): void {
if (this.countdownTimer === undefined) return;
window.clearInterval(this.countdownTimer);
this.countdownTimer = undefined;
}
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: 8px 16px 7px;
border-bottom: 1px solid var(--pi-border-muted);
border-radius: 9px 9px 0 0;
background: var(--pi-surface);
box-shadow: 0 8px 18px var(--pi-shadow-soft);
}
h2, p { margin-top: 0; }
h2 {
min-width: 0;
margin-bottom: 0;
font-size: 14px;
font-weight: 650;
line-height: 1.35;
overflow-wrap: anywhere;
}
.header-status { flex: 0 0 auto; color: var(--pi-muted); font-size: 11px; text-align: end; }
.header-status.answered { color: var(--pi-success); }
.header-status.timeout, .header-status.aborted, .header-status.session-ended { color: var(--pi-warning); }
.dialog-message {
margin: 0;
padding: 12px 16px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.dialog-options { display: grid; gap: 7px; padding: 12px 16px; }
.option-button {
display: block;
width: 100%;
text-align: start;
line-height: 1.35;
overflow-wrap: anywhere;
}
.option-button:hover:not(:disabled) { border-color: var(--pi-accent); background: var(--pi-surface-hover); }
.dialog-input-form { display: grid; }
.dialog-input {
box-sizing: border-box;
width: calc(100% - 32px);
margin: 12px 16px 0;
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);
}
.dialog-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--pi-border-muted);
padding: 12px 16px;
}
.dialog-message + .dialog-footer, .dialog-options + .dialog-footer { border-top: 0; }
button {
border: 1px solid var(--pi-border);
border-radius: 8px;
background: var(--pi-surface);
color: var(--pi-text);
padding: 7px 12px;
font: inherit;
cursor: pointer;
}
button:hover:not(:disabled) { background: var(--pi-surface-hover); }
button:disabled { cursor: wait; opacity: .65; }
button:focus-visible, .dialog-input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; }
.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); }
.closed-summary {
margin: 0;
padding: 12px 16px;
color: var(--pi-muted);
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
@container (max-width: 580px) {
.primary-action { min-height: 42px; }
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"extension-dialog-card": ExtensionDialogCard;
}
}
@@ -0,0 +1,136 @@
import type { TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExtensionDialogAnswer, PendingExtensionDialog, SessionInfo, SessionStatus } from "../api";
import { initialAppState, type AppState, type ClosedExtensionDialog } from "../appState";
import { SessionController } from "../controllers/sessionController";
// Template inspection here is the escape hatch for verifying the chat-view
// dialog callback wiring in a node environment (no DOM harness), mirroring
// PiWebApp.clearQueue.test.ts. See templateInspection.testSupport for the
// proportionality rationale.
import { templateValueAfterMarker } from "../templateInspection.testSupport";
import { PiWebApp } from "./PiWebApp";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("PiWebApp extension-dialog wiring", () => {
it("passes dialog state and stable SessionController callbacks through to chat-view", () => {
const app = createApp();
const state = stateWithDialogs();
setAppState(app, state);
const controller = appSessionController(app);
const answerDialog = vi.spyOn(controller, "answerDialog").mockResolvedValue(undefined);
const cancelDialog = vi.spyOn(controller, "cancelDialog").mockResolvedValue(undefined);
const dismissClosedDialog = vi.spyOn(controller, "dismissClosedDialog").mockReturnValue(undefined);
const firstRender = renderChatView(app, state);
const secondRender = renderChatView(app, state);
const onAnswer = templateDialogCallback(firstRender, ".onAnswerDialog=");
const onCancel = templateDialogCallback(firstRender, ".onCancelDialog=");
const onDismiss = templateDialogCallback(firstRender, ".onDismissClosedDialog=");
expect(templateValueAfterMarker(firstRender, ".pendingDialogs=")).toBe(state.pendingDialogs);
expect(templateValueAfterMarker(firstRender, ".closedDialogs=")).toBe(state.closedDialogs);
expect(templateDialogCallback(secondRender, ".onAnswerDialog=")).toBe(onAnswer);
expect(templateDialogCallback(secondRender, ".onCancelDialog=")).toBe(onCancel);
expect(templateDialogCallback(secondRender, ".onDismissClosedDialog=")).toBe(onDismiss);
onAnswer("dlg-1", true);
onCancel("dlg-2");
onDismiss("dlg-0");
expect(answerDialog).toHaveBeenCalledWith("dlg-1", true);
expect(cancelDialog).toHaveBeenCalledWith("dlg-2");
expect(dismissClosedDialog).toHaveBeenCalledWith("dlg-0");
});
});
type RenderChatView = (this: PiWebApp, state: AppState, session: SessionInfo) => TemplateResult;
type DialogCallback = (dialogId: string, value?: ExtensionDialogAnswer) => void;
function createApp(): PiWebApp {
const storage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
return new PiWebApp();
}
function stateWithDialogs(): AppState {
const session: SessionInfo = {
id: "session-1",
cwd: "/repo",
path: "/repo/session-1.jsonl",
created: "2026-07-27T00:00:00.000Z",
modified: "2026-07-27T00:00:00.000Z",
messageCount: 1,
firstMessage: "hello",
};
const open: PendingExtensionDialog = {
dialogId: "dlg-1",
kind: "confirm",
title: "Allow file writes?",
askedAt: "2026-07-27T10:00:00.000Z",
runScoped: false,
};
const closed: ClosedExtensionDialog = {
dialog: { ...open, dialogId: "dlg-0", title: "Allow reads?" },
reason: "answered",
answer: true,
};
return {
...initialAppState(),
selectedSession: session,
status: dialogStatus(),
pendingDialogs: [open],
closedDialogs: [closed],
};
}
function dialogStatus(): SessionStatus {
return {
sessionId: "session-1",
isStreaming: false,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
function setAppState(app: PiWebApp, state: AppState): void {
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state");
}
function appSessionController(app: PiWebApp): SessionController {
const controller: unknown = Reflect.get(app, "sessions");
if (!(controller instanceof SessionController)) throw new Error("PiWebApp SessionController was unavailable");
return controller;
}
function renderChatView(app: PiWebApp, state: AppState): TemplateResult {
const method: unknown = Reflect.get(app, "renderChatView");
if (!isRenderChatView(method)) throw new Error("PiWebApp.renderChatView is not callable");
const session = state.selectedSession;
if (session === undefined) throw new Error("Expected a selected session");
return method.call(app, state, session);
}
function isRenderChatView(value: unknown): value is RenderChatView {
return typeof value === "function";
}
function templateDialogCallback(template: TemplateResult, marker: string): DialogCallback {
const value = templateValueAfterMarker(template, marker);
if (!isDialogCallback(value)) throw new Error(`Expected callback after ${marker}`);
return value;
}
function isDialogCallback(value: unknown): value is DialogCallback {
return typeof value === "function";
}
+10 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
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 { configApi, effectiveWorkspaceUploadFolder, sessionsApi, terminalsApi, workspacesApi, workspaceEffectiveUploadFolder, type AskUserSubmission, type ExtensionDialogAnswer, 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 type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { isSessionActive } from "../../../shared/activity"; import { isSessionActive } from "../../../shared/activity";
@@ -2147,6 +2147,14 @@ export class PiWebApp extends LitElement {
private readonly handleSubmitAsk = (askId: string, submission: AskUserSubmission): Promise<void> => this.sessions.submitAsk(askId, submission); private readonly handleSubmitAsk = (askId: string, submission: AskUserSubmission): Promise<void> => this.sessions.submitAsk(askId, submission);
private readonly handleAnswerDialog = (dialogId: string, value: ExtensionDialogAnswer): Promise<void> => this.sessions.answerDialog(dialogId, value);
private readonly handleCancelDialog = (dialogId: string): Promise<void> => this.sessions.cancelDialog(dialogId);
private readonly handleDismissClosedDialog = (dialogId: string): void => {
this.sessions.dismissClosedDialog(dialogId);
};
private readonly handleDismissNotification = (notificationId: string): void => { private readonly handleDismissNotification = (notificationId: string): void => {
void this.notifications.dismissNotification(notificationId); void this.notifications.dismissNotification(notificationId);
}; };
@@ -2172,7 +2180,7 @@ export class PiWebApp extends LitElement {
private renderChatView(state: AppState, session: SessionInfo) { private renderChatView(state: AppState, session: SessionInfo) {
return html` 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} .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> <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} .pendingDialogs=${state.pendingDialogs} .closedDialogs=${state.closedDialogs} .onAnswerDialog=${this.handleAnswerDialog} .onCancelDialog=${this.handleCancelDialog} .onDismissClosedDialog=${this.handleDismissClosedDialog} .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>
`; `;
} }
+1
View File
@@ -421,6 +421,7 @@ export const chatStyles = css`
.queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); } .queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); }
.queued-message:first-of-type { padding-top: 0; border-top: 0; } .queued-message:first-of-type { padding-top: 0; border-top: 0; }
.queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
.queued-dialogs { margin: -8px 0 14px; padding: 0 4px; color: var(--pi-muted); font-size: 12px; text-align: center; }
.session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); overflow: hidden; } .session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); overflow: hidden; }
.session-activity.compacting { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); } .session-activity.compacting { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); }
.session-activity strong { color: var(--pi-purple); } .session-activity strong { color: var(--pi-purple); }
@@ -0,0 +1,293 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import type { ExtensionDialogCloseResponse, ExtensionDialogKind, PendingExtensionDialog } from "../api";
import { SessionController } from "./sessionController";
import { defaultApi, EmitSocket, emptyPage, FakeSocket, oldSession, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
function dialog(dialogId: string, kind: ExtensionDialogKind = "confirm"): PendingExtensionDialog {
return {
dialogId,
kind,
title: `Dialog ${dialogId}`,
...(kind === "confirm" ? { message: "Are you sure?" } : {}),
...(kind === "select" ? { options: ["Postgres", "SQLite"] } : {}),
...(kind === "input" ? { placeholder: "type here" } : {}),
askedAt: "2026-07-20T00:00:00.000Z",
runScoped: true,
};
}
function statusWithDialogs(sessionId: string, pendingDialogs: PendingExtensionDialog[]): SessionStatus {
return { ...status(sessionId), pendingDialogs };
}
function closeResponse(sessionStatus: SessionStatus, dialogId = "dialog-1"): ExtensionDialogCloseResponse {
return {
result: "closed",
outcome: {
dialogId,
reason: "answered",
answer: true,
askedAt: "2026-07-20T00:00:00.000Z",
closedAt: "2026-07-20T00:01:00.000Z",
},
sessionStatus,
};
}
function selectedState(patch: Partial<AppState> = {}): AppState {
return {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
...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 = selectedState({ 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 };
}
describe("SessionController extension dialog state", () => {
it("rehydrates open dialogs from the daemon-owned status on selection", async () => {
const pending = [dialog("dialog-1"), dialog("dialog-2", "select")];
const harness = await liveSession({}, statusWithDialogs(oldSession.id, pending));
expect(harness.state().pendingDialogs).toEqual(pending);
expect(harness.state().closedDialogs).toEqual([]);
});
it("opens and closes cards from live dialog events without superseding other dialogs", async () => {
const harness = await liveSession();
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2", "input") });
expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-1", "dialog-2"]);
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: true });
expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]);
});
it("keeps the closed dialog's outcome so the card can render what happened", async () => {
const harness = await liveSession();
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1", "select") });
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" });
expect(harness.state().closedDialogs).toEqual([{ dialog: dialog("dialog-1", "select"), reason: "answered", answer: "SQLite" }]);
});
it("records a close without an answer for cancel-like reasons", async () => {
const harness = await liveSession();
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" });
expect(harness.state().closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "aborted" }]);
});
it("ignores a close for a dialog that is not on screen", async () => {
const harness = await liveSession();
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") });
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" });
expect(harness.state().pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]);
expect(harness.state().closedDialogs).toEqual([]);
});
it("does not duplicate a card when the open frame is already reflected", async () => {
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")]));
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
expect(harness.state().pendingDialogs).toHaveLength(1);
});
it("applies a status that no longer carries a dialog as the authoritative close", async () => {
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")]));
expect(harness.state().pendingDialogs).toHaveLength(1);
harness.controller.applySessionStatus(status(oldSession.id));
expect(harness.state().pendingDialogs).toEqual([]);
});
it("does not adopt another session's open dialogs", async () => {
const harness = await liveSession();
harness.controller.applySessionStatus(statusWithDialogs("other-session", [dialog("dialog-1")]));
expect(harness.state().pendingDialogs).toEqual([]);
});
it("clears open and closed dialogs when the session is deselected", async () => {
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1"), dialog("dialog-2")]));
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" });
expect(harness.state().closedDialogs).toHaveLength(1);
harness.controller.deselectSession({ updateUrl: false });
expect(harness.state().pendingDialogs).toEqual([]);
expect(harness.state().closedDialogs).toEqual([]);
});
it("drops a closed dialog's outcome card when it is dismissed", async () => {
const harness = await liveSession({}, statusWithDialogs(oldSession.id, [dialog("dialog-1")]));
harness.socket.emit({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" });
expect(harness.state().closedDialogs).toHaveLength(1);
harness.controller.dismissClosedDialog("dialog-1");
expect(harness.state().closedDialogs).toEqual([]);
});
});
describe("SessionController extension dialog answers", () => {
it("answers a dialog, records the outcome, and applies the returned status", async () => {
const answerCalls: { dialogId: string; value: unknown; machineId: string }[] = [];
const closedStatus = status(oldSession.id);
let state = selectedState({ status: statusWithDialogs(oldSession.id, [dialog("dialog-1")]), pendingDialogs: [dialog("dialog-1")] });
const api: typeof defaultApi = {
...defaultApi,
answerDialog: (_session, dialogId, value, machineId) => {
answerCalls.push({ dialogId, value, machineId: machineId ?? "local" });
return Promise.resolve(closeResponse(closedStatus));
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.answerDialog("dialog-1", true);
expect(answerCalls).toEqual([{ dialogId: "dialog-1", value: true, machineId: "local" }]);
expect(state.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "answered", answer: true }]);
expect(state.pendingDialogs).toEqual([]);
expect(state.status).toEqual(closedStatus);
});
it("cancels a dialog through its own route", async () => {
const cancelCalls: string[] = [];
let state = selectedState({ pendingDialogs: [dialog("dialog-1")] });
const api: typeof defaultApi = {
...defaultApi,
cancelDialog: (_session, dialogId) => {
cancelCalls.push(dialogId);
return Promise.resolve({
result: "closed" as const,
outcome: { dialogId, reason: "cancelled" as const, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" },
sessionStatus: status(oldSession.id),
});
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.cancelDialog("dialog-1");
expect(cancelCalls).toEqual(["dialog-1"]);
expect(state.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "cancelled" }]);
expect(state.pendingDialogs).toEqual([]);
});
it("trusts the status of a stale close without an error or an outcome card", async () => {
let state = selectedState({ pendingDialogs: [dialog("dialog-1")] });
const api: typeof defaultApi = {
...defaultApi,
answerDialog: () => Promise.resolve({ result: "stale", sessionStatus: statusWithDialogs(oldSession.id, [dialog("dialog-2")]) }),
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.answerDialog("dialog-1", true);
expect(state.error).toBe("");
expect(state.closedDialogs).toEqual([]);
expect(state.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]);
});
it("keeps the dialog open and reports the failure when the answer request fails", async () => {
let state = selectedState({ pendingDialogs: [dialog("dialog-1")] });
const api: typeof defaultApi = { ...defaultApi, answerDialog: () => Promise.reject(new Error("answer failed")) };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.answerDialog("dialog-1", true);
expect(state.error).toBe("Error: answer failed");
expect(state.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-1"]);
expect(state.closedDialogs).toEqual([]);
});
it("does not answer for an archived session", async () => {
const archived = { ...oldSession, archived: true as const };
let state = selectedState({ selectedSession: archived, sessions: [archived] });
let answered = false;
const api: typeof defaultApi = {
...defaultApi,
answerDialog: () => {
answered = 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.answerDialog("dialog-1", true);
expect(answered).toBe(false);
});
});
@@ -0,0 +1,365 @@
import { describe, expect, it, vi } from "vitest";
import { initialAppState } from "../appState";
import type { ExtensionDialogCloseResponse, ExtensionDialogKind, PendingExtensionDialog } from "../api";
import { SessionController } from "./sessionController";
import { defaultApi, deferred, EmitSocket, emptyPage, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo, type SessionStatus } from "./sessionController.testSupport";
const BACKEND_SESSION_ID = "backend-session";
function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity {
return {
sessionId: BACKEND_SESSION_ID,
phase: "active",
label: "Creating session",
detail: "Loading session extensions",
at: "2026-07-20T00:00:01.000Z",
startup: true,
...patch,
};
}
function dialog(dialogId: string, kind: ExtensionDialogKind = "confirm"): PendingExtensionDialog {
return {
dialogId,
kind,
title: `Dialog ${dialogId}`,
...(kind === "confirm" ? { message: "Are you sure?" } : {}),
askedAt: "2026-07-20T00:00:00.000Z",
runScoped: false,
};
}
function statusWithDialogs(sessionId: string, pendingDialogs: PendingExtensionDialog[]): SessionStatus {
return { ...status(sessionId), pendingDialogs };
}
function closeResponse(sessionStatus: SessionStatus, dialogId = "dialog-1"): ExtensionDialogCloseResponse {
return {
result: "closed",
outcome: {
dialogId,
reason: "answered",
answer: true,
askedAt: "2026-07-20T00:00:00.000Z",
closedAt: "2026-07-20T00:01:00.000Z",
},
sessionStatus,
};
}
interface PendingStartHarness {
controller: SessionController;
socket: EmitSocket;
startRequest: ReturnType<typeof deferred<SessionInfo>>;
state: { current: AppState };
}
/**
* A controller with one in-flight create whose start request stays open until
* the test resolves it — the browser side of a `session_start` dialog parking
* session readiness.
*/
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}): PendingStartHarness {
const startRequest = deferred<SessionInfo>();
const socket = new EmitSocket();
const controller = new SessionController(
() => state.current,
(patch) => { state.current = { ...state.current, ...patch }; },
() => undefined,
undefined,
{
api: {
...defaultApi,
startSession: () => startRequest.promise,
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
streamSnapshot: () => Promise.resolve({ seq: 0, partial: null }),
thinkingLevels: () => Promise.resolve({ levels: [] }),
...api,
},
socket,
},
);
return { controller, socket, startRequest, state };
}
function beginPendingStart(harness: PendingStartHarness): { start: Promise<void>; tempId: string } {
const start = harness.controller.startSession();
const tempId = harness.state.current.selectedSession?.id;
if (tempId === undefined) throw new Error("Expected a pending-start row to be selected");
if (!tempId.startsWith("pending-session-")) throw new Error("Expected a pending-start row to be selected");
return { start, tempId };
}
function reportBackendSessionId(harness: PendingStartHarness, tempId: string): void {
harness.controller.applyGlobalEvent({ type: "session.startup", startupToken: tempId, activity: startupActivity() });
runPendingAnimationFrames();
}
function resolveBackendSession(harness: PendingStartHarness): void {
harness.startRequest.resolve({ ...oldSession, id: BACKEND_SESSION_ID, path: "/tmp/backend-session.jsonl" });
}
describe("SessionController session_start dialog startup reachability", () => {
it("subscribes to the backend session as soon as startup progress names it", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const statusCalls: string[] = [];
const harness = pendingStartController(state, {
status: (session) => {
statusCalls.push(sessionLookupId(session));
return Promise.resolve(status(sessionLookupId(session)));
},
});
const { start, tempId } = beginPendingStart(harness);
expect(harness.socket.connectedSessionIds).toEqual([]);
reportBackendSessionId(harness, tempId);
expect(harness.socket.connectedSessionIds).toEqual([BACKEND_SESSION_ID]);
await vi.waitFor(() => { expect(statusCalls).toEqual([BACKEND_SESSION_ID]); });
resolveBackendSession(harness);
await start;
});
it("shows a dialog that opens mid-startup on the pending row, answerable before readiness", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const harness = pendingStartController(state);
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
expect(harness.state.current.selectedSession?.id).toBe(tempId);
expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
resolveBackendSession(harness);
await start;
});
it("recovers a dialog that opened before the subscription from the mid-startup status", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const harness = pendingStartController(state, {
status: (session) => Promise.resolve(sessionLookupId(session) === BACKEND_SESSION_ID ? statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-1")]) : status(sessionLookupId(session))),
});
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
await vi.waitFor(() => { expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); });
// The per-session map holds the backend session's status for the readiness
// swap, while the row keeps its own temporary identity.
expect(harness.state.current.sessionStatuses[BACKEND_SESSION_ID]?.pendingDialogs).toEqual([dialog("dialog-1")]);
expect(harness.state.current.selectedSession?.id).toBe(tempId);
resolveBackendSession(harness);
await start;
});
it("tolerates a daemon that cannot serve status mid-startup", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
// Older daemons 404 the status route until the session is ready; the
// rejection must not disturb the event-driven dialog flow.
let createResolved = false;
const harness = pendingStartController(state, {
status: (session) => sessionLookupId(session) === BACKEND_SESSION_ID && !createResolved
? Promise.reject(new Error("Session not found"))
: Promise.resolve(status(sessionLookupId(session))),
});
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
await vi.waitFor(() => { expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]); });
expect(harness.state.current.error).toBe("");
createResolved = true;
resolveBackendSession(harness);
await start;
});
it("answers a startup dialog through the real session id and proceeds to the chat view at readiness", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const answerCalls: { sessionId: string; dialogId: string; value: unknown; machineId: string }[] = [];
const harness = pendingStartController(state, {
answerDialog: (session, dialogId, value, machineId) => {
answerCalls.push({ sessionId: sessionLookupId(session), dialogId, value, machineId: machineId ?? "local" });
return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID)));
},
});
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
await harness.controller.answerDialog("dialog-1", true);
expect(answerCalls).toEqual([{ sessionId: BACKEND_SESSION_ID, dialogId: "dialog-1", value: true, machineId: "local" }]);
expect(harness.state.current.pendingDialogs).toEqual([]);
expect(harness.state.current.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "answered", answer: true }]);
expect(harness.state.current.error).toBe("");
// The answer settled the hook daemon-side, so the create resolves and the
// normal selection flow takes over the now-real session.
resolveBackendSession(harness);
await start;
await vi.waitFor(() => { expect(harness.state.current.selectedSession?.id).toBe(BACKEND_SESSION_ID); });
expect(harness.state.current.sessions.some((session) => session.id === tempId)).toBe(false);
expect(harness.state.current.sessions.some((session) => session.id === BACKEND_SESSION_ID)).toBe(true);
});
it("cancels a startup dialog through the cancel route under the real session id", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const cancelCalls: { sessionId: string; dialogId: string }[] = [];
const harness = pendingStartController(state, {
cancelDialog: (session, dialogId) => {
cancelCalls.push({ sessionId: sessionLookupId(session), dialogId });
return Promise.resolve({
result: "closed" as const,
outcome: { dialogId, reason: "cancelled" as const, askedAt: "2026-07-20T00:00:00.000Z", closedAt: "2026-07-20T00:01:00.000Z" },
sessionStatus: status(BACKEND_SESSION_ID),
});
},
});
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
await harness.controller.cancelDialog("dialog-1");
expect(cancelCalls).toEqual([{ sessionId: BACKEND_SESSION_ID, dialogId: "dialog-1" }]);
expect(harness.state.current.pendingDialogs).toEqual([]);
expect(harness.state.current.closedDialogs).toEqual([{ dialog: dialog("dialog-1"), reason: "cancelled" }]);
resolveBackendSession(harness);
await start;
});
it("trusts the returned status when the answer loses the race", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const harness = pendingStartController(state, {
answerDialog: () => Promise.resolve({ result: "stale", sessionStatus: statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-2")]) }),
});
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
await harness.controller.answerDialog("dialog-1", true);
expect(harness.state.current.error).toBe("");
expect(harness.state.current.closedDialogs).toEqual([]);
expect(harness.state.current.pendingDialogs.map((pending) => pending.dialogId)).toEqual(["dialog-2"]);
resolveBackendSession(harness);
await start;
});
it("cannot answer before startup progress names the backend session", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
let answered = false;
const harness = pendingStartController(state, {
answerDialog: () => {
answered = true;
return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID)));
},
});
const { start } = beginPendingStart(harness);
await harness.controller.answerDialog("dialog-1", true);
expect(answered).toBe(false);
resolveBackendSession(harness);
await start;
});
it("re-subscribes when the pending row is re-selected mid-startup", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [oldSession] } };
const harness = pendingStartController(state);
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
expect(harness.socket.connectedSessionIds).toEqual([BACKEND_SESSION_ID]);
await harness.controller.selectSession(oldSession, { updateUrl: false });
const pendingRow = harness.state.current.sessions.find((session) => session.id === tempId);
if (pendingRow === undefined) throw new Error("Expected the pending row to stay in the session list");
await harness.controller.selectSession(pendingRow, { updateUrl: false });
expect(harness.socket.connectedSessionIds).toEqual([BACKEND_SESSION_ID, oldSession.id, BACKEND_SESSION_ID]);
// Dialog state keeps flowing after the detour.
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
resolveBackendSession(harness);
await start;
});
it("does not subscribe for another browser's create", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const harness = pendingStartController(state);
const { start } = beginPendingStart(harness);
harness.controller.applyGlobalEvent({ type: "session.startup", startupToken: "pending-session-9-other-tab", activity: startupActivity() });
runPendingAnimationFrames();
expect(harness.socket.connectedSessionIds).toEqual([]);
resolveBackendSession(harness);
await start;
});
it("drops a mid-startup status snapshot that lands after the readiness swap", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
const resyncRequest = deferred<SessionStatus>();
let resyncIssued = false;
const harness = pendingStartController(state, {
status: (session) => {
// The first backend status call is the subscribe-time resync; hold it
// until after the swap. Later calls (the readiness join) answer fresh.
if (sessionLookupId(session) === BACKEND_SESSION_ID && !resyncIssued) {
resyncIssued = true;
return resyncRequest.promise;
}
return Promise.resolve(status(sessionLookupId(session)));
},
});
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
resolveBackendSession(harness);
await start;
await vi.waitFor(() => { expect(harness.state.current.selectedSession?.id).toBe(BACKEND_SESSION_ID); });
// The stale snapshot — issued before the swap and claiming dialog-1 is
// still open — must not clobber the real session's fresher state.
resyncRequest.resolve(statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-1")]));
await Promise.resolve();
await Promise.resolve();
expect(harness.state.current.pendingDialogs).toEqual([]);
expect(harness.state.current.sessionStatuses[BACKEND_SESSION_ID]?.pendingDialogs ?? []).toEqual([]);
});
it("drops the dead card, closes the socket, and ignores late frames when the create fails mid-startup", async () => {
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
let answerCalled = false;
const harness = pendingStartController(state, {
answerDialog: () => {
answerCalled = true;
return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID)));
},
});
const closeSpy = vi.spyOn(harness.socket, "close");
const { start, tempId } = beginPendingStart(harness);
reportBackendSessionId(harness, tempId);
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
closeSpy.mockClear();
harness.startRequest.reject(new Error("create exploded"));
await start;
expect(harness.state.current.error).toBe("Failed to start session: create exploded");
expect(harness.state.current.pendingDialogs).toEqual([]);
expect(closeSpy).toHaveBeenCalled();
// Late frames from the dead session are dropped, and no answer can leave.
harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") });
expect(harness.state.current.pendingDialogs).toEqual([]);
await harness.controller.answerDialog("dialog-2", true);
expect(answerCalled).toBe(false);
});
});
+239 -7
View File
@@ -1,5 +1,5 @@
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 { api as defaultApi, type AskUserCloseResponse, type AskUserSubmission, type CommandResult, type ExtensionDialogAnswer, type ExtensionDialogCloseReason, type ExtensionDialogCloseResponse, type ExtensionDialogOutcome, type PendingAskUser, type PendingExtensionDialog, 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 type { AppState, ClosedExtensionDialog } from "../appState";
import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions"; import { forgetCachedNewSession, isCachedNewSessionInfo, markCachedNewSessionInfo, mergeCachedNewSessions, rememberCachedNewSession, stripCachedNewSessionMarker } from "../cachedNewSessions";
import { textMessage } from "../chatMessages"; import { textMessage } from "../chatMessages";
import { machineSessionKey } from "../machineKeys"; import { machineSessionKey } from "../machineKeys";
@@ -84,6 +84,13 @@ interface PendingSessionStart {
session: ClientPendingStartSessionInfo; session: ClientPendingStartSessionInfo;
queuedSends: QueuedPendingSessionSend[]; queuedSends: QueuedPendingSessionSend[];
discarded: boolean; discarded: boolean;
/**
* The real session id, learned from the daemon's `session.startup` events
* long before the create request resolves. It is what lets the startup view
* subscribe to the constructing session and answer its `session_start`
* dialogs — the dialogs that gate the readiness the create request waits on.
*/
backendSessionId?: string;
} }
interface SuppressedCreatedSession { interface SuppressedCreatedSession {
@@ -162,7 +169,7 @@ export class SessionController {
// session must not cancel the in-flight upload indicator of the session // 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 // that is still sending; the per-session entry is cleared by send()'s
// finally block when the request settles. // finally block when the request settles.
this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, availableThinkingLevels: [], treeDialog: undefined }); this.setState({ selectedSession: undefined, messages: [], messagePageStart: 0, messagePageEnd: 0, messagePageTotal: 0, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [], availableThinkingLevels: [], treeDialog: undefined });
} }
deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) { deselectSession(options?: { forgetRememberedSelection?: boolean | undefined; updateUrl?: boolean | undefined }) {
@@ -221,6 +228,8 @@ export class SessionController {
status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id], status: session.archived === true ? undefined : this.getState().sessionStatuses[session.id],
activity: session.archived === true ? undefined : this.getState().sessionActivities[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), pendingAsk: session.archived === true ? undefined : this.selectedPendingAsk(this.getState().sessionStatuses[session.id], machineId),
pendingDialogs: session.archived === true ? [] : (this.getState().sessionStatuses[session.id]?.pendingDialogs ?? []),
closedDialogs: [],
availableThinkingLevels: [], availableThinkingLevels: [],
}); });
let buffered: SessionUiEvent[] | undefined; let buffered: SessionUiEvent[] | undefined;
@@ -229,7 +238,7 @@ export class SessionController {
const page = await this.api.messages(session, { limit: MESSAGE_PAGE_SIZE }, selectedMachineId(this.getState())); 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; if (seq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
const history = this.transcripts.mergeHistory(transcriptKey, page); const history = this.transcripts.mergeHistory(transcriptKey, page);
this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined }); this.setState({ ...history, isLoadingEarlierMessages: false, status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [] });
this.onSelectedSessionReady?.({ machineId, session }); this.onSelectedSessionReady?.({ machineId, session });
if (options?.updateUrl !== false) this.updateUrl(); if (options?.updateUrl !== false) this.updateUrl();
return; return;
@@ -666,7 +675,7 @@ export class SessionController {
sessions: nextSessions, sessions: nextSessions,
sessionStatuses: omitKeys(state.sessionStatuses, affectedIds), sessionStatuses: omitKeys(state.sessionStatuses, affectedIds),
sessionActivities: omitKeys(state.sessionActivities, affectedIds), sessionActivities: omitKeys(state.sessionActivities, affectedIds),
...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined } : {}), ...(selectedAffected ? { status: undefined, activity: undefined, pendingAsk: undefined, pendingDialogs: [], closedDialogs: [] } : {}),
}); });
if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) { if (state.selectedSession !== undefined && deletedIdSet.has(state.selectedSession.id)) {
@@ -897,6 +906,75 @@ export class SessionController {
return this.closeOpenAsk(askId, (session, machineId) => this.api.cancelAsk(session, askId, machineId)); return this.closeOpenAsk(askId, (session, machineId) => this.api.cancelAsk(session, askId, machineId));
} }
/** Send the value the user gave for one of the session's open extension dialogs. */
answerDialog(dialogId: string, value: ExtensionDialogAnswer): Promise<void> {
return this.closeOpenDialog(dialogId, (session, machineId) => this.api.answerDialog(session, dialogId, value, machineId));
}
/** Close one of the session's open extension dialogs without answering it. */
cancelDialog(dialogId: string): Promise<void> {
return this.closeOpenDialog(dialogId, (session, machineId) => this.api.cancelDialog(session, dialogId, machineId));
}
private async closeOpenDialog(dialogId: string, close: (session: SessionInfo, machineId: string) => Promise<ExtensionDialogCloseResponse>): Promise<void> {
const state = this.getState();
const session = state.selectedSession;
if (session === undefined || session.archived === true) return;
if (isClientPendingStartSessionInfo(session)) {
await this.closePendingStartDialog(session, dialogId, close);
return;
}
const machineId = selectedMachineId(state);
const selectionSeq = this.selectionSeq;
try {
const response = await close(session, machineId);
if (!this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) return;
// When this call closed the dialog, its outcome is recorded right away so
// the card shows what the user gave; the daemon's dialog.closed event
// then finds the dialog already closed here and stays a no-op.
const outcome: ExtensionDialogOutcome | undefined = response.outcome;
if (outcome !== undefined) {
const dialog = this.getState().pendingDialogs.find((pending) => pending.dialogId === outcome.dialogId);
if (dialog !== undefined) this.recordClosedDialog({ dialog, reason: outcome.reason, ...(outcome.answer === undefined ? {} : { answer: outcome.answer }) });
}
// Both outcomes carry the recomputed status, so no follow-up status
// request is needed to learn what the session's open dialogs are now.
this.applyStatus(response.sessionStatus);
} catch (error) {
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
}
}
/**
* Answer or cancel a `session_start` dialog from the startup view. The row
* is still the pending start, but the dialog belongs to the constructing
* backend session the startup events named, so the close goes out under the
* real id — the only route the daemon can serve before readiness.
*/
private async closePendingStartDialog(session: ClientPendingStartSessionInfo, dialogId: string, close: (session: SessionInfo, machineId: string) => Promise<ExtensionDialogCloseResponse>): Promise<void> {
const pending = this.pendingSessionStarts.get(session.id);
const backendSessionId = pending?.backendSessionId;
// Without the real id there is no route to answer through — and no way a
// dialog card could be on screen yet either.
if (pending === undefined || backendSessionId === undefined) return;
const selectionSeq = this.selectionSeq;
try {
const response = await close({ ...session, id: backendSessionId }, pending.machineId);
if (selectionSeq !== this.selectionSeq || this.getState().selectedSession?.id !== session.id) return;
// Same outcome-first ordering as the ready-session path: the card shows
// what the user gave, and the daemon's dialog.closed frame then finds
// the dialog already closed here and stays a no-op.
const outcome: ExtensionDialogOutcome | undefined = response.outcome;
if (outcome !== undefined) {
const dialog = this.getState().pendingDialogs.find((candidate) => candidate.dialogId === outcome.dialogId);
if (dialog !== undefined) this.recordClosedDialog({ dialog, reason: outcome.reason, ...(outcome.answer === undefined ? {} : { answer: outcome.answer }) });
}
this.applyPendingStartStatus(pending, response.sessionStatus);
} catch (error) {
if (selectionSeq === this.selectionSeq && this.getState().selectedSession?.id === session.id) this.setState({ error: String(error) });
}
}
private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise<AskUserCloseResponse>): Promise<void> { private async closeOpenAsk(askId: string, close: (session: SessionInfo, machineId: string) => Promise<AskUserCloseResponse>): Promise<void> {
const state = this.getState(); const state = this.getState();
const session = state.selectedSession; const session = state.selectedSession;
@@ -1076,11 +1154,16 @@ export class SessionController {
status: undefined, status: undefined,
activity, activity,
pendingAsk: undefined, pendingAsk: undefined,
pendingDialogs: [],
closedDialogs: [],
availableThinkingLevels: [], availableThinkingLevels: [],
treeDialog: undefined, treeDialog: undefined,
...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }), ...(activity === undefined ? {} : { sessionActivities: { ...state.sessionActivities, [session.id]: activity } }),
error: "", error: "",
}); });
// Re-selecting the row mid-startup re-establishes the constructing
// session's subscription; the close above dropped it with the old selection.
if (pendingStart?.backendSessionId !== undefined) this.connectPendingStartSocket(pendingStart);
if (options?.updateUrl !== false) this.updateUrl(); if (options?.updateUrl !== false) this.updateUrl();
} }
@@ -1116,7 +1199,7 @@ export class SessionController {
sessionActivities: omitSessionActivity(state.sessionActivities, tempId), sessionActivities: omitSessionActivity(state.sessionActivities, tempId),
sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id), sendingPrompts: moveRecordKey(state.sendingPrompts, tempId, cachedSession.id),
clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id), clientQueuedSessionMessages: moveRecordKey(state.clientQueuedSessionMessages, tempId, cachedSession.id),
...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId) } : {}), ...(wasSelected ? { selectedSession: cachedSession, status: state.sessionStatuses[cachedSession.id], activity: state.sessionActivities[cachedSession.id], pendingAsk: this.selectedPendingAsk(state.sessionStatuses[cachedSession.id], pending.machineId), pendingDialogs: state.sessionStatuses[cachedSession.id]?.pendingDialogs ?? [], closedDialogs: [] } : {}),
error: "", error: "",
}); });
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
@@ -1131,9 +1214,15 @@ export class SessionController {
const pending = this.pendingSessionStarts.get(tempId); const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return; if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId); this.pendingSessionStarts.delete(tempId);
const wasDiscarded = pending.discarded;
// The pending start is dead: stop routing its dialog frames (a card on the
// failed row could never be answered) and drop the early-subscribed socket
// so it stops reconnecting against a session that may not exist.
pending.discarded = true;
if (this.getState().selectedSession?.id === tempId) this.socket.close();
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId); const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
const isCurrentPendingStart = this.isCurrentPendingStart(pending); const isCurrentPendingStart = this.isCurrentPendingStart(pending);
if (pending.discarded || !isCurrentPendingStart) { if (wasDiscarded || !isCurrentPendingStart) {
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
return; return;
} }
@@ -1145,6 +1234,9 @@ export class SessionController {
sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions], sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions],
sessionActivities: { ...state.sessionActivities, [tempId]: activity }, sessionActivities: { ...state.sessionActivities, [tempId]: activity },
activity: state.selectedSession?.id === tempId ? activity : state.activity, activity: state.selectedSession?.id === tempId ? activity : state.activity,
// Open cards on the failed row are dead: the create is gone, so no
// answer could ever reach the daemon. Settled outcomes stay as history.
...(state.selectedSession?.id === tempId ? { pendingDialogs: [] } : {}),
error: `Failed to start session: ${message}`, error: `Failed to start session: ${message}`,
}); });
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId); this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
@@ -1274,9 +1366,48 @@ export class SessionController {
// The daemon owns whether an ask is open, so every status it publishes is // The daemon owns whether an ask is open, so every status it publishes is
// authoritative for the selected session's card, including its removal. // authoritative for the selected session's card, including its removal.
...(isSelected ? { pendingAsk: this.selectedPendingAsk(status, selectedMachineId(state)) } : {}), ...(isSelected ? { pendingAsk: this.selectedPendingAsk(status, selectedMachineId(state)) } : {}),
// Same for extension dialogs: the status projection is authoritative for
// the open list. Closed-card outcomes are event/response-driven instead,
// so a status without the dialog simply drops it from the open list.
...(isSelected ? { pendingDialogs: status.pendingDialogs ?? [] } : {}),
}); });
} }
private applyOpenedDialog(dialog: PendingExtensionDialog): void {
const state = this.getState();
if (state.selectedSession === undefined) return;
// Events apply exactly once, so an id already on screen means this frame
// was already reflected (e.g. a rehydrated open) and must not duplicate
// the card.
if (state.pendingDialogs.some((pending) => pending.dialogId === dialog.dialogId)) return;
this.setState({ pendingDialogs: [...state.pendingDialogs, dialog] });
}
private applyClosedDialog(dialogId: string, reason: ExtensionDialogCloseReason, answer: ExtensionDialogAnswer | undefined): void {
// A close for a dialog that is not on screen is already reflected here
// (e.g. the answering browser's own response landed first), so it must not
// clear or duplicate other cards.
const dialog = this.getState().pendingDialogs.find((pending) => pending.dialogId === dialogId);
if (dialog === undefined) return;
this.recordClosedDialog({ dialog, reason, ...(answer === undefined ? {} : { answer }) });
}
private recordClosedDialog(closed: ClosedExtensionDialog): void {
const state = this.getState();
if (state.closedDialogs.some((entry) => entry.dialog.dialogId === closed.dialog.dialogId)) return;
this.setState({
pendingDialogs: state.pendingDialogs.filter((pending) => pending.dialogId !== closed.dialog.dialogId),
closedDialogs: [...state.closedDialogs, closed],
});
}
/** Drop a closed dialog's transient outcome card (e.g. the user dismissed it). */
dismissClosedDialog(dialogId: string): void {
const state = this.getState();
if (!state.closedDialogs.some((entry) => entry.dialog.dialogId === dialogId)) return;
this.setState({ closedDialogs: state.closedDialogs.filter((entry) => entry.dialog.dialogId !== dialogId) });
}
private applyOpenedAsk(ask: PendingAskUser): void { private applyOpenedAsk(ask: PendingAskUser): void {
const state = this.getState(); const state = this.getState();
if (state.selectedSession === undefined) return; if (state.selectedSession === undefined) return;
@@ -1368,6 +1499,14 @@ export class SessionController {
this.applyClosedAsk(event.askId); this.applyClosedAsk(event.askId);
return; return;
} }
if (event.type === "dialog.opened") {
this.applyOpenedDialog(event.dialog);
return;
}
if (event.type === "dialog.closed") {
this.applyClosedDialog(event.dialogId, event.reason, event.answer);
return;
}
const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event); const transcript = this.transcripts.applyLiveEvent(this.getState().messages, event);
if (transcript) { if (transcript) {
this.setState({ messages: transcript }); this.setState({ messages: transcript });
@@ -1406,6 +1545,7 @@ export class SessionController {
} }
const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken); const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken);
if (pending === undefined || pending.discarded) return; if (pending === undefined || pending.discarded) return;
this.learnPendingStartBackendSession(pending, event.activity.sessionId);
// An idle startup phase means the daemon has nothing left to attribute, so // An idle startup phase means the daemon has nothing left to attribute, so
// restore this row's own generic wording rather than clearing the text of a // restore this row's own generic wording rather than clearing the text of a
// creation request that has not returned yet. // creation request that has not returned yet.
@@ -1414,6 +1554,98 @@ export class SessionController {
: pendingStartActivity(event.activity, pending.tempId)); : pendingStartActivity(event.activity, pending.tempId));
} }
private learnPendingStartBackendSession(pending: PendingSessionStart, sessionId: string): void {
if (sessionId === "" || pending.backendSessionId !== undefined) return;
pending.backendSessionId = sessionId;
if (this.getState().selectedSession?.id === pending.tempId) this.connectPendingStartSocket(pending);
}
/**
* Subscribe the selected pending-start row to its constructing session.
* `session_start` dialogs park the create request until answered, so waiting
* for readiness to subscribe would make them unanswerable; the per-session
* event channel and (on this daemon version) the status route both serve a
* session whose startup is still waiting on the user.
*/
private connectPendingStartSocket(pending: PendingSessionStart): void {
const backendSessionId = pending.backendSessionId;
if (backendSessionId === undefined || pending.discarded) return;
const ref: SessionRef = { id: backendSessionId, cwd: pending.cwd };
this.socket.connect(
ref,
(event) => { this.applyPendingStartEvent(pending, event); },
() => { this.resyncPendingStartDialogs(pending); },
pending.machineId,
);
this.resyncPendingStartDialogs(pending);
}
/**
* Recover dialogs that opened before this subscription connected (or during
* a reconnect gap) from the daemon's status projection. The HTTP snapshot is
* unordered against socket frames — dialogs the socket already opened or
* closed are newer than anything it can say about them — so only genuinely
* unknown opens are adopted, never a wholesale replace. A daemon that
* predates mid-startup status answers 404 until readiness: tolerated, since
* everything that opens from here still arrives as an event.
*/
private resyncPendingStartDialogs(pending: PendingSessionStart): void {
const backendSessionId = pending.backendSessionId;
if (backendSessionId === undefined) return;
void this.api.status({ id: backendSessionId, cwd: pending.cwd }, pending.machineId).then(
(status) => {
// Guard before applying: this unordered snapshot can land after the
// readiness swap made the real session selected, and a stale replace
// must not clobber the socket's fresher dialog state.
if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return;
this.applyStatus(status);
const state = this.getState();
const knownIds = new Set<string>([
...state.pendingDialogs.map((pendingDialog) => pendingDialog.dialogId),
...state.closedDialogs.map((closed) => closed.dialog.dialogId),
]);
const recovered = (status.pendingDialogs ?? []).filter((recoveredDialog) => !knownIds.has(recoveredDialog.dialogId));
if (recovered.length > 0) this.setState({ pendingDialogs: [...state.pendingDialogs, ...recovered] });
},
() => undefined,
);
}
/**
* Route a constructing session's events onto its pending-start row. Only
* dialog frames and their status reconciliation apply here: everything else
* (transcript, activity, naming) is re-fetched authoritatively by the
* readiness join, and routing it onto a temporary row would pollute state
* keyed for a session that does not exist yet. Frames that arrive after the
* row stopped being the selected pending start belong to the selection flow
* that took over.
*/
private applyPendingStartEvent(pending: PendingSessionStart, event: SessionUiEvent): void {
if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return;
if (event.type === "dialog.opened") {
this.applyOpenedDialog(event.dialog);
return;
}
if (event.type === "dialog.closed") {
this.applyClosedDialog(event.dialogId, event.reason, event.answer);
return;
}
if (event.type === "status.update") this.applyPendingStartStatus(pending, event.status);
}
/**
* Apply a constructing session's status from an ordered channel (the
* socket's own status frame, or a dialog close response): the daemon's
* projection is authoritative there, so the open list is replaced wholesale,
* exactly as applyStatus does for a ready session. The per-session map stays
* truthful too — the readiness swap seeds the selected status from it.
*/
private applyPendingStartStatus(pending: PendingSessionStart, status: SessionStatus): void {
this.applyStatus(status);
if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return;
this.setState({ pendingDialogs: status.pendingDialogs ?? [] });
}
private schedulePendingFlush(): void { private schedulePendingFlush(): void {
if (this.pendingFrame !== undefined) return; if (this.pendingFrame !== undefined) return;
this.pendingFrame = requestAnimationFrame(() => { this.pendingFrame = requestAnimationFrame(() => {
+25
View File
@@ -131,6 +131,31 @@ describe("notification socket guards", () => {
expect(parseRealtimeSocketEvent({ type: "ask.opened", ask })).toBeUndefined(); expect(parseRealtimeSocketEvent({ type: "ask.opened", ask })).toBeUndefined();
}); });
it("accepts validated dialog frames and drops malformed ones", () => {
const dialog = {
dialogId: "dialog-1",
kind: "select",
title: "Pick a database",
options: ["Postgres", "SQLite"],
askedAt: "2026-07-20T00:00:00.000Z",
runScoped: true,
};
expect(parseSessionSocketEvent({ type: "dialog.opened", dialog })).toEqual({ type: "dialog.opened", dialog });
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" }))
.toEqual({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: "SQLite" });
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" }))
.toEqual({ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" });
expect(parseSessionSocketEvent({ type: "dialog.opened", dialog: { ...dialog, kind: "modal" } })).toBeUndefined();
expect(parseSessionSocketEvent({ type: "dialog.opened" })).toBeUndefined();
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "ignored" })).toBeUndefined();
// A close whose reason disagrees with its answer cannot be rendered honestly.
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "answered" })).toBeUndefined();
expect(parseSessionSocketEvent({ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled", answer: true })).toBeUndefined();
// Dialog frames are per-session only, so they must not be accepted globally.
expect(parseRealtimeSocketEvent({ type: "dialog.opened", dialog })).toBeUndefined();
});
it("preserves existing event acceptance without treating unknown types as realtime events", () => { 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(parseSessionSocketEvent({ type: "command.output", level: "info", message: "legacy" })).toMatchObject({ type: "command.output" });
expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined(); expect(parseRealtimeSocketEvent({ type: "future.notification", payload: {} })).toBeUndefined();
+5 -1
View File
@@ -1,5 +1,5 @@
import { realtimeEvents, sessionEvents } from "./api"; import { realtimeEvents, sessionEvents } from "./api";
import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers"; import { parseSessionAskClosedEvent, parseSessionAskOpenedEvent, parseSessionDialogClosedEvent, parseSessionDialogOpenedEvent, parseSessionNotificationInboxEvent, parseSessionStartupProgressEvent, parseSessionUnreadEvent } from "./api/parsers";
import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes"; import type { GlobalSessionEvent, RealtimeEvent, SessionRef, SessionUiEvent } from "../../shared/apiTypes";
export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes"; export type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes";
@@ -159,6 +159,10 @@ export function parseSessionSocketEvent(event: unknown): SessionUiEvent | undefi
// so they are validated rather than accepted on their type alone. // so they are validated rather than accepted on their type alone.
if (type === "ask.opened") return safelyParseValidatedEvent(() => parseSessionAskOpenedEvent(event)); if (type === "ask.opened") return safelyParseValidatedEvent(() => parseSessionAskOpenedEvent(event));
if (type === "ask.closed") return safelyParseValidatedEvent(() => parseSessionAskClosedEvent(event)); if (type === "ask.closed") return safelyParseValidatedEvent(() => parseSessionAskClosedEvent(event));
// Dialog frames drive an interactive card the user answers on the extension's
// behalf, so they are validated rather than accepted on their type alone.
if (type === "dialog.opened") return safelyParseValidatedEvent(() => parseSessionDialogOpenedEvent(event));
if (type === "dialog.closed") return safelyParseValidatedEvent(() => parseSessionDialogClosedEvent(event));
return isLegacySessionUiEvent(event) ? event : undefined; return isLegacySessionUiEvent(event) ? event : undefined;
} }
+29 -1
View File
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, askUserEnabled, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS, 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 tempDir: string;
let configPath: string; let configPath: string;
@@ -63,6 +63,22 @@ describe("PI WEB config persistence", () => {
expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234); expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234);
}); });
it("keeps a hand-edited extensionDialogsTimeoutMs across settings saves", async () => {
await writeFile(configPath, `${JSON.stringify({ extensionDialogsTimeoutMs: 60_000 }, null, 2)}\n`, "utf8");
savePiWebConfig({ port: 9000 }, testOptions());
expect(loadPiWebConfig(testOptions()).config.extensionDialogsTimeoutMs).toBe(60_000);
});
it("rejects an invalid extensionDialogsTimeoutMs", async () => {
for (const value of [-1, 1.5, "5000", null]) {
await writeFile(configPath, `${JSON.stringify({ extensionDialogsTimeoutMs: value }, null, 2)}\n`, "utf8");
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config extensionDialogsTimeoutMs must be a non-negative integer");
}
});
it("persists and reads custom agent runtime settings", () => { it("persists and reads custom agent runtime settings", () => {
savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions()); savePiWebConfig({ agent: { command: "acme-agent", dir: "/opt/acme-agent/state" } }, testOptions());
@@ -223,6 +239,18 @@ describe("maxUploadBytes", () => {
}); });
}); });
describe("extensionDialogsTimeoutMs", () => {
it("defaults to five minutes when nothing is configured", () => {
expect(effectivePiWebConfig(testOptions()).config.extensionDialogsTimeoutMs).toBe(DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS);
});
it("resolves a configured value, including zero for waiting forever", async () => {
await writeFile(configPath, `${JSON.stringify({ extensionDialogsTimeoutMs: 0 }, null, 2)}\n`, "utf8");
expect(effectivePiWebConfig(testOptions()).config.extensionDialogsTimeoutMs).toBe(0);
});
});
describe("spawnSessionsEnabled", () => { describe("spawnSessionsEnabled", () => {
it("is on by default when nothing is configured", () => { it("is on by default when nothing is configured", () => {
expect(spawnSessionsEnabled({}, {})).toBe(true); expect(spawnSessionsEnabled({}, {})).toBe(true);
+20 -1
View File
@@ -15,11 +15,12 @@ export interface LoadedPiWebConfig {
config: PiWebConfig; config: PiWebConfig;
} }
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "askUser" | "agent"> { export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "askUser" | "agent" | "extensionDialogsTimeoutMs"> {
uploads: NonNullable<PiWebConfig["uploads"]>; uploads: NonNullable<PiWebConfig["uploads"]>;
spawnSessions: boolean; spawnSessions: boolean;
subsessions: boolean; subsessions: boolean;
askUser: boolean; askUser: boolean;
extensionDialogsTimeoutMs: number;
agent: Required<NonNullable<PiWebConfig["agent"]>>; agent: Required<NonNullable<PiWebConfig["agent"]>>;
} }
@@ -50,6 +51,14 @@ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads"; export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads";
/**
* Default auto-cancel delay for extension dialogs whose extension set no
* `timeout` of its own: five minutes. `extensionDialogsTimeoutMs: 0` waits
* forever. Tunes the unattended-dialog safety valve only; dialogs are always
* enabled.
*/
export const DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS = 300_000;
export const DEFAULT_AGENT_COMMAND = "pi"; export const DEFAULT_AGENT_COMMAND = "pi";
export const PI_WEB_AGENT_COMMAND_ENV = "PI_WEB_AGENT_COMMAND"; export const PI_WEB_AGENT_COMMAND_ENV = "PI_WEB_AGENT_COMMAND";
export const PI_WEB_AGENT_DIR_ENV = "PI_WEB_AGENT_DIR"; export const PI_WEB_AGENT_DIR_ENV = "PI_WEB_AGENT_DIR";
@@ -159,6 +168,8 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options:
subsessions: subsessionsEnabled(env, loaded.config), subsessions: subsessionsEnabled(env, loaded.config),
// Always resolved (on by default); the user is present for every ask. // Always resolved (on by default); the user is present for every ask.
askUser: askUserEnabled(env, loaded.config), askUser: askUserEnabled(env, loaded.config),
// Always resolved; the unattended-dialog safety valve, not a gate.
extensionDialogsTimeoutMs: loaded.config.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS,
agent: { command: agent.command, dir: agent.dir }, agent: { command: agent.command, dir: agent.dir },
}, },
}; };
@@ -226,6 +237,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
...(value["askUser"] !== undefined ? { askUser: parseAskUser(value["askUser"], path) } : {}), ...(value["askUser"] !== undefined ? { askUser: parseAskUser(value["askUser"], path) } : {}),
...(value["extensionDialogsTimeoutMs"] !== undefined ? { extensionDialogsTimeoutMs: parseExtensionDialogsTimeoutMs(value["extensionDialogsTimeoutMs"], path) } : {}),
...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}), ...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}),
}; };
} }
@@ -277,6 +289,13 @@ function parseAskUser(value: unknown, path: string): boolean {
return value; return value;
} }
function parseExtensionDialogsTimeoutMs(value: unknown, path: string): number {
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
throw new Error(`PI WEB config extensionDialogsTimeoutMs must be a non-negative integer: ${path}`);
}
return value;
}
/** /**
* Whether LLMs may post a question set to the browser via the ask_user tool. On * 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 * by default: the questions land in the session the user is already watching and
+1
View File
@@ -87,6 +87,7 @@ async function createSessionDaemonRuntime() {
projectWorkspaces, projectWorkspaces,
subsessionsEnabled: config.subsessions, subsessionsEnabled: config.subsessions,
askUserEnabled: config.askUser, askUserEnabled: config.askUser,
extensionDialogsTimeoutMs: config.extensionDialogsTimeoutMs,
notificationStore, notificationStore,
unreadStore, unreadStore,
catalogRefreshStatus: catalogRefresher, catalogRefreshStatus: catalogRefresher,
@@ -25,6 +25,7 @@ function daemonCollaborators(patch: Partial<SessionServiceDependencyInput> = {})
catalogRefreshStatus: { isRefreshInFlight: () => false }, catalogRefreshStatus: { isRefreshInFlight: () => false },
subsessionsEnabled: false, subsessionsEnabled: false,
askUserEnabled: true, askUserEnabled: true,
extensionDialogsTimeoutMs: 300_000,
...patch, ...patch,
}; };
} }
@@ -93,4 +94,8 @@ describe("sessiond session service dependency assembly", () => {
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: true })).askUserEnabled).toBe(true); expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: true })).askUserEnabled).toBe(true);
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: false })).askUserEnabled).toBe(false); expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: false })).askUserEnabled).toBe(false);
}); });
it("passes the extension-dialog timeout through to the session service", () => {
expect(sessionServiceDependencies(daemonCollaborators({ extensionDialogsTimeoutMs: 60_000 })).extensionDialogsTimeoutMs).toBe(60_000);
});
}); });
@@ -24,6 +24,8 @@ export interface SessionServiceDependencyInput {
subsessionsEnabled: boolean; subsessionsEnabled: boolean;
/** Whether agents may post structured question sets to the browser. */ /** Whether agents may post structured question sets to the browser. */
askUserEnabled: boolean; askUserEnabled: boolean;
/** Auto-cancel delay for extension dialogs whose extension set no timeout; `0` waits forever. */
extensionDialogsTimeoutMs: number;
} }
/** /**
@@ -48,6 +50,7 @@ export function sessionServiceDependencies(input: SessionServiceDependencyInput)
// so they stay off unless spawning is configured too. // so they stay off unless spawning is configured too.
subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled, subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled,
askUserEnabled: input.askUserEnabled, askUserEnabled: input.askUserEnabled,
extensionDialogsTimeoutMs: input.extensionDialogsTimeoutMs,
notificationStore: input.notificationStore, notificationStore: input.notificationStore,
unreadStore: input.unreadStore, unreadStore: input.unreadStore,
// Read-only, so session startup can tell a waiting user that provider // Read-only, so session startup can tell a waiting user that provider
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PendingExtensionDialog } from "../../shared/apiTypes.js";
import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
function dialog(patch: Partial<PendingExtensionDialog> = {}): PendingExtensionDialog {
return {
dialogId: "dialog-1",
kind: "confirm",
title: "Continue?",
askedAt: "2026-02-01T10:00:00.000Z",
runScoped: false,
...patch,
};
}
/** Observe a parked wait without hanging the test when it never settles. */
async function settledValue(promise: Promise<boolean | string | undefined>): Promise<{ settled: true; value: boolean | string | undefined } | { settled: false }> {
return await Promise.race([
promise.then((value) => ({ settled: true as const, value })),
Promise.resolve({ settled: false as const }),
]);
}
describe("extensionDialogCancelValue", () => {
it("matches the SDK cancel value of each dialog kind", () => {
expect(extensionDialogCancelValue("confirm")).toBe(false);
expect(extensionDialogCancelValue("select")).toBeUndefined();
expect(extensionDialogCancelValue("input")).toBeUndefined();
});
});
describe("effectiveExtensionDialogTimeoutMs", () => {
it("picks the sooner of the extension timeout and the daemon default", () => {
expect(effectiveExtensionDialogTimeoutMs(1_000, 300_000)).toBe(1_000);
expect(effectiveExtensionDialogTimeoutMs(600_000, 300_000)).toBe(300_000);
});
it("treats an absent or unusable extension timeout as the daemon default alone", () => {
expect(effectiveExtensionDialogTimeoutMs(undefined, 300_000)).toBe(300_000);
expect(effectiveExtensionDialogTimeoutMs(Number.NaN, 300_000)).toBe(300_000);
expect(effectiveExtensionDialogTimeoutMs(-5, 300_000)).toBe(300_000);
});
it("treats a zero daemon default as waiting forever", () => {
expect(effectiveExtensionDialogTimeoutMs(undefined, 0)).toBeUndefined();
expect(effectiveExtensionDialogTimeoutMs(1_000, 0)).toBe(1_000);
});
});
describe("ExtensionDialogWaiters", () => {
it("resolves a settled wait with the user's answer", async () => {
const waiters = new ExtensionDialogWaiters();
const parked = waiters.park(dialog());
expect(waiters.settleWithAnswer("dialog-1", true)).toBe(true);
await expect(parked).resolves.toBe(true);
});
it("resolves a close without an answer with the dialog kind's cancel value", async () => {
const waiters = new ExtensionDialogWaiters();
const confirm = waiters.park(dialog({ dialogId: "dialog-1", kind: "confirm" }));
const select = waiters.park(dialog({ dialogId: "dialog-2", kind: "select", options: ["a"] }));
waiters.settleWithCancelValue("dialog-1");
waiters.settleWithCancelValue("dialog-2");
await expect(confirm).resolves.toBe(false);
await expect(select).resolves.toBeUndefined();
});
it("reports an unknown dialog instead of settling anything", () => {
const waiters = new ExtensionDialogWaiters();
expect(waiters.settleWithAnswer("nobody", true)).toBe(false);
expect(waiters.settleWithCancelValue("nobody")).toBe(false);
});
it("fires the cancel trigger when the extension aborts its signal", () => {
const waiters = new ExtensionDialogWaiters();
const controller = new AbortController();
const triggers: string[] = [];
void waiters.park(dialog(), { signal: controller.signal, onTrigger: (reason) => { triggers.push(reason); } });
controller.abort();
expect(triggers).toEqual(["cancelled"]);
});
it("unsubscribes the signal on settle, so a later abort cannot trigger a settled wait", () => {
const waiters = new ExtensionDialogWaiters();
const controller = new AbortController();
const triggers: string[] = [];
void waiters.park(dialog(), { signal: controller.signal, onTrigger: (reason) => { triggers.push(reason); } });
waiters.settleWithCancelValue("dialog-1");
controller.abort();
expect(triggers).toEqual([]);
});
it("settles each parked wait exactly once even when settle is repeated", async () => {
const waiters = new ExtensionDialogWaiters();
const parked = waiters.park(dialog());
expect(waiters.settleWithAnswer("dialog-1", true)).toBe(true);
expect(waiters.settleWithAnswer("dialog-1", false)).toBe(false);
await expect(parked).resolves.toBe(true);
});
});
describe("ExtensionDialogWaiters timeout", () => {
afterEach(() => {
vi.useRealTimers();
});
it("fires the timeout trigger when the armed delay elapses", async () => {
vi.useFakeTimers();
const waiters = new ExtensionDialogWaiters();
const triggers: string[] = [];
const parked = waiters.park(dialog(), { timeoutMs: 5_000, onTrigger: (reason) => { triggers.push(reason); } });
vi.advanceTimersByTime(5_000);
expect(triggers).toEqual(["timeout"]);
await expect(settledValue(parked)).resolves.toEqual({ settled: false });
});
it("arms no timer when the dialog waits forever", () => {
vi.useFakeTimers();
const waiters = new ExtensionDialogWaiters();
void waiters.park(dialog());
expect(vi.getTimerCount()).toBe(0);
});
it("cancels the timer when the wait settles, so the timeout cannot fire afterwards", async () => {
vi.useFakeTimers();
const waiters = new ExtensionDialogWaiters();
const triggers: string[] = [];
const parked = waiters.park(dialog(), { timeoutMs: 5_000, onTrigger: (reason) => { triggers.push(reason); } });
waiters.settleWithAnswer("dialog-1", true);
vi.advanceTimersByTime(10_000);
expect(triggers).toEqual([]);
await expect(parked).resolves.toBe(true);
});
});
@@ -0,0 +1,103 @@
import type { ExtensionDialogAnswer, ExtensionDialogKind, PendingExtensionDialog } from "../../shared/apiTypes.js";
/** Why a parked wait ended without the browser: its timer elapsed, or the extension aborted its own signal. */
export type ExtensionDialogWaiterTrigger = "timeout" | "cancelled";
export interface ExtensionDialogWaiterTriggers {
/** Effective auto-cancel delay; omitted (or resolved away) means the dialog waits forever. */
timeoutMs?: number | undefined;
/** The extension's own abort signal, subscribed once and unsubscribed on settle. */
signal?: AbortSignal | undefined;
/**
* Fired exactly once when the wait ends without a browser close. The caller
* owns closing the store record and settling the waiter; the waiters only
* guarantee the trigger cannot fire after a settle. Omit when nothing but
* the browser can end the wait.
*/
onTrigger?: ((reason: ExtensionDialogWaiterTrigger) => void) | undefined;
}
interface ParkedExtensionDialog {
/** The value the extension's Promise resolves with when the dialog closes without an answer. */
cancelValue: boolean | undefined;
resolve: (value: boolean | string | undefined) => void;
cancelArmedTimeout?: (() => void) | undefined;
removeSignalListener?: (() => void) | undefined;
}
/** The value an extension's dialog Promise settles with on any close without an answer. */
export function extensionDialogCancelValue(kind: ExtensionDialogKind): boolean | undefined {
return kind === "confirm" ? false : undefined;
}
/**
* The auto-cancel delay of one dialog: the sooner of the extension's own
* `timeout` and the daemon's `extensionDialogsTimeoutMs` default, where `0`
* (or an invalid extension value, defensively ignored) means "waits forever".
*/
export function effectiveExtensionDialogTimeoutMs(extensionTimeoutMs: number | undefined, daemonDefaultMs: number): number | undefined {
const fromExtension = typeof extensionTimeoutMs === "number" && Number.isFinite(extensionTimeoutMs) && extensionTimeoutMs > 0 ? extensionTimeoutMs : undefined;
const fromDaemon = daemonDefaultMs > 0 ? daemonDefaultMs : undefined;
if (fromExtension === undefined) return fromDaemon;
if (fromDaemon === undefined) return fromExtension;
return Math.min(fromExtension, fromDaemon);
}
/**
* The parked Promise resolvers behind open extension dialogs, plus the timers
* and signal subscriptions that can end a wait without the browser. Timers use
* the global `setTimeout`/`clearTimeout` looked up per call, the same seam the
* rest of the service's timer tests fake.
*
* Kept deliberately separate from {@link PendingExtensionDialogStore}: the
* store owns the domain state every browser sees, the waiters own the one
* in-memory resolver each open dialog parks inside extension code state no
* browser ever observes and that must not survive the runtime. The pairing is
* the wiring's invariant: every open store record has exactly one parked
* waiter, and whoever closes the record settles the waiter exactly once.
*/
export class ExtensionDialogWaiters {
private readonly parked = new Map<string, ParkedExtensionDialog>();
/**
* Park the extension-facing Promise for a dialog the store just opened. Arms
* the timeout and signal triggers; both are disarmed when the wait settles,
* so a settled wait can never be triggered (nor trigger twice).
*/
park(dialog: PendingExtensionDialog, triggers: ExtensionDialogWaiterTriggers = {}): Promise<boolean | string | undefined> {
return new Promise((resolve) => {
const parked: ParkedExtensionDialog = { cancelValue: extensionDialogCancelValue(dialog.kind), resolve };
if (triggers.timeoutMs !== undefined) {
const handle = setTimeout(() => { triggers.onTrigger?.("timeout"); }, triggers.timeoutMs);
parked.cancelArmedTimeout = () => { clearTimeout(handle); };
}
if (triggers.signal !== undefined) {
const signal = triggers.signal;
const onAbort = () => { triggers.onTrigger?.("cancelled"); };
signal.addEventListener("abort", onAbort, { once: true });
parked.removeSignalListener = () => { signal.removeEventListener("abort", onAbort); };
}
this.parked.set(dialog.dialogId, parked);
});
}
/** Resolve the parked wait with the user's answer, which the store has already validated and recorded. */
settleWithAnswer(dialogId: string, answer: ExtensionDialogAnswer): boolean {
return this.settle(dialogId, (parked) => { parked.resolve(answer); });
}
/** Resolve the parked wait with the dialog kind's cancel value after a close without an answer. */
settleWithCancelValue(dialogId: string): boolean {
return this.settle(dialogId, (parked) => { parked.resolve(parked.cancelValue); });
}
private settle(dialogId: string, resolveParked: (parked: ParkedExtensionDialog) => void): boolean {
const parked = this.parked.get(dialogId);
if (parked === undefined) return false;
this.parked.delete(dialogId);
parked.cancelArmedTimeout?.();
parked.removeSignalListener?.();
resolveParked(parked);
return true;
}
}
@@ -0,0 +1,279 @@
import { describe, expect, it } from "vitest";
import {
EXTENSION_DIALOG_INPUT_MAX_LENGTH,
EXTENSION_DIALOG_OPTION_LIMIT,
type ExtensionDialogAnswer,
} from "../../shared/apiTypes.js";
import {
PendingExtensionDialogStore,
PendingExtensionDialogValidationError,
type ExtensionDialogCancelReason,
} from "./pendingExtensionDialogStore.js";
const sessionId = "session-1";
function testStore(createDialogId?: () => string) {
let dialogCount = 0;
let tick = 0;
return new PendingExtensionDialogStore({
createDialogId: createDialogId ?? (() => `dialog-${(++dialogCount).toString()}`),
now: () => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++)),
});
}
describe("PendingExtensionDialogStore open", () => {
it("normalizes a confirm dialog and reports it among the session's pending dialogs", () => {
const store = testStore();
const dialog = store.open({
sessionId,
kind: "confirm",
title: "Deploy to production?",
message: "This will restart the service.",
timeoutMs: 300_000,
runScoped: true,
});
expect(dialog).toEqual({
dialogId: "dialog-1",
kind: "confirm",
title: "Deploy to production?",
message: "This will restart the service.",
askedAt: "2026-01-01T00:00:00.000Z",
timeoutAt: "2026-01-01T00:05:00.000Z",
runScoped: true,
});
expect(store.pendingDialogs(sessionId)).toEqual([dialog]);
expect(store.pendingDialogs("other-session")).toEqual([]);
});
it("keeps several dialogs of one session open, oldest first, without superseding", () => {
const store = testStore();
const confirm = store.open({ sessionId, kind: "confirm", title: "Proceed?", runScoped: true });
const select = store.open({ sessionId, kind: "select", title: "Pick a branch", options: ["main", "dev"], runScoped: false });
const input = store.open({ sessionId, kind: "input", title: "Commit name", placeholder: "feat: …", runScoped: false });
expect(store.pendingDialogs(sessionId)).toEqual([confirm, select, input]);
expect(select).toEqual({
dialogId: "dialog-2",
kind: "select",
title: "Pick a branch",
options: ["main", "dev"],
askedAt: "2026-01-01T00:00:01.000Z",
runScoped: false,
});
expect(input).toEqual({
dialogId: "dialog-3",
kind: "input",
title: "Commit name",
placeholder: "feat: …",
askedAt: "2026-01-01T00:00:02.000Z",
runScoped: false,
});
});
it("keeps each session's dialogs separate", () => {
const store = testStore();
store.open({ sessionId, kind: "confirm", title: "One?", runScoped: false });
store.open({ sessionId: "session-2", kind: "confirm", title: "Two?", runScoped: false });
store.open({ sessionId: "session-2", kind: "confirm", title: "Three?", runScoped: false });
expect(store.pendingDialogs(sessionId).map((dialog) => dialog.title)).toEqual(["One?"]);
expect(store.pendingDialogs("session-2").map((dialog) => dialog.title)).toEqual(["Two?", "Three?"]);
});
it("omits timeoutAt when no timeout applies and drops blank cosmetic fields", () => {
const store = testStore();
const dialog = store.open({ sessionId, kind: "confirm", title: "Sure?", message: " ", runScoped: false });
expect(dialog).toEqual({
dialogId: "dialog-1",
kind: "confirm",
title: "Sure?",
askedAt: "2026-01-01T00:00:00.000Z",
runScoped: false,
});
expect(dialog).not.toHaveProperty("timeoutAt");
expect(dialog).not.toHaveProperty("message");
});
it("drops fields that do not belong to the dialog's kind", () => {
const store = testStore();
const select = store.open({
sessionId,
kind: "select",
title: "Pick",
options: ["a"],
message: "not a confirm field",
placeholder: "not an input field",
runScoped: false,
});
const input = store.open({
sessionId,
kind: "input",
title: "Type",
options: ["a"],
runScoped: false,
});
expect(select).not.toHaveProperty("message");
expect(select).not.toHaveProperty("placeholder");
expect(input).not.toHaveProperty("options");
expect(input).not.toHaveProperty("placeholder");
});
it("rejects dialogs the user could not meaningfully answer", () => {
const store = testStore();
const open = (overrides: Record<string, unknown>) => () =>
store.open({ sessionId, kind: "confirm", title: "Ok?", runScoped: false, ...overrides });
expect(open({ title: " " })).toThrow(/dialog title must not be empty/);
expect(open({ kind: "widget" })).toThrow(/Unknown dialog kind widget/);
expect(open({ kind: "select", options: undefined })).toThrow(/at least one option/);
expect(open({ kind: "select", options: [] })).toThrow(/at least one option/);
expect(open({ kind: "select", options: ["a", "a"] })).toThrow(/Duplicate select option a/);
expect(open({ kind: "select", options: [" "] })).toThrow(/select option must not be empty/);
expect(open({
kind: "select",
options: Array.from({ length: EXTENSION_DIALOG_OPTION_LIMIT + 1 }, (_, index) => `v${index.toString()}`),
})).toThrow(/more than 24 options/);
expect(open({ timeoutMs: 0 })).toThrow(PendingExtensionDialogValidationError);
expect(open({ timeoutMs: -5 })).toThrow(PendingExtensionDialogValidationError);
expect(open({ timeoutMs: Number.NaN })).toThrow(PendingExtensionDialogValidationError);
expect(store.pendingDialogs(sessionId)).toEqual([]);
});
it("rejects an open whose id collides with a still-open dialog", () => {
const store = testStore(() => "dialog-x");
store.open({ sessionId, kind: "confirm", title: "First?", runScoped: false });
expect(() => store.open({ sessionId, kind: "confirm", title: "Second?", runScoped: false }))
.toThrow(/already open/);
expect(store.pendingDialogs(sessionId).map((dialog) => dialog.title)).toEqual(["First?"]);
});
});
describe("PendingExtensionDialogStore answer", () => {
it("closes a confirm dialog with the user's boolean answer", () => {
const store = testStore();
store.open({ sessionId, kind: "confirm", title: "Proceed?", runScoped: true });
const result = store.answer(sessionId, "dialog-1", true);
expect(result).toEqual({
status: "closed",
outcome: {
dialogId: "dialog-1",
reason: "answered",
answer: true,
askedAt: "2026-01-01T00:00:00.000Z",
closedAt: "2026-01-01T00:00:01.000Z",
},
});
expect(store.pendingDialogs(sessionId)).toEqual([]);
});
it("closes a select dialog with the chosen option and an input dialog with the typed text", () => {
const store = testStore();
store.open({ sessionId, kind: "select", title: "Pick", options: ["main", "dev"], runScoped: false });
store.open({ sessionId, kind: "input", title: "Name", runScoped: false });
const selected = store.answer(sessionId, "dialog-1", "dev");
const typed = store.answer(sessionId, "dialog-2", "feat: dialogs");
expect(selected).toMatchObject({ status: "closed", outcome: { reason: "answered", answer: "dev" } });
expect(typed).toMatchObject({ status: "closed", outcome: { reason: "answered", answer: "feat: dialogs" } });
});
it("accepts an empty string as an input answer, distinct from cancelling", () => {
const store = testStore();
store.open({ sessionId, kind: "input", title: "Name", runScoped: false });
const result = store.answer(sessionId, "dialog-1", "");
expect(result).toMatchObject({ status: "closed", outcome: { reason: "answered", answer: "" } });
});
it("rejects answers that do not fit the dialog's kind and keeps the dialog open", () => {
const store = testStore();
store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false });
store.open({ sessionId, kind: "select", title: "Pick", options: ["a", "b"], runScoped: false });
store.open({ sessionId, kind: "input", title: "Type", runScoped: false });
const answer = (dialogId: string, value: ExtensionDialogAnswer) => () => store.answer(sessionId, dialogId, value);
expect(answer("dialog-1", "yes")).toThrow(/expects a boolean answer/);
expect(answer("dialog-2", true)).toThrow(/has no option true/);
expect(answer("dialog-2", "c")).toThrow(/has no option c/);
expect(answer("dialog-3", false)).toThrow(/expects a text answer/);
expect(answer("dialog-3", "x".repeat(EXTENSION_DIALOG_INPUT_MAX_LENGTH + 1))).toThrow(/exceeds its length limit/);
expect(store.pendingDialogs(sessionId).map((dialog) => dialog.dialogId)).toEqual(["dialog-1", "dialog-2", "dialog-3"]);
});
it("treats an answer for a dialog that is no longer open as stale", () => {
const store = testStore();
store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false });
expect(store.answer(sessionId, "dialog-other", true)).toEqual({ status: "stale" });
expect(store.answer("session-2", "dialog-1", true)).toEqual({ status: "stale" });
store.answer(sessionId, "dialog-1", false);
expect(store.answer(sessionId, "dialog-1", true)).toEqual({ status: "stale" });
});
});
describe("PendingExtensionDialogStore cancel", () => {
it("closes a dialog without an answer for every cancel reason", () => {
const store = testStore();
const reasons: ExtensionDialogCancelReason[] = ["cancelled", "timeout", "aborted", "session-ended"];
for (const reason of reasons) {
const dialog = store.open({ sessionId, kind: "confirm", title: `${reason}?`, runScoped: false });
const result = store.cancel(sessionId, dialog.dialogId, reason);
if (result.status !== "closed") throw new Error("expected the dialog to close");
const { closedAt, ...outcome } = result.outcome;
expect(closedAt).toEqual(expect.any(String));
expect(outcome).toEqual({ dialogId: dialog.dialogId, reason, askedAt: dialog.askedAt });
expect(result.outcome).not.toHaveProperty("answer");
}
expect(store.pendingDialogs(sessionId)).toEqual([]);
});
it("closes only the named dialog and keeps the rest in order", () => {
const store = testStore();
store.open({ sessionId, kind: "confirm", title: "One?", runScoped: false });
store.open({ sessionId, kind: "confirm", title: "Two?", runScoped: false });
store.open({ sessionId, kind: "confirm", title: "Three?", runScoped: false });
store.cancel(sessionId, "dialog-2", "cancelled");
expect(store.pendingDialogs(sessionId).map((dialog) => dialog.dialogId)).toEqual(["dialog-1", "dialog-3"]);
store.answer(sessionId, "dialog-1", true);
expect(store.pendingDialogs(sessionId).map((dialog) => dialog.dialogId)).toEqual(["dialog-3"]);
});
it("records the close time, not the open time, as closedAt", () => {
const store = testStore();
store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false });
const result = store.cancel(sessionId, "dialog-1", "timeout");
expect(result).toMatchObject({
status: "closed",
outcome: { askedAt: "2026-01-01T00:00:00.000Z", closedAt: "2026-01-01T00:00:01.000Z" },
});
});
it("treats a cancel for a dialog that is no longer open as stale", () => {
const store = testStore();
store.open({ sessionId, kind: "confirm", title: "Sure?", runScoped: false });
expect(store.cancel(sessionId, "dialog-other", "cancelled")).toEqual({ status: "stale" });
expect(store.cancel("session-2", "dialog-1", "cancelled")).toEqual({ status: "stale" });
store.cancel(sessionId, "dialog-1", "aborted");
expect(store.cancel(sessionId, "dialog-1", "cancelled")).toEqual({ status: "stale" });
});
});
@@ -0,0 +1,257 @@
import { randomUUID } from "node:crypto";
import {
EXTENSION_DIALOG_ID_MAX_LENGTH,
EXTENSION_DIALOG_INPUT_MAX_LENGTH,
EXTENSION_DIALOG_OPTION_LIMIT,
EXTENSION_DIALOG_TEXT_MAX_LENGTH,
type ExtensionDialogAnswer,
type ExtensionDialogCloseReason,
type ExtensionDialogKind,
type ExtensionDialogOutcome,
type PendingExtensionDialog,
} from "../../shared/apiTypes.js";
export interface PendingExtensionDialogStoreOptions {
now?: (() => Date) | undefined;
createDialogId?: (() => string) | undefined;
}
/**
* What one extension dialog needs to open: the SDK `ctx.ui` dialog arguments
* plus the wiring's scoping decisions. The effective timeout (the sooner of
* the extension's own `timeout` and the daemon default) is decided by the
* caller; the store only projects it onto its clock as `timeoutAt`.
*/
export interface PendingExtensionDialogOpenInput {
sessionId: string;
kind: ExtensionDialogKind;
title: string;
message?: string | undefined;
options?: string[] | undefined;
placeholder?: string | undefined;
/** Effective timeout in milliseconds; omit (or have the caller resolve `0`) to wait forever. */
timeoutMs?: number | undefined;
/** True when opened while a run is in flight; run-scoped dialogs are settled on `agent_end`. */
runScoped: boolean;
}
/** Why a dialog was closed without an answer. `"answered"` is {@link answer}'s reason, not a cancel reason. */
export type ExtensionDialogCancelReason = Exclude<ExtensionDialogCloseReason, "answered">;
/**
* Result of answering or cancelling a dialog. `"stale"` means the dialog named
* by the caller is no longer open (already answered, cancelled, timed out, or
* gone with its session runtime), which is an ordinary race a browser can
* lose not an error.
*/
export type PendingExtensionDialogCloseResult =
| { status: "closed"; outcome: ExtensionDialogOutcome }
| { status: "stale" };
/** Rejected input: the dialog is malformed, or an answer does not fit its kind. */
export class PendingExtensionDialogValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "PendingExtensionDialogValidationError";
}
}
/**
* Daemon-owned open-dialog state: the extension dialogs of every session,
* several per session because each dialog is an independent blocking wait
* inside extension code opening one must never supersede another.
*
* The store is pure domain logic no Fastify, no Pi session, no I/O, no
* timers. It validates dialogs and answers and owns the open/answer/cancel
* transitions; callers hold the waiting Promise resolvers, publish the
* returned records and outcomes, and own the timers that turn `timeoutAt`
* into a `"timeout"` cancel.
*
* State is deliberately daemon-lifetime and in-memory. An open dialog is
* meaningful only while the session runtime whose extension is waiting on it
* exists, and browsers rehydrate open dialogs from `SessionStatus` rather
* than from disk.
*/
export class PendingExtensionDialogStore {
private readonly now: () => Date;
private readonly createDialogId: () => string;
/** Per-session open dialogs in insertion order, so `pendingDialogs` reads oldest first. */
private readonly openBySessionId = new Map<string, Map<string, PendingExtensionDialog>>();
constructor(options: PendingExtensionDialogStoreOptions = {}) {
this.now = options.now ?? (() => new Date());
this.createDialogId = options.createDialogId ?? randomUUID;
}
/** The session's open dialogs, oldest first, for {@link SessionStatus} projection. */
pendingDialogs(sessionId: string): PendingExtensionDialog[] {
const dialogs = this.openBySessionId.get(requireSessionId(sessionId));
if (dialogs === undefined) return [];
return [...dialogs.values()].map(cloneDialog);
}
open(input: PendingExtensionDialogOpenInput): PendingExtensionDialog {
const sessionId = requireSessionId(input.sessionId);
const kind = requireKind(input.kind);
const now = this.now();
const dialog: PendingExtensionDialog = {
dialogId: requireId(this.createDialogId(), "dialogId"),
kind,
title: requireText(input.title, "dialog title"),
...kindFields(kind, input),
askedAt: now.toISOString(),
...timeoutField(input.timeoutMs, now),
runScoped: input.runScoped,
};
const dialogs = this.openBySessionId.get(sessionId) ?? new Map<string, PendingExtensionDialog>();
if (dialogs.has(dialog.dialogId)) {
throw new Error(`Dialog id ${dialog.dialogId} is already open in session ${sessionId}`);
}
dialogs.set(dialog.dialogId, dialog);
this.openBySessionId.set(sessionId, dialogs);
return cloneDialog(dialog);
}
/**
* Record the user's answer and close the dialog. The answer is validated
* against the dialog's kind first, so an answer that does not fit leaves the
* dialog open for the browser to correct.
*/
answer(sessionId: string, dialogId: string, value: ExtensionDialogAnswer): PendingExtensionDialogCloseResult {
const dialog = this.openBySessionId.get(requireSessionId(sessionId))?.get(dialogId);
if (dialog === undefined) return { status: "stale" };
const answer = validateAnswer(dialog, value);
return { status: "closed", outcome: this.requireClose(sessionId, dialog, "answered", answer) };
}
/** Close the dialog without an answer; the extension's wait settles with its kind's cancel value. */
cancel(sessionId: string, dialogId: string, reason: ExtensionDialogCancelReason): PendingExtensionDialogCloseResult {
const dialog = this.openBySessionId.get(requireSessionId(sessionId))?.get(dialogId);
if (dialog === undefined) return { status: "stale" };
return { status: "closed", outcome: this.requireClose(sessionId, dialog, reason, undefined) };
}
private requireClose(
sessionId: string,
dialog: PendingExtensionDialog,
reason: ExtensionDialogCloseReason,
answer: ExtensionDialogAnswer | undefined,
): ExtensionDialogOutcome {
const dialogs = this.openBySessionId.get(sessionId);
if (dialogs?.delete(dialog.dialogId) !== true) {
throw new Error(`Dialog ${dialog.dialogId} of session ${sessionId} disappeared while closing`);
}
if (dialogs.size === 0) this.openBySessionId.delete(sessionId);
return {
dialogId: dialog.dialogId,
reason,
...(answer === undefined ? {} : { answer }),
askedAt: dialog.askedAt,
closedAt: this.timestamp(),
};
}
private timestamp(): string {
return this.now().toISOString();
}
}
function validateAnswer(dialog: PendingExtensionDialog, value: ExtensionDialogAnswer): ExtensionDialogAnswer {
switch (dialog.kind) {
case "confirm":
if (typeof value !== "boolean") throw new PendingExtensionDialogValidationError(`Dialog ${dialog.dialogId} expects a boolean answer`);
return value;
case "select":
if (typeof value !== "string" || dialog.options?.includes(value) !== true) {
throw new PendingExtensionDialogValidationError(`Dialog ${dialog.dialogId} has no option ${String(value)}`);
}
return value;
case "input":
if (typeof value !== "string") throw new PendingExtensionDialogValidationError(`Dialog ${dialog.dialogId} expects a text answer`);
if (value.length > EXTENSION_DIALOG_INPUT_MAX_LENGTH) {
throw new PendingExtensionDialogValidationError(`Answer of dialog ${dialog.dialogId} exceeds its length limit`);
}
return value;
}
}
/** Kind-specific fields of a validated record; irrelevant fields are dropped rather than rejected. */
function kindFields(
kind: ExtensionDialogKind,
input: PendingExtensionDialogOpenInput,
): Pick<PendingExtensionDialog, "message" | "options" | "placeholder"> {
switch (kind) {
case "confirm": {
const message = optionalText(input.message, "dialog message");
return message === undefined ? {} : { message };
}
case "select":
return { options: validateOptions(input.options) };
case "input": {
const placeholder = optionalText(input.placeholder, "dialog placeholder");
return placeholder === undefined ? {} : { placeholder };
}
}
}
function validateOptions(options: string[] | undefined): string[] {
if (options === undefined || options.length === 0) {
throw new PendingExtensionDialogValidationError("A select dialog must offer at least one option");
}
if (options.length > EXTENSION_DIALOG_OPTION_LIMIT) {
throw new PendingExtensionDialogValidationError(`A select dialog must not offer more than ${EXTENSION_DIALOG_OPTION_LIMIT.toString()} options`);
}
const seen = new Set<string>();
return options.map((option) => {
const validated = requireText(option, "select option");
if (seen.has(validated)) throw new PendingExtensionDialogValidationError(`Duplicate select option ${validated}`);
seen.add(validated);
return validated;
});
}
function timeoutField(timeoutMs: number | undefined, now: Date): Pick<PendingExtensionDialog, "timeoutAt"> {
if (timeoutMs === undefined) return {};
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new PendingExtensionDialogValidationError("A dialog timeout must be a positive number of milliseconds");
}
return { timeoutAt: new Date(now.getTime() + timeoutMs).toISOString() };
}
function cloneDialog(dialog: PendingExtensionDialog): PendingExtensionDialog {
return { ...dialog, ...(dialog.options === undefined ? {} : { options: [...dialog.options] }) };
}
function requireSessionId(sessionId: string): string {
if (sessionId === "") throw new Error("sessionId must not be empty");
return sessionId;
}
/** Runtime guard: the input crosses extension code, so the declared kind is checked despite its type. */
function requireKind(kind: string): ExtensionDialogKind {
if (kind !== "confirm" && kind !== "select" && kind !== "input") {
throw new PendingExtensionDialogValidationError(`Unknown dialog kind ${kind}`);
}
return kind;
}
function requireId(value: string, field: string): string {
if (value.trim() === "") throw new PendingExtensionDialogValidationError(`${field} must not be empty`);
if (value.length > EXTENSION_DIALOG_ID_MAX_LENGTH) throw new PendingExtensionDialogValidationError(`${field} exceeds its length limit`);
return value;
}
function requireText(value: string, field: string): string {
if (value.trim() === "") throw new PendingExtensionDialogValidationError(`${field} must not be empty`);
if (value.length > EXTENSION_DIALOG_TEXT_MAX_LENGTH) throw new PendingExtensionDialogValidationError(`${field} exceeds its length limit`);
return value;
}
/** Optional cosmetic prose: blank means absent rather than being a validation error. */
function optionalText(value: string | undefined, field: string): string | undefined {
if (value === undefined || value.trim() === "") return undefined;
if (value.length > EXTENSION_DIALOG_TEXT_MAX_LENGTH) {
throw new PendingExtensionDialogValidationError(`${field} exceeds its length limit`);
}
return value;
}
@@ -0,0 +1,633 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import type { PendingExtensionDialog, SessionUiEvent } from "../../shared/apiTypes.js";
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
import { PendingExtensionDialogStore, PendingExtensionDialogValidationError } from "./pendingExtensionDialogStore.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";
/**
* Service over a clocked store with sequential dialog ids, so dialogs are named
* `dialog-1`, `dialog-2`, and timestamps are fixed. The daemon default
* timeout is `0` (wait forever) unless a test says otherwise, so parked waits
* arm no real timers.
*/
function dialogService(options: { extensionDialogsTimeoutMs?: number } = {}) {
const store = new PendingExtensionDialogStore({
now: () => new Date("2026-02-01T10:00:00.000Z"),
createDialogId: (() => {
let next = 0;
return () => { next += 1; return `dialog-${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([sessionRecord(ACTIVE_SESSION_ID)]),
archiveStore: emptyArchiveStore(),
createAgentRuntime: runtimeCreator(fake.runtime),
pendingExtensionDialogStore: store,
extensionDialogsTimeoutMs: options.extensionDialogsTimeoutMs ?? 0,
heartbeatIntervalMs: 60_000,
});
return { service, store, events, fake };
}
/** Start the session and return the UI context its extensions were bound with. */
async function boundUiContext(service: PiSessionService, fake: ReturnType<typeof fakeRuntime>): Promise<ExtensionUIContext> {
await service.status(sessionRef(ACTIVE_SESSION_ID));
const bindings = fake.calls.bindExtensions.at(-1);
if (bindings?.uiContext === undefined) throw new Error("session extensions were not bound");
return bindings.uiContext;
}
function dialogEvents(events: CapturingSessionEventHub): { sessionId: string; event: SessionUiEvent }[] {
return events.sessionEvents.filter(({ event }) => event.type === "dialog.opened" || event.type === "dialog.closed");
}
/** Observe a parked wait without hanging the test when it never settles. */
async function settledValue(promise: Promise<boolean | string | undefined>): Promise<{ settled: true; value: boolean | string | undefined } | { settled: false }> {
return await Promise.race([
promise.then((value) => ({ settled: true as const, value })),
Promise.resolve({ settled: false as const }),
]);
}
function openDialog(events: CapturingSessionEventHub): PendingExtensionDialog {
const opened = dialogEvents(events).find(({ event }) => event.type === "dialog.opened");
if (opened?.event.type !== "dialog.opened") throw new Error("no dialog.opened event published");
return opened.event.dialog;
}
describe("PiSessionService extension dialog UI context", () => {
it("opens a confirm dialog for the extension and parks its answer", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really proceed?");
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([{
dialogId: "dialog-1",
kind: "confirm",
title: "Proceed?",
message: "Really proceed?",
askedAt: "2026-02-01T10:00:00.000Z",
runScoped: false,
}]);
expect(dialogEvents(events)).toEqual([
{ sessionId: ACTIVE_SESSION_ID, event: { type: "dialog.opened", dialog: openDialog(events) } },
]);
await expect(settledValue(parked)).resolves.toEqual({ settled: false });
await service.dispose();
});
it("marks a dialog opened while a run is in flight as run-scoped", async () => {
const { service, store, fake } = dialogService();
const ui = await boundUiContext(service, fake);
fake.session.isStreaming = true;
void ui.confirm("Run consent", "Allow this tool call?");
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ runScoped: true })]);
await service.dispose();
});
it("opens select and input dialogs with their kind-shaped fields", async () => {
const { service, store, fake } = dialogService();
const ui = await boundUiContext(service, fake);
void ui.select("Pick a database", ["pg", "sqlite"]);
void ui.input("Branch name?", "feature/…");
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([
expect.objectContaining({ dialogId: "dialog-1", kind: "select", title: "Pick a database", options: ["pg", "sqlite"] }),
expect.objectContaining({ dialogId: "dialog-2", kind: "input", title: "Branch name?", placeholder: "feature/…" }),
]);
await service.dispose();
});
it("rejects a malformed dialog without opening anything", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
await expect(ui.select("Pick one", [])).rejects.toThrow(PendingExtensionDialogValidationError);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events)).toEqual([]);
await service.dispose();
});
it("dismisses a dialog whose signal is already aborted without opening it", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const controller = new AbortController();
controller.abort();
await expect(ui.confirm("Proceed?", "Really?", { signal: controller.signal })).resolves.toBe(false);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events)).toEqual([]);
await service.dispose();
});
it("keeps delegating non-dialog UI methods to the base context", async () => {
const { service, fake } = dialogService();
const ui = await boundUiContext(service, fake);
await expect(ui.editor("title")).resolves.toBeUndefined();
await service.dispose();
});
});
describe("PiSessionService.answerDialog", () => {
it("resolves the extension's parked wait with the user's answer", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really?");
const response = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true);
await expect(parked).resolves.toBe(true);
expect(response.result).toBe("closed");
expect(response.outcome).toEqual({
dialogId: "dialog-1",
reason: "answered",
answer: true,
askedAt: "2026-02-01T10:00:00.000Z",
closedAt: "2026-02-01T10:00:00.000Z",
});
expect(response.sessionStatus.pendingDialogs).toBeUndefined();
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "answered", answer: true },
]);
await service.dispose();
});
it("routes answers by dialog id when several dialogs are open", async () => {
const { service, store, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const select = ui.select("Pick a database", ["pg", "sqlite"]);
const input = ui.input("Branch name?");
await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-2", "feature/dialogs");
await expect(input).resolves.toBe("feature/dialogs");
await expect(settledValue(select)).resolves.toEqual({ settled: false });
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ dialogId: "dialog-1" })]);
await service.dispose();
});
it("reports a stale dialog id without settling the parked wait", async () => {
const { service, store, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really?");
const response = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-gone", true);
expect(response.result).toBe("stale");
expect(response).not.toHaveProperty("outcome");
expect(response.sessionStatus.pendingDialogs).toEqual([expect.objectContaining({ dialogId: "dialog-1" })]);
await expect(settledValue(parked)).resolves.toEqual({ settled: false });
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1);
await service.dispose();
});
it("rejects an answer that does not fit the dialog kind and leaves the dialog open", async () => {
const { service, store, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really?");
await expect(service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", "yes")).rejects.toThrow(PendingExtensionDialogValidationError);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1);
await expect(settledValue(parked)).resolves.toEqual({ settled: false });
await service.dispose();
});
});
describe("PiSessionService.cancelDialog", () => {
it("settles a browser cancel with the dialog kind's cancel value", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const confirm = ui.confirm("Proceed?", "Really?");
const select = ui.select("Pick a database", ["pg", "sqlite"]);
const response = await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1");
await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-2");
expect(response.outcome).toMatchObject({ dialogId: "dialog-1", reason: "cancelled" });
await expect(confirm).resolves.toBe(false);
await expect(select).resolves.toBeUndefined();
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toMatchObject([
{ type: "dialog.opened", dialog: { dialogId: "dialog-1" } },
{ type: "dialog.opened", dialog: { dialogId: "dialog-2" } },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" },
{ type: "dialog.closed", dialogId: "dialog-2", reason: "cancelled" },
]);
await service.dispose();
});
it("reports a stale cancel of a dialog that is already gone", async () => {
const { service, fake } = dialogService();
await boundUiContext(service, fake);
const response = await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-gone");
expect(response.result).toBe("stale");
await service.dispose();
});
});
describe("PiSessionService extension dialog timeout", () => {
afterEach(() => {
vi.useRealTimers();
});
it("auto-cancels an unanswered dialog when the daemon default timeout elapses", async () => {
vi.useFakeTimers();
const { service, store, events, fake } = dialogService({ extensionDialogsTimeoutMs: 300_000 });
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really?");
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([
expect.objectContaining({ timeoutAt: "2026-02-01T10:05:00.000Z" }),
]);
await vi.advanceTimersByTimeAsync(300_000);
await expect(parked).resolves.toBe(false);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "timeout" },
]);
await service.dispose();
});
it("honors the extension's own sooner timeout over the daemon default", async () => {
vi.useFakeTimers();
const { service, store, fake } = dialogService({ extensionDialogsTimeoutMs: 300_000 });
const ui = await boundUiContext(service, fake);
const parked = ui.input("Branch name?", undefined, { timeout: 1_000 });
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([
expect.objectContaining({ timeoutAt: "2026-02-01T10:00:01.000Z" }),
]);
await vi.advanceTimersByTimeAsync(1_000);
await expect(parked).resolves.toBeUndefined();
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
await service.dispose();
});
it("waits forever on a zero daemon default unless the extension set a timeout", async () => {
vi.useFakeTimers();
const { service, store, fake } = dialogService({ extensionDialogsTimeoutMs: 0 });
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really?");
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)[0]).not.toHaveProperty("timeoutAt");
await vi.advanceTimersByTimeAsync(60_000_000);
await expect(settledValue(parked)).resolves.toEqual({ settled: false });
await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true);
await expect(parked).resolves.toBe(true);
await service.dispose();
});
});
describe("PiSessionService extension dialog signal", () => {
it("dismisses the dialog with the cancel value when the extension aborts its signal", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const controller = new AbortController();
const parked = ui.select("Pick a database", ["pg", "sqlite"], { signal: controller.signal });
controller.abort();
await expect(parked).resolves.toBeUndefined();
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "cancelled" },
]);
await service.dispose();
});
});
describe("PiSessionService extension dialog run end and teardown", () => {
it("settles run-scoped dialogs as aborted on agent_end but leaves idle dialogs open", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
fake.session.isStreaming = true;
const consent = ui.confirm("Run consent", "Allow this tool call?");
fake.session.isStreaming = false;
const idle = ui.input("Session note?");
fake.emit({ type: "agent_end" });
await expect(consent).resolves.toBe(false);
await expect(settledValue(idle)).resolves.toEqual({ settled: false });
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ dialogId: "dialog-2" })]);
expect(dialogEvents(events).map(({ event }) => event)).toMatchObject([
{ type: "dialog.opened", dialog: { dialogId: "dialog-1" } },
{ type: "dialog.opened", dialog: { dialogId: "dialog-2" } },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" },
]);
await service.dispose();
});
it("settles every dialog as session-ended when the session closes", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
fake.session.isStreaming = true;
const consent = ui.confirm("Run consent", "Allow this tool call?");
fake.session.isStreaming = false;
const idle = ui.input("Session note?");
await service.stop(sessionRef(ACTIVE_SESSION_ID));
await expect(consent).resolves.toBe(false);
await expect(idle).resolves.toBeUndefined();
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toMatchObject([
{ type: "dialog.opened", dialog: { dialogId: "dialog-1" } },
{ type: "dialog.opened", dialog: { dialogId: "dialog-2" } },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "session-ended" },
{ type: "dialog.closed", dialogId: "dialog-2", reason: "session-ended" },
]);
await service.dispose();
});
it("settles every dialog as session-ended when the daemon disposes the session", async () => {
const { service, store, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really?");
await service.dispose();
await expect(parked).resolves.toBe(false);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
});
it("settles the old runtime's dialogs as session-ended when the runtime is replaced", async () => {
const { service, store, events, fake } = dialogService();
const rebinds: ((session: PiAgentSession) => Promise<void>)[] = [];
fake.runtime.setRebindSession = (fn) => {
if (fn !== undefined) rebinds.push(fn);
};
const ui = await boundUiContext(service, fake);
const parked = ui.confirm("Proceed?", "Really?");
const replacement = fakeRuntime(ACTIVE_SESSION_ID);
const rebind = rebinds[0];
if (rebind === undefined) throw new Error("runtime replacement was not armed");
await rebind(replacement.session);
await expect(parked).resolves.toBe(false);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "session-ended" },
]);
// The replacement runtime is bound with a fresh UI context of its own.
expect(replacement.calls.bindExtensions).toHaveLength(1);
await service.dispose();
});
});
describe("PiSessionService extension dialog abort request", () => {
it("settles a parked run-scoped dialog as aborted when an abort is requested", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
fake.session.isStreaming = true;
const consent = ui.confirm("Run consent", "Allow this tool call?");
await service.abort(sessionRef(ACTIVE_SESSION_ID));
await expect(consent).resolves.toBe(false);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" },
]);
const statuses = events.sessionEvents.flatMap(({ event }) => (event.type === "status.update" ? [event.status] : []));
expect(statuses.at(-1)?.pendingDialogs).toBeUndefined();
expect(fake.calls.abort).toBe(1);
await service.dispose();
});
it("settles the dialog before the runtime abort completes, so a parked handler cannot deadlock it", async () => {
const { service, store, fake } = dialogService();
const ui = await boundUiContext(service, fake);
fake.session.isStreaming = true;
// Model pi's agent loop parked behind the dialog handler: the runtime
// abort can only finish once the handler (and so the dialog) has ended.
const healthyAbort: typeof fake.session.abort = () => Promise.resolve();
let releaseAbort: (() => void) | undefined;
fake.session.abort = () =>
new Promise<void>((resolve) => {
releaseAbort = resolve;
});
const consent = ui.confirm("Run consent", "Allow this tool call?");
const aborting = service.abort(sessionRef(ACTIVE_SESSION_ID));
await expect(consent).resolves.toBe(false);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
if (releaseAbort === undefined) throw new Error("runtime abort was not requested");
releaseAbort();
await aborting;
fake.session.abort = healthyAbort;
await service.dispose();
});
it("settles the dialog even when the runtime abort itself fails", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
fake.session.isStreaming = true;
const healthyAbort: typeof fake.session.abort = () => Promise.resolve();
fake.session.abort = () => Promise.reject(new Error("abort blew up"));
const consent = ui.confirm("Run consent", "Allow this tool call?");
await expect(service.abort(sessionRef(ACTIVE_SESSION_ID))).rejects.toThrow("abort blew up");
await expect(consent).resolves.toBe(false);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" },
]);
fake.session.abort = healthyAbort;
await service.dispose();
});
it("leaves idle-opened dialogs parked across an abort request", async () => {
const { service, store, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const idle = ui.input("Session note?");
await service.abort(sessionRef(ACTIVE_SESSION_ID));
await expect(settledValue(idle)).resolves.toEqual({ settled: false });
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([expect.objectContaining({ dialogId: "dialog-1" })]);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
]);
await service.dispose();
});
it("does not close the dialog a second time when agent_end arrives after the abort", async () => {
const { service, events, fake } = dialogService();
const ui = await boundUiContext(service, fake);
fake.session.isStreaming = true;
const consent = ui.confirm("Run consent", "Allow this tool call?");
await service.abort(sessionRef(ACTIVE_SESSION_ID));
fake.emit({ type: "agent_end" });
await expect(consent).resolves.toBe(false);
expect(dialogEvents(events).map(({ event }) => event)).toEqual([
{ type: "dialog.opened", dialog: openDialog(events) },
{ type: "dialog.closed", dialogId: "dialog-1", reason: "aborted" },
]);
await service.dispose();
});
});
describe("PiSessionService extension dialog status projection", () => {
it("reports open dialogs oldest first so a reloading browser rehydrates them", async () => {
const { service, fake } = dialogService();
const ui = await boundUiContext(service, fake);
const before = await service.status(sessionRef(ACTIVE_SESSION_ID));
void ui.confirm("Proceed?", "Really?");
void ui.input("Branch name?");
const during = await service.status(sessionRef(ACTIVE_SESSION_ID));
await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true);
const after = await service.status(sessionRef(ACTIVE_SESSION_ID));
expect(before.pendingDialogs).toBeUndefined();
expect(during.pendingDialogs?.map((dialog) => dialog.dialogId)).toEqual(["dialog-1", "dialog-2"]);
expect(after.pendingDialogs?.map((dialog) => dialog.dialogId)).toEqual(["dialog-2"]);
await service.dispose();
});
});
describe("PiSessionService session_start dialog startup reachability", () => {
/**
* A `session_start` dialog parks session construction before the session
* ever becomes active: the bind below models the issue's probe by awaiting
* a confirm inside extension binding. The dialog must stay reachable
* statusable and answerable in that window, or startup could never be
* unblocked from the browser.
*/
function startupDialogService() {
const harness = dialogService();
const confirmAnswers: (boolean | string | undefined)[] = [];
harness.fake.session.bindExtensions = (bindings) => {
harness.fake.calls.bindExtensions.push(bindings);
if (bindings.uiContext === undefined) return Promise.resolve();
return bindings.uiContext.confirm("Proceed at startup?", "Really?").then((answer) => {
confirmAnswers.push(answer);
});
};
return { ...harness, confirmAnswers };
}
async function parkOnStartupDialog(store: PendingExtensionDialogStore): Promise<void> {
await vi.waitFor(() => {
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toHaveLength(1);
});
}
it("serves status for a session still parked on a session_start dialog", async () => {
const { service, store } = startupDialogService();
const started = service.start("/workspace");
await parkOnStartupDialog(store);
const status = await service.status(sessionRef(ACTIVE_SESSION_ID));
expect(status.pendingDialogs).toEqual([
expect.objectContaining({ dialogId: "dialog-1", kind: "confirm", title: "Proceed at startup?", runScoped: false }),
]);
await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true);
await started;
await service.dispose();
});
it("answers a session_start dialog mid-startup so creation can finish", async () => {
const { service, store, confirmAnswers } = startupDialogService();
const started = service.start("/workspace");
await parkOnStartupDialog(store);
const response = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true);
expect(response.result).toBe("closed");
expect(response.outcome).toMatchObject({ dialogId: "dialog-1", reason: "answered", answer: true });
expect(response.sessionStatus.pendingDialogs ?? []).toEqual([]);
const created = await started;
expect(created.id).toBe(ACTIVE_SESSION_ID);
expect(confirmAnswers).toEqual([true]);
expect(store.pendingDialogs(ACTIVE_SESSION_ID)).toEqual([]);
// Readiness handed the session to the active path: a repeat answer races
// lost against the already-closed dialog instead of erroring.
const repeat = await service.answerDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1", true);
expect(repeat.result).toBe("stale");
await service.dispose();
});
it("cancels a session_start dialog mid-startup with the kind's cancel value", async () => {
const { service, store, confirmAnswers } = startupDialogService();
const started = service.start("/workspace");
await parkOnStartupDialog(store);
const response = await service.cancelDialog(sessionRef(ACTIVE_SESSION_ID), "dialog-1");
expect(response.result).toBe("closed");
expect(response.outcome).toMatchObject({ dialogId: "dialog-1", reason: "cancelled" });
await started;
expect(confirmAnswers).toEqual([false]);
await service.dispose();
});
it("dispose settles a startup-parked dialog instead of blocking behind its timeout", async () => {
const { service, store, events, confirmAnswers } = startupDialogService();
// The open flow registers in pendingSessionOpens, which dispose awaits:
// without settling the dialog first, disposal would ride its timeout.
const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
await parkOnStartupDialog(store);
await service.dispose();
expect(confirmAnswers).toEqual([false]);
const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
expect(closedEvents).toHaveLength(1);
expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
// The released open completed inside dispose's awaited window; the late
// messages read neither hangs nor rejects the test run.
await Promise.allSettled([opening]);
});
it("closing a session whose open is parked on a session_start dialog settles the dialog first", async () => {
const { service, store, events, confirmAnswers } = startupDialogService();
const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
await parkOnStartupDialog(store);
await service.stop(ACTIVE_SESSION_ID);
expect(confirmAnswers).toEqual([false]);
const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
expect(closedEvents).toHaveLength(1);
expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
await Promise.allSettled([opening]);
await service.dispose();
});
});
@@ -246,10 +246,15 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
const outcomes = await failedLookups; const outcomes = await failedLookups;
expect(callsWhileOpening).toBe(1); expect(callsWhileOpening).toBe(1);
expect(outcomes).toHaveLength(2); expect(outcomes).toHaveLength(2);
for (const outcome of outcomes) { const [messagesOutcome, statusOutcome] = outcomes;
expect(outcome.status).toBe("rejected"); expect(messagesOutcome.status).toBe("rejected");
if (outcome.status === "rejected") expect(outcome.reason).toBe(openingError); if (messagesOutcome.status === "rejected") expect(messagesOutcome.reason).toBe(openingError);
} // Status no longer parks behind the in-flight open: a session still
// binding its extensions is statusable (its session_start dialogs must
// stay answerable for startup to be unblockable at all), so the lookup
// resolves from the startup window rather than sharing the open's fate.
expect(statusOutcome.status).toBe("fulfilled");
if (statusOutcome.status === "fulfilled") expect(statusOutcome.value).toMatchObject({ sessionId });
expect(service.activeCount()).toBe(0); expect(service.activeCount()).toBe(0);
expect(failed.calls.abort).toBe(1); expect(failed.calls.abort).toBe(1);
expect(failed.calls.dispose).toBe(1); expect(failed.calls.dispose).toBe(1);
@@ -136,7 +136,7 @@ export function testModel(): NonNullable<PiAgentSession["model"]> {
export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) { export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
const promptCalls: { text: string; options: unknown }[] = []; const promptCalls: { text: string; options: unknown }[] = [];
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = []; const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = []; const bindExtensionCalls: TestExtensionBindings[] = [];
const listeners: ((event: unknown) => void)[] = []; const listeners: ((event: unknown) => void)[] = [];
let extensionUiContext = testExtensionUiContext; let extensionUiContext = testExtensionUiContext;
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls }; const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
+248 -14
View File
@@ -15,6 +15,7 @@ import {
type AgentSessionServices, type AgentSessionServices,
type CreateAgentSessionRuntimeFactory, type CreateAgentSessionRuntimeFactory,
type EditToolDetails, type EditToolDetails,
type ExtensionUIDialogOptions,
type ExtensionUIContext, type ExtensionUIContext,
type ModelRuntime, type ModelRuntime,
type ResourceDiagnostic, type ResourceDiagnostic,
@@ -38,6 +39,10 @@ import type {
AskUserCloseResponse, AskUserCloseResponse,
AskUserOutcome, AskUserOutcome,
AskUserSubmission, AskUserSubmission,
ExtensionDialogAnswer,
ExtensionDialogCloseResponse,
ExtensionDialogKind,
ExtensionDialogOutcome,
SavedPromptAttachment, SavedPromptAttachment,
SessionBulkArchiveResponse, SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse, SessionBulkDeleteArchivedResponse,
@@ -62,6 +67,9 @@ import { siblingWorkspaceCwds, type ProjectWorkspaceCwds } from "../workspaces/p
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js"; import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js"; import { createAskUserToolDefinition, type AskUserInvocation, type AskUserToolDeps } from "./askUserTool.js";
import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js"; import { PendingAskStore, renderAskUserAnswersText, type PendingAskCloseResult, type PendingAskOpenResult } from "./pendingAskStore.js";
import { PendingExtensionDialogStore, type ExtensionDialogCancelReason } from "./pendingExtensionDialogStore.js";
import { ExtensionDialogWaiters, effectiveExtensionDialogTimeoutMs, extensionDialogCancelValue } from "./extensionDialogWaiters.js";
import { DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS } from "../../config.js";
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.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 { 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"; import { buildTranscriptView } from "./subsessionTranscript.js";
@@ -128,6 +136,10 @@ function lookupMatchesActiveSession(ref: PiSessionLookup, active: ActiveSession<
return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd); return !isPiSessionRef(ref) || cwdPathsEqual(active.runtime.cwd, ref.cwd);
} }
function lookupMatchesStartupSession(ref: PiSessionLookup, session: PiAgentSession): boolean {
return !isPiSessionRef(ref) || cwdPathsEqual(session.sessionManager.getCwd(), ref.cwd);
}
type QueuedPromptKind = "steer" | "followUp"; type QueuedPromptKind = "steer" | "followUp";
interface QueuedPrompt { interface QueuedPrompt {
@@ -719,6 +731,14 @@ export interface PiSessionServiceDependencies {
askUserEnabled?: boolean; askUserEnabled?: boolean;
/** Daemon-lifetime open-ask state; defaults to an in-memory store in tests. */ /** Daemon-lifetime open-ask state; defaults to an in-memory store in tests. */
pendingAskStore?: PendingAskStore; pendingAskStore?: PendingAskStore;
/** Daemon-lifetime open-dialog state; defaults to an in-memory store in tests. */
pendingExtensionDialogStore?: PendingExtensionDialogStore;
/**
* How long an extension dialog with no extension-set `timeout` waits for an
* answer before the daemon auto-cancels it; `0` waits forever. A tuning
* knob, not a gate: extension dialogs are always on.
*/
extensionDialogsTimeoutMs?: number;
/** Structured logger for notable runtime events (e.g. spawns). */ /** Structured logger for notable runtime events (e.g. spawns). */
logger?: PiSessionLogger; logger?: PiSessionLogger;
/** Clock seam for cleanup planning tests. */ /** Clock seam for cleanup planning tests. */
@@ -739,6 +759,14 @@ export interface PiSessionServiceDependencies {
export class PiSessionService implements SessionRouteService { export class PiSessionService implements SessionRouteService {
private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>(); private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>();
private readonly pendingSessionOpens = new Map<string, PendingSessionOpen>(); private readonly pendingSessionOpens = new Map<string, PendingSessionOpen>();
/**
* Sessions whose extension binding is still in flight. A `session_start`
* dialog parks that window before the session ever becomes active, so this
* is the only way the dialog answer/cancel and status paths can reach it;
* {@link getOrOpen} never consults it, keeping every other operation gated
* on full readiness.
*/
private readonly startupSessions = new Map<string, PiAgentSession>();
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>(); private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
private readonly heartbeat: NodeJS.Timeout; private readonly heartbeat: NodeJS.Timeout;
private readonly commandService: SessionCommandService<PiAgentSession>; private readonly commandService: SessionCommandService<PiAgentSession>;
@@ -785,6 +813,10 @@ export class PiSessionService implements SessionRouteService {
private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>(); private readonly notificationGenerationBySession = new WeakMap<PiAgentSession, SessionNotificationGeneration>();
private readonly unreadStore: SessionUnreadStore; private readonly unreadStore: SessionUnreadStore;
private readonly pendingAskStore: PendingAskStore; private readonly pendingAskStore: PendingAskStore;
private readonly pendingExtensionDialogStore: PendingExtensionDialogStore;
private readonly extensionDialogsTimeoutMs: number;
/** The parked extension Promise resolvers behind the store's open dialogs. */
private readonly dialogWaiters = new ExtensionDialogWaiters();
private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined; private readonly catalogRefreshStatus: CatalogRefreshStatus | undefined;
private readonly unreadPublicationRetryInitialMs: number; private readonly unreadPublicationRetryInitialMs: number;
private readonly pendingUnreadMutations: SessionUnreadMutation[] = []; private readonly pendingUnreadMutations: SessionUnreadMutation[] = [];
@@ -807,6 +839,8 @@ export class PiSessionService implements SessionRouteService {
this.notificationStore = deps.notificationStore ?? new SessionNotificationStore(); this.notificationStore = deps.notificationStore ?? new SessionNotificationStore();
this.unreadStore = deps.unreadStore ?? new SessionUnreadStore(); this.unreadStore = deps.unreadStore ?? new SessionUnreadStore();
this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore(); this.pendingAskStore = deps.pendingAskStore ?? new PendingAskStore();
this.pendingExtensionDialogStore = deps.pendingExtensionDialogStore ?? new PendingExtensionDialogStore();
this.extensionDialogsTimeoutMs = deps.extensionDialogsTimeoutMs ?? DEFAULT_EXTENSION_DIALOGS_TIMEOUT_MS;
this.catalogRefreshStatus = deps.catalogRefreshStatus; this.catalogRefreshStatus = deps.catalogRefreshStatus;
this.unreadPublicationRetryInitialMs = Math.max( this.unreadPublicationRetryInitialMs = Math.max(
0, 0,
@@ -971,15 +1005,20 @@ export class PiSessionService implements SessionRouteService {
this.clearUnreadPublicationRetry(); this.clearUnreadPublicationRetry();
clearInterval(this.heartbeat); clearInterval(this.heartbeat);
this.clearCompactionDrainTimers(); this.clearCompactionDrainTimers();
// Same startup-park hazard as closeActive(): settle `session_start` dialogs
// of sessions still binding extensions before awaiting their pending opens.
for (const sessionId of this.startupSessions.keys()) this.endSessionExtensionDialogs(sessionId);
const pendingOpens = this.pendingSessionOpenPromises(); const pendingOpens = this.pendingSessionOpenPromises();
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens); if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const activeSessions = Array.from(new Set(this.active.values())); const activeSessions = Array.from(new Set(this.active.values()));
for (const active of activeSessions) { for (const active of activeSessions) {
this.forgetUnreadActivity(active.runtime.session); this.forgetUnreadActivity(active.runtime.session);
this.pendingAskStore.forgetSession(active.runtime.session.sessionId); this.pendingAskStore.forgetSession(active.runtime.session.sessionId);
this.endSessionExtensionDialogs(active.runtime.session.sessionId);
} }
this.active.clear(); this.active.clear();
this.pendingSessionOpens.clear(); this.pendingSessionOpens.clear();
this.startupSessions.clear();
this.activities.clear(); this.activities.clear();
this.compactionPromptQueues.clear(); this.compactionPromptQueues.clear();
this.authLossWarnings.clear(); this.authLossWarnings.clear();
@@ -1269,6 +1308,133 @@ export class PiSessionService implements SessionRouteService {
this.publishStatus(session); this.publishStatus(session);
} }
/**
* Record the user's answer to an open extension dialog and resolve the
* extension's parked Promise with it. Unlike an ask, nothing is delivered to
* the model: the waiter is extension code inside an already in-flight run
* (or an idle handler), so no custom message and no turn are triggered.
*/
async answerDialog(ref: PiSessionLookup, dialogId: string, value: ExtensionDialogAnswer): Promise<ExtensionDialogCloseResponse> {
await this.assertWritable(ref);
const session = await this.sessionForStatusOrDialogClose(ref);
const result = this.pendingExtensionDialogStore.answer(session.sessionId, dialogId, value);
if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) };
const { outcome } = result;
this.publishDialogClosed(session.sessionId, outcome);
// `value` is what the store validated and recorded as the outcome's answer.
this.dialogWaiters.settleWithAnswer(dialogId, value);
this.publishStatus(session);
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
}
/** Close an open extension dialog without an answer; the extension's wait settles with its kind's cancel value. */
async cancelDialog(ref: PiSessionLookup, dialogId: string): Promise<ExtensionDialogCloseResponse> {
await this.assertWritable(ref);
const session = await this.sessionForStatusOrDialogClose(ref);
const result = this.pendingExtensionDialogStore.cancel(session.sessionId, dialogId, "cancelled");
if (result.status === "stale") return { result: "stale", sessionStatus: this.statusFromSession(session) };
const { outcome } = result;
this.publishDialogClosed(session.sessionId, outcome);
this.dialogWaiters.settleWithCancelValue(dialogId);
this.publishStatus(session);
return { result: "closed", outcome, sessionStatus: this.statusFromSession(session) };
}
/**
* Implement one `ctx.ui.select()`/`confirm()`/`input()` call from extension
* code: open the store record, tell the browsers, and park a Promise that
* settles when the browser answers or cancels, the extension's own
* `signal`/`timeout` dismisses the dialog, the daemon default timeout
* elapses, or the runtime goes away. `store.open` validates the dialog, so a
* malformed one rejects the extension's call rather than rendering garbage.
* `async` so a rejected dialog becomes a rejection rather than a synchronous
* throw from a promise-returning method.
*/
private async openExtensionDialog(
session: PiAgentSession,
request: { kind: ExtensionDialogKind; title: string; message?: string | undefined; options?: string[] | undefined; placeholder?: string | undefined },
opts: ExtensionUIDialogOptions | undefined,
): Promise<boolean | string | undefined> {
const signal = opts?.signal;
// A pre-aborted signal dismisses the dialog before it ever opens.
if (signal?.aborted === true) return extensionDialogCancelValue(request.kind);
const timeoutMs = effectiveExtensionDialogTimeoutMs(opts?.timeout, this.extensionDialogsTimeoutMs);
const dialog = this.pendingExtensionDialogStore.open({
sessionId: session.sessionId,
kind: request.kind,
title: request.title,
...(request.message === undefined ? {} : { message: request.message }),
...(request.options === undefined ? {} : { options: request.options }),
...(request.placeholder === undefined ? {} : { placeholder: request.placeholder }),
...(timeoutMs === undefined ? {} : { timeoutMs }),
runScoped: session.isStreaming,
});
this.events.publish(session.sessionId, { type: "dialog.opened", dialog });
this.publishStatus(session);
return this.dialogWaiters.park(dialog, {
...(timeoutMs === undefined ? {} : { timeoutMs }),
...(signal === undefined ? {} : { signal }),
onTrigger: (reason) => {
if (this.closeExtensionDialogFromTrigger(session.sessionId, dialog.dialogId, reason)) this.publishStatusForSessionId(session.sessionId);
},
});
}
/**
* Close a dialog whose wait ended without the browser (timeout, signal
* abort, run end, runtime teardown) and settle its parked Promise. Returns
* whether this call closed the dialog; a stale close means a browser answer
* or an earlier trigger already settled everything.
*/
private closeExtensionDialogFromTrigger(sessionId: string, dialogId: string, reason: ExtensionDialogCancelReason): boolean {
const result = this.pendingExtensionDialogStore.cancel(sessionId, dialogId, reason);
if (result.status !== "closed") return false;
this.publishDialogClosed(sessionId, result.outcome);
this.dialogWaiters.settleWithCancelValue(dialogId);
return true;
}
/**
* Settle the session's run-scoped dialogs as `"aborted"`. Runs at
* abort-request time (a user abort parks the agent loop behind the dialog
* handler, so `agent_end` would never arrive on its own) and again from
* the `agent_end` observer as the run-crash backstop the store makes the
* second settlement a stale no-op. Idle-opened dialogs (a `session_start`
* probe, say) are not run-scoped and survive, because their waiter
* outlives the run.
*/
private abortRunScopedExtensionDialogs(sessionId: string): void {
let closedAny = false;
for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
if (dialog.runScoped) closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "aborted") || closedAny;
}
if (closedAny) this.publishStatusForSessionId(sessionId);
}
/**
* Settle every dialog of the session as `"session-ended"`: the runtime
* whose extension code is parked on them is being closed, replaced, or
* disposed, so those Promises would otherwise never settle.
*/
private endSessionExtensionDialogs(sessionId: string): void {
let closedAny = false;
for (const dialog of this.pendingExtensionDialogStore.pendingDialogs(sessionId)) {
closedAny = this.closeExtensionDialogFromTrigger(sessionId, dialog.dialogId, "session-ended") || closedAny;
}
// Publishes only while the session is still (or already re-)registered as
// active, so teardown paths stay silent and runtime replacement refreshes.
if (closedAny) this.publishStatusForSessionId(sessionId);
}
private publishDialogClosed(sessionId: string, outcome: ExtensionDialogOutcome): void {
this.events.publish(sessionId, {
type: "dialog.closed",
dialogId: outcome.dialogId,
reason: outcome.reason,
...(outcome.answer === undefined ? {} : { answer: outcome.answer }),
});
}
/** /**
* Publish status for a session known only by id, as the ask tools are: they * 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. * run inside the session's own runtime, so the active entry is the session.
@@ -1631,7 +1797,7 @@ export class PiSessionService implements SessionRouteService {
} }
async status(ref: PiSessionLookup): Promise<ClientSessionStatus> { async status(ref: PiSessionLookup): Promise<ClientSessionStatus> {
return this.statusFromSession(await this.getOrOpen(ref)); return this.statusFromSession(await this.sessionForStatusOrDialogClose(ref));
} }
/** /**
@@ -2214,6 +2380,12 @@ export class PiSessionService implements SessionRouteService {
const sessionId = active.runtime.session.sessionId; const sessionId = active.runtime.session.sessionId;
this.clearCompactionPromptQueue(sessionId); this.clearCompactionPromptQueue(sessionId);
clearSessionQueue(active.runtime.session); clearSessionQueue(active.runtime.session);
// Settle run-scoped dialogs now, at abort-request time: pi's agent loop
// waits for a parked `tool_call` dialog handler before it can emit
// `agent_end`, so leaving settlement to the `agent_end` observer would
// strand the dialog until its timeout. Settling before the runtime abort
// also means a failing or hung abort cannot strand the parked waiter.
this.abortRunScopedExtensionDialogs(sessionId);
try { try {
await this.abortSessionOperations(active.runtime.session); await this.abortSessionOperations(active.runtime.session);
this.publishActivity(active.runtime.session, "stopped", "idle"); this.publishActivity(active.runtime.session, "stopped", "idle");
@@ -2423,6 +2595,10 @@ export class PiSessionService implements SessionRouteService {
} }
private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise<void> { private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise<void> {
// A session whose open is parked on a `session_start` dialog holds its
// pending open until the dialog settles; settle it first so closing cannot
// block behind the dialog timeout (which `0` makes infinite).
if (this.startupSessions.has(sessionId)) this.endSessionExtensionDialogs(sessionId);
const pendingOpens = this.pendingSessionOpenPromises(sessionId); const pendingOpens = this.pendingSessionOpenPromises(sessionId);
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens); if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const active = this.active.get(sessionId); const active = this.active.get(sessionId);
@@ -2438,6 +2614,9 @@ export class PiSessionService implements SessionRouteService {
// An open ask is meaningful only while the runtime that posted it exists: no // 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. // one is left to receive the answers, so it is dropped without an outcome.
this.pendingAskStore.forgetSession(sessionId); this.pendingAskStore.forgetSession(sessionId);
// Open dialogs share that stance, but their extension waiters are parked
// Promises inside the dying runtime: settle them rather than dropping them.
this.endSessionExtensionDialogs(sessionId);
this.active.delete(sessionId); this.active.delete(sessionId);
this.activities.delete(sessionId); this.activities.delete(sessionId);
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd()); this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
@@ -2559,6 +2738,28 @@ export class PiSessionService implements SessionRouteService {
return undefined; return undefined;
} }
private startupSessionForLookup(ref: PiSessionLookup): PiAgentSession | undefined {
const sessionId = sessionIdFromLookup(ref);
const exact = this.startupSessions.get(sessionId);
if (exact !== undefined && lookupMatchesStartupSession(ref, exact)) return exact;
for (const [candidateId, session] of this.startupSessions.entries()) {
if (candidateId.startsWith(sessionId) && lookupMatchesStartupSession(ref, session)) return session;
}
return undefined;
}
/**
* The session to serve a read-only status or a dialog close for, while it
* can still be found: active first, then still starting up, and only then
* the on-demand open path (which a stale close on an idle session needs for
* its status projection).
*/
private async sessionForStatusOrDialogClose(ref: PiSessionLookup): Promise<PiAgentSession> {
const reachable = this.activeForLookup(ref)?.runtime.session ?? this.startupSessionForLookup(ref);
if (reachable !== undefined) return reachable;
return this.getOrOpen(ref);
}
/** /**
* Construct a session while telling waiting browsers which phase of startup * Construct a session while telling waiting browsers which phase of startup
* they are waiting on. The reporting wraps the *whole* construction rather * they are waiting on. The reporting wraps the *whole* construction rather
@@ -2647,6 +2848,10 @@ export class PiSessionService implements SessionRouteService {
this.notificationGenerationBySession.set(session, candidateGeneration); this.notificationGenerationBySession.set(session, candidateGeneration);
} }
this.bindRuntime(active, session); this.bindRuntime(active, session);
// The runtime being replaced parked every dialog the store still
// holds for this session; settle those waits before the new
// runtime's extensions can open fresh dialogs under the same id.
this.endSessionExtensionDialogs(boundSession.sessionId);
boundSession = session; boundSession = session;
await this.bindSessionExtensions(session, candidateGeneration); await this.bindSessionExtensions(session, candidateGeneration);
if (candidateGeneration !== undefined) { if (candidateGeneration !== undefined) {
@@ -2679,6 +2884,9 @@ export class PiSessionService implements SessionRouteService {
} }
active.unsubscribe(); active.unsubscribe();
this.forgetUnreadActivity(boundSession); this.forgetUnreadActivity(boundSession);
// A session_start dialog may already be parked when a later startup
// step fails; its waiter dies with the runtime being torn down here.
this.endSessionExtensionDialogs(boundSession.sessionId);
let removedActive = false; let removedActive = false;
for (const [sessionId, candidate] of this.active.entries()) { for (const [sessionId, candidate] of this.active.entries()) {
if (candidate !== active) continue; if (candidate !== active) continue;
@@ -2705,15 +2913,24 @@ export class PiSessionService implements SessionRouteService {
generation: SessionNotificationGeneration | undefined, generation: SessionNotificationGeneration | undefined,
): Promise<void> { ): Promise<void> {
const uiContext = this.sessionUiContext(session, generation); const uiContext = this.sessionUiContext(session, generation);
await session.bindExtensions({ // A `session_start` hook can park this bind on a dialog the browser has
uiContext, // not answered yet. On the initial create/open path the session becomes
mode: "rpc", // active only after this returns, so register it for the duration: the
onError: (error) => { // answer that unblocks startup has to be reachable while it waits.
const message = `${error.extensionPath}: ${error.error}`; this.startupSessions.set(session.sessionId, session);
this.publishActivity(session, "extension error", "error", message); try {
this.events.publish(session.sessionId, { type: "session.error", message }); await session.bindExtensions({
}, uiContext,
}); mode: "rpc",
onError: (error) => {
const message = `${error.extensionPath}: ${error.error}`;
this.publishActivity(session, "extension error", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
},
});
} finally {
this.startupSessions.delete(session.sessionId);
}
} }
private replaceSessionNotificationContext(session: PiAgentSession, generation: SessionNotificationGeneration): void { private replaceSessionNotificationContext(session: PiAgentSession, generation: SessionNotificationGeneration): void {
@@ -2744,13 +2961,27 @@ export class PiSessionService implements SessionRouteService {
notificationId: added.notification.id, notificationId: added.notification.id,
}); });
}; };
// PI WEB owns the browser-facing notification and text-formatting // PI WEB owns the browser-facing dialog, notification, and text-formatting
// boundaries. Delegate every other UI method to Pi's headless defaults so // boundaries: the three dialog primitives park daemon-held Promises that
// unsupported dialogs cancel safely instead of hanging. // the browser answers, while every other UI method delegates to Pi's
// headless defaults so unsupported surfaces cancel safely instead of
// hanging.
return new Proxy(baseUiContext, { return new Proxy(baseUiContext, {
get(target, property, receiver): unknown { get: (target, property, receiver): unknown => {
if (property === "notify") return notify; if (property === "notify") return notify;
if (property === "theme") return plainTextTheme; if (property === "theme") return plainTextTheme;
if (property === "confirm") {
return (title: string, message: string, opts?: ExtensionUIDialogOptions) =>
this.openExtensionDialog(session, { kind: "confirm", title, message }, opts);
}
if (property === "select") {
return (title: string, options: string[], opts?: ExtensionUIDialogOptions) =>
this.openExtensionDialog(session, { kind: "select", title, options }, opts);
}
if (property === "input") {
return (title: string, placeholder: string | undefined, opts?: ExtensionUIDialogOptions) =>
this.openExtensionDialog(session, { kind: "input", title, placeholder }, opts);
}
const value: unknown = Reflect.get(target, property, receiver); const value: unknown = Reflect.get(target, property, receiver);
return value; return value;
}, },
@@ -2903,6 +3134,7 @@ export class PiSessionService implements SessionRouteService {
this.events.publish(session.sessionId, toClientEvent(event)); this.events.publish(session.sessionId, toClientEvent(event));
this.publishActivityForEvent(session, event); this.publishActivityForEvent(session, event);
const eventType = getString(event, "type"); const eventType = getString(event, "type");
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId); if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId); if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
this.publishStatus(session); this.publishStatus(session);
@@ -3300,6 +3532,7 @@ export class PiSessionService implements SessionRouteService {
const contextUsage = session.getContextUsage(); const contextUsage = session.getContextUsage();
const warnings = this.warningsForSession(session); const warnings = this.warningsForSession(session);
const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId); const pendingAsk = this.pendingAskStore.pendingAsk(session.sessionId);
const pendingDialogs = this.pendingExtensionDialogStore.pendingDialogs(session.sessionId);
return { return {
sessionId: session.sessionId, sessionId: session.sessionId,
persisted: sessionFileExists(session.sessionFile), persisted: sessionFileExists(session.sessionFile),
@@ -3316,6 +3549,7 @@ export class PiSessionService implements SessionRouteService {
...(contextUsage === undefined ? {} : { contextUsage }), ...(contextUsage === undefined ? {} : { contextUsage }),
...(warnings.length === 0 ? {} : { warnings }), ...(warnings.length === 0 ? {} : { warnings }),
...(pendingAsk === undefined ? {} : { pendingAsk }), ...(pendingAsk === undefined ? {} : { pendingAsk }),
...(pendingDialogs.length === 0 ? {} : { pendingDialogs }),
}; };
} }
+109 -1
View File
@@ -2,10 +2,12 @@ import { resolve } from "node:path";
import Fastify, { type FastifyInstance } from "fastify"; import Fastify, { type FastifyInstance } from "fastify";
import fastifyWebsocket from "@fastify/websocket"; import fastifyWebsocket from "@fastify/websocket";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
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 { ASK_USER_ID_MAX_LENGTH, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, SESSION_TREE_CUSTOM_INSTRUCTIONS_MAX_LENGTH, SESSION_UNREAD_CATALOG_ID_MAX_LENGTH } from "../../shared/apiTypes.js";
import type { import type {
AskUserCloseResponse, AskUserCloseResponse,
AskUserSubmission, AskUserSubmission,
ExtensionDialogAnswer,
ExtensionDialogCloseResponse,
MessagePage, MessagePage,
SessionBulkArchiveResponse, SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse, SessionBulkDeleteArchivedResponse,
@@ -434,6 +436,97 @@ describe("session routes", () => {
} }
}); });
it("parses dialog answers and cancels 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 answered = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/dialogs/answer",
payload: { cwd: "/repo/./", dialogId: "dialog-1", value: true },
});
const answeredText = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/dialogs/answer",
payload: { dialogId: "dialog-2", value: "typed text" },
});
const cancelled = await routeApp.inject({
method: "POST",
url: "/sessions/session-1/dialogs/cancel",
payload: { dialogId: "dialog-3" },
});
expect(answered.statusCode).toBe(200);
expect(answered.json()).toMatchObject({ result: "closed", sessionStatus: { sessionId: "session-1" } });
expect(answeredText.statusCode).toBe(200);
expect(routeService.answerDialogCalls).toEqual([
{ lookup: { id: "session-1", cwd: resolve("/repo") }, dialogId: "dialog-1", value: true },
{ lookup: "session-1", dialogId: "dialog-2", value: "typed text" },
]);
expect(cancelled.statusCode).toBe(200);
expect(cancelled.json()).toMatchObject({ result: "stale" });
expect(routeService.cancelDialogCalls).toEqual([{ lookup: "session-1", dialogId: "dialog-3" }]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("rejects malformed dialog 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 malformedAnswers: Record<string, unknown>[] = [
{ value: true },
{ dialogId: "", value: true },
{ dialogId: "x".repeat(EXTENSION_DIALOG_ID_MAX_LENGTH + 1), value: true },
{ dialogId: "dialog-1" },
{ dialogId: "dialog-1", value: 7 },
{ dialogId: "dialog-1", value: ["option"] },
{ dialogId: "dialog-1", value: "x".repeat(EXTENSION_DIALOG_INPUT_MAX_LENGTH + 1) },
];
try {
for (const payload of malformedAnswers) {
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/dialogs/answer", payload });
expect(response.statusCode).toBe(400);
}
const cancelWithoutDialogId = await routeApp.inject({ method: "POST", url: "/sessions/session-1/dialogs/cancel", payload: {} });
expect(cancelWithoutDialogId.statusCode).toBe(400);
expect(routeService.answerDialogCalls).toEqual([]);
expect(routeService.cancelDialogCalls).toEqual([]);
} finally {
await routeService.dispose();
await routeApp.close();
}
});
it("maps a missing session on a dialog answer to 404", async () => {
const routeApp = Fastify({ logger: false });
await routeApp.register(fastifyWebsocket);
const eventHub = new SessionEventHub();
const routeService = new CapturingRouteSessionService();
routeService.dialogError = new Error("Session not found");
registerSessionRoutes(routeApp, routeService, eventHub);
try {
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/dialogs/answer", payload: { dialogId: "dialog-1", value: true } });
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 () => { 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" } }); const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } });
@@ -834,8 +927,11 @@ class CapturingRouteSessionService implements SessionRouteService {
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = []; readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = []; readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = [];
readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = []; readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = [];
readonly answerDialogCalls: { lookup: SessionRouteLookup; dialogId: string; value: ExtensionDialogAnswer }[] = [];
readonly cancelDialogCalls: { lookup: SessionRouteLookup; dialogId: string }[] = [];
readonly startCalls: { cwd: string; startupToken: string | undefined }[] = []; readonly startCalls: { cwd: string; startupToken: string | undefined }[] = [];
askError: Error | undefined; askError: Error | undefined;
dialogError: Error | undefined;
reloadError: Error | undefined; reloadError: Error | undefined;
clearQueueError: Error | undefined; clearQueueError: Error | undefined;
@@ -851,6 +947,18 @@ class CapturingRouteSessionService implements SessionRouteService {
return Promise.resolve({ result: "stale", sessionStatus: idleStatus(lookup) }); return Promise.resolve({ result: "stale", sessionStatus: idleStatus(lookup) });
} }
answerDialog(lookup: SessionRouteLookup, dialogId: string, value: ExtensionDialogAnswer): Promise<ExtensionDialogCloseResponse> {
if (this.dialogError !== undefined) return Promise.reject(this.dialogError);
this.answerDialogCalls.push({ lookup, dialogId, value });
return Promise.resolve({ result: "closed", sessionStatus: idleStatus(lookup) });
}
cancelDialog(lookup: SessionRouteLookup, dialogId: string): Promise<ExtensionDialogCloseResponse> {
if (this.dialogError !== undefined) return Promise.reject(this.dialogError);
this.cancelDialogCalls.push({ lookup, dialogId });
return Promise.resolve({ result: "stale", sessionStatus: idleStatus(lookup) });
}
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> { cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
this.cleanupPreviewCalls.push(request); this.cleanupPreviewCalls.push(request);
return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } }); return Promise.resolve({ generatedAt: "2026-06-25T00:00:00.000Z", thresholds: request.thresholds, projects: [], totals: { archiveCount: 0, deleteCount: 0 } });
+41 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
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 { ASK_USER_ID_MAX_LENGTH, ASK_USER_OPTION_LIMIT, ASK_USER_OTHER_TEXT_MAX_LENGTH, ASK_USER_QUESTION_LIMIT, EXTENSION_DIALOG_ID_MAX_LENGTH, EXTENSION_DIALOG_INPUT_MAX_LENGTH, 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 ExtensionDialogAnswerRequest, type ExtensionDialogCancelRequest, type SessionBulkMutationRequest, type SessionBulkMutationRef, type SessionCleanupRequest, type SessionTreeNavigateRequest, type SessionTreeSummaryChoice, type SessionUnreadAcknowledgeRequest } from "../../shared/apiTypes.js";
import { projectBrowserMessageResponse } from "../browserMessageProjection.js"; import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
import { normalizeRequestCwd } from "../workingDirectory.js"; import { normalizeRequestCwd } from "../workingDirectory.js";
import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { SessionEventHub } from "../realtime/sessionEventHub.js";
@@ -289,6 +289,26 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
} }
}); });
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dialogId?: unknown; value?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/dialogs/answer`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const answer = extensionDialogAnswerFromBody(body);
return await sessions.answerDialog(sessionLookupFromBody(request.params.sessionId, body), answer.dialogId, answer.value);
} catch (error) {
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
}
});
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dialogId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/dialogs/cancel`, async (request, reply) => {
try {
const body = requireRecord(request.body);
const cancel = extensionDialogCancelFromBody(body);
return await sessions.cancelDialog(sessionLookupFromBody(request.params.sessionId, body), cancel.dialogId);
} 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) => { app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown; dismissId?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/warnings/dismiss`, async (request, reply) => {
try { try {
const body = optionalRecord(request.body); const body = optionalRecord(request.body);
@@ -547,6 +567,26 @@ function requireBoundedId(value: unknown, field: string): string {
return requireNonEmptyBoundedString(value, field, ASK_USER_ID_MAX_LENGTH); return requireNonEmptyBoundedString(value, field, ASK_USER_ID_MAX_LENGTH);
} }
/**
* Shape-check one dialog answer. Only transport-level checks belong here:
* whether the value fits the answered dialog's kind is the pending dialog
* store's job, since only it knows the open dialog.
*/
function extensionDialogAnswerFromBody(body: Record<string, unknown>): ExtensionDialogAnswerRequest {
const dialogId = requireNonEmptyBoundedString(body["dialogId"], "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH);
const value = body["value"];
if (typeof value === "boolean") return { dialogId, value };
if (typeof value === "string") {
if (value.length > EXTENSION_DIALOG_INPUT_MAX_LENGTH) throw new Error("value field is too long");
return { dialogId, value };
}
throw new Error("value field must be a string or a boolean");
}
function extensionDialogCancelFromBody(body: Record<string, unknown>): ExtensionDialogCancelRequest {
return { dialogId: requireNonEmptyBoundedString(body["dialogId"], "dialogId", EXTENSION_DIALOG_ID_MAX_LENGTH) };
}
function optionalRecord(value: unknown): Record<string, unknown> { function optionalRecord(value: unknown): Record<string, unknown> {
if (value === undefined || value === null) return {}; if (value === undefined || value === null) return {};
return requireRecord(value); return requireRecord(value);
+4
View File
@@ -1,6 +1,8 @@
import type { import type {
AskUserCloseResponse, AskUserCloseResponse,
AskUserSubmission, AskUserSubmission,
ExtensionDialogAnswer,
ExtensionDialogCloseResponse,
SavedPromptAttachment, SavedPromptAttachment,
SessionBulkArchiveResponse, SessionBulkArchiveResponse,
SessionBulkDeleteArchivedResponse, SessionBulkDeleteArchivedResponse,
@@ -60,6 +62,8 @@ export interface SessionRouteService {
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>; clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
submitAsk(ref: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse>; submitAsk(ref: SessionRouteLookup, askId: string, submission: AskUserSubmission): Promise<AskUserCloseResponse>;
cancelAsk(ref: SessionRouteLookup, askId: string): Promise<AskUserCloseResponse>; cancelAsk(ref: SessionRouteLookup, askId: string): Promise<AskUserCloseResponse>;
answerDialog(ref: SessionRouteLookup, dialogId: string, value: ExtensionDialogAnswer): Promise<ExtensionDialogCloseResponse>;
cancelDialog(ref: SessionRouteLookup, dialogId: string): Promise<ExtensionDialogCloseResponse>;
dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>; dismissWarning(ref: SessionRouteLookup, dismissId: string): Promise<ClientSessionStatus>;
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>; availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>; setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
+118
View File
@@ -103,6 +103,13 @@ export interface PiWebConfigValues {
* tool. On by default; set to `false` to remove the tool from the runtime. * tool. On by default; set to `false` to remove the tool from the runtime.
*/ */
askUser?: boolean; askUser?: boolean;
/**
* How long an extension dialog may wait for an answer before the daemon
* auto-cancels it, in milliseconds. Applies only when the extension set no
* `timeout` of its own (the sooner of the two wins); `0` waits forever.
* Tuning knob only extension dialogs are always enabled.
*/
extensionDialogsTimeoutMs?: number;
/** Desired Pi-compatible agent profile and companion CLI (Pi by default). */ /** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
agent?: PiWebAgentConfig; agent?: PiWebAgentConfig;
} }
@@ -591,6 +598,109 @@ export interface AskUserCloseResponse {
sessionStatus: SessionStatus; sessionStatus: SessionStatus;
} }
/** Length bound for extension-dialog ids. */
export const EXTENSION_DIALOG_ID_MAX_LENGTH = 128;
/** Length bound for extension-authored dialog prose: titles, messages, options, placeholders. */
export const EXTENSION_DIALOG_TEXT_MAX_LENGTH = 1_000;
/** Largest option list one `select` dialog may offer. */
export const EXTENSION_DIALOG_OPTION_LIMIT = 24;
/** Length bound for the text a user types into an `input` dialog. */
export const EXTENSION_DIALOG_INPUT_MAX_LENGTH = 4_000;
/** Which extension UI dialog primitive a pending dialog belongs to. */
export type ExtensionDialogKind = "confirm" | "select" | "input";
/**
* The value a user gave in an extension dialog: a boolean for `confirm`, the
* chosen option for `select`, the typed text for `input`. Absent when the
* dialog closed without an answer.
*/
export type ExtensionDialogAnswer = boolean | string;
/**
* Why a dialog stopped being open. `"answered"` carries an
* {@link ExtensionDialogAnswer}; every other reason is a close without one.
*/
export type ExtensionDialogCloseReason = "answered" | "cancelled" | "timeout" | "aborted" | "session-ended";
/**
* One open extension dialog of a session, opened by `ctx.ui.confirm()`,
* `ctx.ui.select()`, or `ctx.ui.input()`. Daemon-owned and reported in
* {@link SessionStatus.pendingDialogs}, so a reconnecting or reloading browser
* rehydrates it without depending on having seen the `dialog.opened` event.
*
* Unlike asks, several dialogs may be open per session at once: each dialog is
* an independent blocking wait inside extension code, so opening never
* supersedes an existing one.
*/
export interface PendingExtensionDialog {
dialogId: string;
kind: ExtensionDialogKind;
title: string;
/** Supporting line of a `confirm` dialog. */
message?: string;
/** Offered choices of a `select` dialog. */
options?: string[];
/** Placeholder text of an `input` dialog. */
placeholder?: string;
askedAt: string;
/**
* When the dialog auto-cancels, as ISO: the sooner of the extension's own
* `timeout` and the daemon's `extensionDialogsTimeoutMs` default. Absent
* when the dialog waits forever.
*/
timeoutAt?: string;
/** Opened while a run was in flight, so `agent_end` settles it as `"aborted"`. */
runScoped: boolean;
}
/**
* The complete result of a closed extension dialog. Unlike an ask outcome it
* stays small the dialog itself is not embedded, because a settled card is a
* browser-local record that stays until the user dismisses it; reloads
* rehydrate open dialogs from {@link SessionStatus.pendingDialogs} alone.
*/
export interface ExtensionDialogOutcome {
dialogId: string;
reason: ExtensionDialogCloseReason;
/** Present only when `reason` is `"answered"`. */
answer?: ExtensionDialogAnswer;
askedAt: string;
closedAt: string;
}
/**
* Browser request to answer an open extension dialog with the user's value.
* `cwd` rides along as the standard session-lookup field, as on every other
* session route; whether the value fits the dialog's kind is the store's call,
* so an ill-fitting answer is a 400 that leaves the dialog open.
*/
export interface ExtensionDialogAnswerRequest {
cwd?: string;
dialogId: string;
value: ExtensionDialogAnswer;
}
/** Browser request to dismiss an open extension dialog without an answer. */
export interface ExtensionDialogCancelRequest {
cwd?: string;
dialogId: string;
}
/**
* Result of the browser answering or cancelling an extension dialog. Mirrors
* {@link AskUserCloseResponse}: `"stale"` is an ordinary lost race another
* browser, a timeout, or a teardown closed the dialog first not an error.
* The browser drops its card and trusts `sessionStatus`, which is returned in
* both cases so closing a dialog needs no follow-up status request.
*/
export interface ExtensionDialogCloseResponse {
result: "closed" | "stale";
/** Present only when this call is the one that closed the dialog. */
outcome?: ExtensionDialogOutcome;
sessionStatus: SessionStatus;
}
/** /**
* Progress of the session startup window, where the daemon is still * Progress of the session startup window, where the daemon is still
* constructing the agent session and no `PiAgentSession` exists yet, so * constructing the agent session and no `PiAgentSession` exists yet, so
@@ -773,6 +883,12 @@ export interface SessionStatus {
* user. Daemon-owned, so it survives browser reload and web/API restarts. * user. Daemon-owned, so it survives browser reload and web/API restarts.
*/ */
pendingAsk?: PendingAskUser; pendingAsk?: PendingAskUser;
/**
* The session's open extension dialogs, oldest first, when any are waiting
* for the user. Daemon-owned, so they survive browser reload and web/API
* restarts. Several may be open at once; the UI presents them as a queue.
*/
pendingDialogs?: PendingExtensionDialog[];
} }
export interface WorkspaceActivity { export interface WorkspaceActivity {
@@ -1155,6 +1271,8 @@ type SessionUiEventBody =
| { type: "session.error"; message: string } | { type: "session.error"; message: string }
| { type: "ask.opened"; ask: PendingAskUser } | { type: "ask.opened"; ask: PendingAskUser }
| { type: "ask.closed"; askId: string; reason: AskUserCloseReason } | { type: "ask.closed"; askId: string; reason: AskUserCloseReason }
| { type: "dialog.opened"; dialog: PendingExtensionDialog }
| { type: "dialog.closed"; dialogId: string; reason: ExtensionDialogCloseReason; answer?: ExtensionDialogAnswer }
| { type: "session.name"; sessionId: string; name?: string } | { type: "session.name"; sessionId: string; name?: string }
| { type: "session.created"; session: SessionInfo } | { type: "session.created"; session: SessionInfo }
| { type: "pi.event"; eventType: string }; | { type: "pi.event"; eventType: string };
+2
View File
@@ -70,6 +70,8 @@ export const FEDERATED_HTTP_ROUTES = [
{ method: "POST", path: "/sessions/:sessionId/queue/clear" }, { method: "POST", path: "/sessions/:sessionId/queue/clear" },
{ method: "POST", path: "/sessions/:sessionId/ask/submit" }, { method: "POST", path: "/sessions/:sessionId/ask/submit" },
{ method: "POST", path: "/sessions/:sessionId/ask/cancel" }, { method: "POST", path: "/sessions/:sessionId/ask/cancel" },
{ method: "POST", path: "/sessions/:sessionId/dialogs/answer" },
{ method: "POST", path: "/sessions/:sessionId/dialogs/cancel" },
{ method: "POST", path: "/sessions/:sessionId/warnings/dismiss" }, { method: "POST", path: "/sessions/:sessionId/warnings/dismiss" },
{ method: "POST", path: "/sessions/:sessionId/attachments" }, { method: "POST", path: "/sessions/:sessionId/attachments" },
{ method: "POST", path: "/sessions/:sessionId/shell" }, { method: "POST", path: "/sessions/:sessionId/shell" },