Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
Use a checkout of the commit under Version or commit with its lockfile dependencies installed. Save the script below outside the checkout as repro.sh and run env -u NODE_ENV sh /path/to/repro.sh "$PWD" from the checkout root. It creates a private fixture directory with mktemp -d (under PI_PROBE_SCRATCH, else TMPDIR), sets private HOME, TMPDIR and T3CODE_HOME inside it, backs up and temporarily extends two existing test files (packages/provider-pi/src/server/adapter.test.ts and apps/server/src/orchestration-v2/RunExecutionService.test.ts) with probe cases, runs only those cases with vp test run, prints one PROBE_RESULT line per case, and restores both files and removes the fixture on exit. It changes no production code and makes no model request.
- The probe starts the production Pi adapter against the existing fake Pi RPC process and puts it in an active turn.
- The fake Pi process sends the
extension_ui_requestshapes that extension calls such asawait ctx.ui.input("", ""),await ctx.ui.select("", ["SQLite"])andawait ctx.ui.editor("", "seed")produce, with titles""and" ", plus the same requests titledWhich database?as controls. Pi's extension API (@earendil-works/pi-coding-agent1.5.2) accepts a string title and its RPC mode forwards it unchanged; it does not turn a nonblank title into a blank one. - The adapter's events go through the production
ProviderEventIngestorV2andRunExecutionService, with a recording event sink capturing what is written. When a waiting question is accepted, the probe answersSQLitethrough the adapter and ends the Pi turn normally. - Compare the blank-title cases with the nonblank controls, and with
confirmdialogs titled""," "andWhich database?, which map to an approval request.
The script exits 0 only when all 12 cases match the assertions described under Actual behavior.
Reproduction script: repro.sh
#!/bin/sh
set -eu
checkout=${1:-$PWD}
cd "$checkout"
[ -f packages/provider-pi/src/server/adapter.test.ts ]
tmp=$(mktemp -d "${PI_PROBE_SCRATCH:-${TMPDIR:-/tmp}}/pi-blank-title.XXXXXX")
export HOME="$tmp/fake-home" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3"
mkdir -p "$HOME" "$TMPDIR" "$T3CODE_HOME"
export PATH="$checkout/node_modules/.bin:$PATH"
export PI_PROBE_RESULTS_FILE="$tmp/results.jsonl"
a=packages/provider-pi/src/server/adapter.test.ts
r=apps/server/src/orchestration-v2/RunExecutionService.test.ts
cp "$a" "$tmp/adapter.bak"
cp "$r" "$tmp/run.bak"
restore() { cp "$tmp/adapter.bak" "$a"; cp "$tmp/run.bak" "$r"; rm -rf "$tmp"; }
trap restore EXIT INT TERM
cat >> "$a" <<'TS'
export { makeFakePi as probeMakeFakePi, openRuntime as probeOpenRuntime, makeAppThread as probeMakeAppThread, layerTest as probeAdapterLayer };
TS
# Extend only the existing test fixture's identity parameters, never production code.
python3 - "$r" <<'PY'
import sys
p=sys.argv[1]
s=open(p).read()
s=s.replace('function captureRootRunTermination(input: {', 'function captureRootRunTermination(input: {\n readonly probeIds?: BackgroundScenarioIds;')
s=s.replace('const ids = backgroundScenarioIds(input.key);', 'const ids = input.probeIds ?? backgroundScenarioIds(input.key);')
s=s.replace('ServerSettings.layerTest(),', 'ServerSettings.layerTest(), ProbeCommands.layer,')
open(p,'w').write(s)
PY
cat >> "$r" <<'TS'
import { probeMakeFakePi, probeOpenRuntime, probeMakeAppThread, probeAdapterLayer } from "../../../../packages/provider-pi/src/server/adapter.test.ts";
import { appendFileSync as probeAppendFileSync } from "node:fs";
import * as ProbeSchema from "effect/Schema";
import { OrchestrationV2UserInputQuestion as ProbeQuestion } from "@t3tools/contracts";
import * as ProbeGit from "../vcs/GitVcsDriver.ts";
import * as ProbeCommands from "./ThreadCommandExecutor.ts";
import * as ProbeRegistry from "../provider/ProviderInstanceRegistry.ts";
it.effect.each(["input", "select", "editor", "confirm"].flatMap(method => ["Which database?", "", " "].map(title => ({method, title}))))("PROBE production Pi blank title %j", ({method, title}) =>
Effect.gen(function* () {
const key = method + ":" + (title ? (title.trim() ? "pi-title-control" : "pi-title-spaces") : "pi-title-blank");
const ids = backgroundScenarioIds(key);
const fake = yield* probeMakeFakePi;
fake.setStreaming(true);
const { runtime, emitted } = yield* probeOpenRuntime(fake, "default", ids.threadId);
const providerThread = yield* runtime.ensureThread({ threadId: ids.threadId, modelSelection: { instanceId: ProviderInstanceId.make("pi"), model: "default" }, runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null } });
Object.assign(ids, { providerThreadId: providerThread.id });
const accepted: OrchestrationV2DomainEvent[] = [];
const errors: string[] = [];
const interruptions: unknown[] = [];
const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2.pipe(
Effect.provide(ProviderEventIngestor.layer.pipe(Layer.provide(Layer.mergeAll(
IdAllocator.layer,
Layer.mock(EventSink.EventSinkV2)({ write: ({events}) => Effect.sync(() => { accepted.push(...events); return []; }), writeIfRunCurrent: ({events}) => Effect.sync(() => { accepted.push(...events); return { committed: true, storedEvents: [] }; }), writeIfProviderThreadOwner: ({events}) => Effect.sync(() => { accepted.push(...events); return { committed: true, storedEvents: [] }; }) }),
Layer.mock(ProjectionStore.ProjectionStoreV2)({ isNodeStored: () => Effect.succeed(false), getPendingNativeUserInputs: () => Effect.succeed({ runtimeRequests: [], nodes: [], turnItems: [] }) }),
Layer.mock(ProbeGit.GitVcsDriver)({}),
Layer.mock(ProbeCommands.ThreadCommandExecutor)({}),
Layer.mock(ProbeRegistry.ProviderInstanceRegistry)({ getInstance: () => Effect.undefined }),
)))),
);
let question: unknown;
const result = yield* captureRootRunTermination({
key, probeIds: ids, initialProviderTurnId: null,
shouldFinalizeRun: () => Effect.succeed(true),
events: () => Stream.fromQueue(emitted),
startTurn: (input) => Effect.gen(function* () {
yield* runtime.startTurn({ ...input, providerThread, appThread: yield* probeMakeAppThread("default", ids.threadId), modelSelection: { instanceId: ProviderInstanceId.make("pi"), model: "default" } });
yield* fake.emit({ type: "agent_start" });
yield* fake.emit({ type: "extension_ui_request", id: "dialog-probe", method, title, ...(method === "input" ? {placeholder: ""} : method === "select" ? {options: ["SQLite"]} : method === "confirm" ? {message: ""} : {prefill: "seed"}) });
}),
ingestNormalized: (input) => Effect.gen(function* () {
if (input.event.type === "turn_item.updated" && (input.event.turnItem.type === "user_input_request" || input.event.turnItem.type === "approval_request") && input.event.turnItem.status === "waiting") {
question = input.event.turnItem.type === "user_input_request" ? input.event.turnItem.questions[0] : { prompt: input.event.turnItem.prompt };
}
const exit = yield* ingestor.ingestNormalized(input).pipe(Effect.exit);
if (Exit.isFailure(exit)) {
errors.push(String(Cause.squash(exit.cause)));
return yield* Effect.failCause(exit.cause);
}
if (input.event.type === "turn_item.updated" && (input.event.turnItem.type === "user_input_request" || input.event.turnItem.type === "approval_request") && input.event.turnItem.status === "waiting") {
yield* runtime.respondToRuntimeRequest({ requestId: input.event.turnItem.requestId, decision: "accept", answers: { "dialog-probe": "SQLite" } });
fake.setStreaming(false);
yield* fake.emit({ type: "agent_end", messages: [], willRetry: false });
yield* fake.emit({ type: "agent_settled" });
}
return exit.value;
}),
interruptTurn: (input) => Effect.gen(function* () {
interruptions.push(input);
yield* runtime.interruptTurn({ ...input, providerThread });
}),
});
const prompts = accepted.filter(e => e.type === "turn-item.updated" && (e.payload.type === "user_input_request" || e.payload.type === "approval_request") && e.payload.status === "waiting");
const responses = fake.allRequests().filter(r => r.type === "extension_ui_response");
const run = result.finalEvents.find(e => e.type === "run.updated");
const failure = result.written.find(i => i.type === "error");
let questionDecode = "not-a-question";
if (method !== "confirm") {
try { ProbeSchema.decodeUnknownSync(ProbeQuestion)(question); questionDecode = "OK"; }
catch (e) { questionDecode = String(e); }
}
probeAppendFileSync(process.env.PI_PROBE_RESULTS_FILE!, "PROBE_RESULT=" + JSON.stringify({ method, title, question, questionDecode, acceptedPrompts: prompts.length, responses, interrupts: interruptions.length, nativeCommands: fake.allRequests().map(r=>r.type), runStatus: run?.payload.status, failure: failure?.failure.message, ingestError: errors }) + "\n");
if (method === "confirm" || title.trim()) {
assert.equal(prompts.length, 1);
assert.equal(method === "confirm" ? responses[0]?.confirmed : responses[0]?.value, method === "confirm" ? true : "SQLite");
assert.equal(errors.length, 0);
assert.equal(interruptions.length, 0);
assert.equal(run?.payload.status, "waiting");
} else {
assert.equal(prompts.length, 0);
assert.equal(errors.length, 1);
assert.include(errors[0]!, "Schema validation failed");
assert.equal(interruptions.length, 1);
assert.equal(run?.payload.status, "failed");
assert.isTrue(fake.allRequests().some(r => r.type === "abort"));
assert.isFalse(responses.some(r=>r.value === "SQLite"));
}
}).pipe(Effect.scoped, Effect.provide(probeAdapterLayer)),
);
TS
# Expose the production runtime's queue from the existing fake-RPC harness.
python3 - "$a" <<'PY'
import sys
p=sys.argv[1]; s=open(p).read().replace('return { runtime, takeEvent };', 'return { runtime, takeEvent, emitted };'); open(p,'w').write(s)
PY
vp test run "$r" -t 'PROBE production Pi blank title'
cat "$PI_PROBE_RESULTS_FILE"Expected behavior
A dialog that Pi's extension API accepts should reach the user as an answerable T3 question without failing the active run. The adapter's output must satisfy OrchestrationV2UserInputQuestion.question: TrimmedNonEmptyString, including when the extension's title is empty or whitespace-only, as it already does for a blank placeholder under a nonblank title. Any nonblank question text, such as a generic prompt for a titleless dialog, satisfies the contract; this report does not prescribe the wording.
Actual behavior
Summary. When a Pi extension opens an input, select, or editor dialog whose title is empty or whitespace-only (with no nonblank message or placeholder), the Pi adapter emits a user-input question whose question text is that same blank string. OrchestrationV2UserInputQuestion.question requires a non-blank string, so ingestion rejects the event, no question reaches the user, the server interrupts the turn (Pi receives clear_queue and abort), and the run ends failed with "Provider turn failed.". This is the symptom reported in #22405; its fix (#23235) covered blank placeholders under a nonblank title, and the blank-title case remains. The same dialogs with a nonblank title work, and a blank confirm dialog is unaffected because it uses the approval path. Reachability is narrow: an extension author must pass or compute a blank title, no published extension that does so was found, and real-world frequency is not established.
In the reproduction, every input, select and editor request with a "" or " " title produced a question whose question field was that same blank string. Decoding it with the contract schema fails with Expected a non-blank string at ["question"]. The ingestor rejected the turn item (surfacing as Error: Schema validation failed), so the recording sink accepted no question and no extension_ui_response (answer or cancellation) was sent to Pi. The run service then requested interruption: the adapter sent clear_queue and abort to Pi, and the run was written as failed with an error item reading Provider turn failed.. This is a failed run, not a turn left running with no prompt.
The Which database? controls produced an accepted question, sent SQLite back to Pi, and reached the ordinary waiting finalization state without interruption. All three confirm cases, including blank titles and an empty message, were accepted as approval requests and answered without interruption, because the approval prompt allows an empty string.
From source, the adapter derives the title with recordString(event, "title") ?? method, which keeps an empty string, and piQuestion skips a blank message or placeholder but then assigns question: detail ?? title. The run's ingestion-failure handler requests the interruption and writes the failed outcome. The generic Schema validation failed wording is a separate error-wrapper defect (see References); the direct decode above names the invalid field.
Evidence
- Expected source:
OrchestrationV2UserInputQuestioninpackages/contracts/src/orchestrationV2.tslines 1371-1389 (question: TrimmedNonEmptyString), and the passing nonblank-title controls in the reproduction. - Failure source: the observed reproduction output under Logs or stack traces, and
question: detail ?? titleinpiQuestionatpackages/provider-pi/src/server/adapter.tslines 3656-3690, which passes a blank title through. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The probe exercises the production Pi adapter, ingestor and run service against the fake Pi RPC process with a recording sink and minimal test service layers. It does not establish OS-level termination of a real Pi process, recovery of later live turns in the same thread, or how the web or mobile client displays the failure.
Restoration check
With the script's input, the input, select and editor dialogs titled "" and " " each produce a question that decodes as OrchestrationV2UserInputQuestion; the recording sink accepts one waiting question; the answer SQLite reaches Pi as an extension_ui_response; no interruption, clear_queue or abort occurs; and the run reaches waiting like the controls. As controls, the Which database? dialogs still show that title as the question, and confirm dialogs still go through the approval path. The script asserts the current failing results, so its blank-title expectations need flipping to the restored values for this check.
References
- Pi run fails with "Provider turn failed." when a question's "Type something" option opens a follow-up input dialog #22405 (closed) reported the same "Provider turn failed." symptom for a "Type something" follow-up input dialog with a blank placeholder. fix(pi): stop blank input placeholders from failing the run #23235 (merged as commit 6d9ec7b) fixed blank placeholders when the title is nonblank; the blank-title case was outside that fix, so this is a remaining gap rather than a regression.
- fix(pi): preserve native dialog titles and input hints #23662 (closed, unmerged) contained a blank-title fallback in
piQuestion(a trimmed title, otherwise a generic question). It was closed on 2026-11-09 in favor of fix(pi): render input dialogs as free-text questions #33494. - fix(pi): render input dialogs as free-text questions #33494 (open) renders input dialogs as free-text questions. Its current diff still assigns
question: detail ?? titleand adds no blank-title handling, so it does not cover this case as it stands. - [Bug]: Pi extension input dialogs render as a single "Submit empty value" option, with the placeholder as the question #23494 (open) is a different input-dialog rendering problem (a single "Submit empty value" option with the placeholder as the question).
- fix(server): report invalid provider events without a second schema failure #23650 (open) fixes the secondary error-wrapper defect that reports the decode failure as a generic second schema failure; it does not make the blank question valid.
- fix(pi): ask_user_question single-select questions render as a structured card #32125 and fix(pi): ask_user_question multi-select questions show their options as checkboxes #30608 (open) handle structured
ask_user_questiondialogs and keep the generic dialog mapping for other calls.
Impact
Minor bug or occasional failure
Version or commit
0.1.9 at d9805b2, rerun from a clean checkout; the same script also passed on main at d412871 using the tested commit's installed dependency tree; main at 476fc9b changes none of the Pi adapter, contracts, ingestor or run-service files (source comparison only)
Environment
macOS (Darwin 27.2.0) arm64, Node v26.12.1, repository-pinned Vite+ runner with Vitest 5.0.5; production Pi adapter, ingestor and run service against the repo's fake Pi RPC process; Pi extension API source inspected at @earendil-works/pi-coding-agent 1.5.2; no authenticated model, real Pi process, browser, desktop app or persisted database
Logs or stack traces
Probe output from the rerun, reduced to the fields that matter, followed by the full PROBE_RESULT JSON for two blank-title cases:
method title question questionDecode prompts interrupts native commands runStatus failure
input "Which database?" "Which database?" OK 1 0 extension_ui_response waiting -
input "" "" SchemaError (blank) 0 1 clear_queue,abort failed Provider turn failed.
input " " " " SchemaError (blank) 0 1 clear_queue,abort failed Provider turn failed.
select "Which database?" "Which database?" OK 1 0 extension_ui_response waiting -
select "" "" SchemaError (blank) 0 1 clear_queue,abort failed Provider turn failed.
select " " " " SchemaError (blank) 0 1 clear_queue,abort failed Provider turn failed.
editor "Which database?" "Which database?" OK 1 0 extension_ui_response waiting -
editor "" "" SchemaError (blank) 0 1 clear_queue,abort failed Provider turn failed.
editor " " " " SchemaError (blank) 0 1 clear_queue,abort failed Provider turn failed.
confirm "Which database?" (approval) not-a-question 1 0 extension_ui_response waiting -
confirm "" (approval) not-a-question 1 0 extension_ui_response waiting -
confirm " " (approval) not-a-question 1 0 extension_ui_response waiting -
Test Files 1 passed (1)
Tests 12 passed | 190 skipped (202)
PROBE_RESULT={"method":"input","title":"","question":{"id":"dialog-probe","header":"Question","question":"","options":[{"label":"Submit empty value","description":"Send an empty string to the extension.","value":""}]},"questionDecode":"SchemaError(Expected a non-blank string\n at [\"question\"])","acceptedPrompts":0,"responses":[],"interrupts":1,"nativeCommands":["get_state","get_available_models","get_entries","set_session_name","prompt","clear_queue","abort","get_entries"],"runStatus":"failed","failure":"Provider turn failed.","ingestError":["Error: Schema validation failed"]}
PROBE_RESULT={"method":"editor","title":"","question":{"id":"dialog-probe","header":"Question","question":"","initialAnswer":"seed","options":[]},"questionDecode":"SchemaError(Expected a non-blank string\n at [\"question\"])","acceptedPrompts":0,"responses":[],"interrupts":1,"nativeCommands":["get_state","get_available_models","get_entries","set_session_name","prompt","clear_queue","abort","get_entries"],"runStatus":"failed","failure":"Provider turn failed.","ingestError":["Error: Schema validation failed"]}
Screenshots, recordings, or supporting files
No response
Workaround
An extension author can supply a nonblank title for input, select and editor dialogs; the controls show this restores the question and answer round trip. No workaround was established for a user answering the same blank-title dialog.
Activity