diff --git a/.changeset/ask-user-question-forms.md b/.changeset/ask-user-question-forms.md index 3eab8ae..d59a062 100644 --- a/.changeset/ask-user-question-forms.md +++ b/.changeset/ask-user-question-forms.md @@ -2,4 +2,4 @@ "@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. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool with `askUser: false` or `PI_WEB_ASK_USER=false`. +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. Agents end their run while the form waits; users can submit full or partial answers, unanswered questions are reported explicitly, pending forms survive browser and web/API reconnects, and closed forms remain readable in the transcript. Disable the tool from **Settings → Session daemon**, with `askUser: false`, or with `PI_WEB_ASK_USER=false`. diff --git a/docs/config.html b/docs/config.html index 44d2105..da45276 100644 --- a/docs/config.html +++ b/docs/config.html @@ -270,7 +270,7 @@ it, and whether project-local config overrides or merges with global config. Rows with JSON key are runtime-only environment variables, not config-file keys. Global means machine-global. In Settings, selected-machine-safe global keys (pathAccess, uploads, - maxUploadBytes, agent, spawnSessions, subsessions, and plugins) + maxUploadBytes, agent, spawnSessions, subsessions, askUser, and plugins) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local.

@@ -807,6 +807,10 @@ true; set it to false, or set PI_WEB_ASK_USER=false, to remove the tool. The environment override accepts 0|1|true|false and takes precedence over the config file.

+

+ Use Settings → Session daemon → Allow agents to ask questions to change + askUser on the selected machine. An environment override makes the toggle read-only. +

The tool accepts one set of 1–20 questions. Each question has a unique id, its question text, optional supporting detail, up to 12 options with stable values and diff --git a/docs/config.md b/docs/config.md index 76ebfcb..f28cf05 100644 --- a/docs/config.md +++ b/docs/config.md @@ -102,7 +102,7 @@ Plugins may own separate project files, such as `.pi-web/tasks.json` for the bui ## Configuration matrix -Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local. +Rows with JSON key `—` are runtime-only environment variables, not config-file keys. `Global` means machine-global. In Settings, selected-machine-safe global keys (`pathAccess`, `uploads`, `maxUploadBytes`, `agent`, `spawnSessions`, `subsessions`, `askUser`, and `plugins`) are edited for the selected machine; gateway host/port/allowed-hosts, keyboard shortcuts, and machine registry/tokens stay local. | Config | JSON key | Env var | Scope | Project-local behavior | Applies / restart | | --- | --- | --- | --- | --- | --- | @@ -275,6 +275,8 @@ In **Settings → Session daemon**, these keys are saved on the selected machine `askUser` controls whether agents receive the core `ask_user` tool. It defaults to `true`; set it to `false`, or set `PI_WEB_ASK_USER=false`, to remove the tool. The environment override accepts `0|1|true|false` and takes precedence over the config file. +Use **Settings → Session daemon → Allow agents to ask questions** to change `askUser` on the selected machine. An environment override makes the toggle read-only. + The tool accepts one set of 1–20 questions. Each question has a unique `id`, its `question` text, optional supporting `detail`, up to 12 options with stable values and user-facing labels, and an optional `multiple` flag. The browser always adds a **Custom** free-text answer, including when the model supplies no options. No question is required: the user may leave any of them unanswered. Calling `ask_user` posts the whole set as one browser form and ends the current agent run instead of waiting for the user. The open form is owned by the session daemon, so it survives a browser disconnect, browser reload, or web/API restart while that daemon keeps running. When the user submits, the answers arrive as a follow-up that wakes the session; each question is reported with its selected option values or free text, or explicitly as unanswered. diff --git a/src/client/src/components/settings/SettingsSessiondPanel.askUser.test.ts b/src/client/src/components/settings/SettingsSessiondPanel.askUser.test.ts new file mode 100644 index 0000000..24a50fb --- /dev/null +++ b/src/client/src/components/settings/SettingsSessiondPanel.askUser.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PiWebConfigResponse } from "../../api"; +import { SettingsSessiondPanel } from "./SettingsSessiondPanel"; + +beforeEach(() => { + document.body.replaceChildren(); +}); + +describe("SettingsSessiondPanel Ask Questions setting", () => { + it("lets the user disable ask_user with a daemon config patch", async () => { + const panel = new SettingsSessiondPanel(); + const onSave = vi.fn(); + panel.configResponse = configResponse(true); + panel.onSave = onSave; + document.body.append(panel); + await panel.updateComplete; + + const toggle = askUserToggle(panel); + expect(toggle.checked).toBe(true); + expect(toggle.disabled).toBe(false); + + toggle.click(); + await Promise.resolve(); + + expect(onSave).toHaveBeenCalledWith({ askUser: false }); + }); + + it("keeps an environment-overridden setting read-only", async () => { + const panel = new SettingsSessiondPanel(); + panel.configResponse = configResponse(true, true); + document.body.append(panel); + await panel.updateComplete; + + const toggle = askUserToggle(panel); + expect(toggle.checked).toBe(true); + expect(toggle.disabled).toBe(true); + }); + + it("does not offer an unsupported setting from an older selected machine", async () => { + const panel = new SettingsSessiondPanel(); + panel.configResponse = configResponse(undefined); + document.body.append(panel); + await panel.updateComplete; + + const toggle = askUserToggle(panel); + expect(toggle.checked).toBe(false); + expect(toggle.disabled).toBe(true); + }); +}); + +function askUserToggle(panel: SettingsSessiondPanel): HTMLInputElement { + const toggle = panel.shadowRoot?.querySelector('input[aria-label="Enable Ask Questions"]'); + if (toggle === undefined || toggle === null) throw new Error("Ask Questions toggle was not rendered"); + return toggle; +} + +function configResponse(askUser: boolean | undefined, askUserOverride = false): PiWebConfigResponse { + const askUserConfig = askUser === undefined ? {} : { askUser }; + return { + path: "/tmp/pi-web/config.json", + exists: true, + config: askUserConfig, + effectiveConfig: askUserConfig, + envOverrides: { + host: false, + port: false, + allowedHosts: false, + spawnSessions: false, + subsessions: false, + askUser: askUserOverride, + agentCommand: false, + agentDir: false, + agentSessionDir: false, + }, + }; +} diff --git a/src/client/src/components/settings/SettingsSessiondPanel.ts b/src/client/src/components/settings/SettingsSessiondPanel.ts index e15e489..efa9cd7 100644 --- a/src/client/src/components/settings/SettingsSessiondPanel.ts +++ b/src/client/src/components/settings/SettingsSessiondPanel.ts @@ -5,7 +5,7 @@ import "./SettingsPanelFrame"; import type { SettingsNotice } from "./SettingsPanelFrame"; import { agentProfileConfigPatchFromDraft, agentProfileDraftFromConfig, agentProfileDraftMatchesConfig, emptyAgentProfileConfigDraft, type AgentProfileConfigDraft } from "./settingsConfigDraft"; import type { AgentProfileSettingsSupport } from "./settingsMachineTarget"; -import { agentDirFieldOverridden, agentProfileActivationState, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; +import { agentDirFieldOverridden, agentProfileActivationState, askUserConfigPatch, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; @customElement("settings-sessiond-panel") export class SettingsSessiondPanel extends LitElement { @@ -47,6 +47,11 @@ export class SettingsSessiondPanel extends LitElement { const subsessionsOverridden = config?.envOverrides.subsessions === true; // Beta, off by default; also requires spawn to be enabled. const effectiveSubsessions = config?.effectiveConfig.subsessions === true && effectiveSpawn; + // Current servers always resolve this on-by-default setting. Absence means + // an older selected machine cannot persist it yet. + const askUserSupported = config?.effectiveConfig.askUser !== undefined; + const askUserOverridden = config?.envOverrides.askUser === true; + const effectiveAskUser = config?.effectiveConfig.askUser === true; const agentCommandOverridden = config?.envOverrides.agentCommand === true; const profileEditingSupported = this.agentProfileSupport.state === "supported"; const draftCommand = agentCommandOverridden ? (config.effectiveConfig.agent?.command ?? this.agentDraft.command) : this.agentDraft.command; @@ -141,6 +146,25 @@ export class SettingsSessiondPanel extends LitElement { Beta: agents can start child sessions they stay attached to (spawn_subsession, list_subsessions, check_subsession, read_subsession) and are notified when a child finishes. Requires "Allow agents to start sessions". Off by default. +

+ + Allow agents to ask questions + ${askUserOverridden ? html`environment override` : null} + + + ${askUserSupported + ? html`Agents can post a structured question form and pause until the user responds. On by default.` + : html`This machine does not expose the Ask Questions setting. Update and restart PI WEB on that machine to configure it.`} +

Desired after environment overrides

@@ -151,6 +175,7 @@ export class SettingsSessiondPanel extends LitElement {
Profile status
${profileActivationLabel(profileActivation)}
Spawn sessions
${effectiveSpawn ? "Enabled" : html`Disabled`}
Subsessions
${effectiveSubsessions ? "Enabled" : html`Disabled`}
+
Ask questions
${!askUserSupported ? html`Unavailable` : effectiveAskUser ? "Enabled" : html`Disabled`}
`} @@ -198,6 +223,11 @@ export class SettingsSessiondPanel extends LitElement { await this.onSave?.(subsessionsConfigPatch(enabled)); } + private async toggleAskUser(event: Event): Promise { + const enabled = event.target instanceof HTMLInputElement && event.target.checked; + await this.onSave?.(askUserConfigPatch(enabled)); + } + static override styles = css` :host { display: block; } h3 { margin: 0; font-size: 13px; line-height: 1.3; } diff --git a/src/client/src/components/settings/settingsSessiondConfig.test.ts b/src/client/src/components/settings/settingsSessiondConfig.test.ts index 97a6a36..3856914 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.test.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "vitest"; import type { ActiveAgentProfileDescriptor, PiWebConfigResponse, PiWebConfigValues } from "../../api"; -import { agentDirFieldOverridden, agentProfileActivationState, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; +import { agentDirFieldOverridden, agentProfileActivationState, askUserConfigPatch, mergeSelectedMachineSessiondConfig, spawnSessionsConfigPatch, subsessionsConfigPatch } from "./settingsSessiondConfig"; describe("session daemon settings config helpers", () => { it("builds daemon-only save patches for the sessiond toggles", () => { expect(spawnSessionsConfigPatch(false)).toEqual({ spawnSessions: false }); expect(subsessionsConfigPatch(true)).toEqual({ subsessions: true }); + expect(askUserConfigPatch(false)).toEqual({ askUser: false }); }); it("compares the desired effective profile with the daemon-owned active profile", () => { diff --git a/src/client/src/components/settings/settingsSessiondConfig.ts b/src/client/src/components/settings/settingsSessiondConfig.ts index 202f194..f92417b 100644 --- a/src/client/src/components/settings/settingsSessiondConfig.ts +++ b/src/client/src/components/settings/settingsSessiondConfig.ts @@ -11,6 +11,10 @@ export function subsessionsConfigPatch(enabled: boolean): PiWebConfigValues { return { subsessions: enabled }; } +export function askUserConfigPatch(enabled: boolean): PiWebConfigValues { + return { askUser: enabled }; +} + export function agentProfileActivationState( config: PiWebConfigResponse | undefined, activeProfile: ActiveAgentProfileDescriptor | undefined,