Skip to content

[Bug]: Pi extension input, select, or editor dialogs with a blank title fail the run with "Provider turn failed." #35237

Open

Description

@coygeek

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.

  1. The probe starts the production Pi adapter against the existing fake Pi RPC process and puts it in an active turn.
  2. The fake Pi process sends the extension_ui_request shapes that extension calls such as await ctx.ui.input("", ""), await ctx.ui.select("", ["SQLite"]) and await ctx.ui.editor("", "seed") produce, with titles "" and " ", plus the same requests titled Which database? as controls. Pi's extension API (@earendil-works/pi-coding-agent 1.5.2) accepts a string title and its RPC mode forwards it unchanged; it does not turn a nonblank title into a blank one.
  3. The adapter's events go through the production ProviderEventIngestorV2 and RunExecutionService, with a recording event sink capturing what is written. When a waiting question is accepted, the probe answers SQLite through the adapter and ends the Pi turn normally.
  4. Compare the blank-title cases with the nonblank controls, and with confirm dialogs titled "", " " and Which 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: OrchestrationV2UserInputQuestion in packages/contracts/src/orchestrationV2.ts lines 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 ?? title in piQuestion at packages/provider-pi/src/server/adapter.ts lines 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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions