Archived
feat(config): add askUser capability and shared ask contract
Introduce the shared contract for the upcoming ask_user tool: question and pending-ask types in the API contract, the pendingAsk field on SessionStatus, ask.opened/ask.closed session UI events, the askUser global config key with a PI_WEB_ASK_USER env override, and the sessions.askUser capability requiring both the web and session daemon runtimes. askUser defaults to true: the questions land in the session the user is already watching and nothing happens until they act, unlike the beta-off subsessions flag.
This commit is contained in:
@@ -578,7 +578,7 @@ function piWebConfigResponse(config: PiWebConfigValues) {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,13 @@ describe("API parsers", () => {
|
||||
exists: true,
|
||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
|
||||
})).toEqual({
|
||||
path: "/tmp/config.json",
|
||||
exists: true,
|
||||
config: { host: "0.0.0.0", port: 8504, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { compact: true } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, agent: { command: "agent-lab", dir: "~/agent-profiles/lab" } },
|
||||
effectiveConfig: { host: "127.0.0.1", port: 8504, allowedHosts: true, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: ".pi-web/uploads" }, agent: { command: "agent-lab", dir: "/Users/dev/agent-profiles/lab" } },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
|
||||
envOverrides: { host: true, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: true, agentDirSource: "pi-compatibility", agentSessionDir: false },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -958,6 +958,7 @@ function parsePiWebConfigValues(value: unknown): PiWebConfigValues {
|
||||
...optionalField("agent", optionalAgent(record["agent"])),
|
||||
...optionalField("spawnSessions", optionalBoolean(record, "spawnSessions")),
|
||||
...optionalField("subsessions", optionalBoolean(record, "subsessions")),
|
||||
...optionalField("askUser", optionalBoolean(record, "askUser")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1038,6 +1039,8 @@ function parsePiWebConfigEnvOverrides(value: unknown): PiWebConfigEnvOverrides {
|
||||
allowedHosts: requireBoolean(record, "allowedHosts"),
|
||||
spawnSessions: requireBoolean(record, "spawnSessions"),
|
||||
subsessions: requireBoolean(record, "subsessions"),
|
||||
// Older servers predate the ask_user tool; a missing flag means "not overridden".
|
||||
askUser: optionalBoolean(record, "askUser") ?? false,
|
||||
agentCommand: optionalBoolean(record, "agentCommand") ?? false,
|
||||
agentDir: optionalBoolean(record, "agentDir") ?? false,
|
||||
...optionalAgentDirSource(record),
|
||||
|
||||
@@ -63,7 +63,7 @@ export function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -258,6 +258,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -101,6 +101,7 @@ function preservedGatewayConfigRemainder(baseConfig: PiWebConfigValues): PiWebCo
|
||||
...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }),
|
||||
...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }),
|
||||
...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }),
|
||||
...(baseConfig.askUser === undefined ? {} : { askUser: baseConfig.askUser }),
|
||||
...(baseConfig.agent === undefined ? {} : { agent: baseConfig.agent }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const configResponse: PiWebConfigResponse = {
|
||||
exists: true,
|
||||
config: { host: "127.0.0.1" },
|
||||
effectiveConfig: { host: "127.0.0.1" },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
|
||||
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
|
||||
|
||||
@@ -81,6 +81,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +64,6 @@ function configResponse(config: PiWebConfigValues): PiWebConfigResponse {
|
||||
exists: true,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("session daemon settings config helpers", () => {
|
||||
allowedHosts: false,
|
||||
spawnSessions: true,
|
||||
subsessions: false,
|
||||
askUser: false,
|
||||
agentCommand: true,
|
||||
agentDir: false,
|
||||
agentDirSource: "pi-compatibility",
|
||||
@@ -121,6 +122,7 @@ function configResponse(
|
||||
allowedHosts: false,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
askUser: false,
|
||||
agentCommand: false,
|
||||
agentDir: false,
|
||||
agentSessionDir: false,
|
||||
|
||||
@@ -41,6 +41,7 @@ export function mergeSelectedMachineSessiondConfig(base: PiWebConfigResponse, se
|
||||
...base.envOverrides,
|
||||
spawnSessions: selectedMachine.envOverrides.spawnSessions,
|
||||
subsessions: selectedMachine.envOverrides.subsessions,
|
||||
askUser: selectedMachine.envOverrides.askUser,
|
||||
agentCommand: selectedMachine.envOverrides.agentCommand,
|
||||
agentDir: selectedMachine.envOverrides.agentDir,
|
||||
agentSessionDir: selectedMachine.envOverrides.agentSessionDir,
|
||||
|
||||
+40
-1
@@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, agentDirEnvSource, agentSessionDirEnvKeys, askUserEnabled, effectiveAgentConfig, effectivePiWebConfig, hasAgentDirEnvOverride, hasAgentSessionDirEnvOverride, loadPiWebConfig, maxUploadBytes, offlineModeEnabled, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js";
|
||||
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
@@ -182,6 +182,26 @@ describe("PI WEB config persistence", () => {
|
||||
expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER });
|
||||
});
|
||||
|
||||
it("resolves askUser in the effective config so the runtime has a single source of truth", async () => {
|
||||
expect(effectivePiWebConfig(testOptions()).config.askUser).toBe(true);
|
||||
|
||||
await writeFile(configPath, `${JSON.stringify({ askUser: false }, null, 2)}\n`, "utf8");
|
||||
|
||||
expect(effectivePiWebConfig(testOptions()).config.askUser).toBe(false);
|
||||
expect(effectivePiWebConfig({ ...testOptions(), env: { ...testOptions().env, PI_WEB_ASK_USER: "1" } }).config.askUser).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips the askUser key through save and load", () => {
|
||||
expect(savePiWebConfig({ askUser: false }, testOptions()).config).toEqual({ askUser: false });
|
||||
expect(loadPiWebConfig(testOptions()).config).toEqual({ askUser: false });
|
||||
});
|
||||
|
||||
it("rejects a non-boolean askUser key", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ askUser: "yes" }, null, 2)}\n`, "utf8");
|
||||
|
||||
expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config askUser must be a boolean");
|
||||
});
|
||||
|
||||
it("rejects upload defaults that are not workspace-relative", async () => {
|
||||
await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8");
|
||||
|
||||
@@ -233,6 +253,25 @@ describe("subsessionsEnabled", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("askUserEnabled", () => {
|
||||
it("is on by default because the user is present for every ask", () => {
|
||||
expect(askUserEnabled({}, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("honors an explicit config opt-out", () => {
|
||||
expect(askUserEnabled({}, { askUser: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the env var override the config in both directions", () => {
|
||||
expect(askUserEnabled({ PI_WEB_ASK_USER: "0" }, { askUser: true })).toBe(false);
|
||||
expect(askUserEnabled({ PI_WEB_ASK_USER: "true" }, { askUser: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an empty env value as unset", () => {
|
||||
expect(askUserEnabled({ PI_WEB_ASK_USER: "" }, { askUser: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offlineModeEnabled", () => {
|
||||
it("is off when no offline env var is set", () => {
|
||||
expect(offlineModeEnabled({})).toBe(false);
|
||||
|
||||
+25
-1
@@ -15,10 +15,11 @@ export interface LoadedPiWebConfig {
|
||||
config: PiWebConfig;
|
||||
}
|
||||
|
||||
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "agent"> {
|
||||
export interface EffectivePiWebConfig extends Omit<PiWebConfig, "uploads" | "spawnSessions" | "subsessions" | "askUser" | "agent"> {
|
||||
uploads: NonNullable<PiWebConfig["uploads"]>;
|
||||
spawnSessions: boolean;
|
||||
subsessions: boolean;
|
||||
askUser: boolean;
|
||||
agent: Required<NonNullable<PiWebConfig["agent"]>>;
|
||||
}
|
||||
|
||||
@@ -156,6 +157,8 @@ export function resolveEffectivePiWebConfig(loaded: LoadedPiWebConfig, options:
|
||||
spawnSessions: spawnSessionsEnabled(env, loaded.config),
|
||||
// Beta capability, resolved off by default.
|
||||
subsessions: subsessionsEnabled(env, loaded.config),
|
||||
// Always resolved (on by default); the user is present for every ask.
|
||||
askUser: askUserEnabled(env, loaded.config),
|
||||
agent: { command: agent.command, dir: agent.dir },
|
||||
},
|
||||
};
|
||||
@@ -178,6 +181,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}):
|
||||
delete existing["maxUploadBytes"];
|
||||
delete existing["spawnSessions"];
|
||||
delete existing["subsessions"];
|
||||
delete existing["askUser"];
|
||||
delete existing["agent"];
|
||||
const merged = { ...existing, ...piWebConfigRecord(normalized) };
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
@@ -204,6 +208,7 @@ function piWebConfigRecord(config: PiWebConfig): Record<string, unknown> {
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
...(config.askUser !== undefined ? { askUser: config.askUser } : {}),
|
||||
...(config.agent !== undefined ? { agent: config.agent } : {}),
|
||||
};
|
||||
}
|
||||
@@ -220,6 +225,7 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
|
||||
...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}),
|
||||
...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}),
|
||||
...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}),
|
||||
...(value["askUser"] !== undefined ? { askUser: parseAskUser(value["askUser"], path) } : {}),
|
||||
...(value["agent"] !== undefined ? { agent: parseAgentConfig(value["agent"], path) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -266,6 +272,24 @@ export function subsessionsEnabled(env: NodeJS.ProcessEnv = process.env, config:
|
||||
return config.subsessions ?? false;
|
||||
}
|
||||
|
||||
function parseAskUser(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") throw new Error(`PI WEB config askUser must be a boolean: ${path}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether LLMs may post a question set to the browser via the ask_user tool. On
|
||||
* by default: the questions land in the session the user is already watching and
|
||||
* nothing happens without them acting. Set the env var `PI_WEB_ASK_USER` or the
|
||||
* `askUser` config key to `false` to remove the tool. The env var takes
|
||||
* precedence over the config file.
|
||||
*/
|
||||
export function askUserEnabled(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): boolean {
|
||||
const fromEnv = env["PI_WEB_ASK_USER"];
|
||||
if (fromEnv !== undefined && fromEnv !== "") return fromEnv === "1" || fromEnv.toLowerCase() === "true";
|
||||
return config.askUser ?? true;
|
||||
}
|
||||
|
||||
const OFFLINE_ENV_KEYS = ["PI_WEB_OFFLINE", "PI_OFFLINE"] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -128,6 +128,7 @@ function emptyConfigService(): PiWebConfigService {
|
||||
allowedHosts: false,
|
||||
spawnSessions: false,
|
||||
subsessions: false,
|
||||
askUser: false,
|
||||
agentCommand: false,
|
||||
agentDir: false,
|
||||
agentSessionDir: false,
|
||||
|
||||
@@ -208,7 +208,7 @@ export function piWebConfigResponse(config: PiWebConfigValues): PiWebConfigRespo
|
||||
exists: false,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -281,6 +281,6 @@ function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigRes
|
||||
exists,
|
||||
config,
|
||||
effectiveConfig: config,
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false, askUser: false, agentCommand: false, agentDir: false, agentSessionDir: false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const SELECTED_MACHINE_CONFIG_KEYS = [
|
||||
"maxUploadBytes",
|
||||
"spawnSessions",
|
||||
"subsessions",
|
||||
"askUser",
|
||||
"agent",
|
||||
] as const satisfies readonly (keyof PiWebConfigValues)[];
|
||||
|
||||
@@ -131,6 +132,7 @@ function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "curr
|
||||
const maxUploadBytes = value["maxUploadBytes"];
|
||||
const spawnSessions = value["spawnSessions"];
|
||||
const subsessions = value["subsessions"];
|
||||
const askUser = value["askUser"];
|
||||
const agent = value["agent"];
|
||||
if (host !== undefined) {
|
||||
if (typeof host !== "string") throw new Error("PI WEB config host must be a string");
|
||||
@@ -154,6 +156,10 @@ function parseConfigRequest(value: unknown, agentPathHost: AgentPathHost = "curr
|
||||
if (typeof subsessions !== "boolean") throw new Error("PI WEB config subsessions must be a boolean");
|
||||
config.subsessions = subsessions;
|
||||
}
|
||||
if (askUser !== undefined) {
|
||||
if (typeof askUser !== "boolean") throw new Error("PI WEB config askUser must be a boolean");
|
||||
config.askUser = askUser;
|
||||
}
|
||||
if (agent !== undefined) config.agent = parseAgentRequest(agent, agentPathHost);
|
||||
return config;
|
||||
}
|
||||
@@ -166,6 +172,7 @@ function pickSelectedMachineConfig(config: PiWebConfigValues): PiWebConfig {
|
||||
...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}),
|
||||
...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}),
|
||||
...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}),
|
||||
...(config.askUser !== undefined ? { askUser: config.askUser } : {}),
|
||||
...(config.agent !== undefined ? { agent: config.agent } : {}),
|
||||
};
|
||||
}
|
||||
@@ -241,6 +248,8 @@ function parsePiWebConfigEnvOverridesResponse(value: unknown, source: string): P
|
||||
allowedHosts: requireResponseBoolean(record, "allowedHosts", source),
|
||||
spawnSessions: requireResponseBoolean(record, "spawnSessions", source),
|
||||
subsessions: requireResponseBoolean(record, "subsessions", source),
|
||||
// Older responses predate the ask_user tool; treat a missing flag as "not overridden".
|
||||
askUser: optionalResponseBoolean(record, "askUser", source) ?? false,
|
||||
agentCommand: optionalResponseBoolean(record, "agentCommand", source) ?? false,
|
||||
agentDir: optionalResponseBoolean(record, "agentDir", source) ?? false,
|
||||
...optionalAgentDirSource(record, source),
|
||||
@@ -288,6 +297,7 @@ function piWebConfigEnvOverrides(env: NodeJS.ProcessEnv, config: PiWebConfig = {
|
||||
allowedHosts: isEnvSet(env["PI_WEB_ALLOWED_HOSTS"]),
|
||||
spawnSessions: isEnvSet(env["PI_WEB_SPAWN_SESSIONS"]),
|
||||
subsessions: isEnvSet(env["PI_WEB_SUBSESSIONS"]),
|
||||
askUser: isEnvSet(env["PI_WEB_ASK_USER"]),
|
||||
agentCommand: isEnvSet(env["PI_WEB_AGENT_COMMAND"]),
|
||||
agentDir: hasAgentDirEnvOverride(env, command),
|
||||
...(dirEnvSource === undefined ? {} : { agentDirSource: dirEnvSource }),
|
||||
|
||||
@@ -10,6 +10,7 @@ export const PI_WEB_CAPABILITIES = {
|
||||
sessionsPersistedState: "sessions.persistedState",
|
||||
sessionsNotifications: "sessions.notifications",
|
||||
sessionsUnread: "sessions.unread",
|
||||
sessionsAskUser: "sessions.askUser",
|
||||
promptAttachments: "prompt.attachments",
|
||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||
piPackagesManage: "piPackages.manage",
|
||||
@@ -97,6 +98,11 @@ export interface PiWebConfigValues {
|
||||
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
||||
*/
|
||||
subsessions?: boolean;
|
||||
/**
|
||||
* When true, LLMs can post a question set to the browser via the ask_user
|
||||
* tool. On by default; set to `false` to remove the tool from the runtime.
|
||||
*/
|
||||
askUser?: boolean;
|
||||
/** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
|
||||
agent?: PiWebAgentConfig;
|
||||
}
|
||||
@@ -161,6 +167,7 @@ export interface PiWebConfigEnvOverrides {
|
||||
allowedHosts: boolean;
|
||||
spawnSessions: boolean;
|
||||
subsessions: boolean;
|
||||
askUser: boolean;
|
||||
agentCommand: boolean;
|
||||
agentDir: boolean;
|
||||
/** The configured directory environment source, even when Pi compatibility is inactive for the desired command. */
|
||||
@@ -433,6 +440,54 @@ export interface QueuedSessionMessage {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One selectable option of an {@link AskUserQuestion}. */
|
||||
export interface AskUserQuestionOption {
|
||||
/** Stable machine value reported back to the model. */
|
||||
value: string;
|
||||
/** Short human label rendered in the browser. */
|
||||
label: string;
|
||||
/** Optional clarifying line rendered under the label. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One question of an `ask_user` set. Questions are never required: the user may
|
||||
* submit while leaving any of them untouched, and unanswered questions are
|
||||
* reported to the model as such.
|
||||
*/
|
||||
export interface AskUserQuestion {
|
||||
/** Unique within the ask; used as the answer key. */
|
||||
id: string;
|
||||
/** The question itself, as one plain-text line. */
|
||||
question: string;
|
||||
/** Optional supporting context rendered under the question. */
|
||||
detail?: string;
|
||||
/** Offered options; may be empty when only free text makes sense. */
|
||||
options: AskUserQuestionOption[];
|
||||
/** When true, a labelled free-text field is offered alongside the options. */
|
||||
allowOther?: boolean;
|
||||
/** When true, several options may be selected at once. */
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open, unanswered question set of a session. Daemon-owned and reported in
|
||||
* {@link SessionStatus}, so a reconnecting or reloading browser rehydrates it
|
||||
* without depending on having seen the `ask.opened` event.
|
||||
*/
|
||||
export interface PendingAskUser {
|
||||
askId: string;
|
||||
askedAt: string;
|
||||
questions: AskUserQuestion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an ask stopped being the session's open ask. The answer/outcome types that
|
||||
* describe *what* the user replied arrive with the pending-ask store that
|
||||
* computes them.
|
||||
*/
|
||||
export type AskUserCloseReason = "submitted" | "superseded" | "cancelled";
|
||||
|
||||
/**
|
||||
* Progress of the session startup window, where the daemon is still
|
||||
* constructing the agent session and no `PiAgentSession` exists yet, so
|
||||
@@ -610,6 +665,11 @@ export interface SessionStatus {
|
||||
* there are none. See {@link SessionWarning}.
|
||||
*/
|
||||
warnings?: SessionWarning[];
|
||||
/**
|
||||
* The session's open `ask_user` question set, when one is waiting for the
|
||||
* user. Daemon-owned, so it survives browser reload and web/API restarts.
|
||||
*/
|
||||
pendingAsk?: PendingAskUser;
|
||||
}
|
||||
|
||||
export interface WorkspaceActivity {
|
||||
@@ -990,6 +1050,8 @@ type SessionUiEventBody =
|
||||
| { type: "command.output"; level: "info" | "success" | "error"; message: string; notificationId?: string }
|
||||
| SessionNotificationInboxEvent
|
||||
| { type: "session.error"; message: string }
|
||||
| { type: "ask.opened"; ask: PendingAskUser }
|
||||
| { type: "ask.closed"; askId: string; reason: AskUserCloseReason }
|
||||
| { type: "session.name"; sessionId: string; name?: string }
|
||||
| { type: "session.created"; session: SessionInfo }
|
||||
| { type: "pi.event"; eventType: string };
|
||||
|
||||
@@ -90,6 +90,26 @@ describe("PI WEB capabilities", () => {
|
||||
})).toContain(unread);
|
||||
});
|
||||
|
||||
it("renders the question card only when both runtimes support daemon-owned asks", () => {
|
||||
const askUser = PI_WEB_CAPABILITIES.sessionsAskUser;
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(askUser);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(askUser);
|
||||
expect(parseKnownPiWebCapabilities([askUser, "future.capability"])).toEqual([askUser]);
|
||||
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [askUser] },
|
||||
sessiond: { available: true, capabilities: [] },
|
||||
})).not.toContain(askUser);
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [] },
|
||||
sessiond: { available: true, capabilities: [askUser] },
|
||||
})).not.toContain(askUser);
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [askUser] },
|
||||
sessiond: { available: true, capabilities: [askUser] },
|
||||
})).toContain(askUser);
|
||||
});
|
||||
|
||||
it("keeps only known string capabilities when parsing runtime data", () => {
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
|
||||
|
||||
@@ -15,6 +15,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.sessionsNotifications,
|
||||
PI_WEB_CAPABILITIES.sessionsUnread,
|
||||
PI_WEB_CAPABILITIES.sessionsAskUser,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
||||
PI_WEB_CAPABILITIES.piPackagesManage,
|
||||
@@ -31,6 +32,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.sessionsNotifications,
|
||||
PI_WEB_CAPABILITIES.sessionsUnread,
|
||||
PI_WEB_CAPABILITIES.sessionsAskUser,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
] as const satisfies readonly PiWebCapability[];
|
||||
|
||||
@@ -43,6 +45,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsNotifications]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsUnread]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsAskUser]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||
[PI_WEB_CAPABILITIES.piPackagesManage]: ["web"],
|
||||
|
||||
Reference in New Issue
Block a user