Archived
Compare commits
10
Commits
346607e8bc
...
c09b67d15a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c09b67d15a | ||
|
|
c2b7cce2d3 | ||
|
|
7103bfcb4c | ||
|
|
5f4d81352f | ||
|
|
9b8728a5d6 | ||
|
|
c3aeef23f3 | ||
|
|
93249c8bc4 | ||
|
|
7a426571b8 | ||
|
|
5420869c52 | ||
|
|
5759201a39 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer with mobile-safe text sizing. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, sending an ordinary chat message voids the open form, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Show the thinking level in assistant chat bubble metadata next to the model and timestamp, for both history and live messages. Bubbles from turns with thinking off stay unchanged.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Let chat markdown tables keep their natural width and scroll horizontally instead of being squeezed into the chat column, making them readable on mobile.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Turn the bundled Info plugin panel into an always-available PI WEB status view: it now shows the running and installed versions, installation kind and path, release state, per-service health, and machine and workspace details from host-provided status, plus a "Copy PI WEB Diagnostics" action that copies a plain-text summary for bug reports.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Let an already-known provider extension refresh its own model list after daemon startup. Previously every provider registration made after the global bootstrap was ignored, so a provider that fetched an updated model catalog on session start never had those models appear. A registration is now applied when it matches the provider's recorded startup configuration in every respect except the model list; anything else — a new provider, a changed provider base URL, API key, API type, headers, or auth surface, a native provider registration, or an unregistration — is still ignored to keep project-level provider configuration from leaking between workspaces. Documented the refreshed policy under Pi extension provider baseline in the configuration reference.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add action-palette commands for selecting a session's model and thinking level, with support for assigning custom shortcuts in Settings.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Show cross-workspace session relationships in the session list. A session whose parent lives in another worktree now names that parent's workspace or branch instead of only reporting an unavailable parent, and offers a "Go to parent session" action that switches to the owning workspace and selects the parent. A session with children in other workspaces of the same project now shows how many, so a parent no longer looks childless when its children are not nested beneath it.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add `files.listFiles(path)` to the stable plugin API so workspace panel and label plugins can list workspace directory entries on local and federated machines.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add `state.selectedMachine` to the stable plugin runtime state so plugin actions and other runtime callbacks can read the selected machine's identity, not just workspace panel contexts.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add a built-in Relays plugin: a read-only workspace tab (and **Open Workspace Relays** action) that browses `.pi-web/relays/` packets, with a most-recent relay picker, ordered document tabs, sanitized markdown rendering, and truncation notices.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Remove the legacy session archive migration from session daemon startup. Each `PI_WEB_DATA_DIR` data directory is independent: pointing PI WEB at a new data directory starts there with empty registries and no session archives.
|
||||
|
||||
You are only affected if you have session archives created before July 2026 in the default `~/.pi-web` data directory and you newly set a custom `PI_WEB_DATA_DIR`. To carry those archives over, stop PI WEB, then copy `archived-sessions.json` and the `archived-sessions/` directory from the old data directory into the new one.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Stop the session and workspace lists from re-scrolling to the selected row on live data refreshes, such as message-count updates while a session streams or workspace topology refreshes. The lists now scroll the selection into view only when the selection moves to a different row, an archived session is revealed, a restored session moves back to the current section, or a collapsed section expands.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Say what a slow session start is waiting on. While a session is being created or opened, the activity line now names the current startup step — starting the Pi session, or loading session extensions — and adds a note when provider model lists happen to be refreshing at the same time. When nothing can be attributed, the previous generic wording is kept rather than guessing a cause.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Sessions started via `spawn_session` and `spawn_subsession` now inherit the spawning session's thinking level instead of falling back to the pi default, clamped to the child model's capabilities.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Let agents pick a model when delegating work: `spawn_session` and `spawn_subsession` accept an optional `model` parameter as an exact `provider/model-id` (an unknown value is rejected; omitting it keeps the inherited model). In the chat composer, typing `#` opens a model completion menu that inserts a `#provider/model-id` reference into the draft, which agents forward as that parameter.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Streamline the session list bulk-selection toolbar: the Select visible / Clear visible / Clear buttons are now a single toggle that offers "Select visible" when nothing is selected and "Clear selected" otherwise, and the redundant Done button is gone — selection mode closes from the same ☑ heading button that opened it. and "Archive selected" / "Delete selected" are shortened to "Archive" / "Delete". The slimmer toolbar no longer wraps to two lines on narrow sidebars.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Give every navigation row a single activity indicator that also carries unread state. When sessions beneath a workspace, project, or machine row have unread completions, the row's indicator becomes a static accent ring around the activity dot — or a filled accent dot while idle — instead of a separate dot next to the name. Session rows now surface unread state even while busy or sending, and the "N unread" header and mobile Sessions badge count busy unread sessions too.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add "Mark as read" actions for unread sessions: a per-session item in the session row ⋯ menu (shown only for unread sessions) and a bulk "Mark read" button in the multi-select bar that marks every unread selected session as read.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Add copy buttons to the workspace menu details so the workspace path and branch can be copied to the clipboard with one click, matching the copy affordances already available in chats.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Pick up git worktrees created or removed outside PI WEB without any user action. The selected project's workspace list is re-read whenever the browser tab regains focus or becomes visible, on local and remote machines, keeping the current workspace, session, and scroll position untouched. Worktrees whose checkout directory no longer exists are hidden instead of being offered as selectable workspaces.
|
||||
@@ -1,5 +1,31 @@
|
||||
# @jmfederico/pi-web
|
||||
|
||||
## 1.202607.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9191f59: Add an `ask_user` session tool that lets agents post structured question sets as one chat-native browser form. The form uses the transcript's single scroll area, keeps its header visible, and always gives every question a Custom free-text answer with mobile-safe text sizing. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, sending an ordinary chat message voids the open form, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`.
|
||||
- 111db63: Let chat markdown tables keep their natural width and scroll horizontally instead of being squeezed into the chat column, making them readable on mobile.
|
||||
- 5759201: 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.
|
||||
- 8517800: Turn the bundled Info plugin panel into an always-available PI WEB status view: it now shows the running and installed versions, installation kind and path, release state, per-service health, and machine and workspace details from host-provided status, plus a "Copy PI WEB Diagnostics" action that copies a plain-text summary for bug reports.
|
||||
- 531ccf7: Let an already-known provider extension refresh its own model list after daemon startup. Previously every provider registration made after the global bootstrap was ignored, so a provider that fetched an updated model catalog on session start never had those models appear. A registration is now applied when it matches the provider's recorded startup configuration in every respect except the model list; anything else — a new provider, a changed provider base URL, API key, API type, headers, or auth surface, a native provider registration, or an unregistration — is still ignored to keep project-level provider configuration from leaking between workspaces. Documented the refreshed policy under Pi extension provider baseline in the configuration reference.
|
||||
- ce4b469: Add action-palette commands for selecting a session's model and thinking level, with support for assigning custom shortcuts in Settings.
|
||||
- 69b125b: Show cross-workspace session relationships in the session list. A session whose parent lives in another worktree now names that parent's workspace or branch instead of only reporting an unavailable parent, and offers a "Go to parent session" action that switches to the owning workspace and selects the parent. A session with children in other workspaces of the same project now shows how many, so a parent no longer looks childless when its children are not nested beneath it.
|
||||
- d19fca4: Add `files.listFiles(path)` to the stable plugin API so workspace panel and label plugins can list workspace directory entries on local and federated machines.
|
||||
- 8517800: Add `state.selectedMachine` to the stable plugin runtime state so plugin actions and other runtime callbacks can read the selected machine's identity, not just workspace panel contexts.
|
||||
- 87c0998: Add a built-in Relays plugin: a read-only workspace tab (and **Open Workspace Relays** action) that browses `.pi-web/relays/` packets, with a most-recent relay picker, ordered document tabs, sanitized markdown rendering, and truncation notices.
|
||||
- c3aeef2: Keep the relays panel document tab strip's horizontal scroll position when switching documents, instead of jumping back to the left edge on every tab click.
|
||||
- 5c3461d: Remove the legacy session archive migration from session daemon startup. Each `PI_WEB_DATA_DIR` data directory is independent: pointing PI WEB at a new data directory starts there with empty registries and no session archives.
|
||||
|
||||
You are only affected if you have session archives created before July 2026 in the default `~/.pi-web` data directory and you newly set a custom `PI_WEB_DATA_DIR`. To carry those archives over, stop PI WEB, then copy `archived-sessions.json` and the `archived-sessions/` directory from the old data directory into the new one.
|
||||
|
||||
- 76f292c: Stop the session and workspace lists from re-scrolling to the selected row on live data refreshes, such as message-count updates while a session streams or workspace topology refreshes. The lists now scroll the selection into view only when the selection moves to a different row, an archived session is revealed, a restored session moves back to the current section, or a collapsed section expands.
|
||||
- 49e7c39: Say what a slow session start is waiting on. While a session is being created or opened, the activity line now names the current startup step — starting the Pi session, or loading session extensions — and adds a note when provider model lists happen to be refreshing at the same time. When nothing can be attributed, the previous generic wording is kept rather than guessing a cause.
|
||||
- 8af637b: Give every navigation row a single activity indicator that also carries unread state. When sessions beneath a workspace, project, or machine row have unread completions, the row's indicator becomes a static accent ring around the activity dot — or a filled accent dot while idle — instead of a separate dot next to the name. Session rows now surface unread state even while busy or sending, and the "N unread" header and mobile Sessions badge count busy unread sessions too.
|
||||
- 8af637b: Add "Mark as read" actions for unread sessions: a per-session item in the session row ⋯ menu (shown only for unread sessions) and a bulk "Mark read" button in the multi-select bar that marks every unread selected session as read.
|
||||
- 4a51503: Add copy buttons to the workspace menu details so the workspace path and branch can be copied to the clipboard with one click, matching the copy affordances already available in chats.
|
||||
- 8a24a7c: Pick up git worktrees created or removed outside PI WEB without any user action. The selected project's workspace list is re-read whenever the browser tab regains focus or becomes visible, on local and remote machines, keeping the current workspace, session, and scroll position untouched. Worktrees whose checkout directory no longer exists are hidden instead of being offered as selectable workspaces.
|
||||
|
||||
## 1.202607.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+67
-1
@@ -102,6 +102,8 @@
|
||||
<a href="#pi-extension-provider-baseline">Pi extension providers</a>
|
||||
<a href="#catalog-refresh">Model catalog refresh</a>
|
||||
<a href="#session-tools">Session tools</a>
|
||||
<a href="#extension-dialogs">Extension dialogs</a>
|
||||
<a href="#prompt-completions">Prompt completions</a>
|
||||
<a href="#completion-tools">Completion tools</a>
|
||||
</aside>
|
||||
|
||||
@@ -173,7 +175,7 @@
|
||||
<ul>
|
||||
<li><code>host</code> / <code>port</code>: restart the gateway web/API service or process.</li>
|
||||
<li><code>maxUploadBytes</code>: restart both the web/API process and the session daemon on that machine.</li>
|
||||
<li><code>agent.command</code> / <code>agent.dir</code> / <code>spawnSessions</code> / <code>subsessions</code> / <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>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>
|
||||
@@ -213,6 +215,7 @@
|
||||
"spawnSessions": true,
|
||||
"subsessions": false,
|
||||
"askUser": true,
|
||||
"extensionDialogsTimeoutMs": 300000,
|
||||
"plugins": {
|
||||
"workspace-tasks": { "enabled": true },
|
||||
"updates": { "enabled": true },
|
||||
@@ -376,6 +379,14 @@
|
||||
<td>Not supported locally</td>
|
||||
<td>Restart session daemon on that machine</td>
|
||||
</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>
|
||||
<td>PI WEB plugin enablement/settings</td>
|
||||
<td><code>plugins.<id>.enabled</code>, <code>plugins.<id>.settings</code></td>
|
||||
@@ -795,6 +806,16 @@
|
||||
available when the child stops. Included output and transcripts follow a labeled marker and come last,
|
||||
after PI WEB guidance.
|
||||
</p>
|
||||
<p>
|
||||
Both <code>spawn_session</code> and <code>spawn_subsession</code> accept an optional
|
||||
<code>model</code> parameter, given as an exact <code>provider/model-id</code> such as
|
||||
<code>anthropic/claude-sonnet-4-5</code>. When set, the new session starts on that model instead of
|
||||
inheriting the dispatching session's model. The match is strict: an unknown or malformed value is
|
||||
rejected with an error. A <code>#provider/model-id</code> reference in the prompt (see
|
||||
<a href="#prompt-completions">Prompt completions</a>) is how users ask for a specific model; agents
|
||||
forward that reference as this parameter. The new session also inherits the dispatching session's
|
||||
thinking level, clamped to its model's capabilities.
|
||||
</p>
|
||||
<p>
|
||||
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
|
||||
session daemon on that machine after changing them.
|
||||
@@ -842,6 +863,51 @@
|
||||
</div>
|
||||
</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="prompt-completions">
|
||||
<h2>Prompt completions</h2>
|
||||
<p>The chat composer opens completion menus on three trigger characters:</p>
|
||||
<ul>
|
||||
<li><code>/</code> at the very start of the draft completes session commands.</li>
|
||||
<li>
|
||||
<code>@</code> completes file paths: <code>@</code> for tracked files, <code>@ </code> (at, then
|
||||
space) or <code>!@</code> for all files. Picking one inserts an <code>@path</code> reference into the
|
||||
draft, quoted automatically when the path contains spaces.
|
||||
</li>
|
||||
<li>
|
||||
<code>#</code> completes the models available to the session, filtered case-insensitively as you type
|
||||
(at most 12 entries). Picking one inserts a <code>#provider/model-id</code> reference into the draft,
|
||||
which tells agents the request should run on that model — for example as the <code>model</code>
|
||||
parameter of <code>spawn_session</code>.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="completion-tools">
|
||||
<h2>Optional completion tools</h2>
|
||||
|
||||
+21
-1
@@ -39,7 +39,7 @@ Process restarts depend on the key:
|
||||
|
||||
- `host` / `port`: restart the gateway web/API service or process.
|
||||
- `maxUploadBytes`: restart both the web/API process and the session daemon on that machine.
|
||||
- `agent.command` / `agent.dir` / `spawnSessions` / `subsessions` / `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.
|
||||
- `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.
|
||||
@@ -66,6 +66,7 @@ Process restarts depend on the key:
|
||||
"spawnSessions": true,
|
||||
"subsessions": false,
|
||||
"askUser": true,
|
||||
"extensionDialogsTimeoutMs": 300000,
|
||||
"plugins": {
|
||||
"workspace-tasks": { "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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -271,6 +273,8 @@ A completion notice wakes an idle parent or queues behind in-flight work. Each n
|
||||
|
||||
`list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. Included output and transcripts follow a labeled marker and come last, after PI WEB guidance.
|
||||
|
||||
Both `spawn_session` and `spawn_subsession` accept an optional `model` parameter, given as an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`. When set, the new session starts on that model instead of inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A `#provider/model-id` reference in the prompt (see [Prompt completions](#prompt-completions)) is how users ask for a specific model; agents forward that reference as this parameter. The new session also inherits the dispatching session's thinking level, clamped to its model's capabilities.
|
||||
|
||||
In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them.
|
||||
|
||||
#### `askUser` and `ask_user`
|
||||
@@ -289,6 +293,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`.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
@@ -321,6 +333,14 @@ Shortcut values are keyed by action id. Values are shortcut strings such as `mod
|
||||
|
||||
Prefer Settings → Keyboard for editing shortcuts interactively.
|
||||
|
||||
## Prompt completions
|
||||
|
||||
The chat composer opens completion menus on three trigger characters:
|
||||
|
||||
- `/` at the very start of the draft completes session commands.
|
||||
- `@` completes file paths: `@` for tracked files, `@ ` (at, then space) or `!@` for all files. Picking one inserts an `@path` reference into the draft, quoted automatically when the path contains spaces.
|
||||
- `#` completes the models available to the session, filtered case-insensitively as you type (at most 12 entries). Picking one inserts a `#provider/model-id` reference into the draft, which tells agents the request should run on that model — for example as the `model` parameter of `spawn_session`.
|
||||
|
||||
## Optional completion tools
|
||||
|
||||
File and path `@` completions work without extra tools. If `fzf` is available on the PI WEB server's `PATH`, PI WEB uses it to improve completion filtering/ranking; otherwise it falls back to built-in ranking.
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<strong>On this page</strong>
|
||||
<a href="#extend">What can be extended</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="#example">Canonical example</a>
|
||||
<a href="#built-in-plugins">Built-in plugins</a>
|
||||
@@ -174,6 +175,32 @@
|
||||
</p>
|
||||
</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">
|
||||
<h2>What to ask AI to build</h2>
|
||||
<p>
|
||||
|
||||
@@ -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).
|
||||
|
||||
## 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
|
||||
|
||||
Plugins run as JavaScript in the browser app. Treat them as trusted code:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202607.2",
|
||||
"version": "1.202607.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202607.2",
|
||||
"version": "1.202607.3",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jmfederico/pi-web",
|
||||
"version": "1.202607.2",
|
||||
"version": "1.202607.3",
|
||||
"description": "Web UI for persistent Pi Coding Agent sessions in real workspaces.",
|
||||
"license": "MIT",
|
||||
"author": "Federico Jaramillo Martinez",
|
||||
|
||||
@@ -109,7 +109,7 @@ describe("multiple relays", () => {
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/older`;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||
await flushAsync();
|
||||
|
||||
expect(fake.listFiles).toHaveBeenCalledWith(`${RELAYS_ROOT}/older`);
|
||||
@@ -167,6 +167,77 @@ describe("document tabs", () => {
|
||||
expect(children.map((child) => child.textContent)).toEqual(["status.md", "log.md", "notes.md"]);
|
||||
});
|
||||
|
||||
it("keeps the tab strip mounted with its scroll and focus when switching documents", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [
|
||||
relayDocument("relay", "notes.md"),
|
||||
relayDocument("relay", "log.md"),
|
||||
relayDocument("relay", "status.md"),
|
||||
]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
const strip = tabStrip(panel);
|
||||
strip.scrollLeft = 120;
|
||||
const logTab = tabNamed(panel, "log.md");
|
||||
|
||||
logTab.click();
|
||||
await flushAsync();
|
||||
|
||||
// Switching documents only re-renders the viewer: the strip element and
|
||||
// its buttons stay mounted, so scroll position and button identity survive.
|
||||
expect(documentText(panel)).toBe("log body");
|
||||
expect(tabStrip(panel)).toBe(strip);
|
||||
expect(tabStrip(panel).scrollLeft).toBe(120);
|
||||
expect(tabNamed(panel, "log.md")).toBe(logTab);
|
||||
expect(activeTab(panel)?.textContent).toBe("log.md");
|
||||
});
|
||||
|
||||
it("scrolls the viewer back to the top when switching documents", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/relay`, [relayDocument("relay", "log.md"), relayDocument("relay", "status.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/status.md`, "status body");
|
||||
fake.addDocument(`${RELAYS_ROOT}/relay/log.md`, "log body");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
const viewer = shadow(panel).querySelector("section.viewer");
|
||||
if (!(viewer instanceof HTMLElement)) throw new Error("viewer missing");
|
||||
viewer.scrollTop = 80;
|
||||
|
||||
tabNamed(panel, "log.md").click();
|
||||
await flushAsync();
|
||||
|
||||
expect(documentText(panel)).toBe("log body");
|
||||
expect(viewer.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("starts a different relay's tab strip at the left edge", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [
|
||||
relayDirectory("alpha", "2026-01-01T00:00:00.000Z"),
|
||||
relayDirectory("beta", "2026-02-01T00:00:00.000Z"),
|
||||
]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/alpha`, [relayDocument("alpha", "status.md")]);
|
||||
fake.addDirectory(`${RELAYS_ROOT}/beta`, [relayDocument("beta", "status.md"), relayDocument("beta", "log.md")]);
|
||||
fake.addDocument(`${RELAYS_ROOT}/beta/status.md`, "beta status");
|
||||
fake.addDocument(`${RELAYS_ROOT}/alpha/status.md`, "alpha status");
|
||||
|
||||
const panel = await mountPanel(panelContext(fake));
|
||||
tabStrip(panel).scrollLeft = 120;
|
||||
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/alpha`;
|
||||
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||
await flushAsync();
|
||||
|
||||
expect(documentText(panel)).toBe("alpha status");
|
||||
expect(tabStrip(panel).scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("shows a truncation notice when the open document is truncated", async () => {
|
||||
const fake = workspaceFilesFake();
|
||||
fake.addDirectory(RELAYS_ROOT, [relayDirectory("relay")]);
|
||||
@@ -308,7 +379,7 @@ describe("refresh and context changes", () => {
|
||||
const select = picker(panel);
|
||||
if (select === null) throw new Error("relay picker missing");
|
||||
select.value = `${RELAYS_ROOT}/alpha`;
|
||||
select.dispatchEvent(new Event("change"));
|
||||
select.dispatchEvent(new Event("change", { bubbles: true })); // Real change events bubble; the panel listens at the region container.
|
||||
await flushAsync();
|
||||
tabNamed(panel, "log.md").click();
|
||||
await flushAsync();
|
||||
@@ -463,6 +534,12 @@ function refreshButton(panel: RelaysPanelTestElement): HTMLElement {
|
||||
return button;
|
||||
}
|
||||
|
||||
function tabStrip(panel: RelaysPanelTestElement): HTMLElement {
|
||||
const strip = shadow(panel).querySelector("nav.document-tabs");
|
||||
if (!(strip instanceof HTMLElement)) throw new Error("tab strip missing");
|
||||
return strip;
|
||||
}
|
||||
|
||||
function tabNames(panel: RelaysPanelTestElement): string[] {
|
||||
return [...shadow(panel).querySelectorAll("button[data-document-path]")].map((tab) => tab.textContent);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ interface RelaySelection {
|
||||
* sanitized HTML and everything else as preformatted text. All async loads
|
||||
* flow through scanToken so stale responses for a previous workspace or
|
||||
* selection never overwrite newer state.
|
||||
*
|
||||
* Rendering is region-scoped: the toolbar, tab strip, and viewer are
|
||||
* persistent elements built once, and each async stage re-renders only its
|
||||
* own region. Clicking a tab never rebuilds the strip — it toggles the
|
||||
* active marker in place and re-renders the viewer — so the strip's
|
||||
* horizontal scroll position and keyboard focus survive document switches.
|
||||
*/
|
||||
class PiWebRelaysPanel extends HTMLElement {
|
||||
private contextValue: WorkspacePanelContext | undefined;
|
||||
@@ -39,36 +45,64 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
private documentContent: RelayDocumentContent | undefined;
|
||||
private scanToken = 0;
|
||||
private readonly root: ShadowRoot;
|
||||
private readonly toolbar: HTMLElement;
|
||||
private readonly tabStrip: HTMLElement;
|
||||
private readonly viewer: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.root = this.attachShadow({ mode: "open" });
|
||||
this.root.innerHTML = `
|
||||
${relaysStyles()}
|
||||
<section class="toolbar" hidden></section>
|
||||
<nav class="document-tabs" aria-label="Relay documents" hidden></nav>
|
||||
<section class="viewer"><div class="empty">Select a workspace.</div></section>
|
||||
`;
|
||||
this.toolbar = requiredRegion(this.root, ".toolbar");
|
||||
this.tabStrip = requiredRegion(this.root, "nav.document-tabs");
|
||||
this.viewer = requiredRegion(this.root, ".viewer");
|
||||
|
||||
// Listeners bind once against the persistent regions and delegate to
|
||||
// whichever controls the latest region render produced.
|
||||
this.toolbar.addEventListener("click", (event) => {
|
||||
const button = event.target instanceof Element ? event.target.closest("button[data-refresh]") : null;
|
||||
if (button !== null) this.refresh();
|
||||
});
|
||||
this.toolbar.addEventListener("change", (event) => {
|
||||
const picker = event.target;
|
||||
if (!(picker instanceof HTMLSelectElement) || !picker.matches("select[data-relay-picker]")) return;
|
||||
const context = this.contextValue;
|
||||
if (context !== undefined) void this.openRelay(context, picker.value);
|
||||
});
|
||||
this.tabStrip.addEventListener("click", (event) => {
|
||||
const tab = event.target instanceof Element ? event.target.closest("button[data-document-path]") : null;
|
||||
if (tab === null) return;
|
||||
const documentPath = tab.getAttribute("data-document-path");
|
||||
const context = this.contextValue;
|
||||
if (context !== undefined && documentPath !== null) void this.openDocument(context, documentPath);
|
||||
});
|
||||
}
|
||||
|
||||
set context(value: WorkspacePanelContext | undefined) {
|
||||
const previousKey = this.contextValue === undefined ? undefined : contextKey(this.contextValue);
|
||||
const nextKey = value === undefined ? undefined : contextKey(value);
|
||||
this.contextValue = value;
|
||||
// Parent app updates should not rescan or rebuild this shadow DOM for the
|
||||
// Parent app updates should not rescan or re-render this panel for the
|
||||
// same workspace (mirrors the workspace-tasks panel).
|
||||
if (previousKey === nextKey) return;
|
||||
if (value === undefined) {
|
||||
this.resetScanState();
|
||||
this.render();
|
||||
this.renderAll();
|
||||
return;
|
||||
}
|
||||
void this.scan(value, {});
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Rescan relays, then reload the selected relay's documents and the open document. */
|
||||
private async scan(context: WorkspacePanelContext, selection: RelaySelection): Promise<void> {
|
||||
const token = ++this.scanToken;
|
||||
this.resetScanState();
|
||||
this.render();
|
||||
this.renderAll();
|
||||
|
||||
const listing = await listWorkspaceRelays(context.files);
|
||||
if (!this.isCurrentScan(context, token)) return;
|
||||
@@ -80,8 +114,9 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
? listing.relays.find((candidate) => candidate.path === selection.relayPath) ?? listing.relays[0]
|
||||
: undefined;
|
||||
this.selectedRelayPath = relay?.path;
|
||||
this.renderToolbar();
|
||||
if (relay === undefined) {
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
return;
|
||||
}
|
||||
await this.loadDocuments(context, token, relay.path, selection.documentPath);
|
||||
@@ -96,6 +131,11 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
private async openDocument(context: WorkspacePanelContext, documentPath: string): Promise<void> {
|
||||
const token = ++this.scanToken;
|
||||
this.selectedDocumentPath = documentPath;
|
||||
// The tab set is unchanged: toggle the active marker on the mounted
|
||||
// buttons instead of rebuilding the strip, so its scroll position and
|
||||
// focus stay put. A different document starts reading from the top.
|
||||
this.updateActiveTab();
|
||||
this.viewer.scrollTop = 0;
|
||||
await this.loadDocumentContent(context, token, documentPath);
|
||||
}
|
||||
|
||||
@@ -103,7 +143,8 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
this.documents = undefined;
|
||||
this.selectedDocumentPath = undefined;
|
||||
this.documentContent = undefined;
|
||||
this.render();
|
||||
this.renderTabs();
|
||||
this.renderViewer();
|
||||
|
||||
const documents = await listRelayDocuments(context.files, relayPath);
|
||||
if (!this.isCurrentScan(context, token)) return;
|
||||
@@ -113,8 +154,9 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
? documents.documents.find((candidate) => candidate.path === preferredDocumentPath) ?? defaultRelayDocument(documents.documents)
|
||||
: undefined;
|
||||
this.selectedDocumentPath = document?.path;
|
||||
this.renderTabs();
|
||||
if (document === undefined) {
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
return;
|
||||
}
|
||||
await this.loadDocumentContent(context, token, document.path);
|
||||
@@ -122,12 +164,12 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
|
||||
private async loadDocumentContent(context: WorkspacePanelContext, token: number, documentPath: string): Promise<void> {
|
||||
this.documentContent = undefined;
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
|
||||
const content = await readRelayDocument(context.files, documentPath);
|
||||
if (!this.isCurrentScan(context, token)) return;
|
||||
this.documentContent = content;
|
||||
this.render();
|
||||
this.renderViewer();
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
@@ -148,38 +190,26 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
return token === this.scanToken && this.contextValue !== undefined && contextKey(this.contextValue) === contextKey(context);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const context = this.contextValue;
|
||||
if (context === undefined) {
|
||||
this.root.innerHTML = `${relaysStyles()}<section class="empty">Select a workspace.</section>`;
|
||||
private renderAll(): void {
|
||||
this.renderToolbar();
|
||||
this.renderTabs();
|
||||
this.renderViewer();
|
||||
}
|
||||
|
||||
private renderToolbar(): void {
|
||||
if (this.contextValue === undefined) {
|
||||
this.toolbar.hidden = true;
|
||||
this.toolbar.replaceChildren();
|
||||
return;
|
||||
}
|
||||
this.root.innerHTML = `
|
||||
${relaysStyles()}
|
||||
<section class="toolbar">
|
||||
this.toolbar.hidden = false;
|
||||
this.toolbar.innerHTML = `
|
||||
<strong>Relays</strong>
|
||||
<span class="toolbar-actions">
|
||||
${this.renderRelayPicker()}
|
||||
<button class="icon-button" data-refresh aria-label="Refresh" title="Refresh">${refreshIconSvg()}</button>
|
||||
</span>
|
||||
</section>
|
||||
${this.renderDocumentTabs()}
|
||||
<section class="viewer">${this.renderViewer()}</section>
|
||||
`;
|
||||
|
||||
this.root.querySelector("button[data-refresh]")?.addEventListener("click", () => {
|
||||
this.refresh();
|
||||
});
|
||||
this.root.querySelector("select[data-relay-picker]")?.addEventListener("change", (event) => {
|
||||
const picker = event.target;
|
||||
if (picker instanceof HTMLSelectElement) void this.openRelay(context, picker.value);
|
||||
});
|
||||
for (const tab of this.root.querySelectorAll("button[data-document-path]")) {
|
||||
tab.addEventListener("click", () => {
|
||||
const documentPath = tab.getAttribute("data-document-path");
|
||||
if (documentPath !== null) void this.openDocument(context, documentPath);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderRelayPicker(): string {
|
||||
@@ -197,17 +227,43 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
return `<select data-relay-picker aria-label="Relay">${options}</select>`;
|
||||
}
|
||||
|
||||
private renderDocumentTabs(): string {
|
||||
private renderTabs(): void {
|
||||
const documents = this.documents;
|
||||
if (documents?.kind !== "loaded" || documents.documents.length === 0) return "";
|
||||
const tabs = documents.documents.map((document) => {
|
||||
if (documents?.kind !== "loaded" || documents.documents.length === 0) {
|
||||
this.tabStrip.hidden = true;
|
||||
// A new tab set starts at the left edge, not at the previous set's offset.
|
||||
this.tabStrip.replaceChildren();
|
||||
this.tabStrip.scrollLeft = 0;
|
||||
return;
|
||||
}
|
||||
this.tabStrip.hidden = false;
|
||||
// The strip element itself persists across re-renders, so replacing its
|
||||
// buttons keeps the container's horizontal scroll position.
|
||||
this.tabStrip.innerHTML = documents.documents.map((document) => {
|
||||
const active = document.path === this.selectedDocumentPath;
|
||||
return `<button class="document-tab${active ? " active" : ""}" data-document-path="${escapeAttr(document.path)}"${active ? ' aria-current="true"' : ""}>${escapeHtml(document.name)}</button>`;
|
||||
}).join("");
|
||||
return `<nav class="document-tabs" aria-label="Relay documents">${tabs}</nav>`;
|
||||
}
|
||||
|
||||
private renderViewer(): string {
|
||||
/** Move the active marker between the mounted tab buttons without rebuilding them. */
|
||||
private updateActiveTab(): void {
|
||||
for (const tab of this.tabStrip.querySelectorAll("button[data-document-path]")) {
|
||||
const active = tab.getAttribute("data-document-path") === this.selectedDocumentPath;
|
||||
tab.classList.toggle("active", active);
|
||||
if (active) tab.setAttribute("aria-current", "true");
|
||||
else tab.removeAttribute("aria-current");
|
||||
}
|
||||
}
|
||||
|
||||
private renderViewer(): void {
|
||||
if (this.contextValue === undefined) {
|
||||
this.viewer.innerHTML = `<div class="empty">Select a workspace.</div>`;
|
||||
return;
|
||||
}
|
||||
this.viewer.innerHTML = this.renderViewerContent();
|
||||
}
|
||||
|
||||
private renderViewerContent(): string {
|
||||
const listing = this.listing;
|
||||
if (listing === undefined) return `<p class="muted">Scanning ${escapeHtml(RELAYS_ROOT)}…</p>`;
|
||||
if (listing.kind === "unavailable") return renderErrorState("Could not scan workspace relays.", listing.detail);
|
||||
@@ -255,6 +311,13 @@ class PiWebRelaysPanel extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shell regions come from a literal template; absence means the template broke. */
|
||||
function requiredRegion(root: ShadowRoot, selector: string): HTMLElement {
|
||||
const element = root.querySelector(selector);
|
||||
if (!(element instanceof HTMLElement)) throw new Error(`relays panel shell is missing ${selector}`);
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Reload glyph matching the app's own refresh control (AppRefreshControl). */
|
||||
function refreshIconSvg(): string {
|
||||
return `
|
||||
@@ -297,6 +360,7 @@ function relaysStyles(): string {
|
||||
the viewer's huge content basis starves them down to a sliver once a
|
||||
tall document renders). The viewer absorbs all shrinking instead. */
|
||||
.toolbar { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); }
|
||||
.toolbar[hidden], .document-tabs[hidden] { display: none; }
|
||||
.toolbar-actions { display: inline-flex; align-items: center; flex-wrap: nowrap; justify-content: flex-end; gap: 8px; min-width: 0; }
|
||||
.relay-name { min-width: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; }
|
||||
/* Bottom padding (not viewer margin) so the gap below the tabs persists
|
||||
|
||||
@@ -46,9 +46,10 @@ export interface AppState {
|
||||
pendingDialogs: PendingExtensionDialog[];
|
||||
/**
|
||||
* Dialogs that closed while their session was selected, kept with the close
|
||||
* reason and any answer so the card can render its outcome briefly. 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.
|
||||
* 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. */
|
||||
|
||||
@@ -113,6 +113,12 @@ describe("chat message normalization", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries the thinking level into assistant message metadata", () => {
|
||||
expect(normalizeMessage({ role: "assistant", content: [{ type: "text", text: "hi" }], provider: "openai", model: "gpt-4.1", timestamp: "2026-05-09T12:00:00.000Z", thinkingLevel: "max" })).toEqual([
|
||||
{ role: "assistant", parts: [{ type: "text", text: "hi" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" }, thinkingLevel: "max" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows assistant model errors as system chat messages", () => {
|
||||
expect(normalizeMessage({ role: "assistant", content: [], stopReason: "error", errorMessage: "429 rate limit", timestamp: "2026-05-09T12:00:00.000Z", provider: "openai", model: "gpt-4.1" })).toEqual([
|
||||
{ role: "system", parts: [{ type: "text", text: "Model response failed: 429 rate limit" }], meta: { timestamp: "2026-05-09T12:00:00.000Z", model: { provider: "openai", id: "gpt-4.1" } } },
|
||||
|
||||
@@ -109,8 +109,13 @@ function normalizeSource(message: unknown): ChatLine["source"] | undefined {
|
||||
function normalizeMeta(message: unknown): ChatLine["meta"] | undefined {
|
||||
const timestamp = normalizeTimestamp(getProperty(message, "timestamp"));
|
||||
const model = normalizeModel(message);
|
||||
if (timestamp === undefined && model === undefined) return undefined;
|
||||
return { ...(timestamp === undefined ? {} : { timestamp }), ...(model === undefined ? {} : { model }) };
|
||||
const thinkingLevel = getString(message, "thinkingLevel");
|
||||
if (timestamp === undefined && model === undefined && (thinkingLevel === undefined || thinkingLevel === "")) return undefined;
|
||||
return {
|
||||
...(timestamp === undefined ? {} : { timestamp }),
|
||||
...(model === undefined ? {} : { model }),
|
||||
...(thinkingLevel === undefined || thinkingLevel === "" ? {} : { thinkingLevel }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): string | undefined {
|
||||
|
||||
@@ -259,6 +259,17 @@ describe("chatMessageMetadataLabel", () => {
|
||||
meta: { timestamp, model: { provider: "provider", id: "model" } },
|
||||
})).toBe(`${formattedTimestamp} · provider/model`);
|
||||
});
|
||||
|
||||
it("appends the thinking level after the model when present", () => {
|
||||
const timestamp = "2026-07-10T19:15:30.000Z";
|
||||
const formattedTimestamp = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "medium" }).format(new Date(timestamp));
|
||||
|
||||
expect(chatMessageMetadataLabel({
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
meta: { timestamp, model: { provider: "provider", id: "model" }, thinkingLevel: "high" },
|
||||
})).toBe(`${formattedTimestamp} · provider/model · high`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat event-group content seams", () => {
|
||||
|
||||
@@ -163,7 +163,7 @@ export function chatMessageMetadataLabel(message: ChatLine): string {
|
||||
const timestamp = message.meta?.timestamp;
|
||||
const time = timestamp === undefined ? undefined : formatMessageTimestamp(timestamp);
|
||||
const model = chatMessageModelLabel(message);
|
||||
const parts = [time, model].filter((part): part is string => part !== undefined && part !== "");
|
||||
const parts = [time, model, message.meta?.thinkingLevel].filter((part): part is string => part !== undefined && part !== "");
|
||||
return parts.length === 0 ? "No Pi message metadata available" : parts.join(" · ");
|
||||
}
|
||||
|
||||
|
||||
@@ -141,13 +141,25 @@ describe("extension-dialog-card countdown", () => {
|
||||
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 status = requiredElement(root.querySelector("[role='status']"), "countdown status");
|
||||
const countdown = requiredElement(root.querySelector(".countdown"), "countdown");
|
||||
|
||||
expect(status.textContent).toBe("Auto-cancels in 1m 30s");
|
||||
expect(countdown.textContent).toBe("Auto-cancels in 1m 30s");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
await card.updateComplete;
|
||||
expect(status.textContent).toBe("Auto-cancels in 1m 0s");
|
||||
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 () => {
|
||||
@@ -155,7 +167,7 @@ describe("extension-dialog-card countdown", () => {
|
||||
const card = await mountOpenDialog(openDialog());
|
||||
const root = renderRoot(card);
|
||||
|
||||
expect(root.querySelector("[role='status']")).toBeNull();
|
||||
expect(root.querySelector(".countdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("stops ticking once the dialog closes", async () => {
|
||||
@@ -219,6 +231,11 @@ describe("extensionDialogCountdownText", () => {
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -58,7 +58,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM
|
||||
const seconds = Math.ceil(remainingMs / 1000);
|
||||
if (seconds >= 3600) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.round((seconds % 3600) / 60);
|
||||
// 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) {
|
||||
@@ -75,7 +76,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM
|
||||
* 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
|
||||
* transient outcome for a browser that saw the dialog open.
|
||||
* 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 {
|
||||
@@ -129,7 +131,10 @@ export class ExtensionDialogCard extends LitElement {
|
||||
<h2 id="extension-dialog-heading">${dialog.title}</h2>
|
||||
${countdown === undefined
|
||||
? null
|
||||
: html`<span class="header-status countdown" role="status" aria-live="polite" aria-atomic="true">${countdown}</span>`}
|
||||
// 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>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, type SessionModel } from "../api";
|
||||
import { PromptEditor } from "./PromptEditor";
|
||||
|
||||
const sonnet: SessionModel = { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" };
|
||||
const gpt: SessionModel = { provider: "openai", id: "gpt-5.2" };
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PromptEditor model completions", () => {
|
||||
it("requests models for the session and maps a # token to model completions", async () => {
|
||||
const models = vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet, gpt] });
|
||||
const editor = new PromptEditor();
|
||||
editor.sessionId = "session-1";
|
||||
editor.cwd = "/repo";
|
||||
editor.machineId = "remote-a";
|
||||
|
||||
await refreshCompletions(editor, "please use #cla");
|
||||
|
||||
expect(models).toHaveBeenCalledWith({ id: "session-1", cwd: "/repo" }, "remote-a");
|
||||
expect(currentCompletions(editor)).toEqual([
|
||||
{ kind: "model", replaceFrom: 11, replaceTo: 15, insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
|
||||
]);
|
||||
expect(Reflect.get(editor, "selectedIndex")).toBe(0);
|
||||
});
|
||||
|
||||
it("lists every model for a bare # token", async () => {
|
||||
vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet, gpt] });
|
||||
const editor = new PromptEditor();
|
||||
editor.sessionId = "session-1";
|
||||
editor.cwd = "/repo";
|
||||
|
||||
await refreshCompletions(editor, "#");
|
||||
|
||||
expect(currentCompletions(editor)).toEqual([
|
||||
{ kind: "model", replaceFrom: 0, replaceTo: 1, insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
|
||||
{ kind: "model", replaceFrom: 0, replaceTo: 1, insertText: "#openai/gpt-5.2", detail: "openai" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears completions when the models request fails", async () => {
|
||||
vi.spyOn(api, "models").mockRejectedValue(new Error("models unavailable"));
|
||||
const editor = new PromptEditor();
|
||||
editor.sessionId = "session-1";
|
||||
editor.cwd = "/repo";
|
||||
|
||||
await refreshCompletions(editor, "#cla");
|
||||
|
||||
expect(currentCompletions(editor)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not request models without session context", async () => {
|
||||
const models = vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet] });
|
||||
const editor = new PromptEditor();
|
||||
|
||||
await refreshCompletions(editor, "#cla");
|
||||
|
||||
expect(models).not.toHaveBeenCalled();
|
||||
expect(currentCompletions(editor)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// refreshCompletions is private and driven by CodeMirror updates in production;
|
||||
// invoking it through Reflect mirrors the PromptEditor.draft.test.ts seam and
|
||||
// keeps the wiring test at the component boundary without a DOM harness.
|
||||
async function refreshCompletions(editor: PromptEditor, draft: string): Promise<void> {
|
||||
Reflect.set(editor, "draft", draft);
|
||||
const refresh: unknown = Reflect.get(editor, "refreshCompletions");
|
||||
if (!isRefreshCompletions(refresh)) throw new Error("PromptEditor.refreshCompletions is not callable");
|
||||
await refresh.call(editor);
|
||||
}
|
||||
|
||||
function isRefreshCompletions(value: unknown): value is (this: PromptEditor) => Promise<void> {
|
||||
return typeof value === "function";
|
||||
}
|
||||
|
||||
function currentCompletions(editor: PromptEditor): unknown {
|
||||
return Reflect.get(editor, "completions");
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import { EditorView, keymap, placeholder } from "@codemirror/view";
|
||||
import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
|
||||
import { LitElement, html, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api";
|
||||
import { api, type FileSuggestion, type PromptAttachment, type SessionModel, type SessionStatus, type SlashCommand } from "../api";
|
||||
import type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
||||
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
|
||||
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
|
||||
import { machineSessionKey } from "../machineKeys";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, modelCompletionChoices, type PromptCompletionTrigger } from "../promptCompletions";
|
||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
|
||||
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
|
||||
@@ -283,7 +283,7 @@ export class PromptEditor extends LitElement {
|
||||
keyup: (event) => this.handleEditorKeyUp(event),
|
||||
blur: () => this.resetEditorModifierState(),
|
||||
}),
|
||||
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
|
||||
placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files, # for models"),
|
||||
this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
||||
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
@@ -372,6 +372,15 @@ export class PromptEditor extends LitElement {
|
||||
...(file.path.endsWith("/") && insertText.endsWith("\"") ? { cursorOffset: insertText.length - 1 } : {}),
|
||||
};
|
||||
});
|
||||
} else if (trigger.kind === "model" && this.sessionId !== undefined && this.sessionId !== "" && this.cwd !== undefined && this.cwd !== "") {
|
||||
const models = await api.models({ id: this.sessionId, cwd: this.cwd }, this.machineId).then((response) => response.models).catch(emptySessionModels);
|
||||
if (version !== this.requestVersion) return;
|
||||
this.completions = modelCompletionChoices(models, trigger.query).map((choice) => ({
|
||||
kind: "model",
|
||||
replaceFrom: trigger.from,
|
||||
replaceTo: trigger.to,
|
||||
...choice,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,6 +525,10 @@ function emptyFileSuggestions(): FileSuggestion[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function emptySessionModels(): SessionModel[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function filesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||
if (data === null) return [];
|
||||
return Array.from(data.files);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { SessionInfo } from "../api";
|
||||
import { SessionList } from "./SessionList";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("SessionList bulk selection toolbar", () => {
|
||||
it("offers Select visible with an empty selection and no Clear or Done buttons", async () => {
|
||||
const list = await renderSessionList([session("a"), session("b"), session("c")]);
|
||||
|
||||
currentSelectionToggle(list).click();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(toolbarButton(list, "Select visible")).not.toBeNull();
|
||||
expect(toolbarButton(list, "Clear selected")).toBeNull();
|
||||
expect(toolbarButton(list, "Clear")).toBeNull();
|
||||
expect(toolbarButton(list, "Done")).toBeNull();
|
||||
expect(selectionCount(list)?.textContent.trim()).toBe("0 selected");
|
||||
});
|
||||
|
||||
it("selects every visible session via Select visible, then clears them via Clear selected", async () => {
|
||||
const list = await renderSessionList([session("a"), session("b"), session("c")]);
|
||||
currentSelectionToggle(list).click();
|
||||
await list.updateComplete;
|
||||
|
||||
toolbarButton(list, "Select visible")?.click();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(checkedBoxes(list)).toHaveLength(3);
|
||||
expect(selectionCount(list)?.textContent.trim()).toBe("3 selected");
|
||||
expect(toolbarButton(list, "Select visible")).toBeNull();
|
||||
|
||||
toolbarButton(list, "Clear selected")?.click();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(checkedBoxes(list)).toHaveLength(0);
|
||||
expect(selectionCount(list)?.textContent.trim()).toBe("0 selected");
|
||||
// Clearing keeps selection mode open so the visible set can be re-selected.
|
||||
expect(toolbarButton(list, "Select visible")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("clears a partial manual selection via Clear selected", async () => {
|
||||
const list = await renderSessionList([session("a"), session("b"), session("c")]);
|
||||
currentSelectionToggle(list).click();
|
||||
await list.updateComplete;
|
||||
|
||||
checkboxes(list)[0]?.click();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(selectionCount(list)?.textContent.trim()).toBe("1 selected");
|
||||
expect(toolbarButton(list, "Select visible")).toBeNull();
|
||||
|
||||
toolbarButton(list, "Clear selected")?.click();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(checkedBoxes(list)).toHaveLength(0);
|
||||
expect(toolbarButton(list, "Select visible")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("closes selection mode, discarding the selection, from the same heading toggle that opened it", async () => {
|
||||
const list = await renderSessionList([session("a"), session("b"), session("c")]);
|
||||
currentSelectionToggle(list).click();
|
||||
await list.updateComplete;
|
||||
toolbarButton(list, "Select visible")?.click();
|
||||
await list.updateComplete;
|
||||
expect(checkedBoxes(list)).toHaveLength(3);
|
||||
|
||||
currentSelectionToggle(list).click();
|
||||
await list.updateComplete;
|
||||
|
||||
expect(list.shadowRoot?.querySelector(".bulk-row.selecting")).toBeNull();
|
||||
expect(checkboxes(list)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("offers the same toggle in the archived scope", async () => {
|
||||
const archivedA = session("archived-a", { archived: true, archivedAt: "2026-06-09T00:00:00.000Z" });
|
||||
const archivedB = session("archived-b", { archived: true, archivedAt: "2026-06-09T00:00:00.000Z" });
|
||||
const list = await renderSessionList([session("current"), archivedA, archivedB]);
|
||||
|
||||
archivedSectionToggle(list)?.click();
|
||||
await list.updateComplete;
|
||||
archivedSelectionToggle(list).click();
|
||||
await list.updateComplete;
|
||||
|
||||
toolbarButton(list, "Select visible")?.click();
|
||||
await list.updateComplete;
|
||||
expect(checkedBoxes(list)).toHaveLength(2);
|
||||
expect(selectionCount(list)?.textContent.trim()).toBe("2 selected");
|
||||
|
||||
toolbarButton(list, "Clear selected")?.click();
|
||||
await list.updateComplete;
|
||||
expect(checkedBoxes(list)).toHaveLength(0);
|
||||
expect(toolbarButton(list, "Select visible")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function renderSessionList(sessions: SessionInfo[]): Promise<SessionList> {
|
||||
const list = new SessionList();
|
||||
list.sessions = sessions;
|
||||
document.body.append(list);
|
||||
await list.updateComplete;
|
||||
return list;
|
||||
}
|
||||
|
||||
function currentSelectionToggle(list: SessionList): HTMLButtonElement {
|
||||
const button = list.shadowRoot?.querySelector<HTMLButtonElement>("h2:not(.subheading) .bulk-select-entry");
|
||||
if (button === null || button === undefined) throw new Error("Expected the current selection toggle");
|
||||
return button;
|
||||
}
|
||||
|
||||
function archivedSelectionToggle(list: SessionList): HTMLButtonElement {
|
||||
const button = list.shadowRoot?.querySelector<HTMLButtonElement>("h2.subheading .bulk-select-entry");
|
||||
if (button === null || button === undefined) throw new Error("Expected the archived selection toggle");
|
||||
return button;
|
||||
}
|
||||
|
||||
function archivedSectionToggle(list: SessionList): HTMLButtonElement | null {
|
||||
return list.shadowRoot?.querySelector<HTMLButtonElement>("h2.subheading .section-toggle") ?? null;
|
||||
}
|
||||
|
||||
function toolbarButton(list: SessionList, text: string): HTMLButtonElement | null {
|
||||
const buttons = list.shadowRoot?.querySelectorAll<HTMLButtonElement>(".bulk-row.selecting button") ?? [];
|
||||
for (const button of buttons) {
|
||||
if (button.textContent.trim() === text) return button;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function selectionCount(list: SessionList): HTMLElement | null {
|
||||
return list.shadowRoot?.querySelector<HTMLElement>(".bulk-row.selecting small") ?? null;
|
||||
}
|
||||
|
||||
function checkboxes(list: SessionList): HTMLInputElement[] {
|
||||
return [...(list.shadowRoot?.querySelectorAll<HTMLInputElement>("input.session-checkbox") ?? [])];
|
||||
}
|
||||
|
||||
function checkedBoxes(list: SessionList): HTMLInputElement[] {
|
||||
return checkboxes(list).filter((checkbox) => checkbox.checked);
|
||||
}
|
||||
|
||||
function session(id: string, overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
id,
|
||||
path: `/sessions/${id}.jsonl`,
|
||||
cwd: "/workspace",
|
||||
created: "2026-06-09T00:00:00.000Z",
|
||||
modified: "2026-06-09T00:00:00.000Z",
|
||||
messageCount: 1,
|
||||
firstMessage: id,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -230,16 +230,11 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
const selectedSessions = this.selectedSessions("current");
|
||||
const archivableSessions = selectedSessions.filter((session) => isArchivableSessionInfo(session, this.statuses[session.id], this.sessionPersistenceOptions()));
|
||||
const unreadSelectedSessions = selectedSessions.filter((session) => this.unreadSessionIds.has(session.id));
|
||||
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
<div class="bulk-row selecting">
|
||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive selected</button>
|
||||
${this.renderSelectionControls("current", visibleSessions)}
|
||||
<button ?disabled=${archivableSessions.length === 0} @click=${() => { this.archiveSelectedCurrent(); }}>Archive</button>
|
||||
<button ?disabled=${unreadSelectedSessions.length === 0} @click=${() => { this.markSelectedCurrentRead(); }}>Mark read</button>
|
||||
<button @click=${() => { this.clearSelection("current"); }}>Clear</button>
|
||||
<button @click=${() => { this.closeSelection("current"); }}>Done</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -248,20 +243,33 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
if (visibleSessions.length === 0 || !this.selectionScopes.has("archived")) return null;
|
||||
|
||||
const selectedSessions = this.selectedSessions("archived");
|
||||
const allVisibleSelected = visibleSessions.length > 0 && visibleSessions.every((session) => this.selectedSessionIds.has(session.id));
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
<div class="bulk-row selecting">
|
||||
<button ?disabled=${visibleSessions.length === 0} @click=${() => { this.toggleVisibleSelection(visibleSessions, !allVisibleSelected); }}>${allVisibleSelected ? "Clear visible" : "Select visible"}</button>
|
||||
<small>${selectedSessions.length} selected${visibleSelectedCount !== selectedSessions.length ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete selected</button>
|
||||
<button @click=${() => { this.clearSelection("archived"); }}>Clear</button>
|
||||
<button @click=${() => { this.closeSelection("archived"); }}>Done</button>
|
||||
${this.renderSelectionControls("archived", visibleSessions)}
|
||||
<button class="danger" title=${this.canDeleteArchived ? "Permanently delete selected archived sessions" : this.archivedDeleteUnavailableMessage} ?disabled=${selectedSessions.length === 0 || !this.canDeleteArchived} @click=${() => { this.confirmDeleteSelectedArchived(); }}>Delete</button>
|
||||
${this.canDeleteArchived ? null : html`<small class="capability-hint">${this.archivedDeleteUnavailableMessage}</small>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared selection toggle and count for both scopes. The toggle is binary:
|
||||
* an empty selection offers to select every visible session, and any
|
||||
* existing selection offers to clear the whole scope. Selection mode itself
|
||||
* is exited from the same ☑ heading button that opened it, so the toolbar
|
||||
* carries no separate Done or Clear buttons.
|
||||
*/
|
||||
private renderSelectionControls(scope: SessionSelectionScope, visibleSessions: SessionInfo[]) {
|
||||
const selectedCount = this.selectedSessions(scope).length;
|
||||
const visibleSelectedCount = visibleSessions.filter((session) => this.selectedSessionIds.has(session.id)).length;
|
||||
return html`
|
||||
${selectedCount === 0
|
||||
? html`<button @click=${() => { this.selectVisibleSessions(visibleSessions); }}>Select visible</button>`
|
||||
: html`<button @click=${() => { this.clearSelection(scope); }}>Clear selected</button>`}
|
||||
<small>${selectedCount} selected${visibleSelectedCount !== selectedCount ? html` · ${visibleSelectedCount} visible` : null}</small>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSession(row: SessionRow, descendantCount: number, scope: SessionSelectionScope) {
|
||||
const { session } = row;
|
||||
const cappedDepth = Math.min(row.depth, 2);
|
||||
@@ -445,13 +453,8 @@ export class SessionList extends LitElement implements KeyboardNavigableSection
|
||||
this.selectedSessionIds = next;
|
||||
}
|
||||
|
||||
private toggleVisibleSelection(sessions: SessionInfo[], selected: boolean): void {
|
||||
const next = new Set(this.selectedSessionIds);
|
||||
for (const session of sessions) {
|
||||
if (selected) next.add(session.id);
|
||||
else next.delete(session.id);
|
||||
}
|
||||
this.selectedSessionIds = next;
|
||||
private selectVisibleSessions(sessions: SessionInfo[]): void {
|
||||
this.selectedSessionIds = new Set([...this.selectedSessionIds, ...sessions.map((session) => session.id)]);
|
||||
}
|
||||
|
||||
private selectedSessions(scope: SessionSelectionScope): SessionInfo[] {
|
||||
|
||||
@@ -68,11 +68,13 @@ export interface ChatLine {
|
||||
meta?: {
|
||||
timestamp?: string;
|
||||
model?: { provider?: string; id?: string; responseId?: string };
|
||||
/** Thinking level the assistant message was generated with, when known. */
|
||||
thinkingLevel?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CompletionItem {
|
||||
kind: "command" | "file";
|
||||
kind: "command" | "file" | "model";
|
||||
replaceFrom: number;
|
||||
replaceTo: number;
|
||||
insertText: string;
|
||||
|
||||
@@ -298,4 +298,68 @@ describe("SessionController session_start dialog startup reachability", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1214,9 +1214,15 @@ export class SessionController {
|
||||
const pending = this.pendingSessionStarts.get(tempId);
|
||||
if (pending === undefined) return;
|
||||
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 isCurrentPendingStart = this.isCurrentPendingStart(pending);
|
||||
if (pending.discarded || !isCurrentPendingStart) {
|
||||
if (wasDiscarded || !isCurrentPendingStart) {
|
||||
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||
return;
|
||||
}
|
||||
@@ -1228,6 +1234,9 @@ export class SessionController {
|
||||
sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions],
|
||||
sessionActivities: { ...state.sessionActivities, [tempId]: 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}`,
|
||||
});
|
||||
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
|
||||
@@ -1585,8 +1594,11 @@ export class SessionController {
|
||||
if (backendSessionId === undefined) return;
|
||||
void this.api.status({ id: backendSessionId, cwd: pending.cwd }, pending.machineId).then(
|
||||
(status) => {
|
||||
this.applyStatus(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),
|
||||
|
||||
@@ -31,4 +31,11 @@ describe("input mode helpers", () => {
|
||||
expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" });
|
||||
expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" });
|
||||
});
|
||||
|
||||
it("collapses # model completion tokens to model mode", () => {
|
||||
expect(inputModeForDraft("#anthropic/claude-opus")).toEqual({ kind: "model" });
|
||||
expect(inputModeForDraft("use #gpt-5.2")).toEqual({ kind: "model" });
|
||||
expect(inputModeForDraft("use #gpt-5.2 please")).toEqual({ kind: "normal" });
|
||||
expect(inputModeForDraft("# ")).toEqual({ kind: "normal" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export type InputMode =
|
||||
| { kind: "normal" }
|
||||
| { kind: "command" }
|
||||
| { kind: "file" }
|
||||
| { kind: "model" }
|
||||
| { kind: "shell"; excludeFromContext: boolean };
|
||||
|
||||
export function inputModeForDraft(draft: string): InputMode {
|
||||
@@ -11,7 +12,9 @@ export function inputModeForDraft(draft: string): InputMode {
|
||||
if (trimmed.startsWith("!@")) return { kind: "file" };
|
||||
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
|
||||
if (currentToken(draft).startsWith("/")) return { kind: "command" };
|
||||
if (detectPromptCompletionTrigger(draft)?.kind === "file") return { kind: "file" };
|
||||
const trigger = detectPromptCompletionTrigger(draft);
|
||||
if (trigger?.kind === "file") return { kind: "file" };
|
||||
if (trigger?.kind === "model") return { kind: "model" };
|
||||
return { kind: "normal" };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText } from "./promptCompletions";
|
||||
import type { SessionModel } from "../../shared/apiTypes";
|
||||
import { detectPromptCompletionTrigger, fileCompletionInsertText, modelCompletionChoices } from "./promptCompletions";
|
||||
|
||||
describe("detectPromptCompletionTrigger", () => {
|
||||
it("keeps all-file suggestions active when an @ space query contains spaces", () => {
|
||||
@@ -54,6 +55,73 @@ describe("detectPromptCompletionTrigger", () => {
|
||||
});
|
||||
expect(detectPromptCompletionTrigger("/model")).toEqual({ kind: "command", query: "model", from: 0, to: 6 });
|
||||
});
|
||||
|
||||
it("detects model queries for tokens starting with #", () => {
|
||||
expect(detectPromptCompletionTrigger("#")).toEqual({ kind: "model", query: "", from: 0, to: 1 });
|
||||
expect(detectPromptCompletionTrigger("use #anthropic/claude-opus")).toEqual({ kind: "model", query: "anthropic/claude-opus", from: 4, to: 26 });
|
||||
});
|
||||
|
||||
it("detects model queries at any cursor position but not inside other tokens or quotes", () => {
|
||||
expect(detectPromptCompletionTrigger("use #claude now", 10)).toEqual({ kind: "model", query: "claud", from: 4, to: 10 });
|
||||
expect(detectPromptCompletionTrigger("say hello#world")).toBeUndefined();
|
||||
expect(detectPromptCompletionTrigger('say "#claude')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows model completion for a markdown header only on the bare # keystroke", () => {
|
||||
expect(detectPromptCompletionTrigger("#")).toEqual({ kind: "model", query: "", from: 0, to: 1 });
|
||||
expect(detectPromptCompletionTrigger("# ")).toBeUndefined();
|
||||
expect(detectPromptCompletionTrigger("# Title")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelCompletionChoices", () => {
|
||||
const models: SessionModel[] = [
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
|
||||
{ provider: "google", id: "gemini-3-pro" },
|
||||
];
|
||||
|
||||
it("lists all models for an empty query, mapped to #provider/id insert texts", () => {
|
||||
expect(modelCompletionChoices(models, "")).toEqual([
|
||||
{ insertText: "#anthropic/claude-opus-4-5", detail: "anthropic", description: "Claude Opus 4.5" },
|
||||
{ insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
|
||||
{ insertText: "#openai/gpt-5.2", detail: "openai", description: "GPT-5.2" },
|
||||
{ insertText: "#google/gemini-3-pro", detail: "google" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters case-insensitively across provider/id, id, and display name", () => {
|
||||
expect(modelCompletionChoices(models, "OPUS-4")).toEqual([
|
||||
{ insertText: "#anthropic/claude-opus-4-5", detail: "anthropic", description: "Claude Opus 4.5" },
|
||||
]);
|
||||
expect(modelCompletionChoices(models, "openai")).toEqual([
|
||||
{ insertText: "#openai/gpt-5.2", detail: "openai", description: "GPT-5.2" },
|
||||
]);
|
||||
expect(modelCompletionChoices(models, "sonnet 4.5")).toEqual([
|
||||
{ insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits the description when the display name matches the id", () => {
|
||||
expect(modelCompletionChoices([{ provider: "ollama", id: "qwen3", name: "qwen3" }], "")).toEqual([
|
||||
{ insertText: "#ollama/qwen3", detail: "ollama" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips models without a provider or id since they cannot form a #provider/id reference", () => {
|
||||
expect(modelCompletionChoices([{ id: "orphan" }, { provider: "ghost" }, { provider: "openai", id: "gpt-5.2" }], "")).toEqual([
|
||||
{ insertText: "#openai/gpt-5.2", detail: "openai" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps the list at 12 models, preserving server order", () => {
|
||||
const many = Array.from({ length: 20 }, (_, index) => ({ provider: "p", id: `m${String(index)}` }));
|
||||
const choices = modelCompletionChoices(many, "");
|
||||
expect(choices).toHaveLength(12);
|
||||
expect(choices[0]?.insertText).toBe("#p/m0");
|
||||
expect(choices[11]?.insertText).toBe("#p/m11");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileCompletionInsertText", () => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { SessionModel } from "../../shared/apiTypes";
|
||||
|
||||
export type PromptCompletionTrigger =
|
||||
| { kind: "command"; query: string; from: number; to: number }
|
||||
| { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean };
|
||||
| { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean }
|
||||
| { kind: "model"; query: string; from: number; to: number };
|
||||
|
||||
export function detectPromptCompletionTrigger(draft: string, cursor = draft.length): PromptCompletionTrigger | undefined {
|
||||
const beforeCursor = draft.slice(0, cursor);
|
||||
@@ -17,9 +20,46 @@ export function detectPromptCompletionTrigger(draft: string, cursor = draft.leng
|
||||
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
|
||||
if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" };
|
||||
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" };
|
||||
if (token.startsWith("#")) return { kind: "model", query: token.slice(1), from: tokenStart, to: cursor };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface ModelCompletionChoice {
|
||||
insertText: string;
|
||||
detail: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const MODEL_COMPLETION_LIMIT = 12;
|
||||
|
||||
export function modelCompletionChoices(models: readonly SessionModel[], query: string): ModelCompletionChoice[] {
|
||||
const needle = query.toLowerCase();
|
||||
const choices: ModelCompletionChoice[] = [];
|
||||
for (const model of models) {
|
||||
// A completion must produce a strict provider/model-id reference, so models
|
||||
// missing either half of the identity can never be inserted.
|
||||
if (!hasQualifiedModelId(model)) continue;
|
||||
if (!modelMatchesQuery(model, needle)) continue;
|
||||
choices.push({
|
||||
insertText: `#${model.provider}/${model.id}`,
|
||||
detail: model.provider,
|
||||
...(model.name !== undefined && model.name !== "" && model.name !== model.id ? { description: model.name } : {}),
|
||||
});
|
||||
if (choices.length >= MODEL_COMPLETION_LIMIT) break;
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
function hasQualifiedModelId(model: SessionModel): model is SessionModel & { provider: string; id: string } {
|
||||
return typeof model.provider === "string" && model.provider !== "" && typeof model.id === "string" && model.id !== "";
|
||||
}
|
||||
|
||||
function modelMatchesQuery(model: SessionModel & { provider: string; id: string }, needle: string): boolean {
|
||||
return `${model.provider}/${model.id}`.toLowerCase().includes(needle)
|
||||
|| model.id.toLowerCase().includes(needle)
|
||||
|| (model.name?.toLowerCase().includes(needle) ?? false);
|
||||
}
|
||||
|
||||
export function fileCompletionInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
||||
const prefix = allPrefix ?? "@";
|
||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPiWebCustomToolDefinitions, sessionAllowsDelegationTools, type PiSessionManager } from "./piSessionService.js";
|
||||
import type { SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
@@ -14,13 +15,14 @@ afterEach(async () => {
|
||||
|
||||
function delegationDeps() {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "independent-1", cwd: "/workspace" }));
|
||||
const subsessionSpawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" }));
|
||||
const subsessions: SubsessionToolDeps = {
|
||||
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
|
||||
spawn: subsessionSpawn,
|
||||
list: vi.fn(() => Promise.resolve([])),
|
||||
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
|
||||
read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
|
||||
};
|
||||
return { spawn, subsessions };
|
||||
return { spawn, subsessions, subsessionSpawn };
|
||||
}
|
||||
|
||||
function toolNames(definitions: ReturnType<typeof createPiWebCustomToolDefinitions>): string[] {
|
||||
@@ -35,6 +37,20 @@ function manager(id: string, file: string | undefined, entries: readonly unknown
|
||||
});
|
||||
}
|
||||
|
||||
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
|
||||
|
||||
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
|
||||
// The delegation tools only read sessionManager and model from the context.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools read.
|
||||
return { sessionManager: manager(sessionId, sessionFile), ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function findTool(definitions: ReturnType<typeof createPiWebCustomToolDefinitions>, name: string) {
|
||||
const tool = definitions.find((definition) => definition.name === name);
|
||||
if (tool === undefined) throw new Error(`missing tool ${name}`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
describe("delegation tool capability boundary", () => {
|
||||
it("provides every globally enabled delegation tool to unrestricted sessions", () => {
|
||||
const { spawn, subsessions } = delegationDeps();
|
||||
@@ -63,6 +79,39 @@ describe("delegation tool capability boundary", () => {
|
||||
expect(toolNames(createPiWebCustomToolDefinitions("/workspace", false, spawn, subsessions))).toEqual(["edit"]);
|
||||
});
|
||||
|
||||
it("wires the dispatching session identity, inherited model, and model spec into spawn_session", async () => {
|
||||
const { spawn, subsessions } = delegationDeps();
|
||||
const spawnTool = findTool(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions), "spawn_session");
|
||||
|
||||
await spawnTool.execute("call-1", { prompt: "go", model: "openai/gpt-5" }, undefined, undefined, ctxFor("spawner-7", "/sessions/spawner-7.jsonl", dispatchModel));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/workspace",
|
||||
spawningSessionId: "spawner-7",
|
||||
prompt: "go",
|
||||
cwd: undefined,
|
||||
model: dispatchModel,
|
||||
modelSpec: "openai/gpt-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("wires the parent identity, inherited model, and model spec into spawn_subsession", async () => {
|
||||
const { spawn, subsessions, subsessionSpawn } = delegationDeps();
|
||||
const spawnTool = findTool(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions), "spawn_subsession");
|
||||
|
||||
await spawnTool.execute("call-2", { prompt: "go", model: "openai/gpt-5" }, undefined, undefined, ctxFor("parent-9", "/sessions/parent-9.jsonl", dispatchModel));
|
||||
|
||||
expect(subsessionSpawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/workspace",
|
||||
parentSessionId: "parent-9",
|
||||
parentSessionFile: "/sessions/parent-9.jsonl",
|
||||
prompt: "go",
|
||||
cwd: undefined,
|
||||
model: dispatchModel,
|
||||
modelSpec: "openai/gpt-5",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["human-created", "spawn_session-created"])("allows delegation for a %s session without tracked-child provenance", async () => {
|
||||
const sessionManager = manager("session-1", undefined);
|
||||
const open = vi.fn(() => { throw new Error("no parent session should be opened"); });
|
||||
|
||||
@@ -597,4 +597,37 @@ describe("PiSessionService session_start dialog startup reachability", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PiSessionService } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
describe("PiSessionService", () => {
|
||||
describe("assistant thinking-level attribution", () => {
|
||||
function messagesService(branch: unknown[], patch: Parameters<typeof fakeRuntime>[1] = {}) {
|
||||
const fake = fakeRuntime("session-1", {
|
||||
sessionFile: "/tmp/session-1.jsonl",
|
||||
sessionManager: fakeSessionManager("/workspace", { getBranch: () => branch }),
|
||||
...patch,
|
||||
});
|
||||
const events = new CapturingSessionEventHub();
|
||||
const service = new PiSessionService(events, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("session-1")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
return { fake, service, events };
|
||||
}
|
||||
|
||||
it("annotates paged assistant messages with the thinking level in effect from branch entries", async () => {
|
||||
const branch = [
|
||||
{ type: "message", message: { role: "user", content: [{ type: "text", text: "hi" }] } },
|
||||
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "before any entry" }] } },
|
||||
{ type: "thinking_level_change", thinkingLevel: "medium" },
|
||||
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "first answer" }] } },
|
||||
{ type: "thinking_level_change", thinkingLevel: "max" },
|
||||
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "second answer" }] } },
|
||||
{ type: "thinking_level_change", thinkingLevel: "off" },
|
||||
{ type: "message", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "unthinking answer" }] } },
|
||||
{ type: "message", message: { role: "toolResult", toolName: "bash", content: [{ type: "text", text: "done" }] } },
|
||||
];
|
||||
const { service } = messagesService(branch);
|
||||
|
||||
const messages = await service.messages(sessionRef("session-1"));
|
||||
|
||||
expect(messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
||||
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "before any entry" }] },
|
||||
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "first answer" }], thinkingLevel: "medium" },
|
||||
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "second answer" }], thinkingLevel: "max" },
|
||||
{ role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "unthinking answer" }] },
|
||||
{ role: "toolResult", toolName: "bash", content: [{ type: "text", text: "done" }] },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("annotates live assistant message.end events with the session's current thinking level", async () => {
|
||||
const { fake, service, events } = messagesService([], { thinkingLevel: "high" });
|
||||
await service.status(sessionRef("session-1")); // bring the session online so it publishes events
|
||||
|
||||
fake.emit({ type: "message_end", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "answer" }] } });
|
||||
fake.emit({ type: "message_end", message: { role: "user", content: [{ type: "text", text: "next" }] } });
|
||||
|
||||
const messageEnds = events.sessionEvents.map(({ event }) => event).filter((event) => event.type === "message.end");
|
||||
expect(messageEnds).toEqual([
|
||||
{ type: "message.end", message: { role: "assistant", provider: "openai", model: "gpt-4.1", content: [{ type: "text", text: "answer" }], thinkingLevel: "high" } },
|
||||
{ type: "message.end", message: { role: "user", content: [{ type: "text", text: "next" }] } },
|
||||
]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("annotates the join-time stream snapshot partial with the current thinking level", async () => {
|
||||
const streamingMessage = {
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
content: [{ type: "thinking", thinking: "hmm", thinkingSignature: "provider-signature" }],
|
||||
};
|
||||
const { service } = messagesService([], { thinkingLevel: "xhigh", state: { streamingMessage } });
|
||||
|
||||
const snapshot = await service.streamSnapshot(sessionRef("session-1"));
|
||||
|
||||
expect(snapshot.partial).toEqual({
|
||||
role: "assistant",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
content: [{ type: "thinking", thinking: "hmm" }],
|
||||
thinkingLevel: "xhigh",
|
||||
});
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
const TEST_MODEL_SPEC = "anthropic/claude-sonnet-4-5-20250929";
|
||||
|
||||
describe("PiSessionService", () => {
|
||||
describe("spawnSession", () => {
|
||||
@@ -25,7 +26,7 @@ describe("PiSessionService", () => {
|
||||
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
|
||||
const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" });
|
||||
|
||||
const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" });
|
||||
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue the plan", cwd: "/workspace-feature" });
|
||||
|
||||
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
|
||||
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
|
||||
@@ -53,17 +54,80 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model });
|
||||
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", model });
|
||||
|
||||
expect(initialModel).toBe(model);
|
||||
expect(delegationToolsEnabled).toBe(true);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("passes the dispatching session's thinking level to the spawned session's runtime", async () => {
|
||||
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
|
||||
let initialThinkingLevel: unknown;
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialThinkingLevel = options.initialThinkingLevel;
|
||||
return fake.runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", thinkingLevel: "high" });
|
||||
|
||||
expect(initialThinkingLevel).toBe("high");
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("leaves the spawned session's thinking level to pi defaults when the dispatcher has none", async () => {
|
||||
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
|
||||
let initialThinkingLevel: unknown = "unset";
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialThinkingLevel = options.initialThinkingLevel;
|
||||
return fake.runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" });
|
||||
|
||||
expect(initialThinkingLevel).toBeUndefined();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("names the spawned session's model in the result", async () => {
|
||||
const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(spawned.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" });
|
||||
|
||||
expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects an out-of-project target without starting a session", async () => {
|
||||
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" }))
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/elsewhere" }))
|
||||
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
expect(service.activeCount()).toBe(0);
|
||||
@@ -73,7 +137,7 @@ describe("PiSessionService", () => {
|
||||
it("rejects when the spawning session is not in a registered project", async () => {
|
||||
const { service } = spawnService({ allowed: false, reason: "not-registered" });
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: undefined }))
|
||||
.rejects.toThrow("Spawning session is not in a registered project");
|
||||
await service.dispose();
|
||||
});
|
||||
@@ -88,9 +152,120 @@ describe("PiSessionService", () => {
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined }))
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: undefined }))
|
||||
.rejects.toThrow("Spawning sessions is disabled");
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
describe("model spec resolution", () => {
|
||||
/**
|
||||
* Harness: the spawner comes online via `service.start`, then the spawn
|
||||
* creates the next queued runtime. `initialModels` records every
|
||||
* creation-time model so tests can see exactly what the spawned session
|
||||
* was started with.
|
||||
*/
|
||||
function specService(spawnerPatch: Parameters<typeof fakeRuntime>[1] = {}) {
|
||||
const spawner = fakeRuntime("spawner-1", { sessionFile: "/tmp/spawner-1.jsonl", ...spawnerPatch });
|
||||
const spawned = fakeRuntime("spawned-2", { sessionFile: "/tmp/spawned-2.jsonl", model: testModel() });
|
||||
const initialModels: PiAgentSession["model"][] = [];
|
||||
const runtimes = [spawner.runtime, spawned.runtime];
|
||||
let index = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialModels.push(options.initialModel);
|
||||
const runtime = runtimes[index] ?? spawned.runtime;
|
||||
index += 1;
|
||||
return runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
return { service, spawner, spawned, initialModels };
|
||||
}
|
||||
|
||||
it("resolves the spec against the spawning session's scoped models and names it in the result", async () => {
|
||||
const scoped = testModel();
|
||||
const { service, initialModels } = specService({ scopedModels: [{ model: scoped }] });
|
||||
await service.start("/workspace");
|
||||
|
||||
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: TEST_MODEL_SPEC });
|
||||
|
||||
expect(initialModels).toHaveLength(2);
|
||||
expect(initialModels[0]).toBeUndefined();
|
||||
expect(initialModels[1]).toBe(scoped);
|
||||
expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("falls back to a direct runtime lookup when the spec is not among the available candidates", async () => {
|
||||
// The shared test runtime has no configured auth, so its available
|
||||
// snapshot is empty; only the getModel fallback can resolve the spec.
|
||||
const { service, initialModels } = specService();
|
||||
await service.start("/workspace");
|
||||
|
||||
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: TEST_MODEL_SPEC });
|
||||
|
||||
expect(initialModels[1]).toMatchObject({ provider: "anthropic", id: "claude-sonnet-4-5-20250929" });
|
||||
expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it.each(["no-slash", "anthropic/", "/id"])("rejects the malformed spec %s without starting a session", async (modelSpec) => {
|
||||
const { service, spawned, initialModels } = specService({ scopedModels: [{ model: testModel() }] });
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec }))
|
||||
.rejects.toThrow(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`);
|
||||
expect(initialModels).toEqual([undefined]);
|
||||
expect(spawned.calls.prompt).toEqual([]);
|
||||
expect(service.activeCount()).toBe(1);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects an unknown spec without starting a session", async () => {
|
||||
const { service, spawned, initialModels } = specService({ scopedModels: [{ model: testModel() }] });
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: "anthropic/does-not-exist" }))
|
||||
.rejects.toThrow('Unknown model "anthropic/does-not-exist". Pass an exact "provider/model-id".');
|
||||
expect(initialModels).toEqual([undefined]);
|
||||
expect(spawned.calls.prompt).toEqual([]);
|
||||
expect(service.activeCount()).toBe(1);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("rejects an unknown spec even when the spawning session has no available models", async () => {
|
||||
const { service } = specService();
|
||||
await service.start("/workspace");
|
||||
|
||||
await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: "ghost/model" }))
|
||||
.rejects.toThrow('Unknown model "ghost/model". Pass an exact "provider/model-id".');
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("does not look up the spawning session when no model spec is given", async () => {
|
||||
const spawned = fakeRuntime("spawned-2", { sessionFile: "/tmp/spawned-2.jsonl", model: testModel() });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime: runtimeCreator(spawned.runtime),
|
||||
sessionManager: sessionGateway([]),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
// "ghost" is not a resolvable session, and the default path must not care.
|
||||
const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "ghost", prompt: "go", cwd: "/workspace-feature" });
|
||||
|
||||
expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC });
|
||||
expect(spawned.calls.prompt).toEqual([{ text: "go", options: undefined }]);
|
||||
await service.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,17 +64,19 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("uses the parent model and disables delegation before creating the tracked child runtime", async () => {
|
||||
it("uses the parent model and thinking level and disables delegation before creating the tracked child runtime", async () => {
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
|
||||
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
|
||||
const model = testModel();
|
||||
const initialModels: PiAgentSession["model"][] = [];
|
||||
const initialThinkingLevels: unknown[] = [];
|
||||
const delegationCapabilities: boolean[] = [];
|
||||
const runtimes = [parent.runtime, child.runtime];
|
||||
let index = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialModels.push(options.initialModel);
|
||||
initialThinkingLevels.push(options.initialThinkingLevel);
|
||||
delegationCapabilities.push(options.delegationToolsEnabled);
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
@@ -91,13 +93,46 @@ describe("PiSessionService", () => {
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model });
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model, thinkingLevel: "max" });
|
||||
|
||||
expect(initialModels).toEqual([undefined, model]);
|
||||
expect(initialThinkingLevels).toEqual([undefined, "max"]);
|
||||
expect(delegationCapabilities).toEqual([true, false]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("resolves a model spec against the parent's models and names it in the result", async () => {
|
||||
const scoped = testModel();
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl", scopedModels: [{ model: scoped }] });
|
||||
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature"), model: scoped });
|
||||
const initialModels: PiAgentSession["model"][] = [];
|
||||
const runtimes = [parent.runtime, child.runtime];
|
||||
let index = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
|
||||
await Promise.resolve();
|
||||
initialModels.push(options.initialModel);
|
||||
const runtime = runtimes[index] ?? child.runtime;
|
||||
index += 1;
|
||||
return runtime;
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
archiveStore: emptyArchiveStore(),
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) },
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.start("/workspace");
|
||||
const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", modelSpec: "anthropic/claude-sonnet-4-5-20250929" });
|
||||
|
||||
expect(initialModels[1]).toBe(scoped);
|
||||
expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature", model: "anthropic/claude-sonnet-4-5-20250929" });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("persists tracked child links in the parent and child sessions", async () => {
|
||||
const parentPersisted: { customType: string; data?: unknown }[] = [];
|
||||
const childPersisted: { customType: string; data?: unknown }[] = [];
|
||||
|
||||
@@ -120,6 +120,30 @@ function spawnTargetError(decision: Extract<SpawnTargetDecision, { allowed: fals
|
||||
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
|
||||
}
|
||||
|
||||
function modelSpecOf(model: { provider: string; id: string }): string {
|
||||
return `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a strict `provider/model-id` spec: split on the first `/` (model ids
|
||||
* may themselves contain `/`) and require both parts to be non-empty.
|
||||
*/
|
||||
function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
|
||||
const slash = spec.indexOf("/");
|
||||
if (slash <= 0 || slash === spec.length - 1) return undefined;
|
||||
return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Error for a spawn-tool model spec that matched nothing. States the facts —
|
||||
* the bad spec and the required format — with deliberately no model list
|
||||
* (a list would invite guesses). The agent loop turns the throw into an
|
||||
* error tool result; how to recover is the agent's call.
|
||||
*/
|
||||
function unknownSpawnModelError(modelSpec: string): Error {
|
||||
return new Error(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`);
|
||||
}
|
||||
|
||||
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
|
||||
return `${sessionId}:${provider}/${modelId}`;
|
||||
}
|
||||
@@ -219,6 +243,11 @@ type SessionCreationProvenance = "tracked-subsession";
|
||||
interface StartSessionOptions {
|
||||
parentSession?: string;
|
||||
initialModel?: AgentModel;
|
||||
/**
|
||||
* Thinking level for the brand new session; omit to resolve from settings
|
||||
* and pi defaults. Pi clamps it to the initial model's capabilities.
|
||||
*/
|
||||
initialThinkingLevel?: ClientThinkingLevel;
|
||||
/**
|
||||
* Opaque label, echoed on this construction's startup progress so a browser
|
||||
* row with no session id yet can recognise its own.
|
||||
@@ -415,7 +444,7 @@ interface PendingSessionOpen {
|
||||
promise: Promise<ActiveSession<PiSessionRuntime>>;
|
||||
}
|
||||
|
||||
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> {
|
||||
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "initialThinkingLevel" | "creationProvenance" | "startupToken"> {
|
||||
notificationGeneration?: SessionNotificationGeneration;
|
||||
notifications?: "enabled" | "disabled";
|
||||
/**
|
||||
@@ -583,11 +612,13 @@ interface CreateAgentRuntimeOptions {
|
||||
sessionManager: PiSessionManager;
|
||||
delegationToolsEnabled: boolean;
|
||||
initialModel?: AgentModel;
|
||||
initialThinkingLevel?: ClientThinkingLevel;
|
||||
}
|
||||
|
||||
type PiWebRuntimeFactoryOptions = Parameters<CreateAgentSessionRuntimeFactory>[0] & {
|
||||
delegationToolsEnabled?: boolean;
|
||||
initialModel?: AgentModel;
|
||||
initialThinkingLevel?: ClientThinkingLevel;
|
||||
};
|
||||
|
||||
type PiWebCreateAgentSessionRuntimeFactory = (
|
||||
@@ -598,7 +629,7 @@ type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory,
|
||||
|
||||
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
|
||||
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
|
||||
const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled);
|
||||
const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled);
|
||||
return createAgentSessionRuntime(runtimeFactory, {
|
||||
cwd: options.cwd,
|
||||
agentDir: options.agentDir,
|
||||
@@ -609,20 +640,26 @@ function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntime
|
||||
function createRuntimeWithOneShotSessionOptions(
|
||||
createRuntime: PiWebCreateAgentSessionRuntimeFactory,
|
||||
initialModel: AgentModel | undefined,
|
||||
initialThinkingLevel: ClientThinkingLevel | undefined,
|
||||
delegationToolsEnabled: boolean,
|
||||
): CreateAgentSessionRuntimeFactory {
|
||||
// These inputs belong only to the session being opened. A later runtime
|
||||
// replacement resolves its own model and delegation capability.
|
||||
// replacement resolves its own model and delegation capability, and restores
|
||||
// the thinking level from the existing session file.
|
||||
let pendingInitialModel = initialModel;
|
||||
let pendingInitialThinkingLevel = initialThinkingLevel;
|
||||
let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled;
|
||||
return async (options) => {
|
||||
const model = pendingInitialModel;
|
||||
const thinkingLevel = pendingInitialThinkingLevel;
|
||||
const toolsEnabled = pendingDelegationToolsEnabled;
|
||||
pendingInitialModel = undefined;
|
||||
pendingInitialThinkingLevel = undefined;
|
||||
pendingDelegationToolsEnabled = undefined;
|
||||
return createRuntime({
|
||||
...options,
|
||||
...(model === undefined ? {} : { initialModel: model }),
|
||||
...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
|
||||
...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
|
||||
});
|
||||
};
|
||||
@@ -654,7 +691,7 @@ function createDefaultRuntimeFactory(
|
||||
subsessions?: SubsessionToolDeps,
|
||||
askUser?: AskUserToolDeps,
|
||||
): PiWebCreateAgentSessionRuntimeFactory {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => {
|
||||
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
|
||||
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
|
||||
const resolvedDelegationToolsEnabled = delegationToolsEnabled
|
||||
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
|
||||
@@ -665,6 +702,7 @@ function createDefaultRuntimeFactory(
|
||||
customTools,
|
||||
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
|
||||
...(initialModel === undefined ? {} : { model: initialModel }),
|
||||
...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
|
||||
});
|
||||
return { ...result, services, diagnostics: services.diagnostics };
|
||||
};
|
||||
@@ -1005,6 +1043,9 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.clearUnreadPublicationRetry();
|
||||
clearInterval(this.heartbeat);
|
||||
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();
|
||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||
const activeSessions = Array.from(new Set(this.active.values()));
|
||||
@@ -1143,6 +1184,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
startupIntent: "create",
|
||||
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
|
||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
|
||||
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
||||
},
|
||||
);
|
||||
@@ -1175,13 +1217,26 @@ export class PiSessionService implements SessionRouteService {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
|
||||
// A model spec overrides the inherited model. Only a spec triggers a
|
||||
// spawning-session lookup; the default path must not depend on it.
|
||||
const model = input.modelSpec === undefined
|
||||
? input.model
|
||||
: await this.resolveSpawnModel(input.spawningSessionId, input.modelSpec);
|
||||
const created = await this.start(decision.cwd, {
|
||||
...(model === undefined ? {} : { initialModel: model }),
|
||||
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
|
||||
});
|
||||
const modelUsed = this.active.get(created.id)?.runtime.session.model;
|
||||
await this.prompt(created.id, input.prompt);
|
||||
this.logger.info(
|
||||
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||
"spawn_session started a new session",
|
||||
);
|
||||
return { sessionId: created.id, cwd: decision.cwd };
|
||||
return {
|
||||
sessionId: created.id,
|
||||
cwd: decision.cwd,
|
||||
...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1194,11 +1249,18 @@ export class PiSessionService implements SessionRouteService {
|
||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||
if (!decision.allowed) throw spawnTargetError(decision);
|
||||
// A model spec overrides the inherited model and is resolved against the
|
||||
// parent's model runtime; only a spec triggers that lookup.
|
||||
const model = input.modelSpec === undefined
|
||||
? input.model
|
||||
: await this.resolveSpawnModel(input.parentSessionId, input.modelSpec);
|
||||
const created = await this.startSession(decision.cwd, {
|
||||
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
|
||||
...(input.model === undefined ? {} : { initialModel: input.model }),
|
||||
...(model === undefined ? {} : { initialModel: model }),
|
||||
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
|
||||
creationProvenance: "tracked-subsession",
|
||||
});
|
||||
const modelUsed = this.active.get(created.id)?.runtime.session.model;
|
||||
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
||||
const link: TrackedSubsessionLink = {
|
||||
parentSessionId: input.parentSessionId,
|
||||
@@ -1215,7 +1277,42 @@ export class PiSessionService implements SessionRouteService {
|
||||
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||
"spawn_subsession started a tracked child session",
|
||||
);
|
||||
return { sessionId: created.id, cwd: decision.cwd };
|
||||
return {
|
||||
sessionId: created.id,
|
||||
cwd: decision.cwd,
|
||||
...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The models a session may pick from: its scoped set when model-scoped,
|
||||
* otherwise the runtime's available snapshot. Refreshes the runtime catalog
|
||||
* first so callers see newly configured providers and models.
|
||||
*/
|
||||
private async sessionModelCandidates(session: PiAgentSession): Promise<readonly AgentModel[]> {
|
||||
await session.modelRuntime.refresh();
|
||||
return session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
: session.modelRuntime.getAvailableSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a strict `provider/model-id` spec from a spawn tool against the
|
||||
* *spawning* session's model runtime, using the same candidates
|
||||
* {@link setModel} offers plus a direct runtime lookup as fallback. Unknown
|
||||
* or malformed specs throw; the agent loop turns that into an error tool
|
||||
* result the spawning agent can retry from.
|
||||
*/
|
||||
private async resolveSpawnModel(spawningSessionId: string, modelSpec: string): Promise<AgentModel> {
|
||||
const session = await this.getOrOpen(spawningSessionId);
|
||||
const parsed = parseModelSpec(modelSpec);
|
||||
const candidates = await this.sessionModelCandidates(session);
|
||||
const model = parsed === undefined
|
||||
? undefined
|
||||
: candidates.find((candidate) => candidate.provider === parsed.provider && candidate.id === parsed.modelId)
|
||||
?? session.modelRuntime.getModel(parsed.provider, parsed.modelId);
|
||||
if (model === undefined) throw unknownSpawnModelError(modelSpec);
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1813,16 +1910,13 @@ export class PiSessionService implements SessionRouteService {
|
||||
const streamingMessage = session.state.streamingMessage;
|
||||
const partial = streamingMessage === undefined || streamingMessage === null
|
||||
? null
|
||||
: projectBrowserMessage(streamingMessage);
|
||||
: annotateAssistantThinkingLevel(projectBrowserMessage(streamingMessage), session.thinkingLevel);
|
||||
return { seq, partial };
|
||||
}
|
||||
|
||||
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
|
||||
const session = await this.getOrOpen(ref);
|
||||
await session.modelRuntime.refresh();
|
||||
const models = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
: session.modelRuntime.getAvailableSnapshot();
|
||||
const models = await this.sessionModelCandidates(session);
|
||||
return models.map(modelToClientModel);
|
||||
}
|
||||
|
||||
@@ -1830,11 +1924,8 @@ export class PiSessionService implements SessionRouteService {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.assertTreeNavigationInactive(session, "change models");
|
||||
await session.modelRuntime.refresh();
|
||||
const candidates = await this.sessionModelCandidates(session);
|
||||
this.assertTreeNavigationInactive(session, "change models");
|
||||
const candidates = session.scopedModels.length > 0
|
||||
? session.scopedModels.map((scoped) => scoped.model)
|
||||
: session.modelRuntime.getAvailableSnapshot();
|
||||
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
|
||||
?? session.modelRuntime.getModel(provider, modelId);
|
||||
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||
@@ -2592,6 +2683,10 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
|
||||
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);
|
||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||
const active = this.active.get(sessionId);
|
||||
@@ -2788,6 +2883,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
sessionManager,
|
||||
delegationToolsEnabled,
|
||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
|
||||
});
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
let boundSession = runtime.session;
|
||||
@@ -3124,7 +3220,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
}
|
||||
active.unsubscribe = session.subscribe((event) => {
|
||||
this.events.publish(session.sessionId, toClientEvent(event));
|
||||
this.events.publish(session.sessionId, toClientEvent(event, session.thinkingLevel));
|
||||
this.publishActivityForEvent(session, event);
|
||||
const eventType = getString(event, "type");
|
||||
if (eventType === "agent_end") this.abortRunScopedExtensionDialogs(session.sessionId);
|
||||
@@ -4023,11 +4119,30 @@ function stringValue(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the thinking level in effect when an assistant message was generated,
|
||||
* so chat bubbles can show it next to the model. Non-assistant messages pass
|
||||
* through by reference; assistant messages are copied only when a level is set.
|
||||
* "off" is the absence of thinking, not a level worth labeling on every bubble.
|
||||
*/
|
||||
function annotateAssistantThinkingLevel(message: unknown, thinkingLevel: string | undefined): unknown {
|
||||
if (thinkingLevel === undefined || thinkingLevel === "" || thinkingLevel === "off") return message;
|
||||
if (!isRecord(message) || message["role"] !== "assistant") return message;
|
||||
return { ...message, thinkingLevel };
|
||||
}
|
||||
|
||||
function historyMessages(session: PiAgentSession): unknown[] {
|
||||
const messages: unknown[] = [];
|
||||
// Pi records the initial level at session creation and every later change, so
|
||||
// walking the branch yields the level in effect for each assistant message.
|
||||
let thinkingLevel: string | undefined;
|
||||
for (const entry of session.sessionManager.getBranch()) {
|
||||
if (!isRecord(entry)) continue;
|
||||
if (entry["type"] === "message") messages.push(entry["message"]);
|
||||
if (entry["type"] === "message") messages.push(annotateAssistantThinkingLevel(entry["message"], thinkingLevel));
|
||||
else if (entry["type"] === "thinking_level_change") {
|
||||
const level = getString(entry, "thinkingLevel");
|
||||
if (level !== undefined) thinkingLevel = level;
|
||||
}
|
||||
else if (entry["type"] === "custom_message" && entry["display"] === true) messages.push({ role: "custom", content: entry["content"], customType: entry["customType"], details: entry["details"] });
|
||||
else if (entry["type"] === "compaction") messages.push({ role: "system", source: "compaction", content: `Compacted history:\n\n${stringValue(entry["summary"])}` });
|
||||
else if (entry["type"] === "branch_summary") messages.push({ role: "system", source: "branch_summary", content: `Branch summary:\n\n${stringValue(entry["summary"])}` });
|
||||
@@ -4071,7 +4186,7 @@ function finalAssistantText(messages: readonly unknown[]): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function toClientEvent(event: unknown): SessionUiEvent {
|
||||
function toClientEvent(event: unknown, thinkingLevel?: string): SessionUiEvent {
|
||||
const eventType = getString(event, "type");
|
||||
const assistantMessageEvent = getProperty(event, "assistantMessageEvent");
|
||||
if (eventType === "message_update" && getString(assistantMessageEvent, "type") === "text_delta") {
|
||||
@@ -4096,7 +4211,8 @@ function toClientEvent(event: unknown): SessionUiEvent {
|
||||
if (eventType === "agent_end") return { type: "agent.end" };
|
||||
if (eventType === "message_end") {
|
||||
const message = getProperty(event, "message");
|
||||
return message === undefined ? { type: "message.end" } : { type: "message.end", message };
|
||||
if (message === undefined) return { type: "message.end" };
|
||||
return { type: "message.end", message: annotateAssistantThinkingLevel(message, thinkingLevel) };
|
||||
}
|
||||
return { type: "pi.event", eventType: eventType ?? "unknown" };
|
||||
}
|
||||
|
||||
@@ -2,20 +2,23 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
|
||||
const ctx = {} as ExtensionContext;
|
||||
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
|
||||
const ctxWithModel = { model: dispatchModel } as ExtensionContext;
|
||||
|
||||
function ctxFor(sessionId: string, model?: unknown, thinkingLevel?: string): ExtensionContext {
|
||||
const sessionManager = { getSessionId: () => sessionId };
|
||||
// The spawn tool only reads sessionManager.getSessionId, model, and thinkingLevel.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads.
|
||||
return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
describe("createSpawnSessionToolDefinition", () => {
|
||||
it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => {
|
||||
it("passes the spawning identity, explicit cwd, dispatching model, thinking level, and prompt to spawn callback", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxWithModel);
|
||||
const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel, "high"));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel });
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel, thinkingLevel: "high" });
|
||||
expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" });
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-1 in /repos/a-feature." });
|
||||
});
|
||||
@@ -28,20 +31,49 @@ describe("createSpawnSessionToolDefinition", () => {
|
||||
expect(tool.description).not.toMatch(/subsession|child|parent/i);
|
||||
});
|
||||
|
||||
it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => {
|
||||
it("forwards omitted cwd as undefined and omits a missing dispatching model and thinking level", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctx);
|
||||
await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctxFor("spawner-1"));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined });
|
||||
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "continue", cwd: undefined });
|
||||
});
|
||||
|
||||
it("forwards an explicit model as a model spec alongside the inherited model", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a", model: "openai/gpt-5" }));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
const result = await tool.execute("call-3", { prompt: "continue", model: "openai/gpt-5" }, undefined, undefined, ctxFor("spawner-1", dispatchModel));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/repos/a",
|
||||
spawningSessionId: "spawner-1",
|
||||
prompt: "continue",
|
||||
cwd: undefined,
|
||||
model: dispatchModel,
|
||||
modelSpec: "openai/gpt-5",
|
||||
});
|
||||
expect(result.details).toEqual({ sessionId: "new-3", cwd: "/repos/a", model: "openai/gpt-5" });
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-3 in /repos/a using model openai/gpt-5." });
|
||||
});
|
||||
|
||||
it("teaches the model parameter format and the #provider/model-id reference convention", () => {
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn: vi.fn() });
|
||||
|
||||
expect(tool.parameters).toMatchObject({
|
||||
properties: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- stringMatching yields `any` against the loosely typed tool schema.
|
||||
model: { description: expect.stringMatching(/provider\/model-id.*#provider\/model-id.*Omit to inherit/s) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates the spawn callback error so the agent loop reports it", async () => {
|
||||
const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
|
||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
||||
|
||||
await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx))
|
||||
await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctxFor("spawner-1")))
|
||||
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,16 +4,25 @@ import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-age
|
||||
export interface SpawnSessionResult {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
/** Model the spawned session runs with, as `provider/id`; absent when unknown. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
|
||||
export type SpawnSessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
|
||||
|
||||
export interface SpawnSessionInvocation {
|
||||
spawningCwd: string;
|
||||
/** Id of the dispatching session; used to resolve {@link modelSpec} against its model runtime. */
|
||||
spawningSessionId: string;
|
||||
prompt: string;
|
||||
cwd: string | undefined;
|
||||
/** Current model from the dispatching session, used as the spawned session's default. */
|
||||
model?: SpawnSessionModel;
|
||||
/** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */
|
||||
modelSpec?: string;
|
||||
/** Dispatching session's current thinking level, inherited by the spawned session (pi clamps it to the spawned model's capabilities). */
|
||||
thinkingLevel?: SpawnSessionThinkingLevel;
|
||||
}
|
||||
|
||||
export interface SpawnSessionToolDeps {
|
||||
@@ -29,6 +38,9 @@ const SpawnSessionParams = Type.Object({
|
||||
cwd: Type.Optional(Type.String({
|
||||
description: "Working directory for the new session. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
|
||||
})),
|
||||
model: Type.Optional(Type.String({
|
||||
description: 'Model for the new session, as an exact "provider/model-id" such as "anthropic/claude-sonnet-4-5". When the user references a model as #provider/model-id in their request, forward it here. An unknown value is rejected. Omit to inherit this session\'s model.',
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -50,12 +62,16 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
|
||||
// valid workspace) rather than crash.
|
||||
const result = await deps.spawn({
|
||||
spawningCwd,
|
||||
spawningSessionId: ctx.sessionManager.getSessionId(),
|
||||
prompt: params.prompt,
|
||||
cwd: params.cwd,
|
||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||
...(params.model === undefined ? {} : { modelSpec: params.model }),
|
||||
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
|
||||
});
|
||||
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
|
||||
return {
|
||||
content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}.` }],
|
||||
content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}${modelNote}.` }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -5,11 +5,11 @@ import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spaw
|
||||
|
||||
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
|
||||
|
||||
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext {
|
||||
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown, thinkingLevel?: string): ExtensionContext {
|
||||
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
|
||||
// The subsession tools only read sessionManager.getSessionId/getSessionFile and model.
|
||||
// The subsession tools only read sessionManager.getSessionId/getSessionFile, model, and thinkingLevel.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
|
||||
return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
|
||||
return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function tools(deps: Partial<SubsessionToolDeps>) {
|
||||
@@ -48,7 +48,7 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
|
||||
const { spawn: spawnTool } = tools({ spawn });
|
||||
|
||||
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
|
||||
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel, "max"));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/repos/a",
|
||||
@@ -57,6 +57,7 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
prompt: "do it",
|
||||
cwd: "/repos/a-feature",
|
||||
model: dispatchModel,
|
||||
thinkingLevel: "max",
|
||||
});
|
||||
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
|
||||
expect(firstText(result.content)).toContain("Started tracked subsession child-1");
|
||||
@@ -112,6 +113,36 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("spawn_subsession forwards an explicit model as a model spec and names the model used", async () => {
|
||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-3", cwd: "/repos/a", model: "openai/gpt-5" }));
|
||||
const { spawn: spawnTool } = tools({ spawn });
|
||||
|
||||
const result = await spawnTool.execute("call-model", { prompt: "do it", model: "openai/gpt-5" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel));
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith({
|
||||
spawningCwd: "/repos/a",
|
||||
parentSessionId: "parent-1",
|
||||
parentSessionFile: "/sessions/parent-1.jsonl",
|
||||
prompt: "do it",
|
||||
cwd: undefined,
|
||||
model: dispatchModel,
|
||||
modelSpec: "openai/gpt-5",
|
||||
});
|
||||
expect(result.details).toEqual({ sessionId: "child-3", cwd: "/repos/a", model: "openai/gpt-5" });
|
||||
expect(firstText(result.content)).toBe("Started tracked subsession child-3 in /repos/a using model openai/gpt-5. Continue other work, then join with yield_to_subsessions; do not poll.");
|
||||
});
|
||||
|
||||
it("spawn_subsession teaches the model parameter format and the #provider/model-id reference convention", () => {
|
||||
const { spawn: spawnTool } = tools({});
|
||||
|
||||
expect(spawnTool.parameters).toMatchObject({
|
||||
properties: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- stringMatching yields `any` against the loosely typed tool schema.
|
||||
model: { description: expect.stringMatching(/provider\/model-id.*#provider\/model-id.*Omit to inherit/s) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("list_subsessions reports the caller's subsessions and their status", async () => {
|
||||
const list = vi.fn(() => Promise.resolve([
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
|
||||
|
||||
@@ -8,9 +8,12 @@ export type SubsessionStatus = "working" | "idle" | "error" | "unknown";
|
||||
export interface SpawnSubsessionResult {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
/** Model the child session runs with, as `provider/id`; absent when unknown. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
|
||||
export type SpawnSubsessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
|
||||
|
||||
export interface SpawnSubsessionInvocation {
|
||||
/** cwd of the session that invoked the tool (used for project-scope checks). */
|
||||
@@ -23,6 +26,10 @@ export interface SpawnSubsessionInvocation {
|
||||
cwd: string | undefined;
|
||||
/** Current model from the dispatching session, used as the spawned session's default. */
|
||||
model?: SpawnSubsessionModel;
|
||||
/** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */
|
||||
modelSpec?: string;
|
||||
/** Parent's current thinking level, inherited by the child session (pi clamps it to the child model's capabilities). */
|
||||
thinkingLevel?: SpawnSubsessionThinkingLevel;
|
||||
}
|
||||
|
||||
export interface SubsessionSummary {
|
||||
@@ -72,6 +79,9 @@ const SpawnSubsessionParams = Type.Object({
|
||||
cwd: Type.Optional(Type.String({
|
||||
description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.",
|
||||
})),
|
||||
model: Type.Optional(Type.String({
|
||||
description: 'Model for the child session, as an exact "provider/model-id" such as "anthropic/claude-sonnet-4-5". When the user references a model as #provider/model-id in their request, forward it here. An unknown value is rejected. Omit to inherit this session\'s model.',
|
||||
})),
|
||||
});
|
||||
|
||||
const ListSubsessionsParams = Type.Object({});
|
||||
@@ -197,9 +207,12 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
prompt: params.prompt,
|
||||
cwd: params.cwd,
|
||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||
...(params.model === undefined ? {} : { modelSpec: params.model }),
|
||||
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
|
||||
});
|
||||
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
|
||||
return {
|
||||
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Continue other work, then join with yield_to_subsessions; do not poll.` }],
|
||||
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}${modelNote}. Continue other work, then join with yield_to_subsessions; do not poll.` }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -656,9 +656,9 @@ export interface PendingExtensionDialog {
|
||||
|
||||
/**
|
||||
* The complete result of a closed extension dialog. Unlike an ask outcome it
|
||||
* stays small — the dialog itself is not embedded, because a closed dialog
|
||||
* renders only transiently for browsers that saw it open; reloads rehydrate
|
||||
* open dialogs from {@link SessionStatus.pendingDialogs} alone.
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user