chore: upgrade pi dependencies to 0.84.1

This commit is contained in:
Erik Kotsiuba
2026-08-12 14:18:59 +02:00
parent bd5f4520a3
commit 0365b6f1ff
17 changed files with 242 additions and 118 deletions
+3 -1
View File
@@ -3,7 +3,7 @@
# @eko24ive/pi-ask
[![npm downloads](https://badgen.net/npm/dm/@eko24ive/pi-ask)](https://www.npmjs.com/package/@eko24ive/pi-ask)
[![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=6fef4ac)](https://github.com/eko24ive/pi-ask/commits/main)
[![last commit](https://badgen.net/github/last-commit/eko24ive/pi-ask?v=4b6c81e)](https://github.com/eko24ive/pi-ask/commits/main)
[![stars](https://badgen.net/github/stars/eko24ive/pi-ask)](https://github.com/eko24ive/pi-ask/stargazers)
> [!IMPORTANT]
@@ -130,6 +130,8 @@ Review-tab shortcuts can optionally require the same number key twice via `behav
You can edit the config file yourself, ask pi to edit it for you, or use `/ask-settings` to find the exact config path, toggle behaviour/notification settings, or reset config to defaults with a guarded double press. pi-ask treats the config file as user-owned: load-time migrations and invalid files are handled in memory without rewriting or backing up the file, and read-only/externally managed configs fail gracefully with a manual-edit message.
`/answer` keeps extraction within the current session model scope. Check a configured model before use with `pi auth check --provider <provider> --model <id>`.
```json
{
"schemaVersion": 5,
+1 -1
View File
@@ -16,7 +16,7 @@ The codebase is split so the implementation reads through file boundaries and na
- `src/index.ts` — extension entrypoint
- `src/ask-tool.ts` — tool registration, non-interactive fallback, transcript rendering, ask payload capture
- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring
- `src/answer-extraction.ts` — configured extraction model selection, synthetic `ask_user` tool-call extraction, and tolerant text fallback
- `src/answer-extraction.ts` — configured, session-scope-aware extraction model selection, synthetic `ask_user` tool-call extraction, and tolerant text fallback
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
- `src/pending-ask.ts` — active-branch unresolved tool-call detection and recovery dismissal persistence
- `src/resume-pending-ask.ts` — detached lifecycle wiring and recovered answer delivery
+7 -2
View File
@@ -99,8 +99,13 @@ These settings affect only the `/answer` command. Normal `ask_user` tool calls d
- type: array of `{ "provider": string, "id": string }`
- default: lightweight OpenAI Codex, GitHub Copilot, and Anthropic models
- effect: `/answer` tries configured models in order and uses the first model with available auth
- fallback: if no configured model is usable, `/answer` tries the current chat model after validating its auth
- effect: `/answer` tries configured models in order and uses the first model with available auth that is inside the current session model scope; an empty scope allows all available models
- fallback: if no configured model is usable, `/answer` tries the current chat model after validating its auth and scope
- auth check: use Pi's auth preflight before changing this list:
```bash
pi auth check --provider openai-codex --model gpt-5.4-mini
```
### `answer.extractionTimeoutMs`
+2 -2
View File
@@ -171,7 +171,7 @@ This document defines the stable external behavior. It does not explain internal
- semantically invalid payloads that reach tool execution return `error.kind === "invalid_input"` with structured `issues` and a transcript-friendly `Invalid ask_user payload:` message; their rendered status is `Invalid tool payload`
- payloads missing schema-required fields fail Pi's schema validation before tool execution and use Pi's standard tool-error result without structured `details`
- `mode: "submit"` is normal completion; `mode: "elaborate"` means the user asked the agent to continue with follow-up clarification based on notes
- unanswered questions are omitted from `answers`, but non-cancelled submitted result text includes `<label>: (no answer)` in summary mode and `? <label>: (no answer)` in transcript rendering
- unanswered questions without notes are omitted from `answers`; note-only entries remain in `answers` to carry their notes, but all non-cancelled submitted result text includes `<label>: (no answer)` in summary mode and `? <label>: (no answer)` in transcript rendering
- in `mode: "elaborate"`, `answers` contains only committed answers; note-only entries move to `elaboration.items`
- `continuation.strategy === "refine_only"` means the next ask should refine the current flow rather than restart it
- `continuation.preservedAnswers` contains previously committed answers that should be kept as context and not re-asked
@@ -270,7 +270,7 @@ Dirty dismiss:
The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. In print, JSON, RPC, or any other non-TUI mode, the tool returns a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening custom UI.
The public tool schema requires question `id` and `prompt` plus option `value` and `label`, so missing required fields fail before execution. The tool still validates trimmed text, uniqueness, question types, option counts, and preview requirements during execution and returns structured issues for those failures. Result rendering falls back to Pi's raw tool-error text when schema validation prevents execution.
The public tool schema requires question `id` and `prompt` plus option `value` and `label`, and it restricts question `type` to `single`, `multi`, or `preview`, so malformed structural fields fail before execution. The tool still validates trimmed text, uniqueness, option counts, and preview requirements during execution and returns structured issues for those failures. Result rendering falls back to Pi's raw tool-error text when schema validation prevents execution.
The ask flow subscribes to runtime settings updates while open. In practice, this means changing `Auto-submit when answered without notes`, `Confirm dismiss when dirty`, `Double-press review shortcuts`, `Notifications`, `Show footer hints`, resetting config to defaults, or reloading config-backed keymaps can affect the in-progress ask flow immediately instead of only future asks when the change is saved or otherwise applied in memory. Load-time migrations and invalid config handling do not rewrite, rename, or back up the config file; invalid files load defaults for the session and show a notice. `Present single-select as multi-select` is applied when an ask flow is created and does not rewrite question semantics for an already-open flow; use `main.changeQuestionType` for live per-question changes.
+8 -8
View File
@@ -93,16 +93,16 @@
"@biomejs/biome": "2.4.12",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@earendil-works/pi-ai": "0.79.9",
"@earendil-works/pi-coding-agent": "0.79.9",
"@earendil-works/pi-tui": "0.79.9",
"@earendil-works/pi-ai": "0.84.1",
"@earendil-works/pi-coding-agent": "0.84.1",
"@earendil-works/pi-tui": "0.84.1",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6",
"@semantic-release/npm": "^13.1.5",
"@semantic-release/release-notes-generator": "^14.1.0",
"typebox": "1.1.38",
"typebox": "1.3.7",
"@types/node": "25.6.0",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
@@ -118,10 +118,10 @@
},
"pnpm": {
"overrides": {
"@earendil-works/pi-agent-core": "0.79.9",
"@earendil-works/pi-ai": "0.79.9",
"@earendil-works/pi-tui": "0.79.9",
"@earendil-works/pi-coding-agent": "0.79.9"
"@earendil-works/pi-agent-core": "0.84.1",
"@earendil-works/pi-ai": "0.84.1",
"@earendil-works/pi-tui": "0.84.1",
"@earendil-works/pi-coding-agent": "0.84.1"
},
"ignoredBuiltDependencies": [
"lefthook"
+72 -38
View File
@@ -5,10 +5,10 @@ settings:
excludeLinksFromLockfile: false
overrides:
'@earendil-works/pi-agent-core': 0.79.9
'@earendil-works/pi-ai': 0.79.9
'@earendil-works/pi-tui': 0.79.9
'@earendil-works/pi-coding-agent': 0.79.9
'@earendil-works/pi-agent-core': 0.84.1
'@earendil-works/pi-ai': 0.84.1
'@earendil-works/pi-tui': 0.84.1
'@earendil-works/pi-coding-agent': 0.84.1
importers:
@@ -24,14 +24,14 @@ importers:
specifier: ^19.8.1
version: 19.8.1
'@earendil-works/pi-ai':
specifier: 0.79.9
version: 0.79.9(ws@8.20.0)(zod@4.3.6)
specifier: 0.84.1
version: 0.84.1(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-coding-agent':
specifier: 0.79.9
version: 0.79.9(ws@8.20.0)(zod@4.3.6)
specifier: 0.84.1
version: 0.84.1(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-tui':
specifier: 0.79.9
version: 0.79.9
specifier: 0.84.1
version: 0.84.1
'@semantic-release/changelog':
specifier: ^6.0.3
version: 6.0.3(semantic-release@25.0.3(typescript@6.0.2))
@@ -66,8 +66,8 @@ importers:
specifier: ^25.0.3
version: 25.0.3(typescript@6.0.2)
typebox:
specifier: 1.1.38
version: 1.1.38
specifier: 1.3.7
version: 1.3.7
typescript:
specifier: 6.0.2
version: 6.0.2
@@ -367,22 +367,34 @@ packages:
resolution: {integrity: sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==}
engines: {node: '>=v18'}
'@earendil-works/pi-agent-core@0.79.9':
resolution: {integrity: sha512-GsFbPR85nhncKoU9++fTKa11PzwUkAmkrKXo97dBOzi10Td72rVV6vmfxKjwPLHTzRbJMa0byr12YhODZ59yLA==}
'@earendil-works/pi-agent-core@0.84.1':
resolution: {integrity: sha512-evyzXYWCLQGmcaBYHlmSku02r8qoN4SGI60GZABo6iV+H+nqX+P9ud8fEZ4GmRq9mUSREvvfX+w9dA9ThF9C6w==}
engines: {node: '>=22.19.0'}
'@earendil-works/pi-ai@0.79.9':
resolution: {integrity: sha512-fHmgNMONwCCE7bQAKbcz76sgm3iQuA7km1mpIc4H5xXd9+zhPh/faULz6ARkgjQE0EufHnfZPJY39+lNf8Sa9g==}
'@earendil-works/pi-ai@0.84.1':
resolution: {integrity: sha512-wMsAdJMxuNri08vLqTyYVI201DQQezGhPSTkzYsHdw5dYX3rCNwEmSvpaAwhi7ELKI/2tE/CEgSWg/6iRxSgdQ==}
engines: {node: '>=22.19.0'}
hasBin: true
'@earendil-works/pi-coding-agent@0.79.9':
resolution: {integrity: sha512-8TZ796Zn0NE4vmhxG9hv4ZtJDGJzhqMjlmFg8ZkUKxfqB7LJa4ums2jSJKtnyAZfAamN6VzqzN0A82RNDqv8Ag==}
'@earendil-works/pi-client@0.84.1':
resolution: {integrity: sha512-/V5hGHE4Zq+jG0GtwIB9PyBUOGd6gBLZ7lkQYFKchKnxYHeH3rmWC5xw4kpnZKKBuBuFTdLVbU9vEjlAGMMb2A==}
engines: {node: '>=22.19.0'}
'@earendil-works/pi-coding-agent@0.84.1':
resolution: {integrity: sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==}
engines: {node: '>=22.19.0'}
hasBin: true
'@earendil-works/pi-tui@0.79.9':
resolution: {integrity: sha512-XcqfoGyoX64OSMQklMR1vG2MRi1TCPSUERRCmPDvYKCK6LtZoBqJ6idNCLRklNYveCj4gHDOzOsLhGqn04jmYw==}
'@earendil-works/pi-protocol@0.84.1':
resolution: {integrity: sha512-Ox1pciyeSPGEEUcxvR0/dJcrY7C6hrEGA8y71rOsvSIUlXN1Cbp/be/eoL71OGDBk5O97TeQPfWN6Ju/2Ehjww==}
engines: {node: '>=22.19.0'}
'@earendil-works/pi-telemetry@0.84.1':
resolution: {integrity: sha512-180/xGJtsq7IoR3p9EKWjRd0e9M4DkxInhlo9xyD7prDC7Qrhqq+nhvwrW0lFjPfXcEI2FSHmGCSyvSJE9GsaQ==}
engines: {node: '>=22.19.0'}
'@earendil-works/pi-tui@0.84.1':
resolution: {integrity: sha512-udeXFbgEhJ6JiB0uguwNVNkDy2FENfmtQwPcY+/iJ8GWeq18wkal1tKqa5YyeH0IqtX1vG0cGh8zfSYzyzVuLA==}
engines: {node: '>=22.19.0'}
'@google/genai@1.52.0':
@@ -1258,6 +1270,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
grok-mermaid@0.2.2:
resolution: {integrity: sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==}
engines: {node: '>=18'}
handlebars@4.7.9:
resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==}
engines: {node: '>=0.4.7'}
@@ -2330,8 +2346,8 @@ packages:
resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==}
engines: {node: '>=20'}
typebox@1.1.38:
resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==}
typebox@1.3.7:
resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==}
typescript@6.0.2:
resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
@@ -2366,8 +2382,8 @@ packages:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
undici@8.5.0:
resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
undici@8.9.0:
resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==}
engines: {node: '>=22.19.0'}
unicode-emoji-modifier-base@1.0.0:
@@ -2978,11 +2994,13 @@ snapshots:
picocolors: 1.1.1
optional: true
'@earendil-works/pi-agent-core@0.79.9(ws@8.20.0)(zod@4.3.6)':
'@earendil-works/pi-agent-core@0.84.1(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@earendil-works/pi-ai': 0.79.9(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-ai': 0.84.1(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-telemetry': 0.84.1
diff: 8.0.4
ignore: 7.0.5
typebox: 1.1.38
typebox: 1.3.7
yaml: 2.9.0
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@@ -2992,10 +3010,11 @@ snapshots:
- ws
- zod
'@earendil-works/pi-ai@0.79.9(ws@8.20.0)(zod@4.3.6)':
'@earendil-works/pi-ai@0.84.1(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.91.1(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.1048.0
'@earendil-works/pi-telemetry': 0.84.1
'@google/genai': 1.52.0
'@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0)
'@opentelemetry/api': 1.9.0
@@ -3004,7 +3023,7 @@ snapshots:
https-proxy-agent: 7.0.6
openai: 6.26.0(ws@8.20.0)(zod@4.3.6)
partial-json: 0.1.7
typebox: 1.1.38
typebox: 1.3.7
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- bufferutil
@@ -3013,16 +3032,23 @@ snapshots:
- ws
- zod
'@earendil-works/pi-coding-agent@0.79.9(ws@8.20.0)(zod@4.3.6)':
'@earendil-works/pi-client@0.84.1':
dependencies:
'@earendil-works/pi-agent-core': 0.79.9(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-ai': 0.79.9(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-tui': 0.79.9
'@earendil-works/pi-protocol': 0.84.1
'@earendil-works/pi-coding-agent@0.84.1(ws@8.20.0)(zod@4.3.6)':
dependencies:
'@earendil-works/pi-agent-core': 0.84.1(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-ai': 0.84.1(ws@8.20.0)(zod@4.3.6)
'@earendil-works/pi-client': 0.84.1
'@earendil-works/pi-protocol': 0.84.1
'@earendil-works/pi-tui': 0.84.1
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
cross-spawn: 7.0.6
diff: 8.0.4
glob: 13.0.6
grok-mermaid: 0.2.2
highlight.js: 10.7.3
hosted-git-info: 9.0.3
ignore: 7.0.5
@@ -3030,8 +3056,8 @@ snapshots:
minimatch: 10.2.5
proper-lockfile: 4.1.2
semver: 7.8.0
typebox: 1.1.38
undici: 8.5.0
typebox: 1.3.7
undici: 8.9.0
yaml: 2.9.0
optionalDependencies:
'@mariozechner/clipboard': 0.3.9
@@ -3043,7 +3069,13 @@ snapshots:
- ws
- zod
'@earendil-works/pi-tui@0.79.9':
'@earendil-works/pi-protocol@0.84.1':
dependencies:
typebox: 1.3.7
'@earendil-works/pi-telemetry@0.84.1': {}
'@earendil-works/pi-tui@0.84.1':
dependencies:
get-east-asian-width: 1.6.0
marked: 18.0.5
@@ -4006,6 +4038,8 @@ snapshots:
graceful-fs@4.2.11: {}
grok-mermaid@0.2.2: {}
handlebars@4.7.9:
dependencies:
minimist: 1.2.8
@@ -4928,7 +4962,7 @@ snapshots:
dependencies:
tagged-tag: 1.0.0
typebox@1.1.38: {}
typebox@1.3.7: {}
typescript@6.0.2: {}
@@ -4953,7 +4987,7 @@ snapshots:
undici@7.25.0: {}
undici@8.5.0: {}
undici@8.9.0: {}
unicode-emoji-modifier-base@1.0.0: {}
+2 -1
View File
@@ -194,7 +194,8 @@ function runExtractionUi(
extractAskParams({
assistantText: assistant.text,
previousUserText: assistant.previousUserText,
auth: selected.auth,
complete: (model, context, options) =>
ctx.modelRegistry.complete(model, context, options),
model: selected.model,
onRetry: (attempt, maxRetries) => {
ctx.ui.notify(
+25 -14
View File
@@ -2,8 +2,8 @@ import {
type Api,
type AssistantMessage,
type Context,
complete,
type Model,
modelsAreEqual,
type Tool,
type UserMessage,
} from "@earendil-works/pi-ai";
@@ -51,23 +51,22 @@ interface ExtractionPromptOptions {
}
interface SelectedExtractionModel {
auth: { apiKey?: string; headers?: Record<string, string> };
model: Model<Api>;
usedFallback: boolean;
}
export async function selectExtractionModel(
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
ctx: Pick<ExtensionContext, "model" | "modelRegistry" | "scopedModels">,
preferences: AskConfig["answer"]["extractionModels"]
): Promise<SelectedExtractionModel | { error: string }> {
for (const preference of preferences) {
const model = ctx.modelRegistry.find(preference.provider, preference.id);
if (!model) {
if (!(model && isModelInScope(model, ctx.scopedModels))) {
continue;
}
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (auth.ok) {
return { model, auth, usedFallback: false };
return { model, usedFallback: false };
}
}
@@ -77,6 +76,12 @@ export async function selectExtractionModel(
"No available extraction model. Configure answer.extractionModels or select a chat model.",
};
}
if (!isModelInScope(ctx.model, ctx.scopedModels)) {
return {
error:
"No available extraction model in the current session model scope.",
};
}
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
if (!auth.ok) {
@@ -85,14 +90,24 @@ export async function selectExtractionModel(
};
}
return { model: ctx.model, auth, usedFallback: true };
return { model: ctx.model, usedFallback: true };
}
function isModelInScope(
model: Model<Api>,
scopedModels: ExtensionContext["scopedModels"]
): boolean {
return (
scopedModels.length === 0 ||
scopedModels.some((scoped) => modelsAreEqual(scoped.model, model))
);
}
export async function extractAskParams(options: {
assistantText: string;
previousUserText?: string;
model: Model<Api>;
auth: { apiKey?: string; headers?: Record<string, string> };
complete: ExtensionContext["modelRegistry"]["complete"];
retries: number;
signal?: AbortSignal;
timeoutMs: number;
@@ -141,7 +156,7 @@ async function runExtractionAttempt(options: {
assistantText: string;
previousUserText?: string;
attempt: number;
auth: { apiKey?: string; headers?: Record<string, string> };
complete: ExtensionContext["modelRegistry"]["complete"];
lastError: string;
lastResponse: string;
model: Model<Api>;
@@ -157,14 +172,10 @@ async function runExtractionAttempt(options: {
const abortFromParent = () => controller.abort();
options.signal?.addEventListener("abort", abortFromParent, { once: true });
try {
const response = await complete(
const response = await options.complete(
options.model,
createExtractionContext(options),
{
apiKey: options.auth.apiKey,
headers: options.auth.headers,
signal: controller.signal,
}
{ signal: controller.signal }
);
if (response.stopReason === "aborted") {
throw new Error(
+3 -1
View File
@@ -2,7 +2,9 @@ import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { Value } from "typebox/value";
import { validateParams } from "./ask-tool-helpers.ts";
import { AskParamsSchema } from "./schema.ts";
import type { AskParams } from "./types.ts";
export const ASK_PAYLOAD_ENTRY_TYPE = "ask:payload";
@@ -95,7 +97,7 @@ function isValidAskPayloadData(data: unknown): data is AskPayloadEntryData {
return false;
}
if (
!payload.params ||
!Value.Check(AskParamsSchema, payload.params) ||
validateParams(payload.params, {
allowFreeform: payload.source === "answer-extraction",
}).ok === false
+9 -6
View File
@@ -4,8 +4,10 @@ import type {
ExtensionContext,
SessionEntry,
} from "@earendil-works/pi-coding-agent";
import { Value } from "typebox/value";
import { findPayloadForSourceEntry } from "./ask-payload-store.ts";
import { validateParams } from "./ask-tool-helpers.ts";
import { AskParamsSchema } from "./schema.ts";
import type { AskParams } from "./types.ts";
export const ASK_PENDING_DISMISSED_ENTRY_TYPE = "ask:pending-dismissed";
@@ -16,8 +18,6 @@ export interface PendingAskToolCall {
toolCallId: string;
}
type AskToolCall = ToolCall;
export function appendPendingAskDismissal(
pi: Pick<ExtensionAPI, "appendEntry">,
toolCallId: string
@@ -68,7 +68,7 @@ function collectResolvedToolCallIds(
function findUnresolvedAskToolCall(
entry: SessionEntry,
resolvedToolCallIds: ReadonlySet<string>
): AskToolCall | undefined {
): ToolCall | undefined {
if (
entry.type !== "message" ||
entry.message.role !== "assistant" ||
@@ -96,15 +96,18 @@ function findUnresolvedAskToolCall(
function resolvePendingAskParams(
ctx: Pick<ExtensionContext, "sessionManager">,
toolCall: AskToolCall
toolCall: ToolCall
): AskParams | undefined {
const persistedPayload = findPayloadForSourceEntry(ctx, toolCall.id, "tool");
if (persistedPayload) {
return persistedPayload.params;
}
const argumentsFallback = toolCall.arguments as AskParams | undefined;
if (argumentsFallback && validateParams(argumentsFallback).ok) {
const argumentsFallback = toolCall.arguments;
if (
Value.Check(AskParamsSchema, argumentsFallback) &&
validateParams(argumentsFallback).ok
) {
return argumentsFallback;
}
return;
+3 -3
View File
@@ -18,9 +18,9 @@ export function formatResultLines(
}
const answerLine = formatAnswerLine(question.label, answer, options.mode);
if (answerLine) {
lines.push(answerLine);
}
lines.push(
answerLine ?? formatUnansweredLine(question.label, options.mode)
);
if (hasPresentedTypeOverride(question.type, question.presentedType)) {
hasPresentationOverride = true;
+2 -1
View File
@@ -1,3 +1,4 @@
import { StringEnum } from "@earendil-works/pi-ai";
import { Type } from "typebox";
export const AskOptionSchema = Type.Object({
@@ -42,7 +43,7 @@ export const AskQuestionSchema = Type.Object({
"Required direct question shown to the user; ask about one decision at a time",
}),
type: Type.Optional(
Type.String({
StringEnum(["single", "multi", "preview"] as const, {
description:
"Question type: `single` means one answer is expected, `multi` means multiple answers could reasonably be selected, and `preview` means options need preview-pane detail. Use `preview` only when every option includes `preview` text; descriptions alone are not enough.",
})
+47 -1
View File
@@ -19,6 +19,7 @@ test("selectExtractionModel uses first configured model with auth", async () =>
const result = await selectExtractionModel(
{
model: model("fallback", "c"),
scopedModels: [],
modelRegistry: {
find(provider: string, _id: string) {
if (provider === "missing") {
@@ -44,11 +45,55 @@ test("selectExtractionModel uses first configured model with auth", async () =>
assert.deepEqual(result, {
model: second,
auth: { ok: true, apiKey: "key" },
usedFallback: false,
});
});
test("selectExtractionModel respects the current session model scope", async () => {
const configured = model("configured", "a");
const fallback = model("fallback", "b");
const result = await selectExtractionModel(
{
model: fallback,
scopedModels: [{ model: fallback }],
modelRegistry: {
find() {
return configured;
},
getApiKeyAndHeaders() {
return Promise.resolve({ ok: true, apiKey: "key" });
},
},
} as never,
[{ provider: "configured", id: "a" }]
);
assert.deepEqual(result, {
model: fallback,
usedFallback: true,
});
});
test("selectExtractionModel rejects a fallback outside the current session scope", async () => {
const fallback = model("fallback", "a");
const result = await selectExtractionModel(
{
model: fallback,
scopedModels: [{ model: model("allowed", "b") }],
modelRegistry: {
find() {
return;
},
},
} as never,
[]
);
assert.deepEqual(result, {
error: "No available extraction model in the current session model scope.",
});
});
test("extraction business rules flag option spam", () => {
const issues = collectExtractionBusinessIssues({
questions: [
@@ -111,6 +156,7 @@ test("selectExtractionModel validates fallback model auth", async () => {
const result = await selectExtractionModel(
{
model: fallback,
scopedModels: [],
modelRegistry: {
find() {
return;
+1
View File
@@ -85,6 +85,7 @@ test("branch payload lookup ignores invalid stored payloads", () => {
const result = findLatestPayloadInCurrentBranch(
ctx([
custom({ version: 1, source: "tool", params: { questions: [] } }),
custom({ version: 1, source: "tool", params: {} }),
custom({ version: 1, source: "answer-extraction", params }),
]),
"tool"
+21 -20
View File
@@ -1,9 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validateToolArguments } from "@earendil-works/pi-ai/base";
import { Value } from "typebox/value";
import { registerAskTool } from "../src/ask-tool.ts";
import { AskOptionSchema } from "../src/schema.ts";
import { AskOptionSchema, AskParamsSchema } from "../src/schema.ts";
import type { AskParams } from "../src/types.ts";
const NON_INTERACTIVE_MESSAGE_RE =
@@ -17,7 +16,6 @@ const PREVIEW_RULE_RE =
/questions\[0\]\.options\[0\]\.preview: Question 1, option 1: preview questions require preview text for every option; add preview text or use type "single" instead/;
const MISSING_OPTION_VALUE_RE =
/questions\[0\]\.options\[0\]\.value: Question 1, option 1: value is required/;
const MISSING_OPTION_LABEL_RE = /label/;
const EMPTY_QUESTIONS_RE = /questions: At least one question is required/;
const INVALID_TYPE_RE =
/questions\[0\]\.type: Question 1: invalid type "grid"; expected "single", "multi", or "preview"/;
@@ -96,6 +94,22 @@ test("ask option schema and tool guidance support grounded recommendations", ()
);
});
test("ask params schema constrains question types", () => {
assert.equal(
Value.Check(AskParamsSchema, {
questions: [
{
id: "layout",
prompt: "Choose a layout",
type: "grid",
options: [{ value: "compact", label: "Compact" }],
},
],
}),
false
);
});
test("ask tool stores valid payloads as soon as they are called", async () => {
const { entries, tool } = registerMockTool();
const params = sampleParams();
@@ -357,11 +371,7 @@ test("ask tool reports preview validation with structured issues", async () => {
test("ask tool accepts blank optional presentation fields", async () => {
const { tool } = registerMockTool();
const params = validateToolArguments(tool as never, {
type: "toolCall",
id: "call-blank-optionals",
name: "ask_user",
arguments: {
const params: AskParams = {
title: " ",
questions: [
{
@@ -378,8 +388,8 @@ test("ask tool accepts blank optional presentation fields", async () => {
],
},
],
},
}) as AskParams;
};
assert.equal(Value.Check(AskParamsSchema, params), true);
const result = await tool.execute(
"call-blank-optionals",
params,
@@ -446,16 +456,7 @@ test("public schema requires semantic identifiers and labels", () => {
},
],
};
assert.throws(
() =>
validateToolArguments(tool as never, {
type: "toolCall",
id: "call-missing-label",
name: "ask_user",
arguments: missingLabel,
}),
MISSING_OPTION_LABEL_RE
);
assert.equal(Value.Check(AskParamsSchema, missingLabel), false);
});
test("ask tool transcript renderers summarize call and cancelled result", () => {
+12 -2
View File
@@ -82,7 +82,7 @@ function toolResult(toolCallId: string) {
function storedPayload(
sourceEntryId: string,
storedParams: AskParams = params,
storedParams: unknown = params,
version = 1
) {
return {
@@ -185,12 +185,22 @@ test("pending ask scan validates recorded arguments as payload fallback", () =>
),
{ params, toolCallId: "call-2" }
);
assert.deepEqual(
findPendingAskToolCall(
scannerContext([askToolCall("call-3"), storedPayload("call-3", {})])
),
{ params, toolCallId: "call-3" }
);
assert.equal(
findPendingAskToolCall(
scannerContext([askToolCall("call-3", { questions: [] })])
scannerContext([askToolCall("call-4", { questions: [] })])
),
undefined
);
assert.equal(
findPendingAskToolCall(scannerContext([askToolCall("call-5", {})])),
undefined
);
});
test("pending ask scan skips a newer invalid call for an older valid call", () => {
+8 -1
View File
@@ -343,7 +343,14 @@ test("question-note-only results still render note content", () => {
},
};
assert.equal(renderResultText(result), " note: Prefer incremental changes");
assert.equal(
renderResultText(result),
"? Scope: (no answer)\n note: Prefer incremental changes"
);
assert.equal(
summarizeResult(result),
"Scope: (no answer)\nScope note: Prefer incremental changes"
);
});
test("elaborate results include unselected notes in summary and render output", () => {