# Proof audit, pass 1: ACP sign-in failure

PR [#29548](https://git.cafe/pingdotgg/t3code/pull/29548), branch `fix/issue-29534`, head [941452f31c](https://github.com/pingdotgg/t3code/commit/941452f31c1f7015f57e913da9da4ca34627af0d), reviewed against the supplied base [6f41364805](https://github.com/pingdotgg/t3code/commit/6f41364805e31a1f8d59c18c7cc451630cd9265d). Read-only code and PR-body inspection; no runtime or browser verification and no tests rerun. This is a fixture handoff, not a completed visual audit. All capture statuses below are missing by design in pass 1.

## Findings

1. **Evidence gate remains open.** The description contains a runtime transcript but no uploaded captures. Required rows W1, W2, and W3 need before/after evidence. W2 requires a recording: a still of an absent banner does not prove dismissal preserved the timeline item.
2. **The integration fixture is narrower/different than the session/new reproduction.** Its authentication case first returns a successful `session/new`, then rejects `session/set_config_option` for the selected model with `-32000`. It proves auth failure propagation during opening/model setup, not the claimed mock's `session/new` rejection. Keep those protocol claims distinct in evidence captions.
3. **Do not generalize the claim to every ACP prompt error.** `makeProviderFailure` prefers an explicit `input.message`; `acpRegistryPromptFailure` explicitly supplies `cause.errorMessage` for ACP request errors. That existing prompt path bypasses the new curated message. This is a limit of the PR body's broad “turn failures” wording, not a newly introduced regression or a demand to expand this evidence-only round.
4. **The mock must not auto-authenticate.** Use `T3_ACP_REQUIRE_AUTH=1`, with `T3_ACP_AUTH_METHOD_ID`, `T3_ACP_SESSION_LIFECYCLE`, and `T3_ACP_ANTIGRAVITY` unset. Otherwise the mock may advertise a usable authentication method and `AcpSessionRuntime` can authenticate and retry instead of failing visibly.

## Fixture matrix

“Affected” means the visible data can change; “minimum” identifies the actual capture gate. Optional rows document shared consumers without demanding a new platform campaign for an eight-line server change. A protocol-equivalent row does not add a visually distinct fixture.

| Row / named fixture | Affected / minimum | Data and expected presentation | Capture kind, viewport × theme | Real-app seed / route | Status |
|---|---|---|---|---|---|
| **W1 — Droid session open, banner visible** | Yes / required | One enabled local `acpRegistry` instance, `agentId: factory-droid`; one fresh thread; one user message; `session/new` returns `-32000`; settled failed run. Before: wrapper message including internal session ID. After: exact fixed sign-in sentence in both the error banner and the red **Provider session failed to open** timeline item. Timeline title stays unchanged. | Still pair, **1280×900 CSS px × dark**, before/after at the same viewport, theme, zoom, sidebar state and scroll position. Frame banner and item together, or supply two stills per revision if both do not fit. | Setup below; select `proof_droid`, send “Review the README”; open `/$environmentId/$threadId`. Wait for committed failed run/error item, not a guessed delay. | **MISSING — no captures yet** |
| **W2 — Droid error dismissed, item survives** | Yes / required | W1's exact settled failure. Click **Dismiss error**. Banner disappears; full timeline guidance remains. Do not send another turn or navigate to a different thread between those states. | **RECORDING**, **1280×900 × dark**, before and after revisions. Start with banner and timeline visible, show the dismissal click, then show the surviving item. If baseline banner exposes **Show full error**, demonstrate expansion/collapse in the same recording before dismissal so internal-ID text is legible. Still frames alone are insufficient. | Continue W1 in the same browser session. Dismissal is keyed by thread and message in an in-memory set; create a fresh thread for another take, or reload the page to reset that in-memory set. | **MISSING — no recordings yet** |
| **W3 — Generic local ACP, no registry identity** | Yes / required parity check; visually equivalent to W1 | Same as W1, with `agentId` omitted entirely and a different instance ID/display name. Confirms no Factory-specific recognition is needed. Same fixed sentence in banner and item; no internal ID or raw provider text after the fix. | Still pair, **1280×900 × dark**. No second dismissal recording is required because the resulting failure class/data shape and rendering are the same. | Setup below; select `proof_generic`, open a separate fresh thread and submit the same message. | **MISSING — no captures yet** |
| M1 — Native mobile failed-thread content | Yes / optional shared consumer; outside minimum gate | Connect mobile to the same isolated environment and open W1's failed thread. `ThreadFeed → ThreadWorkLog → ThreadWorkLogRow` renders the complete `failure.message` in selectable text with no expansion state. | Optional still pair, one platform only, e.g. **390×844 pt × dark**. Long-press/copy or a “Copied” transition is **RECORDING** if claimed; no need to claim or test it here. | Reuse the committed W1 data in the real app; no additional turn or provider account. Native device access is not needed for the minimum set. | **NOT CAPTURED — source traced; optional** |
| M2 — Mobile failed-thread list excerpt | Yes / optional shared consumer; outside minimum gate | Same W1 thread, failed status and nonempty `runtime.lastError`; list shows the sign-in message as a one-line excerpt. Truncation in this list is intentional; complete guidance belongs to M1. Selected/unselected and sidebar layouts alter styling, not message selection. | Optional still, same native viewport/theme as M1. Selecting/opening the row is **RECORDING** only if that interaction is claimed. | Reuse W1 data; return to the real mobile thread list. | **NOT CAPTURED — source traced; optional** |
| D1 — Delegated child's own failed thread | Yes / equivalent to W1/W2, outside minimum gate | A child using the same mock fails session opening. Opening that child's thread uses the same web banner/error-row renderer; failed child result projection also selects this `failure.message`. No claim about the reported ten-minute stall or parent-notification layout. | No extra capture required. If parent-to-child navigation or delayed completion is claimed, it is **RECORDING** and needs separate setup/evidence. | A real delegated-child run can target this configured instance; this pass does not launch an agent. Without such a real child, any constructed child view must be labelled **harness capture**, never live delegation proof. | **NOT CAPTURED — source traced; equivalent** |
| P1 — Auth-required model write during initial opening | Yes / protocol equivalent to W1; no separate visual gate | Session creation succeeds, model `replay-model-b` selection fails with `-32000`, error class `provider_error`. The changed replay assertion requires the same fixed sentence. | No extra visual capture. If illustrated from the replay harness, label **harness capture**. | Existing `AcpRegistryReplayFixtures.integration.test.ts`, case `authentication`. The stock mock does not expose a flag to reject only a model write with auth required; do not claim `T3_ACP_FAIL_SET_MODE_CONFIG_OPTION=1` does this (it returns `-32602`, for mode). | **SOURCE COVERED — not rerun or captured** |
| C1 — Non-auth ACP error / successful or pending run | No / control, outside minimum gate | `AcpRequestError.code !== -32000` follows the existing fallback; healthy/pending/empty threads do not acquire the new sign-in message. `-32602` model-selection replay cases retain their existing model guidance. | No capture required for this server-message evidence round. Any waiting/retry/recovery claim would require a **RECORDING**. | Existing tests/code; ordinary mock with auth-required marker absent is the healthy setup control. | **UNCHANGED — source inspected** |

**Minimum artifact set:** two before/after still pairs (W1 and W3) plus one before/after recording pair (W2), all at 1280×900 in dark theme. W1's stills can be extracted from W2's recordings if crisp and full-frame. No light-theme, narrow-web, Electron, native mobile, or delegated-child capture is required: this diff changes neither styling nor platform branches, and the message is a fixed plain sentence. Optional mobile evidence would be useful but is not a blocker for this scoped fix. Local/remote/relay/tunnel carry the same persisted string; no wire contract or transport changed.

## Reproducible fresh-database setup

These are instructions for the capturer; they were **not executed** by this audit. Keep every driver, log, home and capture beneath a new timestamped directory in that capturer's assigned subdirectory of `/tmp/lead-29548-r1/`. Do not delete a previous take. Start the real dev app with `T3CODE_DEV_SEED=0 vp run dev --home-dir <run>/home` (a fresh path); keep the startup pairing URL/token out of uploads. Do not use the live T3 home. Do not set Vite HTTP/WS origins.

1. Create one executable launcher per revision (or per instance if separate request logs are wanted). Use an **absolute** node executable and absolute mock script path for the checkout under test. The installed node in the audited environment resolves to `/home/ubuntu/.local/share/vite-plus/bin/node`. A launcher can select the stock mock's startup environment via an external marker, so switching auth state does not rewrite provider settings and trigger a new probe:

   ```bash
   #!/usr/bin/env bash
   unset T3_ACP_AUTH_METHOD_ID T3_ACP_SESSION_LIFECYCLE T3_ACP_ANTIGRAVITY
   export T3_ACP_REQUIRE_AUTH=0
   if [ -f '<run>/auth-required' ]; then
     export T3_ACP_REQUIRE_AUTH=1
   fi
   exec /home/ubuntu/.local/share/vite-plus/bin/node '<checkout>/apps/server/scripts/acp-mock-agent.ts'
   ```

   `<run>` and `<checkout>` are placeholders to replace with literal absolute paths. `chmod +x` the launcher. This is a real-app fixture using the shipped mock, not a synthetic UI harness. The marker is read at **process launch**, so use a fresh thread after switching it. Do not try to change the environment of an already running mock. Optionally set `T3_ACP_REQUEST_LOG_PATH` in the launcher to an instance-specific file under that take's directory for protocol evidence; upload only a sanitized excerpt.

2. In **Settings → Providers** (`/settings/providers`), choose the isolated environment, **Add provider → Local ACP command**. Configure `source: local`, executable `<run>/launcher.sh`, no arguments, enabled. For deterministic instance IDs and to create the Droid-labelled variant without UI defaults getting in the way, use the same app RPC that the settings UI uses: **`server.updateSettings`** with this payload (authenticated to the isolated environment with provider-management scope):

   ```json
   {
     "patch": {},
     "providerInstanceMutation": {
       "operation": "create",
       "instanceId": "proof_droid",
       "instance": {
         "driver": "acpRegistry",
         "enabled": true,
         "displayName": "Mock Droid",
         "config": {
           "source": "local",
           "agentId": "factory-droid",
           "commandPath": "<run>/launcher.sh",
           "commandArgs": []
         }
       }
     }
   }
   ```

   Create `proof_generic` with display name `Generic ACP` and the **agentId property omitted**; otherwise use the same config. `source: local` uses the command directly, even when `agentId` is present; it does not install or run Factory Droid. Do not use a registry executable override with registry distribution arguments. No real account or credential is necessary.

3. With the marker **absent**, wait for each provider probe to finish successfully, make sure both instances appear in the composer selector, and select the desired instance/model in a new thread. The ordinary mock advertises usable model state during initialization and session creation. Do not confuse a provider-status/probe failure banner with the thread's turn failure.

4. Create the literal marker file `<run>/auth-required` using the driver or shell. Do not change settings, click **Test**, or manually refresh provider health between this switch and sending the message: a probe started with the marker present will itself fail authentication and can prevent the intended direct-turn reproduction. Scheduled provider refresh is another reason to prepare the composer before the switch. If health has already changed, start a new timestamped take and repeat the healthy probe first; do not fabricate server state or remove evidence.

5. Submit “Review the README” in a fresh thread and wait on the app's failed run/error-item milestone. The mock handles `initialize`, then `session/new` calls `requireAuthentication()`, which returns `AcpRequestError.authRequired()` (`-32000`). With no advertised or configured auth method, runtime fallback propagates the auth error instead of auto-signing in. Preserve a sanitized request-log or server-event excerpt tying the failure to this instance/thread and `session/new` for each side. Capture W1 then W2. For W3, return to a normal-probe setup as necessary, select `proof_generic` in another fresh thread, switch the marker and submit.

6. Repeat the same driver and fixture inputs on the supplied base and reviewed head, each with a separate fresh home. Annotate each uploaded artifact with revision, fixture row, viewport/theme and whether it is a real-app or harness capture. The old PR transcript used other revisions; those are context, not substitutes for clearly labelling this round's revisions.

Expected after text, verbatim:

> The provider needs you to sign in. Sign in from its provider settings or with its own CLI or app, then retry the turn.

The fixed message has no provider-name interpolation, internal IDs, raw agent error text, JSON or newline. Longer provider display names, account counts and arbitrary provider error-message lengths do not create additional after-state fixtures.

## Render and branch inventory

- **Changed production function:** [`curatedCauseMessages`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/packages/provider-core/src/server/failure.ts#L74) walks cause wrappers with a depth bound/cycle guard. Exact tag `AcpRequestError` plus numeric `-32000` selects the fixed text. Other codes/tags retain their prior branches. `knownProviderFailureMessage` and `makeProviderFailure` are its only direct callers; explicit `makeProviderFailure.message` takes priority. Existing redaction/bounding remains; this fixed message never reaches the 4096-character cap.
- **Session-open data producer:** [`ProviderTurnStartService.settleStartFailure`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/apps/server/src/orchestration-v2/ProviderTurnStartService.ts#L520) checks the curated reason before wrapper text and persists `type: error`, `status: failed`, `class: provider_error`, title **Provider session failed to open**. Its open-session failure branch is at line 817. [`RunExecutionService`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/apps/server/src/orchestration-v2/RunExecutionService.ts#L1617) likewise prefers a known reason for uncaught execution failures; those may use a generic **Provider error** title. This does not override the adapter's explicitly supplied prompt failure text.
- **Banner path:** [`ChatView`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/apps/web/src/components/ChatView.tsx#L922) → `ChatBannerLane` → [`ThreadErrorBanner`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/apps/web/src/components/chat/ThreadErrorBanner.tsx#L72) → `ExpandableText`. Null/empty/dismissed errors render nothing; a local composer error takes priority over server runtime error. Relevant fixture has a nonempty runtime error, error styling, no usage-limit code, and dismiss available. Banner width is capped at 48rem and available-width minus margins. `ExpandableText` has an expand button only for unequal collapsed/full text, length >180, or newline. Fixed after text takes none of those branches; baseline internal-ID length may take expansion. W2 covers dismissal and any baseline expansion.
- **Web timeline path:** `ChatView` → `MessagesTimeline` → `WorkGroupSection` → `SimpleWorkEntryRow` → [`WorkEntryLogRow`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/apps/web/src/components/chat/TimelineWorkEntryRow.tsx#L170). Work groups can be standalone or expanded; both reach the same row. Empty groups disappear. The relevant terminal `error`/`failed` branch renders a red heading, timestamp and always-visible `failure.message` paragraph with whitespace wrapping and word breaking. It returns before generic row expansion/`V2ItemInspector`, so no hidden-detail expansion is needed to see this failure. `usage_limit` suppresses that paragraph and uses a warning heading/reset time, but this auth fixture is `provider_error`. The workspace-preparation retry button requires its separate code and eligible run and does not apply. No account-count, empty-model-list, loading skeleton or retry-count layout is introduced by this diff.
- **Mobile paths:** `ThreadFeed` → `ThreadWorkLog` → [`ThreadWorkLogRow`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/apps/mobile/src/features/threads/thread-work-log.tsx#L909) renders the same terminal failure body and accessibility label. Failed rows cannot expand; long press copies and briefly exposes a **Copied** indicator. Warning/reset-time and workspace-retry branches do not apply. Separately, [`thread-list-v2-items`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/apps/mobile/src/features/threads/thread-list-v2-items.tsx#L1236) displays nonempty `runtime.lastError` for failed/limited threads in a single line; selection/sidebar affects appearance. Both get data from the same persisted failure through shared thread-error derivation, not a mobile-specific mapper.
- **Delegation:** [`subagentProjection`](https://github.com/pingdotgg/t3code/blob/941452f31c1f7015f57e913da9da4ca34627af0d/packages/provider-core/src/server/subagentProjection.ts#L206) picks the latest failed error item's `failure.message` as a child's terminal result. The child's own view shares the above paths. This does not establish stall duration, navigation, or every parent-summary layout.
- **Other callers:** `ThreadLaunchService`, `ProviderSessionManager`, `Orchestrator`, adapter fallback paths and failure-turn-item construction also use the shared failure helper. They do not add a new layout in this diff: a recognized auth cause without an explicit message changes the persisted plain string; calls that supply a message keep it. Codex/Claude/Cursor/OpenCode/Grok need no separate fixture unless they actually produce this ACP-tagged cause. Antigravity or another ACP-backed driver can take the same generic fallback; built-in provider-specific auth paths are unchanged. Inspector/helper readers of `failure.message` do not create a distinct reachable terminal-error presentation here.
- **Controls:** sign-in text is guidance, not a new link/button; no new settings action, palette action, keybinding, timer, spinner or animation exists. Settings is only fixture setup. Success/recovery, pending, empty and usage-limit views remain unchanged. No CSS/theme/wire-contract change requires the viewport × theme cross-product.

## Review limits and cleanup

Covered the complete three-file supplied diff, PR description, cause selection/priority, session-open and execution producers, mock authentication/probe/launch behavior, web banner and timeline branches, mobile work-log/list consumers and child-result projection. No captures were available; none were made. No real Factory Droid account was exercised. No code edits, pushes, PR comments, dev servers, devices or leases. The lead's worktree and scratch root are retained. This step's scratch directory is removed after this matrix is uploaded.

FRICTION: none

VERDICT: INSUFFICIENT <all affected rows: no captures yet>
