Skip to content

04. E2E Test Plan ​

Scope: MVP acceptance scenarios plus current shipped product increments for Nib Status: Accepted (protocol/Electron automation is active; full desktop Playwright remains planned) Cross-references: acceptance-criteria · milestones · ai-development-workflow · change-checklist


E2E-TRANSCRIPT-lossless-tool-images ​

  • Entry: PI_DESKTOP_HOST_BIN=<candidate-host> pnpm test:e2e:tool-result-persistence after building Host, shared, agent-runtime and host-runtime.
  • Environment: Real isolated Host, SQLite and JSONL, production Desktop IPC and outbox; only Electron shell and unused importer/search services are stubs.
  • Build 35 deterministic valid PNGs / 548 rows. Verify the old redundant full request exceeds 64 MiB, each stored image occurs once, and restarting restores every canonical image, detail field and original JSON text spelling.
  • Build a second history whose unique image bytes alone exceed 64 MiB through individually legal appends. Exercise bounded ID operations and retained-image restoration, revision/fork, missing anchors and duplicate operation retries.
  • Unit/process gates also cover old-format files/outbox, corrupted references, failed conversion/drain, late callbacks and real sidecar restore errors.
  • The original Windows incident did not identify the failed RPC method; this deterministic regression does not claim to recover that missing evidence.

1. Goals ​

E2E model discovery membership ​

  • Entry: node scripts/e2e-model-discovery-state.mjs after building Host, shared, agent-runtime and host-runtime in the task worktree.
  • Environment: Real isolated Host process and SQLite; production provider IPC and Composer selection; simulated HTTP only, no credentials/live app.
  • Sequence: Discover two configured models and retain a never-discovered manual id; remove one; refresh offline; restart Host; read cache; republish; change id casing; send partial, malformed, and empty lists; change endpoint.
  • Expected: Missing previously published models leave normal/recent choices without deleting bindings or changing defaults. Offline/restart cannot resurrect them. Reappearance restores them. Manual ids and partial refreshes remain safe; old endpoint state does not contaminate the new endpoint.
  • Component coverage: provider-model-list-refresh.test.mjs also renders the actual settings panes and verifies saved bindings remain editable.

E2E-PI-130-kernel-upgrade ​

  • Preconditions: Exact Pi 1.3.0 pins and migrated patches in a dedicated worktree, isolated fixture state, and no real provider credentials.
  • Steps: Submit a Desktop turn that receives malformed final tool JSON, then a corrected Write call and a final answer. Repeat with a literal invalid Unicode escape in the tool input. Resume DeepSeek history using the same and a different provider/model identity. Run two turns with extensions that guard before_agent_start by messageType === "user". Exercise hosted search, native Pi continuation, and MCP with existing offline suites. Expand a slash template with an empty quoted argument followed by two values; exercise strict-tool downgrade, thinking-off, error images, mixed reasoning/text stream chunks, and native tool restoration under noTools: "all".
  • Expected: Malformed calls never reach Host execution; raw input reaches the next model request as a tool error, and only the corrected call executes. Unicode escapes retain all fields. Same-model thinking replays as reasoning; foreign thinking is omitted without altering visible history. Prompt additions remain ordered and do not accumulate. Host permissions, persistence, native tool restrictions, search replay, and cancellation remain unchanged. Empty quoted template arguments retain their positions and activate defaults; strict require remains strict, hosted search survives downgrade, and native historical tool activation cannot bypass Desktop's explicit no-tools setting.
  • Coverage: pi-upstream-1.3-contract.test.ts, flash-transcript.test.ts, hosted-search-consumers.test.ts, provider-stream-frame-fields.test.ts, extensions/prompt-chain.test.ts, native-pi-session.test.ts, pi-anthropic-strict-fallback.test.ts, pi-1.3-adapter-features.test.ts, prompt-templates.test.ts, and the existing test:e2e, test:e2e:hosted-search, test:e2e:provider-api-style, and test:e2e:trusted-extensions commands.
  • Specs: Pi dependency boundary, trusted extensions.
  • Acceptance: Offline runtime/contract and isolated process validation.
  • Milestone: Maintenance.
  • Status: Candidate validation recorded in docs/project/pi-130-adoption.md.

E2E-PI-140-kernel-upgrade ​

  • Preconditions: Exact Pi 1.4.0 release-group pins and migrated package patches in a dedicated worktree, isolated fixture state, and no real provider credentials.
  • Steps: Submit a Desktop prompt through the runtime with a local model fixture and capture the provider request identity. Repeat through a delegated subagent. Exercise OpenRouter, NVIDIA, and OpenCode identity-header mapping with pure request fixtures. Run the existing hosted-search, native Pi session, and trusted-extension offline suites against the installed 1.4.0 packages.
  • Expected: Parent and delegated requests retain pi-ai's detailed, versioned User-Agent while suppressing pi-agent-core's default Pi attribution headers. Existing hosted-search replay and validation, provider request errors, native-session context estimates, tool validation, extension compatibility, retry and cancellation behavior remain intact. No provider credentials or live services are used.
  • Coverage: pi-upstream-1.4-identity.test.ts, pi-upstream-1.4-contract.test.ts, pi-1.4-adapter-features.test.ts, runtime.test.ts, subagent.test.ts, plus the existing test:e2e:hosted-search and test:e2e:trusted-extensions commands.
  • Specs: Pi dependency boundary, trusted extensions.
  • Acceptance: Offline runtime/contract and isolated process validation.
  • Milestone: Maintenance.
  • Status: Candidate validation recorded in docs/project/pi-140-adoption.md.

E2E-QUEUE-send-now-wakes-subagents ​

  • Preconditions: Dedicated request worktree with built workspace packages, compatible reused Host binary, temporary HOME/data/project, and a local SSE model fixture. Production Composer queue service, Main IPC, AgentHost, Rust Host and agent sidecar are used; no installed app or real account is used. The fixture requires Linux/macOS HOME isolation and refuses Windows before Host startup because known-folder lookup does not honor that isolation.
  • Steps: Start two background delegates and let the parent enter TaskWait. Submit ordinary FIFO input and a correction, then choose Send now on the correction. Hold both delegate responses until the parent answers the correction. Release both responses and observe report delivery and FIFO drain.
  • Expected: The correction reaches the same durable turn, TaskWait returns interrupted, and neither delegate is aborted. The correction appears once in history and leaves the durable queue only after acceptance. Ordinary input remains queued until parent settlement. Both reports reach the parent and the remaining FIFO input runs once. No graceful stop is requested. In rendered Electron, expand the earlier process after the steering reply: both delegate nodes remain running with Stop controls, then become completed after their reports arrive; the ordinary queued row remains until settlement.
  • Coverage: pnpm test:e2e:send-now-subagents runs the headless process journey; append -- --auto to repeat with the session in auto mode. queue-pending-actions.test.mjs covers normal send followed by Send now through the production renderer service. AgentHost tests cover click order, concurrent delivery/cancellation, stale targets, and permission-mode preservation; runtime tests cover input before/during TaskWait and existing stop/dispose behavior. send-now-subagent-rendering.test.mjs covers the steering transcript path, parent settlement, ordinary-turn boundaries, compaction, and terminal Task snapshots through the real React rendering. The headless command does not replace the rendered Electron check above.

E2E-TOOLS-parallel-read-batches: ordered preparation and parallel reads ​

  • Isolate data/project paths; never run against production profiles.
  • A local provider fixture emits two Read calls. Gate real tool-execution entrypoints until both enter, release them in reverse order, then inspect the follow-up model request: results must retain source toolCallId order and final sibling rules must follow preparation order. Repeat with a delegated reader and verify parent instructions and permission scope are isolated.
  • Add Bash or Edit to the batch: all tools must execute sequentially. Stop before execution must skip unstarted calls; running file calls must drain.
  • Add Task with a delegate that edits a fixture file alongside a parent Read; the batch must stay sequential, Task keeps reporting a started delegation (ADR 0089), and the parent Read must not enter Host until the writable shared-workspace delegate settles its mutation. Task-only batches must still fan out.
  • Automated coverage: runtime.test.ts uses the production Runtime and pi loop with deterministic Host response barriers, including mixed Bash/Write/Edit batches and Stop, plus the mixed Task/read regression in both model orders: the Task result stays a started notice, the parent read waits for the writable shared-workspace delegate's mutation to settle before entering Host, and a failed delegate still releases the batch. scripts/e2e-task-read-batches.mjs runs the same contract end to end with a real delegate loop: it asserts the Host dispatch order write:start, write:end, read:start, read:end, that the parent reads the written content, and that the Task tool result stays a background acknowledgement. subagent.test.ts checks conservative policy inheritance.
  • Run node scripts/e2e-parallel-read-batches.mjs after building agent-runtime, shared and host-core. Set PI_DESKTOP_HOST_BIN when reusing a shared target. It uses a local HTTP provider, real parent/delegate loops and a real Host in temporary directories. A gated response proves overlapping Read dispatch; actual Read/Glob/Grep results, source-order model results and parent prompt isolation are checked. This does not measure blocking filesystem-body overlap, production throughput or Electron UI behavior. UI-only project-edit E2E is not evidence for agent Edit scheduling.
  • Run node scripts/e2e-task-read-batches.mjs under the same build and PI_DESKTOP_HOST_BIN preconditions. Against the pre-fix implementation it fails with read:start before write:start, which is the regression the maintainer asked for.

E2E-STORAGE-custom-location-and-maintenance ​

  • Preconditions: Dedicated request worktree, current remote-main base, shared compatible host toolchain, built host-core and desktop, isolated temporary data and Chromium profiles. No user's running desktop, provider, or network is used.
  • First-install / new-directory regression: Run node scripts/e2e-storage-directory.mjs with PI_DESKTOP_HOST_BIN pointing to the candidate Host. The production storage controls and preload invoke real Main handlers through native mouse clicks, schedule cold maintenance, and reopen the migrated Electron profile and Host. Cover an absent data root with missing parents and an existing conversation with attachments, settings and project memory. The chosen destination initially does not exist. Set PI_STORAGE_E2E_SOURCE_ROOT to a temporary folder on a different drive for cross-volume coverage. Verify browser localStorage, conversation/attachment reads, project-memory lookup, original backups and the displayed new path. Only native picker selection and relaunch dispatch are scripted; each startup phase is a separate real Electron process. Keep screenshots and synthetic profiles under .artifacts/ for inspection rather than deleting them.
  • Electron filesystem regression: Run node scripts/e2e-storage-files.mjs in real Electron, not ELECTRON_RUN_AS_NODE. Seed valid and opaque .asar files, live and dangling file/directory links, Unicode paths, and a destination collision. Expect byte-preserving copies, relocated internal links, unchanged external targets, and path-specific errors without mutating originals. The existing-profile Settings journey also includes an archive and a dangling dependency junction, then verifies history and browser state after reopening.
  • Steps: Through the production Settings page, choose/cancel a destination, confirm/retry migration, and confirm cache/backup cleanup. Seed a real host with sessions, queued attachments, project metadata, credentials and a local installed plugin. Stop it, copy with the production migration service, invoke the real Rust offline relocation process, and reopen the host. Seed default/persistent Chromium localStorage, execute the production cold bootstrap in real Electron, restart with its pointer, clear caches, and then separately remove old backups.
  • Expected: Data and browser source paths are visible, confirmation receives focus, duplicate actions are locked, environment overrides disable changes and failures allow recovery. Copy/verification/path-relocation progress is localized and uses a sandboxed nonpersistent window. Host data, credential decryption, installed plugin locations, main localStorage and plugin persistent storage survive migration/restart. Cache cleanup preserves durable state; backup cleanup retains active data and the stable bootstrap pointer. Filesystem regression tests reject unsafe targets, redirecting links, wrong ownership and active-root overlap.
  • Missing default root recovery: Run node scripts/e2e-storage-bootstrap.mjs with the managed default data root absent. Stub the native choice and folder dialogs at the Electron API boundary, then exercise both decisions in isolated launches: creating a fresh default profile, and selecting an existing storage root containing data/pi.sqlite. Confirm the pointer is normalized only after the selected action succeeds, the selected database remains byte-for-byte intact, and dismissing the choice leaves the pointer unchanged. Also compare Windows path variants with slash and case differences in the offline regression test so they still enter this recovery path.
  • Windows junction spelling: The filesystem suite includes a Node-created junction whose target ends with a separator. Copying it in Electron must preserve the target without a false symbolic-link verification failure.
  • Installation lock and cleanup retry: e2e-storage-backup-lock.mjs holds a real Electron single-instance lock while retrying cleanup with the old data already absent. Verify the lock, bootstrap pointer and current history remain. The Settings directory suite additionally confirms cleanup, cold-restarts and reopens the active Host; backup controls disappear and current attachments and browser state remain readable. These retry fixtures are not full-payload deletion tests and retain their synthetic profiles.
  • Coverage: pnpm test:e2e:storage runs e2e-storage-settings.mjs, e2e-storage-migration.mjs, e2e-storage-bootstrap.mjs, e2e-storage-directory.mjs, e2e-storage-files.mjs, and e2e-storage-backup-lock.mjs. The first uses the production renderer/API with only preload mocked; the latter suites run real host/Electron processes and production maintenance. The bootstrap harness intercepts relaunch to inspect its result, then explicitly starts another isolated child; it does not launch the user's desktop. The same bootstrap harness exercises missing-default recovery without showing native dialogs. storage-maintenance.test.mjs and Rust data_relocation tests cover rollback and path/filesystem boundaries.

E2E-LEGACY-DICTATION-idle-model-release ​

  • Preconditions: Exercise the retained local Dictation service through its VoiceService interface with a deterministic controller/model-manager stub and a controlled clock. Do not access a physical microphone or download a model.
  • Steps: Start and stop a recording, advance time to just before and then to the 10-minute idle boundary, and inspect unload calls. Repeat with a new recording before the boundary, a rejected new start, an error terminal phase, and service disposal.
  • Expected: A settled idle model unloads once at the boundary, never early. A new recording cancels the timer and its terminal phase starts a fresh window. A rejected start does not strand the unload. An active phase at the callback or service disposal prevents unloading.
  • Coverage: apps/desktop/test/voice-service-idle-unload.test.mjs runs the production VoiceService lifecycle with only the runtime and clock mocked. The separate Linux RSS measurement verifies that TranscribeModel.dispose() returns memory to the OS and that the existing lazy-load path reloads it.
  • Specs: Live Voice.

E2E-LIVE-VOICE-gemini-work-schema ​

  • Preconditions: Production Gemini adapter with a deterministic transport fixture and a bound work profile; no credentials or hardware devices.
  • Steps: Start the call with a work target, accept a valid setup, unmute and send PCM. Reject another setup with close 1007 and a UTF-8 reason. Repeat with a transport error followed by close; cancel and close the adapter.
  • Expected: Gemini sends the supported Schema subset and connects; OpenAI's schema and Host argument validation remain strict. Setup rejection settles immediately as LIVE_PROTOCOL_ERROR, transport errors remain network errors, and only one failure is reported. The raw reason reaches only the provider log; even the connect rejection crossing IPC contains only the public code. Cancellation suppresses terminal noise and close removes socket observers.
  • Specs: 03-runtime/live-voice.md, 09-logging-and-observability.md.
  • Acceptance: C, E, Security, Quality.
  • Milestone: Maintenance, issue #1812.
  • Automation: live-voice-gemini-adapter.test.mjs, live-voice-service-lifecycle.test.mjs, and voice-runtime live/protocol.test.ts.
  • Status: Adapter/service contract-covered; full Electron/Gemini account and physical microphone acceptance remain separate and are not certified here.

E2E-LIVE-VOICE-public-settings-and-reconnect ​

  • Preconditions: A built production Renderer and real Electron/Main/Host, isolated data/profile/project, developer mode off, a local TLS or loopback HTTP Realtime fixture and synthetic microphone. For TLS, trust only the fixture CA in the child process; do not disable TLS, sender, sandbox or microphone checks.
  • Steps: Open Live from Composer while disabled and follow Open settings. Find Voice through settings search, bind the fixture account, enable Live, connect, unmute, receive audio/captions, mute and hang up. Cancel a delayed startup, disconnect the provider and explicitly reconnect. Restart with the same isolated profile, inspect persisted settings and disable Live.
  • Expected: Voice is reachable without developer mode. Enabling settings does not capture audio; the real built Renderer passes the exact main-frame owner check. Only the chosen account is used. Mute stops input, every end closes media/socket resources, and failures never trigger an automatic new call. Settings survive restart without reconnecting. Legacy Dictation settings are unchanged; a voice-only call creates no Agent session.
  • Coverage: pnpm test:e2e:live-voice drives the built app and its concrete Realtime GA adapter against local WSS; pnpm test:e2e:live-voice -- --plain-http repeats the call flow against a loopback ws:// endpoint. live-voice-websocket-endpoint.test.mjs connects the production transport to a local loopback WebSocket and verifies proxy refusal; live-voice-owner.test.mjs bundles the production owner module and rejects other files/frames. Fixture audio is not physical-device or real-provider acceptance. Commands and results are recorded in docs/implementation/live-voice-public-readiness.md.
  • Specs: Live Voice.

E2E-LIVE-VOICE-provider-call-lifecycle ​

  • Preconditions: Isolated desktop profile with Live Voice enabled and one fixture provider binding. Use a deterministic local protocol fixture; do not use a real provider account or paid endpoint for automation.
  • Steps: Open Voice settings and confirm only Live Voice controls are visible; legacy Dictation toggles, microphone selection and transcription model controls are absent. Confirm the enabled Composer shows one Live preparation entry without a separate Work or old Dictation microphone button. Bind/select a fixture account for each supported adapter profile. In Settings → Shortcuts, customize the Live Voice toggle and use it to start then end a fixture call. Cancel a pending startup with the voiceCancel binding (default Escape) when no popup consumes the key; pressing Escape during a connected call must not end it. Restore the default, then explicitly Start from preparation, cancel during permission/startup, connect, mute/unmute, interrupt playback, inspect the in-memory transcript, and end. Repeat with Dictation already holding the microphone, a changed active binding, a provider credential removal, renderer hide/reload, and a rejected playback gesture.
  • Expected: Live is off by default; persisted Dictation settings remain unchanged but are not exposed in the UI; only the selected Provider ID is used; secrets do not enter Renderer; no Agent, MCP, shell or file action runs; mute gates new input immediately; cancellation stops late media; the transcript is not persisted; every end path releases media and background-throttling leases or quarantines an unconfirmed microphone lease. A new call starts only after explicit user action.
  • Specs: 03-runtime/live-voice.md, 03-runtime/20-speech.md, 04-ux/09-interaction-patterns.md.
  • Acceptance: Adapter setup and event parsing are local fixtures; Main lifecycle, owner, lease and settings behavior are covered by targeted tests. apps/desktop/test/live-voice-shortcuts.test.mjs covers the configurable toggle/cancel actions, including that Escape never ends a connected call. The full Electron flow and real-provider/device compatibility remain unverified until their respective isolated acceptance environments are run.

E2E-LIVE-VOICE-realtime-plaintext-user-endpoint ​

  • Preconditions: Isolated desktop profile with Live Voice enabled and an OpenAI-compatible API-key Provider whose base URL is a local plain-HTTP Realtime fixture such as http://127.0.0.1:<port>/v1. Do not use a real provider account.
  • Steps: Bind the Realtime adapter to that Provider and start a call with networkPolicy.mode at its default (relaxed). End the call, switch the mode to strict and start again. Repeat in relaxed with a system proxy that does not bypass the fixture host.
  • Expected: In relaxed mode the call connects over ws://127.0.0.1:<port>/v1/realtime?model=… and the one-time plaintext notice is raised. In strict mode, and on a proxied route, the call fails before any socket opens with a network-policy error. An https base URL still connects only over wss; Gemini never uses ws and Codex SDP stays HTTPS-only.
  • Specs: Live Voice, ADR 0304.
  • Acceptance: apps/desktop/test/live-voice-websocket-endpoint.test.mjs covers the scheme mapping, the user/third-party split and the refused base URL shapes; the network guard's relaxed/strict verdict is covered by the existing public-network tests. The full Electron flow remains unverified until its isolated acceptance environment is run.

E2E-LIVE-VOICE-four-stage-ui ​

  • Title: Disabled, preparation, compact call, and deliberate details.
  • Preconditions: Isolated Electron profile with Live Voice initially disabled, deterministic local provider/media fixtures, and local work-session fixtures. No real account, microphone, speaker, paid endpoint, or user's running Desktop instance is used.
  • Steps:
    1. Confirm neither Live nor Work Composer icon exists while disabled. Every account card shows only its provider picker until that account is bound. Enable Live Voice only from Settings → Voice; verify exactly one idle voice icon and that the card keeps its Model configuration link on the heading line beside the help mark.
    2. Open preparation and expand/collapse work options without starting. Verify no prepare request, microphone acquisition, media initialization, provider connection, or work dispatch occurs. Make the selected binding unavailable while another is ready: show the exact selected identity/reason, disable Start, and do not switch providers implicitly.
    3. With a ready binding, explicitly Start and observe Connecting plus Cancel in the compact bar, without automatically opening Details. Cancel while prepare or media acquisition is pending and deliver the late completion. Repeat Start to connect muted, then unmute/mute and inspect status.
    4. Open Details, inspect transcript/provider/work state, and dismiss it with Close, outside press, and Escape. Keep the call connected; use the bar to resume blocked sound, observe a rejected resume, and recover explicitly.
    5. Navigate between chat, Settings, Plugins, and sessions. The call chrome is a docked desktop widget window, so drag it by its own bar, verify the position survives that navigation and is clamped back inside the work area, and verify the main window draws no call bar. End or disable the feature with delayed Main termination and renderer cleanup, in either completion order. Keep Ending visible and prevent another Start until both settle. Exercise unconfirmed release and verify visible blocked recovery.
    6. Reopen preparation: work access and context consent are unchecked. Merely expanding the section or selecting a session never starts a call; Start without the work opt-in is voice-only. Opt into work explicitly, select a valid local target, and Start; inspect the fixed binding and work results in Details. End and reopen preparation, then change/create the target, opt out, or cancel and verify context consent resets. Ending Live does not stop already accepted work.
    7. Use the configured toggle to start a voice-only call directly and end it. During startup, a popup consumes Escape to dismiss itself without cancellation; without a consuming popup, the startup-cancel shortcut works. Escape never ends a connected call. Repeat visible controls in English and Simplified Chinese and check catalog coverage for all shipped locales.
  • Expected: Four distinct UI states, deliberate startup and work consent, exact-binding readiness, default mute, global cleanup visibility, accessible controls and focus return, and no changed IPC, persistence or permission boundary. Playback and error recovery do not require opening Details.
  • Specs: Live Voice, Live Work, Component spec, Settings IA.
  • Acceptance: C / E / Security / Quality.
  • Milestone: Post-MVP experimental interaction maintenance.
  • Coverage: Targeted controller/presentation regression tests and pnpm test:e2e:live-voice-interaction cover the mounted production controls, controller, store, shortcuts, i18n, cleanup ordering, work consent, and error recovery with simulated shell navigation and fake external edges. This does not mount full AppShell/settings/plugin routes or exercise a real provider, device, permission prompt, or audio output.
  • Status: Task-candidate fixture passed 48/48 interaction checks. Full AppShell, real-provider, and device compatibility remain unverified.

E2E-LIVE-WORK-session-admission ​

  • Preconditions: Isolated Live provider fixture, Desktop/Native Pi/Remote session catalog fixtures, and a deterministic fake intent resolver. Provider candidates enter through the existing Live adapter callback; do not use a real account or paid endpoint.
  • Steps: Start a call from a Composer with an active Desktop session and verify that session is the default target without a work-access checkbox or pre-call voice selection. Repeat with Native Pi and Remote Composer sessions. Start with no current session and verify the call remains unbound until the user lists sessions and chooses one by voice. Reject missing and duplicate session identities. With context sharing disabled, submit one work request and verify no history read; then test the separate bounded-context consent. Switch targets by voice during a call, submit before and after the switch, and verify the two operations remain associated with their respective sessions. Deliver each receipt, route through the classifier, and inspect backend admission and voiceOrigin. Exercise backend-specific queue/steer/stop capabilities, approval denial, stale turn, offline Remote Host, and a terminal event arriving before submit resolves. Query an exact terminal result without creating another Host turn. Request project/session lists, verify labels and opaque call-scoped references, then test opening and creating only through their existing panel actions. With a submission waiting on the user, confirm the compact bar keeps a waiting line and Details shows the pending ask question with an action that opens that exact session; verify another session's pending request stays unattributed. Then answer the open asktool question by voice and confirm the session's card resolves through the Host input path; retry with a label the question never offered, with a partial answer, and with two open questions, and confirm each is refused without a write and without being retried. Exercise feedback while generation, user speech, and playback are active; finish by ending Live after Host admission.
  • Expected: The initial target is the current Composer session when present; voice switching affects subsequent requests only. Main validates each target against a fresh multi-backend catalog, and ambiguity/unavailability fails closed. Context reads occur only after independent consent and remain bounded to the current target. Each backend retains its own admission, permission, approval, and capability rules; unsupported operations and offline targets are reported honestly. Duplicate provider IDs do not dispatch twice, terminal state comes from authoritative backend events, result queries project only the exact operation summary, and ending Live does not cancel accepted work. Selection references expose no raw paths or IDs and remain call-scoped. Automatic feedback and task execution remain separate; a renderer signal is not evidence that a person heard the result.
  • Coverage: apps/desktop/test/live-work-scope.test.mjs covers the fresh Desktop/Native Pi/Remote catalog, source-specific prompt/stop/queue routing, offline Remote failure, initial Native/Remote event subscriptions without a local AgentHost bridge, project/workspace identity, opaque refs, and ambiguous-target rejection. apps/desktop/test/live-voice-ipc-media.test.mjs covers separate target/context consent and rejects raw operation/session/turn IDs. apps/desktop/test/live-voice-service.test.mjs covers owner-scoped stop/cancel routing by call and operation IDs. packages/host-runtime/src/live-work/coordinator.test.ts covers receipt ordering, routing, stale steer, replay, call close, target switching, exact-turn stop and old-session queue cancellation. packages/host-runtime/src/live-work/context.test.ts covers bounded context projection. packages/agent-host/src/agent-host.test.ts covers history-free state, queue insertion, and voice provenance. packages/voice-runtime/src/live/protocol.test.ts covers provider-specific tool declarations and receipts. packages/host-runtime/src/live-work/result-summary.test.ts covers exact-turn summary projection and honest fallbacks. The LiveWorkFeedbackScheduler tests cover debounce, speech spacing, silent mode, stale downgrade, deduplication, and overflow; desktop service tests cover provider/user/local playback gating and separate delivery status. apps/desktop/test/live-work-scope.test.mjs covers selection reference scope/expiry, and live-work-operations.test.mjs covers the panel actions. apps/desktop/test/live-work-decision.test.mjs covers the waiting-decision projection, its session scoping, bounded plain-text rendering and the panel action that opens the bound session. packages/host-runtime/src/live-work/ask-answer.test.ts covers the spoken answer intent and its routing (resolved, refused, unknown and never resent), and apps/desktop/test/spoken-answer.test.mjs covers the option-only matching, the bounded read-out and the delivery budget. voice-runtime/src/live/playback-monitor.test.ts covers local audio signal detection. This is targeted automated coverage, not the complete W2-001—W2-096 matrix or real-provider E2E.
  • Status: Partial; remaining scenarios and the real-device matrix are tracked in docs/implementation/live-work-evidence.md.
  • Specs: live-work-session, live-voice.

E2E-LIVE-WORK-v2.1-reliability ​

  • Preconditions: An isolated local Host session, fixture classifier completion, and fake runtime/provider I/O. No real account, user project, or running Desktop instance is used.
  • Steps: Capture a null/active target at provider ingress and delay classification; race a validated stop against ordinary classification; make Host dispatch stay unresolved, then return exact read-only admission evidence; deliver terminal before the write acknowledgement; close Live during dispatch; change the authorized workspace before AgentHost admission; enqueue independent work; delay final assistant persistence; query the task result after a stop control operation.
  • Expected: Stale/null targets never become later turns. A stop barrier withdraws earlier writes that have not crossed admission. Explicitly busy ordinary submits do not silently enter the queue. Host uncertainty is shown as unknown and reconciled without a second write. Terminal evidence cannot revive or be overwritten by a late acknowledgement. Workspace mismatch creates no turn or queue item. Idle explicit queue entries drain through AgentHost; restored held entries remain held. A result query returns only an exact task result, not an admission or stop acknowledgement.
  • Coverage: packages/host-runtime/src/live-work/coordinator.test.ts, packages/host-runtime/src/live-work/operation-ledger.test.ts, packages/agent-host/src/agent-host.test.ts, apps/desktop/test/live-work-production-composition.test.mjs, and apps/desktop/test/agent-host-bridge-work-ack.test.mjs. The production composition fixture instantiates the Live Work bridge, AgentHost bridge, registered agentPrompt handler, and real AgentHost; Host-core persistence, concrete provider adapters/transports, and real device journeys remain outside that test boundary.
  • Status: Automated coverage is partial and is recorded per case in docs/implementation/live-work-v21-coverage.md. Real device acceptance is not run.
  • Specs: live-work-session, live-voice.

E2E-CHAT-fork-completed-reply-while-running ​

  • Preconditions: Isolated real desktop profile, configured model, two turns in one Desktop conversation; the first assistant reply has completed.
  • Steps: Send the second prompt. While it is still running, click Branch from this reply on the first reply. Continue chatting in the child, then return to the parent.
  • Expected: The child contains only history through the first reply, can continue independently, and starts with no running turn. The parent keeps running and retains its second prompt and reply. No live tail is overwritten. A whole-session fork and a fork within the active tool loop remain rejected. A navigation during the fork response records the child without stealing focus.
  • Coverage: Host fork regression, renderer session-fork-running tests, session IPC contract tests, and real-model desktop acceptance. A local model fixture or mocked component result is not real-model acceptance evidence.

E2E-CHAT-transcript-rewrite-late-events ​

  • Preconditions: Isolated session with at least one completed turn and a persistence callback from that turn held until transcript replacement or revision activation begins.
  • Steps: Start either transcript replacement or revision activation. Deliver the held old-turn event after the persistence outbox has drained but before the Host rewrite completes. Complete the rewrite, then start a new turn and persist one event. Repeat with the Host rewrite returning an error.
  • Expected: A successful rewrite does not append the old-turn event to the rewritten transcript, while the new turn persists normally. A failed rewrite replays the buffered event into the unchanged transcript. Both rewrite entrances use the same fence and outbox drain.
  • Coverage: session-turn-ended.test.mjs exercises the event persistence fence and rollback with deterministic event delivery; session IPC contract tests assert the fence/drain/rewrite ordering. No live provider is required.

E2E-WORKTREE-isolated-session-lifecycle ​

  • Preconditions: Isolated desktop profile and data directory, git 2.29+, a git project on a branch with at least one commit, a configured model, and an optional .worktreeinclude listing one ignored file.
  • Steps: Create two isolated sessions from the project menu and run a file-editing prompt in each at the same time. Run a Bash command that prints PI_PORT / PI_WORKTREE_ROOT. Merge the first session into the project; edit the same line in the project and in the second session, then merge the second. Resolve the conflict in the second session and merge again, then use Sync from project in the first session. In one isolated session, start two writing subagents in parallel. Finally delete an isolated session with unmerged work, and restart the app once while a session exists. Open the menu of a non-git project.
  • Expected: Each session's edits land only in its own worktree; the project checkout and git status stay clean until a merge, and the ignored include file is present in each worktree. The two sessions get distinct ports. The first merge fast-forwards the project and a success toast names the branch. The conflicting merge lists the files, leaves the project untouched, and marks the row Conflict; after resolution the badge clears and the merge lands. The first session's row shows ↓N; Sync from project shows the new commits, clears the marker, and brings the resolution into the first session without touching the project. Writing subagents edit in separate worktrees and completed runs merge back into the session. Delete warns about unmerged work and removes the worktree folder and pi-desktop/<id> branch without touching files linked from it. After restart the session reopens in its worktree; a manually removed worktree shows Folder missing and never falls back to the project. In the non-git project New isolated session is disabled with the reason under it.
  • Windows checks: a worktree containing a directory junction is deleted without following the junction; a deep path beyond 260 characters is created and removed; deleting while a file in the worktree is locked fails visibly and can be retried.
  • Specs linked: 03-runtime/03-tools-and-permissions.md §4e, §10.2; 03-runtime/06-host-rpc-protocol.md; 04-ux/01-ui-ia.md §3.2; ADR 0329.
  • Coverage: host-core session_worktrees tests (create, merge, conflict, removal, link safety, reconcile, delegates), worktree IPC contract tests, renderer worktree-ui tests, and agent-runtime delegate tests. The Windows checks run as host-core tests on a Windows host: junction removal (remove::tests), the replaced-root link (lifecycle::tests), and the deep path and locked-file delete through the RPC user path (rpc::worktree_rpc::tests). pnpm test:e2e:worktree-live (scripts/e2e-worktree-live.mjs) runs the steps above against a real model at protocol level, without the renderer.
  • Status: Partially automated

E2E-CONVERSATION-minimap-jump-leaves-follow ​

  • Preconditions: One Desktop conversation long enough that the transcript overflows its viewport, opened at the bottom so follow mode is pinned, with the conversation outline (minimap) visible on the transcript's left edge.
  • Steps: Click the outline dash of an earlier turn without scrolling the transcript first.
  • Expected: The transcript scrolls to that turn and stays there: the turn lands just below the scroller's top edge and the jump-to-latest control appears, because the jump leaves follow mode before it scrolls. A pinned follow must never re-bottom the view one frame after the click. The outline's earlier-history control keeps its existing reveal behavior.
  • Status: Automated in pnpm test:e2e:transcript-minimap-jump, which mounts the production ChatTranscript in a real Chromium page with a synthetic session, clicks an outline dash while follow is pinned, and asserts the scroller's distance from the bottom once the exchange has settled.

E2E-ASKTOOL-compact-card-interaction ​

  • Preconditions: No live provider is required; the fixture stubs api.resolveAskTool and seeds one two-question ask through the real store slice.
  • Steps: Mount the production AskToolCard, assert the header Tab order (decline, skip, next) with no legacy bottom action row, then walk select → next → submit, skip → submit, decline-all, and a custom answer, remounting with a fresh request id between flows. Finally mount a question whose body holds two markdown blocks and measure their boxes, then answer a question at the 300-character limit.
  • Expected: Every flow resolves through the store's resolveAsk with the exact answers (skips recorded as null), and no render errors are reported. The two question blocks stack vertically on a shared left edge that spans the card's text column, so a sentence never wraps inside a flex column. The full 300-character question remains visible and answerable; runtime validation rejects a 301-character question before opening the card.
  • Status: Automated in pnpm test:e2e:asktool-card, which mounts the production AskToolCard in a real Chromium page, clicks through the header actions, and measures the multi-block question layout. Runtime boundary validation is covered by packages/agent-runtime/src/runtime.test.ts.

E2E-ASKTOOL-draft-survives-session-switch ​

  • Preconditions: No live provider is required; the fixture stubs api.resolveAskTool and seeds one two-question ask through the real store slice.
  • Steps: Mount the production AskToolCard, select the second option on question one, advance to question two, unmount the card (the session switch the dock performs), and remount it with the same requestId. Then click the first step indicator to return to question one.
  • Expected: The remounted card opens on question two with the earlier selection still marked, and question one still shows the selected option. Resolving or dismissing the ask clears the stored draft.
  • Status: Automated in pnpm test:e2e:asktool-card as the E2E-ASKTOOL-draft-survives-session-switch flow.

E2E-POWER-keep-awake-setting ​

  • Preconditions: An isolated desktop profile with the setting absent; no live provider is required.
  • Steps: Open Settings > General and enable Keep computer awake. Confirm the main process owns one prevent-app-suspension blocker while idle. Close and reopen the app using the same profile; confirm it restores one blocker. Enable and disable Prevent screen sleep while Keep computer awake stays on, confirming the independent system request remains. Disable Keep computer awake and confirm its blocker is released; then quit and confirm cleanup.
  • Expected: The setting persists, acts immediately, never starts duplicate blockers, and releases on disable or app shutdown. The display switch keeps its own blocker and cannot disable the system blocker. Manual sleep and lid close are outside this contract.
  • Status: Automated in pnpm test:e2e:keep-awake, with a real isolated Electron/Host profile and a Windows powercfg /requests assertion when no other Electron power request is present at baseline. Controller lifecycle and Host settings round-trip also have targeted tests.

E2E-IMAGES-provider-save-feedback ​

  • Preconditions: Image configuration UI fixture; English and Chinese.
  • Steps: Mark image models and save the provider; choose a different image default from its summary; pick a candidate the stored list no longer offers; then unmark the sole image model and save.
  • Expected: Provider edits confirm the provider update, including after clearing the image selection. Explicit default selection keeps its specific image-selection confirmation. A pick the page can no longer accept reports that the image model could not be saved instead of keeping the previous default silently. Persisted bindings retain their existing behavior.
  • Specs: 03-runtime/21-image-generation. Acceptance: B.
  • Milestone: Maintenance. Status: Automated by

E2E-IMAGES-remove-configured-model ​

  • Preconditions: API-boundary fixture with an image-marked model and another configured model on its provider; repeat with legacy single binding and with another image candidate on another provider.
  • Steps: Remove the marked model in the provider editor without toggling its image checkbox. Cancel once, repeat and Save, then reopen settings and editor.
  • Expected: Cancel preserves both configurations. Save removes the model and its image candidate and clears the image default, even when another image candidate remains on this or another provider. The settings summary therefore shows the unavailable state until the user explicitly chooses a new default. An unaffected chat default stays unchanged; a removed chat default still falls back to the first configured model. Reload preserves results.
  • Specs: 03-runtime/21-image-generation. Acceptance: B.
  • Milestone: Maintenance. Status: Automated in English and Chinese by scripts/e2e-image-generation-ui.mjs.

E2E-IMAGES-desktop-conversation ​

  • Preconditions: Isolated desktop profile and workspace, built image feature, local chat and OpenAI Images HTTP fixtures; no live provider credentials.
  • Steps: Open Models settings with no image model configured; verify the image summary row is absent. Open a provider's Advanced model settings and verify Set as image model is grouped with the image/document attachment capabilities and can be checked for multiple models. Save, use the image summary menu to choose one marked model as the default, then submit a two-image request through the composer and edit the first output through a follow-up message. Collapse tool details.
  • Expected: After saving, the image summary appears with a 12px default-row gap and lists all marked candidates, the selected default is changeable without changing the chat default, decoded image previews stay outside collapsed process details, multipart source upload is used for editing, originals are preserved, and no image HTTP request occurs while unconfigured.
  • Settings interactions: The unconfigured state renders no image summary. Mark candidates in the attachment capability group, save, and select one from the summary menu. An existing missing/disabled candidate shows only Currently unavailable. Covered in scripts/e2e-image-generation-ui.mjs.
  • Image model deselection (#826): With one provider and one model marked for images, uncheck it in the provider editor. Cancel must preserve the image default; Save must clear both its candidate and default binding. Reopen settings and the editor: the image row stays hidden, the checkbox stays unchecked, and the model can be selected as the chat default. Covered in English and Chinese by scripts/e2e-image-generation-ui.mjs with an API-boundary fixture.
  • Conversation selection: Every marked image provider/model is absent from default and Composer candidates. Other providers retain same-ID models. An existing session pinned to any marked image binding is rejected before inference.
  • Transport contracts: Real stdio reverse RPC retains a thrown local image error's stable code in the production ParentHostProxy. Local HTTP tests check single/multiple binary multipart fields and boundaries, DALL-E b64_json requests, GPT Image parameter omission, bounded responses, and rejection of more than four references before I/O. These are protocol tests, not official provider account/live compatibility certification.
  • Proxy fake-IP: With the explicit Settings → General → Network proxy fake-IP option enabled, a URL response resolved to Clash's benchmark range is downloaded through the proxy-aware transport; without the option it stays IMAGE_UNSAFE_URL, and real private addresses remain blocked in both cases. Covered by packages/agent-runtime/src/image-generation/download.test.ts, not by the driver below.
  • Status: Automated in node scripts/e2e-image-chat.mjs; optional screenshots use PI_IMAGE_CHAT_EVIDENCE_DIR. The images are deterministic raster fixtures, not evidence of real-model quality or provider compatibility.

E2E-SCHEDULED-desktop-automation-lifecycle ​

  • Additional coverage: Shared model/reasoning root menu, searchable model submenu, keyboard model selection, selecting and reopening high, unchanged application defaults, and the saved thinking level on the executed session.

  • Preconditions: Isolated desktop profile, built task candidate, local SSE fixture model; no real provider credentials or paid API.

  • Steps: Open the footer clock; create a daily Morning task at 09:00; select another saved project, Auto permission and a non-default configured model; edit its name; select a daily time period from four fixed defaults; open the weekday menu, select custom days, save and reopen; reject empty days; verify the four defaults, arrows, Home/End, Enter, Escape/Tab and outside dismissal; select hourly without time fields and verify its first occurrence is one hour away; pause/resume; Run now and read the admitted run's transcript inside the task page; select an interval cadence, enter 30 minutes and verify the row states that span instead of a clock, then switch to a weekly cadence and back to confirm the value survived; open the edit form and verify the task column and the task page step aside; let one task accumulate more runs than the shared window while another stays idle, and verify the idle task still reports its own last outcome; open the result conversation from that page and verify its top bar offers the way back to the scheduled task; return and confirm the same task and run are still selected; observe automatic completion; delete the settled task. In a normal Agent conversation, use model tool calls to discover, create, list, update to 15:30 and delete a task. Verify the custom time appears in the form and survives renaming. The model is a local deterministic fixture, not a live provider.

  • Expected: Project, permission and exact provider/model persist on that task, reopen with the same values and reach the sidecar without changing another task. The project, permission and model controls remain inside the Instruction field's Composer-style bottom toolbar, including at the narrow viewport, with no horizontal overflow. Legacy rows without the new fields retain their previous defaults. Configuration persists, next time is visible, paused tasks do not dispatch, both execution paths reach the real Agent sidecar, the task's own history lists each run with its status and duration and Open conversation reaches the persisted transcript while the SessionList and session search never list it, that conversation's top bar returns to the same task and run, an interval task arms from the span the form states and reports it in its row, the task form owns the page while it is open, a task that stayed idle while another accumulated more runs than the shared window still reports its own last outcome, and automatic execution does not require a renderer prompt. Host tests additionally prove duplicate admission rejection, stale/missed occurrence handling, invalid input rejection and recovery.

  • Specs: 04-ux/01-ui-ia §3.3; 04-ux/08-component-spec §6, §20A; 03-runtime/04-data-storage §4.11; ADR scheduled-desktop-automations; ADR 0305.

  • Acceptance: Scheduled task execution and recoverable run history.

  • Milestone: Post-MVP desktop automations.

  • Status: Automated in node scripts/e2e-scheduled.mjs; run against the request candidate per AGENTS.md, with tested head and base recorded.

E2E-PLUGIN-appearance-extension-lifecycle ​

  • Preconditions: A fixture plugin declares ui.theme, ui.settings, one asset-backed scenic card with a typed --nexus-backdrop-blur 0..20px variable.

  • Steps: Open Settings, find the final Extensions group by search and keyboard navigation, open the entry, change the variable, select/reselect the theme, restart, then disable, reload and uninstall the plugin while its entry is active.

  • Expected: The Extensions rail draws the destination's host icon token (palette / Lucide Palette), not a Skills book glyph and not plugin SVG. Only the declared variable rule changes after Apply; static plugin-asset:// URLs remain valid; the value restores after restart; host- rendered cards retain the scenic backdrop behind their translucent content, have no opaque native rectangle, and leave Windows/Linux minimize/maximize controls clickable while Settings is open. Invalid/cross-plugin values are refused. Every lifecycle transition removes the entry and returns the app to General.

  • Status: Documented; run after integration into main.

  • Document every user-visible and protocol-visible behavior that MVP must verify.

  • Provide a scenario catalog that maps to acceptance criteria (A–H) and milestones (M1–M6).

  • Serve as the traceability backbone: scenario ID ↔ acceptance criterion ↔ spec.

  • Define the required E2E gate for code-bearing task candidates containing the latest origin/main, before the pull request; do not merge into local main.

  • Keep validation evidence tied to the commit the gate ran on, plus any later commit that changes the landed executable content.

E2E-PROVIDER-adaptive-thinking-protocol ​

  • Preconditions: Isolated provider settings fixture with a non-OAuth Anthropic Messages provider, claude-opus-5-5, a legacy Claude model, and a Claude-named relay id whose generic model config has no protocol or reasoning-options metadata; no live credentials.
  • Steps: Open a configured model's Advanced settings, inspect the thinking-protocol row, save an adaptive model with medium, then submit a request through the local Anthropic transport fixture. Repeat with the legacy model. Submit a request with the protocol unset for the generic relay model, then repeat after selecting its legacy protocol.
  • Expected: The adaptive model sends thinking.type=adaptive and output_config.effort=medium; the legacy model sends the existing budget-based thinking shape. Omitting the new field on an old binding keeps the existing inference after reload: effort-only models use adaptive, while models publishing budget_tokens (including effort + budget combinations) retain budget thinking. The metadata-free generic Claude relay defaults to adaptive, while its explicit legacy selection sends budget thinking. An explicit per-model compat.forceAdaptiveThinking value overrides automatic inference when no protocol is selected.
  • Specs: 03-runtime/11-provider-model-system; 03-runtime/12-provider-config-schema.
  • Acceptance: Provider/model protocol selection and wire compatibility.
  • Milestone: Maintenance.
  • Status: Protocol coverage is automated in the agent-runtime provider binding tests; desktop settings coverage remains part of the provider model configuration E2E fixture.

2. Non-goals ​

  • Full UI-driven automated coverage; protocol and source-contract automation is active while the broader desktop suite remains planned.
  • General performance / stress testing (post-MVP); bounded regression checks for desktop responsiveness are covered by the relevant functional scenarios.
  • Native Windows/Linux release qualification (published artifacts exist; native qualification gaps remain documented).
  • Hostile-plugin sandbox scenarios. Publisher provenance and the marketplace download boundary are now in scope (E2E-024R through E2E-024V); basic browse/install/update flows were already part of the catalog.
  • Remote Gateway / browser control remains out of scope (ADR 0004 / baseline #20); the bounded local loopback MCP control plane is covered by E2E-220.

3. Test Pyramid ​

        ╱  E2E  ╲           — few, high-value, cross-system
       ╱ Integration ╲      — IPC/RPC contracts, host↔renderer
      ╱    Unit       ╲     — per-module, fast, isolated
LevelScopeCount targetTooling
UnitSingle module, no IPCManyVitest / Rust #[test]
IntegrationIPC contract, host↔renderer, host↔sidecarModerateVitest + IPC mocks or live Electron
E2EFull user journey through the desktop app100+ functional + US-UI visual catalogprotocol smoke + Electron probes now; Playwright later

Strategy: document all E2E scenarios; add or update unit/integration tests alongside code when change risk makes them necessary; use selective, high-value E2E suites for every code-bearing pull request. Lower-level tests localize correctness, while E2E validates cross-process runtime behavior; passing lower levels does not waive the relevant E2E gate.


4. Tooling Intent ​

ToolPurposeStatus
VitestUnit + integration (TS side)Active (pnpm test, shared package)
Rust #[test]Host-core unit testsActive (cargo test -p host-core)
Protocol smokeHost RPC + tools + plugins headlessActive (test:e2e, 22 checks)
Electron probesBoot bridge, session-list responsiveness, and crash supervisionActive (test:e2e:boot, test:e2e:supervision)
PlaywrightFull UI-driven journeysPlanned (post-M5)

Decision: protocol smoke and Electron probes are active validation assets; broader desktop journeys remain scenario-specific and must be recorded as environment-limited when their required platform is unavailable.


5. Environment Requirements ​

RequirementDetail
PlatformmacOS arm64 and Intel x64, Windows x64, and Linux x64 and arm64 release targets (D126/D285, D638 / ADR 0318)
ProfileClean ~/.pi-desktop profile (no prior config)
FixturesSample project directory (examples/fixtures/sample-project/)
Sample pluginexamples/plugins/hello loaded from local path
ProviderAt least one provider with a valid key (test account)
DisplayHeadless-capable Electron or real display

6. Scenario Template ​

Each scenario is documented in this format:

markdown
### E2E-<ID>: <title>

- **Preconditions**: what must be true before steps start
- **Steps**: ordered list of user / system actions
- **Expected**: observable outcome that proves correctness
- **Specs linked**: relevant spec file(s)
- **Acceptance criterion**: which A–H letter(s) this verifies
- **Milestone**: M1–M6 target
- **Status**: Draft | Documented | Partially automated | Automated | Passed

E2E Main Integration Validation ​

Every code-bearing change must pass the E2E suites relevant to its regression surface on a candidate that already contains the latest origin/main, before the request branch is pushed and the pull request is opened. Code-bearing changes include Renderer, Electron Main, Preload, Agent Runtime, Rust host-core, sessions, transcripts, plans, plugins, MCP, permissions, provider/model runtime, persistence, process lifecycle, packaging/runtime startup, and build or CI behavior that affects application execution. Documentation-only changes are exempt when they do not alter executable behavior.

Run the selected suites from the request worktree after pnpm check:pr-base passes. An E2E run on a branch that is behind origin/main is exploratory and does not satisfy this gate. Do not merge the task into local main to create the candidate.

E2E environment reuse ​

Task-candidate E2E runs from the request worktree after the candidate is prepared, but uses the host development environment already provisioned in the primary checkout. Reuse the compatible Node/pnpm toolchain, node_modules, Electron, Rust/Cargo targets, package-manager stores, build caches, and ignored configuration by reference or link when needed.

Do not run pnpm install, npm install, or create a second dependency/runtime environment solely for E2E. Isolate only mutable test state: temporary profiles, data directories, sockets, ports, logs, and artifacts. Install or rebuild dependencies only when the host environment is missing or incompatible and record the reason. Clean CI and release runners may install from lockfiles.

Use the root package.json as the source of truth for executable commands. The minimum selection is:

  • Cross-cutting runtime, host, or IPC: pnpm test:e2e.
  • Electron startup, preload, or window lifecycle: pnpm test:e2e and pnpm test:e2e:boot.
  • Session-list refresh or model capability lookup: pnpm test:e2e and pnpm test:e2e:boot, including the synthetic large-list responsiveness check.
  • Settings/composer/plugin search theme surfaces: pnpm build:js followed by pnpm test:e2e:theme-surfaces.
  • Composer clipboard representation and text insertion: pnpm test:e2e:composer-paste.
  • Composer slash-menu name/description layout: pnpm build:js followed by pnpm test:e2e:composer-autocomplete.
  • Transcript render boundaries and cross-part delegation display: pnpm test:e2e:transcript.
  • Plan host/runtime behavior: pnpm test:e2e and pnpm test:e2e:plan.
  • Plan UI behavior: pnpm test:e2e:plan and pnpm test:e2e:plan-ui.
  • Host supervision, crash recovery, or restart behavior: pnpm test:e2e and pnpm test:e2e:supervision.
  • Subagent lifecycle: pnpm test:e2e and pnpm test:e2e:subagents.
  • Imported-extension dependency installation or registry-boundary changes: pnpm test:e2e:plugin-import-deps.
  • Trusted extension or plugin-extension changes: pnpm test:e2e:trusted-extensions.
  • Session collaboration / Session Orchestrator: pnpm test:e2e:collaboration.
  • Completion-notice silence or the silent-turn contract (D193 / D446): pnpm test:e2e:session-completion.
  • Changes spanning multiple surfaces use the union of the applicable suites.

pnpm test:e2e is the default cross-system smoke suite for host RPC, IPC, agent execution, plugins, persistence integration, and shared runtime contracts. A required suite that cannot run because of a missing display, platform, credential, hardware resource, or other environment capability must be recorded as NOT RUN with its reason, alternative validation, and remaining risk. The pull request may still be opened with that record so the change can be validated in a capable environment, but the gate is not satisfied and delivery remains incomplete until the suite passes against the integrated main that carries the change.

Required results must apply to the executable commit the gate ran on, and any later commit that changes the landed executable content requires a rerun of the affected suites (landing fixes, conflict resolution, or commits added during review). Otherwise the recorded result stands. Report each command, result, tested commit, and any relevant environment limitation; never claim an unexecuted suite passed.

E2E Failure Policy ​

A failed required E2E blocks the branch push, the pull request, and declaring the integrated delivery complete until the failure is classified as an implementation regression, test regression, environment failure, or known flaky infrastructure. Fix the product or test defect and rerun the affected suite against the integrated main. Do not delete scenarios, weaken assertions, or add retries that hide a deterministic failure. When a scenario is not automated on the required platform, keep its status documented and identify the platform validation still needed.

7. MVP Scenario Catalog ​

Runtime Resource Governance ​

E2E-097: Tool burst is bounded and recovers after host restart ​

  • Preconditions: Host-core is healthy; one session has a workspace; the supervision probe can terminate the host process.
  • Steps: 1) Dispatch a burst larger than the host tool budget containing read tools and shell commands. 2) Observe app.health while the burst runs. 3) Terminate host-core during active calls. 4) Wait for one supervised restart. 5) Allow the persistence outbox to flush.
  • Expected: Active shell processes never exceed the configured global and per-session limits. Excess work returns HOST_OVERLOADED or waits in the bounded queue. Queued requests do not reserve execution capacity: with four running Bash calls and twelve queued Bash calls, another session can still Read and Write. A saturated session cannot reserve spare class capacity, and admission-future cancellation/timeout restores queue and execution counters. Only one restart loop runs; stale-generation calls fail fast as HOST_UNAVAILABLE; no repeated ERR_STREAM_DESTROYED persistence storm is emitted. Temporary OS thread pressure during the same burst does not terminate host-core through its stdio control path; the host remains on one generation and capacity errors stay structured. Completed assistant/tool messages are persisted once after recovery.
  • Specs linked: 03-runtime/06-host-rpc-protocol.md, 03-runtime/07-process-model.md, 03-runtime/08-error-codes.md, 03-runtime/09-logging-and-observability.md, ADR 0051
  • Acceptance: A (runtime health), C (tool execution and recovery)
  • Milestone: M5
  • Status: Cross-session admission is automated by node scripts/e2e-tool-admission.mjs against an isolated real host. Class, session, global, mutation, queue bounds, fairness, cancellation, and timeout are unit-covered by tool_budget/tests.rs; restart coverage remains pending.

Release & Packaging ​

E2E-192: Linux release publishes a system-Electron ASAR asset ​

  • Preconditions: A vX.Y.Z tag matches apps/desktop/package.json; the Linux x64 release runner can complete dist:linux and has a system Electron available for repackaging validation. The arm64 lane is E2E-192a.
  • Steps: 1) Run the tag release workflow. 2) Inspect the published GitHub Release assets. 3) Confirm the versioned Nib-X.Y.Z-linux-x64.asar asset is present. 4) Place that archive in the target Electron resources layout with the target package's native host and other resources, then launch it with electron <archive>.asar.
  • Expected: The ASAR is copied byte-for-byte from linux-unpacked/resources/app.asar, is uploaded alongside the Linux AppImage, deb, and rpm, and the system Electron opens the Nib application archive without requiring the bundled Electron executable.
  • Specs linked: 06-delivery/06-release-runbook.md, 03-runtime/07-process-model.md
  • Acceptance: Quality (release artifact and packaging compatibility)
  • Milestone: M6+
  • Status: Documented; artifact export is unit-covered, native system-Electron repackaging remains runner validation

E2E-192a: Linux arm64 release lane publishes native arm64 packages ​

  • Preconditions: A vX.Y.Z tag matches apps/desktop/package.json; GitHub's arm64 ubuntu-22.04-arm runner is available to the repository.
  • Steps: 1) Run the tag release workflow. 2) Confirm the arm64 lane ran on an aarch64 runner and that the packaged target/release/pi-desktop-host-core is an AArch64 binary. 3) Inspect the published Release assets for Nib-X.Y.Z-linux-arm64.AppImage, pi-desktop_X.Y.Z_arm64.deb, pi-desktop-X.Y.Z-aarch64.rpm, Nib-X.Y.Z-linux-arm64.asar, and pi-host-X.Y.Z-linux-arm64.tar.gz with its .sha256. 4) Confirm latest-linux.yml still lists the x64 AppImage while latest-linux-arm64.yml lists the arm64 one. 5) Install the arm64 AppImage, deb, or rpm on an arm64 Linux machine and launch it.
  • Expected: Both feeds describe exactly one architecture-labelled AppImage each, the arm64 packages carry an arm64 host-core, the app starts on arm64 Linux, and merging the lanes never replaces the x64 feed with the arm64 one.
  • Specs linked: 06-delivery/06-release-runbook.md, 01-product/01-product-scope.md
  • Acceptance: Quality (release artifact and packaging compatibility)
  • Milestone: M6+
  • Status: The matrix, feed naming, artifact naming, and the ASAR export are unit/source-contract covered (ci-workflow.test.mjs, release-asar.test.mjs); native arm64 installation remains runner validation. Microphone capture stays Raspberry Pi only on arm64 Linux (D638 / ADR 0318).

E2E-200: Linux RPM preserves the Wayland desktop identity ​

  • Preconditions: A Linux x64 package validation run or tag release can complete on Ubuntu 22.04; a clean Fedora 44 KDE/Wayland machine is available for installation and launch.
  • Steps: 1) Build the Linux targets and inspect the RPM with rpm -qip and rpm -qpl. 2) Confirm the package contains the application archive, host-core, pi-desktop.desktop, and the 512px pi-desktop icon. 3) Confirm the RPM has no global /usr/lib/.build-id links. 4) Install the RPM on the Fedora KDE/Wayland machine and launch Nib from its desktop entry. 5) Inspect the taskbar grouping and the installed desktop entry.
  • Expected: The x64 RPM is produced with the documented name and uploads with the release artifacts. Its desktop entry contains Icon=pi-desktop and StartupWMClass=pi-desktop; the running Wayland window groups with the Nib launcher and shows its icon instead of a generic Electron icon. The package remains notify-and-link for updates, and the bundled Electron binaries do not create global build-id links.
  • Specs linked: 01-product/01-product-scope.md, 04-ux/09-interaction-patterns.md, 06-delivery/06-release-runbook.md
  • Acceptance: Quality (release packaging and desktop integration)
  • Milestone: M6+
  • Status: Unit/source-contract covered (auto-update.test.mjs, development-branding.test.mjs, ci-workflow.test.mjs); Fedora KDE/Wayland installation remains runner validation

E2E-196a: Unsigned macOS debug lane ​

  • Preconditions: The Release workflow is manually dispatched with sign_macos: false; Windows and Linux release credentials are not affected. This path must not be used to publish a GitHub Release tag.
  • Steps: 1) Dispatch the Release workflow with sign_macos: false. 2) Confirm both macOS architectures complete ordinary DMG/ZIP packaging without certificate secrets. 3) Inspect the artifacts and workflow steps.
  • Expected: macOS DMG/ZIP artifacts are produced and uploaded without Developer ID signatures or notarization, using explicit -arm64 and -x64 filename markers for their native architecture; macOS staple and Gatekeeper checks are explicitly skipped. Windows/Linux artifacts and the merged updater feed still publish normally. This exception does not satisfy E2E-196c.
  • Specs linked: 06-delivery/06-release-runbook.md
  • Acceptance: Quality (debug packaging)
  • Milestone: M6+
  • Status: Opt-in debug lane; tag releases must satisfy E2E-196c.

E2E-196b: macOS packages omit first-launch helper assets ​

  • Preconditions: An unsigned macOS debug packaging run has produced DMG and ZIP artifacts for at least one native architecture.
  • Steps: 1) Inspect the DMG and confirm the app and Applications link are the only items in its window. 2) Inspect the ZIP root without extracting the app and confirm neither Nib-macOS-opening-help.txt nor Nib-macOS-open.command is present. 3) Confirm the app remains installable from the ZIP.
  • Expected: The DMG contains the branded 720×440 background, the app, and the Applications link only. The ZIP contains the app but neither first-launch guidance asset. The same omission applies to signed and unsigned macOS artifacts; signing, notarization, and updater behavior are unchanged.
  • Specs linked: 06-delivery/06-release-runbook.md, 05-security/01-security.md
  • Acceptance: Quality, Security
  • Milestone: M6+
  • Status: Automated by packaging-footprint.test.mjs; native archive inspection remains release-runner validation

E2E-196c: macOS tag artifacts pass Gatekeeper without a quarantine bypass ​

  • Preconditions: A vX.Y.Z tag matching apps/desktop/package.json is pushed, or the Release workflow is dispatched with sign_macos: true (default); GitHub Actions has CSC_LINK, CSC_KEY_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID secrets; both native macOS runners are available.
  • Steps: 1) Run the tag workflow. 2) For each macOS architecture, inspect the unpacked app with codesign -dv --verbose=4 and confirm authority Developer ID Application: XingYu Liu (DUV63RKYTW). 3) Run codesign --verify --deep --strict --verbose=2, spctl --assess --type execute --verbose=4, and xcrun stapler validate against the app, including Contents/Resources/bin/pi-desktop-host-core. 4) Confirm the app stapler shim retries only xcrun stapler staple up to six total attempts after the app submission is accepted, then confirm the workflow's DMG step reported an Apple notary status of Accepted and run xcrun stapler validate against the matching DMG. 5) Download the DMG on a clean macOS profile, move the app to /Applications, and open it without clearing com.apple.quarantine.
  • Expected: Each macOS app passes signature integrity, Gatekeeper reports source=Notarized Developer ID, and both app and DMG contain valid stapled tickets. The DMG has its own submission: a DMG that was never submitted has no ticket and fails stapler staple with error 65, so a tag build must never reach that state. The app opens normally; no xattr quarantine-removal command or Security & Privacy override is required. Missing secrets, a rejected submission, or an exhausted staple retry fail the job.
  • Specs linked: 06-delivery/06-release-runbook.md, 05-security/01-security.md, ADR 0289
  • Acceptance: Quality, Security
  • Milestone: M6+
  • Status: Workflow script/unit-covered; clean-machine journey required for each release (run only in a capable environment when this surface changes)

E2E-212: GitHub Release starts the CNB mirror pipeline ​

  • Preconditions: Repository secret CNB_MIRROR_TOKEN is configured on secretkeyllc/nib; the CNB pipeline at aixk/Pi-Desktop listens for api_trigger_mirror; a GitHub Release tag such as vX.Y.Z exists with uploaded artifacts.
  • Steps: 1) Publish or edit that GitHub Release, or dispatch mirror-to-cnb.yml with the same tag. 2) Inspect the Actions log for the resolved tag and the POST to api.cnb.cool. 3) Confirm the CNB pipeline starts with MIRROR_TAGS equal to that tag.
  • Expected: The job runs only on secretkeyllc/nib. Manual dispatch without a vX.Y.Z tag fails before calling CNB. A missing CNB_MIRROR_TOKEN fails closed. The JSON body is built with jq (not YAML string interpolation). GitHub Release artifacts and updater feeds are unchanged; CNB is a mirror of the same tag.
  • Specs linked: 06-delivery/06-release-runbook.md
  • Acceptance: Quality (release mirroring)
  • Milestone: M6+
  • Status: Source-contract covered (ci-workflow.test.mjs); live CNB start remains operator validation (run only in a capable environment when this surface changes)

Boot & Healthcheck ​

E2E-001: App launches and shows main window ​

  • Preconditions: macOS arm64 or Intel x64; no prior ~/.pi-desktop profile. For the development lane, workspace package build outputs are absent or older than their TypeScript sources.
  • Steps: 1) Launch Nib. In the development lane, use pnpm dev. 2) Observe main window appears. In the macOS development lane, confirm the menu-bar tray icon appears.
  • Expected: Development launch rebuilds all workspace dependencies before host-core and Electron startup. Window first shows the branded startup splash while bootstrap runs, then reveals the main shell in English with the current locale catalog; no compile error, missing-menu runtime error, or crash; version info visible. The macOS development bundle contains both tray icon resources, and the tray starts without a missing-icon warning. Key lifecycle and error records are written to the categorized logs. GitHub auto-update is not started until after ensureWindow, and a hung feed cannot keep updater status on checking for Chromium's ~60s timeout. The Main output root also resolves from inside Rollup shared chunks (out/main/chunks/*.js), so the renderer entry, preloads, and the forked plugin host keep their authored relative paths instead of resolving one level too deep (black screen).
  • Specs linked: 03-runtime/07-process-model.md, 04-ux/01-ui-ia.md, 03-runtime/09-logging-and-observability.md
  • Acceptance: A (app startup)
  • Milestone: M1
  • Status: Partially automated (runtime-build-contract.test.mjs covers the dependency build contract; update-timeout.test.mjs and auto-update.test.mjs cover the bounded auto-check contract; main-module-path.test.mjs covers the Main output-root contract from the entry bundle and from inside out/main/chunks/; Electron window launch remains Draft)

E2E-002: IPC bridge is functional ​

  • Preconditions: App is running.
  • Steps: 1) Trigger an action that calls preload IPC (e.g. version query). 2) Observe result in renderer.
  • Expected: Main↔renderer IPC returns expected data; no error. The packaged main and plugin-panel sandbox preloads are self-contained and do not require an additional local runtime chunk.
  • Specs linked: 03-runtime/01-ipc-protocol.md
  • Acceptance: A (bridge normal)
  • Milestone: M1
  • Status: Automated (scripts/e2e-electron-boot.mjs — sandboxed preload bridge + IPC round-trip)

E2E-003: Rust host healthcheck responds ​

  • Preconditions: App is running; Rust host-core sidecar started.
  • Steps: 1) Electron handshakes with protocol version 11. 2) Call the host healthcheck RPC. 3) Repeat boot with mismatched older and newer protocol fixtures.
  • Expected: The protocol v11 host returns ok and the handshake is logged. Every version other than v11, whether older or newer, is rejected before the conversation surface becomes interactive, so Plan approval/state events and context checkpoints cannot be silently lost.
  • Specs linked: 03-runtime/05-host-core-rust.md, 03-runtime/06-host-rpc-protocol.md
  • Acceptance: A (bridge normal)
  • Milestone: M1
  • Status: Automated (protocol smoke)

E2E-004: First-run inline checklist appears ​

  • Preconditions: Fresh profile (no ~/.pi-desktop).
  • Steps: 1) Launch app on fresh profile. 2) Observe onboarding checklist.
  • Expected: Inline checklist is displayed; provider/key items open Settings → Agent, and the optional plugin item opens the app-shell Plugins destination.
  • Specs linked: 04-ux/05-onboarding.md
  • Acceptance: A (first-run checklist)
  • Milestone: M2
  • Status: Automated (protocol smoke: host onboarding state; UI checklist manual)

Provider & Key ​

E2E-169: Default model picker selects a configured model ​

  • Preconditions: Two runnable providers exist; one provider has at least two configured model bindings.
  • Steps: Open Settings → Model configuration → Change default model, then select the second model under the provider.
  • Expected: The Defaults card presents a compact settings row with the provider name and exact model ID beneath the Default model label; its quiet Change action does not repeat the current value. The picker groups model-level entries by provider, marks the exact current model, and a newly created session inherits that exact model and its owning provider. Changing the default later does not rewrite an already created session's stored provider/model; only a new session or the unpersisted home draft follows the live default. Searching by provider or model filters locally, the result list scrolls without moving the settings card, and an unmatched query shows an empty state.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md
  • Acceptance: B (model selection)
  • Milestone: M6
  • Status: Documented; automation pending

E2E-005: Add a provider and save API key ​

  • Preconditions: App running; no provider configured; the models.dev snapshot ships with the build.
  • Steps: 1) Open Settings → Model configuration and choose Add provider. 2) Confirm the searchable service chooser opens. Filter and select a built-in API-key service or Custom endpoint; the selection must open its matching form, never the plugin-only “selected service unavailable” state. Confirm the form has no stepper or Next/Back buttons. For Custom endpoint, Name and Base URL appear on one row with no helper paragraph under the URL (placeholder only), API Key and API format appear side by side on the next row (not behind Advanced), and a focused field plus its 2px accent ring stays fully inside the dialog, including on a window narrower than 1040px. The custom form has no authentication-mode switch; the API key is optional and its input stays enabled. 3) Enter a name and a base URL for a service that publishes a /models route, then paste an API key. Confirm the models section fills with the models THAT SERVICE returned without requiring a key for discovery; confirm a model the deployment does not host is absent. Also exercise the no-auth path with a custom endpoint: leave the key empty, select a discovered model, and save. 4) Type in the filter box and confirm the list narrows client-side with no network request per keystroke. 5) Confirm each row shows the models.dev-derived context/output for models the catalog knows, that its compact text tracks the published value instead of a coarser rounded one (a 1,050,000 window reads 1.05M, never 1.1M), and that a model with no catalog match still lists with generic defaults. 6) Select two models with the checkboxes. 7) Expand Advanced on one chosen row, override its limits and toggle thinking chips; confirm each numeric field has a five-chip preset ladder for common values, clicking a chip writes the value, hand editing remains possible, and a non-preset value leaves the ladder unselected. Set max output to 8,192 while its context window still follows the catalog. On a model whose published image input is enabled, toggle image input off and then back on before saving, so the explicit choice equals the current catalog value. Confirm the label and optional hint sit above one compact grouped control and do not force the options onto a second row at normal dialog width; confirm all seven canonical levels are available, that published levels start selected for a known reasoning model, and that a non-reasoning or unknown row shows the same chips unselected with the manual-override hint; enable one level on that row and confirm the other row is unaffected. 8) Open the form-level Advanced and confirm the API format is present but pre-derived. 9) Add a free-form model ID the service did not return; confirm it is added with 128,000 / 8,192 / no-thinking defaults, then enable a thinking level if the endpoint supports it; confirm re-adding the same ID in different letter case is rejected as already added. 10) Save.
  • Expected: The service is asked first and models.dev only enriches the answer and seeds known-model defaults. The no-auth custom path saves authKind: "none", omits secretValue, and makes the configured provider available anywhere an enabled no-auth provider is selectable; named cloud preset behavior stays unchanged. Newly fetched or checked model rows stay collapsed until the user opens Advanced, so every selected model ID remains visible in the right pane after a multi-select. The settings picker always offers the seven canonical thinking levels, and the Composer later renders the explicit levels saved in the same model binding; an empty or off-only binding resolves to off. Discovery is debounced ~600 ms, does not mark loading until that window elapses, and a slow reply from an earlier keystroke never replaces a newer list; named add-path discovery waits for an API key, while an unsaved custom provider is probed with the typed base URL (and key, if any) before it exists. Preset ladders cover common context/output limits while preserving hand-edited values. The explicitly selected 8,192 output remains pinned independently of the catalog-sourced context window, and the image-input choice remains explicitly enabled after it is toggled back to the published value; neither is reinterpreted as "follow catalog" on save/reopen. Later catalog corrections change only values whose own source is catalog. Limit text renders through one shared compact formatter, so neighbouring published windows stay distinguishable (1M / 1.05M / 1.1M) and a compact limit never reads above its published value. Custom endpoint keeps API format beside the key and omits Base URL helper copy; named endpoints do not show format. Point the same custom form at an unreachable or unauthorized URL and confirm one classified sentence reaches the app toast while the left pane keeps its single “no list” label (not a raw JSON/HTML dump and not a second “no models” empty state); with cached rows from a later edit the same reason is toasted while the rows stay. Point a second provider at a base URL with no /models route and confirm the list falls back to the catalog, is labelled as coming from models.dev rather than the service, and still saves. The provider appears as a row with its host, model count and secret badge; the key is stored securely (not in plaintext config); models contains both bindings and models[0] remains the provider default.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md, 03-runtime/14-secrets-storage.md, 04-ux/06-settings-ia.md
  • Acceptance: B (multi-model provider configuration, save key)
  • Milestone: M2
  • Status: Manual model-picker acceptance; chooser-to-key-form selection is covered by apps/desktop/test/plugin-provider-catalog-user-path.test.mjs; automated protocol smoke covers provider creation and secret handling without plaintext echo.

E2E-PROVIDER-custom-endpoint-no-api-key: Configure a custom service without credentials ​

  • Preconditions: Isolated settings profile and a local OpenAI-compatible fixture that lists a model without credentials; no live or paid provider.
  • Steps: 1) Open Settings → Model configuration → Add provider → Custom endpoint. 2) Enter a name and the fixture URL; leave the optional API key empty, without selecting an authentication mode. 3) Confirm model discovery succeeds without a key, select the returned model, and save. 4) Reopen and save without changes, then type a key and save; confirm API-key authentication is restored. 5) Edit an API-key provider and save with a blank input; confirm its key is retained. Reopen and confirm Clear saved API key is an icon-only button to the right of the input on the same row, with a localized accessible name and tooltip. Activate it, confirm focus returns to the input, and confirm subsequent discovery does not reuse the stored key. Close without saving and confirm no credential changed. Repeat Clear followed by Save, and Clear followed by a replacement key and Save. 6) Confirm the no-auth provider and model are offered by chat/default-model and subagent selectors. 7) Send a request through the no-auth provider fixture.
  • Expected: The custom form has no no-key switch and keeps the key input enabled. An empty or whitespace-only key with no saved credential automatically saves authKind: "none" without secretValue; a supplied key uses API-key authentication. A blank edit retains an existing key. Clear is staged until Save and a replacement key takes precedence over removal. The enabled no-auth provider with hasSecret: false remains selectable for chat, defaults, subagents, and image generation where its model supports that operation. Named cloud presets remain unchanged. Runtime requests contain no API-key or Authorization credentials.
  • Automation: node --test apps/desktop/test/custom-provider-no-api-key-user-path.test.mjs covers empty/whitespace keys, keyed creation, save/reopen, preservation, staged removal/cancellation, discovery without the old secret, and replacement. pnpm test:e2e:provider-api-style renders the production setup form in English and Simplified Chinese with a stubbed model-discovery/API boundary and verifies create/update payloads and chat/default/subagent selector eligibility. pnpm --filter @pi-desktop/agent-runtime exec vitest run src/provider-binding.test.ts verifies request headers; apps/desktop/test/image-generation-default.test.mjs covers no-auth image-model availability; cargo test -p host-core --locked switching_provider_to_no_auth_clears_its_api_key verifies Host persistence and secret removal.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md, 04-ux/06-settings-ia.md.
  • Acceptance: B (model configuration), F (provider persistence), Security.
  • Milestone: M2.
  • Status: Automated coverage uses a deterministic stub for model discovery and fetch. The full local-endpoint send flow remains available for manual acceptance; no paid provider or user profile is used.

E2E-PROVIDER-defaults-survive-an-added-provider: An added provider leaves the app defaults alone ​

  • Preconditions: App running; provider A saved and set as the app default model; a second provider B serving different models; one image-capable model configured on A and another on a different service.
  • Steps: 1) Open Settings → Model configuration and add provider B; save without touching the Default model row. 2) Confirm the Default model row still names provider A and its exact model, and that a new session starts on it. 3) Set an image model as the default image model, then add a provider that also serves image models; save. 4) Confirm the Default image model row still names the earlier binding while the picker lists the new provider's image models as candidates. 5) Delete the provider that owned a default, then add a service that serves a model and an image model; save. 6) Confirm both defaults now resolve to that newly added provider.
  • Expected: Saving a new provider never repoints an app default that still resolves: the model default keeps the pairing the Default model row already renders, and the image default keeps its stored binding while its candidate list grows. A removed image model is cleared and requires an explicit new selection; a removed chat default still uses the existing chat repair rule. Deleting the provider row that owned an image default needs no manual repair either: the next settings read or write drops the binding and candidate whose provider row is gone, so the Default image model row reports no default instead of a binding the runtime rejects. The explicit make-default actions and the edit path remain unchanged.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md, 03-runtime/21-image-generation.md
  • Acceptance: B (model selection)
  • Milestone: M6
  • Status: Documented; covered by apps/desktop/test/default-model-display.test.mjs, apps/desktop/test/image-generation-default.test.mjs, apps/desktop/test/provider-model-config.test.mjs

E2E-PROVIDER-custom-model-limits: A hand-typed custom model id takes its limits from the model library ​

  • Preconditions: App running; the models.dev snapshot ships with the build; a provider editor is open.
  • Steps: 1) Type a model id the snapshot publishes into the add-model field and add it; confirm the new row's context window, max output tokens and thinking levels match the published record instead of 128,000 / 8,192 with none. 2) Type an id the snapshot does not publish and add it; confirm the row keeps the generic 128,000 / 8,192 seed and no thinking levels. 3) Add an id while the service list is unreachable; confirm the row still appears exactly once and stays editable. 4) Add an id and edit its limits immediately, before the lookup answers; confirm the typed values survive.
  • Expected: providers.lookupModel answers a hand-typed id from the local snapshot only — no provider network request and no host call — and a hit seeds the binding the way a picked model is seeded (published context window, max output tokens, thinking levels, contextWindowSource: "catalog") while the stored id stays exactly what the user typed. A miss, a failed call, or an id the current discovery already described leaves the previous behavior intact: one usable row, the generic seed, and no stalled or duplicated list. A record published under another spelling of that id — a route prefix, a dated stamp, a marker the deployment appends — is a hit too: the row is upgraded in place as soon as the answer arrives, without waiting for a save. In a live discovered row, a positive safe integer context/input or output limit reported by the endpoint takes precedence over the catalog for that field; omitted fields independently use catalog metadata. For an unanchored relay, a published owner record outranks reseller matches; dotted/dashed version aliases and one whitelisted deployment label keep the served ID intact, the owner's sibling keys add evidence when the public key states nothing, and a corroborated canonical declaration translates a name the owner publishes under another ID without rewriting the served ID. Conflicting owner records do not supply guessed limits.
  • Specs linked: 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md
  • Acceptance: B (multi-model provider configuration)
  • Milestone: M2
  • Status: Documented; covered by apps/desktop/test/model-custom-lookup.test.mjs, apps/desktop/test/provider-lookup-model-handler.test.mjs, and apps/desktop/test/provider-endpoint-metadata.test.mjs

E2E-PROVIDER-configured-models-search: The chosen pane's search narrows the configured list ​

  • Preconditions: App running; one provider saved with at least three model bindings, one of them carrying an alias and another whose catalog display name differs from its id.
  • Steps: 1) Open Settings → Model configuration and edit that provider. 2) Type part of one model id into the search field beside the right pane's title and confirm the configured list narrows as you type while the count beside the title keeps reporting every configured model. 3) Search another row by its alias, then by its catalog display name, and confirm both find the binding they stand for. 4) Type a query that matches nothing and confirm the right pane shows its own empty state rather than the "nothing chosen yet" one. 5) With that query still in the field, tick a model in the left discovered list and confirm it is added in view — the field is dropped only when the filter would have hidden it — then tick a model whose id matches the query and confirm the filter stays. 6) Clear the filter, remove every configured model, and confirm the field ends up empty instead of holding a query in a box that is now disabled. 7) Save.
  • Expected: The right pane's search filters the configured list client-side with no host round trip, matching the model id, its alias and the catalog display name case-insensitively. The count badge keeps reporting every configured model, so a filtered view is never mistaken for a provider that offers fewer models. A model added while a filter is active is never added out of view: the field is dropped when the new row would be hidden and kept when it still shows what was added, and an emptied list discards the query so no filter is stranded in a disabled field.
  • Specs linked: 04-ux/08-component-spec.md, 03-runtime/13-model-catalog-and-selection.md
  • Acceptance: B (multi-model provider configuration)
  • Milestone: M2
  • Status: Documented; UI automation pending

E2E-005A: Edit provider model bindings and migrate a legacy model ​

  • Preconditions: One provider saved with two model bindings; one fixture provider row exists with only the legacy default_model_id and no config_json.models; one fixture row carries an unknown or legacy apiStyle string.
  • Steps: 1) Reopen the saved provider; confirm the single form opens with its cached model list painted immediately, and that the API key field explains an unchanged key is kept. 2) Confirm the live probe then refreshes that list without a retyped key, because the stored secret is reused. 3) Confirm both existing bindings are still chosen and check-marked, with their limits, thinking chips and defaults intact. 4) Enable a level the fixture catalog does not publish, edit one row's limits and save. 5) Reopen the fixture provider and confirm its legacy model appears as one chosen row with 128,000 context, 8,192 max output and all seven thinking choices available but unselected. 6) Reopen the unknown-style fixture and confirm the editor renders with Chat Completions selected instead of an error boundary; save it and confirm the repaired style is persisted. 7) Save the fixture provider without changing the model. 8) Select the second binding as the app default, reopen the provider and save unchanged; confirm the app default stays on that binding. Save another provider and confirm the app default is unchanged. Reopen the default provider, remove the selected binding, and save; confirm the app default falls back to the first remaining binding. 9) Take the service offline or revoke the key, reopen the provider, and confirm the cached rows stay visible with a compact classified discovery error rather than an empty list or a raw host dump.
  • Expected: Editing never drops an unmodified binding. An explicitly enabled level remains saved even when the catalog does not publish it, so the Composer reads the same binding rather than silently narrowing it; a binding whose model discovery is unavailable keeps its stored levels untouched. Legacy read materializes one binding without losing the old model ID; the subsequent write stores config_json.models and keeps defaultModelId equal to the first binding for older readers. An unknown or legacy apiStyle is treated as a compatibility input: the editor falls back to Chat Completions, remains usable, and repairs the stored value on save. Saving the default provider preserves settings.defaultModelId while the selected model remains configured; only removal of that model falls back to the first remaining binding. Editing another provider never changes the app default. A failed live probe degrades to the cached list, one classified toast sentence and a one-line pane label, never to a blank picker or a raw HTTP/JSON dump, and a catalog fallback is never written into the model cache. Deleting a configured model and saving forgets that model's cached parameters, so the pane no longer paints the deleted id from cache and a later re-add starts from what the service or the catalog says. A probe that no longer publishes a model drops its cached row the same way, unless the row is the user's own (a hand-typed id, a configured binding), and a save that moves the base URL or the API format drops the answer the previous endpoint produced.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md, ADR 0114
  • Acceptance: F (provider persistence and migration)
  • Milestone: M2
  • Status: Unit-covered host migration; manual UI journey

E2E-MODELS-list-layout: Model lists remain browsable in constrained windows ​

  • Preconditions: 60 discovered and configured models; isolated Electron fixture with real provider/account dialogs, hooks and production styles.
  • Steps: Open both dialog types at 850×600, 850×850, 1000×560, 1200×700 and 1600×1000 content sizes. Scroll each list to its last and first model.
  • Expected: Every list displays at least one complete row. Both endpoints are fully visible inside the list, dialog body and viewport. Narrow panes stack; short wide windows keep the side-by-side layout.
  • Command: pnpm test:e2e:provider-model-layout
  • Boundary: Model-discovery IPC is a fixture; no real credentials or host persistence are involved. React, browser layout and scrolling are real.

E2E-005K: Preserve explicit extended thinking levels on the wire ​

  • Preconditions: A provider has a selected model binding with reasoning enabled and xhigh/max explicitly selected; the catalog omits or marks those adapter mappings as unsupported; a deterministic OpenAI-compatible capture fixture records request JSON.
  • Steps: 1) Select high, xhigh, and max in separate turns. 2) Capture each request body at the fixture boundary.
  • Expected: The three requests contain reasoning_effort: "high", reasoning_effort: "xhigh", and reasoning_effort: "max" respectively. The catalog's non-null wire mapping remains in force when one is published; an absent or null mapping for an explicitly enabled extended level does not silently downgrade it to high.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md
  • Acceptance: B (model configuration), F (runtime provider requests)
  • Milestone: M2
  • Status: Unit-covered; deterministic provider fixture pending

E2E-005B: Configure the fixed OpenCode Go API-style preset ​

  • Preconditions: App running; no OpenCode Go provider configured; the OpenCode Go endpoint is reachable with a test API key.
  • Steps: 1) Open Settings → Model configuration and open the add-provider dialog. 2) Select OpenCode Go in Service. 3) Confirm Name and Base URL are not on the common path, enter an API key, and wait for model discovery. 4) Select a discovered model and save the provider. 5) Reopen the provider and switch Service to Custom endpoint.
  • Expected: Selecting the preset shows Service + API key, a host summary for opencode.ai/zen/go/v1, and focuses the API key field. Discovery requests https://opencode.ai/zen/go/v1/models with Authorization: Bearer <key>; the saved row persists apiStyle: "opencode_go" and the key is stored via the secret store. Reopening preserves the fixed identity. Switching to Custom endpoint reveals editable Name and Base URL.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, 04-ux/06-settings-ia.md, ADR 0116
  • Acceptance: B (model configuration and key storage), Security
  • Milestone: M2
  • Status: Unit-covered (form and discovery contracts); rendered UI scenario Draft

E2E-205: OpenCode Go requests carry a stable session header ​

  • Preconditions: An OpenCode Go provider is configured; a deterministic fixture or capture proxy records outbound HTTP headers. A second generic OpenAI-compatible provider is also configured.
  • Steps: 1) Start an Agent turn in a session against OpenCode Go. 2) Capture the provider request headers. 3) Send a follow-up in the same session. 4) Run a plugin agent.complete one-shot against the same provider. 5) Run /compact in the same session and capture the summary request. 6) Repeat a turn against the generic OpenAI-compatible provider.
  • Expected: Every OpenCode Go LLM request includes x-opencode-session equal to the conversation id (or a stable per-call id when no session exists), x-opencode-client: pi-desktop, and a User-Agent identifying Nib. Follow-up turns reuse the same session header, and so does the compaction summary request, which the harness would otherwise send with no headers at all. The generic OpenAI-compatible provider does not receive these headers. The gateway does not return MissingSessionID, and /compact does not fail with a 400.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, ADR 0116
  • Acceptance: B (model configuration), F (runtime provider requests)
  • Milestone: M2
  • Status: Unit-covered (header merge, one-shot stream options, and the compaction summary request)

E2E-005E: Provider and model wire API precedence stays explicit ​

  • Preconditions: An OpenCode Go provider is configured; a deterministic fixture serves muse-spark-1.3-contributor on /responses and 500s it on /chat/completions. A generic provider and a custom endpoint each serve the same model id; the custom model metadata pins openai-completions, while its saved provider style is Responses.
  • Steps: 1) Select the muse model on the OpenCode Go provider and send a turn. 2) Capture the outbound request path. 3) Repeat against the generic provider with the same model id. 4) Select the model on the custom endpoint and send a turn, then capture its outbound request path.
  • Expected: The OpenCode Go turn posts to /responses (the model-level api: "openai-responses" pin wins); the generic turn still posts to /chat/completions; the custom endpoint posts to /responses because its saved provider style wins over the model metadata. Replayed history carries the resolved API.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, ADR 0116
  • Acceptance: F (runtime provider requests)
  • Milestone: M2
  • Status: Unit-covered (binding resolution and endpoint assertion)

E2E-005K: OpenCode Go Claude models use the published Anthropic route ​

  • Preconditions: An OpenCode Go provider uses the built-in preset. The deterministic catalog fixture contains claude-haiku-5-5 with api: "anthropic_messages" and an OpenCode Anthropic root baseUrl, plus a DeepSeek Chat Completions control model.
  • Steps: 1) Select Claude Haiku and send a text turn through a capture fixture. 2) Capture the request URL and adapter payload. 3) Repeat with the DeepSeek control model. 4) Repeat with the same catalog metadata on a custom OpenAI-compatible provider.
  • Expected: OpenCode Claude uses the Anthropic adapter and posts to /zen/go/v1/messages; it does not post Chat Completions and does not return ModelProtocolUnsupported. DeepSeek keeps /chat/completions. The custom provider keeps its saved wire style and base URL despite the foreign catalog API. Unit coverage lives in packages/agent-runtime/src/provider-binding.test.ts.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/13-model-catalog-and-selection.md, issue #1728
  • Acceptance: F (runtime provider requests)
  • Milestone: M2
  • Status: Unit-covered; live endpoint scenario Draft

E2E-005C: OpenAI-compatible system role fallback ​

  • Preconditions: A deterministic OpenAI-compatible Chat Completions fixture exposes one reasoning-capable model and rejects role: "developer" in favor of role: "system".
  • Steps: 1) Configure the fixture provider and select its reasoning model. 2) Start an Agent turn with a non-empty system prompt. 3) Capture the JSON request body at the fixture boundary. 4) Repeat with a model record that explicitly sets compat.supportsDeveloperRole: true.
  • Expected: The first request contains the system prompt with role: "system" and the turn succeeds. The explicit model override changes only the second request to role: "developer"; the default remains model-scoped and does not affect other provider adapters.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md
  • Acceptance: B (OpenAI-compatible provider interoperability)
  • Milestone: M2
  • Status: Unit-covered (including the #30 GLM gateway regression); deterministic provider fixture pending

E2E-206: Anthropic Messages endpoint with a /v1 base URL ​

  • Preconditions: A deterministic Anthropic Messages fixture exposes GET /v1/models and POST /v1/messages; the configured custom endpoint ends in /v1 and returns one model such as glm-5.3.
  • Steps: 1) Open Settings → Model configuration and add a Custom endpoint. 2) Enter the fixture URL ending in /v1, choose Anthropic Messages, and wait for model discovery. 3) Select the discovered model and save. 4) Start an Agent turn and capture the fixture request path.
  • Expected: Discovery succeeds against /v1/models, and the Agent turn succeeds against /v1/messages. The runtime does not send the request to a doubled /v1/v1/messages path; a configured Anthropic root without /v1 remains equivalent.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md
  • Acceptance: B (custom provider interoperability), C (chat and stream)
  • Milestone: M2
  • Status: Unit-covered; deterministic provider fixture pending

E2E-005F: Custom endpoint input guardrails ​

  • Preconditions: App running; the add-provider dialog is open with Custom endpoint selected.
  • Steps: 1) Enter api.gateway.example.com with no scheme and leave the Base URL field. 2) Confirm the field settles on https://api.gateway.example.com and discovery runs. 3) Paste https://api.gateway.example.com/v1/messages and leave the field. 4) Confirm the field settles on https://api.gateway.example.com/v1, the API format reads Anthropic Messages, and the form says that format was auto detected. 5) Change the API format by hand to OpenAI Chat Completions, then paste the /v1/responses URL: confirm the hand-picked format survives and the operation is left in place until the suggestion is applied. 6) Replace the value with ftp://gateway.example.com, then leave the field. 7) Enter a valid URL again and confirm model discovery can run; paste a full /models path and leave the field. 8) Point the row at a gateway whose /models route answers only under /v1 and confirm the field and the saved row show that address.
  • Expected: A bare host is completed with https:// inside the origin the user typed; credentials, queries and fragments are still refused. A pasted operation path names the matching format, selects it and is stripped from the base endpoint, and /models is removed as well — unless the operation contradicts a format the user picked by hand, which is then preserved and offered as a suggestion instead. When the endpoint itself decided the format, the form says so next to the selector. When only the /v1 candidate answers, the field and the saved row show that address rather than a hidden rewrite, and every probed candidate stays on the typed origin. A non-http(s) URL shows an inline, accessible error, does not start discovery, and keeps Save disabled. A valid URL restores discovery. The long URL field uses a full row on wide dialogs and stacks cleanly with the other credentials at the responsive breakpoint.
  • Specs linked: 04-ux/06-settings-ia.md, 03-runtime/12-provider-config-schema.md
  • Acceptance: B (custom provider configuration)
  • Milestone: M2
  • Status: Unit-covered; rendered UI scenario pending

E2E-005G: Per-provider custom HTTP headers ​

  • Preconditions: One API-key AI service (including an OpenCode Go row) and one signed-in vendor (OAuth) account; a capture proxy records outbound HTTP headers, including Codex and Anthropic adapters.
  • Steps: 1) Open the AI service and click the upper-right Advanced settings action. Confirm a separate modal opens without changing the main form layout, and that its header close action is the only close control (no footer action row). Use the common-header preset to add User-Agent, then import a JSON object containing X-Gateway: alpha and enough headers to exceed five visible rows. Copy headers as JSON and confirm the clipboard is the pretty-printed persisted record (blank names omitted, last write wins) with localized success feedback. Confirm the header list scrolls inside the modal while the underlying model panes keep their working area, close the modal, then save. 2) Start an Agent turn, a follow-up, a plugin agent.complete one-shot, and the standalone plugin's Composer transform when installed. 3) Refresh /models from the form before saving a second change and confirm the unsaved headers are sent. 4) Clear the rows and save; confirm adapter defaults return. 5) Edit the OAuth account Advanced headers, save, then run a turn that refreshes the access token. 6) Repeat against OpenCode Go and confirm x-opencode-session is still present. 7) Repeat against Codex/Anthropic OAuth inference. 8) Enter a value holding fullwidth characters (0, 123) and confirm the editor says it will be saved as half-width, then save and reopen: the stored value is ASCII. 9) Enter a value holding Han text or a NUL and confirm the editor warns before saving, then save: it is rejected as HEADERS_INVALID with the character and its index named. 10) Point the app at a store whose row was written before this rule and run a turn. 11) Attempt Authorization and CR/LF values; save is rejected.
  • Expected: Non-empty custom headers are the last writer on that row's outbound HTTP (turns, subagents, one-shots, discovery, connection test, OAuth refresh). Empty restores pi-ai / claude-cli / OpenCode defaults. Advanced keeps up to five header rows visible and scrolls additional rows in its own bounded area, so they do not compress or hide the model panes. Escape and outside-click close only the Advanced modal while it is open. The preset adds the expected User-Agent value, Copy JSON serializes the same pairsToRecord map saved on the row, JSON import accepts both supported object shapes, and case-insensitive duplicate keys are merged rather than duplicated. Toolbar actions wrap at the narrow dialog breakpoint instead of overflowing. OpenCode still sends x-opencode-session and x-opencode-client. Codex and Anthropic still send the custom User-Agent despite adapter last-writes. First OAuth login does not collect headers. A fullwidth value saves as half-width and is folded again on read, so a row stored before this rule runs a turn instead of throwing. A value holding Han text or a control character is refused at save with the character and index named, and dropped by the runtime rather than thrown by Headers.set. Reserved keys and CR/LF are rejected. Advanced is a compact key/value editor, not a lone User-Agent field.
  • Specs linked: 03-runtime/12-provider-config-schema.md, 03-runtime/11-provider-model-system.md, 03-runtime/02-agent-runtime.md, 04-ux/06-settings-ia.md, ADR 0178
  • Acceptance: B (model configuration), F (runtime provider requests)
  • Milestone: M2
  • Status: Unit-covered (host persistence, fetch wrapper, discovery, form Advanced); rendered UI scenario pending

E2E-005J: GitHub Copilot OAuth requests carry native IDE headers ​

  • Preconditions: A signed-in GitHub Copilot OAuth account with a selected model; a deterministic capture proxy records the model request headers.
  • Steps: 1) Start an Agent turn against the account and capture the request headers. 2) Send a follow-up after the assistant response and capture the next request. 3) Repeat with an image attachment when the selected model supports vision. 4) Set a saved custom header with the same name as one of the Copilot defaults and send another turn. 5) Select a Claude model on the same account (Anthropic Messages) and send a turn, capturing its auth and identity headers.
  • Expected: Every Copilot model request includes the pinned pi-ai transport identity headers Editor-Version, Editor-Plugin-Version, and Copilot-Integration-Id. X-Initiator is user for a user-led request and agent for a continuation; Openai-Intent is conversation-edits, and image requests include Copilot-Vision-Request: true. The OAuth row keeps its local provider id for account binding, and a saved custom header remains the final override. Claude requests carry Authorization: Bearer <Copilot token> and no X-Api-Key, while retaining the same IDE identity and context headers.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, ADR 0095
  • Acceptance: B (model configuration), F (runtime provider requests)
  • Milestone: M2
  • Status: Unit-covered (row-scoped model headers and request-context headers, Anthropic Messages Bearer auth and rotating tokens); live Copilot account journey pending

E2E-005H: Select every visible model from a long service list ​

  • Preconditions: App running; the add-provider or edit-provider dialog is open against a service (or vendor account) that returns a long model list, including at least one model whose id would not match a later search.
  • Steps: 1) Wait until the left pane lists the service's models. Confirm the list header shows a checkbox beside the pane title, unchecked while no rows are chosen. 2) Tick that header checkbox. Confirm every listed row is checked and the right pane lists a chosen binding for each, keeping any already-configured advanced overrides. 3) Untick one row, then confirm the header checkbox is indeterminate. Tick it again and confirm the remaining visible rows are chosen without duplicating already-chosen ones. 4) Type a filter that matches a subset. Untick the header checkbox and confirm only the matching chosen rows disappear; a model hidden by the filter stays on the right. 5) Tick the header checkbox again and confirm only the matching rows are added back. Clear the filter and confirm the previously hidden chosen model is still present. 6) Save.
  • Expected: One header checkbox selects or clears the currently visible list. A search filter narrows which rows "all" means. Models already chosen outside the filter stay chosen. Newly added rows adopt published limits and thinking levels; existing bindings are not rebuilt. The same control is present in the vendor-account editor because both dialogs render the shared picker.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md, 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md
  • Acceptance: B (multi-model provider configuration)
  • Milestone: M2
  • Status: Unit-covered (shared picker source contract); rendered UI scenario pending

E2E-005I: Fetch the service model list from the picker header ​

  • Preconditions: The add-provider or edit-provider dialog is open against a reachable service (or vendor account) that publishes a /models list.
  • Steps: 1) Confirm the left-pane header shows a Fetch list action beside the title, disabled before a valid endpoint is ready. 2) Enter a valid endpoint. Confirm Fetch list enables during the 600 ms debounce wait. Click it immediately; confirm it does not wait for that window, shows the loading label while the probe is in flight, and then shows rows. 3) Click Fetch list again. Confirm it keeps the current rows on screen and replaces them with the live answer. 4) Take the service offline and click Fetch list; confirm the classified error reaches the toast and the previous rows remain. 5) Restore the service, click Fetch list, and confirm the live list returns. 6) Repeat in the vendor-account editor.
  • Expected: The header action probes the service immediately, including during the edit debounce after a URL becomes valid. Automatic discovery on credential edits is unchanged. The same control is present for both credential kinds because both dialogs render the shared picker.
  • Revocation regression: Configure two served models, revoke one upstream, add another, then Fetch list. The left service pane shows the current served pair, including when the revoked ID has a published catalog record. The right chosen pane retains both saved bindings and their overrides; refresh does not delete configuration. A manual/offline fallback still exposes configured IDs.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md, 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md
  • Acceptance: B (multi-model provider configuration)
  • Milestone: M2
  • Status: Unit-covered (discovery hook + picker source contract); rendered UI scenario pending

E2E-005D: Configure a Zhipu / Z.AI named endpoint preset ​

  • Preconditions: App running; no Zhipu provider configured; the models.dev snapshot ships with zhipuai, zhipuai-coding-plan, zai, and zai-coding-plan.
  • Steps: 1) Open Settings → Model configuration and open the add-provider dialog. 2) Open Service, type to filter, and select Zhipu AI Coding Plan. Confirm model discovery does not start until an API key is entered. 3) Confirm the common path is Service + API key with a host summary, enter an API key, and wait for model discovery. 4) Select a discovered model and save. 5) Reopen the provider, switch Service to Z.AI, then to Custom endpoint.
  • Expected: Coding Plan shows Service + API key, a host summary for open.bigmodel.cn/api/coding/paas/v4, and focuses the API key field. Name and API format are not on the common path. The saved row persists vendorKey: "zhipuai-coding-plan", apiStyle: "chat_completions", and the exact Coding Plan URL. Switching to Z.AI replaces the host summary with api.z.ai/api/paas/v4 and vendorKey: "zai". Switching to Custom endpoint reveals editable Name and Base URL without a stepper or vendor-card grid. A later Agent turn against a Zhipu URL sends Completions with Zhipu thinking (thinkingFormat: "zai") rather than the OpenAI developer role.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, 04-ux/06-settings-ia.md, ADR 0155
  • Acceptance: B (model configuration and key storage)
  • Milestone: M2
  • Status: Unit-covered (preset matching, catalog aliases, Completions compat); rendered UI scenario Draft

E2E-248: Responses turn completes without waiting for the server to close the connection ​

  • Preconditions: A provider whose model pins api: "openai-responses" (or a provider with apiStyle: "responses") is configured; the endpoint is fronted by a proxy that holds the HTTP connection open after the final SSE event (a local reverse proxy or a stub server that never sends FIN).
  • Steps: 1) Start a session with that model and send a short prompt. 2) Capture the SSE frames and confirm the server emitted response.completed with status: "completed" and usage. 3) Keep the stub/proxy connection open without sending a TCP FIN. 4) Observe the assistant turn state and send a follow-up prompt.
  • Expected: The turn completes as soon as response.completed is finalized: usage is recorded, stopReason is stop, and the client stops consuming the stream (the underlying request is aborted) instead of blocking on the idle connection. The composer becomes idle immediately and the follow-up turn starts normally. The stream must not hang when the server never closes the connection.
  • Specs linked: 03-runtime/11-provider-model-system.md (§16.1)
  • Acceptance: B (provider Responses compatibility)
  • Milestone: M2
  • Status: Unit-covered (stream processor terminates on the terminal event via the pi-ai patch); live-proxy scenario Draft

E2E-005E: DeepSeek thinking replay includes reasoning_content on aggregator endpoints ​

  • Preconditions: An OpenAI-compatible provider whose base URL is not deepseek.com is configured with a DeepSeek-family model id (for example SiliconFlow deepseek-ai/DeepSeek-V3.2) and thinking enabled.
  • Steps: 1) Start a session in thinking mode. 2) Complete several turns including at least one assistant reply that produces no thinking text. 3) Send another prompt so the history is replayed to the provider.
  • Expected: The later Completions request includes a reasoning field on every assistant message. Official api.deepseek.com rows may use "" for turns that had no thinking. Non-official DeepSeek-family relays use real retained thinking when available, otherwise the documented non-empty placeholder from ADR 0256 — never a silent empty string after compaction. Each assistant message carries exactly one reasoning field, named after the newest assistant row's field; real reasoning stored under another field name is moved there rather than replaced by the placeholder, which only fills rows with no reasoning at all (D677). The thinking panel and replayed history never show a leading echo of the placeholder as reasoning. Relay requests omit the generic OpenAI reasoning_effort field because relays were reported rejecting it (#1664). Official api.deepseek.com requests send thinking: {type: "enabled"} together with the selected reasoning_effort (#1821). The request does not switch thinkingFormat to "deepseek" solely because the model id contains "deepseek". Official api.deepseek.com rows keep URL-based DeepSeek thinkingFormat from pi-ai.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md
  • Acceptance: B (provider Completions compatibility)
  • Milestone: M2
  • Status: Unit-covered (compat inject + convertMessages empty/non-empty fill + mixed-field normalization + echoed-placeholder strip + compaction retained-reasoning wire proof); live OpenCode / aggregator verification deferred; rendered UI scenario pending

E2E-006: Key survives restart ​

  • Preconditions: Provider + key configured.
  • Steps: 1) Quit app. 2) Relaunch. 3) Open Settings → Agent → Providers.
  • Expected: Provider still listed; key usable (no re-entry needed).
  • Specs linked: 03-runtime/14-secrets-storage.md
  • Acceptance: B (key survives restart)
  • Milestone: M2
  • Status: Draft

E2E-007: No-provider blocking prompt ​

  • Preconditions: App running; no provider configured.
  • Steps: 1) Attempt to start a chat.
  • Expected: Clear blocking prompt explaining that a provider must be configured.
  • Specs linked: 04-ux/06-settings-ia.md
  • Acceptance: B (blocking prompt)
  • Milestone: M2
  • Status: Draft

Conversation Stream & Abort ​

E2E-008: New session and send message ​

  • Preconditions: Provider configured.
  • Steps: 1) Create new session. 2) Type a message. 3) Send.
  • Expected: The transcript immediately shows a compact localized Working… status after send, before the first assistant or tool event. It remains visible through thinking/tool/answer output, or identifies a runtime-reported wait. Pending user interaction suppresses it; it disappears when the turn ends. The conversation topbar keeps only the task title and window actions; it does not add a separate running-state indicator.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/10-session-state-machine.md
  • Acceptance: C (new session, send message)
  • Milestone: M2
  • Status: Automated (protocol smoke, live-model lane; requires PI_DESKTOP_TEST_API_KEY)

E2E-008d: Composer Enter-to-send and modifier send ​

  • Preconditions: Provider configured; a session is open.
  • Steps: 1) Leave Enter-to-send enabled. Type a draft and press Enter. 2) Disable Enter-to-send in Settings. 3) Type a second draft and press Cmd/Ctrl+Enter. 4) Press Enter and Shift+Enter in a third draft.
  • Expected: Step 1 sends. After the setting is off, Cmd/Ctrl+Enter sends; plain Enter and Shift+Enter insert newlines and do not send. IME composition and an open autocomplete menu still take precedence over send.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md §11.5, 04-ux/09-interaction-patterns.md §1.2
  • Acceptance: C (composer send)
  • Milestone: M2
  • Status: Source-level regression (composer-ime.test.mjs); full UI keyboard journey remains Draft. Protocol smoke does not dispatch key events.

E2E-008e: Host speech transcribe and speak ​

  • Preconditions: A session is open. No AppSettings.speech role is bound.
  • Steps: 1) Call speech/transcribe with a scratch wav while unbound. 2) Persist an ASR binding (openai_audio / whisper) and a TTS binding with a host settings write. 3) Transcribe the wav and insert the returned text into a draft. 4) Synthesize that draft.
  • Expected: An unbound role fails SPEECH_NOT_CONFIGURED and never calls the provider. Transcribe returns text. Synthesize writes session-scratch audio and returns a bounded data URL; audio bytes never enter the renderer. Whisper/TTS never appear in the chat model picker.
  • Specs linked: 03-runtime/20-speech.md, 04-ux/06-settings-ia.md
  • Acceptance: C (speech)
  • Milestone: M2
  • Status: Source-level regression (speech-capability.test.mjs, plugin-speech-adapter.test.mjs); no renderer entry point exists, and the live provider journey remains Draft.

E2E-008a: First-turn tools load on demand ​

  • Preconditions: Agent mode; provider configured; BrowserPreview or an enabled plugin tool is available; request capture can inspect the first and subsequent provider payloads.
  • Steps: 1) Create a fresh session and send a simple prompt. 2) Inspect the first provider request's tool list. 3) Ask the agent to create or edit an HTML page and observe the tool activity. 4) Start a second user prompt after the preview task completes.
  • Expected: The first request contains only the mode core tools (Agent: Read/Bash/Edit/Write; Chat: Read/Glob/Grep) and local ToolSearch; deferred schemas are represented only by a bounded # On-demand tools catalog. The agent calls ToolSearch before BrowserPreview (or the selected plugin/Skill tool) when the capability is needed, and the matching schema is available on the next model turn. For a user-visible HTML deliverable, BrowserPreview is called once after creation or the first meaningful visual edit, then reused through live reload while the page is refined. Generated, test-only, and non-visual HTML files do not trigger a preview call. At the second prompt, successful activation markers still in the effective context may restore the matching deferred schemas in the first request, including tools that were activated but not yet called. New results use details.addedToolNames; the legacy details.activated and top-level addedToolNames markers remain readable. Failed, interrupted, and missing-result rows do not restore tools. Catalog and mode changes also prevent restoration. No host permission or workspace escape is granted by restoration.
  • Specs linked: 03-runtime/02-agent-runtime.md §7.1, 03-runtime/03-tools-and-permissions.md §2.1, ADR 0048, ADR 0225, 08-meta/decisions-log.md (D185, D400)
  • Acceptance: C (first turn and stream) + E (tool execution)
  • Milestone: M5
  • Status: Unit-covered (agent-runtime deferred-tool tests); live-model request capture and full Electron journey pending

E2E-008b: Bundled Browser plugin chrome and CDP ​

  • Preconditions: Packaged or checkout build with bundled plugins; Agent session with a workspace HTML file; Plan session available.
  • Steps: 1) Confirm Plugins lists pi.browser, enabled, not uninstallable. 2) With the work panel closed, ask the agent to preview a workspace HTML file (BrowserPreview) and verify the Browser tab opens in the visible work panel. 3) Use ToolSearch cdp / Browser to snapshot and interact while the panel remains visible. 4) Collapse the panel and continue Browser snapshot, click/fill, screenshot and navigation. 5) Switch to a second session and call Browser; verify it uses that session's own page. 6) Continue the first session's Browser actions while the second session is visible; reload the shell and return to the first page. 7) In Plan/Goal, exercise the read-only Browser actions and verify mutating actions remain denied. 8) Disable pi.browser. 9) Call BrowserPreview and click an http(s) transcript link. 10) From a third-party or test caller, send Network.getAllCookies through pi.browser.cdp.
  • Expected: The launcher has no host Browser row. Preview opens the plugin view, reveals the work panel, and live-reloads the file. Session-owned Browser operations run in the background without opening the panel, borrowing another page, or changing focus. Shell reload retains the native page and form data; its side-panel tabs can be restored. ToolSearch activates the Browser tool. Plan/Goal allow navigate/snapshot/screenshot/console; click/fill/evaluate/cdp remain denied. Disable hides the view and tools; BrowserPreview errors; http(s) chips use openExternal. Cookie CDP is denied. Guest bounds stay inside the plugin view.
  • Specs linked: ADR 0170, D333, 07-plugins/03-plugin-api.md, 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (plugin view + tool) + security allowlist
  • Milestone: M5
  • Status: Unit-covered (bundled-plugins, browser-cdp, browser-preview-tool); full Electron journey pending

E2E-008c: Quiet intervals explain active work ​

  • Preconditions: A deterministic provider fixture can delay the first response, return one retryable failure with a bounded backoff, and a session can start one delegated task whose completion is controlled by the fixture.
  • Steps: 1) Send a prompt and hold the provider before its first assistant event. 2) Observe the transcript status row. 3) Release a retryable failure and inspect the row during backoff. 4) Start a delegated task and wait for the parent to converge on it. 5) Release the fixture and let the turn end.
  • Expected: The status row names the quiet interval — Starting…, Waiting for model, Preparing next request…, Compacting context…, Recovering empty response…, Retrying model request, or Waiting for a named subagent with its latest coarse action — with a monotonic elapsed time matching the active phase. A multi-subagent wait lists each running target. It uses the same compact inline treatment as Working…, never adds a duplicate progress card, and clears when assistant output or a terminal event arrives. The Stop action remains available throughout.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 04-ux/09-interaction-patterns.md, ADR 0175, ADR 0198
  • Acceptance: C (chat stream), Quality (feedback and accessibility)
  • Milestone: M5
  • Status: Draft (deterministic fixture pending)

E2E-009: Streamed tokens visible in UI ​

  • Preconditions: Session active; message sent.
  • Steps: 1) Request a long answer containing Markdown and inline/display math using both dollar ($…$ / $$…$$) and TeX bracket (\(…\) / \[…\]) delimiters. 2) Observe the assistant response as it streams. 3) Let the answer complete and inspect the renderer console.
  • Expected: Runtime chunks appear progressively through the incremental Markdown renderer and the final response is complete. All four math delimiter forms render with KaTeX, with \[…\] using display layout. The renderer does not start a second animation-frame typewriter loop, raise React error 185, or reject Vite-inlined KaTeX fonts under CSP.
  • Specs linked: 03-runtime/02-agent-runtime.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 05-security/01-security.md
  • Acceptance: C (streamed output), Quality
  • Milestone: M2
  • Status: Partially automated (protocol live-model stream, renderer source regression in renderer-stream-safety.test.mjs, and math delimiter rendering in latex-math.test.mjs; full UI observation remains Draft)

E2E-010: Stop turns and in-flight tools safely ​

  • Preconditions: A session can produce both a deliberately delayed first token and a streaming response. A deterministic Host fixture can hold a read-only Grep call and expose a pending Write approval.
  • Steps: 1) Send ordinary text and click Stop immediately, including while the prompt is still being admitted. 2) Confirm the user row is undone and the text returns to the composer after that first Stop. 3) Send again, wait for partial output, then stop during the stream. 4) Immediately send a new prompt after Stop returns, without waiting for an idle delay. 5) Start a read-only Grep that does not return, press Stop, then start another prompt. 6) Start a Write that is waiting for permission, press Stop, then try to approve the stale request. 7) Observe the transcript, composer, and workspace.
  • Expected: The first Stop is honored even if prompt admission is still in progress; the unanswered send is undone and its draft restored. The streaming send stops with its partial response preserved and no draft restoration or duplicate user turn. The read-only tool returns TOOL_ABORTED, its late result is discarded, and the follow-up starts without AGENT_BUSY. Stopping the pending Write cancels its permission; approving the stale request fails and creates no file. A mutating operation that already started is not reported stopped before its Host result arrives.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/03-tools-and-permissions.md, 03-runtime/06-host-rpc-protocol.md, ADR tool-abort-abandons-host-call
  • Acceptance: C (abort)
  • Milestone: M2
  • Status: Runtime and Host cancellation contracts automated by packages/agent-runtime/src/runtime.test.ts and the pending-Write regression in crates/host-core/src/rpc/mod.rs; rendered Electron interaction remains a manual validation run.

E2E-171: Streaming reply survives quit, crash, and stop ​

  • Preconditions: A session whose transcript is longer than one renderer page (more than 100 messages) and a model that streams a reply for at least ten seconds before its first tool call.
  • Steps: 1) Send a prompt and let the reply stream for ~5 s. 2) Quit the app (Cmd+Q / tray Quit) mid-stream, relaunch, and open the session. 3) Repeat the send, then kill the agent sidecar process mid-stream and observe the transcript. 4) Repeat the send, press Stop mid-stream, then reopen the session from the sidebar and inspect sessions/<id>.jsonl and sessions/<id>.inflight.json. 5) Repeat the send and let it finish normally.
  • Expected: 2) The session shows the user prompt followed by the streamed text up to at most 1.5 s before the quit, as an aborted assistant row under an aborted turn; nothing earlier in the session is missing or truncated. 3) The streaming row settles to aborted in place with its text, the same row is present after a reload, and no .inflight.json remains. 4) The partial reply is visible immediately after Stop and after reopening; the transcript file was not rewritten (its earlier lines are byte-identical) and the checkpoint file is gone once the aborted final row landed. 5) The completed reply has exactly one row per assistant fragment, no aborted duplicate, and no checkpoint file. A completed reply that had not yet left the outbox at quit is still present after relaunch (promoted complete if recovered from the checkpoint, or drained from the outbox before the first session.get).
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/07-process-model.md, 03-runtime/01-ipc-protocol.md
  • Acceptance: C (abort), F (persistence)
  • Milestone: M2
  • Status: Draft

E2E-SESSION-outbox-duplicate-id-does-not-drop-history ​

  • Preconditions: Two sessions whose provider tool rows reuse the same toolCallId as messages.id (for example call_421522). The first session already persisted that id. The second session then runs several turns so assistant/tool rows queue behind the colliding append.
  • Steps: 1) Complete a tool call in session A with id call_421522. 2) In session B, run the same provider tool id, then continue chatting for several turns. 3) Quit and reopen. 4) Open both sessions.
  • Expected: Session A still has its original tool row. Session B kept its later turns after reopen; the colliding tool row is stored under {sessionB}:{call_421522} (or an equivalent remapped id). The persistence outbox is empty and did not stay paused on UNIQUE constraint failed: messages.id. No later assistant/tool row from either session is missing.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0041, D444
  • Acceptance: F (persistence)
  • Milestone: M2
  • Status: Unit-covered (append_message_remaps_ids_owned_by_another_session, persistence-outbox.test.mjs); desktop journey outstanding

E2E-SESSION-outbox-poison-does-not-drop-history ​

  • Preconditions: A session-collaboration delivery turn is running. The user steers with Alt+Enter (content that does not match the delivery). Later assistant and tool rows from this or another session queue behind that append. Optionally the outbox already holds a PERMISSION_DENIED: head from an older host.
  • Steps: 1) Start a collaboration delivery turn. 2) Alt+Enter a steering prompt. 3) Let the turn produce assistant/tool rows, including in another session if convenient. 4) Quit and reopen. 5) Open the affected sessions.
  • Expected: Steering persists as a human user row with no session_message origin. The delivery user row still has host-derived origin. Later assistant/tool rows survive reopen. The outbox is empty and did not stay paused on PERMISSION_DENIED:. A PLUGIN_PERMISSION_DENIED head would still pause rather than drain.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0041, ADR 0239, ADR active-turn-steering, D597
  • Acceptance: F (persistence)
  • Milestone: M2
  • Status: Unit-covered (steering_input_persists_without_inheriting_delivery_origin, persistence-outbox.test.mjs); desktop journey outstanding

E2E-SESSION-outbox-unclassifiable-failure-does-not-stall-the-queue ​

  • Preconditions: One queued outbox append fails with an error the outbox cannot classify (Error: stream did not contain valid UTF-8 from a torn host transcript), while later appends from other sessions queue behind it. Optionally the outbox is already at its 1024-entry cap and the flush log shows repeated session persistence flush paused for that key.
  • Steps: 1) Produce the unclassifiable append failure. 2) Keep sending messages in two other sessions. 3) Observe logs/app/persistence.log for session persistence flush paused (bounded attempts), then session persistence flush quarantined entry, and for the absence of session.persistence.outbox.is.full. 4) Confirm session-message-outbox.dead-letter.json holds that key with its payload and error, and that session-message-outbox.json drops below the cap. 5) Restart the app and confirm the quarantined append is not replayed. 6) Optional: delete the failing session and confirm its dead letter is dropped.
  • Expected: The failing append is paused four times with a growing backoff and quarantined on the fifth consecutive failure, then skipped. Later appends from every session drain, no enqueue is rejected with outbox is full, and the queued outbox file empties. The dead letter survives a restart without being replayed.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0041, D318, D444, D597, D656
  • Acceptance: F (persistence)
  • Milestone: M2
  • Status: Unit-covered (persistence-outbox.test.mjs bounded retry, quarantine, dead-letter persistence and cap tests); desktop journey outstanding

E2E-011: Switch between project and temporary sessions ​

  • Preconditions: One retained project session and one path-less Temporary session exist. Both transcripts exceed one viewport and have distinct final records.
  • Steps: 1) Open the project session from its exact-path sidebar group. 2) Scroll to an earlier record and confirm the jump-to-latest control appears. 3) Open the Temporary session and observe its first painted frame. 4) Switch rapidly project → Temporary → project while the first two transcript reads are delayed, and observe which row responds and which destination commits. 5) Hover/focus Temporary, switch to it again to exercise the warm cache, then repeat with reduced motion enabled. 6) Observe chat content and workspace chrome.
  • Expected: The sidebar contains no Recents aggregate; retained projects have scoped groups and path-less sessions remain under Temporary; each transcript loads correctly; selecting Temporary clears project context and inherits no workspace access; both sessions remain persisted. Every session first activation paints its distinct final record at the transcript bottom without first exposing the transcript top, another session's scroll position, or a stale jump-to-latest control. The latest clicked row responds immediately and its transcript request does not wait for superseded reads; only the final project/session/work-panel tuple commits. On a cold switch the currently visible pane keeps showing its own session under a thin progress track until the destination commits, with the composer keeping its settled home/docked shape but inert so no prompt can reach the session being left; no transcript is dimmed at any point. A warm revisit reveals the retained pane immediately at its own content and scroll position, then revalidates in place without a visible change. Reduced motion keeps the progress track static and preserves the same destination without a skeleton remount or animated traversal through history.
  • Specs linked: 03-runtime/10-session-state-machine.md, 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (switch sessions)
  • Milestone: M2
  • Status: Source-level regression covered; full visual scenario Draft

E2E-TRANSCRIPT-restored-live-tail-status ​

  • Preconditions: An isolated renderer with two retained session panes, a running turn with tool output, and the real Composer and SessionPane.
  • Steps: Restore a bookmark during selection revalidation; append live output; switch away and reveal the running pane; inspect an unread history range and an explicit search at the tail; settle and abort the turn. While reading above the live tail, click return-to-latest and switch away in the same task, before a native smooth-scroll event. Return first with the pane retained, then repeat while a project switch actually unmounts the pane and output continues in the background.
  • Expected: The restored live tail keeps a running process header while Stop is available. Content updates and pane reveal do not falsely show Finished. The restored range and scroll offset remain owned by the reader. Another session, unread pages, and explicit search do not inherit live status. Terminal completion and abort show Finished and Stopped respectively. An empty restored range stays historical, an ordinary empty canonical range shows the initial wait, and a restored navigation-parent copy with the same message id keeps the running presentation. Return-to-latest synchronously records a pinned bookmark; both a retained reveal and a fresh project remount follow the newest output without a second click. The running header and Stop control stay consistent. Readers who did not choose return-to-latest keep their existing historical reading position.
  • Automation: node scripts/e2e-transcript-reading-position.mjs (transcriptLiveTailProbe).
  • Specs linked: 04-ux/09-interaction-patterns.md §2.3 / §2.4
  • Acceptance: C (session switching and running status)
  • Status: Automated isolated Electron regression

E2E-207: Open large and long sessions through bounded transcript windows ​

  • Preconditions: A session contains a message with at least 50 MB of text or tool output and enough additional messages to span several transcript pages; a second session contains a long ordinary conversation.
  • Steps: 1) Open each session from the sidebar while recording the first visible transcript frame and renderer responsiveness. 2) Scroll to the top of the transcript. 3) Repeat the open/close cycle after the session has been cached. 4) Inspect the host request/response or test fixture for the session read window. 5) In an isolated Host profile, create a transcript above the layout-cache threshold, append more rows, settle a middle Task result with a longer value, then restart with a legacy layout file beside the transcript. 6) Read the newest page, page to the beginning, locate a nested message and its owning Task, then read the complete history.
  • Expected: Opening paints the newest bounded page without transferring the complete 50 MB value to the renderer. The large value is visibly marked as truncated for display while the transcript and model-facing read remain lossless. Reaching the top loads older pages incrementally and preserves the viewport position; the final page reports no older history. Reopening a long session reuses the bounded cache and does not synchronously construct every historical row before the latest messages become usable. Edit, delete, revision, and Stop operations rehydrate the full transcript before any rewrite, so older messages are never lost because only a window was visible. The isolated Host journey returns every message once and in order after the middle rewrite, legacy-cache rejection and restart; the Task result is terminal, nested-message navigation resolves its owning Task, and paging reaches the beginning without a UTF-8 error or omitted row.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0120
  • Acceptance: C (session open and scroll), F (persistence), Quality
  • Milestone: M5
  • Status: Source-level and isolated Host RPC cache regressions covered; full large-fixture Electron journey Draft

E2E-TRANSCRIPT-invalid-utf8-window-remains-readable ​

  • Preconditions: An isolated Host profile contains a session transcript with valid messages before and after a complete line containing invalid UTF-8 bytes. The damaged bytes are inside message text in one fixture and make a separate message line unparseable in another. No real user data.
  • Steps: Open the session through the renderer's bounded session.get path with messageLimit, page across the damaged line, then read the full history through the Host API. Compare the transcript file before and after.
  • Expected: The session stays readable. Valid surrounding messages appear in order; parseable damaged text contains U+FFFD, while an unparseable row is skipped. The source transcript remains byte-identical and paging positions stay consistent.
  • Specs: 03-runtime/04-data-storage.md and ADR 0127.
  • Acceptance: C (conversation and stream), F (persistence), Quality.
  • Milestone: Maintenance.
  • Automation: pnpm test:e2e:transcript-cache exercises bounded session.get against an isolated Host and verifies replacement text plus unchanged source bytes. crates/host-core/src/transcripts_layout_tests.rs covers the invalid row and layout reader directly. Full renderer journey remains Draft.

E2E-011a: New session while another session is still streaming ​

  • Preconditions: Provider configured; session A is streaming a long response (composer shows the stop/abort control).
  • Steps: 1) While A is still streaming, click New task / New chat. 2) Observe the fresh session's composer. 3) Type a prompt and send it while A continues streaming in the background. 4) Let A finish and observe the fresh session's composer again.
  • Expected: The previous streaming transcript leaves the screen on the first frame. The new session immediately shows the empty home and the idle Send control (never a stuck stop/abort control) and its textarea is enabled; the prompt sends and streams normally while A keeps running in the background. When A ends, its cross-session agent_end does not change the new session's composer state, which remains idle with the Send control.
  • Specs linked: 04-ux/08-component-spec.md (§11.4), 04-ux/09-interaction-patterns.md (§1.6, §11)
  • Acceptance: C (session isolation, chat & stream)
  • Milestone: M2
  • Status: Unit-covered (composer-send-state.test.mjs); pending-admission component/store user path covered (queue-pending-actions.test.mjs); full UI scenario Draft

E2E-011b: Create a new session from a retained project group ​

  • Preconditions: Provider configured; at least one retained project is visible in the sidebar; the current conversation may be idle or streaming.
  • Steps: 1) Click the project group's New session control. 2) Wait for the project conversation to load. 3) Type a prompt and inspect the Send control. 4) Send without clicking New session again.
  • Expected: Project activation commits as one renderer navigation flow. Reuse is decided from the in-memory list plus renderer empty signals (messageCount, live rows, running flag, submitted drafts), not a blocking session.list. If the latest session is empty, the existing row is selected on the first frame; if it is non-empty, the empty home replaces the previous transcript immediately and one durable empty session is created from session.create. The composer becomes editable with the Send control enabled as soon as the destination is selected; an earlier project's background turn cannot leave it disabled. Repeating the click while the slot is empty selects the same row and creates no duplicate.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/09-interaction-patterns.md (§1.6), 04-ux/08-component-spec.md (§11.4)
  • Acceptance: C (project session creation and send readiness)
  • Milestone: M2
  • Status: Source-level regression covered (app-store-sidebar.test.mjs); full UI scenario Draft

E2E-011c: Session-scoped composer drafts ​

  • Preconditions: Provider configured; sessions A and B exist; A has a transcript so it uses the docked composer; B is an empty session so it uses the home composer; the composer is visible and both sessions are idle.
  • Steps: 1) Select A and type a prompt without sending it. 2) Switch to B and inspect the composer. 3) Type a different prompt in B, then switch back to A. 4) Create a new session and inspect its composer. 5) Return to B and then delete B; revisit the remaining sessions and the home composer if it is available. 6) Type in A, add a workspace file through @ and a pasted scratch file, open Settings (or Plugins), then return to chat. Confirm both file chips and the surrounding text remain. Change the workspace of the same draft and confirm only its workspace-relative reference is removed. 7) Hide the app window and show it again with an unsent draft in A.
  • Expected: B initially shows an empty composer, A restores its original unsent prompt, and the new session starts empty rather than inheriting A or B. Each session keeps only its own draft (including file-reference chips) across empty-home ↔ docked remounts. Deleting B removes its cached draft. If a prompt is sent while its request is in flight and the user switches sessions, successful completion clears only the submitting session's draft and never clears the destination composer. The draft in A is still present after the Settings/Plugins round-trip and after the window is hidden and shown (D301).
  • Specs linked: 04-ux/09-interaction-patterns.md
  • Acceptance: C (session isolation and composer input)
  • Milestone: M2
  • Status: Source-level regression covered (composer-draft-cache.test.mjs); real React remount and workspace-change coverage in scripts/e2e/composer-paste.tsx (pnpm test:e2e:composer-paste); full UI scenario Draft

E2E-011c-1: Preserve newer drafts after command completion ​

  • Preconditions: Sessions A and B exist; the /compact API response can be held pending.
  • Steps: In A, submit /compact and hold its response. Replace the command text with a new draft, then release the response. Repeat with an image added to the unchanged command text, and with navigation to B before completion. For the reentered variant, while compact is pending replace the editor text with temporary text and dispatch input, then type the exact /compact again and dispatch input before releasing the response. Finally run the command without editing its draft.
  • Expected: Completion preserves newer text and image attachments in A; B's draft is untouched, and returning to A restores its newer draft. The reentered case still displays /compact after success even though its final text equals the submitted value. An unchanged submitted command draft clears.
  • Coverage: scripts/e2e/composer-submission.tsx, executed by pnpm test:e2e:composer-paste; real desktop recording uses isolated data and a local model with a controlled response delay.

E2E-011c-2: Late send failures do not restore stale composer drafts ​

  • Preconditions: Session A is idle; the two send results for its composer can be held and settled independently.
  • Steps: Submit draft N, then submit draft N+1 before N settles. Accept N+1 and reject N. Repeat with N+1 rejected after N remains pending.
  • Expected: N's late rejection never restores its text or file references over N+1. If N+1 is rejected, only N+1's own snapshot is restored.
  • Specs linked: 04-ux/09-interaction-patterns.md (session-scoped drafts)
  • Acceptance: C (composer input and session isolation)
  • Milestone: M2
  • Status: Async ownership behavior covered by apps/desktop/test/composer-submit-race.test.mjs; rendered UI scenario Draft
  • Preconditions: Isolated desktop profile; one project with conversations A and B, and a second project with conversation C; a configured model that answers from the context it is given.
  • Steps: 1) In A's conversation overflow menu choose Copy conversation link and read the clipboard. 2) Open B, paste the link into the Composer draft, send it, and inspect the user message. 3) Ask a follow-up in B without repeating the link. 4) Paste C's link into B and send. 5) In A, send A's own link. 6) Rename A from its conversation overflow menu, then return to B.
  • Expected: The clipboard holds pi-desktop://session/<A's id>. B's user message keeps the link text visible and shows a chat-icon chip labelled with the catalog's reference label and A's title; activating the chip opens A. The answer uses A's content, and the follow-up still reads the same reference without the link being written again. C's link and A's own link attach nothing: the message keeps its plaintext and the model receives no excerpt from them, because the resolver drops a self-reference before any read and discards an excerpt whose conversation belongs to another project. Renaming A afterwards renames the chip in B, which names the conversation from its current title; the excerpt the model already read keeps the name recorded when the reference was made.
  • Specs linked: 04-ux/08-component-spec.md §20B, 03-runtime/04-data-storage.md, 03-runtime/02-agent-runtime.md
  • Acceptance criterion: C — Conversation & stream
  • Milestone: M2
  • Status: Draft — the resolver and the chip are covered at source level by apps/desktop/test/session-references.test.mjs and apps/desktop/test/session-reference-ui.test.mjs

E2E-COMPOSER-input-history-recall ​

  • Preconditions: Isolated Electron test profile and the production Composer input/draft/history/submit hooks mounted in the renderer fixture. The send boundary is deterministic; no provider or network credentials are needed.
  • Steps: 1) In session A, send alpha, beta, and beta again through Enter; press ArrowUp twice and ArrowDown twice. 2) Edit a recalled draft and confirm arrows no longer replace it. 3) Send a prompt with a file reference, recall it, and inspect the rendered chip. 4) Switch to B, verify A's history is absent, send beta, and verify B recalls only its own entry. 5) Start the first Home prompt with the send boundary held, switch to B before acceptance, release it, then open the materialized session and recall the prompt. 6) Exit Electron and launch a new process with the same isolated profile; recall A's attachment entry and the Home-created session's prompt.
  • Expected: Duplicate consecutive beta is stored once; ArrowUp/ArrowDown walk newest-to-oldest and back to an empty draft. Editing ends browsing. Attachment text and its rendered file chip return together. Sessions A and B remain isolated. The delayed Home send is recorded under the session ID materialized by sendPrompt, not the session selected when the send finishes. Both histories and the attachment survive a real Electron process restart.
  • Specs linked: 04-ux/09-interaction-patterns.md, 08-meta/decisions-log.md (D632), 04-ux/08-component-spec.md
  • Acceptance: C (composer input and session isolation)
  • Milestone: M2
  • Status: Automated by pnpm test:e2e:composer-paste (renderer keyboard interactions, Home session switch, attachment recall, and second-process persistence); store callback race regression is covered by apps/desktop/test/composer-input-history.test.mjs.

E2E-011d: New task creates an immediate durable empty slot ​

  • Preconditions: Provider configured; at least one real session exists so the sidebar history is non-empty.
  • Steps: 1) Invoke New Task from the sidebar, the top bar, or Cmd/Ctrl+N inside a retained project and in the temporary scope. 2) Inspect the sidebar history and the composer. 3) Click the same group's New Task control several times quickly. 4) Type a message and send it. 5) Inspect the sidebar history again and repeat in a different project group.
  • Expected: When the group's latest session is non-empty, the empty home replaces the previous transcript on the first frame, then one new row is persisted from session.create (without a blocking session.list / session.get round-trip) and selected before the first send. Once that row is the group's latest empty session, repeated clicks select it (or do nothing when already selected) and create no duplicate. Sending updates the same row's title and message count; the project and temporary groups keep independent slots.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/08-component-spec.md (§11), 04-ux/01-ui-ia.md (§5)
  • Acceptance: C (history integrity and group-scoped creation)
  • Milestone: M2
  • Status: Source-level regression covered (app-store-sidebar.test.mjs, composer-send-state.test.mjs, session-create.test.mjs); full UI scenario Draft

E2E-011e: Empty-session reuse is scoped to the latest session ​

  • Preconditions: One project group contains an older empty session and a newer session with messages; a separate project and the Temporary group are available.
  • Steps: 1) Click New Task in the first project group. 2) Confirm the new row is created even though the older empty session remains. 3) Click New Task again before sending. 4) Repeat in the second project and Temporary group.
  • Expected: The first click creates a new durable row because only the latest session is considered and it is non-empty. The second click reuses that newly created empty row. The older empty row remains untouched, while each other group gets its own independent empty-slot decision.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md
  • Acceptance: C (session creation and grouping), F (persistence)
  • Milestone: M2
  • Status: Source-level regression covered; full UI scenario Draft

E2E-012b: Project memory persists only within its project ​

  • Preconditions: App running with two retained projects and a configured provider.
  • Steps: 1) Open the first project row menu and choose Project memory. 2) Add a memory card, enter a title and project-specific note, then save. 3) Reopen the editor, edit the note, add a second card, remove the first card, and save. 4) Start or continue a chat in the first project and verify the next runtime receives the saved entries as derived context. 5) Switch to the second project and start a chat. 6) Return to the first project and reopen the editor.
  • Expected: The editor loads the saved cards after reopening. The first project's runtime receives their readable projection as a labelled user-context block; the second project's runtime does not. An existing legacy plain-text memory opens as one untitled card. Empty memory is valid, removing all cards clears the projection, saving replaces the prior value, and content above 32 KiB is rejected without a partial save. The create dialog's memory hint is concise and does not imply that memory is shared across projects.
  • Specs linked: 03-runtime/01-ipc-protocol.md (§9), 03-runtime/04-data-storage.md (§4.1), 03-runtime/06-host-rpc-protocol.md (Projects), 04-ux/06-settings-ia.md (Project archive), 04-ux/08-component-spec.md (Sidebar interactions)
  • Acceptance: C (chat/stream), D (project UI), F (persistence), Localization
  • Milestone: M5
  • Status: Unit/source covered; full provider/UI journey Draft

E2E-011f: Send while running queues per-session prompts and supports Send now ​

  • Preconditions: Provider configured; session A can produce a delayed response with at least one completed tool batch; session B exists and is idle.
  • Steps: 1) Send a long-running prompt in A. 2) While A is running, verify the composer has exactly one submit button: with an empty draft it is Stop (aria-label="Stop generating") and no Send button is present; type a draft and verify the same slot becomes Send (aria-label="Send") with no Stop button present. Send two more prompts and inspect the queue above the composer. 3) Remove the second queued row and switch to B. 4) Send a prompt in B, then return to A before either run completes. 5) Choose Send now on A's remaining queued row, then choose Send now on a second row. 6) Observe A through the next model request within the same turn. 7) Start another run in A, clear the draft to expose the single Stop button, press Stop, and inspect the queue. 8) Repeat with two queued prompts, let the active turn finish without Send now, and delay its host session.endTurn response until after agent_end is delivered. Release finalization and observe both follow-ups through their turn boundaries. Repeat with a provider error and an immediate abort. 9) With waiting rows, use move up and move down and confirm the persisted order follows. 10) Edit a waiting row while the composer holds text, then with an empty composer. 11) Delay Host queue admission. Attempt to edit/remove the pending row, release admission, then edit/remove the durable row. Repeat with a captured file-reference draft. 12) Withhold a local Host queue-save request until its existing RPC deadline expires, then retry. 13) With an empty composer and two waiting rows, press the steering shortcut (⌥/Alt+Enter): verify the first row is promoted, press it again to promote the second, then repeat with only a saving row present. 14) Repeat with a draft in the composer and confirm the shortcut steers the draft and leaves both rows waiting.
  • Expected: The single submit slot contains exactly one button in every state: disabled Send while idle and empty, enabled Send while running with content (which queues the prompt), and Stop while running with an empty draft. A's two prompts appear in FIFO order, the removed row never sends, and B's queue remains independent. Send now delivers promoted input in click order into the current turn without requesting a stop. An active TaskWait yields to the input while delegates keep running. Refused input or input with a different permission mode remains queued for a later turn. A promoted row locks move/edit and Send now, but permits removal; promotion is one-way. Move up/down swaps only waiting rows, never crosses the promoted block, and persists. Edit is refused with a visible message while the input is non-empty, and otherwise removes the row and returns its text plus its file-reference chips to the composer. Immediate Stop aborts the current reply and preserves A's queued row; switching sessions preserves both queues. Ordinary completion, provider failure, and abort all resume queued sending automatically after durable finalization releases the session. No queued prompt starts while finalization is pending; each subsequent turn starts once in FIFO order, without another click or session switch. Repeated terminal handling does not double-dispatch. Other sessions' queues remain unchanged, and quitting while finalization is pending preserves queued work without starting another turn. Pending-admission rows disable all five actions, explain Saving in each tooltip, show Saving on Send now, and remain visible without restoring a second draft. Once acknowledged, remove deletes the durable row and edit restores exactly its captured draft; neither row reappears after refresh. A local Host admission timeout clears the pending row, reports the error, and restores the rejected composer draft; retry can queue normally. With an empty composer the steering shortcut promotes the first waiting row exactly as its Send now action does, skipping rows that are saving or already promoted; with a draft present it steers the draft and leaves the queue untouched.
  • Specs linked: 03-runtime/01-ipc-protocol.md (§5.6), 04-ux/08-component-spec.md (§11), 04-ux/09-interaction-patterns.md (§3.4), ADR 0118, ADR 0213, ADR 0265
  • Acceptance: C (chat, stream, and session isolation), Quality
  • Milestone: M6+
  • Status: Source-level regression and deterministic desktop finalization / Agent Host integration covered (queued-turn-finalization.test.mjs); promotion, reorder, and edit contracts covered at source level (composer-send-state.test.mjs); the empty-composer send-now shortcut is covered by composer-queued-send-now-shortcut.test.mjs; full UI scenario Draft

E2E-QUEUE-promote-orders-delivery-by-click: Two Send now clicks deliver in click order ​

  • Preconditions: Provider configured; session A is running a turn with at least one completed tool batch; three prompts are queued behind it. the first queued row. 3) Confirm the promoted block orders third → first, that both rows lock their move/edit actions, and that the remaining row is still editable. 4) Observe steering delivery at the next model request.
  • Expected: The first click is delivered first and the second second — the click order is the delivery order, not "last click wins" and not the original queue order. Both rows appear as user messages in the same active turn; the queue no longer lists either accepted promoted row. Both promoted rows show as waiting and cannot be edited or reordered. Removal remains available until delivery. Refused steering retains the row for the next turn and leaves it cancelable. If cancellation wins admission, the input never executes; if delivery wins, cancellation reports that it already started. The row disappears only after Host acknowledgement. A stalled steering delivery does not block admission or cancellation of other queued inputs. Failed durable removal after acceptance reports an error and rejects cancellation; a subsequent drain retries cleanup without re-executing the accepted input in the live Host. The waiting row keeps its actions and is not delivered before either promoted row.
  • Specs linked: 03-runtime/01-ipc-protocol.md (§5.6), 04-ux/08-component-spec.md (§11), ADR 0265
  • Acceptance: C (chat, stream)
  • Milestone: M6+
  • Status: Draft; ordering and the adjacent delivery are covered by turn_queue, turn-queue, and agent-host unit tests
  • Specs linked: 03-runtime/01-ipc-protocol.md (§5.6), 04-ux/08-component-spec.md (§11), ADR 0265
  • Acceptance: C (chat, stream)
  • Milestone: M6+
  • Status: Draft; underlying ordering covered by turn_queue, turn-queue, and agent-host unit tests

E2E-QUEUE-reorder-moves-plain-neighbours: Move up/down reorders the waiting queue ​

  • Preconditions: Provider configured; a turn is running with at least three prompts queued and no promoted row.
  • Steps: 1) Move the third row up twice and confirm it becomes first. 2) Move the first row up once and confirm nothing moves. 3) Promote a row, then try to move the adjacent waiting row across it. 4) Reload the renderer and inspect the queue order.
  • Expected: Each move swaps the row with its adjacent waiting neighbour and the Host persists the new order, so a reload reproduces it. The waiting-block boundary and the promoted block are immovable: a move at the block edge is a no-op that never reaches the Host, and no move changes a promoted row's place.
  • Specs linked: 03-runtime/01-ipc-protocol.md (§5.6), ADR 0265
  • Acceptance: C (chat), F (persistence)
  • Milestone: M6+
  • Status: Draft; host-side reorder covered by turn_queue unit tests

E2E-QUEUE-cancel-next-during-start: The next waiting message stays cancelable ​

  • Preconditions: An isolated desktop profile and a local provider that can hold a reply open; one active turn followed by queued messages A and B.
  • Steps: Release the active reply. While A is executing, cancel B. Finish A and send another message.
  • Expected: A's early runtime events retain A's identity. B remains queued at position 1 until cancellation succeeds, never reaches the provider, and disappears from the durable queue. A and the subsequent message complete.
  • Specs linked: 03-runtime/19-remote-agent-control-protocol.md (turn states)
  • Acceptance: C (chat), F (persistence)
  • Status: Manual local-provider acceptance scenario

E2E-QUEUE-edit-restores-draft-only-when-input-empty: Edit restores the queued draft ​

  • Preconditions: Provider configured; a turn is running with a queued prompt that carries text and a file-reference chip; the composer input is empty.
  • Steps: 1) Type a draft, then choose edit on the queued row. 2) Clear the composer and choose edit again. 3) Confirm the queue no longer lists the row and the composer holds the row's text and its file-reference chip. 4) Send it and compare the transcript with the original queued prompt. 5) Queue an image with a file, quit the app normally, reopen it, and edit the restored row. Repeat with an image-only row and a text-only row; resend each edited draft.
  • Expected: With a non-empty input (or an attachment chip) the edit is refused with a visible message and the row stays queued. With an empty input the row leaves the queue, the Host no longer lists it, and the composer holds the exact text plus the original file-reference chip — not the serialized prompt the Host received. Re-sending produces the same prompt as the queued row would have. After restart, Host-stored content and all attachments survive editing and resubmission even though the in-memory draft is gone.
  • Specs linked: 04-ux/08-component-spec.md (§11), ADR 0265
  • Acceptance: C (chat, stream)
  • Milestone: M6+
  • Status: Draft; composer-side contract covered by composer-send-state.test.mjs

E2E-011g: New Task does not leave the previous transcript on screen ​

  • Preconditions: Provider configured; session A has a visible transcript (idle or streaming); the group's latest session is non-empty.
  • Steps: 1) Click New Task (sidebar, top bar, or Cmd/Ctrl+N). 2) Observe the chat surface on the next frame, before the new sidebar row is required to exist. 3) Type in the composer during that interval. 4) Wait for the new row and send.
  • Expected: A's transcript is gone on the first frame (empty home, idle Send). The composer does not stay on A's draft. After session.create the same empty session is selected, any text typed during the wait belongs to that session, and sending does not create a second row. Repeating New Task before sending reuses the row. The host is not asked to session.list or session.get before the empty destination is visible.
  • Specs linked: 04-ux/09-interaction-patterns.md (§1.6), 04-ux/08-component-spec.md (§11), ADR 0154
  • Acceptance: C (session creation), Quality
  • Milestone: M2
  • Status: Source-level regression covered (session-create.test.mjs, session-switch-performance.test.mjs); full UI scenario Draft

Composer file-reference undo regression ​

  • Add a file reference to an unsent draft, select the chip, delete it, then undo and redo with the native editor shortcuts. Undo must restore both the chip and the file path used by submission; redo must remove both.
  • Switch to another chat and back after undo: the other draft stays empty and the source retains the restored reference. Typing a removed chip's private-use token as plain text must not restore the attachment.
  • Automated by pnpm test:e2e:composer-paste (fileReferenceUndoRedo).

Conversation Top Bar ​

E2E-087: Conversation top bar renders on the chat route ​

  • Preconditions: Provider configured; at least one session exists.
  • Steps: 1) Open the chat route. 2) Inspect the 46px bar at the top of the conversation area. 3) Confirm it shows the concise session/task title and the New task / Search buttons; hover or focus each and inspect its tooltip. Confirm the sidebar toggle appears only when collapsed. 4) Customize each shortcut in Settings → Shortcuts and confirm the tooltip updates; disable each binding and confirm its shortcut is omitted. 5) Switch to Scheduled, Plugins, or Settings and inspect the same top region.
  • Expected: Every route-owned top region uses the same --ds-toolbar-height (46px), bg-primary surface, and bottom border; Windows/Linux reserve the same 120px native-control band at the right. On the chat route the conversation top bar renders with its title and actions only; it has no model or Agent|Plan|Goal mode control. The left-of-input Composer chip owns the active session's Agent/Plan/Goal switch, and the Composer-right combined chip owns model and reasoning selection. The task title is the only visible title text and uses the available width, with an ellipsis only on overflow. Check an English title longer than 10 characters in wide and narrow layouts, with the sidebar expanded/collapsed and the work panel open/closed; titles must not overlap the action buttons. The complete title and project scope remain available through its tooltip. New task and Search tooltips show their effective platform-formatted shortcuts, including custom bindings; an explicitly unbound shortcut is omitted, while the accessible name remains the localized action. The sidebar toggle is present only when collapsed (no duplicate of the sidebar's control). On every other route the frameless drag band renders instead (no chat top-bar controls) while retaining the same surface and alignment. The bar is draggable to move the window; interactive controls do not start a window drag. macOS leaves the left 88px clear for traffic lights only while the sidebar is collapsed (8px in fullscreen); Windows/Linux leave the right 120px clear for native window controls.
  • Specs linked: 04-ux/08-component-spec.md (§2 Topbar)
  • Acceptance: C (send/UI), Quality
  • Milestone: M2
  • Status: Draft

E2E-087a: Destination page headers clear the titlebar band on macOS ​

  • Preconditions: macOS build; at least one plugin installed.
  • Steps: 1) Open the Plugins route with the window at its default size. 2) Inspect the top of the page: the "Plugins" title row, its primary action, and the overflow menu button. 3) Scroll the page to the top and confirm no page content is hidden behind the 46px band. 4) Repeat on the Scheduled route. 5) Open a plugin's detail sheet and inspect its head.
  • Expected: The page header renders fully below the frameless drag band on macOS as it already does on Windows/Linux: the title row is not clipped, and the Installed / Marketplace segmented control and search field sit at their intended offset instead of at the window's top edge. .page-frame reserves --ds-toolbar-height plus an 8px buffer on darwin, win32, and linux alike. Scrolling Plugins and Scheduled use a destination-owned scroller rather than the chat transcript scroller, so the bottom rows remain fully painted and reachable instead of inheriting the Composer occlusion mask. The plugin detail sheet mounts on the viewport overlay host, stays above the expanded sidebar and titlebar band, and keeps its own head at the top edge, with its close button opting out of the drag rectangle.
  • Specs linked: 04-ux/08-component-spec.md (§2.3 Layout)
  • Acceptance: C (UI), Quality
  • Milestone: M2
  • Status: Source-level regression covered (apps/desktop/test/plugins-page-style.test.mjs, apps/desktop/test/route-scroll.test.mjs); full UI scenario Draft

E2E-088: Composer Agent/Plan/Goal chip updates the session ​

  • Preconditions: Chat route active; a session selected.
  • Steps: 1) Click the left-of-input Composer mode chip to enter Plan. 2) Send a prompt that would normally require Write/Edit and observe behavior. 3) Click the same Composer chip to return to Agent. 4) Begin a turn and try to toggle mode mid-run or while a pending Plan approval is visible.
  • Expected: The Composer chip updates the active session mode (Plan and Goal hard-deny Write/Edit and plugin tools while Bash follows the selected permission mode; Agent allows its normal tools per permission settings). The chip is disabled while a turn or active pending approval exists and re-enables after the session returns idle/planning. No top-bar mode control is rendered.
  • Specs linked: 04-ux/08-component-spec.md (§2, §11), 03-runtime/03-tools-and-permissions.md (§10), 03-runtime/04-data-storage.md (§8)
  • Acceptance: C, E
  • Milestone: M2
  • Status: Draft

E2E-088a: Composer configuration controls survive project/session initialization ​

  • Preconditions: Provider configured; a new project or new session flow is visible while the destination activeSessionId is still resolving.
  • Steps: 1) Inspect the Composer mode, model × reasoning, and permission controls during the empty/home transition. 2) Click the mode control and confirm it advances to the next mode. 3) Open the combined chip, enter the Reasoning level submenu, and select a supported level. 4) Open permission mode and select Auto. 5) Inspect the destination session after navigation completes.
  • Expected: None of the idle configuration triggers is disabled merely because the destination session has not been projected yet. New Task has already selected or created the durable empty row, and the first configuration action applies to that session without waiting for a message; no second click is required. Running turns and pending approvals still disable the controls. Behind that transition the chat area follows the cold-switch rule: the currently visible pane keeps showing its own session until the destination commits, the only wait affordance is the thin progress track, nothing is dimmed, and prompt submission stays inert until the visible pane is the active session, so a prompt cannot reach the session being left. In zh-CN/zh-TW, the Composer permission menu renders the Accept edits option as 允许编辑 / 允許編輯 and keeps it on one line.
  • Specs linked: 04-ux/08-component-spec.md (§11), 04-ux/09-interaction-patterns.md (§5A), ADR 0137
  • Acceptance: C (new project/session composer)
  • Milestone: M2
  • Status: Draft

E2E-088b: Composer placeholder guidance follows page and session context ​

  • Preconditions: English and zh-CN locales are available; a provider is configured; at least one Skill is active; both an empty home and two conversations can be opened.
  • Steps: 1) On empty home, record the welcome placeholder and wait longer than 4 seconds to confirm it is unchanged. 2) Open conversation A, record its guidance, type and clear text, focus and blur the textarea, and wait; confirm the copy is unchanged. 3) Switch to conversation B and then back to A, recording each guidance change. 4) Switch between home and a conversation and inspect the command/file and keyboard hints. 5) Type / and inspect the slash menu. Include Skills with long English/CJK descriptions, short/no descriptions, a separate title/argument hint, and an exceptionally long slash name; also inspect a long filename in @ mode. Repeat at 800px and 1680px viewport widths with 320px and 640px composer widths. 6) Switch to zh-CN and repeat the context-switch checks.
  • Expected: The initially rendered context starts with its welcome copy and stays stable until the page/session context changes. Each context switch advances to the next localized command/file or keyboard hint with an opacity fade; no timer-driven changes occur. The keyboard hint includes Shift+Enter and a submit hint, while the command/file hint includes / and @. The slash menu still contains /new, /compact, /agent-mode, /plan-mode, and /goal-mode, followed by a Skills group at the bottom. Selecting a Skill inserts /skill:<id>; sending it keeps the typed command chip visible and the model calls Skill with the original ID before answering. Verify builtin, plugin, and user Skill prefixes, multiple inline references, and a prompt template sharing the unprefixed Skill name; inactive and unknown Skills must not resolve. zh-CN shows the matching localized copy, including Shift+Enter for newline · Use Send to submit. Long descriptions use only the space remaining after command names and hints, so short names stay fully visible. Descriptions and oversized names ellipsize within the row without horizontal overflow; file names retain the available row width. Name highlights and click acceptance with input focus retained remain intact.
  • Specs linked: 04-ux/08-component-spec.md (§11), 04-ux/04-builtin-commands.md (§7–8)
  • Acceptance: C (send/UI), Localization, Quality
  • Milestone: M2
  • Status: Source-covered (composer-placeholder-context.test.mjs); slash-menu layout covered by pnpm test:e2e:composer-autocomplete after pnpm build:js (real React/Chromium and production CSS, deterministic command fixtures; no provider required). Full provider/session scenario Draft; branch runs do not replace post-integration E2E
Connected MCP slash selection (#1377) ​
  • Restart Desktop with an enabled saved MCP server. Without visiting MCP settings or sending an ordinary message first, open the slash menu and submit a server/tool command. Discovery initializes the connection and the first prompt reaches the selected tool; opening the menu executes no tool.

  • With a ready user MCP server, type / or search its display label. The MCP group appears before Skills. Enter and mouse selection insert the encoded server command without sending or invoking a tool.

  • Send a task after the command. The runtime receives the server-specific instruction, exact server IDs, and task; persisted command metadata retains the typed text. All six fixture tools are activated without ToolSearch; a similarly named server remains unselected. Steering activates only for the request consuming the queued message. Plan and Host permissions still apply.

  • Submit a server/tool command with no task text or attachment. Main rejects it before opening or persisting a turn; adding task text or an attachment allows the selected tools to be offered to the model.

  • Disconnect, disable, or remove project access between menu selection and send. Sending fails before a new turn or history replacement. Steering uses the same validation; native Pi cannot silently accept Desktop MCP commands.

  • Coverage: apps/desktop/test/composer-mcp.test.mjs exercises the real command service and prompt/steering IPC with external runtime boundaries stubbed. node scripts/e2e-composer-ime-stack.mjs exercises the real React editor, completion controller and keyboard/mouse acceptance in isolated Electron. node scripts/e2e-composer-mcp.mjs uses production sidecar transport, an isolated Rust Host, a real stdio MCP child and a local SSE model fixture. It verifies the sixth tool executes without ToolSearch and its server-only nonce reaches the next model request. These fixtures do not prove a real provider's tool choice or remote MCP health.

  • Select /mcp:<server>:<tool> by keyboard and pointer, then submit or steer. Main preserves the original command and forwards exact server/tool IDs. Only the requested tool activates; similarly named tools, another server's tools and removed tools cannot broaden the selection. Whole-server rows remain selectable and existing active tools retain their availability.

  • Run MCP_SELECTION_TOOL=1 node scripts/e2e-composer-mcp.mjs for the individual-tool variant of the deterministic sidecar/Host/MCP scenario.

E2E-089: Composer model menu opens upward and switches model ​

  • Preconditions: Chat route active; provider configured with two reasoning models whose binding defaults differ.
  • Steps: 1) Click the Composer-right model × reasoning chip. 2) Confirm the menu opens upward from the bottom composer. 3) Enter Model, select a different provider/model, and return to the root. 4) Confirm its default thinking level, choose another supported level, then reselect the same model. 5) Open Settings from the command palette or application menu.
  • Expected: The trigger uses a Bot icon while retaining the current model and reasoning labels. The root shows only Model and Reasoning level entries. The Model submenu lists enabled runnable providers and only the model bindings saved for each provider, with each model row visibly indented beneath its provider heading. Cached or freshly discovered models may supply display names and metadata for those bindings, but unconfigured discovery results are absent; configured IDs remain available when discovery is unavailable. The root slider lists the selected model's supported levels. Selecting a different model updates the active session to that binding's default thinking level; selecting the already-active model preserves a manually selected level. Settings opens from the command palette/menu. The Composer model trigger ellipsizes long IDs. Each option shows one display name only, and hovering a long option exposes its complete display name in the tooltip without changing the menu layout or adding a visible model ID.
  • Specs linked: 04-ux/08-component-spec.md (§11, model menu), 03-runtime/13-model-catalog-and-selection.md
  • Acceptance: C
  • Milestone: M2
  • Status: Component-covered by pnpm test:e2e:composer-model-selection (isolated React/Electron with the real Composer hook); full provider/session desktop journey remains Draft.

E2E-COMPOSER-narrow-controls: Composer controls adapt to a narrow chat column ​

  • Preconditions: Chat route active; a configured model is selected; the composer is rendered in both empty-home and thread-docked variants.
  • Steps: 1) Set the Composer container to 560px and inspect the combined model × reasoning trigger. 2) Set it to 480px, then 450px. 3) At each width, inspect the mode, permission, context, enhancement, and Send/Stop controls; open the model menu from the narrow trigger; repeat the visual check in the other Composer variant.
  • Expected: The toolbar remains one non-wrapping row and does not overflow its container. At 560px the reasoning label and separator yield first; at 480px the model label is further capped; at the 450px floor the combined model × reasoning trigger is a 32px icon-only control. The full selection is still available through the trigger's menu, tooltip, and accessible name. Mode and permission labels remain single-line and ellipsized, the context ring and action controls retain usable hit targets, and the single Send/Stop slot remains reachable. Home and thread-docked composers match.
  • Specs linked: 04-ux/08-component-spec.md (§11)
  • Acceptance: C (send/UI), Quality
  • Milestone: M2
  • Status: Automated on task candidate 737435248ebd32e0b2a406f93b7b245784bc4289 (base cea6e02c): pnpm test:e2e:layout 167/167 including the 450px model-chip assertion; pnpm test:e2e:composer-autocomplete and pnpm test:e2e:composer-paste passed. Source-covered by composer-responsive.test.mjs.

E2E-090: Transcript bottom reserve tracks the docked composer height ​

  • Preconditions: Chat route active; a session with a transcript that exceeds one viewport so the last message sits near the docked composer.
  • Steps: 1) Scroll the transcript to the latest message. 2) Measure the vertical gap between the last message and the top of the docked composer. 3) Type several lines into the composer so the draft grows multi-line. 4) Re-measure the gap and confirm the last message is still fully visible above the composer (not overlapped). 5) Collapse the draft back to a single line and confirm the gap shrinks back toward the tight ~16px reserve.
  • Expected: The last message sits close above the composer (a small, consistent gap) rather than far below it; the reserve follows the composer's real height via --composer-dock-height so a taller multi-line draft pushes the transcript up instead of covering it. The jump-to-latest button and the minimap stay anchored just above the composer at every draft height.
  • Specs linked: 04-ux/08-component-spec.md (§4.3 MainChat layout)
  • Acceptance: C (send/UI), Quality
  • Milestone: M2
  • Status: Draft

E2E-144: Sending a prompt keeps the transcript at the latest turn ​

  • Preconditions: Chat route active; the selected session contains enough history to overflow the transcript viewport; the transcript is at the latest message or has been scrolled upward.
  • Steps: 1) Send a prompt from the bottom of the viewport. 2) Observe the transcript from the first send state through the persisted user-message event and the first streamed row. 3) Repeat with a multi-line draft so the composer collapses when the draft clears, and again after manually scrolling upward before sending.
  • Expected: Send immediately hides the jump control and re-pins the transcript in the layout phase. The historical rows move upward only as the new turn is added; the viewport never flashes to the top of the conversation, and the new user turn plus streamed response remain visible at the bottom. The composer collapse and indicator layout clamps after send never release follow mode (no "↓ Scroll to bottom" button appears while the turn streams unless the user actually scrolled with an input device).
  • Specs linked: 04-ux/08-component-spec.md (§4.3, §4.4), 04-ux/09-interaction-patterns.md (§9.1, §10.4)
  • Acceptance: C (send/UI), Quality
  • Milestone: M2
  • Status: Draft

Workspace Open ​

E2E-012: Open a project directory ​

  • Preconditions: App running; no project open.
  • Steps: 1) Open project directory via UI. 2) Select a local folder.
  • Expected: Project path displayed; tool paths resolve relative to project root.
  • Specs linked: 03-runtime/15-workspace-ignore-rules.md
  • Acceptance: D (open project, show path)
  • Milestone: M3
  • Status: Draft

E2E-012a: Create a named project from multiple folders ​

  • Preconditions: App running; no project dialog open; at least two local folders are available, including one with a long name or path.
  • Steps:
    1. Invoke Add project from Settings → Project archive or the sidebar Projects heading and inspect the empty dialog.
    2. Add two folders with the folder picker without typing a name. Confirm the name field seeds with the first folder's name, both rows render, and the first row is marked Primary.
    3. Remove one row, check the count, and add it again.
    4. Inspect the empty and populated states in light and dark themes, including a narrow window and reduced-motion settings.
    5. Use Tab and Shift+Tab to traverse the controls. Close with Escape, then reopen and close by clicking outside; check focus after each close.
    6. Reopen, enter the name, add the folders, and create the project. Inspect the in-flight controls and the resulting active primary workspace and one grouped project entry with both roots.
    7. Start a session in the group and ask the agent to read a file using the additional root's absolute path; then try an unrelated outside path.
  • Expected: The dialog traps focus, closes on Escape or outside click while idle, and keeps the name and selected folders visible without horizontal overflow. The native picker allows multiple directories in one selection. Removing a folder updates the count and never removes another row. Create needs one folder and no typed name; an empty name field falls back to the first folder's name. On creation one logical project group receives the entered display name; its primary folder becomes the active workspace and every selected folder is retained as a group root. The Project archive shows one group row, and its sessions, shared instructions, and shared memory use the group identity. Read/Glob/Grep/ Write/Edit can use an explicitly addressed additional root only after host canonical containment; an unrelated outside path still follows the normal permission flow. The dialog is unavailable while creation is in flight and returns focus to the invoking control after close. The surface follows the shell's neutral gray theme with a 480px maximum width, 18px tokenized corners, shared dialog elevation, and the global compact type ramp. The title uses the dialog-level heading size, the name field uses the body/input size, and labels/metadata remain on the smaller global steps. The header and action row use the shared 18px dialog gutter; sections use a 16px gap. It shows one Create project title, a filled name field without duplicate placeholder copy, a compact local source chip, and a softly filled Add folder action. It does not add explanatory memory or multi-selection copy. The workspace section keeps the current local folder selection behavior while staying neutral about future remote sources. No outer stroke, section rules, footer divider, or dashed picker border appears. Spacing provides the section hierarchy; long names and paths remain contained, and scrolling content never hides the fixed action row. Both themes keep text readable and keyboard focus visible; reduced motion suppresses the control transitions. The source chip is the extension point for a future remote project source; the current flow remains local-only.
  • Specs linked: 03-runtime/01-ipc-protocol.md (§9), 04-ux/06-settings-ia.md (Project archive), 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md (§3.5)
  • Acceptance: C (project creation UI), D (multi-folder project setup), Accessibility, Localization
  • Milestone: M3
  • Status: Source-level regression covered; full UI scenario Draft

E2E-012b: Delete a project root from a multi-folder group ​

  • Preconditions: A two-root project group has sessions in both roots; one session in the root being deleted can be started and stopped.
  • Steps:
    1. Start a turn in the primary root, then request deletion of that project and confirm the action.
    2. Confirm the running-session conflict and inspect the group and both session lists.
    3. Let the turn finish, delete the primary project, and confirm again.
    4. Inspect the remaining group, its sessions, shared instructions and memory, and both directories on disk.
  • Expected: A running session blocks deletion without changing group membership or deleting data. After the session is idle, deletion removes the selected root, its sessions and project memory, promotes the first remaining root when needed, and preserves the remaining root's sessions and group-level instructions and memory. The original folders remain on disk.
  • Specs linked: 03-runtime/06-host-rpc-protocol.md (Projects), 04-ux/08-component-spec.md (§3.9)
  • Status: Draft

E2E-013: Read-only tools work in project ​

  • Preconditions: Project directory open.
  • Steps: 1) Ask agent to read a file in the project. 2) Observe result.
  • Expected: Read returns immediately within project scope. In Agent mode, Glob and Grep are available in the first Agent request without ToolSearch; Plan keeps its read/search core available from the first request. All results remain within project scope.
  • Specs linked: 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (Read/Glob/Grep work), D (tools based on project)
  • Milestone: M3
  • Status: Automated (protocol smoke: Read + Glob in sample project)

Permission Allow / Deny / Cancellation ​

E2E-014: Write/Edit/Bash triggers permission card ​

  • Preconditions: Agent mode; project open.
  • Steps: 1) Ask agent to write a file. 2) Observe permission card.
  • Expected: Permission card appears inline in the originating transcript with tool name, workspace, arguments preview, and allow/deny options. It creates no backdrop or modal and does not cover another session.
  • Specs linked: 04-ux/03-permission-ux.md, 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (Write/Edit/Bash trigger confirmation)
  • Milestone: M3
  • Status: Draft

E2E-015: Denied permission blocks execution ​

  • Preconditions: Permission card displayed.
  • Steps: 1) Click deny on permission card. 2) Observe agent response.
  • Expected: Tool not executed; agent receives denied result; no file changed.
  • Specs linked: 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (denied → not executed)
  • Milestone: M3
  • Status: Draft

E2E-016: Allowed permission executes tool ​

  • Preconditions: Permission card displayed.
  • Steps: 1) Click allow on permission card. 2) Observe agent response and UI.
  • Expected: Tool executed; result returned to model and displayed in UI; file modified.
  • Specs linked: 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (allowed → result returned)
  • Milestone: M3
  • Status: Draft

E2E-017: Local permission approval has no automatic deadline ​

  • Preconditions: Permission card displayed; no user action.
  • Steps: 1) Leave the permission card unanswered. 2) Confirm it remains visible and the tool call remains pending. 3) Choose Deny and observe the outcome.
  • Expected: No countdown or automatic denial appears; the request remains pending until the explicit Deny action, and the tool is not executed.
  • Specs linked: 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (timeout → deny)
  • Milestone: M3
  • Status: Draft

E2E-018: Plan denies workspace mutation and plugin tools ​

  • Preconditions: Plan mode active with Auto selected and a plugin agent tool registered.
  • Steps: 1) Ask the Agent to call Write, Edit, and the plugin tool. 2) Ask it to run a Bash command that creates a marker file. 3) Repeat the Bash call with Ask selected and inspect the permission card.
  • Expected: Write, Edit, and the plugin tool are not visible and direct attempts return WRITE_DISABLED_IN_PLAN, EDIT_DISABLED_IN_PLAN, or PLUGIN_DISABLED_IN_PLAN; no file is changed by those tools. Bash runs without a confirmation under Auto and may mutate; under Ask it waits for the ordinary permission card. No Chat-mode error or command exists.
  • Specs linked: 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (Plan policy)
  • Milestone: M3
  • Status: Documented (M6; E2E execution pending)

E2E-019: Workspace-outside paths follow permission mode ​

  • Preconditions: Agent or Plan mode; project open; a readable file exists outside both the session project and scratch roots.
  • Steps: 1) With Ask selected, ask the agent to Read the external file and observe the inline permission card. 2) Deny once and verify no content is returned. 3) Repeat and allow once; verify the tool result carries root: "external" and the canonical absolute path. 4) Switch to Auto and repeat with Grep or Glob; verify no card appears and the bounded result returns. 5) Repeat with Accept edits; verify the external read/search still asks for permission.
  • Expected: An explicit outside path never hard-fails before the user can decide. Ask and Accept edits request permission; Auto executes. Denial, timeout, or cancellation returns TOOL_DENIED and performs no operation.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/15-workspace-ignore-rules.md, 04-ux/03-permission-ux.md
  • Acceptance: E (workspace-outside permission policy)
  • Milestone: M3
  • Status: Automated (host-core protocol/unit coverage; desktop journey pending)

E2E-019e: Bounded search parameters stay portable across platforms ​

  • Preconditions: Agent or Plan mode; project open; the host tool catalog is available on macOS, Linux, or Windows.
  • Steps: 1) In Agent mode, inspect Glob/Grep in the first request without ToolSearch; confirm Plan also exposes both from its first request. 2) Search with workspace-relative path, include, headLimit, and outputMode: "filesWithMatches" or "count", first with a directory and then one explicit file. 3) Call Read with a directory and follow its structured Glob suggestion. 4) Repeat with the platform's native shell selected, without changing the tool arguments.
  • Expected: The schemas expose the same bounded search controls on every platform; Read declares file-only input, Glob declares directory input, and Grep accepts a file or directory. filesWithMatches is accepted as the canonical output mode. A directory Read returns INVALID_ARGUMENT with suggestedTool=Glob and bounded args; the corrected call succeeds. Search results use workspace-relative paths inside the project and absolute paths only for approved external locations. No shell-specific path syntax is required, workspace-relative paths use / for platform separators while literal backslashes in POSIX filenames remain intact, and oversized results remain bounded.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/16-tool-result-limits.md, ADR 0057, ADR 0069
  • Acceptance: E (bounded cross-platform search)
  • Milestone: M5
  • Status: Unit-covered (host-core and agent-runtime); live multi-platform protocol capture pending. The workspace-relative path expectation is covered on Windows by relative_display, which must canonicalize the workspace root with the resolver's own spelling (simple_canonicalize) — std Path::canonicalize keeps the \\?\ prefix there and silently degrades every label to an absolute path.

E2E-019a: Scratch-directory writes stay out of the workspace (D114) ​

  • Preconditions: Agent mode; project open; session started.
  • Steps: 1) Ask the agent to produce a temporary/intermediate file (e.g. a one-off script). 2) Observe where it writes and whether a permission card appears. 3) Check git status and the work-panel state. 4) On Windows with Git Bash selected, print $PI_SCRATCH_DIR and write a file through that path. 5) Delete the session and check <data_dir>/scratch/.
  • Expected: The file lands under <data_dir>/scratch/<sessionId>/ without a permission card; project git status stays clean; no file or Review artifact tab opens for the scratch write; Git Bash receives a directly usable forward-slash path matching the prompt, while PowerShell and cmd retain native path spelling; deleting the session removes the scratch directory.
  • Specs linked: 03-runtime/03-tools-and-permissions.md §4b, 03-runtime/04-data-storage.md
  • Acceptance: E (temp files isolated from workspace)
  • Milestone: M5
  • Status: Partially automated (host-core unit tests: dual-root resolve, scratch write/read, Windows Git Bash PI_SCRATCH_DIR formatting, sweep)

E2E-019b: Scratch containment matches workspace defenses (D114) ​

  • Preconditions: Agent mode; project open.
  • Steps: 1) Attempt Write with .. traversal from the scratch root. 2) Attempt Write through a symlink planted inside scratch pointing outside. 3) Attempt the same Write calls in Plan.
  • Expected: Both escapes return PATH_OUTSIDE_WORKSPACE; Plan returns WRITE_DISABLED_IN_PLAN before any scratch path can make Write available.
  • Specs linked: 03-runtime/03-tools-and-permissions.md §4b
  • Acceptance: E (scratch root cannot be escaped)
  • Milestone: M5
  • Status: Automated (host-core unit tests)

E2E-019c: Permission modes govern high-risk approval (D115/D132) ​

  • Preconditions: Agent mode; project open; global default ask.
  • Steps: 1) With a newly inherited session and global default Ask every time, open the composer menu — expect Ask every time to be selected with no global-default/inherit label — then ask the Agent to write a workspace file and expect a permission card. 2) Switch the session chip to Accept edits; repeat — expect no card for Write/Edit but still a card for Bash. 3) Switch to Auto — expect no card for Bash either. 4) Create another inherited session after setting the global default to Accept edits in Settings — expect the composer chip and menu selection to display Accept edits directly and Write/Edit to be auto-allowed. 5) Switch the session to Plan, then Goal, with Auto set — expect Write/Edit/plugin denied but Bash allowed without confirmation.
  • Expected: Effective mode = session override → global default → ask; Plan and Goal Write/Edit/plugin hard denies outrank every mode while their Bash follows the selected mode; the composer chip and menu always display the effective mode without default/inherit provenance.
  • Specs linked: 03-runtime/03-tools-and-permissions.md §6, 03-runtime/04-data-storage.md, 08-meta/decisions-log.md (D115/D132)
  • Acceptance: E (permission modes resolve and enforce host-side)
  • Milestone: M5
  • Status: Partially automated (host-core unit tests: evaluate matrix, Plan policy precedence, session grants under ask; renderer source test: effective-only composer options and selection)

E2E-019d: Bash tool sees the user's login-shell toolchain (D181) ​

  • Preconditions: Agent mode; project open; the OS user has a login shell (default on macOS) whose profile exports at least one tool not on the app's minimal GUI PATH (e.g. nvm/Homebrew).
  • Steps: 1) Ask the agent to print $PATH and run a toolchain check such as command -v node && node -v. 2) Compare with the PATH a fresh terminal shows for the same user. 3) Optionally remove ~/.bash_profile temporarily and repeat on a machine where only .zshrc initializes the toolchain.
  • Expected: The Bash tool resolves tools the user's own login shell exports (nvm, pnpm, Homebrew) even though commands run through bash; the probed login PATH is a subset of the effective child PATH (bash profile may prepend/dedupe). A missing or wedged user shell degrades to the host PATH without failing the tool.
  • Specs linked: 03-runtime/03-tools-and-permissions.md §5, 08-meta/decisions-log.md (D181), ADR 0045
  • Acceptance: E (user toolchain visible in Bash tool)
  • Milestone: M5
  • Status: Automated (host-core unit tests: login-PATH probe + child-PATH injection)

E2E-MCP-stdio-login-path: Market stdio MCP finds uvx/npx after a GUI launch (issue #571) ​

  • Preconditions: macOS; the app was started from Finder/Dock so process.env.PATH is the GUI default; uvx or npx exists on the user's login-shell PATH.
  • Steps: 1) Install Fetch from the MCP market (uvx mcp-server-fetch). 2) Test connection. 3) Repeat with an npx catalog entry.
  • Expected: The stdio child env PATH includes the login-shell PATH (Homebrew / nvm / ~/.local/bin) ahead of the GUI PATH, so spawn succeeds. A truly missing binary reports command not found: uvx rather than spawn uvx ENOENT. Windows keeps the inherited PATH.
  • Specs linked: ADR 0045, D600, 03-runtime/03-tools-and-permissions.md
  • Acceptance: Quality
  • Milestone: M6+
  • Status: Automated (apps/desktop/test/user-login-path.test.mjs, apps/desktop/test/plugin-mcp.test.mjs)

E2E-MCP-stdio-windows-npx: Official Node and fnm both start npx MCP (issue #789) ​

  • Preconditions: Windows; Node is either the official Program Files\nodejs install (node.exe + npx.cmd + npx-cli.js on PATH) or fnm-managed and not on the GUI PATH.
  • Steps: 1) Add Memory from the MCP market (npx -y @modelcontextprotocol/server-memory). 2) Test connection. 3) Repeat with a user-typed npx server.
  • Expected: Official Node rewrites to node.exe + npx-cli.js and handshakes. fnm is discovered from %LOCALAPPDATA%\fnm\aliases\default when PATH has no node. Remaining .cmd shims start through cmd.exe /d /s /c with quoted literal args, never shell: true. A Git-Bash extensionless npx next to npx.cmd is not chosen. Missing binaries still report command not found: npx.
  • Specs linked: ADR 0038, D624, 07-plugins/04-plugin-security.md
  • Acceptance: Quality
  • Milestone: M6+
  • Status: Automated (apps/desktop/test/mcp-stdio-launch.test.mjs)

Session Persistence ​

E2E-020: Session survives restart ​

  • Preconditions: Session with message history exists.
  • Steps: 1) Quit app. 2) Relaunch. 3) Open session list.
  • Expected: Previous session appears; messages recoverable.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/10-session-state-machine.md
  • Acceptance: F (session survives restart)
  • Milestone: M2
  • Status: Automated (protocol smoke: host-level persistence; full restart lane manual)

E2E-021: Delete session works ​

  • Preconditions: A session exists near the bottom of the expanded Sidebar.
  • Steps: 1) Open its overflow menu. 2) Verify the menu and Delete action stay inside the window. 3) Repeat in a short window and scroll the menu to its last action. 4) Delete the session and observe the session list.
  • Expected: The menu stays within the viewport, the last action is reachable, and the deleted session disappears from the list with its data removed.
  • Specs linked: 03-runtime/04-data-storage.md, 04-ux/09-interaction-patterns.md
  • Acceptance: F (delete session)
  • Milestone: M2
  • Status: Unit-covered (sidebar-floating-menu.test.mjs, sidebar-navigation.test.mjs); rendered viewport scenario Draft

E2E-021a: Rename session title persists without changing activity ​

  • Preconditions: A project-scoped session and a path-less session exist; at least one session has transcript history and one still has the default title. The standalone Session Titles plugin is enabled with session.autoTitle, agent.complete, and models.list, a deterministic test model, a custom prompt template, and thinking set to off.
  • Steps: 1) Open a Sidebar session overflow menu or right-click a session row and choose Rename. 2) Enter a title with surrounding whitespace and save. 3) Verify the title in the Sidebar, topbar, Project archive, and Search. 4) Restart the app and verify the title again. 5) Try an empty and an over-80-Unicode-code-point title. 6) Open Session Titles: Configure, set the prompt to return a fixed test title, choose the deterministic test model, and save. 7) Send the first prompt in the default-title session. 8) Confirm the Sidebar and topbar immediately show the normalized 48-character prompt fallback, then wait for session:turnEnded and the plugin completion, which replaces that fallback with the deterministic test title. 9) Manually rename that session and complete another turn. 10) Send the first prompt in another default-title session with the plugin disabled.
  • Expected: The saved title is trimmed, displayed across every current session-summary surface, and persists after restart. The session stays in the same project or Temporary group, its transcript/message count and recent-activity ordering do not change, and historical notification title snapshots are unchanged. Empty and overlong values are rejected. Sending the first prompt immediately persists the normalized 48-character prompt fallback as a still-replaceable automatic title. With the plugin enabled, that fallback is replaced by the deterministic test title after the completed turn; after a manual rename, a later plugin completion cannot replace it. A session without the plugin keeps its prompt fallback, and a second prompt never changes it.
  • Upgrade regression: In an isolated v22 fixture, manually rename a session to lowercase new task before opening it in the current build. After upgrade, the title remains new task with source legacy; first-prompt derivation and plugin title writes cannot replace it. New sessions still derive titles. Host regression: v22_database_migrates_session_title_sources_without_losing_titles.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/02-agent-runtime.md, 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, ADR 0143, ADR 0323
  • Acceptance: F (session metadata persistence), Quality (localized task management)
  • Milestone: M2
  • Status: Draft (run only in a capable environment when this surface changes)

E2E-021b: Session context menus expose hover submenus ​

  • Preconditions: A session exists, including one whose scratch directory has not been created yet. The session can create a branch; worktree actions are available when it belongs to a Git project.
  • Steps: 1) Open the session overflow menu or right-click its row. Hover Git and verify its second-level actions appear; click/tap Git to open it without hover. Use ArrowRight to enter, ArrowLeft to return, and Escape to close only the child menu. 2) Open Copy and choose Copy conversation ID, Copy session path, and Copy conversation link in turn; paste each clipboard value. 3) Enable developer mode, reopen the menu, and choose Open session path. 4) Disable developer mode and verify Open session path is absent while Copy remains.
  • Expected: Git and Copy are menuitem/haspopup triggers; hover and click reveal their child menus without dismissing the root. ArrowRight enters a child, ArrowLeft and the first Escape return to its trigger, and a second Escape closes the root. Child actions remain keyboard reachable and execute once. The ID equals the session id, the path equals the Host-resolved scratch path for local sessions, and the link is the canonical conversation link. Remote sessions omit Copy session path. The system file manager opens <data_dir>/scratch/<sessionId>/ when the developer-only Open session path action is chosen. The renderer never sends a filesystem path to Main.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/08-component-spec.md, 04-ux/06-settings-ia.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (sessions), Quality (nested menu interaction)
  • Milestone: M2
  • Status: Component interaction automated (worktree-menu-user-path.test.mjs, sidebar-menu-panel.test.mjs, sidebar-navigation.test.mjs); full Sidebar desktop journey remains Draft

E2E-021c: Bottom-edge project menus stay beside their anchor ​

  • Preconditions: The expanded Sidebar shows a retained project in its lowest visible row, with insufficient room below it for the project menu.
  • Steps: 1) Open the project's Copy submenu; copy the project name and path and verify each pasted value. 2) Right-click the bottom project row and inspect the root menu. 3) Close it, open the row's overflow button, and hover Copy and Git. Verify both child menus and all actions stay inside the viewport.
  • Expected: Both root-menu entry points place the menu immediately above the row when it cannot fit below; otherwise they open below. Copy yields the displayed project name and path. Child submenus remain attached to their trigger, flip sides as needed, and are neither clipped nor displaced.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: Quality (sidebar interaction)
  • Milestone: M2
  • Status: Automated (node scripts/e2e-three-column-layout.mjs --sidebar-only, sidebar-floating-menu.test.mjs, sidebar-navigation.test.mjs)

E2E-036: Settings session-import destination retired ​

  • Expected: Settings has no session-import tab or scan panel. Plugin session import remains available through the host-owned API and is covered by E2E-214 / E2E-215 / E2E-216. The legacy local-session scanner remains covered by its importer unit tests.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, 07-plugins/03-plugin-api.md, ADR 0319 / D645
  • Status: Retired by D645; Settings UI removed

E2E-037: Core session-import project workflow retired ​

  • Expected: Project binding and renderer refresh for imported sessions are verified through plugin-owned session import in E2E-216. Settings no longer invokes the legacy core session scanner/import path. Plugin ownership, durable project rows, and project archive behavior remain unchanged.
  • Specs linked: 03-runtime/04-data-storage.md, 04-ux/01-ui-ia.md, 07-plugins/03-plugin-api.md, ADR 0319 / D645
  • Status: Superseded by E2E-214 / E2E-216

E2E-038: Settings owns the project archive destination ​

  • Preconditions: App running with developer mode enabled, at least one configured provider, one retained project, and one archived project.
  • Steps:
    1. Open Settings and inspect the complete settings rail.
    2. Open General and change the theme in its Appearance card using the searchable theme picker.
    3. Open AI and inspect the Permissions and Defaults cards, including the Command shell row; confirm Context management has no settings card.
    4. Open Shortcuts and inspect the Keyboard shortcuts card.
    5. Open Instructions and save global instructions.
    6. Open Models and inspect the provider studio and inline import action.
    7. Open Skills and MCP and confirm each has its own external-import action.
    8. Open Projects and Info in order.
    9. Search Settings for "project" or "archive".
    10. In Projects, compare each group strip's count with its rendered rows.
    11. Switch the sort control from Recent to Name.
    12. Search for a known session title, confirm the owning project is selected and its inspector lists matching sessions, then reveal more than eight sessions; clear the search with the clear affordance.
    13. Open the inspector menu, dismiss it with Escape and with an outside press.
    14. Restore the archived project, then activate it.
    15. Archive one project session, open it from Projects, and return to Projects.
    16. Return to the app shell and open Plugins.
  • Expected: The rail contains exactly General, AI, Shortcuts, Instructions, Models, Skills, MCP, Subagents, Projects, Cloud sync, Remote Hosts, and Info in that order, each with its semantic Lucide icon (Sliders / Sparkles / Keyboard / FileText / Bot / BookOpen / Server / Bot / Archive / CloudDownload / Globe / Info). The flat directory is visually grouped under four muted, non-interactive headings — Preferences / 偏好 for General, AI, and Shortcuts; Agent / 智能体 for Instructions and Models; Workspace / 工作区 for Projects; About / 关于 for Info — with whitespace and no divider lines between groups; searching keeps the destination results flat and hides empty groups together with their headings. Appearance remains in General, while Permissions, Defaults, and the Command shell row live under 全局 AI; an available selected shell is represented by the selector without a duplicate Configured status, while default, fallback, and no-effective-shell states remain explicit; Context management has no settings card; Keyboard shortcuts and global instructions have their own destinations; Developer lives under Info; Projects shows active, closed, and archived durable rows without a visibility toggle, grouping them under the always-visible Pinned / All projects / Archived strips (D168/D267/D455) with per-section counts in a one-column workbench. The destination renders no hero block and no page-level counter run: the intro is one quiet description line, and each group strip's count agrees with its rendered rows; a click selects a row without leaving Settings; sorting by Name reorders rows inside every section without hiding any; search matches project fields and session titles and reports a match count, a session-title result selects its owning project, lists sessions in the inspector by latest activity with relative update times, and reveals history in batches of eight; clearing the search restores the complete index. The inspector menu closes on Escape and on an outside press. Bootstrap completion and background refreshes do not return Settings or Extensions to the chat home; the destination changes only after an explicit navigation action. Restore keeps the archive open and activation returns to chat with the restored project retained in the sidebar. Opening an archived session succeeds before clearing its archived state, returns to chat with that session selected, and makes it visible in the project sidebar; returning to Project archive no longer shows that session as archived. The home sidebar and global page results have no standalone Projects destination; Settings search finds Projects; Plugins remains an independent app-shell destination. Cloud sync and Remote Hosts appear in packaged Settings with Experimental badges on their navigation rows and page titles; see 04-ux/06-settings-ia.md.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/01-ui-ia.md, 03-runtime/11-provider-model-system.md
  • Acceptance: B (model configuration), F (project persistence)
  • Milestone: M4
  • Status: Unit-covered (settings-project-archive.test.mjs, project-archive.test.mjs, sidebar-navigation.test.mjs); rendered scenario Draft

E2E-091: Appearance card selects searchable theme and language pickers ​

  • Preconditions: App running on macOS; the harness can exercise English, Simplified Chinese, Traditional Chinese, Turkish, German, Spanish, French, Korean, and Brazilian Portuguese system locales.
  • Steps:
    1. Open Settings → General.
    2. In the Appearance card, open the Theme picker. Confirm System, Light, and Dark are pinned at the top; select Dark and confirm the trigger shows Dark and the UI switches to dark.
    3. Select Light and confirm the UI switches to light.
    4. In the Language row, open the searchable picker. Confirm Auto is pinned at the top with the detected native name and that English, 简体中文, 繁體中文, Türkçe, Deutsch, Español, Français, 한국어, and Português (Brasil) are listed by native name. With Simplified Chinese selected as the OS locale, selecting Auto applies Simplified Chinese; with Traditional Chinese selected, Auto applies Traditional Chinese; with Brazilian Portuguese selected, Auto applies pt-BR.
    5. Select English, 简体中文, 繁體中文, Türkçe, Deutsch, Español, Français, 한국어, and Português (Brasil) in turn and confirm shell chrome switches to each locale without a reload. Confirm zh-Hant and zh-HK resolve to 繁體中文, de-DE to Deutsch, es-MX to Español, fr-CA to Français, ko-KR to 한국어, and pt, pt-BR, pt_BR, and pt-PT to pt-BR.
    6. Type a native name or English name into the language search and confirm unmatched locales disappear. Type a theme name into the theme search and confirm unmatched options disappear.
  • Expected: Theme and Language are searchable picker rows (not a card grid and not a native select); each closed trigger sizes to the current label, capped by the settings control column, without overflowing the row. Theme lists System, Light, and Dark, then any plugin themes after a divider. Auto resolves the OS locale through the main process (app.getLocale()), passes it safely through the sandboxed preload bridge, and reflects the detected native name inline in the menu; zh-TW, Turkish, German, Spanish, French, Korean, and Brazilian Portuguese are complete shell catalogs, including release-note copy; switching options updates the live UI without a reload.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/02-i18n-english-first.md
  • Acceptance: A (core shell), H (localization)
  • Milestone: M4
  • Status: Partially automated in scripts/e2e-settings-scroll.mjs for pt-BR picker selection and live copy; the full locale list and OS Auto-resolution matrix remain documented.

E2E-091a: Theme changes keep the Windows frameless background aligned ​

  • Preconditions: App running windowed on Windows with the OS in light mode.
  • Steps: 1) Select Dark in Settings → General → Appearance. 2) Inspect the lower-left, lower-right, and resize edges while the shell settles and while collapsing/expanding the sidebar. 3) Select Light and repeat. 4) Switch the OS color preference and select System; repeat after the app resolves the system theme.
  • Expected: The native BrowserWindow background follows the resolved application theme (#181818 for dark and #ffffff for light), so no white strip appears around the frameless renderer during theme changes or shell animations. macOS keeps its transparent vibrancy behavior unchanged.
  • Specs linked: 04-ux/06-settings-ia.md, 02-architecture/01-architecture.md
  • Acceptance: A (core shell)
  • Milestone: M5
  • Status: Documented; native Windows validation pending

E2E-SETTINGS-ai-tab-pickers-use-in-app-menus ​

  • Preconditions: App running with a host that reports at least one configured command shell and at least one catalog shell that is unavailable on this platform.
  • Steps: 1) Open Settings → General and open the Theme and Language pickers; note the pill trigger and the opened surface. 2) Open 全局 AI. 3) Open the Permissions card's permission-mode control and select ask, accept-edits, and auto in turn. 4) Open the Defaults card's Command shell control; inspect the unavailable entries and select an available shell. 5) Dismiss each open menu with Escape and then with an outside press. 6) Close Settings, reopen it, and read both rows.
  • Expected: Both rows open the same anchored menu surface as the Appearance pickers — the app-drawn frame with the shared radius, elevation, border, and theme tokens, a check mark on the current option, and a hover/keyboard highlight — and never a platform-drawn <select> popup. Closed triggers size to the current label, capped by the settings control column. Unavailable shells stay listed with their suffix, are not selectable, and cannot become the current value. Escape and an outside press close the menu and restore focus to the trigger; arrow keys move between selectable options with wraparound. The selected permission mode and command shell persist across closing and reopening Settings, and the selected shell stays the only configured-state indicator.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/09-interaction-patterns.md
  • Acceptance: A (core shell)
  • Milestone: M4
  • Status: Documented

E2E-039: Settings titlebar drag moves the window ​

  • Preconditions: App running windowed on macOS with Settings open.
  • Steps: 1) Record the window position. 2) Drag the empty 46px band above the settings rail. 3) Drag the same band above the content pane. 4) Use Back, search, Project archive, and navigation controls. 5) In light and dark themes, sample the top row's color above the rail and above the content pane.
  • Expected: Either top-band drag moves the native window; Back, search, and navigation remain interactive and never initiate a window drag. The top row above the rail matches the rail surface (#f4f4f4 light / #000 dark) and the top row above the content pane matches the primary surface, so the band never renders a mismatched color over the rail.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/01-ui-ia.md
  • Acceptance: Quality (key operations feel polished)
  • Milestone: M5
  • Status: Draft

E2E-043: Settings content follows window width ​

  • Preconditions: App running windowed on macOS with Settings open.
  • Steps: 1) Open General at the default window width and record the content-card width. 2) Expand the window to 1600px wide. 3) Open Models, Skills, MCP, and Projects. 4) Shrink the window to the supported 800px minimum.
  • Expected: The right-side content cards expand and contract with the available pane at every tested width; the 275px rail and pane gutters remain stable; controls remain visible without clipping or horizontal page scrolling.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/07-ui-design-system.md
  • Acceptance: Quality (key operations feel polished)
  • Milestone: M5
  • Status: Unit-covered (settings-responsive-layout.test.mjs); scenario Documented

E2E-040: Nested tool activity survives transcript reload ​

  • Preconditions: Provider configured; project open; a turn can contain progress text, multiple search/tool/thinking items, and a failed or denied tool.
  • Steps: 1) Stream progress paragraph A, a multi-item search segment, progress paragraph B, and a multi-item command segment with adjacent Bash rows. 2) Inspect the open active group; manually close it while more output arrives. 3) Complete the turn and confirm exactly one process disclosure owns every pre-answer row; close it and confirm the groups and run rows close with it, then reopen and confirm the group choice returns. 4) Open one completed group and one item, copy its output, close and reopen the group, then open a sibling group independently. 5) Switch Transcript display across Flat, Commands first, and Fully folded and inspect the grouping, group boundaries, where the run rows render, and the disclosure count. 6) Repeat with a singleton item and with the last activity group's literal final item set to a tool/search, thinking, failed tool, and denied tool. 7) Toggle Show thinking off and on and inspect the active-thinking, failure/recovery and completed states. 8) Remount rows within the retained pane, then reload the renderer and reopen the session.
  • Expected: A turn renders exactly one process disclosure containing every pre-answer row, the Bash/run rows included; the trailing answer, assistant errors and stopped trailing text stay outside it, as do pending actions. The active group stays open while the turn runs. After completion, ordinary groups with at most four visible items and Task topology groups remain open; larger ordinary groups start closed. Closing the turn's disclosure hides groups and run rows under it, and reopening restores their defaults and explicit user choices. In Fully folded (the default), run rows stay inside their groups and only ordinary assistant progress text splits a group. In Commands first, run rows render as their own rows beside the groups, adjacent calls stay separate, a row's detail stays closed until opened, and a normal assistant progress message or a command ends a group. In Flat, no nested group renders and the rows keep transcript order inside the outer disclosure. Every mode keeps the disclosure count at one. A singleton has no group wrapper. No item payload opens itself except the display mode's Thinking default: a tool/search row stays a header until the user opens it, while a final thinking item never selects an earlier tool. The header reports Running, Finished, Stopped, or Failed with failure and stop outranking a normal finish, reads Thinking while the live step is reasoning, never repeats the current command, and omits the duration when none was recorded. When Show thinking is off, no completed reasoning row renders; only the live TextShimmer Thinking indicator remains while reasoning streams, and turning it back on restores the completed rows. Parent, child and sibling choices are independent; closing/reopening a parent preserves descendants, and streaming/completion does not override user-owned choices. Retained-pane remounts preserve choices; renderer restart reapplies defaults while tool names, arguments, results and statuses remain restored. Group headers show bounded running and issue summaries without marking the whole turn failed.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (chat stream), E (tools), F (persistence)
  • Milestone: M3
  • Status: Draft. Unit and style checks cover the display modes, Thinking defaults and header rules; this scenario remains the intended manual transcript-reload behavior.

E2E-041: Conversation minimap navigates long transcripts ​

  • Preconditions: A session contains enough user and assistant turns to scroll beyond one viewport and densely fill the minimap, including one AI response emitted as multiple assistant fragments around tool activity; a second session has at least two eligible turn markers that still fit in one viewport.
  • Steps: 1) Open the long session. 2) Scroll through the transcript and observe the active minimap marker. 3) Hover a marker and inspect its preview. 4) Use keyboard focus to reach another marker. 5) Activate a marker. 6) Open a session with fewer than two eligible turn markers. 7) Open the multi-message session that still fits one viewport. 8) Resize the long session window taller until content no longer overflows, then shorter again. 9) At a short window height, inspect and activate the first and last markers near the minimap's vertical bounds, including from the titlebar-facing side.
  • Expected: The rail contains one marker per visible user turn and one per AI response. Multiple assistant fragments between two user messages share a single marker and combined bounded preview, while tool-only rows create no marker and do not split the response. The marker near the upper-third reading anchor exposes aria-current; hover and focus show the same localized sender and preview; nearby markers magnify horizontally without shifting the stack; activation smoothly scrolls to the first contentful message in that response; the rail is absent when fewer than two eligible markers exist or when content does not overflow one viewport; the rail reappears once overflow returns after a resize. Dense markers remain centered in the unobstructed span between the 46px titlebar and docked composer, compress uniformly, and remain interactive without entering the native window drag region. The empty-home and docked composer use the same horizontal width envelope, and the composer does not shrink when the minimap appears.
  • Specs linked: 04-ux/08-component-spec.md
  • Acceptance: C (chat stream), Quality (keyboard and long-thread navigation)
  • Milestone: M3
  • Status: Draft

E2E-042: Pre-v7 storage archives via breaking reset; transcripts live in session files ​

  • Preconditions: A fixture data directory contains a pi.sqlite whose PRAGMA user_version is between 1 and 6 (pre-D119 content-in-DB schema) with representative rows.
  • Steps: 1) Start host-core against the fixture. 2) Create a session and append messages through host RPC. 3) Stop and restart host-core. 4) Reload the session through RPC and inspect the data directory.
  • Expected: Host-core renames the legacy file to exactly one pi.sqlite.v6.bak, bootstraps a fresh schema-v7 database (index-only messages), writes sessions/<id>.jsonl with a session-header line plus one line per message, reloads the transcript from the file after restart with identical logical results, and deleting the session removes both the index rows and the session files. No Electron-owned persistence file is authoritative.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0014
  • Acceptance: F (persistence), H (reset failures are diagnosable)
  • Milestone: M2
  • Status: Unit-covered (db::tests::archives_pre_v7_database_and_starts_fresh, sessions::tests::transcript_survives_reopen_from_file, sessions::tests::delete_session_removes_transcript_files); full fixture scenario Draft

E2E-252: Session and folder drag/drop across projects ​

  • Preconditions: Two projects are open in the sidebar; one holds an idle session, and one session somewhere is running a turn.
  • Steps: 1) Drag the idle session row onto the other project group and drop it. 2) Reopen that session and read its transcript. 3) Attempt to drag the running session and, separately, open its session menu. 4) Drop a folder onto the projects list. 5) Drop a folder on the composer.
  • Expected: The dragged idle session lists under the target project with its transcript, attachments, and tasks unchanged, and the move survives a restart because the session's project membership is persisted. The running session is not draggable, its context menu has no project-move list, and a move issued after a turn starts is rejected as busy rather than rebinding the agent. Dropping a folder on the projects list adds or switches to that project without creating a duplicate row, and a non-folder drop explains that a folder is required. Dropping a folder on the composer offers an explicit choice and never attaches directory contents: "Open as project" opens the project, and "Reference folder" inserts the literal path. The dragged row paints at reduced opacity and the eligible group shows an accent outline.
  • Specs linked: 04-ux/09-interaction-patterns.md §8, 03-runtime/06-host-rpc-protocol.md (session.moveProject)
  • Acceptance: D (project membership and workspace switching), Quality (drag/drop feedback and keyboard fallback)
  • Milestone: M5
  • Status: Documented; source-contract covered (session-project-move.test.mjs); automation pending

Plugin Load / Command / Disable ​

E2E-022: Load local plugin ​

  • Preconditions: App running; sample plugin available at local path.
  • Steps: 1) Open Extensions from the sidebar footer Plugins icon. 2) Choose Load local plugin from the header overflow menu. 3) Enable it with the row switch.
  • Expected: Plugin loads; manifest validated; contributions registered; the row appears under Active with a Local tag.
  • Specs linked: 07-plugins/01-plugin-system.md, 07-plugins/05-plugin-lifecycle.md
  • Acceptance: G (load local plugin)
  • Milestone: M4
  • Status: Automated (protocol smoke: plugins.loadDev)

E2E-022A: Create a plugin from a template ​

  • Preconditions: App running; an empty folder available.
  • Steps: 1) Open Extensions. 2) Choose New plugin from template in the header overflow menu (or use the empty-state button). 3) Pick each of the four templates in turn and read its description. 4) Choose the folder. 5) Cancel the folder picker on a second attempt.
  • Expected: The picker lists exactly panel-basic, agent-tool-basic, skill-pack, full-demo, each named and described in the active locale; choosing a folder writes the template files, loads the plugin as a development plugin, refreshes the list, then opens that folder as the active project — the app lands on chat with the new folder as the workspace, it appears in the sidebar project list, and the toast reads "<name> created, loaded, and opened for development"; the plugin's contributions are immediately usable and the built-in plugin-development skill is active in the new workspace. That skill teaches the current global pi API (onLoad() plus pi.commands.register) and the fixed window.pluginBridge boundary, and does not teach the retired onLoad(pi) / pi.registerCommand shape. A canceled folder picker changes nothing and reports no error.
  • Specs linked: 07-plugins/10-plugin-devex.md, ../../plugin-development.md, ADR 0039
  • Acceptance: G (create plugin from template)
  • Milestone: Post-MVP
  • Status: Automated in part (apps/desktop/test/plugin-template-action.test.mjs: channel, template-id parity with the devkit, locale coverage, canceled-pick ordering, project activation); UI walk-through Documented

E2E-022B: Development plugin hot reload ​

  • Preconditions: A plugin loaded from a local folder and enabled.
  • Steps: 1) Edit main.js to change a command title and save. 2) Save several files at once. 3) Introduce a syntax error and save. 4) Fix the error and save. 5) Add a new permission to manifest.json and save. 6) Open the development-plugin card's More actions menu and choose Reload. 7) Edit again and restart the app.
  • Expected: The single edit reloads the plugin without re-picking the folder and the command palette shows the new title; a save burst produces one reload, and writes under dist/ or node_modules/ produce none; the syntax error reports a reload failure without crashing the app, and the fixing save recovers the plugin; the added permission refuses automatic reload with PERMISSION_DENIED, while the explicit Reload action loads the registered folder, refreshes the permission ceiling, and reports success; a later edit uses the refreshed ceiling, and after a restart the folder is still watched.
  • Specs linked: 07-plugins/10-plugin-devex.md §7, 07-plugins/13-plugin-permissions-matrix.md, ADR 0039, ADR 0075
  • Acceptance: G (hot reload), D (permissions cannot widen without review)
  • Milestone: Post-MVP
  • Status: Automated in part (apps/desktop/test/plugin-hot-reload.test.mjs: debounce, ignore list, permission ceiling, recovery, teardown); manual edit loop Documented

E2E-022C: Check, pack, install round-trip ​

  • Preconditions: A scaffolded plugin directory.
  • Steps: 1) pnpm pi-plugin check <dir>. 2) Delete the file named by main and run check again; repeat with the file named by manifest.renderer in a plugin that declares one. 3) Restore it, declare contributes.skills without agent.prompt.inject, and run check again. 4) pnpm pi-plugin pack <dir>. 5) Install the resulting .piplug from the plugins page. 6) Ask the agent to run PluginCheck and PluginPack on the same directory.
  • Expected: A scaffolded plugin checks clean and reports its file count and size; the missing main or manifest.renderer entry is an error that blocks pack; the inert-skills case is a warning that does not block; pack writes dist/<id>-<version>.piplug with store-only entries and prints its sha256; the package installs through the normal permission review and appears under Active; the agent tools produce the same verdicts and refuse any directory outside the session workspace.
  • Specs linked: 07-plugins/10-plugin-devex.md §5–§6, 07-plugins/06-plugin-packaging.md, ADR 0039
  • Acceptance: G (local packaging round-trip)
  • Milestone: Post-MVP
  • Status: Automated in part (packages/plugin-devkit vitest: scaffold→check→pack per template, store-method headers, every check rule, and a parity test that pins the permission.high-risk list to the permissions matrix's high rows); install step Documented

E2E-023: Plugin command in global search and executes ​

  • Preconditions: Plugin loaded and enabled.
  • Steps: 1) Open global search (Cmd/Ctrl+K or Cmd/Ctrl+Shift+P). 2) Find the plugin command under the Commands section. 3) Execute.
  • Expected: Command appears in global search results; execution produces expected result.
  • Specs linked: 07-plugins/09-plugin-command-palette.md
  • Acceptance: G (plugin command appears and executes)
  • Milestone: M4
  • Status: Draft

E2E-024: Plugin registers and calls agent tool ​

  • Preconditions: Plugin loaded; plugin declares an agent tool.
  • Steps: 1) Ask agent to use the plugin's tool. 2) Observe permission card if required. 3) Allow.
  • Expected: Tool registered with forced prefix (plugin_<id>_<name>); call succeeds.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md
  • Acceptance: G (plugin agent tool)
  • Milestone: M4
  • Status: Automated (protocol smoke: dispatch roundtrip host->runner->host; in-app JS execution via PluginRuntime)

E2E-024G: Marketplace detail sheet shows README, permissions, versions ​

  • Preconditions: Official marketplace catalog available.

  • Steps: 1) Open Extensions → Marketplace. 2) Open details for demo.workspace-summary. 3) Inspect README / risk-grouped permissions / version rows. 4) Pick a version and install after permission review. 5) Dismiss the sheet with Escape and by clicking the scrim.

  • Expected: Detail sheet loads via market.getDetail; README, safety notes, and per-risk permission explanations render; the picked version drives the sticky install action; the sheet mounts on the viewport overlay host above the expanded sidebar; Escape and scrim both close the sheet without closing the permission dialog underneath.

  • Specs linked: 07-plugins/07-plugin-marketplace.md

  • Acceptance: G (marketplace detail UX)

  • Status: Documented

  • Focus regression: In light and dark themes, open a marketplace card and press Escape. Like pointer dismissal, Escape leaves no line or focus ring on the card. Then use Tab and Shift+Tab: the detail button shows a complete, unclipped ring, and Install remains separately focusable. Automated focus-state regression: node scripts/e2e-plugin-detail-focus.mjs; verify the rendered ring visually.

E2E-PLUGIN-reviews-public-reads: Public ratings and reviews stay read-only in the desktop ​

  • Preconditions: Extensions reachable with at least one installed and one marketplace plugin the centre knows, plus a second run against a centre that has no review API.
  • Steps: 1) Open Extensions → Installed and, without visiting Marketplace, confirm a published plugin's card shows a rating chip only when the centre returned a summary. 2) Open a marketplace card's detail sheet and read the ratings summary between Permissions and Versions; press "See all reviews". 3) In the drawer, filter by sentiment and switch between newest and most helpful; page with Load more. 4) Inspect the composer: it is disabled, and its one action is the link to the plugin centre page. 5) Press Escape once (only the drawer closes) and again (the sheet closes). 6) Reopen the sheet and confirm the drawer left its scroll position untouched. 7) Repeat against the centre without review support and read the summary.
  • Expected: Installed and marketplace ids are batched into one summary read that never requires a Marketplace refresh; an unavailable centre shows no fabricated zeros, and the detail summary says ratings are unavailable — while the review entry stays reachable and the drawer retries its own list, so its disabled composer and its web link (official fallback until the host reports reviewUrl) are never lost; a successful summary read replaces the ratings for the ids just requested, so a plugin the centre stops serving loses its stale rating chip; the chip rides the card's existing metadata line and adds no row; the drawer covers the mounted detail sheet, keeps its scroll position, closes on the first Escape only, returns focus to its opener (Escape claims the key with preventDefault/stopPropagation; the covered sheet is inert and Tab is trapped inside the drawer), appends pages deduplicated by review id, and drops an answer for a filter the user already left; a review's version reads as user-reported, never as an installed version; the composer accepts no input, and the only write path is the real link opened through browserOpenExternal in the system browser on a primary or middle click (right-click stays with the platform menu), with a failed open reported as a toast; no sign-in, vote, report, or review-writing UI exists in any locale.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §7, 07-plugins/14-plugin-roadmap.md
  • Acceptance: G (Extensions page) + Security
  • Status: Automated fixture (node scripts/e2e-plugin-reviews-drawer.mjs: three probes — full path with card chip, section order, paging/dedup, filter stale-drop, error+retry, link href/middle-click-only/failure toast, Tab trap + inert, Escape/focus/scroll restoration; stale-summary replacement; unavailable-summary drawer entry + retry + fallback link) plus unit-covered (plugin-reviews-logic.test.mjs, plugin-reviews-surface.test.mjs)

E2E-024F: Refresh the official marketplace catalog ​

  • Preconditions: Network available to plugins.aiuo.net.
  • Steps: 1) Open Extensions → Marketplace. 2) Use the header Refresh marketplace action. 3) Confirm the official catalog refreshes and the card grid updates.
  • Expected: The marketplace uses plugins.aiuo.net/catalog.json; the page has no source selector; the cached official catalog remains available if refresh fails.
  • Specs linked: 07-plugins/07-plugin-marketplace.md
  • Acceptance: G (remote marketplace source)
  • Status: Documented / host-core unit covered

E2E-024P: The marketplace ignores legacy source settings ​

  • Preconditions: Profiles can be seeded with legacy pluginMarketSource values github, mirror, and custom, including a custom URL. Network or a local request stub is available for the official catalog.
  • Steps: 1) Start the app with each seeded profile. 2) Open Extensions → Marketplace and refresh. 3) Inspect the catalog request. 4) Install a published plugin from the marketplace.
  • Expected: The page has no source controls; every profile requests https://plugins.aiuo.net/catalog.json and ignores the persisted source and custom URL; install uses the official platform resolve path, with the official catalog URL as the verified fallback; legacy settings remain readable and require no migration.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §2
  • Acceptance: G (remote marketplace source)
  • Status: Documented / host-core unit covered

E2E-024Z: Windows curl handles offline revocation checks and localized errors ​

  • Preconditions: Windows x64 host with curl built against Schannel. For the offline-revocation case, use a proxy route that can reach the HTTPS fixture while its certificate revocation distribution point is unavailable. For the diagnostic case, force the catalog request to fail with localized, non-UTF-8 curl output (a deterministic fake curl may emit GBK stderr and exit 35).
  • Steps: 1) Open Extensions → Marketplace. 2) Refresh the catalog and install a package through the system proxy while the revocation distribution point is offline. 3) Confirm the installed curl advertises --ssl-revoke-best-effort and inspect the verified package. 4) Force a localized curl/Schannel failure, refresh again, and inspect the error toast.
  • Expected: When the installed Schannel curl supports the option, catalog and package requests tolerate an unavailable revocation distribution point without disabling certificate verification; package size and SHA-256 checks still gate installation. A real network/TLS failure remains PLUGIN_NETWORK, with localized diagnostics readable and no Unicode replacement characters.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, 03-runtime/07-process-model.md, ADR 0177
  • Acceptance: G (remote marketplace source)
  • Status: Documented; host-core unit covered; Windows proxy/TLS validation pending

E2E-024B: Marketplace install with permission review ​

  • Preconditions: App running; official market catalog available.
  • Steps: 1) Open Extensions → Marketplace. 2) Install demo.workspace-notes. 3) Read the risk-tiered permission dialog. 4) Accept high-risk permissions.
  • Expected: Permissions are grouped High / Medium / Low with plain-language explanations before any download; the review dialog mounts on the viewport overlay host above the expanded sidebar; the host refreshes marketplace metadata immediately before download so a stale UI cache cannot pair an old checksum with a current package; plugin installed from the marketplace package, checksum verified, permissions granted, panel/tools available; the installed tab and risk-grouped rows reflect the new plugin without a separate overview card row.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, 07-plugins/13-plugin-permissions-matrix.md
  • Acceptance: G (marketplace install + permission review)
  • Status: Documented / host-core covered by unit tests + protocol methods

E2E-024R: Install a center-published plugin from the official marketplace ​

  • Preconditions: A schemaVersion: 2 catalog served from the distribution repository, with one plugin carrying provenance and an approved review verdict, its package under packages/. A second fixture declares artifactBaseUrl for the mirror/enterprise case.
  • Steps: 1) Open a plugin's detail sheet. 2) Read the Source section. 3) Install the selected version through the official resolve path. 4) Repeat with a local resolve stub that returns no source, exercising the official catalog URL fallback and an artifactBaseUrl fixture.
  • Expected: Center-published plugins appear in the fixed official catalog; the detail sheet shows the source repository, commit, and builder before install; resolve mirror bytes match the platform digest, while catalog fallback bytes match the catalog digest; the installed record keeps publisher, trust tier, and source pin.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, 07-plugins/15-plugin-center.md
  • Acceptance: G (publisher-owned source distribution)
  • Status: Documented / host-core covered by unit tests

E2E-024S: Package host allowlist refuses an untrusted download ​

  • Preconditions: An isolated host-core test uses PI_DESKTOP_PLUGIN_MARKET_URL to load a catalog fixture whose version URL points at a host outside the allowlist, plus fixtures with embedded credentials and plain HTTP.
  • Steps: 1) Refresh the fixture catalog. 2) Inspect the plugin card and detail sheet. 3) Attempt an install. 4) Repeat with a private catalog whose packages sit on its own host.
  • Expected: The row does not offer an install action for an off-allowlist URL; an attempted install fails with PLUGIN_MARKET_UNTRUSTED_HOST naming the rejected host before any request leaves the machine; a credentialed URL and non-loopback plain HTTP are refused the same way; a private catalog can still serve packages from the host that served it, without widening the allowlist for third-party hosts.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, 07-plugins/04-plugin-security.md
  • Acceptance: G (marketplace download boundary)
  • Status: Documented / host-core covered by unit tests

E2E-024T: A withdrawn version is not offered, installed, or hidden ​

  • Preconditions: A catalog where the newest version is yanked with a reason, an older version is live, and the yanked version is installed locally.
  • Steps: 1) Open the plugin's detail sheet. 2) Inspect version history. 3) Select the withdrawn version. 4) Run Check for updates. 5) Run Apply automatic updates. 6) Inspect the installed row.
  • Expected: The offered latest version is the newest non-yanked one; the withdrawn version stays in history with its reason and a struck-through number; selecting it disables the install action with withdrawn copy rather than not-yet-published copy; an explicit install of it fails with PLUGIN_MARKET_YANKED and the reason; no update is offered when the newest live version is not newer than the installed one, so a downgrade is never presented as an update; the installed row is flagged as needing attention and the plugin keeps running.
  • Specs linked: 07-plugins/08-plugin-signing-updates.md
  • Acceptance: G (incident response propagation)
  • Status: Documented / host-core covered by unit tests

E2E-024U: Trust tier and host version bound are enforced by the client ​

  • Preconditions: Test runs use PI_DESKTOP_PLUGIN_MARKET_URL for a catalog claiming trust: "verified", a catalog with an unrecognised tier, and a version whose minPiDesktop exceeds the running host.
  • Steps: 1) Load the fixture catalog. 2) Inspect the card and detail sheet badges. 3) Attempt to install the version pinned to a newer host. 4) Repeat with a minPiDesktop that is a range expression rather than a version.
  • Expected: A verified claim from a non-official source renders as community with no shield; an unrecognised tier renders as unknown; the version requiring a newer host is not offered and an explicit install fails with PLUGIN_HOST_TOO_OLD naming both versions; an unparseable bound is ignored rather than making the plugin uninstallable.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, 07-plugins/15-plugin-center.md
  • Acceptance: G (trust presentation)
  • Status: Documented / host-core covered by unit tests

E2E-024V: Publisher pins a version with pi-plugin publish ​

  • Preconditions: A plugin directory inside a git repository with an https or ssh origin remote.
  • Steps: 1) Run pi-plugin publish with uncommitted changes. 2) Commit and tag, then rerun. 3) Rerun on a commit with no tag. 4) Rerun with a remote containing credentials. 5) Feed the resulting registry entry through the center's catalog build and the client preflight.
  • Expected: A dirty worktree is refused so the submitted commit describes the packaged bytes; a clean run writes the package, its sha256, the canonical repository URL, the tag ref, the resolved commit, and the plugin subdirectory into a submission payload; an untagged commit is accepted with a warning; a credentialed remote is refused; the generated catalog passes the same preflight the client ships.
  • Specs linked: 07-plugins/10-plugin-devex.md, 07-plugins/15-plugin-center.md
  • Acceptance: G (publisher tooling)
  • Status: Documented / devkit covered by unit tests

E2E-024C: Plugin package install and auto-update path ​

  • Preconditions: Marketplace catalog has a newer version or local .piplug.
  • Steps: 1) Install package from the header overflow menu. 2) Enable auto-update from the row overflow menu. 3) Reopen Extensions while the marketplace is slow or unavailable and confirm the Installed surface and Marketplace tab remain usable. 4) Confirm the installed list refreshes update metadata from the local catalog. 5) Run Check for updates, then Apply automatic updates.
  • Expected: The Extensions surface does not wait for a remote request during its silent cache-only check; the row moves to Updates available and the update banner reports the count; the highest semantic version is selected even when the catalog version array is not newest-first; the explicit check fetches the current catalog and uses the cached catalog offline; permissions the new version adds are tagged New in the review dialog; auto-update applies only when the permission diff is empty or pre-granted.
  • Specs linked: 07-plugins/06-plugin-packaging.md, 07-plugins/08-plugin-signing-updates.md
  • Acceptance: G (package install + update policy)
  • Status: Documented

E2E-024Q: Marketplace update diagnosis and release-data gate ​

  • Preconditions: A catalog fixture contains an installed 0.5.0 plugin, a 0.5.1 version, and either unsorted version entries or intentionally missing package metadata.
  • Steps: 1) Record the plugin ID, installed version, displayed latest version, catalog URL, and local cache path. 2) Fetch the live/fixture catalog and inspect the exact entry. 3) Inspect the installed registry separately. 4) Run pnpm check:marketplace -- --url <catalog> --plugin <id>. 5) Run the update check against the fixture.
  • Expected: The failure is classified as catalog data, fetch/cache, host comparison, IPC propagation, or renderer presentation before code is changed; the preflight reports every missing checksum, URL, package size, or permissions field; an incomplete release may be shown for discovery but is not installable; a valid 0.5.1 is detected regardless of catalog ordering; the final fix includes a regression test for the diagnosed boundary.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, 07-plugins/08-plugin-signing-updates.md, 06-delivery/03-ai-development-workflow.md
  • Acceptance: G (marketplace update diagnosis)
  • Status: Documented / preflight script

E2E-024H: Installed plugins surface state, risk, and failures ​

  • Preconditions: At least one enabled plugin, one disabled plugin, and one plugin whose load failed.
  • Steps: 1) Open Extensions → Installed while plugin changed events are arriving in a burst. 2) Read the tab and group counts. 3) Confirm the failed plugin sits under Needs attention with its error message. 4) Search by author and by permission. 5) Clear the search.
  • Expected: The page remains usable during transient host backpressure: identical plugin and extension registry refreshes coalesce, and renderer-facing RPC calls retry explicit HOST_OVERLOADED responses with bounded backoff before surfacing an error. Plugins group as Needs attention / Updates available / Active / Turned off with counts; status: "error" | "load_error" renders the error message inline instead of being silent; each plugin is a card in an auto-filling grid, defaulting to a quiet name/id/version summary (plus a rating chip when the centre has one), while a Details disclosure reveals risk-tinted permission chips, capabilities, and resident service status; the single scope trigger opens explained Off / This project / Everywhere choices and the project picker; card icon actions expose hover/focus labels; the result count reflects the filtered subset and clearing restores every group.
  • Specs linked: 04-ux/01-ui-ia.md, 07-plugins/13-plugin-permissions-matrix.md
  • Acceptance: G (installed plugin management)
  • Status: Documented

E2E-024D: Isolated plugin panel host bridge ​

  • Preconditions: Plugin with ui.panel enabled.
  • Steps: 1) Set the app language to English and open a panel whose manifest declares localized ui.title.en and ui.title.zh-CN; confirm the native window/launcher identity remains available without a host-rendered title. 2) Set the app language to Simplified Chinese and reopen the panel; confirm the panel content remains plugin-owned. 3) While the panel stays open, switch the app language to Korean and confirm the live appearance:changed event updates the panel controls, safe-area reminder, and accessible labels without reopening it. 4) Open the panel on macOS, Windows, and Linux; confirm the same frameless 46px drag band, fixed top-right capsule fully contained inside that band, and exactly three accessible controls. 5) Exercise minimize, maximize, restore, close, keyboard focus, light/dark themes, page-defined light/dark backgrounds, and reduced motion on every platform. 6) Render a plugin-owned titlebar/toolbar; verify fixed/sticky UI uses --pi-plugin-titlebar-height, its interactive controls use no-drag, and clicks outside the capsule in the top 46px are treated as window dragging. 7) On Windows with classic scrollbars, scroll a panel with content overflow and inspect the right edge. 8) Open a development plugin and confirm the localized reminder explains that the top 46px is not clickable outside the capsule. 9) Reopen a minimized panel. 10) Invoke panel bridge APIs (ui.showToast, optional fs/net with grants). 11) Close the panel from the capsule and by disabling or uninstalling the plugin. 12) Repeat with a panel and a docked work-panel view open and quit the app, then read the main-process log.
  • Expected: Panel runs in its sandboxed window/partition; all three platforms use one host-owned frameless chrome contract with no native traffic lights, host-rendered title, or application menu; the top drag band is exactly 46px, and the minimal capsule stays fixed at the top-right without exceeding it. The capsule contains minimize/maximize-or-restore/close, follows the plugin page's surface/text colors, and never forces a black surface onto a light page. A v2 page marked pi-plugin-chrome uses --pi-plugin-titlebar-height and starts its own content directly below the 46px band without an additive duplicate spacer; a legacy page keeps the compatibility offset. A panel's stable scrollbar gutter is scoped to its actual content scroller; Windows does not show a second root-level empty side rail outside the page surface. The plugin owns its title and toolbar; the host drag strip remains usable, blocks clicks outside the capsule, and development panels alone show the reminder. Reopening restores the existing panel; switching to Korean while the panel remains open updates the host capsule, reminder, and accessible labels in place; bridge calls remain permission-checked and the host remains stable on panel close. Closing a panel must not throw a main-process TypeError: Object has been destroyed or show an uncaught-exception dialog, and neither closing a panel nor quitting with panels and docked views open may log a panel-bridge failure for the page that is closing: a call from a page that is already gone is settled, and the panel and view pages are gone before the plugin runtime and the host stop.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/07-ui-design-system.md, 07-plugins/01-plugin-system.md, 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 07-plugins/12-plugin-ipc-and-host-services.md, ADR 0081, ADR 0082, ADR 0092, ADR 0093
  • Acceptance: G (isolated panel)
  • Status: Documented

Initial-load timeout check: Open a fixture panel from Extensions → Installed whose page load remains pending. After 15 seconds, the open request must report PANEL_LOAD_TIMEOUT through the initiating action and destroy the hidden window; opening a normal panel afterward must still work.

E2E-024AA: Plugin-owned UI follows the host locale ​

  • Preconditions: A loaded plugin with a panel or settings destination, and a plugin process subscribed to pi.events.on("appearance:changed").
  • Steps: 1) Call pi.app.getLocale / app.getAppearance and confirm the tag matches Settings → language. 2) Switch the app language while the plugin stays loaded. 3) Confirm generated contributes.settings titles remain the author-language strings. 4) Confirm the plugin process and any open panel receive appearance:changed with the new locale and restyle their own copy.
  • Expected: The host publishes language only. Plugin-owned copy changes without a reload. The generated settings sheet does not parse { en, "zh-CN" } maps. Host-owned identity still follows manifest.i18n (ADR 0267).
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/02-plugin-manifest-schema.md, 04-ux/02-i18n-english-first.md, ADR 0280
  • Acceptance: G (plugin i18n)
  • Status: Automated in part (apps/desktop/test/plugin-settings.test.mjs, plugin-work-panel-views.test.mjs); UI walk-through Documented

E2E-024Y: Large-file plugin reads and dropped-file grants stay host-gated ​

  • Preconditions: A test plugin declares fs.read; the workspace contains a readable log and a protected credential fixture; the plugin panel is open.
  • Steps: 1) Pick a directory and open a large log. 2) Confirm initial page, search, follow, and a file-growth poll complete through fs.stat and bounded fs.readRange. 3) Drag a regular file into the panel and open it. 4) Drag a second file or forge an absolute path with the first grant and retry. 5) Reload/unload the plugin and retry the old grant.
  • Expected: Directory reads remain relative to the selected root and are permission/audit checked. Range reads reject invalid values and lengths above 8 MiB, return an empty byte array at EOF, and preserve total size. A real drop creates a one-file, read-only, memory-only grant; the dropped file uses the same paging/search/follow engine, while another path, a protected path, and a grant after unload fail closed with PERMISSION_DENIED or NOT_FOUND. No renderer worker or raw plugin node:fs path is used.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 07-plugins/12-plugin-ipc-and-host-services.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0190
  • Acceptance: Security + G (plugin host services)
  • Status: Unit/integration-covered; real drag gesture remains manual

E2E-PLUGIN-fetch-redirect-policy ​

  • Preconditions: Current-main task candidate, built workspace packages, real Node plugin child process and two loopback HTTP fixtures; no real user profile, provider or credentials.
  • Steps: Load the example probe plugin and invoke its probe.fetch operation through the production panel bridge. Query capabilities, request error/manual/ follow, and repeat against the injected single-hop transport. Exercise invalid modes, missing grants, disallowed targets, relative/missing Location, loops, and timeout. Test 301/302/303/307/308 and default compatibility.
  • Expected: Error returns REDIRECT_DISALLOWED and manual returns the original response, both with zero target requests. Follow reaches the target only after egress authorization. Error codes survive child IPC; refusals are audited.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md
  • Acceptance: G + Security
  • Milestone: Post-MVP plugin API
  • Status: Automated protocol E2E via pnpm test:e2e:plugin-fetch-redirect. The sample panel's native-window gesture remains manual; no desktop is attached.

E2E-024E: High-risk plugin APIs require grants ​

  • Preconditions: Notes plugin installed with explicit grants.
  • Steps: 1) Call fs.writeText / net.fetch / shell.openExternal through plugin runtime or panel bridge. 2) Revoke one permission and retry.
  • Expected: Granted calls succeed with audit; revoked/undeclared calls fail with PERMISSION_DENIED and do not crash the app.
  • Specs linked: 07-plugins/13-plugin-permissions-matrix.md, 07-plugins/04-plugin-security.md
  • Acceptance: Security + G
  • Status: Documented

E2E-024W: Plugin clipboard history captures bounded text and images ​

  • Preconditions: The app is running; a test plugin declares and is granted clipboard.read; the Composer can receive one text paste and one image paste.
  • Steps: 1) Paste text into the Composer, paste an image into the Composer, then invoke pi.clipboard.getHistory(). 2) Invoke it again and mutate the returned image bytes. 3) Paste the same text consecutively and invoke the API. 4) Leave the app idle with an image on the OS clipboard and verify no clipboard sampling occurs. 5) Revoke clipboard.read and invoke it again. 6) Add fixtures over the text/image caps and older than the retention window.
  • Expected: The result is newest-first with text and image entries interleaved, ISO timestamps, PNG bytes, and image dimensions; mutating the result does not mutate host state. Consecutive duplicates collapse with a refreshed timestamp. Entries over the per-entry caps and expired entries are absent, and the host total/entry caps are enforced. A paste causes only the event's already-read content to be recorded; the host does not reread the OS clipboard or sample it while idle. The API reuses the clipboard.read grant, denied calls fail with PERMISSION_DENIED, and a successful call emits an audit entry containing the returned entry count.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0115
  • Acceptance: G (plugin clipboard history) + Security
  • Status: Unit-covered (clipboard-history.test.mjs); Electron clipboard capture remains manual

E2E-024I: Plugin skills reach the agent and load on demand ​

  • Preconditions: examples/plugins/hello enabled with agent.prompt.inject granted; a second copy of the manifest without that permission available; one workspace that is a plugin directory and one that is not.
  • Steps: 1) Start a session and ask the agent what skills it has. 2) Ask it to follow the Hello demo skill so it calls the Skill tool. 3) Edit the skill document and repeat step 2. 4) Disable the plugin and start a new turn. 5) Load the variant without agent.prompt.inject and repeat step 1. 6) Declare a document larger than the per-skill cap. 7) Open each of the two workspaces in turn.
  • Expected: The catalog lists the skill id, name, and trimmed description but no body or path, after the built-in skills and before the project instruction chain; the Skill schema is loaded through ToolSearch only when requested and reads the edited file without a restart; the loaded result identifies the actual SKILL.md path and its parent directory before the body so relative references resolve against that document; disabling the plugin rebuilds the runtime so the skill disappears from the next turn; the variant without the permission loads normally and contributes no skills; the oversized document is skipped with an audit line rather than clamped into the prompt; the built-in plugin-development skill is catalogued in the plugin workspace and absent in the other, while PluginCheck is listed in the bounded on-demand tool catalog in both.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md, 07-plugins/04-plugin-security.md §7.1, 07-plugins/10-plugin-devex.md, ADR 0039, ADR 0037, D174
  • Acceptance: G (skill activation) + E (tools & permissions) + D (high-risk permission gating)
  • Status: Unit-covered (plugin-skills.test.mjs, agent-runtime prompt/digest tests); agent-facing scenario Draft

E2E-024J: Plugin theme applies and falls back when withdrawn ​

  • Preconditions: A marketplace/package-installable examples/plugins/hello variant (demo.hello) whose midnight theme CSS references a declared package-relative image at art/preview.png; a plugin with CSS using @import or remote url() for rejection plus a comment-only variant; an asset theme with windowAppearance variants with and without ui.window.appearance, including cornerRadius: 0 and an invalid value above 24.
  • Steps: 1) Install the packaged Hello variant from Marketplace or its .piplug package and select Hello Midnight in Settings → General → Theme. 2) Restart the app. 3) Disable the providing plugin. 4) Re-enable it, then uninstall it. 5) Load the plugin with unsafe CSS. 6) Load the comment-only variant. 7) Select the asset variant's theme on Windows/Linux and on macOS, verify the package-relative image renders through plugin-asset: in the shell and the plugin's panel, and load a sheet with an undeclared package-relative url() to verify it is refused. 8) Deselect its theme after removing ui.window.appearance.
  • Expected: The packaged plugin installs successfully with its relative image resolved inside the plugin root; its theme appears in the picker alongside the built-ins and applies immediately, with the image served through plugin-asset:; the choice survives restart as plugin:demo.hello:midnight; disabling or uninstalling the provider falls back to system instead of an unstyled shell; unsafe CSS is refused at load with the reason logged and no <style> element injected; the comment-only sheet loads and contributes its theme, because the sanitizer only inspects CSS the browser would apply; the declared asset paints through plugin-asset: in the shell and in the plugin's own panel, an undeclared reference is refused with the reason logged, the declared background colours the native window on Windows/Linux and is never sent on macOS, and cornerRadius: 0 makes only the Windows main window rectangular while the authorized theme is selected. Deselecting the theme or dropping the grant restores the host background and the global 12 DIP --radius-md Windows corners; a radius above 24 rejects without changing the window. The whole shell follows the theme, including the work-panel column, its header, and the browser/file viewer strips, all of which read --ds-bg-dock / --ds-bg-dock-raised rather than a literal.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md, 07-plugins/04-plugin-security.md §3.1, 04-ux/07-ui-design-system.md, D175
  • Acceptance: G (theme contribution) + Security
  • Status: Unit-covered (plugin-themes.test.mjs, theme-css SDK tests, host-core package-relative asset/install tests). test:e2e:window-controls selects an authorized test plugin theme with cornerRadius: 0 and returns to a built-in theme, verifying the native shape follows both choices. The broader asset visual scenario remains Draft.

E2E-PLUGIN-runtime-theme-apis ​

  • Preconditions: A plugin with ui.theme that can drive pi.app.setTheme and pi.themes.upsert / remove (panel or command). Settings → General shows the searchable theme picker.
  • Steps: 1) From the plugin UI, call themes.upsert with a new theme id and distinct CSS. 2) Confirm the theme appears in Settings without reload/disable. 3) Call app.setTheme to select it. 4) Call themes.upsert again with different CSS while it is active. 5) Call themes.remove on an inactive theme. 6) Call app.setTheme with an unknown id. 7) Upsert a ninth theme (former hard cap was 8).
  • Expected: The new theme is listed and applies immediately through the same path as Settings; live CSS edits restyle the shell without plugin reload; remove drops the picker row and the event refreshes the list; an unknown id rejects with INVALID_ARGUMENT and leaves the preference unchanged; the ninth theme is accepted. settingsChanged reaches the renderer store; appearance:changed reaches open panels.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/12-plugin-ipc-and-host-services.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0260, D417
  • Acceptance: G (theme contribution) + Security
  • Status: Unit-covered (plugin-themes.test.mjs); interactive scenario Draft

E2E-PLUGIN-sidebar-gradient-token ​

  • Preconditions: A plugin theme that sets --ds-bg-sidebar to a solid color and --ds-bg-sidebar-image to a linear-gradient(...); Windows/Linux and macOS shells.
  • Steps: 1) Select the theme. 2) Inspect the sidebar plate and rail. 3) Confirm borders/glass tint still resolve from the color token. 4) Switch back to a built-in theme.
  • Expected: The gradient paints as background-image over the color plate on all platforms; macOS sheen still overlays the image layer; borders and color-mix consumers do not break; clearing the token returns the plain sidebar.
  • Specs linked: 04-ux/07-ui-design-system.md, ADR 0260, D417
  • Acceptance: Visual / platform
  • Status: CSS unit contracts in plugin-themes.test.mjs; visual scenario Draft

E2E-024K: Plugin MCP server tools reach the agent ​

  • Preconditions: A plugin declaring one stdio and one non-loopback HTTP MCP server against trusted local-network stubs; mcp.server.local and mcp.server.remote granted; the HTTP host is listed in net.domains; a settings key holding the stub credential.
  • Steps: 1) Enable the plugin and confirm no server process starts yet. 2) Ask the agent to call a discovered tool. 3) Inspect the stub's received environment/headers. 4) Make the stub fail a call, then run an HTTP tool that takes longer than the 10s handshake budget but less than the 100s call budget, then time a call out beyond its own budget. 5) Grow the stdio stub's catalog past the old 64-tool cap and re-discover it. 6) Disable the plugin.
  • Expected: Servers connect lazily on first use; tools appear as plugin_demo_*_<serverId>_<tool> at risk: "medium" with per-call audit; the stdio child receives only the declared env values plus PATH/temp/locale, never host provider keys; the non-loopback HTTP endpoint is accepted only because its host is declared, and its unencrypted transport is visible in review; a redirect to an undeclared host is blocked before the second request; the HTTP tool can finish after the handshake budget while a call that exceeds its own budget fails; other failures and timeouts return tool errors without crashing the plugin or the host; a catalog larger than the old 64-tool cap arrives whole, while a server that breaks a per-server guard (count, pages, cursor, traversal time) is refused instead of contributing a prefix of its catalog; disable disconnects both servers.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md, 07-plugins/04-plugin-security.md §8.1, ADR 0038, ADR 0142, D176, D281, D452
  • Acceptance: G (MCP bridge) + E (tools & permissions) + Security
  • Status: Unit-covered (plugin-mcp.test.mjs stdio + HTTP stubs); agent-facing scenario Draft

E2E-MCP-pi-client-owner-policy: Pi protocol under existing Desktop owners ​

  • Preconditions: Pi 1.1.0 with the pinned host-policy patch; offline stdio and HTTP fixtures, no paid provider or user credentials.
  • Steps: Run plugin-mcp.test.mjs, user-mcp.test.mjs, mcp-stdio-launch.test.mjs, mcp-call-registry.test.mjs, mcp-oauth.test.mjs, and mcp-oauth-discovery.test.mjs under apps/desktop/test using node --test.
  • Expected: Concurrent discovery starts one process; host launch/env/egress policies remain enforced; Pi delivers progress and structured content; progress cannot extend the total call deadline; cancellation reaches the stdio peer and leaves other calls usable. OAuth uses Pi DCR/PKCE/refresh with host TLS/redirect, scope, callback-state, and encrypted-storage boundaries. No legacy client runs.
  • Status: Offline integration-covered; packaged Electron journey NOT RUN.

E2E-MCP-CANCEL: Stop interrupts only the calling session's MCP request ​

  • Preconditions: Two Agent sessions share one user or plugin MCP server; each has a pending tool call, and the server records cancellation notifications.
  • Steps: Stop the first session while both calls are pending, then allow the second call to finish. Repeat with a remote HTTP server and a stdio server.
  • Expected: The first request receives notifications/cancelled and cannot produce a successful tool result after Stop. The second session's call finishes normally; its server connection remains available. Closing the app cancels remaining requests without retaining listeners.
  • Specs linked: 03-runtime/01-ipc-protocol.md §12a, 07-plugins/04-plugin-security.md §8.1
  • Status: Client and session-isolation unit-covered; full desktop journey Draft

E2E-MCP-tool-requires-approval: User MCP tools prompt under ask and accept-edits ​

  • Preconditions: A project-bound Agent session; one user-configured stdio MCP server whose tool list annotates a tool as read-only/low risk.
  • Steps: 1) With the session in ask, ask the agent to call the MCP tool. 2) Answer the card with allow-once, call it again, then answer with allow-session and call it a third time. 3) Switch to accept-edits in a new session and repeat the call. 4) Switch to auto and call it. 5) Switch to Plan, then Goal, and call it.
  • Expected: Under ask and accept-edits every call shows an approval card with reason "MCP server tool requires approval" at medium risk, regardless of the server's self-declared annotation. Allow-once covers only that call; allow-session suppresses further cards for the same mcp_<serverId>_<tool> name in that session only, and does not cover other tools of the server. auto runs the tool without a card. Plan and Goal deny it even with a session grant.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 05-security/01-security.md, D640, ADR mcp-tool-approval-risk
  • Acceptance: E (tools & permissions) + Security
  • Status: Unit-covered (host-core permissions.rs MCP risk and mode tests); desktop journey Draft

E2E-024L: Resident plugin service is supervised and visible ​

  • Preconditions: examples/plugins/hello enabled with background.service granted.
  • Steps: 1) Open Extensions → Installed and expand Details on the plugin row to read the Greeter heartbeat chip. 2) Kill the plugin's utility process and watch the chip. 3) Kill it repeatedly past the restart ceiling. 4) Disable and re-enable the plugin. 5) Revoke background.service and reload.
  • Expected: The Details disclosure exposes a chip that reports running after load; a kill shows failed then running again with an incremented restart count and backoff between attempts; past five attempts the plugin stays failed and stops retrying; manual disable/enable cancels the pending timer and resets the counter; without the permission the service never starts and the skip is audited.
  • Specs linked: 07-plugins/05-plugin-lifecycle.md §3.1, ADR 0040, D177
  • Acceptance: G (resident services)
  • Status: Unit-covered (plugin-services.test.mjs supervision + backoff); manual kill scenario Draft

E2E-024M: Bus messages cross plugins only as declared ​

  • Preconditions: Two plugins enabled — one publishing demo.* topics, one subscribing demo.** — with bus.publish / bus.subscribe granted.
  • Steps: 1) Run the publisher's command and watch the subscriber. 2) Publish a topic absent from contributes.bus.publish. 3) Subscribe to a pattern absent from contributes.bus.subscribe. 4) Publish a payload over 64KB and exceed 100 publishes in 10s. 5) Unload the subscriber and publish again.
  • Expected: The subscriber receives { topic, from, payload, at } and the publisher never receives its own message; undeclared publish and subscribe both fail PERMISSION_DENIED with an audit line naming the topic; the oversized payload and the rate burst fail LIMIT_EXCEEDED / RATE_LIMITED; publishing to a departed subscriber succeeds with a smaller fan-out and no host error.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md §5.1, 07-plugins/04-plugin-security.md §5.1, ADR 0040, D178
  • Acceptance: G (message bus) + Security
  • Status: Unit-covered (plugin-bus.test.mjs delivery, filtering, caps); two-plugin manual scenario Draft

E2E-024N: Extensions page density and theme-readable actions ​

  • Preconditions: App running with at least one installed extension and one available marketplace action; dark and light themes available.
  • Steps: 1) Open Extensions in dark theme. 2) On Windows/Linux, confirm the compact two-tier header presents the extension mark/title and contextual action rail below the native window-control band, then confirm the Installed and Marketplace tabs are the only tabs and both reach the content without a four-card numeric overview band or explanatory header/section paragraphs. 3) Confirm installed plugins are cards in an auto-filling grid — cards keep their width above 340px and collapse below — then expand Details on one card and inspect its capabilities, service status, and permissions. 4) Use the compact scope control and its explained scope menu, the card's one call to action (Update to the available version, or Open panel), and the 28px settings and overflow icon actions; widen and narrow the window to see the rail wrap at the card's left edge rather than clip. 5) Switch to light theme and repeat. 6) Keyboard-focus the Details disclosure, scope states, and each action.
  • Expected: The four numeric overview cards are absent; the header uses a quiet 24px extension mark, 18px title, restrained 30px action controls, and a visible keyboard focus ring; tab counts, installed group counts, and any update alert remain available in their relevant surfaces. The page header, section headers, empty states, and update alert use compact labels and actions; decision-specific explanations remain in disclosures, details, and dialogs. Cards keep their default height low while the disclosure exposes the complete secondary readout. The scope trigger stays aligned with the card's control rail, its menu explains each state, and icon actions remain visible at rest while showing labels on hover and focus; a disabled card shows no primary action and no settings icon. Primary and secondary buttons keep visible semantic surfaces, text, borders, hover states, and focus rings in both themes, and keyboard focus does not depend on pointer hover.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 07-plugins/07-plugin-marketplace.md, ADR 0058, D196
  • Acceptance: G (Extensions page) + Quality
  • Status: Unit-covered (extensions-page.test.mjs, plugins-page-style.test.mjs, plugin-reviews-surface.test.mjs); visual scenario Draft

E2E-024O: Marketplace hides development-only sample plugins ​

  • Preconditions: App running; the official catalog contains the development fixtures demo.hello or demo.workspace-summary and at least one product plugin.
  • Steps: 1) Open Extensions → Marketplace with an empty search. 2) Search for Hello, Workspace Notes, and Workspace Summary in turn. 3) Inspect the category filters and result cards. 4) Open Installed and verify an already-installed sample remains manageable.
  • Expected: Entries whose IDs begin with demo. never appear in the marketplace cards, categories, or search results; product plugins remain discoverable. An already-installed sample is still listed under Installed so it can be disabled or uninstalled rather than becoming an unmanaged runtime.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, 04-ux/01-ui-ia.md
  • Acceptance: G (Extensions page) + Quality
  • Status: Unit-covered (extensions-page.test.mjs); visual scenario Draft

E2E-025: Disable plugin removes contributions ​

  • Preconditions: Plugin enabled and contributions visible.
  • Steps: 1) Disable the plugin on the Extensions page. 2) Check global search and agent tools.
  • Expected: Commands and tools disappear; no leftover contributions.
  • Specs linked: 07-plugins/05-plugin-lifecycle.md
  • Acceptance: G (disable removes contributions)
  • Milestone: M4
  • Status: Automated (protocol smoke: disable clears enabled flag; global search removal manual)

E2E-026: Plugin error does not crash app ​

  • Preconditions: Plugin loaded.
  • Steps: 1) Trigger a scenario where plugin throws an error. 2) Observe app behavior.
  • Expected: App remains running; error is captured and reported; no crash.
  • Specs linked: 07-plugins/04-plugin-security.md
  • Acceptance: G (plugin error → no crash)
  • Milestone: M4
  • Status: Draft

E2E-024X: Page copy stays concise in both locales ​

  • Preconditions: App running with English and Simplified Chinese available; project archive, Scheduled, Extensions, and Agent capability destinations are reachable.
  • Steps: 1) Open each destination in English and inspect its header, toolbar, empty state, and primary action. 2) Switch to 简体中文 and repeat. 3) Trigger a permission, validation, destructive-action, or provider-error state.
  • Expected: Page headers do not repeat their title as explanatory subtitles; empty states use a concise title and action, with body text only when context or a required next step is necessary. Settings and capability pages omit prose that only explains obvious controls. Permission, security, validation, destructive-action, keyboard, scope, and error details remain visible in both locales.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md
  • Acceptance: Quality (concise page copy)
  • Status: Unit-covered (packages/i18n/test/user-facing-copy.test.mjs); visual scenario Draft

Security — No Secret Leakage ​

E2E-027: Secrets not in logs for normal flows ​

  • Preconditions: Provider configured with API key.
  • Steps: 1) Perform a chat session. 2) Inspect log files.
  • Expected: API keys / tokens not present in any log output for normal flows.
  • Specs linked: 05-security/01-security.md, 03-runtime/09-logging-and-observability.md
  • Acceptance: H (secrets not in logs)
  • Milestone: M2
  • Status: Automated (protocol smoke: provider list carries no secret material)

E2E-028: Renderer has no Node integration ​

  • Preconditions: App running.
  • Steps: 1) Inspect renderer process flags.
  • Expected: nodeIntegration: false; contextIsolation: true; preload is the only bridge.
  • Specs linked: 05-security/01-security.md
  • Acceptance: Security (no Node in renderer)
  • Milestone: M1
  • Status: Draft

E2E-029: Unwhitelisted IPC cannot be called ​

  • Preconditions: App running.
  • Steps: 1) Attempt to invoke an IPC method not on the whitelist from renderer.
  • Expected: Call blocked; no data returned; error or no response.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 05-security/01-security.md
  • Acceptance: Security (IPC whitelist enforced)
  • Milestone: M1
  • Status: Draft

E2E-030: Plugin cannot read API key ​

  • Preconditions: Plugin loaded; provider configured.
  • Steps: 1) Plugin attempts to access provider secret via any API. 2) Observe result.
  • Expected: Access denied; no secret data returned to plugin.
  • Specs linked: 07-plugins/04-plugin-security.md, 03-runtime/14-secrets-storage.md
  • Acceptance: Security (plugin cannot read API key)
  • Milestone: M4
  • Status: Draft

E2E-031: Error codes are stable and readable ​

  • Preconditions: App launched through the normal desktop development command; provider configured.
  • Steps: 1) Select or enter a model ID that the provider rejects. 2) Send a prompt. 3) Inspect the assistant error message and its detail disclosure. 4) Switch sessions and reload the failed session. 5) Repeat with an invalid provider key.
  • Expected: The run stops and the transcript contains one durable role=assistant, status=error message instead of a toast, floating banner, or blank row. It shows a localized summary and stable MODEL_NOT_CONFIGURED or PROVIDER_UNAUTHORIZED code. Details expose the redacted provider response plus provider/model IDs and can be copied; no API key or Authorization value appears. The configuration failure links to settings, retriable failures offer Retry, the composer becomes usable again, and reload preserves the error message. The development launch executes a sidecar rebuilt from current runtime source.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/07-process-model.md, 03-runtime/08-error-codes.md
  • Acceptance: C (failed chat settles), H (errors expose stable codes)
  • Milestone: M2
  • Status: Unit-covered (agent-runtime error message/redaction, host persistence, desktop transcript contract, and predev build contract); full Electron UI scenario Draft

Hardening (M5) ​

E2E-032: Backend crash triggers supervised restart ​

  • Preconditions: App running; host-core and sidecar healthy.
  • Steps: 1) Kill the host-core (or sidecar) process externally. 2) Observe app behavior.
  • Expected: In-flight RPCs fail fast (no long hang); hostStatus shows degraded then restored; child restarts with backoff; after 3 failed restarts in 2 minutes the app stays degraded with a visible fatal status. Repeat the kill with the main window closed: the crash is still logged (with the child's last stderr lines), the child still restarts, and no unhandled renderer-send error appears — supervision is independent of a live window.
  • Specs linked: 03-runtime/07-process-model.md
  • Acceptance: Quality (main path no crash)
  • Milestone: M5
  • Status: Automated (scripts/e2e-supervision.mjs — SIGKILL host-core, assert restart + healthy RPC)

E2E-033: Window bounds persist across restart ​

  • Preconditions: App running with default window size.
  • Steps: 1) Resize/move the window to distinct normal bounds A (≥800×560), maximize before the 600ms save debounce ends, quit, and relaunch. 2) Restore, resize/move to distinct bounds B, quit before the debounce ends, and relaunch again.
  • Expected: Each relaunch restores the latest normal bounds (A, then B), including when quit occurs while maximized or with a pending save. Maximized/fullscreen geometry is never stored as normal bounds; invalid/tiny saved bounds fall back to the 1200×800 default.
  • Specs linked: 04-ux/09-interaction-patterns.md
  • Acceptance: Quality (key operations feel polished)
  • Milestone: M5
  • Status: Documented

E2E-034: NDJSON log files are written and redacted ​

  • Preconditions: Fresh profile; provider configured; one chat turn completed.
  • Steps: 1) Run a prompt with a tool call. 2) Open ~/.pi-desktop/logs/. 3) Inspect the categorized files under app/, host/, and agent/.
  • Expected: NDJSON records exist with ts/level/channel/category/event/message; a normal tool call produces one completion or failure record carrying sessionId/toolCallId, safe tool metadata, and bounded result/duration information; an interrupted tool remains traceable by the same id; no API key, authorization value, raw command output, or local absolute path appears; each category file rotates at 5 MB; lifecycle, permission, tool, provider, plugin, persistence, updater, and error records remain available without creating dedicated timing category files.
  • Specs linked: 03-runtime/09-logging-and-observability.md
  • Acceptance: H (diagnostics)
  • Milestone: M5
  • Status: Documented

E2E-194: Broken stdout does not crash the main process ​

  • Preconditions: Packaged or development app; a session can send a prompt.
  • Steps: 1) Launch so stdout/stderr are a closed pipe (Linux AppImage from a desktop entry, or with the stdout reader closed). 2) Send a chat message. 3) Confirm the main process stays up and the uncaught-exception dialog does not appear. 4) Quit and relaunch.
  • Expected: No Electron "A JavaScript error occurred in the main process" dialog with Error: write EPIPE from Logger.log. NDJSON category logs still receive the prompt records. Relaunch still reaches the local host service instead of a fatal "Can't reach the local service" / “无法连接本地服务” status caused by a previous main-process crash.
  • Specs linked: 03-runtime/09-logging-and-observability.md, 03-runtime/07-process-model.md
  • Acceptance: H (diagnostics), Quality (main path no crash)
  • Milestone: M5
  • Status: Unit-covered (logger-routing.test.mjs); packaged AppImage journey Documented

E2E-RUNTIME-non-ascii-http-header-does-not-show-main-exception-dialog ​

  • Preconditions: Packaged or development app on a machine whose system HTTP proxy or gateway injects a non-Latin-1 response header (for example a value starting with U+661F), or a test that delivers the same TypeError: Cannot convert argument to a ByteString through Electron net.
  • Steps: 1) Launch so the auto-updater check or model discovery issues a main-process net.fetch / Electron-updater request. 2) Confirm the native exception dialog does not appear. 3) Dismiss nothing; wait for a later updater or discovery request. 4) Open ~/.pi-desktop/logs/app/runtime.log.
  • Expected: No Electron "A JavaScript error occurred in the main process" dialog. The app stays running and does not quit. runtime.log contains an error record with code: "NON_ASCII_HTTP_HEADER" and recoverable: true. A later main-process HTTP request does not re-open the native dialog.
  • Specs linked: 03-runtime/07-process-model.md, 03-runtime/09-logging-and-observability.md
  • Acceptance: H (diagnostics), Quality (main path no crash)
  • Milestone: M5
  • Status: Unit-covered (main-process-errors.test.mjs); packaged Windows proxy journey Documented

E2E-195: Linux glibc below 2.35 names supported distros ​

  • Preconditions: Linux x64 or arm64 packaged app; the machine glibc is older than 2.35 (for example Ubuntu 20.04 / Debian 11 / Fedora 35), or a test doubles process.report to 2.31.
  • Steps: 1) Launch the AppImage, deb, or rpm. 2) Observe the main window and fatal banner. 3) Confirm host-core is not restarted in a loop.
  • Expected: Electron still opens. There is no uncaught write EPIPE dialog. The fatal banner says the build needs glibc 2.35 or newer and names Ubuntu 22.04, Debian 12, and Fedora 36+. Restart supervision does not spin. A host-core binary whose symbols need glibc 2.39 fails scripts/check-linux-host-glibc.mjs.
  • Specs linked: 03-runtime/07-process-model.md, 01-product/01-product-scope.md, 06-delivery/06-release-runbook.md
  • Acceptance: H (diagnostics), Quality (main path no crash)
  • Milestone: M5
  • Status: Unit-covered (linux-glibc.test.mjs); packaged distro journey Documented

E2E-035: Bash tool uses the effective catalog shell ​

  • Preconditions: Workspace open; agent mode.
  • Steps: 1) Select an available catalog shell and run Bash (e.g. echo ok). 2) Make the persisted selection unavailable and inspect the effective catalog before running the next turn. 3) Run with the previous turn snapshot.
  • Expected: The unchanged Bash protocol call uses the selected catalog entry. A later unavailable persisted choice falls back to the first available platform shell and marks the catalog fallback; the previous turn snapshot is rejected as stale by COMMAND_SHELL_CHANGED rather than silently changing shell. No partial execution occurs; E2E-113 covers the stale identity path.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/08-error-codes.md, ADR 0054
  • Acceptance: H (errors expose stable codes)
  • Milestone: M5
  • Status: Unit-covered (tools::shell::tests); scenario Documented

E2E-044: Development launch uses Nib Dock branding ​

  • Preconditions: macOS development checkout with canonical build/icon_1024.png.
  • Steps: 1) Run pnpm dev. 2) Inspect the running application's Dock icon.
  • Expected: The Dock shows the Nib brand icon, not Electron's default icon; packaged builds continue to use build/icon.icns.
  • Specs linked: 06-delivery/06-release-runbook.md
  • Acceptance: Quality (development shell matches release branding)
  • Milestone: M5
  • Status: Unit-covered (development-branding.test.mjs); visual scenario Documented

E2E-045: Global text selection preserves editing and copying ​

  • Preconditions: App running with a chat transcript containing a user message, an assistant Markdown response with a code block, and an expanded tool result.
  • Steps: 1) Drag across sidebar/titlebar chrome and a button label. 2) Drag across user/assistant prose, code, and tool output. 3) Focus the composer and a settings/search input, then use Cmd/Ctrl+A and replace the selected text. 4) Copy selected transcript and code text.
  • Expected: Chrome does not leave an accidental text selection; message prose, code, tool input/output, and editable controls remain selectable and copyable; native editing shortcuts, focus-visible rings, and window drag behavior remain intact.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/09-interaction-patterns.md
  • Acceptance: Quality (key operations feel polished)
  • Milestone: M5
  • Status: Unit-covered (user-select.test.mjs); scenario Documented

E2E-046: Nib renderer branding and composer icon boundary ​

  • Preconditions: App running in both English and zh-CN locales, with an empty home and a docked transcript available.
  • Steps: 1) Inspect the expanded and collapsed sidebar. 2) Inspect the empty-home hero and docked composer. 3) Observe the eight-frame mascot GIF looping in place, move the pointer over it, and confirm its cadence and geometry do not change. Enable reduced motion and confirm the still first frame is shown. 4) Focus the footer Settings and Plugins icons, then each project/Temporary session create control. 5) Open Settings and the composer input.
  • Expected: Visible shell identity reads Nib; the empty-home hero renders the theme-matching 100px HomeMascotLogo GIF with a short idle hold and a looping wave. Pointer hover does not alter the cadence or geometry, and reduced motion shows the matching still first frame. The expanded/collapsed sidebar renders the derived src/assets/brand/logo-*.png asset through BrandLogo and the docked composer prompt row has no leading brand icon or reserved icon slot and its text aligns directly with the input gutter. The right Composer toolbar shows a Bot model × reasoning chip and the single submit slot; an installed plugin may contribute text actions between them. Without such a plugin no prompt-enhancement action appears. The footer Settings and Plugins actions are compact icon buttons; Plugins sits immediately to the right of Settings and exposes a localized accessible name. Every scoped session-creation control uses the dedicated message-plus icon with localized labels and accessible names. Codex remains visible only as the external import-source label or in non-runtime design-reference text.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 08-meta/decisions-log.md (D094/D160/D293), ../../adr/0031-icon-free-composer-prompt-row.md, ../../adr/0152-eight-frame-empty-home-mascot-gif.md
  • Acceptance: Quality (brand consistency and key operations feel polished)
  • Milestone: M5
  • Status: Unit-covered (renderer-branding.test.mjs); scenario Documented

E2E-047: Retain, collapse, switch, and close multiple project tabs ​

  • Preconditions: Projects A and B each have at least one durable session; neither path is archived; a Temporary session also exists.
  • Steps: 1) Open project A from Settings → Project archive. 2) Open project B without closing A. 3) Click A's directory row on its chevron, folder, label, and trailing disclosure hit area in turn to collapse/expand it; use B's directory row to activate and collapse B; verify + and overflow do not toggle B. 4) Hover and keyboard-focus A's project title and confirm the full path is exposed; open A's project overflow or right-click menu and choose Open folder; confirm conversation overflow no longer offers Open folder. 5) Select A's conversation. 6) Close B. 7) Restart the app. 8) Reopen B from Settings → Project archive.
  • Expected: A and B render as separate exact-path sidebar groups in a compact continuous list with one keyboard stop per directory disclosure; every non-action point in A's row toggles only A, project activation is owned by the directory row rather than its overflow menu, and project actions appear on hover/focus without shifting labels, the project title hover/focus path shows A's full absolute path in a content-sized tooltip (long paths wrap within a 420px maximum), Open folder is a project-menu action only and opens A in the system file manager, and collapse survives restart; activating a group or its conversation clears the previous visible transcript, updates the selected workspace and session binding, and then loads only the selected project's conversation; Temporary remains separate; closing B removes only its retained tab and deletes neither its project row nor sessions; reopening B restores the same sessions without duplication.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0016
  • Acceptance: C (switch sessions), D (workspace), F (local presentation persistence)
  • Milestone: M5
  • Status: Unit-covered (sidebar-preferences.test.mjs for retained paths and collapse persistence); full UI scenario Draft

E2E-047b: Sidebar session hover card surfaces rich metadata ​

  • Preconditions: A retained project with at least two sessions, one of which has a recorded git branch; a Temporary/scratch session also exists.
  • Steps: 1) Hover a session row under the retained project and wait for the card to appear; repeat with keyboard focus on the same row. 2) Change the active project's Git branch externally, then switch to another conversation in the same directory and hover its row; wait for the card. 3) Move the pointer to a different session row without leaving the sidebar; wait. 4) Hover a Temporary/scratch session row. 5) Resize the sidebar narrower than 320px; hover again. 6) Right-click a session row while the card is visible and open its context menu. 7) Scroll the sidebar body while the card is up.
  • Expected: The card appears after a 500ms dwell, never appears during quick pointer passes, and re-targets to the latest hovered row when the pointer changes. Each card shows: the localized session title, the mode/permission badge, live status, the readable model display name when known, the project name (or "Temporary" / "临时对话" for scratch rows) with the latest Git branch on the same row, and the row's Updated timestamp formatted by the active locale without seconds. Ordinary sessions do not show a Local task chip, session UUID, separate Provider label, or a status-checked timestamp. Refreshing the branch does not activate a project or change the selected conversation. The session row has no native title tooltip; the hover card is the only full-title surface. The card never widens past 320px, never causes the underlying row to horizontally scroll, and disappears immediately on resize, scroll, or context-menu open.
  • Specs linked: 04-ux/09-interaction-patterns.md §9.1b
  • Acceptance: F (local presentation)
  • Milestone: M5
  • Status: Unit-covered (sidebar-navigation.test.mjs and app-store-sidebar.test.mjs for the session hover card, branch refresh, and the absence of a native row title); full UI scenario Draft

E2E-048: Pin, archive, restore, and sort project/conversation rows ​

  • Preconditions: Two retained projects contain conversations with distinct titles and created/updated timestamps; archived view is initially disabled.
  • Steps: 1) Inspect the Sessions and Projects heading actions at rest. 2) Hover each heading and keyboard-focus each action to confirm the controls reveal without moving the labels. 3) Inspect the Sessions toolbar and verify that Sort appears before New Chat. 4) Open Sort and inspect its placement, then select Recently updated, Created date, Oldest first, and Name in turn. 5) Pin one project and one conversation. 6) Archive another conversation and project. 7) Enable Show archived and restore both. 8) Restart the app. 9) Delete a disposable conversation through the distinct Delete action.
  • Expected: Section create and sort controls are visually quiet at rest and reveal on toolbar hover or keyboard focus; project + and overflow actions follow the same rule without shifting labels. Sort precedes New Chat in the Sessions toolbar; the sort menu remains content-sized, opens to the trigger's right without flipping left, and session/project/section body-level menus use the same right-side rule with a narrow-viewport width cap. Pinned rows remain ahead of unpinned rows under every selected secondary order; in the sidebar, pinned project rows replace the Folder with a filled accent Star while unpinned rows retain Folder. Each sort produces the documented stable order; archived rows disappear from the default view but retain transcripts/project records and reappear in Show archived; restore returns them to the selected order; archiving the active row selects a visible non-archived fallback or creates the documented empty fallback instead of leaving hidden active context; pin/archive/sort choices survive restart; only Delete removes the disposable durable session. A legacy manual preference loads safely without exposing or implying a drag-reorder workflow.
  • Specs linked: 03-runtime/04-data-storage.md, 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (session organization), F (persistence)
  • Milestone: M5
  • Status: Unit-covered (sidebar-preferences.test.mjs for metadata, filtering, and sort behavior); full UI scenario Draft

E2E-048b: Edit a logical project name and folder roots ​

  • Preconditions: One retained logical project is visible in the sidebar and in Settings → Project archive; it has a primary folder, one additional folder promoted to primary during the scenario, and one other additional folder without chats.
  • Steps: 1) Open the project's overflow menu in the sidebar and choose Edit project. 2) Change the name, use the extra folder's Set as Primary action, and confirm that row moves to the top with the Primary tag while the former primary becomes an ordinary row. 3) Remove the other additional folder and add it again with the native folder picker. 4) Confirm the Primary row cannot be removed. 5) Save and inspect the sidebar row, Project archive roots, and active workspace. 6) Restart the app and inspect the group again.
  • Background-update regression: Open Edit project from Settings, change the name, promote another folder to primary, and remove the additional folder, then complete a background task before saving. Every draft edit must remain visible and save together. The isolated rendered test is node scripts/e2e-project-edit.mjs; it uses real page/store wiring with a controlled host IPC boundary and does not prove disk persistence.
  • Expected: Both project menus offer Edit project. The editor keeps focus contained, trims the name, limits it to 80 Unicode characters, keeps the Primary folder as the first row, and updates the root count without removing another row. Promoting a member folder makes it the first row and the stored primary root, so the project opens there afterwards; a folder outside the saved roots is never accepted as primary, and a remote project keeps its host-owned binding unchanged. Saving persists one logical group with the adjusted roots; the name and the promoted primary folder survive restart, while the group's sessions, transcripts, and on-disk folders remain unchanged.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: D (workspace identity), F (local presentation persistence)
  • Milestone: M5
  • Status: Unit-covered (project-edit.test.mjs, sidebar-preferences.test.mjs); rendered scenario Draft, with primary promotion covered by node scripts/e2e-project-edit.mjs

E2E-048c: Detach a project folder with chats and delete it ​

  • Preconditions: One retained logical project has a primary folder and an additional folder with at least one saved chat. All chats are idle.
  • Steps: 1) Open Edit project and remove the additional folder. 2) Save and inspect the remaining group and the standalone project row for the detached folder. 3) Open its chat and confirm the transcript is intact. 4) Choose Delete for the detached project and explicitly confirm in the dialog. 5) Inspect the remaining group, project list, chat list, and folder on disk.
  • Expected: Removing the folder from the group preserves its project row, chats, and transcripts, and exposes it as a standalone project. Future chats use the detached project's path-scoped context; shared instructions and memory remain with the original group. The confirmed delete then removes that project's sessions and transcripts while keeping its folder on disk; the original group remains with its primary folder.
  • Specs linked: 03-runtime/04-data-storage.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0249, ADR 0251.
  • Acceptance: C (project and chat interaction), F (persistence), Quality.
  • Milestone: M5
  • Status: Host RPC integration-covered; full UI scenario Draft

E2E-048A: Project session lists fold after the ten most recent rows ​

  • Preconditions: One retained project contains more than ten durable sessions with distinct updated timestamps; the sidebar uses the default Recently updated sort; another retained project has ten or fewer sessions.
  • Steps: 1) Inspect the large project's session rows and count them. Check rows with recent and older replies. Hover a row, then focus one with the keyboard. 2) Select the Load N more… control. 3) Switch the sort to Name and inspect the same group before expanding. 4) Restart the app and inspect the group again.
  • Expected: The group shows exactly ten session rows by default plus a Load N more… control (N = remaining session count); there are no time-group headers. Every row places the title before a divider and the localized last reply date. Replies within seven calendar days show an exact relative day (including 0–7 days); older replies show a concrete localized date. Hovering or focusing a row hides its date and reveals the session action. The ten rows are the first rows in the active sort order, so pinned rows are never pushed behind unpinned rows and a Name sort folds everything after the first ten alphabetically; selecting Load more expands the flat list and the control disappears. Expansion is per group and resets on restart (not persisted); the project with ten or fewer sessions shows no fold control.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (session organization)
  • Milestone: M5
  • Status: Scenario Documented

E2E-049: Background sessions keep their originating workspace ​

  • Preconditions: Projects A and B are retained; each contains a session in Agent mode; both workspaces contain different marker files with the same relative name.
  • Steps: 1) In session A, start a turn that reads the marker and performs a permission-gated long-running tool. 2) While A is running, activate project B and open session B. 3) Read B's marker and allow a tool only in B. 4) Wait for both turns to complete. 5) Open a Temporary session and attempt a workspace-required tool.
  • Expected: Switching tabs aborts neither turn; A's tool cwd/path sandbox remains project A and B's remains project B; A's events and grants never appear in B's transcript/session; each sidebar row reports its own running/completed state; the Temporary session inherits no project and receives WORKSPACE_REQUIRED; returning to A restores A's completed transcript.
  • Specs linked: 02-architecture/01-architecture.md, 03-runtime/02-agent-runtime.md, 03-runtime/03-tools-and-permissions.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/10-session-state-machine.md, ADR 0016
  • Acceptance: C (parallel sessions), D (workspace), E (tool/permission isolation), Security (workspace boundary)
  • Milestone: M5
  • Status: Unit-covered (rpc::tests for project-bound, Temporary, and missing-session workspace resolution); full multi-turn UI scenario Draft

E2E-050: Composer model × reasoning menu follows exact capability ​

  • Preconditions: One catalogued reasoning model, one non-reasoning model, and one unknown free-form model id.
  • Steps: 1) Open the Composer model × reasoning chip. 2) Confirm the root contains the Model and Reasoning level entries with current values, plus a slider with one track dot per supported level on a rail directly beneath the Reasoning level entry; confirm every level keeps a visible label under the rail, each centered on its track dot. 3) Drag and click the slider across multiple supported levels and click a tick label, confirming the chip updates while the menu stays at the root and the selected track dot sits under the thumb. 4) Open Model, search for a model, and select a model from a provider group; confirm the menu remains open at the root. 5) Open Reasoning level and choose a level from the radio list. 6) Repeat with a non-reasoning provider and an unknown free-form model id; exercise Escape, outside click, Up/Down, Enter, Left, and the slider's arrow keys. In both roomy and crowded toolbars, switch omit, low, high, max, and off without closing the menu: its horizontal position stays stable, and the trigger stays inside its slot, including with a long model name. Moving the anchor and dispatching a viewport resize must reposition the menu without an event-target type error. Automated geometry coverage: node scripts/e2e-composer-thinking-layout.mjs (isolated Electron fixture, production React picker and compiled styles; no live providers). Repeat native mouse press/release to open, select the first and subsequent reasoning levels, close, and reopen three times with motion enabled. The menu must retain its opening position on every selection; synthetic DOM click() alone does not exercise the trigger's :active transition. Hover each dot and its label: the corresponding label highlights. Unfilled dots brighten and enlarge slightly; filled dots and the selected thumb stay unchanged without shifting the menu. Move outside: the preview clears and the selected value remains unchanged. Reduced motion disables hover transitions. Click a non-adjacent level and verify the thumb travels through intermediate positions while the selected value already equals the target. Interrupt travel with another click; the thumb retargets without jumping to the old start, and only the clicked levels are submitted. Check save failure, stale completion, remount, and 3/5/7/8-stop alignment. At each stop, verify the fill covers the first dot's left edge and still ends at the thumb center. Native range dragging follows immediately; arrow keys retain focus and update the selection. With reduced motion enabled, the target is shown without a transition.
  • Expected: The chip is in the right toolbar with a Bot icon; any explicitly installed plugin text actions appear after it and before Send/Abort. The host has no built-in prompt-enhancement action. Off omits the level text. The single anchored menu replaces its root with an in-place back row and submenu, never opens tabs or a second popover, and always reopens at the root. Model search filters sticky provider groups; reasoning levels come from omit then the selected model binding's enabled levels in canonical order. The root carries a drag slider with one labeled stop per level directly beneath the Reasoning level entry when more than one level is listed (a single-level binding hides the slider). Dragging across several stops persists only the last pending level; tick labels are not tab stops. Slider and tick commits update the chip immediately while the menu stays at the root. The Reasoning level entry opens the radio list, which uses radio semantics, a trailing check, and the current model support note. Selecting either value immediately updates the chip and root value, clears model filtering, and keeps the menu open. A non-reasoning or unknown model starts at off, but an explicit Settings binding can make its configured levels available; discovery never promotes it automatically. Refreshing discovered model data cannot overwrite the binding. Before the first message creates a session, the Composer uses the exact model selected in its model menu rather than the provider's default model; after materialization, the same exact-model capability remains in effect.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md, ADR 0018, ADR 0027
  • Acceptance: B (model config), Quality
  • Milestone: M5
  • Status: Unit-covered (thinking-ui.test.mjs, composer-model-thinking-menu.test.mjs, thinking-commit-queue.test.mjs, agent-runtime capability tests); full UI scenario Draft

E2E-051: Thinking level persists with the session ​

  • Preconditions: A reasoning-capable session is idle.
  • Steps: 1) Select high. 2) Change Plan/Goal/Agent mode without changing the thinking level. 3) Restart the app and reopen the session. 4) Switch to another session and back.
  • Expected: Every configuration update sends the complete session config; high survives the permission-mode change, session switches, host reload, and app restart. A v2 database migrates the same field to off without transcript loss.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/08-component-spec.md, ADR 0018
  • Acceptance: F (persistence)
  • Milestone: M5
  • Status: Unit-covered (host schema/session tests, thinking-ui.test.mjs); full restart scenario Draft

E2E-052: Thinking level reaches the pi request ​

  • Preconditions: Instrumented reasoning-capable provider with a sparse level set and request capture; one session configured above and below gaps.
  • Steps: 1) Select each enabled level and run a prompt. 2) Seed an explicit binding level the catalog does not publish and run again. 3) Repeat with a pi-catalogued non-reasoning model, first with no enabled level and then after an explicit Settings opt-in.
  • Expected: Main resolves capability using the session's actual model id; Composer, main, sidecar, and pi use the same upward-first/downward-second clamp over the binding's enabled set, without intersecting it with the catalog. Pi receives the effective level, an empty or off-only binding receives off, and an explicitly opted-in endpoint receives the configured level. Model-specific request semantics, including adaptive thinking and whether off is expressible, match the pinned pi record without a desktop rewrite.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 03-runtime/13-model-catalog-and-selection.md, ADR 0018, ADR 0027
  • Acceptance: B (model config), C (chat and stream)
  • Milestone: M5
  • Status: Unit-covered (agent-runtime prompt/clamp tests); integration scenario Draft

E2E-053: Thinking streams separately from the answer ​

  • Preconditions: Provider emits thinking deltas before and between answer deltas.
  • Steps: 1) Start a turn in both light and dark themes. 2) Observe a thinking-only phase. 3) Let the answer complete. 4) Toggle the disclosure, test keyboard focus, enable reduced motion, and use Copy answer.
  • Expected: The transcript opens during thinking-only streaming; the latest Thinking disclosure opens and updates without an empty answer bubble or duplicate Working indicator. If the user collapses or expands it, that choice remains authoritative through later thinking deltas and completion. The disclosure uses the transcript surface, theme tokens, a Sparkles/chevron trigger, and a left rule instead of an inset card; collapsed content leaves focus traversal and reduced motion disables the running marker pulse and transitions. Final answer markdown renders separately; Copy answer contains no thinking text.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, ADR 0018
  • Acceptance: C (chat and stream), Quality
  • Milestone: M5
  • Status: Unit-covered (thinking-ui.test.mjs, agent-runtime event tests); full streaming scenario Draft

E2E-054: Stored thinking reloads losslessly ​

  • Preconditions: A completed assistant message contains both reasoning and final answer blocks; another contains reasoning only.
  • Steps: 1) Complete both turns. 2) Restart the host/app. 3) Reopen the session. 4) inspect search results and answer copy.
  • Expected: Host returns the same separate thinking and content values after reload/import/replace round-trips; both messages remain visible; search and answer copy exclude reasoning.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/08-component-spec.md, ADR 0018
  • Acceptance: C (chat and stream), F (persistence)
  • Milestone: M5
  • Status: Unit-covered (host message/import tests, thinking-ui.test.mjs); full reload scenario Draft

E2E-055: Unsupported provider transition clamps safely ​

  • Preconditions: Session on a reasoning provider at max; target providers include non-reasoning and sparse-level variants.
  • Steps: 1) Switch to the non-reasoning provider. 2) Run a turn. 3) Switch to sparse variants around the previous level. 4) send malformed/legacy payloads lacking capability or thinking fields.
  • Expected: Non-reasoning persists and sends off; sparse variants choose the same nearest level everywhere; missing fields fall back safely; malformed thinking is not rendered and never contaminates answer content.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/13-model-catalog-and-selection.md, 04-ux/08-component-spec.md, ADR 0018
  • Acceptance: B (model config), C (chat and stream), Quality
  • Milestone: M5
  • Status: Unit-covered (thinking-ui.test.mjs, host validation tests); full UI scenario Draft

E2E-056: Work panel shell docking and persistence ​

  • Preconditions: App running with any workspace state.
  • Steps: 1) Relaunch and inspect the titlebar and application menu; confirm the panel starts closed and a viewport-fixed work-panel toggle is present (disabled with no session). Press Cmd/Ctrl+J or click the toggle and inspect the no-resource New launcher, then press/click again to confirm it collapses the panel and no tab is created or deleted; a third press must restore the same context. 2) Open two distinct file artifacts, the same first file again, a URL preview, and a completed Bash row. 3) Verify the header is a tablist: open enough tabs to overflow it, confirm only the strip scrolls and the + trigger stays visible, and activate the scrolled-away tab. Right-click an inactive tab and choose Close Other Tabs; verify the pointed-to tab remains and becomes active, and that the action is disabled with one tab. Reopen multiple artifacts, then right-click and choose Close All Tabs; confirm the panel remains open on the New launcher. Reopen resources before checking hover/focus ×, middle-click, drag reorder, insertion indicators, and edge auto-scroll. Open a tab menu with Shift+F10, navigate its items with arrows, and press Escape to leave the tabs unchanged. With only one or two tabs, drag unused header space to move the native window; tab and action clicks must still work without moving it. 4) Click + twice and verify each creates and activates a separate New launcher tab. Confirm its body lists Review and each in-scope plugin view exactly once. Open Browser and confirm its native surface opens at unchanged bounds. Open its context menu and verify the native popup appears above Browser chrome while the page remains visible. With Browser active, right-click another tab and confirm it still uses the native menu. Repeat at 50% and 150% app zoom; the menu must remain anchored to the pointer. Escape closes it without hiding the page or changing bounds. Close the window while a native menu is open and confirm the pending request settles. 5) Close active middle/edge tabs, verify neighbor selection, then close the final tab and confirm New remains. 6) Collapse/reopen the panel and trigger an artifact. In session A, leave Browser open; switch to B and create another tab set, then switch repeatedly between sessions and select a project without an active conversation. Generate a background artifact. 7) Drag the inner divider pointer-down does not jump the divider or resize the native window, cancel one gesture with Escape, then focus the handle and exercise Arrow/Shift+Arrow/Home/End. Commit a different panel width with Browser active, double-click the divider and confirm the default 360px width returns (or the live budget when it is smaller), then commit a different width again. 8) Record MainChat width and native bounds while opening, repeating the same open action, resizing the panel, collapsing, reopening, and closing the final resource. Repeat collapse on Windows while watching the entire frameless window. 9) With the panel open, resize the application from each edge and confirm only the application bounds change; the panel remains at its renderer-committed width. Resize from the left edge and repeat after toggling the sidebar. 10) Open, resize, and collapse on a small work area, then repeat while maximized and fullscreen. 11) Move the normal window between displays and change the active display's work-area geometry. 12) Send valid and malformed reservation payloads, including positive values, and confirm the compatibility seam never changes native bounds. 13) Relaunch. 14) Open a contributed plugin view and reload the main renderer with Cmd/Ctrl+R. Confirm the native view detaches before the new renderer appears, then reopen its tab and verify the cached plugin page returns. Repeat with a same-document navigation and a child-frame navigation; neither may detach the active view.
  • Expected: Startup shows no panel, welcome chooser, or fixed tool buttons. A viewport-fixed toggle in the window's top-right corner is the pointer equivalent of Cmd/Ctrl+J; there is still no application-menu launcher. Cmd/Ctrl+J opens the active session's panel at its committed width without creating a resource tab and collapses it again on the next press while retaining that context, and the shortcut does nothing without an active session or while Settings is open. Each artifact atomically opens the docked third column and creates or activates one resource; file resources are path-keyed and repeated resources deduplicate. Opening, collapse, and closing animate the panel's width/flex allocation with its bounded opacity/slide, so MainChat reflows continuously without a pre-animation jump. Opening the panel, collapsing it, or committing a divider resize updates the presentation without a native-window jump. The header is a horizontally scrollable tablist with stable 92px–180px tabs, visible spacing, and a fixed +; labels stay readable instead of shrinking into one cluster, the strip alone scrolls, active tabs scroll into view, and close selects the right neighbor then left. A localized, keyboard-navigable tab context menu offers Close Tab, Close Other Tabs, and Close All Tabs. Close Other Tabs retains and activates its target and is disabled for one tab; Close All Tabs closes Browser resources and leaves the panel open on New. For an active Browser/plugin view, the tab context menu is a Main-owned native popup above Browser chrome; the page stays visible and its bounds do not change. When Browser is active, right-clicking another tab also selects this native path. Native menu labels, order, and enabled states match the renderer menu, while platform styling preserves the native-view z-order. Its position follows the pointer at the current app zoom, and closing the window settles the request. New launcher tabs expose Review and plugin views as body buttons, with no overlapping popup. Dragging a tab shows a before/after insertion indicator; edge-holding scrolls toward hidden tabs, and reordering leaves the active resource unchanged. Alt+ArrowLeft/Alt+ArrowRight provides keyboard reordering. Selecting a launcher row replaces New or activates its singleton. Collapse retains runtime tabs while hiding the panel. Width follows the shared three-column budget without a fixed pixel cap and previews its current/minimum/maximum values through the panel separator. The inner divider exposes the panel width to assistive technology and supports the documented keyboard steps. Pointer-down preserves the starting width, movement follows the pointer continuously, and release commits once only when the target changed. Escape or cancellation restores the press-time width, and a double-click restores the 360px default inside the live bounds. Browser preview does not intercept an active divider drag. A and B independently restore their runtime open state, ordered tabs, active tab, and Browser resource; selecting a project without an active conversation hides the panel, and no relative resource crosses session/workspace context. Background artifacts update only their retained context and never change the visible panel or native window geometry. Before exit motion, the native Browser preview detaches from the window; collapse produces no stale preview frame. Only {width} is restored after relaunch; every session's open state, tabs, active tab, and Browser resource reset. The panel remains exactly at its committed width while open, and sidebar or native window changes do not alter that preferred panel width. The compatibility reservation seam returns {requested: 0, reserved: 0} for every valid request, including positive legacy values, and never changes native bounds. Malformed payloads fail with INVALID_ARGUMENT and never coerce. A full main-frame renderer reload detaches the visible plugin view without closing its page, so no orphan overlay covers the fresh renderer; reopening the tab reattaches that same page. Same-document and child-frame navigation leave the view attached. The former context-panel overlay no longer exists.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0068, ADR 0151, ADR 0195, D207, D292, D357
  • Acceptance: F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (work-panel-resize.test.mjs, work-panel-window.test.mjs, work-panel-presentation.test.mjs, work-panel-tabs.test.mjs, browser-tab-restore.test.mjs, work-panel.test.mjs, plugin-work-panel-views.test.mjs, plugin-view-navigation.test.mjs, work-panel-tab-menu.test.mjs); isolated Electron tab-menu, Browser native-menu page visibility, and close geometry in scripts/e2e-work-panel-tab-close.mjs; full UI scenario Draft

E2E-057: Message-owned review history survives commits and rolls back safely ​

  • Preconditions: A project-bound Agent session with a writable workspace; no Git repository is required.
  • Steps: 1) Ask the active agent to edit an existing file and create a new file in session A. 2) Expand the activity group and inspect each review card directly after its corresponding tool row; verify every card starts collapsed with its added/modified status and +/− counts visible in the header, then expand it to verify the exact hunks. 3) Commit the files outside the app, close and reopen the Review panel, then reload session A. 4) Verify the same cards and counts remain because they come from the transcript messages. 5) Use a card's rollback action and verify the created file is removed or the previous file bytes are restored. 6) Edit that file again outside the app and retry rollback; inspect the conflict result and verify the later bytes remain. 7) Switch to session B and a background project session, then return to A. 8) Repeat with failed, denied, and scratch writes.
  • Expected: Each successful workspace Write/Edit creates one message-owned review record and one adjacent keyboard-accessible card; the card is never a bottom/global entry. The completion opens nothing: the panel keeps whatever the user left it showing, and Review appears only after the user opens it. Every review card, inline and in the Review tab, is collapsed by default and expands on demand. The Review tab lists A's chronological recorded changes, independent of Git status, repository presence, commit state, focus refresh, or workspace switching. Added, modified, and deleted statuses plus line additions/deletions and hunks are shown when bounded evidence is available. Successful rollback updates the card to Rolled back and survives restart. A post-tool file change returns Conflict and does not overwrite it. Failed, denied, and scratch writes do not create cards; session B cannot inherit A's records. Binary or oversized snapshots show bounded metadata and disable rollback when the previous bytes were not retained.
  • Specs linked: 03-runtime/01-ipc-protocol.md §13a, 03-runtime/03-tools-and-permissions.md §4c, 04-ux/08-component-spec.md §5, ADR 0043
  • Acceptance: D (workspace), Quality
  • Milestone: M5
  • Status: Unit-covered (chat-review-entry.test.mjs); full UI scenario Draft

E2E-CHAT-turn-changed-files-summary: A finished turn lists the files it changed ​

  • Preconditions: A project-bound Agent session with a writable workspace.
  • Steps: 1) Ask the agent to create one file and edit another; watch the turn while it streams. 2) After the reply finishes, inspect the area below the answer. 3) Ask for a change touching more than twelve files across several directories. 4) Click a row, then the View all N files in Review link. 5) Roll back one file's change in Review and return to the turn. 6) Ask a question that writes no files. 7) Switch sessions and come back.
  • Expected: No summary appears while a turn streams or after a failed turn. A finished turn that wrote workspace files shows one borderless Changes this turn card below its answer and above plugin entryExtra blocks, with the file count and summed +/−. Each path appears once with its merged status letter and counts; up to four files use relaxed rows, more use dense rows with a directory count, and rows past 12 fold into +N more. A row click opens the session's Review tab scrolled to that file with its changes expanded; the link opens Review without expanding. The rolled-back file stays listed, struck through. Every earlier finished turn keeps its own card, a turn without writes has none, and a session switch fetches nothing extra. The per-call review rows are unchanged.
  • Specs linked: 04-ux/08-component-spec.md §4.4, D674
  • Acceptance: D (workspace), Quality
  • Milestone: M5
  • Status: Unit-covered (turn-changes-card.test.mjs); full UI scenario Draft

E2E-CHAT-completed-turn-scrolls-to-prompt: A finished reply reads from its prompt ​

  • Preconditions: An Agent session whose next reply is longer than the transcript viewport.
  • Steps: 1) Send a prompt and let the transcript follow the reply to its end. 2) Send a prompt with a reply short enough to fit on screen. 3) Send a long prompt again and scroll up while it streams. 4) Stop a long reply midway. 5) Queue a follow-up while a long reply streams.
  • Expected: 1) When the reply completes, its prompt sits at the top of the viewport and jump-to-latest appears. 2) The short reply stays at the bottom in follow mode. 3) The view stays where the user scrolled. 4) The aborted turn stays at the bottom. 5) The follow-up turn keeps following at the bottom.
  • Specs linked: 04-ux/09-interaction-patterns.md §2.2, 04-ux/08-component-spec.md §4.4, D676
  • Acceptance: Quality
  • Milestone: M5
  • Status: Automated (pnpm test:e2e:transcript-turn-completion, real useTranscriptScroll in Electron; turn-completion-scroll.test.mjs)

E2E-058: Built-in interactive terminal is absent ​

  • Preconditions: A workspace is open and the Agent has completed a Bash tool call.
  • Steps: 1) Open the work panel with Cmd/Ctrl+J and inspect the empty state and context menu. 2) Confirm there is no Terminal tab, launcher row, terminal-specific panel copy, or terminal IPC surface. 3) Confirm the completed Bash row still shows its command, output, status, and copy action, and that its IconTerminal presentation remains available. 4) Verify an interactive shell is opened in the user's external terminal instead of the work panel. 5) Build/package the desktop app and inspect the dependency and unpacked-resource lists.
  • Expected: The work panel offers the Review launcher row plus Browser and in-scope plugin views; Review opens on explicit user action and file resources are transcript-opened; no PTY is created and no terminal tab can be opened. Agent Bash remains non-interactive and fully visible in the transcript. Interactive shell work is performed by the external terminal. Desktop packaging has no PTY/xterm dependency, terminal-specific IPC, or native terminal payload, while generic lifecycle terminal values continue to work.
  • Specs linked: 02-architecture/02-tech-stack.md, 03-runtime/01-ipc-protocol.md §13a, 04-ux/08-component-spec.md §5, ADR 0108
  • Acceptance: D (workspace), Quality
  • Milestone: M5
  • Status: Unit-covered (work-panel.test.mjs, packaging-footprint.test.mjs); full UI scenario Draft

E2E-059: Embedded browser preview isolation and overlays ​

  • Preconditions: A local dev server is running; a URL or BrowserPreview artifact exists; HTML fixtures exist both inside and outside the workspace.
  • Steps: 1) Activate the artifact, enter localhost:<port> and localhost:<port>/index.html without a scheme, and submit each. Enter a dotted host with a port (for example, example.com:8080), then verify an explicit HTTP(S) URL and a rejected javascript: URL. 2) Navigate site links; use back/forward/reload/stop. 3) Trigger a window.open popup and a permission-requesting page (e.g. notification prompt). 4) Open global search, then rename a session from the left sidebar; close it and open Settings. Return to chat and trigger an inline tool permission card. 5) Switch to another panel tab and back; close the panel. 6) Use open-external. 7) From both an existing page and a blank Browser tab, enter an existing HTML file inside the workspace by platform-appropriate file: URL and absolute path. Then enter an existing file outside the workspace by its platform-appropriate URL and absolute path (for example, file:///tmp/demo.html and /tmp/demo.html on Unix, or file:///D:/demo.html and D:\demo.html on Windows). From the loaded local HTML, follow a link to another existing file outside the workspace. From an HTTP page, try the same outside file link and enter a missing local file path.
  • Expected: Scheme-less input normalizes to http; nav state (URL bar, back/forward enablement, load spinner) mirrors the page. Popups open in the default browser (never in-app) only when the URL parses as http(s) or mailto; popups using file:, javascript:, and custom schemes are denied. Permission requests are denied; top-level navigation allows HTTP(S), in-workspace file links, and existing local file links from a local file page. HTTP pages cannot navigate to files outside the workspace. Missing files, directories, remote file authorities, and other schemes are blocked. The preview hides for the whole lifetime of the rename dialog and every other blocking overlay, and while unmounted, reappearing with correct bounds afterwards. An inline permission card does not hide or remount the preview; resize/drag keeps the native view visible and aligned with the placeholder rect without a black flash. Opening the work-panel context dropdown moves beside the native view; the view keeps its full surface rect and the plugin body does not shift down. Open-external launches an http(s) page in the default browser and any loaded local file via openPath. The view uses an isolated persist partition (no session bleed from the app shell). Both inside and outside forms load; the HTTP page's outside link and missing path do not reach the guest load. The submitted address remains visible on failure, with localized copy that only existing local files can open.
  • Specs linked: 03-runtime/01-ipc-protocol.md §13a, 04-ux/08-component-spec.md, 05-security/01-security.md, ADR 0019, ADR 0168, ADR 0328
  • Acceptance: Quality, Security
  • Milestone: M5
  • Status: Partial automation: scripts/e2e-browser-local-file-access.mjs covers native local-file navigation; the address-bar and overlay workflow remains manual.

E2E-CHAT-html-artifacts: Open generated pages from ordinary and project conversations ​

  • Preconditions: Isolated Electron profile, real Host storage and loopback model fixture; no credentials or paid provider requests.
  • Steps: Open assistant Markdown links to ordinary-session scratch HTML, primary project HTML, secondary project-folder HTML, project scratch HTML and an isolated session's worktree HTML. Repeat with a file: URL containing Unicode/spaces. Interact with each rendered page using native pointer input. Try a missing file, an outside file and a positioned HTML source reference. Send a prompt that invokes BrowserPreview through the real sidecar in ordinary, project and isolated conversations. Disable Browser and click again. Switch conversations between cases.
  • Expected: Valid pages render and execute in the side browser at their canonical path. Missing/restricted files report errors without guest loads. A line reference opens source, not execution. Disabled Browser remains disabled and reports an error. BrowserPreview uses its originating session's roots and delivers a valid resource tab. No visible-project fallback.
  • Automation: node scripts/e2e-chat-html-artifacts.mjs, after building Host, runtime and Electron. Captures and synthetic profiles are retained in PI_TEST_OUTPUT (or the OS temporary directory).
  • Status: Electron user-path covered on Windows; other OSes pending.

E2E-BROWSER-capture-resize: Capture preserves live placement and restores the latest viewport ​

  • Preconditions: Isolated Electron profile and a local responsive page taller than the visible browser viewport. No provider account is needed.
  • Steps: Start a full-page screenshot for pages taller than 2048px and verify proportional scaling to at most 1280×2048 output pixels. Keep one capture pending while resizing the browser hole twice. Repeat with raw Page.captureScreenshot, which retains caller-supplied capture settings. While a capture is pending, maximize, restore, enter fullscreen, and leave fullscreen so the panel moves or changes size. Also queue overlapping captures, switch resource tabs, hide and restore a captured page, fail a capture, close a tab with a queued capture, and recreate a destroyed guest.
  • Expected: Full-page Browser captures are proportionally bounded to 1280×2048; viewport captures are unchanged. During capture, the native guest follows the panel's current position and remains clipped within its bounds; it never covers the conversation. Its capture-time viewport stays stable until completion, then innerWidth/innerHeight match the latest requested bounds. Captures on one page serialize without blocking a sibling page. Failed capture releases resize handling. Hiding or closing a page cannot expose it or redirect its queued capture to another page. A destroyed guest leaves no orphan native view when it is recreated or disposed. No screenshot is repeated to repair layout.
  • Image failure recovery: If a full-page image cannot be normalized, the tool result keeps its screenshot path/text and replaces the image with an omission message; the failed image is absent from later model requests.
  • Timeout recovery: When either screenshot API does not complete within 30 seconds, it fails with TIMEOUT, detaches the stalled CDP session, and releases the page queue; the next capture reattaches and can run.
  • Status: Native Electron capture/resize and pending window-transition placement path automated by node scripts/e2e-browser-capture-resize.mjs (artifacts retained). The production Host/Pane/CDP service paths for failure, queueing, tab closure, guest recreation, and relocated-panel clipping are covered by apps/desktop/test/browser-capture-resize.test.mjs.

E2E-BROWSER-session-preview-race: Session switching does not expose a stale preview ​

  • Preconditions: Two sessions have distinct HTML previews; their Browser panel contexts are retained independently. Directory lookups and document loads can be delayed independently by the fixture.
  • Steps: Preview A; produce B's preview in the background; collapse A's panel and open B; delay B's directory lookup and load, then return to A. Switch rapidly B → C and finish the older B lookup last. Repeat with an already-started old load, an empty session, a failed load, and disposal.
  • Expected: Background B does not navigate A. On switching, no A document appears in B's panel, even after bounds/visibility updates. Only the latest navigation can reveal the guest; the late B lookup cannot replace C. Returning to A restores A, while an empty session remains empty. Closing or disposing cannot be undone by a delayed completion. Same-session navigation keeps its current page visible. Invalid, failed, or timed-out loads do not report the previous document as the destination being ready. A switch that exceeds the 15-second main-frame wait stays hidden with an error until retried. Slow images/subframes do not delay revealing a committed current document. Start navigation during an unfinished page load: the old ERR_ABORTED event must not prevent the new address, title, and loading state from updating.
  • Specs linked: 04-ux/08-component-spec.md §5.3; ADR 0028, ADR 0170.
  • Status: Automated service-path coverage in browser-host-session.test.mjs and browser-pane-navigation.test.mjs: production BrowserHost/BrowserPane, controlled native-browser/Host boundaries, and deterministic timers. Native Electron compositing and the reporter's live sessions are not covered.

E2E-BROWSER-responsive-resource-tabs ​

  • Preconditions: Browser enabled; isolated profile; local HTML with a 17-second image, a delayed main response, and ordinary/new-window links.
  • Steps: Submit the slow-image URL once; observe loading feedback and page display before the image finishes. Open two chat links and a website new-window link; switch tabs, navigate within one, then switch away and back. Repeat with a delayed main response, Stop, failed navigation, and a retry. Set Link open destination to External and repeat the new-window link.
  • Expected: The current document displays after main-frame commit; slow assets do not strand the empty state. New links preserve earlier resource tabs. Tab switching retains forms, scroll positions, JS state and independent history without another page request. Each tab keeps its last address; same-tab navigation updates only its originating tab. Failure/stop leave usable address controls. External mode does not create a work-panel tab. Session switches and invalid schemes retain their security and visibility gates.
  • Additional paths: Invoke BrowserPreview through ToolSearch/BrowserPreview while a tab has unsent form input: only a new host-owned page navigates, before its renderer tab mounts. Navigate for an inactive session and verify immediate execution in its connected page without changing the visible one. Open a blank tab and inspect its empty address and state. Close a background tab and verify its WebContents is destroyed without affecting its sibling; deleting a session and plugin/window disposal release the appropriate retained pages.
  • Status: Targeted isolated Electron validation; no new repository test suite.

E2E-BROWSER-in-page-navigation: Browser chrome follows same-document navigation ​

  • Preconditions: Browser plugin enabled; a local page has a fragment link and a History API route control.
  • Steps: Open the page; click the fragment link; change the route with history.pushState; use Back, Forward, and Reload. Switch to another session and verify late events from the old page cannot update the new preview.
  • Expected: The address matches the main document URL and history buttons reflect its history. Loading settles back to Reload. Subframe, replaced-frame, and invalidated-session events cannot change the toolbar.
  • Coverage: browser-pane-navigation.test.mjs for published state and event isolation; native Electron click-through for actual same-document events.
  • Specs linked: 04-ux/08-component-spec.md §5.3.

E2E-BROWSER-background-lifecycle: Side-panel presentation does not own page execution ​

  • Preconditions: Dedicated worktree, reused Electron/toolchain, isolated profile and localhost A/B/C pages. No real account or paid provider requests.
  • Steps: Snapshot A, switch to B, fill and click A through production CDP, then start a new hidden screenshot. Detach presentation, reload the renderer, repeat A's snapshot/screenshot and restore its tab. Open a first C preview before UI mounting. Close C and try unknown/closed targets. Exercise every Browser API through real concurrent plugin invocations with spoofed argument identities. Restore missing renderer tabs while the panel is collapsed.
  • Expected: A's actions, pixels and screenshot path belong to A; B is unchanged and remains the only visible guest. No background action reveals a page. Renderer reload retains native identity and inputs. C executes before mounting. Unknown/closed targets fail without fallback. Restored tabs are scoped, duplicate-free and do not reopen or replace a user's selected resource. Hidden backing hosts remain invisible and release with the last parked page.
  • Automation: node scripts/e2e-browser-background.mjs, browser-background-session.test.mjs, browser-tab-restore.test.mjs, browser-capture-resize.test.mjs, and plugin-tool-invocations.test.mjs. The isolated Desktop trial also uses real Composer, sidecar, plugin and Host wiring with a localhost SSE model; no real provider is contacted.
  • Specs: 04-ux/08-component-spec.md, 07-plugins/03-plugin-api.md, 05-security/01-security.md, background lifecycle ADR.
  • Status: Automated native Chromium/service paths; isolated Desktop trial passed.

E2E-060: Files tab browsing stays inside the workspace ​

  • Preconditions: Workspace with file artifacts for nested source, large (>512KB), image, and binary files.
  • Steps: 1) Activate each file artifact and verify a distinct path-keyed resource in the header switcher; browse the tree, expanding nested folders. 2) Open a source file, the image, the binary, and the large file. 3) Use reveal-in-Finder. 4) Attempt a traversal read (../outside) via devtools IPC. 5) Switch workspaces.
  • Expected: Directories list lazily, folders first, with .git / node_modules / build outputs hidden; text renders with syntax highlight (capped at 5000 lines), images preview inline, binary and oversized files show fallbacks with reveal still available. Traversal attempts are rejected with INVALID_ARGUMENT; no workspace → empty state; switching workspaces resets the tree and viewer.
  • Specs linked: 03-runtime/01-ipc-protocol.md §13a, ADR 0019, 03-runtime/15-workspace-ignore-rules.md
  • Acceptance: D (workspace), Security
  • Milestone: M5
  • Status: Unit-covered (fs-panel-guard.test.mjs); full UI scenario Draft

E2E-059a: Transcript message plates follow WorkBuddy density ​

  • Preconditions: A session contains at least one short user prompt, one longer user prompt, and a completed assistant answer; light and dark themes available.
  • Steps: 1) Open the session in dark theme. 2) Inspect user and assistant rows at rest and on hover. 3) Start a streaming assistant answer. 4) Switch to light theme and repeat. 5) Focus the copy control with the keyboard.
  • Expected: User turns are right-aligned, theme-neutral soft plates capped near 560px, derived from each theme's primary text ink rather than an accent tint, with a subtle border; assistant answers remain transparent full-width prose in the 720px content band, including while they stream — no left rail and no whole-turn --ds-tile (D323). The tile belongs only to a subagent/delegation card (D319). Row spacing is denser (~10px). Copy chips are hidden at rest, appear on hover/focus-within, and stay right-aligned under user turns. Both themes keep readable contrast on the user plate.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md §8.3 / §8.4, 04-ux/10-workbuddy-benchmark-ux.md, decisions-log D101, D323
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (transcript-style.test.mjs); full visual scenario Draft

E2E-CHAT-transcript-context-menu: Right-click a message or the transcript ​

  • Preconditions: A session contains a completed user prompt and a completed assistant answer; the conversation pane is focused.
  • Steps: 1) Right-click the user plate. 2) Choose Copy, then Select message text. 3) Select a phrase in the user plate, right-click that plate, and choose Copy; collapse the caret, right-click again, and choose Copy. 4) Right-click the assistant turn and choose Copy. 5) Right-click empty space below the last turn and choose Copy conversation. 6) Press Escape on an open menu, then Tab. 7) Right-click a markdown link in the answer. 8) Edit the user message, replace its content, select a phrase, and right-click Copy. Repeat with a collapsed caret and Select message text, then cancel editing and copy the saved message.
  • Expected: The user menu lists Copy, Select message text, Edit, and a separated Delete; the assistant menu lists Copy, Select message text, Regenerate, and Branch. Copy writes the live selection in the right-clicked turn when the menu opened over one; a collapsed caret or a selection outside that row falls back to the whole turn. Copy conversation writes the labelled thread. Both show a toast. Select text highlights the bubble. Escape and Tab dismiss the menu without running an item. A link still offers Open in default browser, Open in work panel, and Copy link address. Quote, Annotate, and Open side chat are absent (ADR 0268). The surface is a viewport-fixed body-level layer and does not resize the transcript. During editing, Copy uses the selected draft text or the full draft with a collapsed caret; Select message text selects the draft. Edit, Delete, and revision actions are absent until editing ends. Cancel preserves the original message. Automated Chromium component check: node scripts/e2e-message-edit-copy.mjs.
  • Specs linked: 04-ux/08-component-spec.md §8.3 / §8.5, 04-ux/09-interaction-patterns.md (floating dropdown surfaces), ADR 0268
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (chat-context-menu.test.mjs, chat-context-menu-items.test.mjs, chat-context-menu-surface.test.mjs); full UI scenario Draft

E2E-CHAT-radial-background-menu: Radial menu on the empty transcript background ​

  • Preconditions: Settings → AI → Radial context menu is available. A session has a completed user prompt, a completed assistant answer, and enough history to scroll.
  • Steps: 1) With the switch off, right-click empty space below the last turn and confirm the vertical conversation menu. 2) Turn the switch on and right-click the same space; inspect the four sectors. 3) Move the pointer through each sector and click Copy conversation. 4) Set Up to Copy last answer and Right to Leave empty, then reopen the ring. 5) Click the hub without moving, then reopen and press Escape; repeat with an outside click, a scroll, and a window resize. 6) Reopen with Shift+F10, walk the sectors with the arrow keys, and run one with Enter. 7) Restart the app, confirm the switch and the four directions persist, then write an unknown action for one direction into the settings file and confirm that direction resolves to its default.
  • Expected: With the switch off the vertical menu opens unchanged. With it on, a four-sector ring centred on the pointer replaces it and stays fully inside the window near an edge. Each sector highlights under the pointer; clicking runs that action, closes the ring, and shows the usual toast where the action copies. A Leave empty direction renders a disabled sector and runs nothing; Copy last answer is disabled until an answer exists. Open browser reveals the work panel on a blank browser tab, the work panel direction toggles — it reads Open work panel while the panel is collapsed and Close work panel while it is open, and the second click closes it — and Open project folder opens the session's project in the OS file manager and is disabled for a session without one. A click on the hub, an outside press, Escape, Tab, scroll, and resize all dismiss the ring without running anything. Arrow keys move the highlight and Enter runs the highlighted action. The switch and the four directions survive a restart, and an unknown stored action falls back to that direction's default. Message rows, links, file references, the sidebar, and the work panel keep their vertical menus.
  • Specs linked: 04-ux/09-interaction-patterns.md §1.11, 04-ux/06-settings-ia.md
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Chromium component check (node scripts/e2e-radial-menu.mjs: sector labels, pointer highlight, click/hub/disabled/Escape paths, arrow-key focus, and viewport clamping) plus unit coverage (radial-menu.test.mjs, settings-roundtrip.test.mjs); full desktop journey Draft

E2E-CHAT-delete-turn-preserves-other-live-outputs ​

  • Preconditions: A session has three completed turns. The renderer has assistant and tool outputs for all three turns, while persistence is missing at least one output because its outbox append is still pending.

  • Steps: Delete the middle user turn, then reload the session after the transcript replacement completes.

  • Expected: The deleted user turn and its outputs remain absent. The first and third turns retain their user, assistant, and tool messages before and after reload. A pending append cannot run after transcript replacement and restore the deleted turn.

  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0060

  • Acceptance: C (chat stream), F (persistence), Quality

  • Milestone: M6+

  • Status: Unit-covered (delete-message-preserves-transcript.test.mjs, persistence-outbox.test.mjs); complete desktop journey remains Draft

  • Full-history regression: Open a 140-message session without scrolling back and choose Copy conversation. The clipboard must contain messages 1–140, not only the loaded tail. Repeat from a search context window and with a truncated long message. Preserve visible in-flight text; a failed history read must report an error and leave the clipboard unchanged. Copy must not load history until selected or change the reading position. Run node scripts/e2e-copy-conversation.mjs.

E2E-CHAT-copy-formula-as-tex: Copying rendered math yields its source ​

Math boundaries remain parseable after copying: touching inline fences get one separator, and every prose dollar in the copied text is escaped, together with backslash runs that would otherwise escape a fence. Annotation whitespace is preserved; widened multiline inline math uses a literal <span> wrapper to prevent a flow opener when pasted at column zero. TeX newlines are not flattened because they can terminate % comments. The wrapper is Markdown source in text/plain, not a text/html payload; compatibility with external editors that disallow inline HTML is not promised. Regression coverage checks both copy entry points and Markdown round trips for adjacent formulas, prose dollars on either side, formatting wrappers, line/block boundaries, padding, and multiline math including TeX comments. Display math containing - x, + x, * x, > x, or internal blank lines must remain a single math node after copying. The shared remark grammar keeps unclosed math in the streaming tail until its fence closes; subsequent prose and its source offsets remain intact. A footnote whose definition follows its reference — adjacently or streamed in later — renders as a real reference and note section rather than a literal [^1], which is what block splitting costs when a slice is parsed without the rest of the message. CRLF, ordinary lists, quotes, GFM tables, fenced code and the sources that must keep splitting are covered by markdown-blocks.test.mjs.

  • Preconditions: An assistant answer contains inline math inside a sentence, a display formula on its own lines, a \[ … \] formula inside a sentence and another on its own lines, a \( … \) formula, a formula carrying a literal $, an inline formula whose value carries a newline and another whose value carries one at each edge, a paragraph with no math, and — each sharing a selection with a formula — a paragraph whose source wrapped across several lines, a bulleted list, a table, a second table whose cell holds a \[ … \] formula, a fenced code block that contains blank lines, and a turn whose prose is interrupted by chrome that is unselectable only because it inherits the shell's user-select: none (a tool row's section head).
  • Steps: 1) Select the sentence holding the inline formula and press Ctrl/Cmd+C. 2) Select the display formula and copy it. 3) Select from the middle of a formula to the end of its sentence and copy. 4) Select a paragraph, a formula, and another paragraph together and copy. 5) Select prose with no formula in it and copy. 6) Copy each of the wrapped paragraph, the list, both tables, and the code block. 7) Copy each \[ … \] formula, the \( … \) one, the formula carrying a literal $, and the two whose values carry newlines. 8) Select a formula backwards — from the end of the sentence to its start — and copy. 9) With a formula selected, right-click that turn and choose Copy, then right-click a different turn and choose Copy. 10) Select a turn's whole contents the way the Select text item does, so the range is anchored on the turn while the copy is raised on a paragraph inside it, and copy. 11) Select without ever leaving one formula — a drag inside its rendering — and copy. 12) Render each result back through the same answer renderer — what pasting it into the composer and sending it produces — and compare the formulas it paints with the ones the selection covered.
  • Expected: Every formula reaches the clipboard as the TeX it was written in — $…$ inline, $$…$$ on its own lines — instead of the glyphs KaTeX painted, or the same expression twice over from its two trees. A formula carrying a literal $ widens its delimiter run past it, as a code span's fence does, and pastes back as the formula rather than as prose; an inline formula whose value carries a newline keeps the narrow run, because a $$ run at the start of a line would open a flow block and swallow the paragraph; one whose value carries a newline at each edge is written with padding of its own, because the grammar drops a line ending there as it drops a space. A cut that starts inside a formula copies the whole formula, and a selection that never leaves one copies that formula alone. Everything else in the selection is byte for byte what the platform already copied: a wrapped paragraph stays one line, a list keeps one line per item, a table keeps tabs between cells and one line per row including the newline the platform closes the last row with, and a code block keeps its blank lines. Chrome the copy leaves behind stays out of the clipboard whether base.css marks it user-select: none by selector (a code block's language rail) or it is inert only by inheriting the shell's default (a tool row's section head, a compact thinking row). A selection with no formula in it is written by the platform unchanged; a selection that holds one reaches the clipboard as its source however deep inside it the copy was raised, so a range anchored on a turn — what Select text produces — copies as TeX even though Chromium raises the event on a paragraph within it. Right-click Copy and Ctrl/Cmd+C produce the same string for one selection, and a right-click on a turn that does not own the selection reads no excerpt from it, so Copy falls back to that turn's own source. A pasted formula renders as the one it came from — same TeX, and display math still display math — \[ … \] included: it is painted inside its sentence's paragraph, and copies with $$ on a line of its own, which is enough to open a display block when it is pasted back. \( … \) stays inline. A table cell reaches it too, measured rather than assumed: the .katex-display box breaks the row for the platform's own reading as well, so no tab lands beside the fence and the formula copies as display math with the neighbouring cell intact on its own line. A backward selection is still backward after the copy. The copy writes text/plain only — no text/html flavour is put on the clipboard.
  • Specs linked: 04-ux/08-component-spec.md §8.7, ADR 0268, decisions-log D619, issue #414
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Automated (pnpm test:e2e:copy-tex, real Chromium) plus selection-tex.test.mjs

E2E-060b: Neutral gray accent across chrome ​

  • Preconditions: App running in dark and light themes; plugins page and a chat with markdown links/blockquotes available.
  • Steps: 1) Inspect focus rings, primary buttons, toggles, selected session ring, plugin market primary CTAs. 2) Open an assistant answer with links and a blockquote. 3) Switch theme and re-check.
  • Expected: No blue brand accent remains. Interactive accent, markdown links/rules, and plugin primary actions resolve through the neutral gray accent tokens (white/gray dark, dark-ink light). The plugins installed/market UI (tabs, search, cards, permission modal, and primary / secondary buttons) uses only --ds-* tokens with no blue-slate fallbacks in either theme; button surfaces and ink remain visible in dark mode. Semantic success/warning/error colors are unchanged.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (neutral-accent.test.mjs, plugins-page-style.test.mjs); visual scenario Draft

E2E-060c: Assistant markdown prose hierarchy and code chrome ​

  • Preconditions: A completed assistant answer containing headings, a blockquote, a GFM table, a fenced code block with a language tag, inline code, a task list, and a remote image link; light and dark themes available.
  • Steps: 1) Open the session in dark theme and scroll the answer. 2) Hover the code-block copy control and the table rows. 3) Expand a thinking disclosure that contains markdown. 4) Switch to light theme and re-check contrast on inline code, blockquote rule, and code card.
  • Expected: Answer prose uses the .prose-chat hierarchy (h1–h6 ramp, accent-tinted blockquote, hairline-bordered inline code, zebra/hover table shell, inset code card with monospace language tag). A wide GFM table stays inside the transcript column: headers and cells wrap rather than overflowing. Thinking prose stays secondary/smaller and does not merge into the answer. Both themes keep readable contrast; copy still copies raw fence text.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md §8.7
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (user-select.test.mjs, thinking-ui.test.mjs, markdown-prose-style.test.mjs); full visual scenario Draft

E2E-061: User message plaintext layout survives wrapping and reload ​

  • Preconditions: Provider configured; composer can accept multi-line input via Shift+Enter (or Enter-to-send disabled).
  • Steps: 1) Compose a three-line prompt with two hard newlines and a URL whose encoded path is wider than the user plate. 2) Send. 3) Inspect the user bubble in the transcript. 4) Copy the user message and paste into an external editor. 5) Reload the session.
  • Expected: The user plate shows three distinct lines (not collapsed to a single paragraph). The linked URL wraps inside the plate without horizontal overflow, and every continuation line stays logical-start aligned with the first line instead of being centered. Copied text retains the original newlines. After reload the same line breaks remain.
  • Specs linked: 04-ux/08-component-spec.md
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (transcript-style.test.mjs); full visual scenario Draft

E2E-060d: Assistant meta chips, compact context summary, and retry action ​

  • Preconditions: A completed visual turn includes modelId and token usage across more than one assistant message; another completed assistant message has content but no usage. The selected model has a published 1m-class context window, while its provider binding still contains the legacy 128k generic seed.
  • Steps: 1) Open the session. 2) Confirm the completed turn shows a model badge and no context inspector under the answer. Confirm the turn usage capsule follows the badge with its average speed and compact total, then hover or keyboard-focus it and inspect the cache-read/input/output exact counts, the turn total, and cache write only when non-zero. Move the pointer into the popover and confirm it stays open, then dismiss it with Escape and with an outside pointer press. 3) Hover the composer toolbar inspector trigger, confirm the panel stays closed, then click it. 4) Inspect the remaining-token-plus-percentage heading, used/window counts, unboxed turn/speed values, total session duration, and three timing rows for network/provider wait, streamed model response (including thinking and answer), and tool execution, each with its share of session duration. Confirm overlapping top-level tool calls are counted once, nested delegate timings are excluded, and one inline provider-usage summary plus one aggregate tool-usage summary remain. There is no doubled heading rule or inner section hairline. For a transcript with more than one loaded page, wait for earlier timing metadata to finish loading and confirm the cumulative values include it. 5) Scroll the transcript and resize the window while the panel is open. Toggle and resize the sidebar and work panel while the panel remains open. 6) Move the pointer away from the panel, then dismiss it by clicking the trigger again, clicking outside it, and pressing Escape from the keyboard. 7) Open the work panel on a browser-preview artifact, open the context inspector, and confirm the summary stays inside the conversation pane: fully visible, clear of the panel column, and narrowed rather than clipped on a narrow pane. 8) Click Retry on that turn while idle. 9) Confirm a session without usage still offers Retry on completed turns and omits the composer inspector.
  • Expected: Model badge appears under completed assistant answers when a model id exists, followed immediately by the turn usage capsule when that turn has usage. The capsule's speed is summed output tokens over summed response duration, and its total sums input/output/cache read/cache write across the visual turn, counting reasoning inside output and excluding subagent spend. Its popover stays open while the pointer is over it, closes on pointer leave after a grace delay unless the trigger retains keyboard focus, on Escape, or on an outside press; it portals to the body, clamps to the conversation pane, and is absent on streaming turns. The compact Context inspector appears in the composer right toolbar, left of the model picker, once any usage exists, and always mirrors the newest usage-bearing assistant turn. The trigger shows the ring plus remaining-capacity percentage (no Context label) and low-space warning/error states; click or keyboard activation toggles the same compact summary while pointer hover alone never opens or closes it. An open panel survives the pointer leaving it and closes on a second trigger activation, an outside click, or Escape, which returns focus to the trigger. Session duration runs from session creation to the latest activity (or to now while running). Network request wait ends at the first model response event; model response includes streamed thinking and answer, and tool time excludes nested delegates and unions overlapping top-level calls. Each cumulative timing percentage is relative to that wall-time span. Earlier transcript pages load only while the inspector is open. Provider values remain exact, tool values remain visibly approximate through the ~ aggregate total, and no per-tool list, source badge, progress bar, or explanatory estimate paragraph is rendered. Occupancy, turn total, and provider cache/input/output/reasoning/hit-rate are the last usage-bearing assistant message, not the summed tool-loop, so cache read stays on the same scale as the context window; this last-request value remains distinct from the capsule's visual-turn aggregate. The cache hit rate is omitted when cache-read metadata is absent rather than inferred. A published 1m-class limit (for example gpt-5.6-luna at 1,050,000 tokens) is shown instead of 128k, the same effective window is used by the agent runtime, and a non-default Advanced override remains honored. Generation rate remains a completed-turn value and does not update during streaming; Retry re-sends the nearest preceding user prompt and is disabled while a turn is running; the portaled panel stays inside the conversation pane so the work panel's native browser or plugin surface can never cover it, narrows with a narrow pane instead of crossing that edge, and follows the trigger after scrolling or resize; Copy still excludes thinking text.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/10-workbuddy-benchmark-ux.md, 03-runtime/01-ipc-protocol.md
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (transcript-style.test.mjs, context-usage.test.mjs, latest-turn-context.test.mjs, runtime usage mapping); full scenario Draft

E2E-061a: Regenerate replaces the current turn in place ​

  • Preconditions: A session has user A → assistant A → user B → assistant B.
  • Steps: 1) Hover assistant A and click Regenerate. 2) Wait for the new turn to complete. 3) Reload the session.
  • Expected: Transcript truncates away assistant A / user B / assistant B before the redo starts; only user A plus the new assistant/tool tail remain. The regenerated answer does not leave the old branch above it. Reload keeps the truncated branch only.
  • Specs linked: 04-ux/08-component-spec.md, 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md
  • Acceptance: C (chat stream), F (persistence)
  • Milestone: M5
  • Status: Unit-covered (store/main truncate wiring tests); full scenario Draft

E2E-062: Regenerate history pager restores prior variants ​

  • Preconditions: A session where an assistant answer was regenerated at least once.
  • Steps: 1) Click Retry/Regenerate on a completed assistant turn. 2) Observe the visible root user bubble while the replacement turn starts and after it completes. 3) Switch to a previous variant. 4) Switch forward again. 5) Reload the session.
  • Expected: The root user bubble remains visible and shows the current / total pager inside its action toolbar once the row is hovered or focused; the toolbar, including the pager, is hidden by default. Retry does not move or detach the selector from that bubble. Switching restores the archived assistant/tool branch in place. Reload preserves the active variant and the full revision set.
  • Specs linked: 04-ux/08-component-spec.md, 03-runtime/04-data-storage.md
  • Acceptance: C (chat stream), F (persistence)
  • Milestone: M5
  • Status: Unit-covered (sessions::tests::save_and_activate_message_revision, schema v4 migration); full scenario Draft

E2E-063: Empty home keeps the primary task surface focused ​

  • Preconditions: App running on empty chat home (no transcript) in light and dark themes; window can be resized to ~1200×690 and ~900×640.
  • Steps: 1) Open empty home. 2) Confirm the hero contains the quiet logo, localized title, and short supporting line. 3) Confirm no developer starter cards or contextual quick-action row is rendered. 4) Dismiss onboarding and inspect again. 5) Repeat in the other theme. 6) Resize to a short height and scroll the content region if needed.
  • Expected: The default empty state keeps the hero and bottom composer as the visual anchors, with optional onboarding as the only additional content. Dismissing the checklist leaves no empty spacer. The composer remains at the bottom without covering the hero or checklist, and short windows keep every content block reachable via scroll.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 08-meta/decisions-log.md (D111/D131/D204/D206)
  • Acceptance: Quality (layout integrity)
  • Milestone: M5
  • Status: Unit-covered (home-empty-layout.test.mjs); full UI scenario Draft

E2E-094: Active turns keep the lower transcript surface clear ​

  • Preconditions: A deterministic agent can emit thinking, tool start/end, streamed answer, permission, and terminal events for a durable session; a second session can run in the background.
  • Steps: 1) Start a turn in the visible session. 2) Observe the transcript while the agent is thinking, using tools, and streaming an answer. 3) Trigger a permission request and inspect the approval card. 4) Switch to a second session while the first continues. 5) Return to the first session after completion.
  • Expected: No generic Understanding, Working, Checking, or completion card appears below the transcript while the turn is active. Assistant and tool rows remain inline. One compact tail status remains visible throughout the running turn, including partial-answer pauses and completed-tool gaps. Runtime phases take precedence over Working or Planning/Goal; pending user interaction suppresses the status. Only an actual permission request renders an actionable permission card. Background activity never changes the visible session, transcript, composer focus, or project.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 03-runtime/10-session-state-machine.md
  • Acceptance: C (chat stream), Quality (interaction and accessibility)
  • Milestone: M5
  • Status: Unit-covered (active-turn-surface.test.mjs); full UI scenario Draft

E2E-095: Terminal failures expose recovery without a success card ​

  • Preconditions: A deterministic provider can recover from a failed directory Read with Glob, complete a turn with a workspace edit, and fail another turn with a retriable error; the session has a visible composer.
  • Steps: 1) Run the failed-Read then successful-Glob recovery turn and inspect its activity group. 2) Complete the workspace-edit turn. 3) Confirm that no success outcome card appears and inspect the inline review card immediately after the change tool row. 4) Expand the inline card and verify its hunks. 5) Commit the edited file and confirm the recorded card remains, then use rollback once. 6) Trigger the retriable failure. 7) Inspect the failure card, then choose Retry. 8) Start another new prompt and inspect the old card.
  • Expected: The recovered turn keeps the failed Read visible on its own row, labels the containing group as processed, completes its session outcome, and shows no failure card. Completion uses the transcript and inline review card as its evidence without adding a "Task complete" card. File status, counts, and hunks remain on the adjacent card after commit, and guarded rollback restores the pre-tool state. Failure shows that existing work remains, exposes Retry and Continue, and retry preserves the latest prompt. A new turn clears the previous failure card; an abort creates no failure outcome copy.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 03-runtime/10-session-state-machine.md, ADR 0069
  • Acceptance: C (chat stream), Quality (completion and recovery)
  • Milestone: M5
  • Status: Unit-covered (assistant-turns.test.mjs, interaction-performance.test.mjs, turn-outcome-card.test.mjs); full UI scenario Draft

E2E-064: Durable notification inbox records terminal task outcomes ​

  • Preconditions: Two durable sessions exist; a deterministic provider can complete one turn, fail one turn with a stable error code, and abort one turn; notification inbox starts empty.
  • Steps: 1) Focus and view session A, then complete a turn in A. 2) While still focused on A, fail a turn in background session B. 3) Unfocus the window and complete another turn in A. 4) Restore/focus the app from its taskbar or Dock without switching sessions and confirm A's visible terminal outcome is acknowledged. 5) Abort a fourth turn. 6) Repeat each terminal RPC. 7) Confirm the main titlebar has no bell, then open the bell in the expanded sidebar footer and switch between All and Unread. 8) Mark one row read and confirm its session has no terminal sidebar mark, then close/reopen the popover and restart the app. 9) Select the other session from its terminal-marked sidebar row. 10) Generate a host fixture with 205 eligible terminal turns. 11) Use Mark all read, then Clear. 12) While a native task banner and a renderer refresh are still in flight, deliver a delayed notification.changed payload for a cleared/read durable id and a duplicate payload for an id that is already present. 13) On Windows, create one unread successful outcome while the bell has no failure rows. Open the empty bell popover, use Mark all read, then create another success and use Clear. 14) Create enough unread outcomes for a two-digit count and inspect the taskbar overlay before and after marking all outcomes read.
  • Expected: A's visible-current completion creates no row or terminal sidebar mark. Restoring/focusing the app with A already visible clears A's matching durable outcome and taskbar count without clearing B. Exactly two rows exist, newest first: the unfocused A completion and background B failure, with localized labels, snapshotted session titles, and B's stable code. Abort/repeated terminal calls create no row. The former footer Help shortcut is absent; the 32px footer bell and its upward-opening popover replace it. Badge and Unread show the exact unread count without opening implicitly reading rows. Read state and both records survive restart. Row selection marks it read and activates its bound project/session. The fixture retains exactly the newest 200 rows. Mark all preserves rows with zero unread. Selecting the other session clears its terminal sidebar mark and marks its task notification read; neither mark returns after refresh or restart. Clear empties only the inbox and leaves sessions, turns, and transcripts intact. Clear/read also dismisses any outstanding task-native object, a repeated durable id produces no second banner or row, and a delayed pre-clear event cannot resurrect the cleared item; a genuinely new post-clear turn still produces exactly one row and banner. Each newly accepted task notification plays one soft in-app chime; duplicate, delayed, and historical rows do not replay it. On Windows, the taskbar overlay is a smooth black circular badge with a centered white system-font numeral. Its visual weight matches common Windows taskbar badges for both single- and two-digit counts. A success-only outcome increments the taskbar badge without adding a bell row or bell count; both toolbar actions remain enabled when relevant and clear the taskbar badge. The badge also clears when no unread outcomes remain. The underlying taskbar button keeps the Nib P icon and Nib name while the overlay is present.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/01-ipc-protocol.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 08-meta/decisions-log.md (D117/D130)
  • Acceptance: C (turn completion), F (persistence), Quality
  • Milestone: M5
  • Status: Draft

E2E-065: Native task notifications are unfocused-only and activate sessions ​

  • Preconditions: Native notifications are supported; sessions A and B exist; the main window can be focused, unfocused, hidden, and minimized. A Windows run uses the NSIS-installed app or the standard development command.
  • Steps: 1) Keep the app focused on A and complete a turn in A. 2) While still focused on A, complete a turn in B. 3) Unfocus the app while A remains current and complete another turn in A. 4) Let A's notification move into the OS notification center, then click it. 5) Minimize the app, fail another turn, and click its native notification. 6) Unfocus the app and abort a turn. 7) Repeat with native delivery suppressed by the OS. 8) On Windows, inspect the native notification attribution, notification-settings entry, taskbar group, installed executable, and Start menu shortcut. 9) Deliver the same durable id twice, then mark it read and clear the inbox while its native object is still pending in the OS.
  • Expected: Focused-current A creates neither inbox row, terminal sidebar mark, nor native banner. Focused-background B creates an inbox row without a native banner. Unfocused current A and the minimized failure each create one durable row and one localized, silent native notification plus one soft in-app chime. Clicking restores, shows, and focuses the main window before activating the matching session, including after the notification has moved to Windows Action Center; no event opens the wrong currently selected session. Abort shows neither surface. OS suppression does not lose the durable row or surface a misleading app error. Every inspected Windows system surface identifies Nib; no stock Electron application name or identity is exposed. Duplicate delivery is idempotent: one durable id owns at most one live native object, and mark-read/clear closes that object so a late activation or renderer event cannot show the old task again.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/07-ui-design-system.md, 04-ux/09-interaction-patterns.md, 08-meta/decisions-log.md (D117/D141)
  • Acceptance: C (turn completion), Quality
  • Milestone: M5
  • Status: Source-contract covered (notification-contract.test.mjs); packaged Windows Action Center activation remains runner validation; full UI scenario Draft

E2E-065A: App notifications use one soft chime ​

  • Preconditions: A running desktop app with audio output; a second session for background notification delivery.
  • Steps: 1) Trigger info, success, warning, and error toasts. 2) Complete or fail a background task. 3) Trigger an asktool request and a permission or plan approval request. 4) Replay a durable notification id and refresh the inbox.
  • Expected: Each newly presented app toast, task notification, interactive prompt, and successfully shown plugin-native notification plays one short, low-volume chime. Rapid events coalesce; duplicate or historical notification rows do not replay it. Asktool's toast does not add a second chime, and native banners stay silent to prevent a second, platform-dependent sound. If Web Audio is unavailable or blocked, visual notifications still work and no app action fails.
  • Specs linked: 03-runtime/17-asktool-questions.md
  • Acceptance: C (notification feedback), Accessibility
  • Status: Unit/source-contract covered; audible desktop check Draft

E2E-066: Provider model catalog survives restart and offline refresh ​

  • Preconditions: A saved provider matches a models.dev provider/API URL and has at least two catalog models; a deterministic fixture can make https://models.dev/api.json unavailable and can also make a provider discovery endpoint unavailable.
  • Steps: 1) Open the provider model picker and confirm models.dev model names, limits, capability badges, and source label appear. 2) Inspect the network fixture and confirm the provider API key is never sent to models.dev. 3) Quit and restart the app. 4) Disconnect models.dev and the provider endpoint. 5) Open the Composer model menu and wait for refresh fallback. 6) Reconnect only the provider endpoint with one custom model, then reopen the picker. 7) Warm lookups for a known and an unknown model, then refresh the catalog fixture with changed metadata and the previously unknown model. 8) Repeat after making that catalog refresh fail.
  • Expected: The first picker open renders the configured/provider cache without starting from an empty list. On restart, the bundled models.dev release snapshot is used without network access. A failed Settings refresh preserves that in-memory snapshot; a custom provider then falls back to its endpoint only for IDs absent from models.dev and finally the configured bindings. Offline refresh preserves every cached/configured entry. A successful provider discovery may persist normalized IDs to Rust-owned SQLite, but it cannot replace models.dev metadata or user-defined bindings. A successful catalog refresh replaces both cached matches and cached misses; a failed refresh keeps the previous results. Editing a model binding or changing the default provider/model takes effect without restarting the app.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md, 04-ux/08-component-spec.md
  • Acceptance: B (model config), F (persistence), Quality, Security
  • Milestone: M5
  • Status: Unit-covered (providers::tests, model-cache.test.mjs, models-dev-catalog.test.mjs); full restart/offline UI scenario Draft

E2E-080: Models.dev metadata and generic unknown models ​

  • Preconditions: A provider/model exists in the models.dev fixture with limits, modalities, and reasoning options. A second fixture contains a provider-discovered model ID absent from models.dev.
  • Steps: 1) Open the provider model picker and select the fixture model. 2) Confirm its models.dev name, context/output limits, capability badges, modalities, cost fields, and thinking levels. 3) Start a short turn and inspect the sidecar model snapshot/request metadata. 4) Use Settings → Model configuration to force a models.dev refresh and confirm the new record is visible without changing the bundled file or writing a user cache. 5) Repeat with an ID absent from models.dev and confirm its limits display as unknown in Settings. 6) Open the picker at a wide but short viewport (1254 × 772 CSS px): confirm the model panes remain side by side, both lists scroll inside their panes, and a selected model's Advanced fields are reachable. Narrow the viewport to 520 × 480 CSS px and confirm the panes stack while both lists and Advanced remain reachable.
  • Expected: The matching models.dev record is authoritative, including its limit, modalities, reasoning_options, tool_call, structured_output, dates, and cost fields; no provider secret is sent to the catalog. Vendor-prefixed records match providers whose configured model ID omits the prefix when the vendor/API identity is unambiguous. A PDF-capable model shows PDF in its modality metadata; PDF attachments remain bounded file references until the selected transport exposes a native PDF block. A provider-discovered or explicitly configured ID absent from models.dev remains runnable with the generic text-only, non-reasoning shape; Settings shows an em dash instead of presenting its 128k / 8.2k runtime fallback as a published model limit. Explicit user limits remain visible. pi-ai supplies only the selected wire adapter, OAuth flow, and fallback account IDs. The custom endpoint keeps its selected API style even if the published model adapter differs; named providers may retain a model-level route when required. A ChatGPT Plus/Pro or GitHub Copilot account lists IDs returned by its live account endpoint; matching models.dev records supply their published metadata, while live-only IDs absent from models.dev keep generic chat limits and capabilities.
  • Specs linked: 02-architecture/02-tech-stack.md, 03-runtime/11-provider-model-system.md, 03-runtime/13-model-catalog-and-selection.md, ADR models-dev-catalog-authority
  • Acceptance: B (model config), C (conversation & stream), Security
  • Milestone: M5
  • Status: Unit-covered (model-capabilities.test.ts, models-dev-catalog.test.mjs); full UI scenario Draft

E2E-067: Platform application menus and window chrome ​

  • Preconditions: Native macOS, Windows, and Linux runners; built desktop app; English and zh-CN locales available. The Windows/Linux harness can set PI_DESKTOP_START_MAXIMIZED=1 before launch so Main maximizes the hidden native window before renderer mount.
  • Steps: 1) On macOS, launch both pnpm dev and a packaged build. Confirm the application-menu title is Nib, open About Nib, and inspect its name, version, and icon. Then open every system menu and invoke New Task, Open Project, Settings, global search, sidebar toggle, editing, zoom/fullscreen, Window, Help, Logs, and Check for Updates actions. Verify the update status reports that the current fixture version is up to date. 2) On Windows/Linux, confirm no File/Edit/View/Window/Help menubar appears inside the window and the left-side navigation occupies the reclaimed titlebar space. Verify F10 and Shift+F10 are not consumed by shell chrome; exercise New Task, Open Project, Settings, close-window, zoom, fullscreen, global search (Cmd/Ctrl+K and Cmd/Ctrl+Shift+P), sidebar, and standard editing shortcuts. Invoke Check for Updates from Settings -> Info with the same status result. 3) Close the macOS window, immediately invoke two native menu commands, and acknowledge renderer readiness after the replacement loads. Verify one window and one delivery per command. 4) On Windows/Linux, repeat from the main chat, Settings, and an open work panel. With the work panel open, confirm the viewport-fixed toggle and native window controls stay at the window's right edge over the panel header, that resource switching plus close actions remain available from the panel menu to their left, and that the native window controls themselves still take hover and click — clicking a control must not move the window. In the main chat, send a first user message and confirm its full bubble starts below the 46px titlebar control band. Open the Extensions page and confirm its header actions, then the detail sheet's close button, also start below that band and take their own clicks instead of moving the window. Click the center plus the top, bottom, and titlebar-facing edges of each right-side control to minimize, maximize, restore, and close the window. 5) Start the renderer while its native window is already maximized and inspect the initial queried glyph/state. 6) Attempt unknown menu/window IPC actions while a window exists and after it closes. 7) Build each target on its native runner from a clean release-host directory. On Windows, inspect the installed app's taskbar button and Start menu shortcut icon.
  • Expected: macOS development and packaged launches show Nib as the native application identity, and the About panel uses the canonical Nib icon; neither surface exposes the stock Electron name or icon. macOS follows native menu conventions and accelerators. Windows/Linux show no application menu inside the window; navigation and right-side controls do not collide with drag regions, keyboard shortcuts remain operational, and a viewport-fixed work-panel toggle is present on non-Settings routes (not an application-menu command). While the panel is open the native control band and that toggle overlay the panel header; the header ends its box before the band, so the resource tabs and close controls stay clear of the toggle and neither the window controls nor resource close sit under a drag rectangle. Check for Updates invokes the allowlisted update command from the macOS system menu and the Settings surface and shows the resulting up-to-date state. Replacement-window commands wait for renderer readiness without creating duplicate windows or losing events. No Main, Settings, or work-panel drag rectangle overlaps the reserved control zone. The 120px control band is an opaque bg-primary surface in both light and dark themes, with an 8px visual buffer separating it from adjacent work-panel actions, so destination content never bleeds through it. Window controls remain clickable across their full 46px-high hit targets, match native state, and have accessible names; the first user or assistant transcript row never paints beneath them, and neither do the Extensions page header actions or the plugin detail sheet close button. The titlebar and right-side control band share one continuous 1px border-subtle separator; the control band's leading divider uses the same token and its bottom edge does not disappear under the window buttons. Unknown actions fail closed. The installed Windows taskbar button and Start menu shortcut use the Nib icon, never Electron's default icon. Each package contains the target-native host binary (.exe only on Windows). Passing this scenario on Windows/Linux proves shell readiness, not first-release qualification.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/01-ui-ia.md, 04-ux/02-i18n-english-first.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 06-delivery/06-release-runbook.md, 08-meta/decisions-log.md (D118, D121, D129, D357)
  • Acceptance: A (app startup), Quality
  • Milestone: M5 on macOS; post-MVP release qualification on Windows/Linux
  • Status: Unit-covered (window-menu.test.mjs, development-branding.test.mjs); Electron boot probe covers platform bridge, native menu installation, and the pre-render maximize fixture on Windows/Linux; native visual scenario Draft

E2E-143: Close behavior is asked once and stays configurable (D230) ​

  • Preconditions: Windows/Linux run with a clean data dir (no close-behavior.json); the main window is visible; Settings → General is reachable. macOS is excluded: it keeps the native Dock lifecycle.
  • Steps: 1) With the preference unset, click the close button (or press the close-window shortcut) and verify the themed in-app dialog opens with Cancel focused. Escape, a backdrop click, or Cancel keeps the window open and preference unset; Close to tray hides the window, shows the tray icon, and leaves a live turn running; Quit exits the app. Renderer-unavailable behavior uses the native fallback. 2) Re-run each choice and verify it is remembered across a full restart and that no second prompt ever appears once a choice exists. 3) With tray set, click the tray icon: the window restores, shows, and focuses; the tray context menu offers Open and Quit, and Quit exits the app. 4) In Settings → General, switch between Close to tray / Quit app and verify the next close follows the new choice, that the D216 tray icon stays resident either way, that an unset preference shows no selection, and that search matches the row. 5) With quit stored, restart and close the window: the app exits even though the tray icon is present. 6) From any setting, use the renderer minimize control on Windows/Linux and verify the native taskbar entry remains available and restores the same window; on macOS verify its native minimize still hides to the tray. The Windows taskbar toggle is covered separately by E2E-124. 7) Invoke unknown values and "ask" on pi-desktop/window/closeBehavior/set and verify they fail closed, and verify the channel is rejected outright on macOS.
  • Expected: The first close renders the themed dialog exactly once per unset state. Escape, backdrop click, or Cancel never persists a choice; if the renderer is unavailable, a bounded native fallback is used, and failure to show it does not trap close. Tray mode keeps the app alive with a localized tooltip/menu and no data loss; switching to Quit app leaves the tray icon in place, because close-to-tray and macOS minimize still need a restore surface. The preference survives restarts and is honored by both the window-control close button and the close shortcut, and a stored quit exits through the ordered before-quit shutdown rather than depending on window-all-closed. Windows/Linux renderer minimize remains taskbar-native; macOS native minimize remains tray-resident; the bounds watchdog never force-restores a minimized or tray-hidden window. The automated boot probe (app.quit) exits without prompting.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/01-ui-ia.md, 04-ux/09-interaction-patterns.md, 08-meta/decisions-log.md (D216, D230, D256), ADR 0078, ADR 0090, ADR 0123
  • Acceptance: A (app startup), Quality
  • Milestone: M5 on Windows/Linux (release qualification)
  • Status: Themed renderer path, broker fallback, and Main close/quit choices are covered by lifecycle-confirmation-dialog-user-path.test.mjs, lifecycle-dialog-broker.test.mjs, and lifecycle-close-behavior-runtime.test.mjs; the full persisted close-behavior journey remains Draft.

E2E-204: Explicit quit confirms before shutdown (D363) ​

  • Preconditions: A normal interactive session is running (not a boot, supervision, or capture probe). The main window may be visible or tray-hidden.
  • Steps: 1) Choose Quit from the tray menu, or press Cmd+Q / the application-menu Quit item. If tray-hidden, confirm the main window is restored. 2) Verify the themed in-app confirmation, Cancel as initial focus, and distinct title/body. While it is open, issue a second Quit request and verify the same decision remains pending: no duplicate dialog and no shutdown bypass. Exercise Escape and a backdrop click; confirm the window, tray, host-core, and sidecar remain. 3) Repeat and Confirm; verify ordered shutdown. 4) On Windows/Linux with close behavior unset, close the window. Verify the themed D230 dialog offers Cancel / Close to tray / Quit: cancel leaves the preference unset, and choosing Quit causes no second dialog. 5) Launch with PI_DESKTOP_BOOT_PROBE=1 (and supervision/capture equivalents) and confirm app.quit() exits without a dialog. 6) On packaged Windows NSIS or Linux AppImage, restart to install an update; confirm no quit warning appears and the installer completes upgrade and relaunch.
  • Expected: Accidental explicit quit is cancellable. Repeated quit requests cannot duplicate or bypass an outstanding confirmation; Cancel clears the pending decision so a later quit asks again. Tray Quit presents the dialog even when the main window was hidden. A D230 Quit is not re-confirmed. When the renderer is unavailable, crashes, or fails to answer, the bounded native fallback is used; failure to show it does not hold shutdown. Probes never block on the warning. An in-app update restart is never deferred: the installer is spawned before app.quit() and aborts if the app stays alive past its wait window, so that quit goes directly into ordered shutdown.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 08-meta/decisions-log.md (D216, D230, D363), ADR 0022, ADR 0090
  • Acceptance: A (app startup), Quality
  • Milestone: M5
  • Status: Broker, renderer user path, Main close/quit runtime, and pending shutdown lifecycle covered (lifecycle-dialog-broker.test.mjs, lifecycle-confirmation-dialog-user-path.test.mjs, lifecycle-close-behavior-runtime.test.mjs, close-behavior-tray.test.mjs, shutdown-regenerate-persistence.test.mjs). Full application shutdown and packaged update journey remains Draft; run in a capable release environment when lifecycle wiring changes.

E2E-067A: Prerelease install discovers newer stable release (D120) ​

  • Preconditions: Packaged build whose embedded version is a prerelease such as 0.2.0-rc.6; GitHub Releases latest stable tag is newer (for example 0.2.2) with published latest*.yml feeds.
  • Steps: 1) Launch the packaged prerelease install. 2) Wait for the automatic check or invoke Check for Updates from the application menu / Settings → Info.
  • Expected: Update state reports available (manual platforms) or advances through in-app download for packaged macOS, Windows NSIS, and Linux AppImage with availableVersion equal to the newer stable tag. A Windows portable ZIP run stays on the manual notify-and-link path and must not download or run the NSIS installer; a legacy portable executable is likewise manual when PORTABLE_EXECUTABLE_FILE is present. The client must not report up-to-date merely because no newer release shares the same rc prerelease channel.
  • Specs linked: 04-ux/09-interaction-patterns.md, 05-security/01-security.md, 08-meta/decisions-log.md (D120), ADR 0022
  • Acceptance: A (app startup), Quality
  • Milestone: M5
  • Status: Unit-covered (auto-update.test.mjs asserts allowPrerelease = false); packaged discovery scenario Draft

E2E-067B: Shipped-locale update notes and full changelog dialog (D164/D345/D349) ​

  • Preconditions: The shipped packages/shared CHANGELOG contains aligned en, zh-CN, zh-TW, ko, and pt-BR stable history; product language can be switched. For the compact update path, use a packaged or fixture updater state with a catalogued availableVersion.
  • Steps: 1) With no available update, open Settings → Info and open Release notes. 2) Inspect the complete history, current-version marker, scrolling, and close behavior by close control, Escape, and backdrop. 3) Force or wait for update discovery so status is manual available, in-app downloading, or downloaded; inspect the ambient banner and Settings Updates row, then reopen Release notes. 4) Switch UI language to zh-CN, then zh-TW, then ko, then pt-BR and re-inspect without invoking a new check. 5) Repeat the compact update path with a version absent from the catalog.
  • Expected: UpdateState.releaseNotes is plain multi-line product highlights selected by Main from the shipped-locale catalog — never a renderer-supplied URL. Both surfaces show a localized "What's new" block when notes exist and hide it when they do not. Locale change refreshes notes for the same version. The Release notes action remains available in every updater state and opens a localized, newest-first modal containing every shipped stable entry, with current and available versions identified when present. The modal traps focus, restores it after close, and does not expose a new IPC domain or feed configuration.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/09-interaction-patterns.md, 05-security/01-security.md, 06-delivery/06-release-runbook.md, 08-meta/decisions-log.md (D164), ADR 0022
  • Acceptance: A (app startup), Quality
  • Milestone: M5
  • Status: Unit-covered (auto-update.test.mjs, changelog.test.ts); packaged UI scenario Draft

E2E-067C: Release version-surface preflight blocks a misaligned tag (D260) ​

  • Preconditions: A clean worktree at the current stable version. No release tag has been created for the candidate version.
  • Steps: 1) Run node scripts/check-release-docs.mjs on the aligned tree. 2) Regress one surface at a time — replace the models.dev catalog with an empty object, remove the newest changelog entry from en, then from zh-CN, change a highlight count so the locales differ, set docs/package.json to an older version, and leave the READMEs stating the previous <major>.<minor>.x release line — then rerun the preflight after each. 3) Run node scripts/release.mjs <next-version> --tag with one surface still regressed. 4) Restore every surface, rerun the preflight, and repeat the release command.
  • Expected: The aligned tree reports alignment and exits 0. Each regression is reported by name with the offending file and expected version, and exits non-zero. With a regressed surface, release.mjs bumps files but creates neither the release commit nor the tag, and says the documentation check failed. After restoration the preflight passes and the release command proceeds to commit and tag. --skip-docs-check bypasses only the check and is documented as non-release use.
  • Specs linked: 06-delivery/06-release-runbook.md §4.1, 06-delivery/05-change-checklist.md, 06-delivery/03-ai-development-workflow.md, 08-meta/decisions-log.md (D164, D260)
  • Acceptance: Quality (release process)
  • Milestone: M5
  • Status: Script-covered (scripts/check-release-docs.mjs); manual release rehearsal Draft

E2E-068: Fork a conversation into an independent session ​

  • Preconditions: An idle project conversation has user, assistant, thinking, and tool history plus at least one regenerate variant. A second source conversation is running. A Temporary conversation and two retained project workspaces contain distinct same-named marker files. The idle source has a session-scoped tool grant.
  • Steps: 1) Open the idle conversation overflow menu with keyboard. 2) Choose Create branch. 3) Append a prompt and change model/mode on the child. 4) Switch the visible workspace, return to the child, and read the marker. 5) Trigger the previously granted tool and verify confirmation is requested. 6) Reopen the source. 7) Restart the app and inspect both sessions. 8) Open the running conversation overflow menu. 9) Fork the Temporary conversation and invoke a workspace-required tool.
  • Expected: A localized branch title appears in the same project group and is activated with composer focus. Its visible active transcript and durable project/provider/model/mode/thinking/permission configuration match the source snapshot, but regenerate pager history is absent. Child messages and later configuration changes do not affect the source; both survive restart. The running source action is disabled. No turns, notifications, artifacts, permission grants, revisions, or scratch files are copied. The marker resolves under the child's inherited project; the Temporary child remains path-less and returns WORKSPACE_REQUIRED.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (sessions), D (workspace), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (sessions::tests::fork_session_clones_active_transcript_and_configuration, session-fork.test.mjs); full restart UI scenario Draft

E2E-071: Fork an assistant response without changing its source ​

  • Preconditions: An idle conversation contains two completed user/assistant exchanges and the second assistant response has cache-token usage metadata.
  • Steps: 1) Hover the first assistant response and inspect its toolbar. 2) Click Fork. 3) Confirm the activated child ends at that response and append a prompt. 4) Reopen the source and inspect it. 5) Append a prompt to the child, restart, and inspect source and child. 6) Repeat while the source is running.
  • Expected: The completed-assistant toolbar contains Copy, Fork, and Regenerate only — no Delete and no Edit (D137 moved Edit to user turns). Fork is disabled during a source turn. It activates a separately titled session whose history stops at the selected response; later source turns are absent. Source text, version history, token metadata, later turns, runtime, and cache state remain unchanged. Continuing the child affects only that child and reseeds from its own remapped transcript.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/08-component-spec.md, 08-meta/decisions-log.md (D134, D137)
  • Acceptance: C (chat stream/sessions), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (sessions::tests::message_scoped_fork_stops_at_selected_assistant_response, session-fork.test.mjs, transcript-style.test.mjs); full restart UI scenario Draft

E2E-071b: Forked session retains messages after the first AI turn completes ​

  • Preconditions: A completed conversation with at least two user/assistant exchanges. The session history window feature is active (bounded loading).
  • Steps: 1) Fork the conversation from the sidebar overflow menu. 2) In the forked session, send a new prompt and wait for the AI to finish responding. 3) Observe the transcript after agent_end fires. 4) Click the forked session entry in the sidebar (re-select). 5) Restart the app and reopen the fork.
  • Expected: All forked messages plus the new user prompt and AI response remain visible after agent_end. Re-selecting the session from the sidebar shows the same messages (no flash of empty state). After restart, messages persist. The session history window is { messageStart: 0, hasMoreBefore: false }.
  • Specs linked: 03-runtime/04-data-storage.md, 04-ux/01-ui-ia.md
  • Acceptance: C (chat stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (session-fork.test.mjsfork actions commit the child through one durable helper); UI scenario Draft

E2E-071c: A branch survives a navigation that lands during the fork ​

  • Preconditions: A completed conversation with several exchanges, and at least one other session in the sidebar.
  • Steps: 1) Start Branch from here on the source session. 2) While the fork request is in flight, immediately click another session in the sidebar (or press the history-back shortcut). 3) Wait for both to settle. 4) Inspect the sidebar. 5) Open the branch and scroll its transcript.
  • Expected: The later navigation wins the view, and the branch is still listed in the sidebar with its title, with no manual refresh. Opening it shows its complete copied transcript from cache. Nothing is lost and no duplicate branch row appears if the fork is repeated.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md
  • Acceptance: C (chat stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (session-fork.test.mjsa fork is recorded even when a newer navigation took over); UI scenario Draft

E2E-071d: Opening a long session stays responsive and shows its newest turn ​

  • Preconditions: One session whose transcript is far longer than the renderer page size (several hundred messages, including large tool results).
  • Steps: 1) Open the long session from the sidebar. 2) Observe the first painted frame and the scroll position. 3) Scroll to the top edge to page older history, repeatedly, until the first message is reached. 4) Switch to another session and back, recording the frame and offset on return. 5) Scroll up a measured amount, switch away, and switch back again. 6) Send a new prompt and let it complete.
  • Expected: The session opens at its newest turn without a blank or top-of-history frame, and opening it does not visibly slow down as the conversation grows. Each older page prepends without moving the message the user is reading. Paging back reaches the true first message with none skipped or duplicated. Re-selection paints from the retained pane: the first frame back is the same frame that was left, at the same scroll position, with no dim, no blank or skeleton frame, and no rebuild of the rows. A pane the user had scrolled up in returns to that measured offset rather than the bottom, and a pane left pinned re-anchors to the bottom. The new turn appends normally.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0137
  • Acceptance: C (chat stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (transcripts::tests::layout_window_reads_only_the_requested_tail, sessions::tests::bounded_reads_use_physical_line_positions_not_the_dedup_counter); UI scenario Draft

E2E-SESSION-list-refresh-keeps-desktop-responsive: Large session-list refreshes keep Electron responsive ​

  • Preconditions: A built Electron desktop, the bundled models.dev catalog, and a fresh temporary profile with no configured providers. The probe uses only a synthetic authKind: none provider and never starts an Agent turn.
  • Steps: 1) Create 800 empty durable sessions through the Rust Host API, distributed over up to thirteen known model IDs. 2) After fixture creation, request eight session lists concurrently through the renderer preload bridge. 3) Measure Electron Main timer gaps and renderer-to-Main version IPC latency during those reads. 4) Compare all returned session IDs and capability fields.
  • Expected: Every list contains all fixture sessions with stable model and capability fields. Main remains responsive: no measured timer gap or version IPC round trip reaches one second. The probe records individual list/heartbeat durations and the largest Main gap. The ordinary sandboxed boot, platform window, and menu assertions still pass. The profile is discarded afterwards; existing user profiles and running desktop processes are untouched.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/13-model-catalog-and-selection.md, ADR 0134
  • Acceptance: C (sessions), Quality
  • Milestone: M6+
  • Status: Automated (scripts/e2e-electron-boot.mjs via pnpm test:e2e:boot, using the existing PI_DESKTOP_BOOT_PROBE entry point).

E2E-071e: Regenerating from a paged-back transcript replaces the right turn ​

  • Preconditions: A session long enough that opening it loads only a bounded window, with several completed exchanges above that window.
  • Steps: 1) Open the session and page older history until an earlier user turn is visible. 2) Regenerate that turn (or edit and resend it). 3) Wait for the new answer. 4) Walk the revision pager back to the original. 5) Reopen the session.
  • Expected: Exactly the selected turn and its answer tail are replaced; no unrelated earlier or later exchange is truncated or archived. The pager restores the original tail. After reopening, the transcript matches what was shown, with no missing messages.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md
  • Acceptance: C (chat stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (transcript-truncation.test.ts, transcript-style.test.mjs); UI scenario Draft

E2E-071f: Long-transcript scrolling and minimap hover stay smooth ​

  • Preconditions: One session with several hundred messages, including large tool results and at least one code block, so the minimap rail shows a dense dash stack.
  • Steps: 1) Open the session. 2) Scroll continuously through the whole transcript, up and down. 3) Sweep the cursor slowly along the minimap rail from top to bottom and back. 4) Hover a dash until its preview popover appears, then click it. 5) Resize the window and repeat the rail sweep. 6) Type a long multi-line draft so the composer grows, then sweep the rail again. 7) Switch to another session and back.
  • Expected: Scrolling holds a steady frame rate with no progressive slowdown as more history is traversed. The rail sweep magnifies dashes smoothly and does not degrade as the dash count grows; the dash stack never shifts vertically while magnifying. The popover names the correct turn and clicking scrolls to it. After a window resize, and after the composer grows under a multi-line draft, magnification still tracks the cursor against the dashes' new positions rather than their old ones. Returning to the session reveals its retained pane at the position it was left, with no rebuild of the earlier history and no dim. Switching back and forth repeatedly, the transcript text never jumps or scrolls up and down after the first painted frame. Scrolling up keeps the chosen position across later switches instead of being pulled back to the bottom.
  • Specs linked: 04-ux/08-component-spec.md, ADR 0137
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (interaction-performance.test.mjsminimap hover magnification never measures geometry per dash, session switch bounds the first transcript commit instead of rebuilding it, session-switch hydration expands without moving the transcript, minimap re-measures dash centers when the rail's own box changes); UI scenario Draft

E2E-071g: Retained session panes are bounded and evict the oldest ​

  • Preconditions: At least five sessions with distinct transcripts, each longer than one viewport, so a scroll position and a final record identify each one unambiguously.
  • Steps: 1) Open sessions A, B, and C in that order, scrolling each one up a measured amount. 2) Switch back to A, then B, and confirm each returns to its own offset. 3) Open D and then E, so A and B fall outside the retained budget. 4) Return to A and scroll to the lower boundary of the restored range without clicking any load control. 5) Return to E, then D, and confirm they are still warm. 6) Repeat the whole cycle once more and watch for a stuck progress track, an error toast, or a blank chat area.
  • Expected: Only the visible pane and the two most recently visited ones are retained; visiting beyond that budget evicts the oldest pane. A warm return paints the retained frame and offset immediately. The returned pane keeps the page that already holds its anchor, so a warm return never offers to load later messages for rows that page already had. Returning to an evicted session restores the message and viewport offset recorded before eviction, including explicit expanded content, using a bounded historical read if the latest page no longer contains the anchor. If the anchor is already loaded but outside the mounted window, the saved absolute scroll offset is restored while the row window remains bounded. The cold selection's progress track and composer fence remain unchanged. No search highlight is introduced, live model-input messages are unchanged, and real input or a new turn cancels automatic alignment. First visits and pinned revisits still follow latest. Nothing is dimmed at any point, hidden panes stay non-interactive and out of the accessibility tree, and repeated cycling neither leaks a growing number of mounted transcripts nor leaves a pane showing another session's rows. A restored historical range has no Load later messages button: reaching its lower boundary automatically reads bounded newer pages without moving the visible anchor. Short ranges fill the viewport automatically; hidden panes do not initiate reads. Pending reads coalesce, obsolete results cannot replace a newer navigation, and failures do not cause automatic retry loops. After continuation reaches the tail, live updates resume without adding older cached rows above the reading position. Explicit search still uses manual later-message paging.
  • Specs linked: 04-ux/08-component-spec.md §1.6 / §3.5 / §7, 04-ux/09-interaction-patterns.md §5, ADR 0130, ADR 0137
  • Acceptance: C (switch sessions), Quality
  • Milestone: M5
  • Status: Renderer regression automated by node scripts/e2e-transcript-reading-position.mjs (isolated React/Electron, fixture IPC); live UI scenario Draft.

E2E-071h: Reopening a session while its response is streaming ​

  • Preconditions: Session A has a completed transcript and is producing a long assistant response; at least one other session is available.
  • Steps: 1) While A is streaming, open another session. 2) Wait long enough for A to emit several assistant or tool updates without finishing. 3) Open A again before the response ends. 4) Continue watching A until the turn completes. 5) Switch away and back once more.
  • Expected: A's retained/live pane is revealed without a blank, skeleton, dim, or top-of-history flash. The first frame includes the latest in-memory assistant/tool tail available before the click. The delayed durable detail response may add completed rows, but it never replaces the partial reply with an older transcript; subsequent stream updates continue from the same row and the completed answer is not duplicated. A's background events do not change the other session's transcript, composer, workspace, or focus.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0137
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (session-transcript.test.mjs, session-switch-performance.test.mjs); UI scenario Draft

E2E-073: Icon-only message toolbars and editing a user prompt ​

  • Preconditions: An idle conversation contains two completed user/assistant exchanges; one user turn was sent as a slash-template invocation.

  • Steps: 1) Hover a completed assistant row and a user row, then hover and keyboard-focus each action chip. 2) Choose Edit on the first user prompt. 3) Press Escape, reopen Edit, retry the prompt unchanged. 4) Reopen Edit, change the text, and retry with Cmd/Ctrl+Enter. 5) After the new answer completes, use the current / total pager to return to the original exchange and forward again. 6) Reload the session. 7) Choose Edit on the slash-command turn and inspect the seeded text. 8) Start another response and inspect its assistant toolbar while it is streaming and after it completes. 9) Try Edit while a turn is running.

  • Expected: Every toolbar chip shows its glyph only, with the label appearing as a fully visible tooltip 8px above the chip on hover and on keyboard focus (#74); the tooltip remains fully painted when it overlaps the sidebar edge and is never occluded by the sidebar background; no chip renders caption text. Clicking an action dismisses its tooltip immediately; it does not remain visible while the action retains focus. While an assistant response is streaming, its toolbar omits Copy; after the response settles, the assistant toolbar offers Copy, Fork, Regenerate. The user toolbar offers the pager (when variants exist), Copy, Edit, Delete. Edit replaces the prompt bubble with a wider composer-matched inline editor plate filled with --ds-tile-deep (no outer shadow, inset focus ring) so the light plate stays distinct from the white pane without a clipped glow, with Retry and Cancel controls; Escape or Cancel restores the bubble unchanged. Retry truncates the transcript from that prompt and streams a new answer whether or not the text changed, leaving a current / total pager on the user turn that restores the original prompt with its full answer tail in place — surviving reload. The slash turn seeds the typed /command form and re-expands the template on retry. Edit is disabled while a turn is running.

  • Specs linked: 04-ux/08-component-spec.md, 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md, 08-meta/decisions-log.md (D137, D274)

  • Acceptance: C (chat stream), F (persistence), Quality

  • Milestone: M5

  • Status: Unit-covered (transcript-style.test.mjs); full UI scenario Draft

  • Long-message regression: Send a log longer than 65,536 characters as message text, reopen the saved chat, then Edit and resend with a short clarification. The editor and outgoing request must retain the final log lines, without a display truncation marker. A failed full-history read must show an error without opening a clipped editor; switching chats while the read is pending, including A→B→A with the same retained message array, must not open a stale editor or publish stale history. A fresh Edit after returning must still load the complete text and resend successfully. Automated coverage also preserves attachments and the typed slash-command seed.

E2E-069: Platform-specific sidebar header behavior ​

  • Preconditions: Nib is open with the expanded sidebar and a chat session is active.
  • Steps: 1) Open Extensions on macOS windowed mode. 2) Inspect the expanded sidebar titlebar. 3) Confirm no Nib logo/title is visible and Collapse sidebar appears at the right of the traffic lights. 4) Enter fullscreen and inspect the same row. 5) On Windows/Linux, confirm the brand remains visible; activate it with a pointer, then with keyboard focus and Enter/Space.
  • Expected: macOS uses one 46px row with native lights at left, a usable drag region, and an accessible Collapse button at right; the Logo/Home brand is absent in both windowed and fullscreen modes. Global search stays on the conversation topbar, shortcuts, and application menu, not the sidebar header. Windows/Linux render the canonical 20px logo beside the 15px shell name; the complete brand has a localized Home accessible name, visible hover/focus feedback, and returns the main pane to chat without clearing the active conversation or workspace. The logo itself is theme-aware: light mode shows src/assets/brand/logo-light.png, dark mode shows src/assets/brand/logo-dark.png, swapping live with data-theme (no reload).
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (renderer-branding.test.mjs, sidebar-navigation.test.mjs); rendered interaction scenario Draft

E2E-098: Sidebar collapse and expand animate as a docked transition ​

  • Preconditions: Nib is open with the expanded sidebar and an active chat session; prefers-reduced-motion is off.
  • Steps: 1) Click Collapse sidebar in the expanded sidebar header (or press the sidebar toggle shortcut). 2) Watch the sidebar during collapse. 3) Confirm the main pane expands and the collapsed titlebar now shows an Expand control. 4) Press the sidebar toggle shortcut again from the collapsed state: each press must strictly alternate collapse and expand, so the second press re-expands the sidebar (regression: it must never re-collapse). 5) Collapse and re-expand once more via the shortcut, then repeat the full round trip with the pointer controls. 6) Repeat on Windows/Linux.
  • Expected: Collapse plays the sidebar-out keyframe (opacity + ≤8px slide plus width/flex allocation) while the aside stays in the tree, then unmounts once the animation ends; the main pane fills the freed space continuously. Expand plays the sidebar-in keyframe and the controls return to the expanded header. On Windows the dock stays opaque during exit (sidebar-out-windows), matching the work-panel dock behavior. No layout jump precedes the animation, and focus returns to the sidebar/Expand control predictably.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/07-ui-design-system.md
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (sidebar-collapse-animation.test.mjs); rendered interaction scenario Draft

E2E-208: Collapsed sidebar does not force a 640px chat band ​

  • Preconditions: Nib is open with an active chat session at a viewport wide enough for the default 760px chat content band; reduced motion is off; the user has not resized the band.
  • Steps: 1) Record the width of the centered transcript or empty-home composer band with the sidebar expanded. 2) Collapse the sidebar. 3) Inspect the same content band while the dock transition runs and after it settles. 4) Expand the sidebar and inspect the return transition.
  • Expected: The outer main pane fills the space released by the sidebar. The centered chat content band stays at its preferred 760px ceiling (or min(available, preferred) if the pane is narrower). It does not jump to 640px. The transcript, empty-home stack, and Composer share that envelope.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, ADR 0277, D439
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (sidebar-collapse-animation.test.mjs, chat-content-width.test.mjs); rendered interaction scenario Draft

E2E-CHAT-content-width-handles: Dual edge handles resize the centered chat band ​

  • Preconditions: Nib is open on chat (empty home or a transcript) at a viewport wider than 760px. Reduced motion off.
  • Steps: 1) Confirm no divider is visible at rest. 2) Hover the left content edge and confirm a short faint capsule, then the right edge. 3) Drag the right handle outward and confirm both edges move, the composer matches, and both capsules lengthen slightly while dragging. 4) Open the work panel or expand the sidebar until the pane is narrower than the new preference; the band compresses without a horizontal scroll. 5) Close the panel / collapse the sidebar and confirm the preference returns. 6) Double-click a handle to restore 760px. 7) Repeat with Arrow keys on a focused handle.
  • Expected: Default 760px. Drag floor 560px (or the pane if smaller). User bubbles stay compact. Handles stay keyboard-accessible (role="separator"). Preference persists as chatContentMaxWidth.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, ADR 0277, D439
  • Acceptance: C (conversation), Quality
  • Milestone: M5
  • Status: Unit-covered (chat-content-width.test.mjs, packages/shared/src/chat-content-width.test.ts); rendered scenario Draft

E2E-UI-tooltip-never-outlives-its-trigger: A themed tooltip always retreats ​

  • Preconditions: Nib is open on a session with at least one message toolbar, a sidebar with two retained projects, and a window that can lose focus (another application or an OS dialog).
  • Steps: 1) Hover an icon-only action and wait for its tooltip. 2) With the tooltip visible, move the pointer straight out of the window without clicking and then return it. 3) Hover one action, then move the pointer quickly across a row of adjacent actions. 4) Hover an action, then trigger a sidebar re-order or a project expand/collapse that moves its row in the DOM. 5) Hover an action, then press Escape. 6) Hover an action, switch to another application, then return to Nib. 7) Repeat step 1 for the project path tooltip (long absolute path), a session row's overflow control, and a message-toolbar chip.
  • Expected: Each tooltip appears after its delay (300ms, 500ms for the project path) and retreats when the pointer leaves. No tooltip survives its trigger unmounting, a window blur, a hidden document, or Escape, and none is left painted after the pointer leaves the window entirely. Crossing adjacent actions shows at most one tooltip at a time. A move that keeps the same row element (list re-order, expand/collapse) keeps the tooltip instead of blinking it; a row that React truly unmounts and replaces drops it. placement near the top edge.
  • Specs linked: 04-ux/09-interaction-patterns.md §6.4
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (icon-tooltip.test.mjs source contract); rendered pointer/Escape/blur validation Draft

E2E-UI-row-actions-do-not-swallow-the-row-click: A hidden row action is inert ​

  • Preconditions: Nib is open with two retained projects, each with at least three sessions, and one active conversation.
  • Steps: 1) Without hovering it, click the right-hand gutter of an idle session row where its overflow control will appear, and note which conversation opens. 2) Repeat on an idle project header row. 3) Hover a row and activate the revealed overflow control on the first click. 4) Tab through the sidebar until a row action receives focus and activate it. 5) Repeat step 1 with an emulated coarse pointer / touch device. 6) With the pointer resting on a row and its action revealed, switch focus to another application, then return without moving the pointer.
  • Expected: The idle gutter belongs to the row — the first click there opens that conversation (a project header gutter activates and toggles that group) instead of an invisible menu, and a coarse pointer never meets a hidden control. The hover-revealed control still opens its menu on the first click, and keyboard focus reveals and keeps it operable. On window blur the row (and the project title tile) drops its hover paint and hides its revealed action; moving the pointer back over the row re-arms it.
  • Specs linked: 04-ux/09-interaction-patterns.md §9.1c
  • Acceptance: Quality
  • Milestone: M5
  • Status: Source-contract and style assertions cover the hidden/revealed pointer-events states and the blur release (sidebar-navigation.test.mjs); rendered pointer/touch validation Draft
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (sidebar-navigation.test.mjs, interaction-polish.test.mjs); rendered pointer/touch validation Draft

E2E-070: Native select menus follow the Windows theme across the app ​

  • Preconditions: Nib is running on Windows with light and dark themes available.
  • Steps: 1) In light theme, open remaining native selects (scheduled-task form) and confirm Settings pickers on General, 全局 AI, Model configuration, and Import open as in-app menus rather than platform <select> popups. 2) Repeat every remaining native list in dark theme. 3) Open each list after switching themes without restarting the app.
  • Expected: Settings compact pickers use the shared anchored menu. Every remaining closed native trigger and opened native option list uses the active theme's readable foreground/background pairing. No dark-theme list falls back to a light Windows surface with light text, no light-theme list uses dark-theme ink, and changing theme updates subsequent openings. The same result holds for native selects outside Settings.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/07-ui-design-system.md
  • Acceptance: Quality (cross-platform theme readability)
  • Milestone: M5
  • Status: Unit-covered (settings-general.test.mjs); Windows rendered scenario Draft

E2E-071i: Long session opens under a settle veil and sends clear instantly ​

  • Preconditions: One session with several hundred messages including code blocks and tool results; a configured model; the host made slow (for example a throttled sidecar or a large pending tool output) so a prompt round trip takes visibly longer than a frame.
  • Steps: 1) From the home surface, open the long session and record the first painted frames. 2) Wait for the transcript to appear. 3) Leave and re-open the same session while its history page is being revalidated. 4) Type a multi-line prompt so the composer grows, then press Enter. 5) While the host is still busy, press Enter again on the now-empty box. 6) Make the host reject a send (for example disable the model's provider) and press Enter with a new draft. 7) Open a session with fewer than fifteen messages.
  • Expected: The first frame of the long session is an opaque skeleton of alternating user and assistant lines under the composer; no transcript text is visible during the frames in which the history expands or row heights settle, and the skeleton fades out within roughly 600ms onto a transcript already positioned at its newest turn. The rows never move up and down after the reveal. While the multi-line draft grows, the newest turn moves up with the composer instead of disappearing behind it. Re-opening during revalidation and a repeated older-page response leave exactly one row per message id, including the existing user row. Pressing Enter clears the box and shows the user row at the bottom of the transcript in the same frame, before the host has answered; when the host echo arrives the row does not duplicate or jump. The second Enter on the empty box does nothing and queues no duplicate. When the send is rejected, the user row disappears and the draft returns to the box with the caret at its end. The short session shows no skeleton.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 08-meta/decisions-log.md (D287, D288)
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (transcript-settle.test.mjs, composer-send-state.test.mjs send clears the composer before the round trip and restores a rejected draft (D287) and the user row is inserted before the host round trip and echoed under the same id (D288), session-transcript.test.mjs (repeated transcript rows keep one position and the latest value); UI scenario Draft

E2E-072: Keyboard shortcut mappings persist and stay conflict-safe ​

  • Preconditions: App running on macOS and on one Windows/Linux target with Settings open; no custom shortcut overrides are stored.
  • Steps: 1) Open Settings → Shortcuts and inspect Keyboard shortcuts. 2) Change Search to an unused modifier chord. 3) Invoke the new chord and then the old chord. 4) Attempt to assign that chord to the command shortcut (now opened via global search). 5) Attempt a bare letter and a reserved editing chord. 6) Disable Search and confirm its row shows Unbound. 7) Confirm neither the default nor custom Search chord invokes Search, then restart and check that it remains disabled. 8) Restore Search individually and confirm its default returns; choose Restore defaults and confirm all rows return to defaults. 9) On macOS inspect the corresponding native application-menu accelerator after each save/reset. 10) On Windows disable the plugin launcher and confirm its old global binding, focused fallback, and Alt+Space host fallback are all inactive. 11) Press and release Ctrl/Command alone, confirm an IME candidate, and hold the back/forward chord long enough to generate repeats. 12) With the main window focused, press the window-visibility chord Alt + Shift + W and confirm the window hides to the tray with no close-behaviour prompt and with the app still running; from another application, press it again and confirm the window returns and focuses. 13) Disable New task, assign its default chord to Search, then reset New task individually. Confirm the conflict error preserves both mappings and Search still opens with that chord. Repeat with New task customized instead of disabled; free the default by resetting Search, then retry New task reset. 14) Seed one profile with a stored customized closeWindow binding and one with a customized summonWindow binding; confirm each profile keeps that binding on the single toggle row after restart and that Cmd/Ctrl + Shift + W registers nothing. 15) With an unsent composer draft in the main window, press unmodified Ctrl + R; confirm the renderer remains loaded and the draft remains intact. On macOS, confirm Cmd + R and the explicit View → Reload menu action are unchanged.
  • Expected: Actions are grouped as Navigation, Agent, and Window with platform-native key labels; recording has visible focus and Escape cancels; the custom Search chord takes effect immediately, replaces the old chord, survives restart, and updates the macOS menu; duplicate, modifier-free, and reserved assignments show an inline error without changing either action; Unbound displays as a localized explicit state, participates in no conflicts, dispatches no old or default chord, persists across restart, removes the macOS accelerator, and disables the Windows launcher fallback layers; individual reset rejects an occupied default without changing either action; conflict-free individual and global reset restore the shared defaults; Keyboard shortcuts is its own Settings destination. Modifier-only and IME keydowns dispatch nothing, an unmodified Ctrl + R is consumed before Chromium reloads the main renderer, preserving the unsent draft; macOS Cmd + R and View → Reload remain available. and a held history chord traverses only once per physical press. The window-visibility key is one toggle on Alt + Shift + W — a visible, focused window hides to the tray, anything else shows and focuses — and it never enters the close path, so it raises no close-behaviour prompt and never quits; it is globally registered and deliberately avoids Cmd/Ctrl + W, which macOS spends on its own close-window command; the retired Cmd/Ctrl + Shift + W chord registers nothing, and a stored closeWindow/summonWindow override folds into the toggle (D438, D439).
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/07-ui-design-system.md, 03-runtime/01-ipc-protocol.md
  • Acceptance: F (settings persistence), Quality (keyboard accessibility)
  • Milestone: M5
  • Status: Shortcut unit-covered (keyboard-shortcuts.test.ts, settings-keyboard-shortcuts.test.mjs, window-menu.test.mjs, window-toggle-shortcut.test.mjs); test:e2e:boot verifies the main-window Ctrl+R input is consumed. The full rendered settings journey remains Draft.

E2E-073a: Developer mode gates the developer-tools console ​

  • Preconditions: App running on macOS and on one Windows/Linux target; developer mode is absent or false in persisted settings and Settings -> Info is open.
  • Steps: 1) Find the Developer card through Settings search. 2) Confirm the Open console action is disabled and invoke F12 plus the platform secondary shortcut. 3) Enable developer mode and open the console from Settings. 4) Close it and reopen it with F12; on Windows/Linux repeat with Ctrl+Shift+I, and on macOS inspect and invoke the View-menu developer-tools item. 5) Restart the app and invoke an enabled entry point. 6) Disable developer mode while the console is open. 7) Attempt the console IPC directly while disabled.
  • Expected: No disabled entry point opens developer tools, and macOS omits the View-menu item. Enabling the persisted switch unlocks the localized Settings action and applicable platform shortcuts; each toggles the same window console after restart. Disabling the switch closes the console, disables the Settings action, removes the macOS menu item, and makes direct IPC requests fail closed.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: F (settings persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (settings-general.test.mjs, window-menu.test.mjs); native interaction scenario Draft

E2E-074: Concurrent session events and permissions never steal focus ​

  • Preconditions: Sessions A and B exist in Agent mode and can run concurrently; A is visible with a draft in its composer.
  • Steps: 1) Start turns in A and B, then return to A. 2) Let B emit streamed messages, tool activity, completion, and a permission request. 3) Confirm A remains visible and continue editing its draft. 4) Trigger a permission request in A as well. 5) Open B explicitly, resolve only B's request, then return to A and resolve A's request. 6) Rapidly select A then B while session details load in opposite completion order. 7) While B is loading, resolve A's Write/Edit request so its tool completion records an inline review card, then let B emit a BrowserPreview artifact while A is visible. Switch back to each session.
  • Expected: B's background events update only B's row and retained state; they do not change A's active session/project/page, transcript, draft, scroll, or keyboard focus, and no global modal appears. Opening B reveals only B's inline card with no countdown. Both requests remain independently actionable, and resolving B does not clear A. The final rapid selection stays on B even when A's older load finishes later. Only explicit notification or session activation may navigate. A's post-approval review card is retained only in A without a transient open/close flash in B; B's BrowserPreview carries B's session identity, updates only B's retained Browser resource, and never opens, navigates, focuses, or resizes A's panel. Explicitly returning to either session restores its own open state, tabs, active tab, and Browser resource.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/03-permission-ux.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (session isolation), E (permission isolation), Quality
  • Milestone: M5
  • Status: Unit-covered (permission-inline.test.mjs for scoped state, inline rendering contract, no-countdown behavior, and latest-selection guard; work-panel.test.mjs and browser-preview-tool.test.mjs for session-scoped artifact retention and routing); full UI scenario Draft

E2E-075: Sidebar section context menus create sessions and projects ​

  • Status: Manual
  • Priority: P1
  • Covers: A, C, D / US-UI-57
  • Preconditions: App running with the expanded home sidebar visible.
  • Steps:
    1. Right-click the Sessions heading label (not a session row).
    2. Choose the single create item.
    3. Right-click empty chrome in the standalone session list.
    4. Right-click the Projects heading label.
    5. Choose the single create item and cancel or complete the project picker.
    6. Right-click empty chrome in the project list (outside any project group).
  • Expected:
    • Sessions context menus apply the temporary-group empty-session reuse rule and focus the composer; a new durable row is visible before any message.
    • Projects context menus open the same Create project dialog as the heading folder-plus control.
    • Existing row context menus and heading glyph buttons remain available; the section menus stay one-item and theme-matched with other sidebar menus.
    • Section, session-row, and project-row right-click menus open to the right of the pointer when space permits, and remain fully within the viewport at the right edge.
    • Escape and outside click dismiss the menu without creating anything.

E2E-076: Startup splash appears then yields to the main shell ​

  • Status: Partially automated (startup-splash-motion.test.mjs covers splash markup, motion tokens, reduced-motion, and catalog keys; macos-sidebar-vibrancy.test.mjs covers the darwin glass splash and shell cross-fade; full window timing remains Draft)
  • Priority: P1
  • Covers: A, Quality / US-UI shell polish
  • Preconditions: App launch path available (dev or packaged).
  • Steps:
    1. Launch Nib.
    2. Observe the first painted renderer surface before bootstrap completes.
    3. Wait until sessions/settings bootstrap finishes.
    4. Repeat with OS prefers-reduced-motion: reduce when available.
    5. On macOS, compare the splash surface with the sidebar glass after the shell appears.
    6. Repeat with bootstrap held unanswered past the watchdog bounds, and observe the boot surface at 30s and after 180s (issue #831).
  • Expected:
    • Before ready: full-window splash with brand mark, shell name, tagline, and accessible starting status (data-testid="startup-splash").
    • After ready: splash exits with a short fade (or instantly under reduced motion) and the main shell (or settings page) is interactive underneath.
    • If the initial state never arrives, the splash is replaced rather than held: the boot surface gains logs, diagnostics, and quit at 30s (STARTUP_SLOW_HINT_MS) without reporting a failure, and at 180s (STARTUP_STALLED_MS) becomes the recovery surface, which also offers a retry (data-testid="startup-recovery"); every action works without a backend, the renderer-drawn window controls stay above the surface on Windows/Linux, and quit goes through pi-desktop/app/quit.
    • On macOS the splash uses the same glass tint and sheen as the sidebar over native sidebar vibrancy; the mounted shell stays hidden until the splash exit fade, then cross-fades in. Other platforms keep the opaque --ds-bg-primary fill.
    • No plain unbranded “Starting…” centered text as the only boot UI.
    • Overlay/dialog enter motion uses shared tokens; reduced motion keeps state changes without decorative duration.
    • If a non-settings bootstrap request fails while the settings read is still in flight, opening Settings still renders the loaded controls. If the settings request itself fails, the active section shows a loading/failure state and retry action rather than a blank content pane; retrying after the local service recovers restores the controls without leaving Settings.
  • Specs linked: 04-ux/07-ui-design-system.md §8, 04-ux/02-i18n-english-first.md, decisions-log D146 / D304 / D348
  • Acceptance: A (app startup), Quality
  • Milestone: M5

E2E-SESSION-reopen-restores-running: Recreated windows retain live turn status ​

  • Steps: Start a Desktop turn in a session with an older completion notification. Close and recreate its renderer while Main keeps the turn alive. Inspect the sidebar and select the session before another agent event. Repeat after the turn ends, and with a start/end event arriving during bootstrap.
  • Expected: Live tasks show running, and selecting one restores the running composer state. Completed tasks stay idle. A delayed snapshot never overrides a start/end event already received by the new renderer.
  • Automation: node scripts/e2e-running-session-reopen.mjs recreates real Electron windows with the production bootstrap, Sidebar and selection flow, using fixture backend responses. session-list-runtime.test.mjs checks the Main snapshot; session-running-bootstrap.test.mjs covers event races and responses from older hosts. The native macOS close gesture remains a manual check.
  • Specs: 03-runtime/01-ipc-protocol.md, ADR 0090.

E2E-099: Brand logo follows the active theme ​

  • Status: Draft
  • Priority: P3
  • Covers: Quality / US-UI shell polish
  • Preconditions: App running; theme can switch between light and dark (and system) without restart.
  • Steps:
    1. In light mode, open the app shell, an empty chat home, and the expanded sidebar (Windows/Linux) or startup splash.
    2. Inspect the rendered BrandLogo source in the sidebar and startup splash, and inspect the light eight-frame HomeMascotLogo GIF in the empty-home hero. Hover the mascot and verify that its cadence does not change.
    3. Switch the theme to dark (Settings → General → Appearance, or system appearance change).
    4. Re-inspect the same surfaces without reloading.
    5. Switch back to light and re-inspect.
  • Expected:
    • Light and dark mode render src/assets/brand/logo-light.png / src/assets/brand/logo-dark.png live in the sidebar and startup splash without a window reload.
    • The empty-home hero renders the 100px eight-frame mascot GIF for the active theme (home-mascot-light.gif / home-mascot-dark.gif) with a short idle hold and a looping wave. Switching theme swaps the pair live without a window reload. Pointer hover does not change the cadence; under reduced motion the matching still first frame remains visible.
    • Sizes stay stable across theme changes (sidebar 20px, hero 100px, splash 64px), and the marks stay decorative with no click, keyboard, or focus behavior.
  • Specs linked: 04-ux/08-component-spec.md §3.7, 04-ux/07-ui-design-system.md
  • Acceptance: Quality
  • Milestone: M5

E2E-077: Theme-aware selection and CJK section labels ​

  • Status: Partially automated (user-select.test.mjs, interaction-polish.test.mjs)
  • Priority: P2
  • Covers: A, Quality / US-UI shell polish
  • Preconditions: App running with at least one selectable transcript or input; language can be switched to zh-CN.
  • Steps:
    1. Select text inside a transcript message or the composer.
    2. Inspect sidebar Sessions/Projects section labels in English.
    3. Switch the app language to zh-CN and re-check the same labels.
    4. Hover jump-latest (when visible), stop, search rows, and profile menu items.
  • Expected:
    • Selection highlight uses a neutral text-primary wash (not browser-default blue).
    • Caret/form accent colors stay on the monochrome token ramp.
    • English section labels may use uppercase + wide tracking; zh-CN labels use normal tracking without forced uppercase.
    • Listed chrome controls ease background/color changes via shared motion tokens.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md
  • Acceptance: D147
  • Milestone: M5
  • Status detail: Source-level coverage for CSS contracts; visual selection paint remains manual.

E2E-078: Work panel and settings light-surface polish ​

  • Status: Partially automated (surface-polish.test.mjs)
  • Priority: P2
  • Covers: D, Quality / US-UI shell polish
  • Preconditions: App running; theme can switch to light; a work-panel tab can be opened.
  • Steps:
    1. Switch to light theme.
    2. Open Settings and inspect form fields, toggles, segment controls, and shortcut keycaps.
    3. Open the work panel (Review / Files / Browser) beside a chat session.
    4. Hover file-tree rows or diff headers; focus the browser URL field.
    5. Open a confirmation/provider dialog and inspect the scrim.
    6. In both light and dark palettes, inspect the settings rail, search, selected item, on-state knob, composer shell, plugin/capability searches, the code card's head band, the Mermaid canvas, tool output, the composer placeholder and disabled send chip, the dialog scrim and permission backdrop, and the dock question card with its option row. Apply custom surface variables, keyboard-focus both searches, then remove the custom theme.
  • Expected:
    • Work panel body reads as quiet #fafafa inset paper with a white header band.
    • Settings fields, browser URL, segment tracks, and shortcut keycaps use light inset fills; focused fields lift with a neutral ring.
    • Toggle on-state keeps a white knob on the near-black track.
    • Hover fills on file-tree/diff/resize ease with shared motion tokens, and the divider matches the sidebar: a 32px centered grip on direct hover/focus, not a full-height hairline; keyboard focus and an in-progress drag use the solid accent.
    • Light dialog scrim is softer than the dark 45% veil (~28% ink).
    • The dock question card paints the composer plate in both palettes — light #ffffff with the composer shadow, dark 96% #212121 — and its option rows are inlaid --ds-tile-deep fills with no raised shadow. A custom --ds-bg-composer / --ds-tile-deep repaints both, and removing it restores the built-in paint.
    • Tool output keeps its cascade: light paints the same lighter tile over error output and over plain tool blocks, while dark shows the error tint and leaves plain blocks transparent.
    • Custom variables repaint the corresponding fills, keycap ink, and search focus states; removing them restores the built-in 8-bit RGBA paint and the existing shadows/focus rings. Prose ink mixes follow --ds-text-primary, and the one-dark-pro / one-light Shiki plate and its ink stay with the Shiki theme by design. This batch does not change plugin APIs.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md
  • Acceptance: D148
  • Milestone: M5
  • Status detail: pnpm test:e2e:theme-surfaces exercises these ordinary fills, focus states, and built-in restoration with real Chromium, production CSS, and deterministic DOM fixtures; plugin installation/lifecycle is not covered. Branch runs do not replace post-integration E2E. Other surfaces in this scenario retain manual visual checks.

E2E-079: User-facing catalog copy in English and Chinese ​

  • Status: Partially automated (packages/i18n/test/user-facing-copy.test.mjs, catalogs.test.mjs)
  • Priority: P2
  • Covers: A, Quality / US-UI copy
  • Preconditions: App running; language can switch between English and zh-CN.
  • Steps:
    1. Inspect empty-home hint, sidebar temporary-chat section, and status/connection toasts.
    2. Open Settings → AI providers and the marketplace refresh action.
    3. Switch the app language to zh-CN and re-check the same surfaces.
  • Expected:
    • Copy explains outcomes in plain product language (AI provider, project, marketplace, connected/limited) rather than host/backend/repo jargon.
    • English and zh-CN catalogs keep identical keys and interpolation variables.
    • Crash chrome and empty-home titles remain catalog-backed in both locales.
  • Specs linked: 04-ux/02-i18n-english-first.md
  • Acceptance: D149
  • Milestone: M5
  • Status detail: Source-level coverage for catalog contracts; visual wording review remains manual.

E2E-081: Send re-pins transcript and jumps to bottom ​

  • Preconditions: Long transcript that overflows one viewport; provider configured.
  • Steps:
    1. Start at the pinned bottom and use a trackpad to scroll upward with a small initial movement; observe the first movement and the jump-to-latest control.
    2. Type a new prompt in the composer and send it.
    3. Observe transcript position while the turn starts and streams.
    4. Scroll upward again with a small trackpad movement while streaming, wait for more content, then click jump-to-latest.
  • Expected:
    • The first upward movement immediately releases follow mode and remains stable; it does not snap back, reverse direction, or oscillate while a pending stream or resize follow frame completes.
    • New streamed content does not move the manually positioned viewport, and jump-to-latest appears as soon as follow mode is released.
    • On send, the transcript re-pins, hides jump-to-latest, and jumps to the bottom so the new user message (and following stream) is visible.
    • Streaming continues to follow while pinned.
    • Long Markdown responses with nested lists keep their list container line boxes stable during streaming; the cursor follows the terminal inline text without adding a blank line below a nested list.
    • Manual scroll mid-stream pauses follow and shows jump-to-latest again; clicking it resumes follow.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (chat stream), Quality / D151
  • Milestone: M5
  • Status: Partially automated (apps/desktop/test/transcript-scroll.test.mjs); full trackpad interaction remains Draft

E2E-082: New reasoning session defaults to the binding thinking level ​

  • Preconditions: The app default provider/model resolves as reasoning-capable and publishes a sparse thinking-level set with a stored binding default that is not the strongest enabled level; a second default model is non-reasoning.
  • Steps:
    1. Set the reasoning-capable model as the app default and create a new session.
    2. Inspect the Composer model × reasoning chip and the session configuration sent to the host.
    3. Select a lower level or Off, leave the session, and reopen it.
    4. Set the non-reasoning model as default and create another new session.
  • Expected:
    • The first new session persists and displays the binding's stored default thinking level, clamped onto the enabled set, even when the provider returns its sparse levels out of order. It does not jump to the strongest enabled level merely because the model supports reasoning.
    • Reopening the first session preserves the user's later explicit selection.
    • The non-reasoning session starts at off; its combined chip keeps the Bot icon, omits level text, and its reasoning submenu exposes only Off. Missing capability metadata also falls back to off.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md, 04-ux/08-component-spec.md, ADR 0018, D303
  • Acceptance: B (model config), F (persistence), Quality
  • Milestone: M5
  • Status: Partially automated (thinking-levels.test.ts, thinking-ui.test.mjs); full UI scenario Draft

E2E-083: Long streaming turns keep shell interaction responsive ​

  • Preconditions: Provider configured; an active session has enough user, assistant, and tool rows to overflow several viewports; work panel can be opened; both normal and reduced-motion preferences are available.
  • Steps:
    1. Start a long assistant response that emits frequent streamed updates.
    2. While it streams, hover and focus sidebar rows, type in the composer when enabled, open/collapse the work panel, and scroll the transcript away from and back to latest.
    3. Observe the minimap, completed history rows, composer surface, and shell chrome throughout the stream.
    4. Navigate to Plugins or Settings and back, then repeat with reduced motion.
  • Expected:
    • The current assistant row reveals content progressively and pinned follow stays at latest without visible oscillation.
    • Smooth text reveal on a simulated 120 Hz display commits no faster than 60 Hz, and its animation frame loop stops after it catches up.
    • Replaceable message/tool partials are coalesced to the next paint, while terminal, permission, planning, and error states remain immediate.
    • A failed tool row remains error-hued and locally expandable, but never marks the containing activity group as terminally failed. The group reports only processing duration; terminal turn styling comes from the terminal agent outcome surfaces.
    • Sidebar, composer, completed message/activity rows, work panel, titlebar, and global overlays do not visibly repaint or lose pointer/keyboard responsiveness for each token update.
    • Completed history remains in its stable render boundary while the active tail changes; history stays selectable, copyable, and anchored in the minimap without being rebuilt as a React subtree for every token.
    • Within that same active turn, unchanged non-delegation activity groups do not render again just because text updates rebuild turn-wide delegation maps. Tool content changes still render; later TaskWait results update the original Task group's terminal status and completion duration.
    • Pressing and releasing standard, icon, sidebar, send, stop, and message action controls uses one eased transform rather than a snapped scale; active streaming labels keep their readable text while their compact status markers pulse at or below 1 second; loading skeletons retain their pulse.
    • Minimap overflow and active-marker state remain correct without marker jitter while streamed content changes height.
    • Destination, panel, focus, pressed, jump, and error feedback use one short bounded transition; no composer blur trails the transcript.
    • The initial shell does not eagerly evaluate secondary destination modules; first navigation may show a compact localized loading indicator, then preserves normal page interaction after the local chunk resolves.
    • Reduced motion preserves every state change and uses instant programmatic scrolling with near-zero transition duration.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (interaction-performance.test.mjs); automated React/Chromium render regression via pnpm test:e2e:transcript (no provider credentials; requires installed Electron and a graphical session, or Xvfb on Linux). It mounts production transcript components, counts ActivityGroup renders across 20 text updates with 100 completed groups, checks changed tool content, checks cross-part Task terminal status/timing updates, and exercises the production smooth-text hook against deterministic 120 Hz animation frames. The page links the app's built stylesheet, which the runtime-status scenario below measures real geometry against; full provider streaming and shell responsiveness remain Draft.

E2E-CHAT-long-history-stream-keeps-responsive ​

  • Preconditions: Isolated Electron fixture with production React, ChatTranscript, Composer, event slice, store subscription, and session cache; synthetic Host reads, clipboard, and DOM input, no credentials or running Desktop.
  • Steps: Keep three sessions running with 100, 1,000, and 10,784 canonical loaded rows. Stream warmed deltas into the foreground and both background caches, then switch through real session selection with 100-row revalidation. Correct an older row; inspect its minimap preview. Toggle a disclosure, scroll away from latest, complete/re-key the tail, copy its turn, and switch back. Separately stream a 256-fragment single turn and a 256-tool activity group; update a nested child through the real read-only subagent dock, stream the giant group's final thinking item, and deliver actual tool_update partials to a running tool in the middle of that group. Append three tool starts to the loaded 10,784-row session and switch away and back.
  • Expected: Ordinary warmed deltas read zero unchanged bodies; unchanged groups do not rerender. Mounted history stays bounded despite full canonical input. Background updates never replace the foreground, and revalidation never discards its cached live tail. Content, minimap previews, disclosure choice, reader scroll ownership, completed Copy text and terminal identity remain correct. Changed tools/children/thinking still reach their real components. Structural tool appends read zero unchanged completed bodies and survive session reselection; immutable text facts are also reused for delegate answers.
  • Specs linked: 04-ux/08-component-spec.md §7.6.
  • Acceptance: C (chat stream), Quality. Milestone: M5 maintenance.
  • Status: Automated fixture in scripts/e2e/transcript-long-history.tsx, registered in pnpm test:e2e:transcript; requires built renderer CSS and the existing Electron environment. Assertions are work counts and DOM state, not timing thresholds. Event-to-settled-DOM latency, supported long-task entries and optional heap samples are diagnostic only. Elapsed-label wall time is controlled; the hidden-window fixture explicitly dispatches DOM focusin. Existing deterministic 120 Hz cadence coverage remains unchanged. This is renderer/store integration, not provider/IPC throughput, real-session profiling, or complete shell interaction acceptance; those broader E2E-083 facets remain Draft.

E2E-CHAT-running-status-survives-output-pauses ​

  • Preconditions: Production ChatTranscript, real store and React DOM in Electron; deterministic messages/status transitions, no provider credentials.
  • Steps: Send a turn, stream partial text, stop delivering deltas, start and finish a tool, clear/set runtime phases, resume text, then complete, stop, or fail. Repeat with Show thinking on and off. Exercise planning, permission, question and proposal waits, history reading, and switching sessions.
  • Expected: Exactly one localized, accessible tail status remains through output pauses and completed-tool gaps. Runtime phases take precedence over Planning/Goal or Working. Pending user interaction suppresses it; terminal turns and history reading remove it. Switching sessions uses that session's status only. Existing content and permission actions remain available. Active and assistant-error turns mount no empty action toolbar. A partial answer has 24 CSS px between its fragment box and the status label at the default font scale; settled non-error content retains Copy, Fork, and Regenerate. Check the Show thinking preference on and off. History reading and permission cards retain normal message padding. Ending a turn keeps pinned follow at the bottom while restoring the normal toolbar height. A scrolled-up reader keeps the exact text position and scroll offset.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: C (chat stream), Quality (interaction and accessibility)
  • Milestone: M5
  • Status: Automated by pnpm test:e2e:transcript (scripts/e2e/transcript-status.tsx). Reproduces issue #669 on upstream ecece8f570fc0c18f21e4941eafe52244a7c6e5f; no real-provider claim.

E2E-CHAT-initial-wait-spacing ​

  • Send a user message before any assistant output: retain the original user action row and spacing (52px bubble-to-status in the default text fixture).
  • At wide and narrow transcript widths, Copy remains focusable and pointer reachable; edit/delete/revision controls retain their disabled running state. Status and action hit targets never overlap, including revision pagers.
  • Right-click the status/background for the conversation menu and the message for its own menu. Change runtime phase, receive first output, or stop before output: exactly one status follows the active turn and terminal actions return.
  • Coverage: scripts/e2e/transcript-status.tsx, pnpm test:e2e:transcript.

E2E-CHAT-runtime-status-keeps-row-position ​

  • Preconditions: An active session whose transcript is taller than the conversation viewport, with a completed tool row owning the tail; the pane is built (pnpm build:js) and Electron is installed.
  • Steps:
    1. Mount the production ChatTranscript with fixed messages, the tail owned by a completed activity group, and the session running.
    2. Record the content height, scroller scroll height/offset, and the first and last rendered row positions with no runtime activity reported.
    3. Switch the session's runtime activity to the waiting-for-model phase only; let the layout settle.
    4. Clear the runtime activity again and let the layout settle.
    5. Finish the turn (isRunning false) and inspect the tail.
  • Expected:
    • The waiting row occupies the reserved status lane: the content height, scroll height, scroll offset, and the position of every already-rendered row are unchanged (within 0.01px) while Working switches to the named wait and back.
    • The lane stays reserved and transparent — no background, border, or shadow — in both themes. Clearing a runtime phase restores Working.
    • The status row keeps its live-region semantics (role="status", aria-live="polite"); pending user interaction leaves the reserved lane empty.
    • An idle finished transcript renders no status lane at all, so its layout is unchanged.
  • Specs linked: 04-ux/08-component-spec.md
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (active-turn-surface.test.mjs) and automated React/Chromium geometry regression via pnpm test:e2e:transcript (scripts/e2e/transcript-render.tsx, no provider credentials; requires an installed Electron and a graphical session, or Xvfb on Linux). The scenario fails by 40.125px of content height and 40px of row movement when the reserved lane is removed (issue #323).

E2E-263: Native interaction stays live during large transcript and tool-output rendering ​

  • Preconditions: Isolated visible Electron window with the production Markdown and ToolDetailBlocks renderers and built app stylesheet. A separate local Node fixture emits ordered synthetic deltas; no provider, real Host, production session, or user data is used.
  • Steps: Mount a 680 Ki-code-unit tool result, then stream a 90 Ki-code-unit unbroken message plus a final marker from the child process. Once the rendered message exceeds the streaming-tail threshold, use Electron webContents.sendInputEvent to click, move between tool-output pages, type into the draft, switch to another session and back, collapse and expand the transcript, and scroll while deltas continue. Finish the stream and compare every received source code unit, sequence number, final marker, and rendered source length.
  • Expected: Every native action is acknowledged while streaming remains active, each completes within 250 ms, and the action P95 is at most 100 ms on the recorded environment. The independent producer advances all 180 sequence numbers in order; switching sessions does not move or lose the background stream, and the finished content retains the exact received source. The large tool result exposes page controls, renders one page at a time (at most 32 Ki code units plus one code unit where a surrogate pair crosses the boundary), and retains its final page marker.
  • Specs linked: 04-ux/08-component-spec.md §8.7, §9.6.
  • Status: Automated by pnpm test:e2e:renderer-responsiveness. The parent Electron process drives native input under an external 25-second deadline; the renderer cannot self-report its own timeout. The fixture is deterministic and offline, not an incident replay or a claim about production-session root cause.

E2E-STREAM-long-turn-keeps-realtime ​

  • Preconditions: Provider configured; an Agent session can run a long autonomous turn with thinking, tools, and at least one subagent.
  • Steps:
    1. Start a long Agent task that streams thinking and answer text at a high upstream token rate and continues through many tool rounds.
    2. Observe streaming latency of later short thinking/answer blocks in the same turn, including a nested subagent.
    3. Stop the turn, send a new prompt in the same session, and compare the new turn's streaming latency.
    4. Confirm historical activity rows in the still-running turn do not flash or rebuild as the tail token updates.
  • Expected:
    • Upstream 200+ tok/s streams stay visually caught up (batched to the display refresh is allowed; a growing backlog is not).
    • Later short chunks in the same long turn do not keep getting slower.
    • Stop plus a new prompt is not required to restore speed.
    • Parent and subagent streams both stay realtime.
    • Transcript text after message_end matches the streamed content.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, ADR 0242, D412, issue #299
  • Acceptance: C (chat stream), Quality
  • Milestone: M5
  • Status: Unit-covered (message-stream.test.ts, stream-coalescer.test.ts, streaming-benchmark.test.ts, assistant-turns.test.mjs); rendered long-turn scenario Draft

E2E-084: Long tool loop compacts before the provider context limit ​

  • Preconditions: Provider configured with known pi-ai context/output limits; a fixture can produce repeated tool turns and large capped tool results without finishing the agent run. Automatic protection is always on and has no settings.
  • Steps:
    1. Start one agent task whose tool loop grows past the hard budget.
    2. Let at least three turn_end events occur before agent_end; observe the composer/session controls, processing rows, transcript, and toasts.
    3. Continue until a checkpoint is installed, then allow the task to finish.
    4. Send further prompts until a second checkpoint is installed.
    5. Repeat the hard-boundary turn with multiple parallel capped tool results in the compacted range.
    6. Restart the app, reopen the session, and send a follow-up that depends on summarized old work while verifying that completed prompts are not replayed as naked historical user messages.
    7. Repeat with a provider fixture that returns Bedrock's prompt is too long: N tokens > M maximum once.
    8. Run a turn where the model calls new_context well below the hard budget.
    9. Invoke /compact manually while idle.
  • Expected:
    • The summary request of every checkpoint carries the session's own conversation identity: on the Responses-shaped providers (openai-responses, openai-codex-responses) the outgoing payload keeps the session id as prompt_cache_key, exactly as the session's turns do, so a gateway fronting a Codex backend accepts it instead of answering 400 invalid_responses_request. A provider on another wire API, or on a published vendor host, is unchanged; an unrecognized endpoint on Chat Completions gains the key for the reason E2E-PROVIDER-custom-endpoint-session-identity records.
    • Each turn_end is evaluated before another provider request and never marks the overall task idle; composer/config controls remain blocked until agent_end, error, or manual-only compaction_end. In-run follow-up assistant turns compact through prepareNextTurn (pi 0.84.4+ skips that hook on a terminating turn); a new user prompt still compacts before its first provider request.
    • Every successful compaction adds exactly one divider row to the transcript, positioned immediately after the last message that checkpoint covers, and raises exactly one warning toast. Two checkpoints produce two rows, in order, and neither row replaces or hides a message.
    • The new_context call appears as a normal tool activity row, returns immediately, and the checkpoint is created at the following turn boundary rather than mid-turn.
    • Opening the context usage inspector after a checkpoint shows one line with the compaction count and the newest summary's token estimate; before any checkpoint that line is absent.
    • The context usage indicator exposes the same localized remaining-context summary on hover and keyboard focus, and its tooltip remains visible above the home/docked composer without clipping.
    • At the hard boundary a durable checkpoint is created before the next model request. The complete visible transcript is unchanged, and the continued task stays below the model-aware safe budget.
    • After a checkpoint the next provider request contains no assistant or tool message from before the boundary — only the summary and, while an active turn continues, its latest user message, which may carry the checkpoint-truncation marker. A completed-turn checkpoint has an empty retained tail. The request contains no tool call without its result, and expanding the original transcript rows still shows their complete persisted results.
    • Restart restores the summary and the recorded active/completed retention mode, and every earlier compaction row is still drawn. A regenerate/fork before a checkpoint boundary drops that record specifically; records anchored on surviving messages are preserved/remapped.
    • The exact provider overflow removes only the failed assistant from model context, keeps the visible assistant bubble in a running state while compaction/retry is pending, retries once after compaction, and does not loop on a second overflow. A recoverable first overflow emits no terminal error message or error event; only a failed retry (or failed compaction) closes the bubble with CONTEXT_TOO_LARGE / the actual terminal error.
    • If a retryable provider stream failure arrives after compaction has already replaced the failed assistant with a summary or retained user message, the retry keeps that current compacted transcript and calls continue(); it does not surface the missing-assistant guard error (issue #1768).
    • If automatic summary generation fails, a durable retained-tail fallback checkpoint is appended, the run stays active, and one warning explains that older model context was reduced; the transcript row for that checkpoint reads "summary generation failed · recent context retained", never summary ≈N tokens (ADR 0282). Before that fallback, the summary request retries transient provider failures up to three times with 2s/4s/8s backoff, Stop cancels the backoff, deterministic failures do not retry, and an input whose serialized prompt exceeds the window is sent once more with tool results cut to a short prefix rather than skipping the model. If fallback persistence or the safe budget guard fails, CONTEXT_COMPACTION_FAILED is emitted once.
    • If the newest checkpoint is already the transcript leaf when a follow-up prompt crosses the hard budget, the runtime rebuilds a smaller tail from the full transcript and carries the existing summary forward instead of reporting that there is no new context to compact.
    • The budget reminders appear at most once each per checkpoint window, never in the transcript, and never in a persisted system prompt.
    • Idle /compact succeeds and shows its own informational toast on top of the compaction warning, because the user asked for it. Compaction failures surface once through CONTEXT_COMPACTION_FAILED without duplicate error toasts.
    • Settings contains no context-management card and Settings search returns no compaction rows.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 03-runtime/03-tools-and-permissions.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0030, ADR 0049, ADR 0061, ADR 0064, ADR 0136, D158, D203, D275
  • Acceptance: C (chat/stream), F (persistence), Quality
  • Milestone: M5
  • Status: Partially automated (runtime.test.ts, context-compaction.test.mjs, assistant-turns.test.mjs, host-core transcript/session unit tests); full provider/UI journey Draft

E2E-PROVIDER-custom-endpoint-session-identity: A self-hosted endpoint receives the session id ​

  • Preconditions: Two independent sessions, each with several turns, bound to a provider row whose API style is chat_completions and whose base URL points at a local capture endpoint (not a published vendor host). A third row on a published vendor host, a fourth on anthropic_messages, and a fifth on openai-responses, use the same capture endpoint's stand-ins where applicable.
  • Steps: 1) Send a turn in each session and capture the full request headers and body of both. 2) Compare the two requests field by field. 3) Trigger a context-compaction summary in the first session and capture it. 4) Run a subagent turn and a plugin one-shot that carry a session id, and capture both. 5) Repeat step 1 on the row whose base URL host is a published vendor host. 6) Repeat step 1 on a row whose API style is opencode_go.
  • Expected:
    • Both captured requests carry a stable per-session identifier that differs between the two sessions: x-session-affinity and x-client-request-id beside session_id, plus prompt_cache_key in the body, all holding the session's own conversation id and unchanged across that session's turns.
    • The custom Responses request also carries x-session-affinity alongside the adapter's existing session_id, x-client-request-id, and prompt_cache_key, so a reverse proxy that drops underscore headers can still route the session.
    • The compaction summary, the subagent turn, and the one-shot carry the same id for their session, so a gateway pins the whole session to one backend.
    • The published-vendor-host row and the OpenCode row are byte-identical to their pre-change requests (the OpenCode row keeps x-opencode-session).
    • A one-shot call with no session id gains no prompt_cache_key or x-session-affinity header.
  • Specs linked: 03-runtime/02-agent-runtime.md (§6.2, §6.3), 03-runtime/11-provider-model-system.md, issue #1634
  • Acceptance: C (chat/stream) + Quality
  • Milestone: M5
  • Status: Unit-covered (session-affinity.test.ts); full gateway capture journey Draft

E2E-AGENTS-001: Project instruction chain configures an agent session ​

  • Preconditions: A project contains root AGENTS.md, nested packages/api/AGENTS.md, and a provider is configured.
  • Steps:
    1. Start an Agent-mode conversation and submit a task covered by the root instruction.
    2. Let the agent read or edit packages/api/handler.ts.
    3. Have the agent read an attachment outside the project root.
    4. Add packages/api/AGENTS.override.md, then have the agent access another file in that directory.
    5. Edit the root instruction while the session is idle, then submit a follow-up task.
  • Expected: The initial runtime receives the root chain. Before the file tool executes, the nested instruction is appended after its root source and therefore takes precedence. In one directory, AGENTS.override.md wins over AGENTS.md; CLAUDE.md and .claude/CLAUDE.md are fallback names. The idle follow-up uses changed root content rather than reusing the prior runtime. Empty, unreadable, oversized, and out-of-root instruction files do not block the turn; the global file and the project chain each have an independent 32 KiB UTF-8 budget, an oversized global file does not remove project entries, and a truncated file ends with a notice naming its source and the loaded and total byte counts. A file tool whose target is outside the project root, or targets the root itself, keeps the root chain rather than clearing the project instructions; instruction files are still read only from inside the root. A fixture-backed sidecar run verifies that a nested read applies nested rules and a following attachment read restores the root rules without loading an outside AGENTS.md. If path-specific resolution exceeds its two-second deadline or the host is unavailable, the file tool continues with the base chain and does not retain a sibling directory's rules. Repeated file tools in the same directory during one prompt reuse one path-resolution claim; the next prompt resolves again so changed instruction files are observed. The resolver uses the session-bound project root passed at runtime launch and does not issue a per-file session.get RPC.
  • Specs linked: 03-runtime/02-agent-runtime.md
  • Acceptance: C (chat/stream), F (persistence)
  • Milestone: M5
  • Status: Resolver and runtime behavior are automated in project-instructions.test.ts and runtime.test.ts; the fixture-backed sidecar scenario runs through pnpm test:e2e:hosted-search. The broader full provider/UI journey remains Draft.

E2E-AGENTS-002: Global settings and project menus manage instruction files ​

  • Preconditions: Nib is running; a project can be opened.
  • Steps:
    1. Open Settings -> Instructions without an active project and save global content.
    2. Start a new agent session and verify its instruction context includes the global source.
    3. Open the Projects view and use a project's more menu to edit and save its displayed AGENTS.md.
    4. Submit a prompt in a new or idle session.
  • Expected: The global editor targets only ~/.pi/agent/AGENTS.md. The project editor is available only from a known project's Projects-view more menu and targets only that project's root AGENTS.md. Both editors show their resolved paths, preserve the typed text, and save through the dedicated IPC rather than a general file write API. Global content precedes project content in the next runtime; the saved project content follows it and takes precedence on conflicts. The project editor is a viewport-level dialog.
  • Specs linked: 03-runtime/02-agent-runtime.md, ADR 0037
  • Acceptance: C (chat/stream), D (workspace), F (persistence)
  • Milestone: M5
  • Status: Unit-covered (project-instructions.test.ts); UI journey Draft

E2E-085: Expanded sidebar typography keeps list content compact ​

  • Preconditions: The expanded sidebar contains at least one standalone session, one retained project with a session, and one empty project group; light and dark themes are available.
  • Steps:
    1. Open the app at the default window width and inspect session titles, project/group titles, empty-state copy, section labels, and the footer's Settings, Plugins, and notification icons.
    2. Switch between light and dark themes, then narrow the window to the minimum supported expanded-sidebar width.
    3. Compare the sidebar hierarchy with 14px chat body text and inspect long session/project names.
  • Expected:
    • Footer action icons use the shared 32px hit target and compact 14px icon sizing; Plugins sits immediately to the right of Settings.
    • Session titles, project/group titles, and empty-state copy use --text-md (13px); section labels and secondary metadata remain at --text-sm (12px).
    • The hierarchy remains readable in both themes, row pitch stays compact at approximately 28–32px, and long labels truncate without shell reflow.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, D161
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (sidebar-navigation.test.mjs); rendered visual scenario Draft

E2E-086: Assistant Mermaid fences render safely without blocking streams ​

  • Preconditions: A provider can stream an assistant answer containing a valid Mermaid flowchart, an invalid Mermaid fence, and ordinary fenced code; light and dark themes are available.
  • Steps:
    1. Stream a valid mermaid fence slowly and observe it before and after the closing fence arrives.
    2. Scroll the completed diagram into view, toggle source, copy the source, and switch between light and dark themes.
    3. Scroll upward so transcript follow is paused, then bring another completed diagram near the viewport.
    4. Render invalid and over-20,000-character Mermaid sources plus a payload attempting an HTML label, external image, link, or Mermaid config override.
    5. Expand thinking content containing a mermaid fence.
  • Expected:
    • The partial stream remains a normal source code block; only the complete answer fence starts rendering, and only near the viewport. Ordinary code fences and thinking Mermaid fences retain their source presentation.
    • The diagram uses bounded card chrome, remains within the transcript width, switches theme without stale colors, and exposes keyboard-accessible diagram/source and copy controls. Copy returns the original fence source.
    • Diagram height changes keep a pinned transcript at the bottom but never resume follow after the user scrolls upward.
    • Invalid, oversized, or unsafe input cannot fail the assistant turn, execute a link, load embedded media, add foreign HTML, or weaken strict settings; it falls back to readable copyable source when rendering is unavailable.
  • Specs linked: 04-ux/08-component-spec.md §8.7, 04-ux/09-interaction-patterns.md §2, 05-security/01-security.md §2, D165
  • Acceptance: C (chat & stream), Security, Quality
  • Milestone: M5
  • Status: Unit-covered (mermaid-rendering.test.mjs); rendered security and visual scenario Draft

E2E-092: Packaged runtime is self-contained without duplicate dependencies ​

  • Preconditions: Native macOS arm64 and Intel x64, Windows x64, and Linux x64 packages built from clean release-host directories; a clean application profile; English and zh-CN available; external network access can be disabled while loopback remains available; a deterministic loopback OpenAI-compatible fixture provider returns code, KaTeX, Mermaid, and a Bash command.
  • Steps:
    1. Record every compressed artifact format plus the unpacked application, ASAR, Electron runtime, locale, and unpacked-native sizes on each native runner.
    2. Inspect ASAR and resource inventories for sidecar, host, production modules, source maps, tests/examples/declarations, Chromium locales, and native prebuild targets.
    3. On each macOS package, run file (or lipo -info) against the app executable and Resources/bin/pi-desktop-host-core; confirm arm64 and x86_64 packages contain only their declared architecture and that the Rust host matches the Electron app. Confirm the shared apps/desktop/package.json macOS configuration produces arm64 assets named Nib-X.Y.Z-arm64.dmg and Nib-X.Y.Z-arm64-mac.zip, while the Intel assets use Nib-X.Y.Z-x64.dmg and Nib-X.Y.Z-x64-mac.zip; confirm the release directory has both DMG and ZIP artifacts and one merged latest-mac.yml feed whose URLs and checksums match those generated assets.
    4. Inspect the renderer output for its size controls: emitted JS is minified, no .woff or .ttf files are present, only the KaTeX woff2 faces remain (no application font face is emitted any more), and the brand marks are the renderer-sized assets/brand/logo-*.png rather than the 1024px installer icons.
    5. Configure the loopback fixture provider, disable external egress, and launch from a clean profile. Switch between English and Simplified Chinese, request the deterministic response, render common JavaScript/TypeScript, Python, Rust, shell, Mermaid, and unknown-language fences plus KaTeX and a Mermaid diagram, run the Bash fixture, and verify host and agent-sidecar health.
    6. Confirm typography and branding survive the stripped font fallbacks: KaTeX math renders with its own faces, Chinese text in both the UI chrome and assistant output stays readable through the system CJK fallback tier, and the sidebar plus startup-splash logos render crisply on a HiDPI display.
  • Expected: Each macOS package contains exactly one bundled agent sidecar, one Rust host matching its declared architecture, and only configured Chromium locale packs. The release output contains both native macOS architectures, DMG/ZIP artifacts, and one merged updater feed. Each macOS DMG and ZIP carries its standard -arm64 or -x64 architecture marker, and no generic macOS DMG, ZIP, or blockmap remains in the release output. The per-architecture updater metadata points to those names without collisions. Renderer dependencies exist through Vite output rather than duplicate raw node_modules; dependency source maps, tests, examples, declarations, a second agent-runtime tree, and reliably excludable non-target native assets are absent. Curated Shiki grammars highlight locally while an unknown fence stays readable as plain text. The renderer ships minified chunks, carries no legacy woff/truetype payload, emits no application font face (only the KaTeX woff2 faces remain), and imports only renderer-sized brand marks; math, Chinese text under the CJK fallback tier, and the chrome logos all render correctly. The offline shell starts and all fixture capabilities use local packaged assets; provider/update network failures do not block startup.
  • Specs linked: 02-architecture/01-architecture.md, 02-architecture/02-tech-stack.md, 03-runtime/07-process-model.md, 04-ux/02-i18n-english-first.md, 05-security/01-security.md, 06-delivery/06-release-runbook.md, D008
  • Acceptance: A (app startup), Quality
  • Milestone: M5
  • Status: Unit-covered (packaging-footprint.test.mjs validates static dependency and builder configuration); native inventory and packaged offline launch Draft

E2E-093: Mutating tools serialize and recover from stale edit context ​

  • Preconditions: A project-bound Agent session has a writable workspace; the provider fixture can emit two same-session Write/Edit calls in one tool batch; a second edit can be given a stale tag; a Bash command can return a non-zero exit code with diagnostics.
  • Steps:
    1. Start a task that emits two mutations for the same session while also emitting independent read/search calls.
    2. Inspect the key tool result and transcript while the first mutation runs.
    3. Force the second Edit to carry a tag that no longer hashes the file, with anchors that recovery cannot remap, then allow the agent to re-read the file and retry from the current contents.
    4. Run a Bash command that exits non-zero and inspect its tool result and inline state.
    5. Repeat with an ops payload whose ranges overlap.
    6. If the task uses a dedicated worktree outside the advertised workspace, verify its guarded Bash edit and resulting git diff.
  • Expected:
    • Read/search calls may overlap, but only one Write/Edit executes for a session at a time; queued mutations do not consume another global mutation slot while waiting.
    • The stale-tag edit fails without changing the file and returns EDIT_TAG_MISMATCH carrying the live tag and current content at the anchors; the overlapping-range payload fails with EDIT_RANGE_INVALID before any write.
    • The non-zero Bash command is marked failed while retaining its exit code, stdout, and stderr for the agent and diagnostics.
    • The retry performs one fresh read and operates on the current file; once that path has spent its recovery graces, the third counted same-path failure — or a third failed shell patch command — returns a terminating tool result plus a visible MUTATION_RETRY_BUDGET_EXHAUSTED row, stops the mutation workflow, and does not repeatedly modify an old patch artifact or its hunk headers.
    • The final file contains exactly the intended change, and diff/review data contains no partial or interleaved mutation.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/08-error-codes.md
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5
  • Status: Unit-covered (tool_budget.rs, tools/mod.rs, agent-runtime/runtime.test.ts); full provider/UI journey Draft

E2E-096: Recover transient provider stream failures in place ​

  • Preconditions: A project-bound Agent session uses a deterministic provider fixture that emits a partial assistant stream, terminates once, then succeeds on the next request; a second fixture run can terminate eleven times; a third fixture returns OpenAI API error (502) before headers on one attempt and mid-stream on the next; a fourth fixture returns eleven consecutive 502s; a fifth fixture returns a 503 with Retry-After; a sixth fixture returns a pre-stream opaque 400/422 once and succeeds when the output-limit fields are omitted; the fixture supports both Chat Completions and Responses payloads and aborting after the opaque failure.
  • Steps:
    1. Start an Agent turn with the one-termination fixture and observe the partial assistant response.
    2. Wait for the bounded retry and inspect the transcript, session state, and terminal diagnostics after recovery.
    3. Repeat with the eleven-termination fixture and inspect the terminal error message/event and its diagnostic details.
    4. Run the mixed-phase 502 fixture and inspect the request count and terminal diagnostics for both the pre-header and the mid-stream 502.
    5. Run the persistent eleven-502 fixture and inspect the terminal error.
    6. Run the 503 Retry-After fixture and inspect the observed wait.
    7. Run the opaque 400/422 fixture with both API styles and inspect the two request payloads, request count, and terminal diagnostics.
    8. Abort immediately after the first opaque 400/422 failure and inspect that no repair request starts.
    9. Reload the session and verify that only the completed response or the single terminal failed assistant remains durable.
    10. Open Settings → AI → Defaults, enable infinite provider retry, and repeat a network fixture that fails beyond ten attempts before recovering. Stop the turn during backoff and verify no later request starts; disable the setting and verify a fresh persistent outage stops after ten retries.
    11. Alternate one socket failure and one successful tool-call response for eleven actual Read calls, then fail once and return a final answer. Verify all twelve independent failures recover in the same user turn, each beginning at retry 1, with no repeated tool execution.
    12. After a recovered tool response, keep the next response failing. Verify it receives ten fresh retries and terminates once with retryAttempt: 10. Restore the fixture, click Continue, and verify completion in the UI.
  • Expected:
    • terminated is classified as STREAM_FAILED, and an upstream gateway 502/503/504 as retryable PROVIDER_ERROR.
    • Non-429 transient failures share one bounded budget of ten retries after the initial attempt, for eleven provider attempts total, shared by request setup and stream delivery. Each retry waits for an abortable bounded backoff, removes the entire failed assistant suffix from model context, and produces no duplicate assistant bubble or terminal error notification. A retry never calls native continuation while an assistant remains the last model message.
    • A mid-stream 502 is retried rather than surfacing immediately. The mixed-phase fixture spends one counter across both phases and makes eleven attempts in total, not one retry per phase. A complete successful response replenishes both retry budgets for the next tool round. Headers and partial output do not. Observed waits without a Retry-After header are 1, 2, 4, then 8 seconds for every later retry, identical in both phases.
    • Only the failed request is replayed: the session, its transcript, and any completed tool call are untouched across every retry.
    • The recovered turn emits one terminal lifecycle and keeps the same visible assistant message id. Its terminal diagnostics retain the bounded retry outcome and attempt number.
    • The eleventh termination emits one terminal STREAM_FAILED assistant error and lifecycle event; the persistent 502 fixture emits one terminal PROVIDER_ERROR. Both carry retryAttempt: 10. Available details include phase, stream timing, and provider status, without credentials or an unrestricted provider body.
    • The 503 fixture waits for the server's Retry-After instead of the client backoff. Non-429 server and fallback waits are capped at 8 seconds.
    • A pre-stream 400/422 with the (no body) marker gets one silent repair request with max_tokens, max_completion_tokens, and max_output_tokens removed. The caller's payload rewrite remains active, the repair consumes no transient retry budget or backoff, and both Chat Completions and Responses fixtures complete on their second request. A second opaque failure remains terminal.
    • If the turn is aborted after the first opaque failure, the repair request is not started and the result is aborted.
    • A mid-stream HTTP 429 is covered by E2E-149's separate ten-retry path; the two budgets do not draw from each other.
    • Authentication, model-selection, context, and descriptive malformed-request failures do not enter either provider replay path. The opaque empty-body 400/422 case is the bounded repair exception described above. Infinite mode changes no classification and only removes the retry ceiling for admitted network/transient failures; it remains abortable and is visibly marked with an unbounded retry indicator.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 03-runtime/08-error-codes.md, 08-meta/decisions-log.md (D186, D259, D378), ADR 0050, ADR 0128, ADR 0206
  • Acceptance: C (chat & stream), F (persistence), H (diagnostics), Quality
  • Milestone: M5
  • Status: Retry and successful-response budget boundaries covered by provider-recovery-flow.test.ts through real agent loops. Desktop socket failure, partial-stream recovery, Responses recovery, exhaustion, Continue, and eleven-tool-round recovery run in scripts/e2e-provider-recovery.mjs. Existing classification coverage: agent-errors.test.ts, provider-retry.test.ts, runtime.test.ts, subagent.test.ts. Other scenario variants remain Draft.

E2E-1174: Return a paired tool denial in Plan/Goal ​

  • Seed earlier tool-call history, then switch an Agent session to Plan or Goal. A loopback Responses gateway emits Edit, Write or Task only if that tool is declared in the request; an undeclared name would cause a 502.
  • Verify all six mode/tool combinations reach the normal tool-result path: the next request contains the original call id and a mode-denial error, preserves old call/result pairs, and adds no synthetic user correction.
  • Assert that no host call occurs and no delegate starts. The tool event is an error while the model can continue with a read-only answer.
  • A handler reference retained from Agent must refuse execution after Plan/Goal is entered; switching back to Agent restores the normal permission path.
  • Automation: mode-tool-access.test.ts runs real runtime/SDK loops against a loopback HTTP/SSE fixture; runtime.test.ts covers catalog/mode transitions. Existing host permission tests keep Write/Edit denied regardless of grants or permission mode. No paid provider or user Desktop profile is used.

E2E-149: Recover provider rate limits (429) silently in place ​

  • Preconditions: A project-bound Agent session uses deterministic provider fixtures for a setup HTTP 429 and a mid-stream HTTP 429. Each fixture can succeed after a retry and can return eleven consecutive 429 responses. Fixtures cover retry-after-ms, retry-after seconds, and HTTP-date headers, and support aborting during the wait. A builtin subagent uses a fixture with the same responses. Billing/account-exhaustion fixtures also return HTTP 429 with insufficient_quota, credit_balance_exhausted, organization usage-limit, and spend-limit codes.
  • Steps:
    1. Start an Agent turn with a setup-429 fixture whose next request succeeds.
    2. Repeat with a mid-stream-429 fixture whose next request succeeds.
    3. Inspect the transcript, lifecycle events, request count, and terminal diagnostics for both recoveries.
    4. Repeat with eleven consecutive 429 responses, then inspect the terminal assistant error and diagnostic details.
    5. Start the subagent fixture, then repeat the persistent eleven-429 case.
    6. Start another 429 turn and abort while it is waiting; inspect that no later provider request or terminal retry is started.
    7. Repeat with authentication, model-selection, malformed-request, and context-error fixtures.
    8. Repeat with the billing/account-exhaustion 429 fixtures and inspect the terminal assistant error and provider request count.
  • Expected:
    • Throughput rate-limit 429s are classified as retryable PROVIDER_RATE_LIMITED in both setup and post-start recovery, including a response body that omits rate-limit wording when the captured HTTP status is 429. Diagnostics retain providerStatus: 429.
    • Explicit billing/account exhaustion is terminal PROVIDER_ERROR even when returned with HTTP 429, including insufficient_quota, credit_balance_exhausted, organization usage-limit, and organization/ project spend-limit codes. It starts no retry and consumes neither retry budget.
    • Setup and mid-stream failures share one budget of ten retries after the initial attempt. A persistent fixture therefore makes eleven provider attempts, never multiplies attempts through nested pi-ai retries, and emits no intermediate assistant error, lifecycle error, turn_end, or agent_end. A complete successful response, including a tool-call response, resets both budgets before the next model request. Independent recovered rate limits across more than ten tool rounds must not terminate the user turn or a builtin subagent; partial output alone must not replenish either budget.
    • A recovered attempt removes the failed assistant from model context and reuses its visible assistant message id. The transcript has one assistant bubble and one terminal lifecycle; bounded retry diagnostics retain the phase, delay, and attempt number.
    • Delay precedence is retry-after-ms, retry-after seconds, HTTP-date, then exponential backoff with positive jitter. Server and fallback waits are capped at 30 seconds and the wait is abortable.
    • Exhaustion emits one terminal PROVIDER_RATE_LIMITED assistant error and lifecycle event with retryAttempt: 10 and providerStatus: 429; no eleventh retry occurs after the eleven provider attempts. The structured assistant error card remains the only failure surface, exposing one localized Continue action and no Regenerate action; the generic TurnOutcomeCard is omitted. Activating Continue appends the localized continuation prompt (Continue the user's unfinished task. / 继续用户未完成的任务) to the same session and starts the next turn without discarding the failed turn.
    • The subagent uses the same ten-retry budget and one visible child bubble; its final report is failed only after the budget is exhausted, while intermediate 429s never become a parent-visible error report.
    • Aborting during a backoff cancels the pending timer and starts no later provider request. Authentication, model-selection, malformed-request, and context fixtures make no automatic retry.
    • The 429 budget is separate from the non-429 transient budget in E2E-096. A 429 does not consume transient retries and a 502 does not consume 429 retries.
  • Specs linked: 03-runtime/02-agent-runtime.md (D245, D378), 03-runtime/08-error-codes.md, 08-meta/decisions-log.md (D245, D378), ADR 0091, ADR 0206
  • Acceptance: C (chat & stream), H (diagnostics), Quality
  • Milestone: M5
  • Status: Unit-covered (provider-retry.test.ts, runtime.test.ts, subagent.test.ts); full provider/UI journey Draft

7A. M6 Plan and shell scenarios ​

E2E-104: Legacy contract values migrate to schema v11 ​

  • Preconditions: Schema-v8 fixtures contain sessions, app defaults, and scheduled records with legacy chat values plus transcripts and permissions; schema-v7 and schema-v9 fixtures cover both guarded entry paths.
  • Steps: 1) Start host-core and allow the guarded migration (v7 first reaches v8). 2) Inspect sessions, settings, scheduled modes, plan_approvals fields/indexes, and the exact readable v8/v9 backup. 3) Restart and inspect the same records. 4) Repeat with malformed app settings, malformed scheduled config, invalid top-level operating modes, and an unknown or wrong-platform default shell. 5) Repeat with a platform-valid persisted shell marked temporarily unavailable and nested extension mode fields.
  • Expected: Every legacy mode is plan, Agent remains the new-session and new-task default, transcripts/permissions survive, plan_approvals retains approval data and has artifact/execution fields, v8→v11 is one atomic transaction after its WAL checkpoint and v8 backup, v9 and v10 create readable backups, and migration failure leaves the source schema authoritative. Every malformed or invalid fixture fails closed before schema promotion. The temporarily unavailable platform-valid shell remains persisted for runtime fallback, and nested extension modes remain unchanged.
  • Specs linked: 00-baseline.md, 03-runtime/04-data-storage.md, 03-runtime/01-ipc-protocol.md, 04-ux/06-settings-ia.md, ADR 0053
  • Acceptance: F (persistence), H (diagnostics)
  • Milestone: M6
  • Status: Automated (passed 2026-08-05): host-core 139/139, including 15 focused DB tests, covers schema-v7→v8→v11, v8→v11, v9→v11, and v10→v11 guarded paths, exact readable backups, fail-closed rollback, restart, transcript, settings, scheduled-mode, approval-field, and index tests

E2E-105: Plan policy remains host-authoritative ​

  • Preconditions: A project-bound session is idle in Plan with BrowserPreview, a plugin tool, and a forged requestedMode = "agent" fixture.
  • Steps: 1) Inspect visible Plan tools. 2) Use Read/Glob/Grep and BrowserPreview. 3) Attempt Write, Edit, plugin, and unknown tools through the host with every permission mode. 4) Run Bash under Ask, Accept edits, and Auto.
  • Expected: Plan denies Write/Edit/plugin/unknown tools regardless of the forged mode, grants, or Auto; Bash follows the selected permission mode. The runtime remains one pi Agent and all denials are audited.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/03-tools-and-permissions.md, 03-runtime/05-host-core-rust.md, 03-runtime/06-host-rpc-protocol.md, 05-security/01-security.md, ADR 0053
  • Acceptance: E (tools and permissions), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan plus host-core permission/policy and agent-runtime tool-composition tests

E2E-PLAN-WORKSPACE: Missing workspace does not strand a contract turn ​

  • Preconditions: Isolated host, active global workspace, temporary session without a persisted project workspace; repeat for Plan and Goal.
  • Steps: Enter the contract from Agent mode and submit. In the runtime, submit from a workspace-less contract session, receive the tool error, deliver a final explanation, and repeat on the user's next "continue" turn. Use a bound project as the success control.
  • Expected: Entry succeeds and leaves the session in planning state. Submission fails with PLAN_WORKSPACE_REQUIRED without an approval or artifact. The runtime returns a non-terminating error with workspace binding guidance and permits a final assistant response. No execution is authorized. Bound-project submission still produces a pending immutable checkpoint.
  • Status: Automated by scripts/e2e-plan.mjs and the runtime Plan transition tests with a scripted provider boundary.

E2E-106: SubmitPlan rejects into editable planning and resubmits a new artifact ​

  • Preconditions: A project-bound session is idle in Plan with a provider; .pi/plan/ is absent or empty and the workspace permits host artifact creation.
  • Steps: 1) Let the Agent call SubmitPlan with fixed title, Markdown, and question. 2) Inspect the new .pi/plan/*.md file byte-for-byte and the plan_approvals row. 3) Inspect the card's title and artifact opener; confirm the opener uses the bundled file view when it is launchable and the host file tab otherwise (D452), and that the question/description, validity/deadline, and status are absent with only Approve and Reject offered. 4) Open the approval mode menu, choose Auto, and verify the next approval defaults to Auto. 5) Reject the proposal. 6) Confirm durable mode is Plan, live state is editable planning, the approval gate is cleared, and a later prompt is accepted. 7) Let the Agent revise and call SubmitPlan once in that new turn with a complete snapshot. 8) Approve the second proposal with the remembered Auto mode.
  • Expected: Host preserves the exact submitted Markdown bytes in a new unique artifact, records its relative path/hash/size with structured title/question, and never lets the renderer or sidecar write or replace it. The title-derived artifact filename is recognizable from the title, including non-ASCII title characters. The card shows the title and opens the artifact in the bundled file view, falling back to the host file tab when that view is not launchable (D452); it does not require inline question/Markdown/hash/size or a validity/deadline indicator. The selected approval mode is remembered locally for the next approval. Rejection is terminal for the first row, leaves durable mode Plan, and returns live state to editable planning. The later prompt/resubmission creates a second complete Markdown snapshot and a different .pi/plan/*.md artifact; the first artifact bytes remain unchanged. Approving the second proposal with the remembered Auto mode still changes the same Agent to Agent and queues execution.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/03-permission-ux.md, 04-ux/08-component-spec.md, 05-security/01-security.md, ADR 0053
  • Acceptance: C (conversation/stream), E (permissions), F (persistence)
  • Milestone: M6
  • Status: Automated (passed 2026-08-05): test:e2e:plan verifies the Host artifact/approval lifecycle. The optional live test:e2e:plan-ui case requires an env-provided OpenAI-compatible provider; the authorized run with model gpt-5.6-luna passed 6/6 with zero console diagnostics. It used the real controlled Composer and Send, the live Agent called EnterPlanMode then SubmitPlan, normal rendered Ask approval resolved through preload/Main, approved execution emitted the exact durable marker, and a private env-gated WeakMap check proved the same DesktopAgentRuntime object before and after approval. Main/Host/sidecar PIDs remained stable; credentials never entered CDP or output. The default no-key run remains 5/5 with the live case explicitly skipped.

E2E-CHAT-opaque-floating-decision-and-retry-surfaces: Dock, Plan approval, and retry surfaces occlude transcript text ​

  • Status: Automated (pnpm test:e2e:composer-occlusion, pnpm test:e2e:theme-surfaces, apps/desktop/test/plan-mode-source-contract.test.mjs, apps/desktop/test/active-turn-surface.test.mjs)
  • Priority: P2
  • Covers: C, Quality / floating composer and retry surfaces
  • Preconditions: Renderer CSS is the production source under apps/desktop/src/styles.
  • Steps:
    1. Inspect the computed background of .composer-dock-docked (fully transparent) and the .thread-scroll mask with two different --composer-dock-height values, in both built-in themes and a custom theme.
    2. Scroll a long transcript until a row crosses the Composer boundary.
    3. Inspect .plan-approval-bar in the composer dock styles.
    4. Inspect .run-activity-error-popover.message-error in the transcript styles.
    5. Hover or focus a retrying active-turn row in a live session.
  • Expected:
    • The dock paints no backing surface. Transcript text fades out across the .thread-scroll mask at the Composer boundary and cannot remain visible below the shell or around its rounded corners. The mask's two stops sit --composer-dock-height + 16px and --composer-dock-height - 2px above the scrollport's bottom edge, so a transcript pinned to its end keeps its last row fully opaque and the conversation pane's own surface - including a contributed theme's background - stays visible behind the dock.
    • The Plan/Goal approval bar paints --ds-bg-composer with --ds-shadow-composer rather than the in-flow --ds-tile wash, so it remains a readable plate over the transparent composer dock.
    • The retry hover tooltip mixes the error tint over --ds-bg-elevated-opaque, so transcript text does not show through.
    • The retry tooltip is capped to the room above the tail status row and scrolls the remainder, so a long provider message keeps its first lines inside the transcript scrollport and below the conversation bar instead of being hidden by either.
  • Specs linked: 04-ux/03-permission-ux.md, 04-ux/08-component-spec.md
  • Acceptance: C, Quality
  • Milestone: M6
  • Status detail: Source contracts assert the CSS tokens. Live hover remains a visual check.

E2E-107: Plan approval uses one absolute 30-minute expiry ​

  • Preconditions: A pending Plan request exists with a controllable clock.
  • Steps: 1) Record createdAt and expiresAt. 2) Reload the renderer and reopen the request. 3) Advance time to the deadline without resolving. 4) Attempt approval after expiry.
  • Expected: Renderer reload rehydrates only the still-pending row while the host remains alive, and the displayed countdown retains the original absolute deadline; rejected, expired, approved/completed, and interrupted terminal cards are not part of reload hydration. Expiry records expired, leaves the session Plan, returns PLAN_APPROVAL_TIMEOUT, and rejects the late response without changing mode or permission.
  • Specs linked: 03-runtime/06-host-rpc-protocol.md, 03-runtime/08-error-codes.md, 03-runtime/10-session-state-machine.md, 04-ux/03-permission-ux.md, ADR 0053
  • Acceptance: E (permissions), H (diagnostics), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan-ui covers pending renderer reload; test:e2e:plan and the deterministic host-core late-expiry test cover the absolute deadline, timeout persistence, and fail-closed resolution. No terminal-card reload hydration is claimed.

E2E-108: Startup fence interrupts pending Plan work ​

  • Preconditions: A Plan request is pending with a live approval waiter and running planning turn; host and renderer can restart independently.
  • Steps: 1) Reload the renderer and list the live request. 2) Restart the host/app before resolution. 3) Inspect the plan_approvals row, turn, and session after startup. 4) Submit the pre-restart response.
  • Expected: Renderer reload while the host remains alive preserves the still-pending row and original deadline. Full Host/app restart transactionally marks the pending row and turn interrupted/aborted before RPC service, leaves the session Plan, and returns PLAN_APPROVAL_STALE for the old response. No actionable stale card or execution is restored, and the UI is not required to present the interrupted terminal snapshot after restart. No process epoch field is persisted or sent.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/07-process-model.md, 03-runtime/10-session-state-machine.md, 04-ux/08-component-spec.md, ADR 0053
  • Acceptance: F (persistence), H (diagnostics), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan performs a real Host restart and host-core recovery tests verify interrupted durable state; the pending renderer-reload assertion is covered by the E2E-107 UI lane.

E2E-109: Approved Plan execution is not replayed after restart ​

  • Preconditions: A Plan request has been approved with Ask and is captured once in queued and once in running state.
  • Steps: 1) Restart the host during each state. 2) Inspect the plan_approvals.execution_state and turn records after startup. 3) Observe provider/tool invocations and session mode. 4) Start a new user turn explicitly.
  • Expected: Queued/running execution fields become interrupted, associated turns abort, no provider/tool call is replayed, and the session remains Agent because approval already committed. A new turn is accepted only after the user starts it; no interrupted terminal card or stale action is required after restart.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/07-process-model.md, 03-runtime/10-session-state-machine.md, ADR 0053
  • Acceptance: C (conversation/stream), F (persistence), H (diagnostics), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan restarts real queued and claimed executions and verifies no replay plus Agent retention

E2E-110: Scheduled Plan is rejected before any work ​

  • Preconditions: A scheduled task is Plan and an unattended runner is available; provider, artifact, and queue writes can be observed.
  • Steps: 1) Trigger the task through the unattended path. 2) Inspect the provider trace, .pi/plan/, and plan_approvals table. 3) Switch the task/session explicitly to Agent and run it again.
  • Expected: Plan is rejected before provider, artifact, approval, or queue work with PLAN_REQUIRES_INTERACTIVE_SESSION; no background auto-approval occurs. Explicit Agent selection permits normal unattended policy.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/08-error-codes.md, 04-ux/01-ui-ia.md, ADR 0053
  • Acceptance: F (persistence), H (diagnostics), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan verifies Plan rejection before side effects and explicit Agent execution independent of the global default

E2E-111: Active-turn, pending-approval, and configuration boundaries are enforced ​

  • Preconditions: A session has one active Agent turn and another session is idle; a Plan run can be made pending/queued/running.
  • Steps: 1) Attempt a second prompt, mode/provider/model/permission/shell configuration change, and second Plan submission during the active turn. 2) Let the turn become pending approval and repeat the prompt and configuration attempts. 3) Reject the approval. 4) Submit a later prompt and let the Agent create a revised Plan snapshot. 5) Repeat configuration after the session is editable planning.
  • Expected: Active-turn/configuration changes, prompts, and a second Plan submission fail with AGENT_BUSY/CONFLICT while the turn or active pending approval exists; only the originating session is blocked. Reject returns the durable session to Plan and the live state to planning, clears the gate, and permits the later prompt/new artifact. Terminal proposal snapshots do not disable input, the Composer mode chip, or model selection during the current renderer lifetime. Idle/planning configuration succeeds and no cross-session event or workspace root leaks.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/10-session-state-machine.md, 04-ux/08-component-spec.md, ADR 0053
  • Acceptance: C (conversation/stream), E (permissions), Quality
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan verifies Host boundaries and test:e2e:plan-ui verifies pending-only gating plus editable rejected/terminal states during the current renderer lifetime

E2E-111a: A staged mode switch does not move the live planning indicator mid-turn ​

  • Preconditions: A project-bound session is running an Agent turn with a provider; the renderer shows the working indicator and no Plan / planning indicator.
  • Steps: 1) While the Agent turn is still running, switch the Composer mode chip from Agent to Plan. 2) Inspect the transcript status area before the turn ends. 3) Let the turn reach its terminal event. 4) Inspect the status area again while idle, then send a new prompt.
  • Expected: The staged Plan choice updates the chip immediately, but the live planning indicator stays away while the in-flight Agent turn runs; it does not show Plan / planning until the new prompt starts under the staged mode, and the Composer chip does not pulse until that live state projects planning. After the terminal event flushes the configuration the session is durable Plan with editable planning state, and the sent prompt surfaces the Plan / planning indicator in the reserved tail slot while the chip pulses. Tools and answers do not hide it; runtime phases take precedence, and pending user interaction or turn completion suppresses it.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/10-session-state-machine.md, 04-ux/08-component-spec.md
  • Acceptance: C (conversation/stream), Quality
  • Milestone: M6
  • Status: Draft

E2E-112: Selectable shell catalog persists the default ​

  • Preconditions: Host has an available platform catalog entry, a fixture can make a persisted choice unavailable, and a project-bound Agent session is idle. The Windows lane exercises the multi-choice ordering.
  • Steps: 1) Inspect the catalog for the platform-valid IDs windows-powershell, windows-pwsh, cmd, git-bash, and bash. 2) Verify settings rejects an unavailable or wrong-platform ID. 3) Select an available shell and persist defaultCommandShell. 4) Make that persisted choice unavailable, restart, and verify the catalog selects the first available platform shell with fallback: true. 5) Execute the unchanged Bash tool.
  • Expected: Settings persists only a valid stable shell ID; unavailable entries remain unavailable with guidance and a later unavailable persisted choice uses the intentional first-available fallback. The host invokes the effective shell while the tool/protocol name stays Bash, and shell selection follows the idle configuration boundary.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/03-tools-and-permissions.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, ADR 0054
  • Acceptance: B (model/config), E (tools/permissions), F (persistence)
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan verifies catalog, validation, persistence, and restart; the deterministic host-core catalog test verifies first-available fallback when a stored shell becomes unavailable

E2E-SHELL-windows-pwsh-msix-store-alias-is-available ​

  • Preconditions: Windows host with PowerShell 7 installed via MSIX / Microsoft Store (winget msixbundle). %ProgramFiles%\PowerShell\7\pwsh.exe is absent. pwsh.exe on PATH is the WindowsApps app-execution alias (IO_REPARSE_TAG_APPEXECLINK).
  • Steps: 1) Open Settings → command shell. 2) Inspect windows-pwsh availability. 3) Select PowerShell 7 and persist defaultCommandShell. 4) Run Bash.
  • Expected: windows-pwsh is available and selectable. The host resolves the Store alias without treating it as missing. Bash launches PowerShell 7.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 04-ux/06-settings-ia.md, ADR 0054, ADR 0209
  • Acceptance: B (model/config), E (tools/permissions)
  • Milestone: M6
  • Status: Documented; host-core unit tests cover the Win32-attribute decision (alias attrs 0x420 + tag 0x8000001B). Live Store-alias probe is Windows-only and is not run in Linux CI.

E2E-113: Stale shell identity fails closed ​

  • Preconditions: A Bash turn has a pinned effective shell ID/dialect; the fixture can change the effective catalog selection before spawn.
  • Steps: 1) Change the effective shell ID or dialect. 2) Execute Bash with the old expected ID. 3) Inspect process creation, fallback attempts, audit, and UI error. 4) launch a fresh turn and retry.
  • Expected: The first call returns COMMAND_SHELL_CHANGED, starts no process, and does not change shell after the turn pin. The audit records the selected ID and dialect. A fresh turn snapshot is required for a later run.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/05-host-core-rust.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/08-error-codes.md, 05-security/01-security.md, ADR 0054
  • Acceptance: E (tools/permissions), H (diagnostics), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan verifies stale dialect rejection before marker creation plus host-core stale ID/dialect tests

E2E-114: Bash streams stdout and stderr independently ​

  • Preconditions: A selected shell is available and a deterministic command writes interleaved stdout and stderr chunks.
  • Steps: 1) Execute the command through Bash. 2) Observe host/RPC/UI output events. 3) Inspect the final bounded result and transcript row.
  • Expected: stdout and stderr remain separate, ordered per tool call, and visible while the process runs. Final output preserves truncation metadata; no chunks cross sessions or turns and the Bash protocol name is unchanged.
  • Specs linked: 03-runtime/06-host-rpc-protocol.md, 03-runtime/09-logging-and-observability.md, 03-runtime/16-tool-result-limits.md, 04-ux/09-interaction-patterns.md, ADR 0054
  • Acceptance: C (stream), E (tools), Quality
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan verifies distinct stdout/stderr notifications and final tool identity; host/runtime stream tests cover bounded accumulation and session isolation

E2E-115: Bash timeout uses 60 seconds and a bounded override ​

  • Preconditions: A selected shell can run a command longer than 60 seconds; host clock is observable.
  • Steps: 1) Run without a timeout override. 2) Observe the 60-second deadline. 3) Run with an in-range override including values above 300 seconds. 4) Submit zero, negative, and over-21,600-second overrides.
  • Expected: Missing timeout uses exactly 60 seconds and returns TOOL_TIMEOUT after process-tree shutdown; the error names the effective timeoutMs budget and suggests raising it or splitting the command. In-range values work within 1–21,600 seconds; out-of-range values fail validation and never spawn.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/08-error-codes.md, 03-runtime/16-tool-result-limits.md, 05-security/01-security.md, ADR 0054, ADR 0167
  • Acceptance: E (tools), H (diagnostics), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): long-timeout test:e2e:plan measured the no-override timeout at 60,024 ms and verified in-range plus invalid bounds without delayed marker writes

E2E-116: Bash abort shuts down the complete process tree ​

  • Preconditions: A Bash command starts a child and grandchild that emit delayed output; the originating session is running.
  • Steps: 1) Start the command. 2) Abort the active turn. 3) Inspect process descendants, output events, audit, and turn state after the shutdown grace.
  • Expected: The process group/job tree is terminated, no descendant remains, no later output arrives, the turn returns TURN_ABORTED, and the workspace is not rolled back automatically. On Windows, the hidden runner requests CREATE_BREAKAWAY_FROM_JOB only when the job that contains the host grants JOB_OBJECT_LIMIT_BREAKAWAY_OK, then enters its own kill-on-close job. A host job that withholds breakaway leaves the runner in a nested job instead of failing the tool closed; termination is still scoped to that job, so the Desktop tree never becomes the termination scope.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/07-process-model.md, 03-runtime/08-error-codes.md, 03-runtime/16-tool-result-limits.md, 05-security/01-security.md, ADR 0054
  • Acceptance: C (abort), E (tools), H (diagnostics), Security
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): test:e2e:plan aborts a real descendant process tree and verifies no late marker/output; host-core tests verify cancellation registry cleanup

E2E-265: Background Bash tasks outlive their turn ​

  • Preconditions: A project session whose catalog shell supports background execution, and a port the test server may bind.
  • Steps: 1) Ask the agent to start a long-lived process with runInBackground (a local web server) and watch the running-tasks bar above the composer. 2) Abort the turn, send a second prompt in the same session, then reload the renderer. 3) Expand the row's Output. 4) Press Stop. 5) Start ten tasks with 100,000 lines of 100 characters on both streams, then inspect their final output. 6) Show Todo and Ask alongside the task list at wide and narrow widths, scroll to the tenth task, and switch output previews while an earlier read is pending. 7) Fail a Stop request and retry it. 8) Start two more tasks and quit the app during output capture.
  • Expected: The tool result carries backgroundTaskId and resolves without waiting for the command; the bar lists the task as running with a growing elapsed time and shows its streamed output; the process stays alive across the abort, across the 60-second Bash timeout, and across a renderer reload. Stop settles the row to Stopped and kills the process tree; quitting the app leaves no orphan process behind. The ten-task list scrolls within its height cap without displacing Todo, Ask, or input. Only the selected log is fetched and refreshed; old responses cannot erase it. Each stream remains bounded to 256KiB and retains its final marker after exit. A Stop failure is visible without an unhandled rejection and the control remains retryable. An ended task keeps its row only for a short grace window and can be closed from the bar, so finished commands do not accumulate above the composer.
  • Specs linked: 03-runtime/03-tools-and-permissions.md (§5), 03-runtime/06-host-rpc-protocol.md (§4)
  • Acceptance criterion: C, E, F
  • Milestone: M6+
  • Status: Partially automated (host-core background_tasks::tests and the renderer contract test apps/desktop/test/background-tasks.test.mjs; node scripts/e2e-background-tasks-host.mjs runs the real Host/process/pipe stress path without providers, and node scripts/e2e-background-tasks.mjs exercises production React components in isolated Electron at 1280px, 700px, and 390px widths. Host tests cover exit-before-drain and shutdown under output lock contention. The full agent-to-Desktop journey and an 83-minute paced 100,000-line soak remain manual; the automated volume test is accelerated. Renderer visibility (grace window, close action, pinned output preview) is covered by apps/desktop/test/background-tasks-bar-visibility.test.mjs with a controlled clock; the multi-second hand-off itself is not timed in Electron.)

E2E-117: Agent/Plan/Goal UX and locales contain no Chat controls ​

  • Preconditions: App can run in English and zh-CN with an idle session, Plan artifact fixture, shell settings, and global search's Commands section available.
  • Steps: 1) Inspect Agent/Plan/Goal, permission, artifact approval, and shell controls in English. 2) Enter Plan and inspect planning/approval/queue/ terminal states while the renderer remains alive. 3) Approve and reject a proposal, confirming that the approval surface disappears after host confirmation. 4) Inspect the Commands section and confirm it contains exactly builtin.session.new, builtin.agent.compact, and the three builtin.mode.* commands, with only /new, /compact, /agent-mode, /plan-mode, and /goal-mode in the builtin / group. Confirm the removed command IDs and newChat / openProject / openSettings dispatch aliases are absent. Use /plan-mode / /agent-mode slash aliases to switch the active idle session, confirming the Composer chip changes immediately. Type a prompt after either alias in the same draft and send it; confirm the mode changes and the prompt remains as a visible user turn. Send an alias alone and confirm it remains a local mode switch without creating an empty transcript turn. If prompt dispatch fails, confirm the complete draft remains editable. 5) Reload after a terminal proposal and inspect the session while asserting that Electron Main and Host process identities did not change. 6) Repeat in zh-CN. 7) Search visible commands for the removed Chat mode and request-changes controls. Host/app restart recovery is exercised separately by E2E-108 and E2E-109.
  • Expected: Agent is the default; the left-of-input Composer chip is the sole active-session Agent/Plan/Goal control; Plan and Goal show Ask/Accept edits/Auto, the submitted title, an artifact opener, remembered approval mode, approve/reject only, shell catalog/fallback status, and localized failed-closed states. No Chat mode, /chat-mode, request-changes action, inline Markdown/hash/size requirement, or stale actionable queue is exposed; terminal checkpoint metadata may remain non-actionable only for the current renderer lifetime, while the composer approval surface is removed after host-confirmed resolution. Renderer reload does not rehydrate rejected, expired, approved/completed, or interrupted terminal cards. Host/app restart does not replay work or restore stale actions, and the UI is not required to present the interrupted terminal snapshot; page = "chat" remains an internal route.
  • Specs linked: 01-product/01-product-scope.md, 04-ux/01-ui-ia.md, 04-ux/04-builtin-commands.md, 04-ux/03-permission-ux.md, 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, 04-ux/02-i18n-english-first.md, ADR 0053, ADR 0054
  • Acceptance: C (conversation), Quality
  • Milestone: M6
  • Status: Automated (passed 2026-08-04): raw-CDP test:e2e:plan-ui uses an env-gated Electron Main probe against the existing Host, asserts stable Electron/Host PIDs, covers pending restore, live terminal controls, rejected and approved/completed terminal-card absence after renderer reload, EN/zh-CN, and 1280×800 / 900×700 rendering. E2E-108/E2E-109 cover Host restart interruption, stale-action rejection, and no replay.

E2E-118: A regenerated turn keeps its final answer after the branch archive ​

  • Preconditions: A project-bound Agent session whose transcript already has one completed exchange, a provider that streams a multi-tool turn long enough for the final assistant message to land through the persistence outbox, and read access to <data_dir>/sessions/<id>.jsonl, <id>.revisions.jsonl, and the messages index.

  • Steps: 1) Regenerate the assistant answer so the root user turn carries revisionCount / activeRevision and revision 1 is archived. 2) Let the re-run finish a turn that ends with tool calls followed by a final assistant message. 3) Immediately after agent_end, inspect the transcript file, the messages rows, and the archived revision payload. 4) Reload the session. 5) Page the root bubble back to revision 1 and forward again.

  • Expected: The final assistant message is present in the transcript file, in the index, and as the last message of the archived branch. Every message of the turn keeps its owning turn_id. The root carries revisionCount = 2 and activeRevision = 2, reload shows the complete turn, and paging restores each branch whole. No session.replaceMessages call is made on the turn-completion path.

  • Specs linked: 03-runtime/04-data-storage.md §4.9/§7, 03-runtime/06-host-rpc-protocol.md §4, ADR 0041, ADR 0060

  • Acceptance: C (conversation), F (persistence), H (diagnostics), Quality

  • Milestone: M6

  • Status: Covered by host-core unit tests (2026-08-06): save_active_branch_revision_keeps_a_message_appended_after_its_read archives with a message appended after the read and asserts the transcript, the archived payload, and the index (seq, turn_id) rows; replace_messages_preserves_owning_turn_ids covers the remaining rewrite callers. UI paging remains manual.

  • Bounded-history regression (#1870): Regenerate an older prompt, then continue until the root is outside the newest 100-message page. Complete another turn while viewing that page, then repeat after switching to another session. The archive must not append the older user root after the latest answer in the active transcript, retained pane, or session cache. If the root is loaded, its pager updates in place using its current message identity, which can differ from the stable family key. A root removed during the archive RPC stays absent. Previous variants remain available after restart. With neither a cached nor retained background transcript, the acknowledgement must leave the cache absent and allow the next prefetch to read the Host. Automated event-to-projection coverage: apps/desktop/test/regenerate-root-projection.test.mjs. node scripts/e2e-regenerate-root.mjs exercises the real Host process, production main persistence and renderer projections with an isolated 3,484-message transcript, three follow-up turns, restart, and a revision round trip. Set PI_DESKTOP_HOST_BIN to a host built from the candidate.

E2E-246: Retrying a large session truncates without a whole-transcript RPC ​

  • Preconditions: An Agent session whose live transcript is thousands of messages (large enough that a session.replaceMessages JSON-RPC line would exceed tens of megabytes), with a completed or failed last user turn.
  • Steps: 1) Retry or regenerate the last user prompt. 2) Inspect host RPC traffic, turns.status, the live jsonl, and message_revisions. 3) Retry again immediately if the first attempt is still on screen as running.
  • Expected: Electron main calls session.truncateFrom with fromMessageId only — no session.replaceMessages and no session.saveRevision of the discarded array. The kept prefix never appears in an NDJSON request. The leftover running turn is aborted before beginTurn. The discarded tail is archived (refresh of the stamped variant, or a new inactive variant). A second retry while a cut is in flight does not ship another full transcript. The UI error is not host RPC timeout: session.replaceMessages.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md §4.9/§7, 03-runtime/06-host-rpc-protocol.md §4, ADR 0216, D390
  • Acceptance: C (conversation), F (persistence), Quality
  • Milestone: M6
  • Status: Covered by host-core unit tests (2026-09-10): truncate_from_drops_the_tail_and_archives_the_discarded_branch, truncate_from_rejects_an_unknown_message, truncate_from_refreshes_the_stamped_revision, truncate_from_rpc_cuts_without_shipping_the_kept_prefix. Desktop journey remains Draft.

E2E-247: Windows host-core exits after stdin EOF and oversize RPCs fail immediately ​

  • Preconditions: Windows host-core with the Alt+Space keyboard hook installed; a JSON-RPC request whose NDJSON line exceeds 64 MiB.
  • Steps: 1) Send stdin EOF to a running host-core. 2) From Electron, call a host method whose stringified payload exceeds 64 MiB. 3) If a line still reaches host-core, inspect the LIMIT_EXCEEDED reply id.
  • Expected: After stdin EOF, host-core exits without waiting 130 s. The Windows keyboard hook does not keep the stdout writer alive. Electron rejects the oversize call with LIMIT_EXCEEDED before stdin.write. A host-side oversize reply uses the request id peeked from the prefix, not null. The UI error is not host RPC timeout.
  • Specs linked: 03-runtime/07-process-model.md, 03-runtime/06-host-rpc-protocol.md §7, ADR 0217, D391
  • Acceptance: Quality
  • Milestone: M6
  • Status: Covered by unit and source-contract tests (2026-09-11): start_does_not_keep_the_stdout_channel_open, peek_jsonrpc_id_reads_a_string_id_from_a_truncated_prefix, apps/desktop/test/windows-host-runtime.test.mjs (weak sender), apps/desktop/test/rpc-lifecycle-contract.test.mjs (client precheck), packages/shared/src/rpc-limits.test.ts. Desktop journey remains Draft.

E2E-248: Subagent details open as closable work-panel tabs with a read-only composer ​

  • Preconditions: A project-bound Agent session whose assistant emits at least two Task calls — one named explorer and one resumed explorer follow-up — plus one Task that ends in failure; the work panel closed.
  • Steps:
    1. Run the turn. Without clicking anything, verify the panel stays closed while the delegates run (no tab is opened automatically).
    2. Click the explorer topology node. Verify a subagent tab opens in the work panel's normal tab strip (labeled explorer, bot icon) and the tab strip keeps its other tabs.
    3. Click the second (resumed) explorer node. Verify a second tab opens labeled explorer#2, both tabs coexist, and each can be activated, drag-reordered, and closed via ×, middle-click, or Delete.
    4. In the resumed tab, verify the conversation reads user: first prompt, delegate rows, user: follow-up prompt, delegate rows — the same message-list language as the main transcript (user bubbles, tool rows, thinking rows, markdown answers).
    5. Verify the composer at the foot is a disabled two-row textarea whose placeholder reads the read-only hint, in every supported locale.
    6. Wait for the failed delegate to settle, open its tab, and verify the ended tab persists until manually closed.
    7. Switch to another session and back. Verify the tabs are restored per session and closed tabs stay closed.
  • Expected: No auto-open on delegate start; N subagents = N coexisting closable tabs with deduplicated name#n labels; the tab body is the delegation's user/assistant message stream with follow-up prompts as user turns; the composer is disabled with the in-field hint and has no send path; ended delegates' tabs persist until closed; tabs are session-scoped.
  • Specs linked: 04-ux/08-component-spec.md (delegation tab), 04-ux/09-interaction-patterns.md (work panel tabs)
  • Acceptance: Quality
  • Milestone: M6
  • Status: Draft

E2E-119: Parallel subagents report back without entering the parent's context ​

  • Preconditions: A project-bound Agent session with the user home containing ~/.agents/subagents/scout.md (read-only, no tools key), ~/.agents/subagents/fixer.md (tools: Read, Edit), ~/.agents/subagents/pinned.md (model: naming a second configured provider) and ~/.agents/subagents/broken.md (missing name); a provider whose stream can be driven to emit two Task calls in one assistant message; permission mode ask so a delegate's Edit is gated; read access to <data_dir>/sessions/<id>.jsonl and the messages index.
  • Steps:
    1. Prompt a turn in which the assistant emits two Task calls — scout and pinned — in one message. Observe the delegation card while both run and after each one settles; collapse it, then expand each node.
    2. Prompt a turn whose assistant message emits a single Task call — scout — and compare its presentation with step 1's.
    3. Prompt a turn in which two fixer delegates each edit a different file, and answer only the first permission card.
    4. Answer the second card, then prompt a third turn where two fixer delegates edit the same file.
    5. Start a fan-out and press Stop while one card is on screen and another is queued.
    6. Prompt a Task call naming broken, then one naming an agent that does not exist, then one whose definition pins an unconfigured provider.
    7. Switch the session to Plan, then to Goal, and inspect the tool catalog.
    8. Reload the session and re-expand the delegation card and every Task node.
    9. Open a node with a long description and long tool paths. Resize the work panel to its minimum, default, and a wider width and inspect the topology card and the delegation tab at each width. Long content wraps inside the committed width without dragging the divider to read a complete line.
  • Expected:
    • Both delegates in step 1 run concurrently, and pinned streams on its own provider/model while the parent keeps the session's.
    • The two Task calls in step 1 form one full-width delegation card. While active it opens once and its header updates the subagent and settled counts; after settlement it keeps the user's expansion choice and reports aggregate success, warning, or issue state plus elapsed time.
    • The lone Task in step 2 draws the same card with a single delegate node — same root, connector, outcome, runtime and step count — and never the compact one-line tool row (D265). Its aggregate line is worded for one subagent, so no locale reads "1 个 Subagents" or "Subagents working".
    • The expanded card shows one main-agent root connected to scout and pinned in parent-row order, with no invented edge between delegates. Each node shows its agent, short description, explicit outcome, duration and step count. Expanding a node shows the brief, report exactly once, and status/turns/toolCalls. Delegate rows appear only inside that node, never in the turn stream or the minimap.
    • At narrow, default, and wide panel widths, topology titles, descriptions, and step summaries reflow within the card instead of using a fixed one-line ellipsis. Long tool paths, commands, and answer fragments in the delegation tab wrap inside the committed width, produce no horizontal overflow, and retain the tab as the only scroll owner.
    • If the parent keeps working after those Task calls — thinking, Read, Grep, or a lifecycle row — that work is a separate processing group, not rows inside the delegation card (D319). The card's tile, “Subagent working” header, and topology canvas contain only the Task nodes.
    • The parent's next request contains the reports and no delegate message or tool row; the rows are nonetheless present in the transcript file and the index with meta.parentToolCallId and meta.agentName.
    • Only the head permission card is rendered; it names the asking delegate and the number waiting behind it. Answering it reveals the next card, and neither answer resolves the other request.
    • scout cannot call Edit or Write at all; fixer can. Same-file edits in step 4 apply in a defined order and neither loses the other's write.
    • Stop denies both the shown and the queued request, and both delegates end aborted in text and icon inside their own Task nodes — the parent turn ends once and the aggregate card settles with a warning.
    • broken is absent from the catalog with a launch diagnostic and the session keeps its other three delegates; an unknown agent and an unresolvable model pin each fail as a Task tool error naming the cause, with no fallback to the session provider and no turn failure.
    • Task is absent from the catalog in Plan and Goal.
    • After reload the card is collapsed by default; re-expanding preserves node order, attribution, outcome and nested content exactly as they appeared live.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f/§7.2b/§8, 03-runtime/03-tools-and-permissions.md §10.2, 03-runtime/04-data-storage.md §4.7a, 04-ux/03-permission-ux.md §6a, 04-ux/08-component-spec.md §9.9, ADR 0062, decisions-log D201, D265, D319
  • Acceptance: C (conversation), E (tools & permissions), F (persistence), Security, Quality
  • Milestone: M6
  • Status: Covered by unit tests (2026-08-06): packages/sharedsubagent-definition.test.ts and packages/agent-runtimesubagent-definitions.test.ts (frontmatter, tool filtering, malformed documents, global-user-shadows-builtin, legacy-maxTurns ignored); subagent.test.ts (report bounding, abort, event attribution, prompt framing) and path-lock.test.ts (same-path ordering, concurrency cap); desktop permission-inline.test.mjs (queue order, id-matched removal, tool-call removal, abort denying the queue, card copy), subagent-wiring.test.mjs (main-process discovery and model pins) and subagent-transcript.test.mjs + assistant-turns.test.mjs (nesting, single report print, memoization, the card gate that admits a lone delegation and the count-aware aggregate copy in both locales), plus subagent-topology.test.mjs (delegate detection, structured outcomes and aggregate counts). Full multi-provider fan-out and rendered topology interaction remain manual.

E2E-SUBAGENT-legacy-turn-limit-frontmatter-is-ignored ​

  • Preconditions: Agent mode. A user document ~/.agents/subagents/legacy-worker.md whose frontmatter declares maxTurns: 2 next to a valid description and tools, a second document that spells the same key max-turns: 2, and a third that never mentions it.
  • Steps: 1) Open Settings → Subagents and confirm every row in the Built-in and Global groups renders with its tool grant and that no turn-limit field exists anywhere in the page or the editor's Advanced disclosure. 2) Delegate to legacy-worker and let it make more than two tool-calling turns. 3) Read the document back through the settings API and again as a raw file. 4) Open the editor on that row, save it without changing anything, and re-read the file.
  • Expected: Both spellings load as valid definitions. The declared key is ignored exactly like any other unrecognized frontmatter key: no parse error, no warning or diagnostic naming it, the definition still resolves, and the file is not rewritten. The delegate is never stopped at two turns and never reports truncated; it ends only when it finishes or is TaskStop'd. No surface in the app, and no locale's chat.subagentStatus copy, reports a turn limit or a “Turn limit reached” status.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, 04-ux/06-settings-ia.md §7, ADR 0253, decisions-log D423
  • Acceptance: C (conversation), Quality
  • Milestone: M6
  • Status: Draft — unit covered in packages/shared, packages/agent-runtime, and the host-core user_subagents regression test; the full UI journey needs a capable environment.

E2E-SUBAGENT-inherit-parent-tools ​

  • Preconditions: Agent mode. A user document ~/.agents/subagents/worker.md with tools: inherit (no assignable extras), at least one plugin or MCP tool in the parent catalog, and a non-empty Skill catalog. Built-in explorer remains on its whitelist.
  • Steps:
    1. Confirm Settings → Agent → Subagents lists worker and that Edit shows Inherit parent tools on. Save without changing tools and reopen the file.
    2. Delegate Task to worker with a brief that needs a Skill id and a plugin tool the parent already had.
    3. Delegate Task to builtin explorer in the same session.
    4. Confirm worker cannot call Task, ToolSearch, asktool, or new_context.
  • Expected:
    • Step 1 round-trips tools: inherit; the document does not disappear from agents.active and save does not rewrite it to Read, Glob, Grep.
    • worker receives Skill, the plugin/MCP tool, and the parent builtins minus the deny list. Its system prompt lists those names, includes the # Skills catalog, and says it may change files when the parent catalog includes Bash/Edit/Write.
    • explorer still has only Read, Glob, Grep, Bash and cannot call Skill.
    • The Task catalog line for worker reads (tools: inherit).
  • Specs linked: 03-runtime/03-tools-and-permissions.md §10.2, 03-runtime/02-agent-runtime.md §5f/§7.2b, ADR 0246, issue #215
  • Acceptance: E (tools & permissions), Security
  • Milestone: M6+
  • Status: Automated by test:e2e:subagents (host-core create/read/on-disk/active/loader inherit round-trip) and test:e2e:subagent-models (real sidecar/local transport Task spawn, inherited Skill/plugin catalog minus the deny list, and builtin explorer isolation). Unit coverage remains in packages/shared, packages/agent-runtime, and host-core user_subagents; the UI inherit-checkbox journey remains Draft. Required suites: test:e2e, test:e2e:subagents, test:e2e:subagent-models.

E2E-145: Tool results read as structured blocks, never JSON ​

  • Preconditions: A project-bound Agent session with permissions allowed for the turn; a plugin tool whose result is an arbitrary record is installed; the workspace contains a file large enough to trip host truncation.
  • Steps:
    1. Run one turn that reads a source file, globs a directory, greps a token, greps again with outputMode: filesWithMatches and count, edits a workspace file, edits a scratch-root file, runs a failing shell command, and calls the plugin tool.
    2. Inspect each collapsed activity row, then expand every row in light and dark.
    3. Click a Glob path and a Grep hit heading.
    4. Trigger a high-risk tool so the inline permission card appears.
    5. Read a truncated file and copy each block.
  • Expected:
    • No expanded row shows escaped JSON, and no payload appears twice.
    • Read/Write show highlighted content; Bash shows command, output, and error-hued stderr as separate blocks with empty channels omitted; Glob shows a path list; Grep shows hits grouped per file with line numbers in content mode, a path list in filesWithMatches, and per-file totals in count; the failing command carries an exit 1 chip.
    • A Read row's collapsed chip shows the returned window as {lineCount},L{offset+1}-L{offset+lineCount} (for example, 50,L16-L65), never fileBytes; a Read result without valid window metadata has no size chip. Write continues to show its byte-size chip.
    • Each Glob/Grep path-list row is start-aligned with natural character spacing; glyphs are not distributed across the block width.
    • The workspace edit shows no inline diff (its ReviewChangeCard owns it); the scratch edit shows a compact diff and a scratch chip.
    • The plugin result renders label/value fields and labeled blocks, not a blob.
    • Clicking a path opens it in the work panel; paths outside the workspace root are not clickable.
    • The permission card's args preview uses the same blocks.
    • Host truncation markers stay visible, a truncated chip appears, a host notice renders as a neutral note under the block it qualifies, capped lists report the hidden remainder, and copy yields the full payload.
  • Specs linked: 04-ux/08-component-spec.md §9, §10.2, 08-meta/decisions-log.md (D192)
  • Acceptance: C (chat & stream), E (tools & permissions), Quality
  • Milestone: M5
  • Status: Unit-covered (tool-presentation.test.mjs, transcript-style.test.mjs); full UI journey Draft

E2E-146: A turn that produces no visible text re-runs once ​

  • Preconditions: A project-bound Agent session uses a deterministic provider fixture that ends one turn with no tool call and no text — once with reasoning content present, once with nothing at all; a second fixture run ends both the first turn and the re-run that way.
  • Steps:
    1. Start an Agent turn with the reasoning-only fixture and watch the transcript while the runtime recovers.
    2. Inspect the transcript, session state, and terminal diagnostics afterwards.
    3. Repeat with the nothing-at-all fixture.
    4. Repeat with the twice-silent fixture and inspect the terminal error message, its details disclosure, and its action button.
    5. Click the error's retry action.
    6. Reload the session and verify what stayed durable.
  • Expected:
    • The recovered turn keeps the same visible assistant message id, emits one terminal lifecycle, and shows no error. The user sees only the answer.
    • The empty assistant is removed from model context before the re-run, so the provider never receives two assistant messages in a row, and it is never appended to the durable transcript.
    • The terminal diagnostics identify the empty-response recovery and the re-run's outcome.
    • The second silence emits one terminal retriable EMPTY_MODEL_RESPONSE assistant error and lifecycle event; the message names both attempts, and the retry action re-sends the last prompt.
    • A turn whose text is empty because it requested tools is untouched, and so is an aborted or already-failed turn.
    • Only one re-run happens per prompt, including after context-overflow recovery within the same prompt.
  • Specs linked: 03-runtime/02-agent-runtime.md §5e, §7, 03-runtime/08-error-codes.md §3.2, 08-meta/decisions-log.md (D193)
  • Acceptance: C (chat & stream), F (persistence), H (diagnostics), Quality
  • Milestone: M5
  • Status: Unit-covered (runtime.test.ts); full provider/UI journey Draft

E2E-146a: Approved Plan/Goal progress text continues once ​

  • Preconditions: A project-bound session has an approved Plan or Goal and a deterministic provider fixture. One fixture ends with short forward-looking progress text and no tool call; a second ends with a normal final report;
  • Steps:
    1. Start the approved execution with the progress-only fixture and inspect the transcript while the runtime recovers.
    2. Inspect the model request context, lifecycle events, and visible message ids after the continuation completes.
    3. Repeat with the normal final-report fixture.
    4. Repeat the progress fixture with a continuation that emits a real tool call, then inspect the tool result and final report.
  • Expected:
    • The progress text remains in one assistant bubble and causes exactly one continuation. The provider receives the user/execution context without the prior assistant message at the end, and the continuation carries the progress nudge.
    • The first attempt's agent_start, turn_start, turn_end, and agent_end are suppressed; the reused bubble id and one terminal lifecycle are visible to the UI. The nudge is removed after the run.
    • A continuation tool call proceeds through the ordinary autonomous loop; it does not create a duplicate assistant bubble or lifecycle.
    • A normal final report, including one recovered after silent-turn recovery, does not cause an unnecessary progress continuation. Ordinary Agent text answers remain unchanged.
  • Specs linked: 03-runtime/02-agent-runtime.md §5e/§5e.1/§7, 03-runtime/08-error-codes.md §3.2
  • Acceptance: C (chat & stream), F (persistence), H (diagnostics), Quality
  • Milestone: M5
  • Status: Unit-covered (runtime.test.ts, progress-turn.test.ts); full provider/UI journey Draft

E2E-147: Scoped search stays inside its budget and the agent narrates ​

  • Preconditions: A project-bound Agent session; the workspace contains a multi-megabyte source file, a minified bundle with a .map sibling (one line, megabytes long), a binary file, and a dependency tree excluded by .gitignore.
  • Steps:
    1. Inspect tools.list for Read, Glob, and Grep.
    2. Read the multi-megabyte file, then read it again from the reported next offset.
    3. Grep a token that hits the minified bundle and its .map.
    4. Grep the same token with path pointing into the ignored dependency tree, then with include narrowing to one extension, then with outputMode: filesWithMatches and count.
    5. Glob a broad pattern, and Glob with path and limit.
    6. Read the binary file.
    7. Run a shell command that prints far past the shell budget on stdout, then one that fails after printing progress noise on stderr, and open the spill file named in each marker.
    8. Ask a question that needs several tool batches, and watch the transcript between batches.
  • Expected:
    • Every description carries its parameters and the real limit numbers.
    • No single tool result exceeds its budget: 128 KB for Read/Glob/Grep, 96 KB for Bash. Read reports offset, lineCount, fileBytes, and a next-offset notice; the second read continues without overlap; totalLines is always reported from the first read so the model knows the file scale upfront. A filled default or requested window reports truncated: false even when the file continues.
    • No file size is ever refused. Lines from the bundle and the .map arrive clipped at 16,384 chars and the clip count appears in notice, so one line cannot consume the result.
    • An explicit path reaches into the ignored tree; without it the same search returns nothing from there. include, outputMode, and headLimit each shrink the payload, and results order newest-modified first. When rg is on PATH, Grep uses it and still matches that contract; when it is missing or exits 2, Grep falls back in-process (D315).
    • The binary read fails with TOOL_BINARY_CONTENT and no binary reaches the model; Grep skips it silently.
    • Bash stdout keeps its head, stderr keeps its tail, both markers name which end survived and the spill path, and each spill file opens with the fuller output.
    • The agent answers in the language the user wrote in, precedes each tool batch with a sentence about what it is doing, never leaves more than one batch without new visible text, and ends with a self-contained result.
  • Specs linked: 03-runtime/16-tool-result-limits.md, 03-runtime/02-agent-runtime.md §7, 08-meta/decisions-log.md (D194, D306, D315)
  • Acceptance: C (chat & stream), E (tools & permissions), Quality
  • Milestone: M5
  • Status: Unit-covered (host-core tools tests, runtime.test.ts prompt assertions); full provider/UI journey Draft

E2E-100: A pasted MCP server runs, and only where it is scoped ​

  • Preconditions: Two projects on disk, ~/work/api and ~/personal/site. A local stdio MCP server available on PATH. An Agent session per project.
  • Steps:
    1. Extensions → MCP → Import from JSON. Paste a mcpServers document holding three servers: one valid stdio entry, one remote HTTP entry at a trusted LAN address such as http://192.168.1.20:8080/mcp with no type, and one stdio entry with no command.
    2. Confirm the import, then open the imported stdio server and press Test connection.
    3. Leave the server at Everywhere and ask the agent in each project to list its available tools. Have the stdio fixture report its working directory and process id; open a second session in ~/work/api as well.
    4. Set the server to These projects, with only ~/work/api picked.
    5. Ask again in each project.
    6. In the already-open ~/personal/site session — assembled while the server was global — ask the agent to call one of the server's tools by name.
    7. Edit the server's env, set a 45-second connection timeout, and save. Reopen it to confirm the value persisted, then clear the timeout and save. Ask in ~/work/api again.
    8. Rename the server and re-scope it; ask once more.
    9. Point the server's command at a binary that does not exist, save, and open a new session.
    10. Restore the valid command and Test connection. Activate a tool through ToolSearch, then terminate the stub server process between calls. Call the same tool in the existing session without searching again.
    11. Repeat with the restarted stub omitting that tool, and with the server disabled or scoped away before recovery. Also try concurrent calls after a disconnect and a server that fails its recovery handshake.
    12. Connect the HTTP server, stop it, and refresh the MCP settings page. Test connection again after restarting the server.
  • Expected:
    • The activated tool works after a transport restart without a second search; concurrent calls share one handshake. The fresh server list must still advertise the tool. Removed tools and inactive servers are refused without executing a call; inactive servers are not reconnected. Recovery failure returns UNAVAILABLE and does not trigger repeated handshake attempts until an edit or Test connection. A failed tool execution is never replayed.
    • Two servers import; the third is listed as skipped with "a stdio server requires command". The LAN HTTP entry lands as http with its url intact, and the editor shows the unencrypted-connection warning.
    • Test reports connected with the tool names it found, and the row's glyph turns from connecting to ready.
    • While global, both sessions see mcp_<serverId>_<tool> names.
    • The stdio fixture runs from each session's project directory. Sessions in ~/work/api share one process, while ~/personal/site uses a separate process. A projectless session uses the user's home directory.
    • After narrowing, only the ~/work/api session sees them; the summary chip reads "1 project" and names it.
    • The stale call from step 6 fails with TOOL_NOT_FOUND and "not active for this session" — scope holds at dispatch, not only in the catalog.
    • The env edit drops the connection: the next assembly or call re-handshakes, and the tool's behaviour reflects the new value. The rename in step 8 does not reconnect anything.
    • The editor restores a saved timeout when reopened. Clearing it removes the override so the next connection uses the default handshake timeout.
    • The broken command records failed with a message, contributes no tools, and is not re-dialled on the following session assembly; pressing Test retries it.
    • A stopped HTTP server loses its ready status on settings refresh and reports failed; Test connection restores ready after it restarts.
  • Specs linked: 07-plugins/01-plugin-system.md §12, 03-runtime/01-ipc-protocol.md §12a, 08-meta/decisions-log.md (D192, D193)
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/user-mcp.test.mjs, packages/shared/src/mcp-import.test.ts, host-core mcp_servers tests); full UI journey Draft

E2E-100B: Remote HTTP MCP server OAuth 2.1 authorization and token lifecycle ​

  • Preconditions: An HTTP MCP server endpoint configured requiring OAuth 2.1 authentication (RFC 9728 discovery and PKCE S256).
  • Steps:
    1. Open Settings > Agent > MCP. Add an HTTP MCP server URL.
    2. The server status displays Authorization required.
    3. Click Authorize. Main spins up loopback on 127.0.0.1, launches external browser to the authorization endpoint with RFC 8707 resource.
    4. Complete login in browser, redirecting to http://127.0.0.1:<port>/callback.
    5. The loopback callback validates state/code, completes token exchange with PKCE verifier, saves token to encrypted secret secret:mcp:<id>:oauth, renders escaped success page, and emits done event.
    6. Settings UI updates status to Ready with discovered tools, shows localized success toast, and displays OAuth badge.
    7. When access token expires, UserMcpRuntime transparently uses refresh token to obtain a fresh access token without user prompt.
    8. Moving the server via mcp.transfer preserves and re-keys the OAuth token secret under the destination ID.
  • No-DCR case: When authorization-server metadata omits registration_endpoint and there is no reusable client, Authorize does not open a browser. The error event contains MCP_OAUTH_DCR_UNAVAILABLE and Settings shows localized recovery guidance to request a client ID from the server operator or use a DCR-capable server.
  • Specs linked: 03-runtime/01-ipc-protocol.md, ADR 0283, ADR 0142
  • Acceptance: E (tools & permissions), Security
  • Milestone: M5
  • Discovery regression (#1221): Local HTTP authorization flow covers root and path issuers, ordered OAuth/OIDC fallbacks, challenge-scope precedence, all protected-resource scopes, and omitted scope when absent. It checks the real callback, PKCE token exchange and storage handoff using synthetic tokens; no external account is contacted.
  • Status: Unit-covered (apps/desktop/test/mcp-oauth.test.mjs, apps/desktop/test/user-mcp.test.mjs); local HTTP flow covered by apps/desktop/test/mcp-oauth-discovery.test.mjs; full UI journey Draft

E2E-261: Clear a per-server MCP timeout override ​

  • Preconditions: Isolated Electron profile and a saved MCP server with a 45-second timeout override; no live server or provider.
  • Steps: Open the production MCP editor, enter an out-of-range value and confirm Save is disabled, then clear the timeout field and save.
  • Expected: An invalid timeout cannot be saved. Clearing an existing override is valid and serializes as timeoutSeconds: null, allowing host-core to remove the override and use the default.
  • Acceptance: node scripts/e2e-mcp-timeout-clear.mjs exercises the production editor and save serialization. apps/desktop/test/mcp-editor-timeout.test.mjs covers value conversion; host-core MCP tests cover null clearing and default behavior. No live MCP server is contacted.
  • Milestone: M6+

E2E-SKILL-import-multiple-folders ​

  • Preconditions: An isolated Desktop profile and three sibling folders: alpha/SKILL.md and beta/SKILL.md are valid; broken/ has no SKILL.md. A project is selected in Settings > Agent > Skills.
  • Steps: Choose Import folder for that project, select all three folders, and confirm. Check the project list and the result message. Reopen Import folder and confirm it starts at the parent containing alpha/, beta/, and broken/. Cancel, reopen it, and confirm the parent is unchanged. Import a single Markdown file through Import file.
  • Expected: Both valid skills appear at project level with their sibling resources, the invalid folder is reported without blocking the others, and the picker remembers their shared parent across cancellation. The single-file action still selects only one file.
  • Coverage: Folder import service, preference, and settings source contract tests. Native multi-folder picker and remembered location remain desktop E2E.

E2E-101: A user skill is written once and scoped per project ​

  • Preconditions: Two projects on disk. An Agent session in each.
  • Steps:
    1. Extensions → Skills → New. Save with an empty description.
    2. Fill in a description, write a body, and save.
    3. Ask the agent in each project to use the skill by name.
    4. Set the skill to These projects with only the first project picked, then switch it Off and back to These projects.
    5. Ask again in each project.
    6. In the first project's session, narrow the skill to the second project and immediately ask the agent to invoke it.
    7. Paste a body over 128 KB.
    8. Switch the app language to 中文 and revisit every surface above.
  • Expected:
    • Saving without a description is refused with a message naming the field: the description is the only part that enters the prompt.
    • The base prompt carries the skill's id, name and trimmed description and not its body; the body arrives only through the Skill tool.
    • Toggling Off and back restores the picked project without re-picking it.
    • After narrowing, only the scoped project's session can invoke it; the other gets "not enabled for this project".
    • Step 6 fails in the already-open session too — the scope is re-read when the body is loaded, not trusted from the catalog that listed it.
    • The byte counter warns before the 128 KB cap and the save is refused past it.
    • Every label, empty state, error and count renders in Chinese, with counts reading naturally at 0, 1 and many.
  • Specs linked: 07-plugins/01-plugin-system.md §12.3, 03-runtime/01-ipc-protocol.md §12b, 08-meta/decisions-log.md (D174, D192, D194)
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5
  • Status: Unit-covered (host-core user_skills tests, apps/desktop/test/extensions-page.test.mjs); full UI journey Draft

E2E-102: Composer file and image paste keeps structured attachment metadata ​

  • Preconditions: The app is running with an Agent session in a project and a home composer available. The OS clipboard contains a text snippet no longer than the configured large-paste threshold, one or more local files (including a filename with whitespace), and an image in separate paste attempts. Record the app data directory and the session id.
  • Steps:
    1. Paste text-only within the configured threshold and confirm editable text. Copy a Word selection with text plus a generated image representation; confirm text wins, including multiline/CRLF, blank/trailing lines, literal <>& and quotes, replacing selections across line breaks/attachment chips, caret position, and native undo/redo. Repeat above the threshold and confirm a TXT chip rather than an image. Paste a real native image file with accompanying filename text and confirm it remains an image attachment.
    2. Paste one local file, then paste multiple files including a spaced name.
    3. Paste an image from the OS screenshot/clipboard provider.
    4. Inspect the draft before sending: confirm each materialized item is a removable leaf-name chip and no scratch absolute path occupies the textarea. Hover/focus chips to inspect their full paths, remove one, then send the prompt and inspect the session message's attachment metadata. Before sending, verify each image is a compact inline chip in the draft and that hovering it reveals its preview card. Replacing all text removes the inline image chips while undo restores them; image-only drafts must survive session switching and retain submission metadata. Mount the complete Composer: click Send for image-only input, verify attachment delivery and successful clearing, and immediately reject a text-plus-image send to verify full draft restoration. In a narrow pane, prefill 20 images, confirm 20 inline chips inside the composer, and remove one without losing the others. Send a prompt that puts text, an image, then more text in that order: confirm the sent message renders the image chip at that position instead of after the body, and that an image the draft did not name inline still follows the text. The provider-facing prompt keeps the same order; the runtime placement tests assert those content blocks. Inspect the chip and open it with click, Enter, and Space. Confirm a centered modal preview opens, the work panel stays unchanged, and the draft is neither edited nor sent. Check small images stay at natural size, wide images fit without distortion, zoom/reset and download target the selected original, and multiple images support button and arrow-key navigation with zoom reset. Drag at fit size and after zoom, including releasing outside the image; check cancellation and ordinary wheel panning. Fit and image navigation must reset position; dragging must not dismiss the preview or leave capture active after close/session changes. Close with Escape, the close button, and blank space; verify focus/caret restoration and modal focus containment. Native plugin surfaces must hide while the preview is open. Repeat without a project. Remove an attachment; it must neither preview nor send. Switch session/project or remove the selected attachment during a delayed read; the old preview must close and late results must not overwrite a newer image. Missing/undecodable images show retry and keep the draft. Text chips must still expand as editable text.
    5. Inspect <data_dir>/scratch/<sessionId>/pasted/ and compare the saved bytes with the source files/image. Check the project git status.
    6. Delete the session, then confirm its scratch directory and pasted files are removed.
  • Expected:
    • Text-only paste within the configured threshold remains native and is not routed through the file bridge. Oversized text behavior is covered by E2E-102g.
    • Each file/image is saved with a sanitized, UUID-backed unique name under the session scratch root, while its chip shows only the sanitized original leaf name. Duplicate leaf names remain separate references.
    • The dispatched prompt carries ordinary files through the existing path reference flow and carries pasted files/images as structured attachments; the persisted user message contains refs and metadata, never binary bytes. The agent can use its normal file tools to read the materialized files.
    • A home paste creates or reuses a durable session before writing. The workspace remains clean and no workspace artifact row is created.
    • Image preview preserves the draft and attachment metadata, uses fs/readImageDataUrl with bounded reads and path containment, and works without a workspace for allowed scratch files. Preview preserves the draft attachment format and leaves work-panel tabs unchanged. Closing/unmounting releases modal/native-view blocking.
    • Deleting the session removes the pasted files with the rest of scratch.
    • Deleting text immediately before an inline file chip must not insert a blank line or move the chip to the next line; native undo/redo stay intact.
  • Specs linked: 04-ux/08-component-spec.md §11.7–11.8, 03-runtime/01-ipc-protocol.md §13c, 03-runtime/03-tools-and-permissions.md §4b, 03-runtime/04-data-storage.md, 08-meta/decisions-log.md (D197, D209, D243, D392), ADR 0059, ADR 0070, ADR 0101, ADR 0218
  • Acceptance: C (conversation & stream), E (tools & permissions), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (composer-paste-files.test.mjs, composer-clipboard.test.mjs, composer-native-deletion.test.mjs); prompt-inline-attachments.test.mjs covers where an inline image's @path is recorded and that a replayed fallback copy still travels, session-message-presentation.test.mjs renders the row order, and packages/agent-runtime/src/runtime.test.ts asserts the prompt content blocks. pnpm test:e2e:composer-paste mounts the real ComposerInput, draft/paste hooks, file viewer, production CSS and sandboxed preload. It dispatches Chromium ClipboardEvents with synthetic mixed data and native File objects, exercises the real scratch writer and contained file reader, compares saved bytes and decodes the preview image. Requires a desktop build, installed Electron and a graphical session (Xvfb on Linux). It does not modify the OS clipboard or automate Word; Word/platform journeys and full provider dispatch remain manual. Run against the refreshed request candidate and record its revision; PR integration validation follows the authoritative AGENTS.md workflow without merging into local main first.

E2E-102h: Composer picker imports files into session scratch ​

  • Preconditions: The app is running with a home or Agent composer and a durable session. The native picker can select a text file and an image outside the active workspace.
  • Steps: 1) Click the Composer + button twice in quick succession; confirm that only one native file picker opens, directly without an intermediate type-choice menu. 2) Select both fixtures. 3) Inspect the draft chips and send a prompt asking the agent to read the text fixture and identify the image marker. 4) Inspect the renderer request, session transcript, and the session scratch directory.
  • Expected: While the native picker or its renderer import is in flight, repeated + clicks are ignored. While the native picker is open, the main process rejects duplicate picker requests instead of opening another dialog. The native picker returns a short-lived one-shot token, never a source absolute path, and the selections are copied into <data_dir>/scratch/<sessionId>/pasted/ before they enter the draft. The app classifies each selected item from its MIME/extension metadata, so the same picker handles both regular files and images. Chips show sanitized leaf names while prompt attachments reference only the copied paths; the workspace is unchanged. The agent can call Read on the text fixture, and a vision-capable model receives the image as an image block. Durable messages retain metadata and refs only, never the source absolute path or binary bytes. The file picker does not offer directory selection; missing files, expired/replayed tokens, and oversized files return a visible IPC error and write nothing.
  • Specs linked: 03-runtime/01-ipc-protocol.md §13c, 03-runtime/04-data-storage.md, 04-ux/08-component-spec.md §11.7–11.8, ADR 0059, ADR 0101, ADR 0218
  • Acceptance: B (model config), C (conversation & stream), E (tools & permissions), F (persistence), Security, Quality
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/composer-paste-files.test.mjs); provider/UI journey Draft (run only in a capable environment when this surface changes)

E2E-102i: Composer accepts native file and folder drops ​

  • Preconditions: The app is running with an Agent session and a visible Composer. The OS file manager exposes one regular file, one folder, and a mixed multi-selection; place the caret in the middle of a non-empty draft.
  • Steps: 1) Drag the regular file over the Composer and observe the target outline, then drop it. 2) Drag the folder into the same draft. 3) Repeat with a mixed file/folder selection while the caret is between existing text. 4) Inspect the draft, remove the file chip, and send the prompt. 5) Inspect the saved scratch file and the persisted user message.
  • Expected: File-system drag-over prevents the browser default and marks the whole Composer shell without layout movement. Regular files are saved through the existing bounded session-scratch bridge and appear as removable leaf-name chips; folders are not read, copied, or traversed, and their full native paths appear at the caret as @<path>/ literal directory references. Mixed drops preserve OS order, retain surrounding text, and restore the caret after asynchronous file saving. Sending keeps the file's existing attachment metadata/path behavior and the folder path as prompt text; no workspace files are created.
  • Specs linked: 04-ux/08-component-spec.md §11.5–11.8, 04-ux/09-interaction-patterns.md §8.2/§8a.2, 03-runtime/01-ipc-protocol.md §13c, ADR 0059, ADR 0070, ADR 0222
  • Acceptance: C (conversation & stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/composer-drag-drop.test.mjs); full desktop gesture journey Draft (run only in a capable environment when this surface changes)

E2E-102a: Composer file reference results show leaf names with full relative paths ​

  • Preconditions: The app is running with an Agent session in a workspace containing nested files, duplicate leaf names in different directories, and a directory whose name contains whitespace.
  • Steps: 1) Type @ and filter to the nested and duplicate entries. 2) Inspect the visible rows, then hover for full-path tooltips and inspect their accessible names. 3) Accept a file with Enter and confirm a leaf-name chip remains in the draft at the caret while the @ token and full path stay hidden. Accept a second file with Tab or click. Accept a directory result and continue to a child file. 4) Send the completed references and inspect the persisted user message.
  • Expected:
    • Each result persistently renders its leaf name followed by the entry's complete workspace-relative path in a de-emphasized span; directories retain a trailing / on the leaf name, the span carries the raw entry.path, and it ellipsizes before the leaf name can while the row never overflows the menu.
    • The tooltip and accessible name retain the complete relative path so duplicate leaf names remain distinguishable.
    • In a Git workspace containing accented, CJK, and emoji file names, indexing and filtering retain each name exactly, including when Git's stdout chunks split a UTF-8 character. Accepting the result names the existing file, without replacement characters in its path.
    • Enter/Tab/click on a file replaces the @ token with an inline chip that stays in the draft; that key does not send. File acceptance retains the original complete entry.path behind the chip; directory acceptance retains literal path continuation. At dispatch the sent and persisted prompt contains each complete path with existing whitespace quoting, and the agent can read both selected files normally.
  • Specs linked: 04-ux/08-component-spec.md §11.8, 04-ux/09-interaction-patterns.md §8a, 03-runtime/01-ipc-protocol.md §13c, 08-meta/decisions-log.md (D124, D209, D362, D660), ADR 0024, ADR 0070
  • Acceptance: C (conversation & stream), Quality
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/composer-file-reference-display.test.mjs and packages/host-runtime/src/fs-index-encoding.test.ts); Unicode filtering and file acceptance run in composer-deep-file-search-user-path.test.mjs; file-mode row layout covered by scripts/e2e-composer-autocomplete.mjs; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-COMPOSER-deep-file-mentions: @ reaches deep files in a large workspace ​

  • Preconditions: The app is running with an Agent session in a workspace whose index is truncated (more than 8000 files and directories), with a script several folders below thousands of shallow assets — for example a Unity project's Assets/_Game/Scripts/Gameplay/Player/FeatureController.cs. Run once with the workspace under git and once without.
  • Steps: 1) Type @ and inspect the footnote. 2) Type the script's name and pause. 3) Accept the script's row with Enter. 4) Edit the query quickly through several prefixes. 5) Repeat in a small workspace and in a remote session.
  • Expected:
    • The bare @ lists the index and shows the truncation footnote.
    • After the pause the deep script appears as a row, ranked like any other match, and the footnote disappears once that search has answered; files excluded by .gitignore or the ignore set never appear.
    • Accepting it inserts the same file chip as any other reference.
    • Rows never belong to an earlier query. The menu keeps the local matches while a search is pending.
    • A small workspace issues no search request; a remote session keeps its snapshot and footnote.
  • Specs linked: 04-ux/08-component-spec.md §11.8, 03-runtime/01-ipc-protocol.md §13c, 08-meta/decisions-log.md (D124, D669), ADR 0024
  • Acceptance: C (conversation & stream), Quality
  • Milestone: M5
  • Status: Unit-covered (packages/host-runtime/src/fs-index.test.ts covers the git and walk listings, ignore rules, directories, and the result limit); the renderer journey — search after the pause, merge, accept, stale answers, the footnote, and the complete-index path — is covered by apps/desktop/test/composer-deep-file-search-user-path.test.mjs in a real Electron renderer. The full desktop journey against a large real tree is Draft (run only in a capable environment when this surface changes).

E2E-102b: Unanswered Stop restores compact file-reference drafts ​

  • Preconditions: An Agent session can delay its first assistant event. The draft contains ordinary text, one workspace reference, two pasted references with duplicate leaf names, and a canonical path containing whitespace.
  • Steps: 1) Send the mixed draft and stop before assistant text, thinking, or any tool row begins. 2) Inspect the restored composer and transcript. 3) Send the restored draft again and inspect the persisted user message. 4) Repeat, allow partial assistant output to begin, then stop.
  • Expected:
    • Unanswered Stop removes the just-sent user row and restores the original ordinary text plus leaf-name chips in stable order.
    • Relative and scratch absolute paths never appear in the restored textarea; duplicate labels remain distinct references.
    • Resending serializes each exact canonical path once with existing whitespace quoting.
    • Stop after reply start preserves the partial aborted transcript and does not restore or duplicate text or chips.
    • Scratch bytes remain under the existing session lifecycle.
  • Specs linked: 04-ux/08-component-spec.md §11.5/§11.8, 04-ux/09-interaction-patterns.md §3.2/§8a.2, 03-runtime/10-session-state-machine.md, 08-meta/decisions-log.md (D209), ADR 0070
  • Acceptance: C (conversation & stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (composer-file-reference-display.test.mjs, transcript-style.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-102c: Vision-capable models receive pasted images as image input ​

  • Preconditions: A deterministic vision-capable models.dev model whose resolved record contains modalities.input: ["text", "image"]; an Agent session; one pasted PNG and one text prompt. Capture the renderer request, main-to-sidecar payload, provider request, durable transcript, and <data_dir>/attachments/.
  • Steps:
    1. Select the vision-capable model and paste the PNG into Composer.
    2. Confirm the PNG appears as a removable image chip above the textarea and no separate explanatory vision-status row is rendered.
    3. Send a prompt asking the model to identify one visible detail.
    4. Inspect the provider request and durable session message after completion.
    5. Reload the session and ask a follow-up about the same image.
  • Expected:
    • The model picker/session capability state comes from the exact models.dev record, and Composer shows the image as a removable chip without a separate explanatory status row. If a remote refresh fails, the bundled release snapshot remains authoritative for the process.
    • Main writes one content-addressed image blob and sends the sidecar a transient image attachment; the provider adapter emits an image content block/data URL, not only @<scratch-path> text.
    • The durable message contains kind, display name, MIME/size, and the attachments/<sha256> ref, but no base64 or image bytes.
    • After reload, history hydration restores the image block from the bounded attachment root and the follow-up still has image context.
  • Specs linked: 03-runtime/01-ipc-protocol.md §5.1/§13c, 03-runtime/02-agent-runtime.md §5c, 03-runtime/04-data-storage.md, 03-runtime/13-model-catalog-and-selection.md §11.2, 04-ux/08-component-spec.md §11.7–11.8, 08-meta/decisions-log.md (D243), ADR 0101
  • Acceptance: B (model config), C (conversation & stream), F (persistence), Quality, Security
  • Milestone: M5
  • Status: Unit-covered (model-capabilities.test.ts, host-core attachment roundtrip); provider/UI journey Draft (run only in a capable environment when this surface changes)

E2E-102j: Historical vision replay uses a bounded aggregate byte budget ​

  • Preconditions: A vision-capable model, a session with no compaction boundary across the fixture turns, six distinct user image turns whose combined raw image size is below 30 MB, and a later text-only user turn. Capture the provider request during runtime recreation and edit/resend.
  • Steps:
    1. Send the six image turns and then a text-only turn.
    2. Reload/recreate the runtime and edit/resend the text-only turn.
    3. Inspect the provider request and the durable transcript.
  • Expected:
    • All six historical images are replayed as image blocks; being older than five image messages does not drop visual context while the aggregate stays within budget.
    • The current text row is removed before history hydration and is not double-counted against the restored-history budget.
    • Durable turns retain only attachment refs/metadata, never base64. A unit case with a deliberately small budget verifies that many images in one message are capped and newest attachments win.
  • Specs linked: 03-runtime/01-ipc-protocol.md §5.1, 03-runtime/02-agent-runtime.md §5c, 03-runtime/04-data-storage.md, ADR 0101
  • Acceptance: C (conversation & stream), F (persistence), Quality
  • Milestone: M5
  • Status: Documented; provider/UI journey Draft (run only in a capable environment when this surface changes)

E2E-ATTACHMENTS-svg-file-fallback: SVG inputs are sent as files, not model images ​

  • Preconditions: A vision-capable model; an Agent session; one SVG file (clipboard paste or native file picker) and one PNG.
  • Steps:
    1. Paste or attach an SVG file into Composer alongside a PNG.
    2. Send a prompt asking the model about both attachments.
    3. Inspect the provider request body.
    4. Reload the session and inspect restored attachments.
  • Expected:
    • The SVG appears as a file chip, not an image chip. It is classified as kind: "file" with .svg extension (even if the original had none).
    • The PNG still appears as an image chip and is sent as a model image block.
    • The provider request contains no inlineData for the SVG; it uses a safe @path file reference.
    • Legacy content-addressed SVG attachments from older sessions are restored as files, not images; stale temporary image data is cleared.
    • Non-SVG image attachments and vision capability detection are unaffected.
  • Specs linked: 03-runtime/svg-attachment-input.md, 03-runtime/01-ipc-protocol.md §5.1, 04-ux/08-component-spec.md §11.7
  • Acceptance: C (conversation & stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (svg-attachments.test.mjs); Desktop E2E Draft

E2E-102d: Non-vision and oversized images use the path fallback ​

  • Preconditions: One known non-vision model and one known vision-capable model; an Agent session; a normal PNG and a deterministic image just above the 10 MB inline bound.
  • Steps:
    1. Select the non-vision model, paste the normal PNG, and inspect Composer's removable image chip.
    2. Send the prompt and inspect the sidecar/provider request.
    3. Select the vision model, paste the oversized image, and send it.
    4. Retry each turn after disposing/recreating the runtime.
  • Expected:
    • Composer shows the pasted image as a removable chip without a separate vision-status explanation. Both cases show a safe @path fallback and no image block/base64 payload.
    • The non-vision request references the session scratch file; the oversized vision request references a safe path while the image remains available to the normal file tools.
    • Retries and runtime recreation use the content-addressed image ref and a session replayed/ path where needed; no duplicate binary blobs are made.
    • The transcript still stores attachment metadata/ref and the UI does not claim that the image was sent visually.
  • Specs linked: 03-runtime/01-ipc-protocol.md §5.1, 03-runtime/03-tools-and-permissions.md §4b, 03-runtime/02-agent-runtime.md §5c, 03-runtime/04-data-storage.md, ADR 0101
  • Acceptance: B (model config), C (conversation & stream), E (tools & permissions), F (persistence), Security
  • Milestone: M5
  • Status: Draft (run only in a capable environment when this surface changes)

E2E-102e: Unknown model ids fail closed for vision transport ​

  • Preconditions: A custom provider/model id absent from the models.dev catalog, discovery data that incorrectly labels it vision, no explicit supportsImages binding override, and a pasted PNG.
  • Steps:
    1. Refresh the provider model list and select the discovered custom id.
    2. Paste the PNG and inspect the model picker and Composer image chip.
    3. Send the prompt and inspect the sidecar/provider payload.
  • Expected:
    • The unknown model is runnable as a generic text model but is not promoted to vision by discovery/cache metadata.
    • Composer keeps the image chip without rendering a model-dependent status message, and the provider receives no image block or base64 value; the safe file path remains available.
    • The durable attachment ref is still recorded so a later known vision model can replay the image correctly.
  • Specs linked: 03-runtime/11-provider-model-system.md §6.2/§11, 03-runtime/13-model-catalog-and-selection.md §11.2, 04-ux/08-component-spec.md §11.8, ADR 0101, ADR 0218
  • Acceptance: B (model config), C (conversation & stream), E (tools & permissions), Security
  • Milestone: M5
  • Status: Unit-covered (model-capabilities.test.ts); provider/UI journey Draft (run only in a capable environment when this surface changes)

E2E-102f: Oversized image references do not block session startup ​

  • Preconditions: A project contains an image larger than the 10 MB inline bound; a known vision-capable model is selected; the session has a valid provider and workspace.
  • Steps: 1. Reference the oversized image from the Composer and send a prompt. 2. Observe the turn lifecycle while the attachment is prepared. 3. Dispose/recreate the runtime and send a follow-up about the image. 4. Inspect the session transcript, the content-addressed attachment blob, and the session replayed/ path.
  • Expected: The session accepts the prompt and reaches the provider without hanging the Electron main process or crashing the sidecar. Main hashes and copies the image without constructing a whole-file in-memory buffer. The provider receives the safe @path fallback rather than an oversized image block/base64 payload. Runtime recreation copies the stored blob into the scratch fallback path without reading the whole blob into memory. The durable message retains only attachment metadata and the content-addressed ref.
  • Specs linked: 03-runtime/01-ipc-protocol.md §5.1, 03-runtime/02-agent-runtime.md §5, 03-runtime/04-data-storage.md, ADR 0101
  • Acceptance: C (conversation & stream), E (tools & permissions), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-102g: Large text paste becomes an inline session-scratch reference ​

  • Preconditions: The app is running with an Agent session in a project and a home composer available. The large paste threshold is first left at its default, then changed to a small test value. Record the app data directory, session id, and a multiline Unicode text fixture.
  • Steps:
    1. Open Settings → AI → Defaults and confirm the large paste threshold is 600. Set it to a deterministic lower value, save it, then return to the composer.
    2. Paste text exactly at the threshold and confirm native textarea behavior; paste text one character above it at the beginning, middle, and end of drafts, including multiline and Unicode content.
    3. Inspect the draft after each oversized paste: confirm the exact prefix and suffix remain, a generated pasted-text-*.txt chip appears at the original selection, the editor does not contain the scratch absolute path, and the composer reports its busy/error state correctly during the transfer.
    4. Click the generated TXT chip and repeat with keyboard focus plus Enter and Space. Confirm the exact UTF-8 contents replace the chip at its position, the text is editable, the caret lands after it, and subsequent send uses the edited text. While a read is pending, switch drafts or remove the chip and confirm a stale response does not change the current draft.
    5. Inspect the session scratch/<sessionId>/pasted/ file bytes, send a mixed draft that still contains a chip, and inspect the renderer request, persisted user message, and agent-readable path. Switch projects and sessions before sending a cached draft, then remove the chip and confirm it is no longer dispatched.
    6. Delete the owning session and confirm its temporary paste files are removed.
  • Expected:
    • The threshold is persisted as an AI default, defaults to 600 on older settings, and accepts only integer values from 1 through 1,000,000.
    • Text at or below the threshold remains native. Text above it is saved byte-for-byte as UTF-8 text/plain under the owning session's scratch pasted/ directory, without changing the project or creating an artifact.
    • The sentinel-backed TXT chip is inserted at the exact paste selection, including in the middle of a multiline draft. Clicking or pressing Enter / Space expands it to editable exact text and removes its reference; edits made afterward are what dispatch sends. A failed, binary, image, or oversized read leaves the chip and mapping intact.
    • Any remaining chip is resolved to its canonical path in place exactly once; it is neither appended as a basename nor duplicated as an attachment. Removing the chip removes that mapping.
    • Session switching, project switching, unanswered Stop restoration, and session deletion respect the existing session ownership and cleanup rules.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/07-ui-design-system.md §8.1, 04-ux/08-component-spec.md §11.7–11.8, 04-ux/09-interaction-patterns.md §8a, 03-runtime/01-ipc-protocol.md §8, 03-runtime/04-data-storage.md §7, 08-meta/decisions-log.md (D262), ADR 0059, ADR 0070, ADR 0131
  • Acceptance: C (conversation & stream), E (tools & permissions), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (composer-trigger.test.ts, apps/desktop/test/composer-paste-files.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-103: Settings Agent pages manage file-backed capabilities ​

  • Preconditions: The app is running with two registered projects, A and B, and an Agent session available in each. The fixtures use only ~/.agents/skills, ~/.agents/servers, ~/.agents/subagents, and the two projects' .agents directories; no .pi capability directory exists.
  • Steps:
    1. Open Settings > Agent and verify Skills, MCP, and Subagents are three independent navigation destinations. Open Extensions and verify that only Installed and Marketplace tabs are present.
    2. Open Skills. Confirm one toolbar sits above one panel, the panel shows a global group header rooted at ~/.agents/skills and a project group header rooted at project A's .agents/skills, both flow in one column at natural page height, and the project picker changes the selected project. Confirm the Skills toolbar has the same single primary action shape as MCP, with the concise New label; the level-specific Import action is available in each Skills group header. On first paint confirm skeleton rows appear; on a later refresh confirm the rows already on screen stay and the list dims instead.
    3. Exercise the level filter and the search field. Confirm All / Global / Project carry counts that agree with the rendered rows, that selecting a level hides the other group without hiding the toolbar or its actions, that search narrows both groups and its counts, that clearing the search restores every row, and that a search with no matches reports it and suggests widening the filter.
    4. Toggle a global skill off while project A is selected. Confirm the switch flips immediately without the list reverting to skeletons, the row is dimmed, a toast names the skill, every other row stays interactive while the request is in flight, and the global document remains unchanged. Switch to project B and confirm the global skill remains enabled there. Force a host rejection and confirm the switch returns to its previous position.
    5. Create a skill from the page. With the filter on Global confirm the primary action names the global destination and the new document lands in ~/.agents/skills; with the filter on Project confirm it names the project and lands in project A. With Project selected and no project chosen, confirm the attempt reports it instead of failing silently. Edit the new skill, save, and confirm the body round-trips.
    6. From a project skill's overflow menu choose Reveal and confirm the project file is revealed, not a global file of the same id. Choose Remove and confirm the first press only arms and relabels the item, the second press deletes, and dismissing the menu disarms it.
    7. Put a same-name project skill in A and disable it. Confirm the effective runtime catalog does not fall back to the global skill; the project record shadows first and filtering happens second.
    8. Use the Import action to choose exactly one Markdown file. Confirm it is physically copied to the level the filter points at, appears immediately, and a second file cannot be selected in the same picker invocation.
    9. Open MCP. Add a project server and a global server, edit the project server, and test an existing connection from the row's overflow menu. Confirm the modal locks the id while editing, rejects a same-level duplicate id or label, and reports ready or failed through the row badge and a toast. Delete a server through the same two-press menu action and confirm its file is gone.
    10. Delete a skill or MCP file outside the app, reload its page, and confirm the row disappears and its local state has no orphaned entry. Confirm deleting a global file also removes its project overrides.
    11. Open Subagents. Confirm it is one global-only panel rooted at ~/.agents/subagents, with no level filter, no project picker, and no project-level controls. Confirm a Built-in group lists the five shipped defaults (explorer, code-reviewer, test-runner, fixer, ui-designer) even when the user directory is empty, each with a Built-in badge, its tool grant, and an enablement switch, but no reveal or delete. Confirm the Global group header carries the global level label and item count, that create/edit/delete/reveal all work from the page for user-owned rows, that leaving the output limit empty writes a definition with no maxTokens, and that an empty user directory still resolves settings.subagentsEmpty to localized empty-state copy under the Global group rather than displaying a raw translation key. Open New subagent and confirm the Model field is a select of the same configured, runnable models as the Composer, grouped by provider, with an inherit-session option, not a free-typed provider/model input. Pin a configured model, save, and confirm the document's model: frontmatter is vendorKey-or-name/modelId. When two configured providers share a generic or vendor key, confirm each provider remains a separate group and its model pin uses a unique display name (or provider id when the names also collide). Edit a definition whose pin is no longer configured and confirm that pin remains selected instead of snapping to inherit.
    12. Narrow the window to the toolbar's stacking breakpoint. Confirm the segmented control spans the width, search moves below it, actions wrap left-aligned, group headers drop the resolved path, and the page gains no horizontal overflow. With a pointer that cannot hover, confirm the row's edit and overflow controls are visible without hovering.
  • Expected:
    • The three Settings pages use no tabs for switching capabilities, flow at natural page height for empty and populated states, support dark and light themes, and begin with quiet page-specific descriptions plus project-over-global scope copy where relevant. Each page is one toolbar above one panel: the toolbar carries the level filter with live counts, one search field with a clear affordance, the project picker, and the primary actions; the panel divides levels with group headers naming the level, its resolved .agents path, and a localized count. Rows use quiet capability icons, a level badge plus localized source/transport badges, descriptions, and persistent enablement switches; edit and the overflow menu stay quiet until the row is hovered, focused, or has its menu open, and are always visible where hover is unavailable. Skeleton rows appear on first paint only, later refreshes dim the rows already on screen and announce the refresh, busy state is confined to the row with the in-flight request, and counts are exposed to assistive technology. Empty states stay centered inside the panel without a decorative frame, show a host Lucide icon in a chip wrapper (not a padded raw SVG), and offer the page's primary action, and no capability-specific color system is introduced.
    • Create, edit, and delete are available for all three capabilities without leaving Settings. New capabilities land at the level the filter points at, destructive actions require two presses of the same relabelled menu item, and revealing a project-level skill opens that project's file rather than a global file sharing its id. The Subagents editor Model field is a grouped select of configured runnable models plus inherit-session, not a free-typed id; saving writes vendorKey-or-name/modelId (using a unique provider name or id when aliases collide), and an unconfigured existing pin remains selected. The Advanced disclosure also carries the delegate's own output limit: it starts empty, shows the model-default placeholder rather than an unlimited one, and a value round-trips through the document's maxTokens frontmatter and back into the field — while clearing it removes the key so the delegate follows the model again.
    • Capability files contain configuration/frontmatter only; enablement is persisted in the app-local agent-capabilities state files.
    • Project records shadow global records by id or name even when disabled, and the next runtime activation reflects the same result as the UI.
    • Physical import is single-file and level-specific, and disk deletion is removed by scanning rather than represented as a pending row.
  • Specs linked: 03-runtime/01-ipc-protocol.md §12a–§12d, 03-runtime/02-agent-runtime.md §5f, 03-runtime/13-model-catalog-and-selection.md §2 (Subagent editor), 04-ux/01-ui-ia.md §3.4–§3.5, 04-ux/06-settings-ia.md §2 (Agent capability destinations), §4.21–§4.25, 07-plugins/01-plugin-system.md §12.2–§12.3, 08-meta/decisions-log.md (D193, D194, D202, D257), ADR 0112, ADR 0126
  • Acceptance: D (workspace), E (tools & permissions), F (persistence), Quality
  • Milestone: M6+
  • Status: Source/unit covered by apps/desktop/test/agent-capability-settings.test.mjs, apps/desktop/test/extensions-page.test.mjs, apps/desktop/test/subagent-models.test.mjs, apps/desktop/test/subagent-output-limit.test.mjs (the output cap's path from the editor draft through host-core to the built delegate model), and host-core capability tests; full native-picker, rendered modal, project-switch, and runtime journey remain Draft (run only in a capable environment when this surface changes)

E2E-CAPABILITY-move-across-levels: MCP servers and skills move between the global and project .agents levels ​

  • Preconditions: The app runs against a disposable global .agents root (an isolated HOME, or PI_DESKTOP_AGENTS_DIR when the bare host is driven directly). Projects A and B are registered. The global root owns ~/.agents/servers/echo.json (id echo, label Echo), ~/.agents/skills/review.md (frontmatter name Review), and nothing else that uses the names used below. Project A owns no echo server and no review skill, and owns <project A>/.agents/skills/with-resources/ holding SKILL.md plus scripts/run.sh and templates/report.md. The MCP and Skills pages show the project picker described in the Settings IA spec.
  • Steps:
    1. Open Settings > Agent > MCP with project A selected. Disable the global echo row, then choose Move into A. Confirm the row leaves the global group, appears under project A, and stays disabled there.
    2. Confirm ~/.agents/servers/echo.json no longer exists and <project A>/.agents/servers/echo.json does, byte-identical. Read <data>/agent-capabilities/mcp.json and confirm it holds no global echo entry and no project-A override for the old id, and that project A's entry carries the disabled value.
    3. Create a new global echo server again (id echo, label Echo). With project A selected choose Move into A and confirm the arriving row is echo-2 with label Echo (2), that project A's original echo.json is byte-identical to before the move, and that both project rows list.
    4. Open Skills with project A selected. Create a project review.md whose frontmatter carries name: Review, a description, and an extra custom field, plus a body; disable it, then choose Move to Global. Confirm it appears globally as review-2, arrives disabled, and leaves the global review.md unchanged.
    5. Diff the moved document against the project original: only the frontmatter name line differs (name: Review (2)); the description, the custom field, blank lines, line endings, and every body byte are identical.
    6. Move project A's with-resources skill to Global. Confirm <project A>/.agents/skills/with-resources/ is gone and ~/.agents/skills/with-resources/ holds SKILL.md, scripts/run.sh, and templates/report.md with identical bytes, and that the Skill tool still resolves the document's resources.
    7. Reload the global and project pages. Confirm the moved rows keep the enablement they had across the reload, that project B lists none of the project-A capabilities, and that the moved global review-2 applies to project B with the value it had after the move.
    8. Clear the project selection in the toolbar. Confirm Move into <project> is no longer offered on the remaining global rows and the project group asks for a project selection instead; re-select project B and confirm the action returns.
    9. Drive mcp.transfer and skills.transfer with from and to naming the same directory and confirm the response returns the current record and neither directory changes.
  • Expected:
    • A move relocates the document: the destination directory gains the file, the source level stops listing the entry, and no copy is left behind.
    • Enablement follows the document. A global row disabled for project A arrives in project A disabled; a project row arrives globally with its visible value as the new global default. The source level keeps no state entry — including project overrides — for the old id.
    • A destination collision is resolved by renaming only the colliding name: an id collision gives the arriving id a -2/-3 suffix, a case-insensitive display-name / label collision gives the display name a (2)/ (3) suffix, and the existing entry is never overwritten or merged. Renaming a skill rewrites only the frontmatter name line and preserves every other byte.
    • A directory-shaped skill moves as one unit with its sibling resources, and a transfer whose two targets name the same directory is a no-op.
    • Without a selected project the move action is unavailable with an explicit explanation, and no capability file or state entry changes.
  • Specs linked: 03-runtime/06-host-rpc-protocol.md §4 (Agent capabilities), 03-runtime/01-ipc-protocol.md §12a–§12b, 04-ux/06-settings-ia.md §2 (Agent capability destinations), ADR 0112, ADR 0269
  • Acceptance: E (tools & permissions), F (persistence), Quality
  • Milestone: M6+
  • Status: Host-covered (pnpm test:e2e:capability-move drives the same RPC surface Electron main calls against a real host-core and an isolated global root, asserting the documents and the state file on disk; host-core registry/state tests cover the rename, the id the scan derives, and directory resources; the source contract is pinned by apps/desktop/test/agent-capability-settings.test.mjs). The rendered Settings journey — row menu, toggle, toast — remains Draft.

E2E-120: Global plugin launch, next-turn editing, and stopped throughput ​

  • Preconditions: Install and enable a panel plugin whose Chinese display name is 无限画布, plus a second panel plugin (for example 邮件助手). Configure a provider that streams slowly enough to stop a partial answer. Run once on macOS and once on Windows.
  • Steps:
    1. Immediately after Nib finishes booting, leave it unfocused and press Option+Space on macOS or Alt+Space on Windows while another application owns the foreground window. Confirm the first invocation promptly reveals a fully rendered, centered launcher on the pointer's display without a blank initialization frame, native close, minimize, maximize, resize, or taskbar controls. Confirm Windows does not show the active application's system menu.
    2. Search separately for 无限, wuxianhuabu, and wxhb. Use Up/Down and Enter for one run and click for another; confirm the existing plugin panel opens. Confirm Chinese IME candidate Enter does not open a result.
    3. Open the launcher again with an empty query and confirm the plugin opened in step 2 is the first result. Open the other panel plugin, then reopen the launcher and confirm the two plugins appear in reverse opening order. Restart the application and confirm the same most-recently-used order survives the restart.
    4. Start an Agent answer. While it streams, type the next draft and change Thinking, permission mode, and Agent/Plan/Goal. Confirm every selection is editable, Stop remains present, and Send cannot dispatch.
    5. Stop after partial output. Confirm the partial answer remains, the queued configuration becomes durable only after termination, and the next turn uses the final selection rather than any intermediate selection.
    6. Inspect the stopped answer's conversation statistics, reload the session, and inspect again.
  • Expected:
    • Early warm-up starts before backend boot completes and removes BrowserWindow and renderer loading from the first shortcut's visible path. On macOS the panel is activated once without a second app/window-stack hop, so the reveal does not visibly stutter. Launcher search returns only enabled, ready panel plugins and every invocation starts with an empty, focused query ordered by most-recently-used history across restarts; typing a query still ranks relevance first. Escape and focus loss hide it without closing the main application.
    • No running turn observes the staged mode/model/thinking/permission change, and a second prompt cannot be sent concurrently.
    • Changing Thinking during a stream keeps the selected model's enabled levels visible. The submenu never collapses to Off-only, including for unpinned sessions that inherit the app default model.
    • Stopped throughput is present before and after reload. It uses exact output usage when the provider supplied it; otherwise the UI labels the persisted four-code-point estimate as approximate.
    • Completed-response throughput uses pi-ai 1.1.0's monotonic request duration; stopped partial responses retain the sidecar duration fallback.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md §5b/§9, 04-ux/07-ui-design-system.md §8.2–8.3, 04-ux/08-component-spec.md §11, 04-ux/09-interaction-patterns.md §1/§3, D211, D212, D219, ADR 0072, ADR 0073
  • Acceptance: C (conversation & stream), F (persistence), G (plugins), Quality
  • Milestone: M6
  • Status: Unit/source-contract covered; full cross-platform UI journey Draft (run only in a capable environment when this surface changes)

E2E-121: Goal approval resumes autonomous acceptance-criteria execution ​

  • Preconditions: A project-bound session has a configured provider and is idle in Agent mode; the workspace permits host artifact creation and has no prior test goal artifact.
  • Steps: 1) Switch the session to Goal and let the Agent call EnterGoalMode, then SubmitGoal(title, markdown, question). 2) Inspect the exact Markdown bytes in the new .pi/goal/*.md artifact and the matching plan_approvals row. 3) Confirm the shared approval card exposes only Approve/Reject and that Goal denies Write/Edit/plugin tools while Bash follows the selected permission mode. 4) Approve with Ask and observe the same Agent resume in Agent mode. 5) Inspect the final response for criterion-by-criterion verification or an explicit boundary, then reload the session.
  • Expected: Goal uses the Plan approval pipeline without a second planner; the artifact is immutable and the row records kind = goal, path, hash, size, and execution state. Approval is a separate user decision, transitions the session to Agent, and starts autonomous work only after approval. The transcript remains reviewable after reload and no scheduled/unattended Goal run can bypass the approval boundary.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/03-tools-and-permissions.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/10-session-state-machine.md, 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, D198
  • Acceptance: C (conversation & stream), E (tools & permissions), F (persistence), H (diagnostics), Security
  • Milestone: M6+
  • Status: Unit/source-contract covered (packages/agent-runtime and host-core Goal tests); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-122: Plugins request and deliver native notifications ​

  • Preconditions: An installed plugin declares and is granted notify; the desktop platform supports Electron native notifications; the OS notification permission is in its initial or previously denied state.
  • Steps: 1) From the plugin process, call getNotificationPermission(). 2) Call requestNotificationPermission() and observe the native permission/probe result. 3) Call showNativeNotification({ title, body }). 4) Repeat with a plugin that lacks notify, and on a platform where native notifications are unsupported.
  • Expected: The first status is unknown, denied, or unsupported; the request returns a best-effort granted, denied, or unsupported result; a granted plugin receives { shown: true, permission: "granted" } for native delivery, while denied/unsupported delivery returns shown: false without crashing the plugin. Missing notify fails with PERMISSION_DENIED. Clicking a delivered native plugin notification restores and focuses the main window, but native plugin notifications do not add durable task inbox rows or activate a chat session.
  • Specs linked: 07-plugins/01-plugin-system.md, 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0074
  • Acceptance: E (tools & permissions), G (plugins), Security, Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered; full cross-platform OS permission journey Draft (run only in a capable environment when this surface changes)

E2E-148: Plugin settings and local shortcuts are editable ​

  • Preconditions: An enabled plugin declares string, boolean, JSON, and shortcut settings; the shortcut points to a declared plugin command.
  • Steps: 1) Open Plugins and open the plugin settings surface. 2) Change a normal field and the shortcut, then save. 3) Reload the plugin list and press the new shortcut while the app window is focused. 4) Try a reserved app shortcut and repeat with the plugin scoped away from the current project.
  • Expected: Controls are generated from the manifest, values persist in the plugin-private settings file, and plugin:settingsChanged is delivered. The new shortcut invokes the plugin command only in the focused app window and matching activation scope. Reserved/conflicting bindings are rejected; no OS global shortcut is registered.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md, 07-plugins/03-plugin-api.md, 07-plugins/11-plugin-storage-isolation.md, ADR 0159
  • Acceptance: F (persistence), G (plugins), Security, Quality
  • Milestone: M6+
  • Status: Source-contract and focused integration coverage; full desktop journey Draft (run only in a capable environment when this surface changes)

E2E-OAUTH-anthropic-rate-limit-retry: Bounded token retries preserve the account ​

  • Preconditions: A local HTTP fixture intercepts only Anthropic's token URL; the production pi-ai flow and Desktop VendorOAuth run with an in-memory Host RPC fixture. No real account, browser authorization or remote endpoint is used.
  • Steps: Run pnpm test:e2e:oauth-retry. Exercise exchange and refresh 429 → success, repeated 429, seconds/date/malformed/over-budget Retry-After, invalid grants (including after 429), 5xx, socket disconnect, invalid success JSON, cancellation during request/body/wait, and an earlier caller deadline. Resolve the same account concurrently and retry after a failed refresh.
  • Expected: At most three requests share one deadline and original signal; no retry precedes a server hint. Only explicit 429 is retried; grant rejection and ambiguous failures stop. Request grant fields and custom headers remain unchanged. Failed refresh retains the old credential and releases its lock; concurrent resolves rotate/write once. Failed/cancelled login removes only its newly created row. HTTP/token-JSON errors contain no token-body canary, URL or embedded stack and provide recovery guidance without claiming every 429 consumes code. Existing network-error diagnostics are unchanged.
  • Specs linked: 03-runtime/11-provider-model-system.md §8a; ADR 0095.
  • Acceptance: B (vendor accounts), Security, Quality.
  • Status: All 20 local HTTP scenarios passed against the installed patch; the existing 20 login/session regressions also passed. This is a pre-merge transport and orchestration integration test, not live OAuth, visual UI or Host persistence validation. Post-integration main E2E is NOT RUN.

E2E-OAUTH-anthropic-copy-code: Anthropic copy-code login uses the existing prompt bridge ​

  • Preconditions: A local token-endpoint fixture intercepts only Anthropic's OAuth token URL. The production pi-ai Anthropic flow and Desktop VendorOAuth use an in-memory Host RPC fixture. No real account or remote endpoint is used.
  • Steps: Start an Anthropic vendor login; choose copy_code from the select prompt; inspect the generated authorization URL; provide a synthetic code#state to the manual-code prompt; finish token exchange against the fixture; resolve request auth and inspect the stored Host credential.
  • Expected: The select options retain browser and copy_code; the authorization URL uses https://platform.claude.com/oauth/code/callback; the token exchange succeeds; refresh credentials stay under the provider-scoped Host OAuth reference and only the short-lived access token is returned to runtime auth. No API-key secret is created.
  • Specs linked: 03-runtime/14-secrets-storage.md §10; 07-plugins/16-trusted-extensions.md §4.
  • Acceptance: B (vendor accounts), Security, Quality.
  • Status: Local provider-flow integration fixture; no live account or visual renderer validation.

E2E-151: Multiple vendor accounts stay isolated through login, use, and removal ​

  • Preconditions: A build with registerBunOAuthFlows() running at startup and a real subscription for at least one PKCE vendor (Anthropic) and one device-code vendor (xAI, GitHub Copilot, or Meta/Muse). No provider row exists yet for either vendor.
  • Steps: 1) Open Settings -> Model configuration, confirm the Vendor accounts card starts empty, and open Add account — the picker lists every models.getProviders().filter(p => p.auth.oauth) vendor, including vendors with existing accounts; when accounts exist, confirm their rows use the same single-level list surface as AI services and that Add account matches the primary button treatment of Add provider. 2) Pick Anthropic and complete the browser login; confirm one connected account row and one OAuth provider row appear. 3) Use Add account again, pick Anthropic again, and complete a second login with a different account; confirm two account rows and two provider ids. 4) Edit the first account, change its display name, select multiple catalog/custom models, configure context window, max output tokens, and thinking levels for one model, then save; confirm the row shows the account label once while the Defaults selector reflects the edited model and each option shows only one provider name, without an appended account label or model ID. Before saving, focus the default-model field and confirm the authenticated suggestions open as an app-styled list with aligned model IDs/display names, that typing filters it, ArrowDown/ArrowUp plus Enter selects an option, Escape closes it, and the fixed list is portaled above the dialog without changing dialog height or being clipped by the dialog's overflow. Confirm a custom model ID can still be entered. Press Test connection and confirm the result resolves the edited account. Open the Composer model menu and confirm the edited account label is used as the OAuth provider group heading, while the configured model alias is shown on its model row. With both Anthropic accounts configured, open the Defaults picker and confirm the one provider name each group shows is that account's own label, so the two accounts are not two identical Anthropic groups, that the summary line above the Change action names the account holding the current default, and that typing an account label filters to that account's models while typing the vendor name still reaches both. Repeat the 5) Resolve and use each account separately, including model discovery and one streamed turn per account. 6) Start the device-code login on a second vendor, including Meta/Muse when available, then press Cancel while the dialog is polling; confirm no row or credential is left. 7) Remove the first Anthropic account, then confirm its provider row and OAuth secret are gone while the second Anthropic account remains usable. 8) If the removed account was default, confirm Defaults points to another ready provider or shows no default. 9) Grep sidecar and renderer logs for token material.
  • Expected: Each successful login creates a distinct row with authKind: "oauth", hasSecret and hasOauth both true, a non-secret account label, and baseUrl/apiStyle/defaultModelId filled from that account's own catalog. Each row has its own secret:provider:<providerId>:oauth ref and row-scoped pi-ai collection; resolving one account never returns the other account's token. The model list is the authenticated catalog (a Copilot account lists only what its subscription includes; a Meta account lists Muse Spark models), not a /models probe. Matching models.dev metadata supplies each newly logged-in binding's limits, modalities, and thinking levels; an ID missing from models.dev uses the conservative generic text-only/non-reasoning shape. label/model fields and the full per-model bindings, and Test connection resolves that exact account. Both turns run without a pasted key and reuse the same warm runtime — the launch payload carries apiKey: "" and each request resolves auth through provider.resolveAuth, which Electron main answers locally and refuses with PROVIDER_NOT_BOUND for an unbound provider id. Cancel aborts the local callback server / device-code polling and leaves no row and no credential. The connection test proves the account by resolving auth rather than probing with a key. Remove account delegates to providers.delete, clearing both secret refs and metadata for exactly that row. No log, event, or IPC payload contains an access token, refresh token, or authorization code.
  • Specs linked: 03-runtime/11-provider-model-system.md §8a, 03-runtime/12-provider-config-schema.md §3, 03-runtime/14-secrets-storage.md §10, 03-runtime/01-ipc-protocol.md §8, 04-ux/06-settings-ia.md, 08-meta/decisions-log.md (D237/D240), ADR 0095, ADR 0098
  • Acceptance: B (model config), C (conversation & stream), F (persistence), Security, Quality
  • Milestone: M6+
  • Status: Unit coverage in packages/agent-runtime (auth resolution and runtime reuse) and host-core secret-ref tests; full desktop journey Draft and needs live vendor accounts (run only in a capable environment when this surface changes)

E2E-197: GitHub Copilot accepts the default Enterprise domain ​

  • Preconditions: A build with the GitHub Copilot OAuth flow registered; the device-code login can reach the GitHub endpoint.
  • Steps: 1) Open Settings -> Model configuration -> Add account and choose GitHub Copilot. 2) Leave the Enterprise URL/domain field empty. 3) Confirm Continue is enabled and submit the empty value. 4) Complete the device-code login and inspect the resulting account row.
  • Expected: The empty text prompt is submitted as an empty string, the provider uses github.com, and the device-code flow completes normally. Secret and manual-code prompts remain disabled while empty. No OAuth token or device credential is rendered or logged.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md §19, 03-runtime/12-provider-config-schema.md §3, 08-meta/decisions-log.md (D237/D240)
  • Acceptance: B (model config), Security, Quality
  • Milestone: M6+
  • Status: Unit-covered (apps/desktop/test/oauth-login-prompt.test.mjs); live device-code journey Draft (run only in a capable environment when this surface changes)

E2E-152: A plugin contributes a work panel view ​

  • Preconditions: A development plugin declaring ui.view and one contributes.views entry whose entry is a small HTML page calling window.pluginBridge.invoke("ui.showToast", …). Two projects open, with the plugin's activation scope limited to the first.
  • Steps:
    1. Load the plugin as a development plugin. Confirm the Plugins page shows a work-panel-views capability badge.
    2. Press Cmd/Ctrl + J to reveal the work panel, then click + to create a New launcher tab. Confirm the fixed + trigger, the maximize control, and the viewport-fixed work-panel toggle resolve to one button group — one 4px control gap, no divider between maximize and the toggle — while the + trigger and the toggle stay separate, non-overlapping hit regions with more than 24px of visual gap. Confirm the launcher lists the built-in Review row and the plugin view's localized title and icon (or a lettered tile if the manifest names an unknown token).
    3. Activate the plugin row. Confirm the plugin's page renders inside the panel body with no window-control capsule and no reserved 46px band, and that its button reaches the host toast.
    4. Drag the inner panel divider and resize the conversation area. Confirm the page tracks the panel rect without lag or tearing, then drag the outer right window edge and confirm the panel width changes while the base chat width stays fixed.
    5. Open global search, then Settings. Confirm the page is hidden while each overlay is up and returns when it closes.
    6. Click + again, then choose the same view from the new launcher. Confirm it returns to the live page — same scroll position, no reload — rather than stacking a second tab.
    7. Switch to the second project. Confirm the view disappears from the New launcher and from the no-tab entry list.
    8. Switch back, reopen the view, then disable the plugin. Confirm the tab closes and the view's renderer process exits (Activity Monitor / Task Manager).
    9. Re-enable, reopen, then edit the plugin's HTML on disk to trigger a development reload. Confirm the view reloads rather than going blank.
  • Expected: A plugin view is reachable, isolated, correctly positioned, and bounded by the plugin's lifecycle and activation scope. The panel remains an in-flow internal column; its renderer-owned divider resizes the panel and native window edges never change that target. It never renders while a blocking overlay is open, and it never obtains window controls.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md §4/§5, 07-plugins/13-plugin-permissions-matrix.md §2, 04-ux/08-component-spec.md §5, ADR 0104, ADR 0092
  • Acceptance: G (plugins), Security, Quality
  • Milestone: M6+
  • Status: Unit coverage in apps/desktop/test/plugin-work-panel-views.test.mjs (addressing, launcher grouping, isolation parity, scope filtering, lifecycle teardown), packages/plugin-sdk and host-core manifest validation; the desktop journey is Draft (run only in a capable environment when this surface changes)

E2E-153: The vendored file manager replaces the built-in Files tool ​

  • Preconditions: A packaged build (so resources/plugins is copied outside the asar) and a project with nested directories, a node_modules, a .env, a binary file, an image, a CSV, and a Markdown file. The project is a group whose second folder is registered as another root (ADR 0249).
  • Steps:
    1. Open the Plugins page. Confirm File Manager is listed as a bundled plugin, enabled, showing a work-panel-views capability, and that it offers no Uninstall action.
    2. Reveal the work panel and click + to create a New launcher tab. Confirm its rows include Review and the plugin-contributed File Manager and Browser views. Trigger an agent edit and confirm the panel does not open itself: Review appears under Open resources only after the user selects its row.
    3. Open the File Manager view. Confirm the tree lists the project, expands directories lazily, and omits node_modules, .git, and .env. Switch the view's top-left folder control to the project's second folder and confirm the tree follows it while the app's visible workspace does not, then switch back to the project's primary folder.
    4. Right-click an HTML file and confirm Open in built-in Browser appears under Open With. Select it and verify a new Browser resource tab opens the file in the active local session while the File Manager tab remains open. Right-click a non-HTML file and confirm that item is absent. Confirm Open with default app and Show in folder remain available for files and work. For directories, confirm the built-in Browser and Show in folder actions are absent, the default-app action is disabled, and editor choices remain available.
    5. Open a text file, edit it, and save. Confirm the file on disk changed and the editor keeps the saved content. In dark appearance, press Ctrl/Cmd+F and confirm the find text, replace text, and action labels remain readable against their themed fields and buttons; repeat after switching to light appearance. Change the same file outside the app, edit and save again, and confirm the conflict is reported instead of the external change being overwritten. Click the binary file and confirm it reports as unsupported rather than printing replacement characters; open the image, the CSV, and the Markdown file and confirm each gets its own viewer. Switch the app to Simplified Chinese and confirm the tree, viewer, and context menu are localized. Switch projects and confirm the tree updates without waiting on a poll.
    6. Click a project file path in the conversation. Confirm it opens in this view on that file — a chat click now prefers the file view over the host file: tab.
    7. Click a conversation reference to src/example.ts:42. Confirm the host file: tab opens at line 42 even though the File Manager plugin is enabled.
    8. Disable the File Manager plugin. Confirm the view disappears from the menu and the panel. Click a project file path in the conversation; the host should enable the bundled plugin and open that file in its view.
    9. Restart the app. Confirm the enabled state and the tree return, and that the registry did not gain a duplicate row.
  • Expected: A panel surface runs entirely on the public plugin contribution channel, is user-disableable, cannot be uninstalled, and survives restart. Its host-mediated actions obey the declared fs.read scope, and its own reads and writes stay inside the jail of the one project folder it is browsing (ADR 0241, ADR 0263). Plain project-file links open in the bundled view, bringing it up on demand after a direct click when its scope and permission allow it; a positioned path:line reference opens the host file tab and scrolls the requested line even while the plugin view is available.
  • Specs linked: 07-plugins/03-plugin-api.md §3, 07-plugins/13-plugin-permissions-matrix.md §2, 04-ux/08-component-spec.md §5, ADR 0104, ADR 0109, ADR 0111, ADR 0169, ADR 0241, ADR 0249, ADR 0263, ADR 0330
  • Acceptance: G (plugins), D (workspace), Security, Quality
  • Milestone: M6+
  • Status: pnpm test:e2e:file-manager-theme loads the shipped plugin view in Electron, opens a text file through a fixture public bridge, invokes Ctrl/Cmd+F, and checks the rendered find/replace fields and actions in dark and light appearances. apps/desktop/test/bundled-plugins.test.mjs also covers the manifest contract, public-bridge-only page, vendored checksums, and themed CodeMirror controls. apps/desktop/test/plugin-fs-scope.test.mjs covers fs.openDefault, fs.openInBrowser, and fs.reveal guards; apps/desktop/test/browser-preview-tool.test.mjs covers Browser resource-tab event routing; apps/desktop/test/plugin-work-panel-views.test.mjs covers docked-view event broadcast; and host-core covers bundled_plugins_refresh_from_disk_but_keep_user_state. The remaining packaged journey is Draft (run only in a capable environment when this surface changes).

E2E-PLUGIN-file-view-collapse-persists ​

  • Preconditions: The bundled File Manager view is open on a project with nested folders and a text file, and the work panel is wide enough for the two-pane layout.
  • Steps:
    1. Drag the divider between the file list and the content pane to a non-default width, then activate the toolbar's left-most toggle from the keyboard alone (Tab to it, then Enter or Space).
    2. Click a file reference in the conversation.
    3. Activate the same toggle again.
    4. Collapse the file list, then close and reopen the view, and finally restart the app.
    5. Expand the file list by hand and click another chat file reference.
  • Expected: The toggle hides the view's own left file list and gives the content pane the full width, and it stays keyboard-reachable with an accessible name that swaps between Hide file list and Show file list. The open request that follows a chat click shows the requested file with its ancestor folders expanded and the file list collapsed, whether the view was already open or was opened by that click. Expanding again restores the previously dragged split width together with the expanded folders and the selected file, not the default split or the project root. The collapsed state is persisted: it survives closing and reopening the view and a full app restart, and a manual expansion holds until the next host open request collapses it again.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md §4/§5, 04-ux/08-component-spec.md §5.2.2, ADR 0104, ADR 0241, ADR 0262
  • Acceptance: G (plugins), Quality
  • Milestone: M6+
  • Status: The bundle's manifest, entry page, and upstream checksum are unit-covered (apps/desktop/test/bundled-plugins.test.mjs); the plugin-side journey is Draft (run only in a capable environment when this surface changes)

E2E-PLUGIN-file-view-switches-folder-per-project ​

  • Preconditions: A project group whose two folders each hold a text file the other folder does not, and a second, single-folder project. The work panel is open on the bundled File Manager view, browsing the first project's primary folder.
  • Steps:
    1. Switch the view's top-left folder control to the project's second folder. Confirm the tree, the filename search, and open/save all follow it.
    2. Save an edit in the text file only that second folder holds.
    3. Close and reopen the view, then restart the app. Confirm the second folder is still the one being browsed.
    4. Switch to the other project and back. Confirm each project remembers its own folder.
    5. Click a chat reference that resolves in the project's second folder, then one that resolves in its primary folder.
    6. With the second folder selected, search the tree for the primary folder's own file name, and try to open a .env and a symlink or junction inside the second folder that points outside it. 7) With the second folder selected, right-click the text file only that folder holds and use Open with default app, then Show in folder; switch to the primary folder and do the same for a file only it holds. 8) Back in the second folder, use the same two actions on a file whose name both folders contain.
  • Expected:
    • The control lists the project's folders in group order, primary first, and names the one being browsed; the tree, search, and editing all work inside that one folder, and a single-folder project offers just its one folder.
    • Switching folders changes only what this view browses: the app's visible workspace, the agent's tool roots, the session's primary path, project instructions, and project memory are all unchanged (ADR 0263).
    • The choice is remembered per project: it survives closing and reopening the view and a full app restart, and the other project keeps its own folder.
    • Both chat references open in this view on the file they name — the one from the second folder included, in that folder — with no host file: tab.
    • The selected folder is the jail, not the group: the filename search does not reach a file that only another project folder holds, and the credential path and the symlink/junction escape are still refused (ADR 0241).
    • The two system actions reach the file that was clicked, in the folder being browsed: a file only the second folder holds opens or reveals its real self instead of reporting "not found", and the file whose name both folders share opens the second folder's copy, not the primary folder's (ADR 0264).
  • Specs linked: 07-plugins/03-plugin-api.md §3, 04-ux/08-component-spec.md §5.2.2, ADR 0241, ADR 0249, ADR 0263, ADR 0264
  • Acceptance: G (plugins), Security, Quality
  • Milestone: M6+
  • Status: The host-side resolution and addressing are unit-covered (apps/desktop/test/chat-ref-resolve.test.mjs, apps/desktop/test/transcript-file-chips.test.mjs); the plugin-side journey is Draft (run only in a capable environment when this surface changes)

E2E-PLUGIN-bundled-plugin-keeps-a-marketplace-update ​

  • Preconditions: A build that ships a bundled plugin whose marketplace entry offers a newer version, and a data directory the user can install into.
  • Steps:
    1. Open the Plugins page. Confirm the plugin is listed as bundled and enabled, and that it offers no Uninstall action.
    2. Update it from the marketplace. Confirm the row moves to the catalog version and still offers no Uninstall action.
    3. Restart the app. Confirm the updated version is still the installed one, the plugin is still enabled, and the registry holds exactly one row for it.
    4. Disable the plugin, restart again, and confirm it stays disabled at the updated version.
  • Expected: Bundled means default and non-removable, not frozen. A user's update outlives the launch that reconciles the shipped copy, an app that ships a strictly newer version still wins, and a catalog version that is not newer is never offered as an update.
  • Specs linked: 07-plugins/07-plugin-marketplace.md, ADR 0104, ADR 0241
  • Acceptance: G (plugins), Quality
  • Milestone: M6+
  • Status: Unit coverage in host-core a_bundled_plugin_keeps_the_update_the_user_installed, a_newer_shipped_version_replaces_an_older_user_install, a_plugin_a_build_stops_shipping_is_no_longer_bundled, and market_entry_offers_an_update_only_when_the_catalog_is_newer; the packaged journey is Draft

E2E-154: Model additions use models.dev metadata and generic unknown IDs ​

  • Preconditions: A custom provider dialog matches a models.dev provider/API URL and exposes at least two model records with limits, modalities, and reasoning options. A deterministic fixture also exposes a provider-discovered ID absent from models.dev.
  • Steps:
    1. Select two models from the models.dev list and inspect their names, context/output limits, capability badges, source labels, and thinking chips. 2. Save the provider and start a session; confirm the request keeps the provider's configured base URL/API style. 3. Force a Settings catalog refresh and confirm it refetches models.dev without changing the bundled release file or writing a user cache. 4. Add an ID absent from models.dev and inspect its generic fallback card. 5. In the OAuth protocol fixture, sign in to an account that offers a new Claude ID absent from models.dev while a same-tier Pi sibling exists. Resolve its runtime binding, apply runtime model configuration without a saved model binding, and send a request through the real Anthropic adapter to an intercepted HTTP boundary. Confirm the sibling does not supply chat limits, reasoning levels or effort mappings.
  • Expected: models.dev fields prefill known model bindings and remain the published metadata source. Provider keys are never included in the fixed models.dev request. Custom IDs without metadata retain generic defaults. An OAuth live-only ID does not inherit metadata from a Pi sibling; only an exact models.dev match supplies its chat limits and reasoning shape. The selected adapter keeps its wire behavior, and Copilot Bearer authentication remains unchanged.
  • Specs linked: 03-runtime/11-provider-model-system.md §6.2 and §8a, 03-runtime/13-model-catalog-and-selection.md §11.1–§12, ADR 0134
  • Acceptance: B (model config), C (conversation & stream), Security
  • Milestone: M6+
  • Status: OAuth-to-runtime-binding-to-adapter HTTP contract automated in apps/desktop/test/vendor-oauth-login.test.mjs. Full provider-dialog journey Draft. No live account or paid provider request is required for this fixture.
  • Preconditions: An isolated profile has a named conversation and visible messages. No model credentials or external marketplace access are required.
  • Steps: 1) Select the conversation, type an unsent draft, enter Plugins from the footer, and click the same button again. 2) Reopen Plugins, type a search in Installed, return and reopen. 3) In the navigation fixture, repeat from scheduled and Settings; test a history containing both Scheduled and Settings before Plugins. 4) Exercise Forward then the Plugins button again. 5) Open Plugins with no previous history entry and click it.
  • Expected: The second click performs the existing Back action exactly once; it does not append a return entry or skip Settings. A chat history entry uses existing session selection/loading behavior. The conversation and unsent draft remain usable on return. If Back is unavailable, the button opens chat. Its pressed state reflects whether Plugins is active. Plugin browsing tab, search fields, and category survive route unmounts; dialogs and listeners are released rather than kept hidden. Settings navigation is unchanged.
  • Specs linked: 04-ux/01-ui-ia.md §2/§5, 04-ux/08-component-spec.md §3
  • Acceptance: C (session navigation), G (plugin browsing), Quality
  • Milestone: M6+
  • Status: Actual footer-handler/history-slice and browse-state regression tests passed. Real macOS UI validation of the rebuilt history-based revision passed two open/back cycles: the same two conversation messages and unsent draft remain, the button clears its active state on return, and reopening retains the Installed search filter. Non-chat history entries, Forward, and no-history fallback are covered by tests, not native UI. The layout journey also activates the footer button a second time and asserts the previous destination; it passed 37/37 checks from integrated main d6ffaa3b. No external marketplace or live model was required.

E2E-PROVIDER-custom-form-excludes-account-formats: Keep account APIs out of new custom services ​

  • Preconditions: Isolated profile, English and Simplified Chinese. Seed non-OAuth legacy rows using Codex and Pi account formats, including an OpenAI preset URL; use synthetic models and no real credentials. Include a synthetic OpenAI Codex OAuth account with one configured model binding.
  • Steps: 1) Add a Custom service and inspect its format choices. 2) Edit each legacy row and save unchanged, then explicitly select Responses and save. 3) Copy each original legacy row, inspect the current format and explanatory hint, wait past discovery debounce, and cancel. 4) Copy again, choose Anthropic Messages and save. 5) Check saved payloads and the unchanged source row. 6) Edit the synthetic Codex OAuth account; confirm Native web search is enabled but unchecked, check it, save, and inspect the model binding payload.
  • Expected: New custom choices are the four general protocols; Codex and Pi are available through vendor accounts, not as new API-key choices. Legacy editing preserves format, name, URL and authentication unless explicitly changed. A copied account format is visible but cannot be newly selected; saving and discovery are blocked until an explicit supported choice. The explanation is localized. Cancel performs no create; a valid copy never uses the source id or credentials. For Codex OAuth, Native web search is enabled for the supported Responses wire, defaults off, and saves as nativeWebSearch: true on that model only. Named OpenCode Go and OAuth account flows remain unchanged.
  • Automation: pnpm test:e2e:provider-api-style renders the production React form and VendorAccountDialog in Electron/Chromium with a stubbed API boundary; it checks create/update/discovery payloads and the Codex model binding save. This does not verify Host storage, live OAuth or live model/search calls.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/12-provider-config-schema.md, ADR 0095, ADR 0297.
  • Acceptance: B (model configuration), Security.
  • Status: The Electron/React scenario passed in English and Simplified Chinese: eight scenario groups, four creates and eight provider updates through the stubbed API; both locales also verify the Codex OAuth search opt-in save. Host persistence, live OAuth/model calls and visual layout were not exercised. Post-integration main E2E is NOT RUN.

E2E-PROVIDER-stepfun-plan-setup: Save the StepFun subscription preset ​

  • Preconditions: Isolated Electron profile, English and Simplified Chinese, synthetic API key and a discovered step-5-preview model; no live provider.
  • Steps: Select StepFun Plan in Add AI service, enter the test key, wait for model discovery, explicitly select Step 5 Preview, then save.
  • Expected: The chooser and connection summary both visibly include api.stepfun.com/step_plan/v1. Discovery receives https://api.stepfun.com/step_plan/v1, anthropic_messages, and the entered key sent as Authorization: Bearer rather than the Anthropic x-api-key. Save retains that URL and format, the catalog vendor stepfun-step-plan, and the selected model. The ordinary StepFun /v1 endpoint is not substituted for the subscription endpoint.
  • Automation: pnpm test:e2e:provider-api-style uses real settings components with only the API boundary stubbed. provider-presets.test.ts checks the preset contract and ordinary-endpoint separation; service-catalog.test.mjs checks discoverability by localized label, name, vendor key, and URL; provider-stepfun-plan-discovery.test.mjs drives the real provider handlers and pins the Bearer model list for both discovery and the connection test.
  • Scope: Save/discovery payloads are covered; host persistence, live model calls, and visual layout are not asserted by this fixture.
  • Specs linked: 03-runtime/12-provider-config-schema.md, guide/stepfun.md.

E2E-PROVIDER-copy-config-without-credentials: Copy configuration into an independent provider ​

  • Preconditions: Settings contains an ordinary provider with a saved API key, custom headers, and two model bindings with distinct aliases, limits, thinking levels, and modality overrides; an OAuth account also exists. Record the source configuration and global default provider/model. Use a deterministic endpoint to capture discovery requests without real secrets.
  • Steps: 1) Copy the ordinary provider. 2) Confirm the custom-service draft retains the name, URL, API format, and model bindings while the key and custom headers are blank and an omission notice is visible. 3) Change the API format, a model alias/limit, and its thinking levels; cancel. 4) Confirm provider count, source data, and global defaults are unchanged. 5) Copy again and trigger discovery before entering a new key, then with a distinct fixture key. 6) Save under a distinct name with the changed API format. 7) Reopen both providers and edit the copy. 8) Inspect the OAuth account row for absence of Copy. 9) In the draft-construction fixture, add unknown source/model fields and verify they are not copied. 10) Copy an OpenCode Go provider, confirm its named service and fixed format are kept, then select Custom service and choose another ordinary API format.
  • Expected: Cancel creates no provider or secret. Draft model objects and thinking arrays do not share references with the source. Discovery and connection testing do not use the source provider id or stored credential; authenticated discovery uses only the new draft key. Copy never reads the secret store. Save creates a distinct provider through the existing create path with independent model bindings and credentials, while the source and global defaults remain unchanged. Custom headers and unknown fields are omitted even if they contain credential-like values. OAuth accounts cannot be copied through this action.
  • Specs linked: 03-runtime/12-provider-config-schema.md, 03-runtime/14-secrets-storage.md
  • Acceptance: B (model configuration), F (independent persistence), Security
  • Milestone: M2
  • Status: Real Host/helper fixture verified independent creation, edits, deletion, retained source credentials/defaults, and restart persistence. Actual UI validation on an isolated no-secret profile confirmed cancel leaves one provider; saving a copy with Responses changed to Anthropic Messages and a changed name/alias creates a second provider without changing the global default. Reopening both rows confirmed the source retained Responses and its original alias, while the copy retained Anthropic Messages and its edited alias. Credential-bearing network discovery, external-model calls, and the OpenCode Go UI variant were not exercised.

E2E-PLUGIN-global-shortcut-owns-only-its-own-command ​

  • Preconditions: Two local fixture plugins are installed in an isolated profile. Plugin A declares keyboard.globalShortcut, contributes.commands with one command, and contributes.globalShortcuts mapping Alt+Shift+V to it. Plugin B declares the same permission and asks for Alt+Shift+V while A holds it. A second application is available for a cross-application keypress.
  • Steps: 1) Install and load A and confirm the accelerator is held through listGlobalShortcuts. 2) Focus another application, press Alt+Shift+V, and confirm A's command runs; drive the registered handler through the host's test seam when no cross-application input is available. 3) Attempt a shortcut whose command belongs to another plugin. 4) Attempt B's registration of Alt+Shift+V and inspect the answer. 5) Disable and uninstall A and confirm the accelerator becomes free and B can take it; repeat after terminating A's runtime (crash) and while A's panel is closed. 6) Attempt the app's own launcher accelerator Alt+Space, the Alt+Shift+W window toggle, a reserved binding such as Mod+C, an invalid accelerator, and a ninth shortcut for one plugin.
  • Expected: Only A's own command runs for the accelerator; a shortcut whose command is not registered by the plugin is refused with INVALID_ARGUMENT. B receives a refusal (registered: false, code SHORTCUT_CONFLICT) and keeps no accelerator while A holds it. The host's own Alt+Space launcher and Alt+Shift+W window-toggle shortcuts and OS-reserved bindings are refused with SHORTCUT_CONFLICT or SHORTCUT_UNAVAILABLE; an invalid accelerator is refused with INVALID_ACCELERATOR and the ninth per-plugin shortcut with LIMIT_EXCEEDED. Disabling, unloading, or crashing a plugin releases every accelerator it held, after which another plugin can take it, and app quit releases all shortcuts. Audit records each register/unregister attempt with the plugin id and result, and never records key contents.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0257
  • Acceptance: G (plugins), Security, Quality
  • Milestone: M6+
  • Status: Registry, host wiring, and manifest validation are unit-covered (apps/desktop/test/plugin-shortcuts.test.mjs, packages/plugin-sdk/src/index.test.ts, crates/host-core/src/plugins/tests.rs); the cross-application keypress journey is Draft and needs a capable desktop environment.

E2E-PLUGIN-permission-gate-for-real-time-capabilities ​

  • Preconditions: A fixture plugin whose manifest can be changed between runs, and a data directory in which the user can re-grant permissions.
  • Steps: 1) Install a build that requires keyboard.globalShortcut and inspect the install dialog. 2) Load a plugin that calls pi.keyboard.listGlobalShortcuts() without declaring the permission. 3) Declare but do not grant the permission, then call the same API. 4) Grant the permission and call the same API again. 5) Revoke the permission with the plugin loaded and press the accelerator. 6) Repeat steps 2–5 for audio.capture.background, audio.playback.background, and net.websocket.
  • Expected: Undeclared and declared-but-ungranted calls are refused with PERMISSION_DENIED and an audit entry, so an ungranted capability fails closed instead of degrading. After a grant the same call is allowed — for net.websocket that is a connect that proceeds past the gate — while the callable audio methods answer a coded UNSUPPORTED because the host has no device backend yet, audited as { api: "audio.<method>", ok: false, errorCode: "UNSUPPORTED" }, never a silent success. Revoking the permission with the plugin loaded stops the accelerator immediately and the host releases it. The four permissions appear with their risk tiers (high for audio.capture.background and net.websocket, medium for audio.playback.background and keyboard.globalShortcut) and localized copy in the install dialog and in the plugin detail sheet, ordered by descending risk.
  • Specs linked: 07-plugins/04-plugin-security.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0257
  • Acceptance: G (plugins), Security
  • Milestone: M6+
  • Status: The gates and validator behaviour are unit-covered (packages/plugin-sdk/src/index.test.ts, packages/plugin-devkit/src/check.test.ts, crates/host-core/src/plugins/tests.rs); the dialog journey is Draft.

E2E-PLUGIN-background-audio-and-realtime-connection ​

  • Preconditions: A voice-assistant fixture plugin holding background.service, audio.capture.background, audio.playback.background, keyboard.globalShortcut, net.websocket, and desktop.control, with a mock audio backend and a local mock WebSocket server so CI never needs a real microphone.
  • Steps: 1) Load the plugin with no panel open and confirm that its background service starts. 2) Register the push-to-talk accelerator. 3) Open the input device and receive PCM frames. 4) Connect the mock server and send PCM. 5) Receive the response audio and play it. 6) Interrupt playback with stopOutput. 7) Call pi.desktop.invoke({ operation: "session/create" }) and then agent/prompt. 8) Unload the plugin.
  • Expected: Frames flow with bounded buffering and no unbounded queue growth, and stopOutput clears queued audio immediately. Egress to a host outside manifest.net.domains is refused. On unload the microphone, the audio output, the socket, and the accelerator are all released with no orphan process, listener, or timer. session/delete-class dangerous desktop operations still require the host's native confirmation even when the plugin passes confirm: true.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 07-plugins/12-plugin-ipc-and-host-services.md, ADR 0257
  • Acceptance: G (plugins), Security, Quality
  • Milestone: M6+
  • Status: Partially implemented — the socket half is implemented and unit-covered by apps/desktop/test/plugin-websocket.test.mjs (allowlist, bounds, lifecycle); the background-audio half answers a coded UNSUPPORTED because the host has no device backend yet, so this scenario stays Draft until audio lands.

<a id="e2e-config-sync-webdav-portable-configuration"></a>

E2E-CONFIG-SYNC-s3-portable-configuration ​

  • Preconditions: A built task candidate, isolated Host processes with separate data, HOME and agent directories, and a local S3 fixture with strong ETags and conditional PUT. No real model provider or running desktop instance. A live S3 run is separately opt-in and requires explicit authorization.
  • Steps:
    1. In a packaged build, confirm Cloud sync appears in Settings navigation and search with Experimental badges on its row and page title. Open the destination and confirm only the new S3 setup is offered; old formats are not detected, migrated or deleted.
    2. Confirm initial setup shows only required connection/password fields and automatic runs use the shared switch. Expand Advanced to edit region, prefix, addressing, device label and optional token; collapse/reopen it without losing values. Select categories before the first save, keeping credentials/memory opt-in. Test conditional creation, readback, matching update, stale-write rejection and probe cleanup. Reject ignored preconditions, weak ETags, redirects, public HTTP and 403; never interpret 502 as a missing object. LAN HTTP follows network policy.
    3. Configure two sync devices. Publish a provider and mapped packaged skill, receive them as pending where approval is required, approve, and verify visible persisted configuration and resource bytes. Verify there is no projects category and publishing project/group metadata neither registers nor changes projects/groups on the receiving device. Register its workspace locally, then map and approve the project skill; its resources must arrive without changing local group names, roots or ordering. Existing vault projects entities are ignored on sync/restore, preserving local projects and cloud history. Exercise disjoint edits, same-entity conflicts and explicit choice convergence, remote deletion versus offline edit, and repeated polling. Credential opt-out must not manufacture local edits or prevent deletion convergence. No real executable capability is run.
    4. Configure backup mode. Verify remote configuration is not applied, backup history is separate, unchanged snapshots do not duplicate revisions, and a local restore does not change either cloud head.
    5. Restore an older sync revision with propagation acknowledgment. Verify a local recovery point, a new shared revision and propagation to the second device, with sensitive activation still requiring approval. Rewrap the password without claiming revocation of already unlocked devices.
    6. Reopen Settings with saved and unfinished drafts. Preserve typed S3 choices and automatic-run preferences, but never persist secret keys, tokens or passwords. Test blank credential reuse, explicit token clearing and full replacement. Invalidate history on destination/mode changes and ignore outstanding responses from the old connection. Unsaved mode changes must not alter restore confirmation or propagation flags. Saved connections reopen collapsed while status, manual run and inline unlock remain visible above them. Open History and Security/connection to restore, change password or disconnect. Pending approvals and errors remain visible. At narrow content widths the form becomes one column without horizontal overflow. In light/dark themes, activate shared checkboxes by label and Space; verify focus, disabled and mixed states.
    7. Restart an isolated Host and verify persisted state, then disconnect without deleting unrelated data. Inspect remote object bytes for known fixture plaintext and verify there is no automatic immutable-object GC.
    8. Hold an in-flight manual sync, navigate away from Settings and return. Verify Host still reports syncing, duplicate actions stay disabled, and releasing the fixture completes the operation and refreshes Host state.
  • Expected: Only S3 is supported, with mandatory conditional writes and encrypted vault contents. Identical/disjoint edits converge; conflicts and tombstones preserve changes; excluded categories do not imply deletion. Backup never downloads/applies remote configuration during normal runs. History queries index at most 30 revisions; stored immutable objects are not automatically deleted. Import recovery remains observable; per-file atomic replacement is not asserted to be a whole-filesystem transaction.
  • Live S3 safety: --live-s3 --env-file <authorized-file> reads credentials programmatically without logging/copying them. Use a new random test prefix, confirm it is initially empty, run actual isolated Host processes, delete only objects created under that prefix, and verify it is empty afterward. Never use an existing backup prefix or production desktop profile. Record tested base/candidate and distinguish this run from a standalone SDK probe.
  • Automation: pnpm test:e2e:config-sync is the multi-Host harness. Rust config_sync:: and user_skills:: suites cover transport, merge, application and resource safety. config-sync-user-path.test.mjs runs the real settings page and shared checkbox with a mocked external IPC boundary, actual styles and isolated Electron input, without network access; config-sync-ipc.test.mjs covers Main notification shape. Visibility remains covered by settings-developer-only-destinations.test.mjs.
  • Specs: 03-runtime/22-config-sync.md, 04-ux/06-settings-ia.md, 03-runtime/14-secrets-storage.md, 05-security/01-security.md, docs/adr/s3-only-configuration-backup-sync.md.
  • Acceptance: F (persistence), Security, Quality. Milestone: M6+.
  • Status: Candidate validation required; full checkpoint-recovery fault injection is not claimed by the settings or multi-Host happy-path suites.

E2E-CHAT-session-todo-checklist: TodoWrite to session-aware TodoDock

  • Preconditions: An isolated local Electron profile with a deterministic Agent/host fixture, two Desktop sessions, and no real provider or paid API.
  • Steps: Start a multi-step Agent turn that calls TodoWrite with ordered pending and in_progress items. Observe the TodoDock above the Composer; confirm its progress text is left-aligned, its separate arrow button is at the right edge, measures at least 36px square, and contains an independently sized 18px arrow icon. Confirm clicking the text does not expand it. Click the upward arrow to expand, then confirm the arrow points down. Switch sessions and confirm the checklist stays session-scoped. Expand a checklist longer than the dock's height cap: every row must render and the list must scroll inside the dock without growing the Composer stack. Complete and cancel items, confirm the all-cancelled label, then clear the checklist and reload/restart the host. Deliver an out-of-order older todos.changed event and confirm it cannot replace the newer snapshot. Exercise invalid payload, Plan/Goal, delegated, and remote-session paths.
  • Expected: Host SQLite is authoritative; each successful full replacement advances revision, including clear, and emits one committed todos.changed snapshot. Invalid or unauthorized writes do not mutate or emit. TodoDock renders plain text, separates collapsed progress from the current item, uses glyph status marks in a flat expanded list, does not take focus, strikes through completed task text, sweeps a light band across the working step's text only (the header line while collapsed, its row while expanded), resets expansion on session changes, lists every ordered row with the dock's own list as the scrollport, and keeps a collapsed dock to its header height, rejects stale events, and skips local recovery for remote: sessions because RACP v1 has no Todo snapshot operation.
  • Specs: 03-runtime/03-tools-and-permissions.md, 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 04-ux/08-component-spec.md, ADR 0312.
  • Acceptance: C / E / F / Quality / Security.
  • Milestone: M6+.
  • Automation: pnpm test:e2e:todos exercises the isolated Electron checklist journey through the production Agent's ToolSearch/TodoWrite path, the production renderer, and a real host/SQLite profile. Only the external model stream and preload transport are fixtures; no live provider or user profile is used. The scenario includes Unicode truncation with warning replay, single-active-item normalization, full-list rendering with the dock's internal scroll, a failed initial read followed by host recovery without changing sessions, cached-snapshot reconciliation, and stale-event rejection. Runtime runtime-todos.test.ts exercises Agent tool validation, overlong content normalization, and continuation through a deterministic provider.
  • Status: Run against the exact request candidate after building the desktop and host. Host-core and targeted renderer tests are companion checks, not substitutes for the Electron journey.

8. Traceability Matrix ​

AcceptanceScenarios
C / E / Security / Quality — Gemini work setupE2E-LIVE-VOICE-gemini-work-schema
C / Quality — Continue after malformed tool outputE2E-RUNTIME-malformed-tool-call-continue-recovery
B / Security / Quality — Optional provider client certificate requestsE2E-PROVIDER-optional-client-certificate
C / E / F / Quality / Security — Session Todo checklistE2E-CHAT-session-todo-checklist
C / E / Security / Quality — Live Voice four-stage interactionE2E-LIVE-VOICE-four-stage-ui
C / F — Hourly task updatesE2E-SCHEDULED-manual-to-hourly
C / F / Quality — Saved project isolationE2E-SCHEDULED-manual-workspace-binding
C / F / Quality — Desktop automationsE2E-SCHEDULED-desktop-automation-lifecycle
F / Security / Quality — Portable configuration syncE2E-CONFIG-SYNC-s3-portable-configuration
A / C — Unicode stdio framingE2E-RPC-unicode-separators
C / G / Quality — Plugins navigationE2E-NAV-plugins-button-goes-back
C / D / Quality — Sidebar row statesE2E-LAYOUT-sidebar-row-states
E / Quality — Browser screenshot timeout recoveryE2E-BROWSER-capture-resize
A / C / Quality — Sidebar material and settings returnE2E-LAYOUT-sidebar-settings
C / Quality — Destination loading and focusE2E-087b
A / H / Quality — Renderer process crash recoveryE2E-RUNTIME-renderer-crash-recovery
C / F / Quality — Project folder with chats detaches and deletes safelyE2E-048c
B / F / Security — Provider copyE2E-PROVIDER-copy-config-without-credentials
C / Quality — Self-hosted endpoint receives the session idE2E-PROVIDER-custom-endpoint-session-identity
B / F / Quality — Selected model orderE2E-MODEL-selected-order-persists
E / F / Quality — MCP server timeout overrideE2E-261
A — App startupE2E-001, E2E-002, E2E-003, E2E-004, E2E-067, E2E-076, E2E-079, E2E-092, E2E-097, E2E-143, E2E-150, E2E-168, E2E-204
A / C / F / Quality — Tray session navigationE2E-TRAY-bounded-session-navigation
B — Model configE2E-005, E2E-PROVIDER-custom-endpoint-no-api-key, E2E-006, E2E-007, E2E-038, E2E-050, E2E-052, E2E-055, E2E-066, E2E-080, E2E-082, E2E-102c, E2E-102d, E2E-102e, E2E-151, E2E-154, E2E-163, E2E-166, E2E-172, E2E-174, E2E-197, E2E-005G, E2E-005J, E2E-199, E2E-201, E2E-202, E2E-203, E2E-205, E2E-206, E2E-209, E2E-PLUGIN-provider-catalog-add-service
C — Conversation & streamE2E-CHAT-long-history-stream-keeps-responsive, E2E-CHAT-running-status-survives-output-pauses, E2E-008, E2E-008d, E2E-008e, E2E-008a, E2E-009, E2E-010, E2E-011, E2E-011a, E2E-011b, E2E-011d, E2E-011e, E2E-011g, E2E-031, E2E-040, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-052, E2E-053, E2E-054, E2E-055, E2E-059, E2E-059a, E2E-060c, E2E-060d, E2E-061, E2E-061a, E2E-062, E2E-064, E2E-065, E2E-068, E2E-071, E2E-073, E2E-074, E2E-075, E2E-081, E2E-083, E2E-084, E2E-086, E2E-087, E2E-088, E2E-088b, E2E-089, E2E-090, E2E-COMPOSER-narrow-controls, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-106, E2E-109, E2E-111, E2E-114, E2E-116, E2E-117, E2E-118, E2E-119, E2E-120, E2E-121, E2E-218, E2E-259, E2E-219, E2E-AGENTS-001, E2E-142, E2E-144, E2E-145, E2E-146, E2E-146a, E2E-147, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-161, E2E-162, E2E-166, E2E-172, E2E-173, E2E-174, E2E-177, E2E-178, E2E-179, E2E-180, E2E-182, E2E-183, E2E-187, E2E-198, E2E-199, E2E-202, E2E-203, E2E-207, E2E-208, E2E-CHAT-content-width-handles, E2E-250, E2E-102i, E2E-PLUGIN-session-orchestrator-real-workers, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-SUBAGENT-resume-a-settled-delegation
C — Conversation & stream (composer drafts)E2E-011c, E2E-011c-1, E2E-011c-2
D — WorkspaceE2E-012, E2E-013, E2E-022B, E2E-024I, E2E-047, E2E-049, E2E-057, E2E-058, E2E-060, E2E-068, E2E-075, E2E-078, E2E-153, E2E-158, E2E-182, E2E-187, E2E-252
C / D / Quality — Project session history resets after collapseE2E-263
D — Workspace (project ordering)E2E-253
E — Tools & permissionsE2E-008a, E2E-014, E2E-015, E2E-016, E2E-017, E2E-018, E2E-019, E2E-024I, E2E-024K, E2E-040, E2E-049, E2E-074, E2E-093, E2E-097, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102d, E2E-102e, E2E-102g, E2E-103, E2E-105, E2E-106, E2E-107, E2E-111, E2E-112, E2E-113, E2E-114, E2E-115, E2E-116, E2E-119, E2E-121, E2E-122, E2E-142, E2E-145, E2E-147, E2E-155, E2E-158, E2E-166, E2E-181, E2E-PLUGIN-imported-pi-package-skills
F — PersistenceE2E-020, E2E-021, E2E-021a, E2E-038, E2E-SETTINGS-inline-capability-imports, E2E-040, E2E-042, E2E-047, E2E-048, E2E-051, E2E-054, E2E-056, E2E-061, E2E-062, E2E-064, E2E-066, E2E-068, E2E-071, E2E-072, E2E-073, E2E-082, E2E-084, E2E-096, E2E-098, E2E-102, E2E-102b, E2E-102c, E2E-102d, E2E-102g, E2E-102i, E2E-103, E2E-AGENTS-001, E2E-061a, E2E-073a, E2E-104, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-118, E2E-119, E2E-120, E2E-121, E2E-123, E2E-142, E2E-146, E2E-146a, E2E-148, E2E-151, E2E-158, E2E-160, E2E-168, E2E-171, E2E-177, E2E-178, E2E-183, E2E-186, E2E-005J, E2E-PROVIDER-custom-endpoint-no-api-key, E2E-PLUGIN-session-orchestrator-real-workers, E2E-PLUGIN-provider-catalog-add-service, E2E-SESSION-outbox-unclassifiable-failure-does-not-stall-the-queue
F — Persistence (project ordering)E2E-251
G / Security — Fetch redirect policyE2E-PLUGIN-fetch-redirect-policy
G — PluginsE2E-022, E2E-022A, E2E-022B, E2E-022C, E2E-023, E2E-024, E2E-024B, E2E-024C, E2E-024D, E2E-024AA, E2E-024E, E2E-024W, E2E-024F, E2E-024G, E2E-024H, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M, E2E-024N, E2E-024O, E2E-024P, E2E-025, E2E-026, E2E-105, E2E-117, E2E-120, E2E-122, E2E-123, E2E-024Q, E2E-148, E2E-152, E2E-153, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-imported-pi-package-wrapper, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-global-shortcut-owns-only-its-own-command, E2E-PLUGIN-permission-gate-for-real-time-capabilities, E2E-PLUGIN-background-audio-and-realtime-connection, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-PLUGIN-provider-catalog-add-service, E2E-PLUGIN-reviews-public-reads
H — DiagnosticsE2E-027, E2E-031, E2E-034, E2E-042, E2E-096, E2E-098, E2E-104, E2E-107, E2E-108, E2E-109, E2E-110, E2E-113, E2E-115, E2E-116, E2E-118, E2E-121, E2E-146, E2E-146a, E2E-155, E2E-159, E2E-176, E2E-194, E2E-195
SecurityE2E-SETTINGS-inline-capability-imports, E2E-028, E2E-029, E2E-030, E2E-024J, E2E-024K, E2E-024M, E2E-049, E2E-068, E2E-086, E2E-102c, E2E-102d, E2E-102e, E2E-105, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-112, E2E-113, E2E-115, E2E-116, E2E-117, E2E-119, E2E-121, E2E-122, E2E-123, E2E-142, E2E-148, E2E-151, E2E-153, E2E-158, E2E-187, E2E-196c, E2E-196b, E2E-196, E2E-PROVIDER-custom-endpoint-no-api-key, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-PLUGIN-provider-catalog-add-service, E2E-PLUGIN-reviews-public-reads
QualityE2E-SETTINGS-inline-capability-imports, E2E-CHAT-long-history-stream-keeps-responsive, E2E-CHAT-running-status-survives-output-pauses, E2E-032, E2E-033, E2E-039, E2E-043, E2E-044, E2E-045, E2E-046, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-050, E2E-053, E2E-055, E2E-056, E2E-057, E2E-058, E2E-059, E2E-060, E2E-061, E2E-062, E2E-063, E2E-064, E2E-065, E2E-066, E2E-067, E2E-068, E2E-069, E2E-070, E2E-071, E2E-072, E2E-073, E2E-074, E2E-075, E2E-076, E2E-077, E2E-078, E2E-079, E2E-080, E2E-081, E2E-082, E2E-083, E2E-084, E2E-085, E2E-086, E2E-092, E2E-093, E2E-094, E2E-095, E2E-096, E2E-097, E2E-098, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102e, E2E-103, E2E-AGENTS-001, E2E-021a, E2E-024N, E2E-059a, E2E-060b, E2E-060c, E2E-061a, E2E-073a, E2E-111, E2E-114, E2E-117, E2E-118, E2E-119, E2E-120, E2E-122, E2E-123, E2E-142, E2E-143, E2E-144, E2E-145, E2E-146, E2E-147, E2E-148, E2E-150, E2E-151, E2E-153, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-168, E2E-172, E2E-173, E2E-174, E2E-011g, E2E-176, E2E-177, E2E-178, E2E-179, E2E-180, E2E-181, E2E-182, E2E-183, E2E-186, E2E-187, E2E-194, E2E-195, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-196, E2E-201, E2E-204, E2E-202, E2E-203, E2E-205, E2E-206, E2E-207, E2E-208, E2E-209, E2E-210, E2E-UPDATE-preference-and-once-only-reminder, E2E-218, E2E-259, E2E-219, E2E-250, E2E-252, E2E-102i, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation, E2E-PLUGIN-provider-catalog-add-service
Quality (sidebar menu placement)E2E-021c
Quality (project ordering)E2E-253
Quality (work-panel tab close visibility)E2E-WORK-PANEL-active-tab-close-visibility
C / E / G / Quality — Pi kernel upgradeE2E-PI-130-kernel-upgrade
C / E / G / Quality — Pi kernel upgradeE2E-PI-140-kernel-upgrade
C — Conversation & stream (IME slash alias)E2E-255
E — Tools & permissions (Skill residency)E2E-254
Quality (Skill residency and IME slash alias)E2E-254, E2E-255
C / E / F / Quality — Built-in skill activationE2E-SKILL-builtin-activation
Quality — Market catalog retry recoveryE2E-MARKET-CATALOG-RETRY
C — Conversation & stream (plugin session import visibility)E2E-257
F — Persistence (plugin session import visibility)E2E-257
G — Plugins (session import visibility)E2E-257
Quality (plugin session import visibility)E2E-257
Quality (Windows updater cache)E2E-260
G — Plugins (Session Orchestrator)E2E-PLUGIN-session-orchestrator-real-workers
Security (Session Orchestrator)E2E-PLUGIN-session-orchestrator-real-workers
Quality (Session Orchestrator)E2E-PLUGIN-session-orchestrator-real-workers
C — Conversation & stream (Session list responsiveness)E2E-SESSION-list-refresh-keeps-desktop-responsive
Quality (Session list responsiveness)E2E-SESSION-list-refresh-keeps-desktop-responsive
Security (imported extension dependencies)E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency
Quality (imported extension dependencies)E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency
F / G / Security / Quality — Imported extension npm recoveryE2E-PLUGIN-import-extension-recovers-missing-npm
G — Plugins (desktop tool dispatch budget)E2E-PLUGIN-slow-tool-is-not-cut-off-by-host-dispatch
C — Conversation & stream (Independent session communication)E2E-SESSION-independent-top-level-communication
D — Plugin security (Independent session communication)E2E-SESSION-independent-top-level-communication
G — Plugins (Independent session communication)E2E-SESSION-independent-top-level-communication
Quality (Independent session communication)E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links
C — Conversation & stream (Hover card model and links)E2E-SESSION-hover-card-model-and-links
C — Conversation & stream (Chat file references)E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it
C / Quality / Security (Conversation MP4 attachments)E2E-CHAT-mp4-attachment-opens-in-system-player
G — Plugins (Chat file references)E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists
Quality (Chat file references)E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it, E2E-PLUGIN-file-view-collapse-persists
C / Quality (Composer deep file mentions)E2E-COMPOSER-deep-file-mentions
G — Plugins (project folder roots)E2E-PLUGIN-file-view-switches-folder-per-project
Security (project folder roots)E2E-PLUGIN-file-view-switches-folder-per-project
Quality (project folder roots)E2E-PLUGIN-file-view-switches-folder-per-project
D — Workspace (project delete)E2E-PROJECT-delete-removes-project-and-owned-sessions
F — Persistence (project delete)E2E-PROJECT-delete-removes-project-and-owned-sessions
Quality (project delete)E2E-PROJECT-delete-removes-project-and-owned-sessions
Quality (two-click delete)E2E-SESSION-two-click-delete-arms-first
Security (plugin real-time capabilities)E2E-PLUGIN-global-shortcut-owns-only-its-own-command, E2E-PLUGIN-permission-gate-for-real-time-capabilities, E2E-PLUGIN-background-audio-and-realtime-connection
C — Conversation & stream (disclosure reading position)E2E-CHAT-disclosure-toggle-keeps-reading-position
E — Tools & permissions (disclosure reading position)E2E-CHAT-disclosure-toggle-keeps-reading-position
D (surfaces) / Quality — Remote-host Add and Pair form disclosureE2E-REMOTE-HOST-settings-compact-inventory
E — Tools & permissions (capability level move)E2E-CAPABILITY-move-across-levels
F — Persistence (capability level move)E2E-CAPABILITY-move-across-levels
Quality (capability level move)E2E-CAPABILITY-move-across-levels
MilestoneScenarios
Post-MVP experimental interaction maintenanceE2E-LIVE-VOICE-four-stage-ui
M1E2E-001, E2E-002, E2E-003, E2E-028, E2E-029
M2E2E-004, E2E-005, E2E-006, E2E-007, E2E-008, E2E-008d, E2E-008e, E2E-009, E2E-010, E2E-011, E2E-011a, E2E-011b, E2E-011d, E2E-011e, E2E-011g, E2E-020, E2E-021, E2E-021a, E2E-027, E2E-031, E2E-042, E2E-087, E2E-088, E2E-088b, E2E-089, E2E-090, E2E-COMPOSER-narrow-controls, E2E-144, E2E-005J, E2E-201, E2E-202, E2E-207, E2E-206
M2 (sidebar menu placement)E2E-021c
M3E2E-012, E2E-013, E2E-014, E2E-015, E2E-016, E2E-017, E2E-018, E2E-019, E2E-040
M4E2E-022, E2E-023, E2E-024, E2E-025, E2E-026, E2E-030, E2E-038
M5E2E-CHAT-long-history-stream-keeps-responsive, E2E-CHAT-running-status-survives-output-pauses, E2E-008a, E2E-032, E2E-033, E2E-034, E2E-039, E2E-043, E2E-044, E2E-045, E2E-046, E2E-047, E2E-048, E2E-048A, E2E-049, E2E-050, E2E-051, E2E-052, E2E-053, E2E-054, E2E-055, E2E-056, E2E-057, E2E-058, E2E-059, E2E-060, E2E-061, E2E-062, E2E-063, E2E-064, E2E-065, E2E-066, E2E-067, E2E-068, E2E-069, E2E-070, E2E-071, E2E-072, E2E-073, E2E-074, E2E-075, E2E-076, E2E-077, E2E-078, E2E-079, E2E-080, E2E-081, E2E-082, E2E-083, E2E-084, E2E-085, E2E-086, E2E-092, E2E-093, E2E-096, E2E-097, E2E-098, E2E-099, E2E-100, E2E-101, E2E-102, E2E-102a, E2E-102b, E2E-102c, E2E-102d, E2E-102e, E2E-AGENTS-001, E2E-059a, E2E-060b, E2E-060c, E2E-061a, E2E-073a, E2E-094, E2E-095, E2E-143, E2E-145, E2E-146, E2E-146a, E2E-147, E2E-177, E2E-178, E2E-180, E2E-181, E2E-182, E2E-183, E2E-186, E2E-187, E2E-194, E2E-195, E2E-204, E2E-208, E2E-CHAT-content-width-handles, E2E-250, E2E-252, E2E-102i, E2E-BROWSER-capture-resize
M5 (project ordering)E2E-253
M2 (IME slash alias)E2E-255
M5 (Skill residency)E2E-254
M6E2E-104, E2E-105, E2E-106, E2E-107, E2E-108, E2E-109, E2E-110, E2E-111, E2E-112, E2E-113, E2E-114, E2E-115, E2E-116, E2E-117, E2E-118, E2E-119, E2E-120, E2E-103, E2E-172
M6+E2E-121, E2E-122, E2E-148, E2E-150, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-166, E2E-168, E2E-173, E2E-174, E2E-176, E2E-179, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-202, E2E-203, E2E-205, E2E-209, E2E-SETTINGS-inline-capability-imports, E2E-210, E2E-UPDATE-preference-and-once-only-reminder, E2E-212, E2E-213, E2E-214, E2E-215, E2E-216, E2E-217, E2E-218, E2E-259, E2E-219, E2E-257, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation
M6+ (Session Orchestrator)E2E-PLUGIN-session-orchestrator-real-workers
M6+ (Selected model order)E2E-MODEL-selected-order-persists
M6+ (MCP server timeout override)E2E-261
M6+ (Destination loading and focus)E2E-087b
M6+ (Session list responsiveness)E2E-SESSION-list-refresh-keeps-desktop-responsive
M6+ (Windows updater cache)E2E-260
M6+ (Independent session communication)E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links
M5 (Chat file references)E2E-CHAT-shorthand-file-ref-opens-the-matching-file, E2E-CHAT-file-ref-opens-the-surface-that-owns-it
M5 (Composer deep file mentions)E2E-COMPOSER-deep-file-mentions
M5 (Conversation MP4 attachments)E2E-CHAT-mp4-attachment-opens-in-system-player
M6+ (Chat file references)E2E-PLUGIN-file-view-collapse-persists
M6+ (project folder roots)E2E-PLUGIN-file-view-switches-folder-per-project
Post-MVPE2E-022A, E2E-022B, E2E-022C, E2E-024I, E2E-024J, E2E-024K, E2E-024L, E2E-024M (plugin roadmap R2/R3/R6)
Post-baseline local automationE2E-220
Post-baseline local automation (MCP pi_session_get large compaction)E2E-MCP-session-get-projects-large-compaction
Post-MVP remote controlE2E-221, E2E-222, E2E-223, E2E-224, E2E-225, E2E-226, E2E-227, E2E-228, E2E-229, E2E-230, E2E-231, E2E-232, E2E-REMOTE-HOST-settings-compact-inventory
Trusted extensions (R7 v1)E2E-DIALOG-long-text-boundaries, E2E-241, E2E-242, E2E-HOOKS-prompt-chain, E2E-HOOKS-context-every-request, E2E-HOOKS-cancel-and-dispose, E2E-TRUSTED-EXTENSION-custom-agent-stream-and-binding, E2E-243, E2E-TRUSTED-EXTENSION-temporary-session-cwd-is-scratch, E2E-244, E2E-245, E2E-PLUGIN-imported-pi-package-skills, E2E-PLUGIN-import-extension-installs-dependencies, E2E-PLUGIN-import-extension-reports-missing-dependency, E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list, E2E-PLUGIN-provider-catalog-add-service
Trusted extensions (R7 v1 npm recovery)E2E-PLUGIN-import-extension-recovers-missing-npm
Post-MVP regression coverage (plugin tool dispatch)E2E-PLUGIN-slow-tool-is-not-cut-off-by-host-dispatch
M6+ (Project delete)E2E-PROJECT-delete-removes-project-and-owned-sessions
M6+ (Two-click delete)E2E-SESSION-two-click-delete-arms-first
C — Conversation & stream (model fallback)E2E-SUBAGENT-ordered-model-fallback-preserves-work
Quality (model fallback isolation)E2E-SUBAGENT-ordered-model-fallback-preserves-work
C — Conversation & stream (legacy subagent turn limit)E2E-SUBAGENT-legacy-turn-limit-frontmatter-is-ignored
Quality (legacy subagent turn limit)E2E-SUBAGENT-legacy-turn-limit-frontmatter-is-ignored
M6+ (disclosure reading position)E2E-CHAT-disclosure-toggle-keeps-reading-position
M6+ (capability level move)E2E-CAPABILITY-move-across-levels
E — Tools & permissions (builtin subagent defaults)E2E-SUBAGENT-settings-lists-builtin-defaults
Quality (builtin subagent defaults)E2E-SUBAGENT-settings-lists-builtin-defaults
C — Conversation & stream (opaque floating surfaces)E2E-CHAT-opaque-floating-decision-and-retry-surfaces
Quality (opaque floating surfaces)E2E-CHAT-opaque-floating-decision-and-retry-surfaces
M6 (opaque floating surfaces)E2E-CHAT-opaque-floating-decision-and-retry-surfaces
B — Model config (catalog window provenance)E2E-MODEL-catalog-window-correction-reaches-saved-bindings
F — Persistence (catalog window provenance)E2E-MODEL-catalog-window-correction-reaches-saved-bindings
Quality (catalog window provenance)E2E-MODEL-catalog-window-correction-reaches-saved-bindings
M6+ (catalog window provenance)E2E-MODEL-catalog-window-correction-reaches-saved-bindings
C — Conversation & stream (delegate context budget)E2E-SUBAGENT-context-overflow-compacts-before-failing, E2E-SUBAGENT-context-overflow-reports-actionable-failure, E2E-SUBAGENT-resume-seeds-within-context-budget
Quality (delegate context budget)E2E-SUBAGENT-context-overflow-compacts-before-failing, E2E-SUBAGENT-context-overflow-reports-actionable-failure, E2E-SUBAGENT-resume-seeds-within-context-budget
M6+ (delegate context budget)E2E-SUBAGENT-context-overflow-compacts-before-failing, E2E-SUBAGENT-context-overflow-reports-actionable-failure, E2E-SUBAGENT-resume-seeds-within-context-budget
C / F / Quality — The context estimate stays safe (calibration)E2E-CONTEXT-estimate-calibration-stays-safe
C — Conversation & stream (unique tool-call ids)E2E-RUNTIME-unique-tool-call-ids-per-request
F — Persistence (stored model binding array)E2E-PROVIDER-stored-binding-array-reads-entry-by-entry
Quality (stored model binding array)E2E-PROVIDER-stored-binding-array-reads-entry-by-entry
Quality (unique tool-call ids)E2E-RUNTIME-unique-tool-call-ids-per-request
C — Conversation & stream (system update during a tool call, restored)E2E-RUNTIME-system-update-during-tool-call-restore
Quality (system update during a tool call, restored)E2E-RUNTIME-system-update-during-tool-call-restore
C — Conversation & stream (loop context ownership)E2E-RUNTIME-loop-context-ownership
C / F / Quality — Transcript invalid UTF-8 recoveryE2E-TRANSCRIPT-invalid-utf8-window-remains-readable
Quality (loop context ownership)E2E-RUNTIME-loop-context-ownership
G — Plugin host lifecycle (crash report)E2E-PLUGIN-crash-report-names-the-exit-code
Quality (crash report)E2E-PLUGIN-crash-report-names-the-exit-code
C / E / F / Quality — Background Bash tasks outlive their turnE2E-265

The US-UI-* visual scenarios (§UI shell visual scenarios) trace to the Codex parity decisions in decisions-log §D rather than the A–H criteria; their gold source is the capture suite.

The release artifact paths are covered by E2E-192, E2E-192a, E2E-196a, E2E-196b, E2E-196c, and E2E-200 (Quality, M6+).


9. How AI Must Update This Doc ​

When adding or changing a feature that affects user-visible or protocol-visible behavior:

  1. Add a new scenario using the template in §6. Assign the next available ID (E2E-<N>). IDs retired by ADR 0268 are never reused.
  2. Link it to the relevant acceptance criterion (A–H) and milestone (M1–M6 or M6+ for the current product increment).
  3. Set status to Draft unless an automated test already exists.
  4. Update the traceability matrix in §8.
  5. Commit the update as part of the change (per ai-development-workflow R3).

10. Future Automation Mapping ​

When E2E automation is implemented (post-M5):

  • Each Draft scenario → Playwright test file.
  • Scenario ID becomes test case name: e2e-001-app-launches.
  • Fixtures and test data paths defined in a tests/e2e/fixtures/ directory.
  • CI gate: all E2E scenarios must pass before release.

Automation section will be expanded in a future ADR when the tooling decision is finalized.


11. Acceptance Criteria ​

This test plan spec is accepted when:

  • [ ] All MVP acceptance criteria (A–H) have at least one E2E scenario.
  • [ ] All security acceptance items have at least one E2E scenario.
  • [ ] Every scenario links to at least one spec document.
  • [ ] Traceability matrix is complete (scenarios ↔ acceptance ↔ milestones).
  • [ ] Scenario template is defined and all entries follow it.
  • [ ] AI update rules are documented and cross-linked to workflow spec.
  • [ ] Environment requirements match baseline (native macOS arm64/Intel x64, clean profile).

UI shell visual scenarios ​

US-UI-01 Codex-aligned shell chrome ​

  • Open the desktop app on macOS dark theme.
  • Expect charcoal main surface (#181818), left sidebar with current-project and Temporary session groups, and a floating bottom composer with mode/model controls and no workspace rail.
  • Expect no blue-slate marketing chrome; primary send control is a circular inverted button.

US-UI-02 Empty thread hero ​

  • Open or create a thread with zero messages.
  • Expect the centered hero copy "What can I help you build?", a short muted supporting line, with no developer starter cards. The optional project name remains a dotted-underline action when a workspace is open.

US-UI-03 Sidebar destinations ​

  • Expect the expanded home sidebar to show Sessions and Projects without standalone Plugins or Scheduled rows.
  • Click the plug-shaped Plugins icon in the sidebar footer, immediately to the right of Settings, and expect it to replace the main pane with a dedicated page.
  • Open Settings → Project archive and use it to open, switch, and close a local folder workspace.

US-UI-04 Composer without workspace context ​

  • With a git workspace open, composer does not show project, Local, or branch labels above the prompt surface.
  • Operating-mode selector switches between Agent, Plan, and Goal; both contract modes keep the permission-mode chip and explain their Bash tradeoff.

US-UI-05 Locale chrome ​

  • On a zh-CN system locale, sidebar labels render in Chinese (项目 / 临时会话), with no extra standalone destination rows. The footer plug-shaped Plugins icon exposes the localized accessible name 插件.
  • Empty-thread hero and supporting line are localized Chinese copy; project name remains a dotted-underline action when a workspace is open.
  • Composer omits the 本地 workspace label and shows Agent/Plan/Goal plus the active model ID; both locales expose the Plan and Goal approval copy.

US-UI-06 Session title plugin ​

  • Create a new task and send a first prompt such as "同步代码".
  • The core keeps the localized default title while the prompt is sent and after completion. When the Session Titles plugin is enabled and authorized, it may generate a title after the first completed turn using its configured prompt, model, and thinking level. Without the plugin, the title remains the default.
  • Rename the session manually and complete another turn. The plugin must not replace the user-defined title.

US-UI-08 Shortcut-only destination history ​

  • Navigate Settings → Project archive → a project session → Plugins.
  • Expect no back/forward buttons in the expanded sidebar or main titlebar.
  • Press Cmd/Ctrl+[ and Cmd/Ctrl+]; expect them to traverse that history.

US-UI-09 Default session title persistence ​

  • Open an older session that showed "New task"/"New chat" and has a first user message.
  • Without the title plugin, its scoped sidebar row keeps the default title; with the plugin, the title changes only after the first turn completes and the plugin is authorized.

US-UI-11 Empty draft reuse ​

  • Click New task twice.
  • Expect only one empty "New task" draft in the current project or Temporary group and the home hero remains visible. Empty drafts in another scope are not reused.

US-UI-12 Composer without workspace rail ​

  • On empty home, project home, and in a thread, expect no project / Local / branch context rail above the composer.
  • The prompt shell remains one uninterrupted rounded surface with no reserved rail height, attached top lip, rail shadow, bottom seam, or separators.

US-UI-13 Light theme shell parity ​

  • Set theme to system/light on a light macOS appearance.
  • Expect sidebar #f3f3f3, main #ffffff, text #1a1c1f, white floating composer, and home hero with project underline.
  • Sidebar project/session labels, footer Settings/Plugins/notification icons, current-project identity, thread titles, and composer controls must remain readable dark-on-light (≥4.5:1). Never white/translucent text on the light sidebar.
  • The macOS traffic-light row keeps Collapse sidebar readable at the right on light chrome without rendering the Logo/Home brand.

US-UI-14 Semantic chrome tokens ​

  • Toggle theme system → light → dark without restart.
  • Shell chrome (sidebar items, composer runtime controls, icon buttons) follows semantic --ds-text-* / --ds-bg-* tokens in both themes; no hard-coded white (gray-0) text on light surfaces.

US-UI-15 Codex density + elevation ​

  • Sidebar rows use a compact ~28–32px pitch with the 12–14px hierarchy from US-UI-69 and 8px horizontal padding (Codex radius-token-row 10px).
  • Floating composer uses Codex elevation-prominent: 0.5px stroke + soft 3px/20px shadow (not heavy 10–30px drop).
  • Empty hero title is 28px / 34px line-height, weight 400.
  • Window restores ≥1000×700 (target 1200×800) if Stage Manager collapses it.
  • On the light/dark home shell, the sidebar footer is a transparent utility band with no separator. Settings, Plugins, and notification actions are grouped on the left, while the build/version chip is right-aligned.
  • The notification Bell remains visible in the left action group with its unread badge and opens the inbox above the footer; the main titlebar has no duplicate Bell.
  • Clicking the build/version chip checks for updates when current, or opens Settings → Info when an actionable update is available.
  • Traffic lights sit at Codex {x:16,y:16} with a 46px toolbar; the expanded macOS sidebar places Collapse sidebar at the right in that same row, with no Logo/Home brand or back/forward buttons.
  • On empty chat home, the 100px HomeMascotLogo GIF renders above the title as an eight-frame waving mascot with a short idle hold. Light and dark themes each use a dedicated GIF and still PNG.
  • Pointer hover does not change the cadence or geometry; reduced motion shows the matching still first frame. The mascot remains decorative.
  • Title is 28px / weight 400; active project name uses dotted underline (1px, offset 4px).
  • Composer does not render attachment or appshot controls before their payload reaches pi end to end.

US-UI-18 Composer has no inert actions ​

  • On chat home and a docked thread, inspect every composer control.
  • Expect no file, photo, or appshot controls while those payloads are unsupported by the pi runtime. Exact reasoning-capable models expose the current Thinking level immediately to the right of Agent / Plan / Goal; unsupported models show no trigger. Unknown compatible models can explicitly enable thinking from the model menu, and changes update the durable session.
  • Expect no project, Local, or branch context labels in the composer.
  • Every visible composer control changes the active session, opens its menu, or submits/aborts the current turn.

US-UI-19 Permanent Stage Manager bounds restore (macOS only) ​

  • On macOS with Stage Manager, shrink or unfocus the PI window until width < 800 or height < 560.
  • Expect the shell to re-assert a Codex-like footprint (~1200×800, min 800×560 capped to the display work area) and keep restoring while still collapsed (not only during the first 20s after launch).
  • The recovery watchdog is macOS-only (D447). On Windows/Linux it must not run at all: the app must never re-layer or re-raise its own window unprompted. Focus another window, then confirm Nib stays behind it instead of jumping back to the top of the stack, and that a stacking check (xprop -root _NET_CLIENT_LIST_STACKING) never shows it returning to the top periodically.
  • Windows regression: trigger tiny-bounds recovery through show, focus, or restore, then Alt+Tab to another app. Nib must stay behind that app immediately and after four seconds. Ordinary focus must not resize or raise it; hidden/minimized windows keep their state. Recovery must never enable always-on-top off macOS.
  • Automated lifecycle coverage: window-topmost-lifecycle.test.mjs executes the production recovery handlers and watchdog with controlled time, including the unchanged macOS temporary pin and release. Native Alt+Tab validation remains a separate platform check.

US-UI-20 Dark floating composer box ​

  • Switch to dark theme on chat home.
  • Expect main #181818, sidebar #000000, and the floating composer plate at elevated-primary (#212121f5 / gray-800 96%) with elevation-prominent stroke + soft lift so the box reads against the main surface.

US-UI-21 Composer model menu configures pi ​

  • Create a session with provider A/model A, then open the Composer-right model × reasoning menu.
  • Expect the top bar to show only the task title and window actions. The Composer-right model × reasoning chip shows provider A/model A and the current reasoning level. Its menu starts with only Model and Reasoning level entries; Model opens the searchable provider-group list and Reasoning level opens the capability-filtered radio list in the same popover.
  • In the searchable provider-group list, expect each provider heading to read as the visual parent: its --text-md treatment is stronger than the indented model rows' normal-weight --text-sm treatment. In zh-CN, headings must not force uppercase or wide Latin tracking.
  • Select provider B/model B, send a prompt, and expect the main-to-sidecar agent.prompt payload and pi runtime to use B for that session.
  • Switch away and back; expect B and its clamped reasoning level to remain selected. Selecting a model or reasoning level returns to the root without closing the Composer menu; outside click and Escape close it. While a turn runs, expect the combined control to remain available only for next-turn configuration unless a pending approval gates it.
  • On the sidebar footer, click the Custom / Local profile trigger.
  • Expect a 280px opaque elevated menu 8px above the footer. It repeats the local identity in a non-interactive header, then shows a divider and Settings, Logs, and Theme actions in that order.
  • Arrow keys wrap through the three actions; Home/End jump to the boundary. Escape closes the menu and restores trigger focus. An outside pointer press closes it without stealing target focus.
  • Settings navigates to the settings page, Logs opens local logs, and Theme cycles the current theme after closing the menu.

US-UI-23 Project archive index ​

  • Open Settings → Project archive.
  • Expect the Settings title "Project archive" plus the one-workbench composition (D168/D267): a quiet intro line carrying the page description only, with no hero block, gradient banner, or page-level counter run; one toolbar with a Recent / Name sort segmented control, a search field, its clear affordance, the live match count, and the primary "Add project"; and one panel whose sections run Pinned, All projects, Archived as in-panel header strips, each present strip showing its label and row count. With no project at all the panel shows one quiet empty state with its own primary action.
  • Expect each row to carry a colored glyph, the project name with its Active / Open / pinned / Archived tags, one meta line with the shortened monospace path, branch, and session count, a relative last-active time, and the hover-revealed New task and row-menu actions. Ordinary rows use a Folder glyph; pinned rows use a filled Star glyph and retain the pinned text tag. Archived rows stay listed and softened rather than hidden.
  • Expand a non-active project and open one of its sessions; expect the app to activate that project before selecting the session, so workspace tools and session scope use the same project.
  • Switch the sort to Name and expect rows to reorder inside every section with no row hidden; clear the search and expect the complete index back.

US-UI-24 Settings full-page shell ​

  • Open Settings (footer profile → Settings).
  • Expect full-page Codex settings (no app sidebar/nav). Left rail has Back to app, search, and exactly General / AI / Shortcuts / Instructions / Models / Skills / MCP / Subagents / Projects, Cloud sync / Remote Hosts / Info in that order; packaged builds show both Cloud sync and Remote Hosts with Experimental badges. The content pane shows the selected destination.
  • Return to the app shell and expect Plugins to remain an independent sidebar-footer destination.
  • Drag the empty 46px top band over either the rail or content pane; the native window moves while Back, search, and navigation remain clickable.

US-UI-27 Dark destination pages ​

  • Force dark theme and open Plugins and Settings → Project archive.
  • Expect black sidebar, main #181818, and destination cards/rows readable on elevated dark plates (not flat same-gray).

US-UI-28 Home empty composer association ​

  • On empty chat home (light + dark), expect the hero, optional onboarding checklist, and home composer in one scrollable vertical flow (D111/D204/D206), without a large empty gap or starter-card layer.
  • The composer remains a standalone plate without an attached workspace rail.
  • Starting a transcript restores the bottom-docked composer with fade veil.

US-UI-29 Light composer plate legibility ​

  • On light theme empty home, the white composer shell uses one uniform solid fill with no internal gradient or background image.
  • The shell still reads as an elevated box through a hairline stroke and restrained soft shadow against the #ffffff main surface.
  • Toolbar controls and placeholder remain legible (not pure white-on-white).

US-UI-30 Composer placeholder copy ​

  • Empty home and session composers start with their localized welcome copy: chat.placeholderHome / chat.placeholder.
  • The selected guidance stays unchanged until the page/session context changes; switching context advances to localized //@ command/file guidance and the keyboard hint Shift+Enter for newline · Use Send to submit, with an opacity fade.
  • Waiting, focusing, editing, clearing, or composing does not change the copy.
  • Placeholder ink is legible on light and dark floating plates.

US-UI-31 Home empty vertical stack (D111/D204/D206) ​

  • Given empty chat home, when the window is ~1200×690, the hero and optional onboarding checklist render in a centered scrollable content stack above a bottom-reserved home composer (not dual-grow absolute portal regions).
  • No starter cards or absolute overlay are present; the onboarding checklist remains actionable and the composer is directly available.

US-UI-32 Dark floating box elevation ​

  • Given dark theme empty home, when the composer shell is painted, it uses elevated-primary #212121 on #181818 with elevation-prominent stroke+lift identical to light (no heavier custom dark shadow).

US-UI-33 Scoped sidebar session groups ​

  • The home sidebar has no Recents aggregate.
  • It shows one independently collapsible header per retained project path with nested sessions and one Temporary sessions / 临时会话 header for path-less sessions.
  • Project and Temporary headers expose compact scope-specific + controls; project/session overflow menus expose pin/archive actions; nav row pitch remains ~32px and session row pitch ~28–31px.

US-UI-34 Home has no developer starter cards (D206) ​

  • On empty chat home (light + dark), no developer starter grid, card, or contextual quick-action row renders between the hero and composer.
  • Task entry starts directly in the bottom composer, while the optional onboarding checklist remains actionable when present.

US-UI-35 Empty composer plate density ​

  • Empty-home composer is compact and content-driven with an empty or one-line draft; it does not reserve the former fixed ~148px empty plate.

US-UI-36 Hero Y + night box elevation ​

  • At ~1200×690 light home, the hero forms one centered block; the home scroller does not clip its top or overlap the bottom composer, and no starter grid is rendered.
  • Dark home composer plate reads as elevated-primary #212121f5 with elevation-prominent against #181818 (not flat same-surface).
  • Light composer renders as one uninterrupted solid surface with no context rail or independent top elevation.
  • Model chip shows the active model ID; its menu contains only runnable provider/model choices and Agent.
  • Placeholder and approval chip remain legible on light and dark plates.

US-UI-39 Home mark + hero title optical ​

  • Empty-home Nib mark is visible (not near-invisible); stroke density remains readable without a decorative ghost effect.
  • Empty-home title with a project uses a readable project label span (short basenames may display as Nib for optical parity).

US-UI-40 Home content width vs rem root ​

  • At 1200×690 light empty home, composer plate outer width is ~744–760px (not ~640px).
  • Home suggestion grid spans the same content column as the composer plate.

US-UI-41 Dark hero + night box readability ​

  • Dark empty home hero title ink is light-on-dark (--ds-text-primary / near white), not hardcoded #1a1c1f.
  • Night composer plate is elevated-primary #212121f5 on main #181818 with elevation-prominent; light theme is not forced to the night plate fill.

US-UI-42 Light scoped session creation chrome ​

  • On the light sidebar, the Sessions and Projects scoped create controls remain icon-only with semantic hover wash; no standalone New task row is rendered.

US-UI-43 Empty home plate Y + night elevated-primary ​

  • Open empty home at ~1200×690 light theme.
  • Composer plate is bottom-aligned and content-driven: an empty or one-line draft uses the compact shell rather than a fixed ~140px minimum; the surface remains uniformly solid with no decorative wash.
  • Switch dark theme: night plate is elevated-primary (#212121f5 / gray-800 96%) with the same restrained elevation and no internal gradient.

US-UI-44 Settings compact directory + merged sections ​

  • Open Settings light theme at ~1200×690.
  • Full-page shell: rail ~260px on #f3f3f3, main #fff; search pill at the rail top; Back to app pinned at the rail foot and vertically centred on the main shell's sidebar footer icon line; General active pill with icon.
  • Rail order is exactly General / 常规, AI, Shortcuts / 快捷键, Instructions / 指令, Models / 模型, Skills / 技能, MCP, Subagents / 子智能体, Import / 导入, Projects / 项目, and Info / 信息; the rows are grouped under muted Preferences / 偏好, Agent / 智能体, Workspace / 工作区, and System / 系统 headings without divider lines, duplicate destinations, or placeholder rows. The selected page keeps its descriptive title, such as Model configuration or Project archive.
  • General content: large title and an Appearance card with working system/light/dark controls. 全局 AI holds Permissions, Defaults, and the Command shell row; Context management has no settings card. Shortcuts holds the Keyboard shortcuts card. File-open target, language override, menu-bar behavior, and bottom-panel behavior are absent until host-backed implementations exist.
  • Model configuration contains the default model selector, separate vendor account management with add/edit dialogs, and card-based AI service management with an add-provider dialog. The Defaults card reuses the compact geometry of every other settings row: the "Default model" label sits above the provider - model id line, and the quiet Change action remains separate from the current value. Change opens a floating listbox anchored to it: the card's height never changes, the model-level list is grouped by provider and scrolls once the configured models exceed its bounded height, it flips above the trigger near the viewport bottom, it is not clipped by the settings panel, and Escape, an outside press, or scrolling the trigger out of view closes it while focus returns to Change.
  • Plugin load/enable/disable/uninstall remains available from the app shell's independent Extensions destination; its Marketplace tab always uses the official catalog, so Settings has no duplicate Extensions destination.
  • Dark: rail #000, main #181818, cards elevated #212121.

US-UI-38 Composer workspace context omitted ​

  • On empty home, project home, and after starting a transcript, the composer never renders a project / Local / branch capsule.
  • Workspace identity remains visible through the home hero or sidebar rather than being duplicated above the prompt.

US-UI-37 Empty draft row + resize ​

  • Empty composer prompt rows show no leading brand icon and retain visible placeholder ink (not a blank white/night hole).
  • Auto-resize never collapses empty textarea below ~28px.
  • Disabled send control is a solid gray chip on light (#8e8e90), full opacity with white arrow.
  • Dark night plate remains elevated-primary #212121f5 with readable elevation-prominent on #181818.

US-UI-31b (superseded) ​

  • Superseded by US-UI-31 home empty vertical stack (D111).

US-UI-45 Composer width remains stable when the minimap appears ​

  • Open empty home and record the composer plate width. Open a short transcript that fits one viewport, then grow it until the left-edge conversation minimap appears.
  • The empty-home and docked composer plates use the same horizontal gutter and maximum content width. Their shell, textarea, placeholder alignment, toolbar padding, minimum input height, theme fill, and shadow are visually identical; only the parent placement and localized welcome copy differ. The minimap remains out of flow on the transcript's left edge and does not squeeze or resize the composer.
  • When transcript content first overflows, the native scrollbar gutter is already reserved on both sides, so the transcript and composer keep one horizontal center instead of jumping left.

US-UI-46 Home-with-project composer chrome ​

  • Open a project on empty home (no transcript).
  • Expect no workspace controls attached to the plate; there is no legacy draft mark, and the placeholder uses the Nib copy.
  • Model chip shows the active model ID; the footer uses the circular local-user glyph, two-line Custom / Local profile identity, disclosure chevron, and separate Help → Settings Info control.

US-UI-47 Projects index parity ​

  • Open Settings → Project archive.
  • Expect the Settings section title, search pill, Add project button, and the complete durable project list including archived rows.
  • Rows expand for recent tasks; activating a project or one of its sessions uses setProject without re-picking via dialog and keeps session/workspace context synchronized. Sidebar pin, archive, and close metadata remains local to the renderer; only the explicit row-menu Delete project action removes a durable Project-archive row, and it removes that project's sessions with it (ADR 0251).

US-UI-48 Home starter glyphs and labels are absent (D206) ​

  • On empty home, no developer starter icon plate, title/description, or starter-card glyph renders in light or dark themes.
  • The hero, optional onboarding checklist, and bottom composer remain the only empty-home task-entry surfaces.

US-UI-49 Scoped sidebar row chrome ​

  • Hover or select a project or temporary session row.
  • Expect restrained title rows with active/hover background and compact overflow actions for pin/archive (not a Recents aggregate).
  • Multiple retained project groups may be visible at once; sessions remain under exact-path groups, while closed-project sessions remain available in Settings → Project archive.

US-UI-50 Destination title scale ​

  • Open Settings → Project archive and Plugins.
  • Expect large section titles (~28px) consistent with Codex destination/index pages.
  • Dark home scoped session-creation controls remain quiet icon actions without a standalone New task row.

US-UI-52 Settings gold chrome metrics (D070) ​

  • Open Settings light General at ~1200×690.
  • Expect ~275px #f4f4f4 rail, single active General pill, Back + search.
  • Expect the working theme selector without inert toggle or open-target rows.
  • Expect Appearance in General and Permissions + Defaults in AI. The rail contains General, AI, Shortcuts, Instructions, Models, Skills, MCP, Subagents, Projects, Cloud sync and Remote Hosts (both Experimental in all builds), and Info. Voice may appear between AI and builds with developer mode on; plugin-contributed destinations follow the core groups. There is no Import destination.
  • Resize between 800px, 1200px, and 1600px widths; the content cards fill the available right pane at each size without changing the rail or introducing horizontal scrolling.

US-UI-53 Settings dark shell (D070) ​

  • Dark theme Settings General: black rail, elevated cards, blue on-toggles, Back returns to chat.
  • Row descriptions use theme-aware secondary text and remain clearly readable on the #212121 card surface; they must not fall back to low-contrast muted ink.

US-UI-54 Toast variants + lifecycle (D085) ​

  • Trigger a success (save provider), an error (run with an invalid key), and an info toast from a test plugin.
  • Expect a top-center stack on an elevated plate with a tinted variant icon (green ✓ / red ! / info) and an X dismiss per card; newest enters at the top-center anchor and pushes older cards down.
  • Success/info auto-dismiss ~4s, error lingers ~8s; hovering a card pauses its countdown; X removes it immediately.
  • With the stack overlapping the frameless titlebar band, hover still pauses the countdown and every X remains clickable instead of dragging the window.
  • Repeating the same action restarts the existing toast instead of stacking a duplicate; stack never exceeds 4.
  • Toast message text is selectable: dragging across a card highlights only its message text and Cmd/Ctrl+C copies it, while the card icon and its X dismiss control expose no selection.
  • Capture rig scenes pi-toasts-light / pi-toasts-dark show the stack in both themes.

US-UI-55 Composer textarea growth (D089) ​

  • In both home and thread-docked composers, an empty or single-line draft displays one visible text line.
  • Enter or paste two through seven visual lines; the textarea grows with the wrapped content without manual resizing.
  • Add an eighth visual line; the textarea stays at seven visible lines and scrolls internally instead of growing the composer further.
  • Delete back to one line or submit the draft; the textarea contracts to its one-line default.
  • With a large workspace open and the @ file menu filtering, type sustained text; the caret keeps up with typing without visible stalls and the menu's row order is unchanged. Growth through seven lines, internal scrolling past the seventh, and contraction on delete or submit behave as above (D264).

US-UI-56 Codex transcript tool activity ​

  • In light and dark themes, tool calls use transparent compact activity rows, not elevated cards or colored success rails.
  • Historical consecutive calls appear inside a default-collapsed processing group. During a live turn, the latest group opens automatically so its process list is visible, but tool-call details stay collapsed. The latest thinking step opens automatically; when the turn settles, only that automatic thinking disclosure closes. A group or row touched by the user keeps its chosen state.
  • Its active header shows Processing · {elapsed} and a localized current action/phase capsule such as Editing, Thinking, or Waiting for model; its completed header shows Processed for {elapsed}, plus a localized step count.
  • The row shows a semantic 15–16px icon, progressive/past-tense action, ellipsized monospace argument hint, quiet disclosure chevron, and localized running/error/denied state.
  • Fork-family tools show the GitFork branch icon instead of the generic tool glyph.
  • Expanding a completed call reveals output before input. Both sections are independently copyable and capped with internal scrolling.
  • Reloading the session preserves the action label and argument hint instead of degrading the row to a generic Tool.
  • Run a turn that emits assistant text, calls multiple tools, and resumes with more assistant text. During streaming and after session reload, expect one assistant article for the whole user turn, with fragments and activity in original order but only one trailing model/usage row and one Copy/Fork/Retry toolbar. Copy includes all assistant text fragments in order.

US-UI-57 Multi-project sidebar groups ​

  • Open projects A and B without closing either.
  • Expect a Sessions heading above Projects, containing path-less conversations plus new-session and sort actions. With more than five standalone conversations, expect a five-row-high list that scrolls to every remaining row without growing further.
  • Right-click the Sessions heading or empty standalone list chrome and expect a one-item create menu that creates/reuses a path-less temporary session.
  • Expect the following Projects heading to retain its new-project folder action, one path-keyed group per retained project, and an active-state marker on exactly one group. Its list consumes the remaining height and scrolls independently. Adjacent project groups read as a compact continuous tree without detached card spacing.
  • Right-click the Projects heading or empty project-list chrome and expect a one-item create menu that opens the same project picker as the folder-plus action.
  • Expect project and session lists to scroll inside the sidebar body without clipping behind the footer; sidebar Collapse remains in the sidebar header. When the work panel is open, expect its sole collapse control to be the viewport-fixed toggle in the window's top-right corner rather than a chevron in the work-panel content header.
  • Collapse A by clicking its directory label, expand it from the chevron area, then activate B and return to A. Only A's child rows collapse; project + and overflow actions do not toggle it; the active project, topbar path, and transcript switch together; the composer remains free of workspace identity chrome.
  • Close B and reopen it from Settings → Project archive. Closing removes only the sidebar tab; durable project/session rows remain available.

US-UI-58 Sidebar organization actions ​

  • Open a project and conversation overflow menu.
  • Expect localized Pin/Unpin, Archive/Restore, and (for conversations) Delete actions with keyboard-reachable menu semantics.
  • Open the sort menu from the standalone Sessions heading, pin one project/session, and choose each user-facing sort mode (Recently updated, Created date, Oldest first, Name). Pinned projects remain first in Projects; pinned conversations appear once in the global Pinned section above Sessions and Projects, with the selected session sort and no date headers.
  • Archive a row, verify it is absent by default, enable Show archived, and restore it. The transcript and project binding remain unchanged.
  • A legacy manual preference loads without presenting a drag-reorder affordance.

E2E-SIDEBAR-global-pinned-conversations ​

  • Seed an old pinned conversation in project A, today's unpinned conversation and eleven other normal rows in A, a pinned conversation in collapsed project B, one in closed project C, and a pinned Temporary conversation.
  • Expect one Pinned section above Sessions and Projects, containing all four pins with their project names or Temporary space label. Pins have no date headers and do not appear a second time in ordinary history. A still shows ten normal rows initially, with its remaining rows behind Load more.
  • Change the date across midnight and select each session sort. Pins remain above history; sorting changes only their internal order. Selecting B or C's pin activates its original conversation and project; selecting the Temporary pin clears workspace context. Running and unread states remain visible.
  • Pin and unpin through the keyboard menu. The row moves immediately and focus follows its overflow control, or returns to the Sessions sort control if the row is now folded or belongs to a closed project. Unpin the last pin and expect no empty Pinned section.
  • Archive a pin and a pinned conversation's project. Both disappear by default; Show archived reveals them and Restore preserves the pin. Delete a pinned conversation and expect no stale row. Reload and expect saved pins to return.
  • With enough pins to overflow, scroll within Pinned and verify that Sessions, Projects, and the footer remain reachable in light/dark themes at minimum supported window size. Existing hover cards, context menus, drag/drop, and project pinning retain their normal behavior.

E2E-PROJECT-delete-removes-project-and-owned-sessions ​

  • Preconditions: three durable projects A, B, and C, each with at least one session that has a transcript; A archived and retained as a sidebar tab; B the active workspace; C a root of a stored two-folder project group.
  • Steps: open Settings → Project archive, choose Delete project for A, and verify the confirmation dialog appears before anything is removed. Press X and verify A remains. Reopen the dialog and explicitly confirm. From the sidebar project menu, repeat for active project B: choose Delete project, cancel, verify B remains, then reopen and confirm. Then attempt the same action for C, a path the host no longer knows, and a fourth project D while one of D's tasks is still running.
  • Expected: choosing Delete immediately opens the dialog for idle projects on both surfaces. X, Cancel, Escape, and backdrop clicks dismiss it without removing the project, sessions, or transcripts. The dialog names the project, states that its sessions and transcripts are removed permanently, and says the folder on disk is untouched. After explicit confirmation, the durable project row, owned sessions, transcripts, scratch and review files, and project memory are gone. The project disappears from Settings → Project archive and the sidebar immediately and after reload, with no retained tab, recent-project entry, session-derived row, or stale pin/archive/order preference. Other projects' sessions and transcripts are untouched. If the deleted project was active, the workspace falls back to another open project or Temporary, and the next launch does not reopen its path. A project whose folder was moved or deleted on disk is still removable. Deleting C is refused with a message and leaves the group unchanged; an unknown host path is removed from the archive and sidebar without a missing-project error. Deleting D while its task runs opens the same dialog with the running sessions named and a stop-and-delete confirm label; cancelling leaves it and its task untouched, while confirming stops exactly those turns before deleting D (see E2E-PROJECT-delete-running-sessions-are-named-and-stopped).
  • Specs linked: 03-runtime/06-host-rpc-protocol.md §Projects, 03-runtime/04-data-storage.md, 04-ux/08-component-spec.md §3.9, ADR 0251
  • Acceptance criterion: D (workspace), F (persistence), Quality
  • Milestone: M6+
  • Status: Partially automated — pnpm test:e2e covers the host contract (project row, owned sessions, on-disk transcripts and scratch, project memory, isolation from other projects, the running-task refusal, the group-root refusal, and the idempotent unknown path), and pnpm test:e2e:boot round-trips pi-desktop/project/remove through the sandboxed preload; the Settings archive → dialog → sidebar journey remains Draft

E2E-PROJECT-delete-running-sessions-are-named-and-stopped ​

  • Preconditions: a durable project D with one session whose turn is still streaming, reachable both from the sidebar project menu and from Settings → Project archive.
  • Steps: from each surface, open D's row menu and choose Delete project without stopping the turn. Expect the confirmation dialog with a running-session line and a stop-and-delete confirm label; press Cancel and expect nothing to change. Open the dialog again and confirm.
  • Expected: the menu never replaces the dialog with a bare warning, so the action stays reachable while a task runs. The dialog keeps naming the project, its session count, and the untouched folder; while a turn is live it also names how many sessions are still running, its confirm button reads as stopping them, and that line joins the dialog's aria-describedby only while it is rendered. Cancelling deletes nothing and leaves the turn streaming. Confirming stops exactly the listed sessions and only then removes the project, its sessions, their transcripts, and its durable memory, leaving the folder on disk. A turn that starts between the dialog opening and the confirmation is still refused by the host, and the dialog reports that refusal with project.deleteRunningBlocked while removing nothing.
  • Specs linked: 03-runtime/06-host-rpc-protocol.md §Projects, 04-ux/08-component-spec.md §3.9, ADR 0251, D421, D431
  • Acceptance criterion: D (workspace), Quality
  • Milestone: M6+
  • Status: Partially automated — apps/desktop/test/project-delete.test.mjs checks that both menu handlers always open the confirmation dialog without deleting, and that X/Cancel only dismiss while the confirm handler owns the delete call; the rendered Settings/sidebar journey remains Draft

E2E-SESSION-two-click-delete-arms-first ​

  • Preconditions: a project with one idle and one running session, both reachable from the sidebar session menu.
  • Steps: open the idle session menu and press Delete once. Verify it arms, then cancel with Escape or wait for expiry and verify the session remains. Reopen the menu, press Delete twice, and verify the session is removed. Repeat the arm/cancel/confirm sequence for the running session after it becomes idle.
  • Expected: the first press removes nothing and relabels the item to nav.deleteTaskConfirm ("Delete?" / "确认删除?") with data-armed="true"; the menu stays open, and an outside press, Escape, or expiry clears the arm without removing anything. Only the second press removes a session and its transcript. Project deletion instead requires its explicit confirmation dialog (see E2E-PROJECT-delete-removes-project-and-owned-sessions).
  • Specs linked: 04-ux/09-interaction-patterns.md §1.6, D421, D431
  • Acceptance criterion: Quality
  • Milestone: M6+
  • Status: Partially automated — apps/desktop/test/two-step-delete.test.mjs pins the session arm and expiry plus localized labels; the end-to-end journey remains Draft

US-UI-59 Session-rooted background tools ​

  • Start a visible turn in project A, switch to project B while it runs, and inspect both sidebar status indicators.
  • Expect A's turn to continue in the background, B's composer/context to show only B, and tool output/artifacts from A to remain rooted in A without opening or activating a work-panel tab over B.
  • Open a Temporary session and invoke a workspace-required tool; expect the normal WORKSPACE_REQUIRED result rather than inheritance from B.

US-UI-60 WorkBuddy transcript plates (D101) ​

  • Open a mixed transcript in light and dark themes.
  • Expect right-aligned compact user plates, transparent full-width assistant prose, denser row spacing, and hover-only copy chips under each turn.
  • While an assistant answer streams, expect the same transparent full-width prose as a completed turn — no left rail and no whole-turn tile. The tile belongs only to a subagent/delegation card (D319, D323).

US-UI-60b Assistant markdown prose redesign ​

  • Open an assistant answer with headings, table, code fence, blockquote, and task list.
  • Expect the refined .prose-chat hierarchy and inset code chrome in both themes.
  • Expand thinking markdown and confirm it stays visually subordinate to the answer.

US-UI-60c Compact assistant error card ​

  • Trigger a retriable provider/model failure in the transcript in light and dark themes.
  • Expect the assistant error to use a restrained inline surface with a thin error rail. The localized summary, stable code, and details disclosure share one compact header; the card does not render a second bottom action row.
  • Confirm the details remain expanded on first render, keep the redacted provider response and provider/model IDs, and expose an icon-only copy control with an accessible label/tooltip. On a narrow window, header actions wrap without horizontal overflow. At default and widened chat content widths, the card fills the assistant column; resizing the content band makes the card follow it, and a constrained pane does not overflow.
  • Expect the compact assistant error card itself to expose one localized Continue action beside the details disclosure. Click it and expect the app to append the localized continuation prompt (Continue the current task / 继续当前任务) to the same session and start the next turn without truncating the failed turn.
  • For a terminal PROVIDER_RATE_LIMITED (including HTTP 429), expect the structured assistant error card to remain the only failure surface: it exposes exactly one localized Continue action and no Regenerate action, while the generic TurnOutcomeCard is omitted.
  • Click Continue and expect the app to append the localized continuation prompt (Continue the user's unfinished task. / 继续用户未完成的任务) to the same session and start the next turn without truncating the failed turn.
  • Activate the localized Dismiss action. Expect the error card to disappear from the current transcript view while its underlying error message and raw diagnostic data remain unchanged; navigating away and back in the same app runtime keeps the dismissal.

US-UI-61 Assistant context summary + retry (D103, D184, D244, D347) ​

  • Complete an assistant turn that reports usage.
  • Expect a model badge under the answer and the compact Context inspector in the composer toolbar, left of the model picker. The trigger shows the remaining-capacity ring and percentage; clicking it (or activating it from the keyboard) shows remaining tokens plus percentage, used/window counts, two unboxed turn/speed values, one inline exact provider-usage summary, and one aggregate tool-usage summary with types, calls, and approximate tokens. Per-tool rows, bars, badges, explanatory estimate copy, and inner section hairlines are not shown.
  • Hovering the trigger changes nothing; the open panel closes on a second activation, an outside click, or Escape.
  • Scroll or resize while the panel is open; expect the body-level overlay to flip, clamp, and remain fully visible instead of being clipped by the composer or transcript.
  • Hover the action row and click Retry; the nearest preceding user prompt is re-sent.

US-UI-62 In-place regenerate (D105) ​

  • On a multi-turn transcript, regenerate an earlier assistant answer.
  • Expect the later turns to disappear and the chosen user prompt to re-run in place, without stacking a second copy of the prompt.

US-UI-63 Regenerate history pager (D109) ​

  • Regenerate an assistant answer twice.
  • After each retry, hover or focus the root user bubble and expect its action toolbar to expose a 1/N pager for restoring earlier variants.

US-UI-64 Empty home no composer overlap (D111/D204/D206) ​

  • Open empty home at ~1200×690 and at a shorter height (~900×640).
  • Expect the hero and optional onboarding checklist in a scrollable content region, with the home composer visibly reserved at the bottom and no starter cards.
  • Short windows scroll the content region rather than stacking the composer over the checklist; when the checklist is absent, no empty spacer remains.

US-UI-65 Durable notification inbox (D117/D130) ​

  • Verify a focused-current completion leaves the inbox unchanged, then populate it through background/unfocused completed and failed task rows, including one long session title. Inspect the expanded sidebar footer and popover in light/dark themes at default and narrow supported widths.
  • Expect no titlebar bell, a stable 32px footer bell in the former Help position, a non-overlapping 1–99 / 99+ badge, dense 360px-or-narrower list, localized kind/session/time/error content, and distinct text/icon/unread-dot semantics without nested cards or clipped text.
  • Switch All/Unread; use Tab, arrow keys, Home/End, Enter/Space, Escape, and outside click. Focus order remains predictable, row activation opens the correct session, and Escape restores focus to the bell.
  • Mark all read and Clear expose icon tooltips/accessible names, disabled and empty states remain understandable, and reduced-motion mode changes the popover instantly without suppressing focus or unread state.
  • While a task banner is pending, mark its row read and then clear the inbox; inject a delayed duplicate event afterwards. The old row, sidebar mark, and native banner stay dismissed, while a new post-clear task appears once.

US-UI-66 Application update notice layout ​

  • In a conversation with the docked composer visible, exercise manual available, in-app downloading, and downloaded update fixtures in light and dark themes at default and minimum supported window sizes. Grow the composer draft to its maximum visible height.
  • Expect one compact update notice below the titlebar in the main pane's top-right safe area. It never intersects the composer, including while the draft grows, and it does not cover an open work panel.
  • Expect a stable update icon/title/message hierarchy, determinate progress for downloading, the applicable View release or Restart to update action, and a 24px dismiss control with an accessible name. Dismissing one status stage does not suppress a later stage for the same version.
  • When the fixture includes releaseNotes, expect a "What's new" section under the status message on both the banner and Settings → Info Updates row, using the product UI locale (EN or zh-CN). A fixture without notes omits the section. Switching language re-resolves the same version's notes without a new check.

US-UI-67 Distinct sidebar task status indicators (D135) ​

  • In light and dark themes, keep session B selected while session A progresses through in-progress, completed, a new in-progress turn, failed, and aborted states. Repeat with reduced motion enabled and inspect keyboard focus.
  • Expect A to show an orange dot that breathes twice over 3.2 seconds and then stays steady while in progress, a green check on completion, and a red circled alert on failure. Starting a new turn clears A's earlier terminal mark; abort leaves no completed or failed mark.
  • Expect selected idle B to show a static accent-blue outlined ring and active row background. If selected B starts work, its orange in-progress dot takes precedence until the turn settles; its latest terminal result remains hidden behind the selected ring while selected.
  • Every indicator exposes localized In progress / Selected / Completed / Failed text through its accessible name and tooltip. Reduced motion makes the orange dot static without changing its color or meaning. Row height, title truncation, pin icon, hover actions, and focus ring remain stable in both themes.
  • Open a conversation with a completed or failed mark and expect that terminal mark to clear immediately while its durable task notification becomes read. Refresh notifications and restart the app; the acknowledged mark must not return. A terminal notification marked read from the inbox likewise produces no sidebar terminal mark.
  • Repeat for a running related-session marker in the hover card. After 3.2 seconds, neither running marker has an active animation; recording an otherwise idle native macOS window must show no continuous frames from these markers. Start a new turn and reopen the hover card: the bounded animation can play again. GPU measurements must distinguish app frame submissions from whole-system load.
  • Repeat with the three idle-reachable indicators, one per idle window: leave a session parked on a tool approval request, turn plan mode on, and raise a backend warning banner. After 3.2 seconds none of the permission dot, the plan-mode chip, or the warning dot has an active animation, and recording an otherwise idle native macOS window shows no continuous frames from them. Under reduced motion all three are static from the first painted frame.

US-UI-68 Session-scoped inline permissions and artifacts (D138/D142) ​

  • Run two sessions concurrently and keep A visible while B reaches a tool approval request. Inspect light/dark themes at default and narrow widths.
  • Expect no backdrop, modal, page/session switch, work-panel hide, transcript replacement, or composer-focus change in A. B retains its pending state.
  • Open B explicitly and expect one inline permission card after B's latest activity, with readable risk, args, workspace, and wrapping action controls. Switching away and back preserves the pending request without a deadline.
  • Make A and B pending together, resolve each independently, and confirm neither action removes or changes the other card.
  • Resolve A's Write/Edit permission and switch to B before completion. Expect no transient Review panel in B and no panel/window flash; returning to A restores A's prior panel selection with the inline review card in its transcript — the edit opened no tab — while B's tabs and Browser resource remain unchanged.

US-UI-69 Sidebar type balance (D144/D161) ​

  • Open the expanded sidebar in light and dark themes at default and minimum supported widths with at least one session, one project group, and the local profile footer visible.
  • Expect Plugins, footer profile name, and profile menu actions to render at the body chrome size (--text-base / 14px).
  • Expect session/thread titles, project/group titles, and empty-state copy at --text-md / 13px, with uppercase section labels (SESSIONS / PROJECTS) at --text-sm / 12px — never below --text-md for primary list content.
  • Confirm row pitch remains compact (≈28–32px), titles still truncate cleanly, and collapsed icon-rail controls stay legible without reflowing the shell.

US-UI-70 Disable text correction on editable fields (D145) ​

  • Open empty home, a docked transcript, Settings search, Plugins market search, Projects archive search, global search (which now includes commands), provider model combo, message-edit textarea, and the work-panel browser URL bar in light and dark.
  • Expect every text input/textarea to expose spellcheck="false" (React spellCheck={false}) plus autocorrect="off" and autocapitalize="off".
  • Expect no red spelling underlines while typing code-like tokens, paths, model ids, or URLs; checkboxes and non-text controls remain unchanged.

US-UI-71 Composer runtime chip descenders (D150) ​

  • Open empty home and a docked thread with a model ID that contains descenders (for example gpt, gemini, or any id with g/y/p/q/j).
  • Inspect Agent/Plan/Goal, Thinking (when present), permission mode, and the model chip in light and dark.
  • Expect every chip label to show full glyph ink — bottoms of g/y/p are not clipped by the 28px capsule — while long model IDs still ellipsize horizontally.
  • Specs linked: 04-ux/07-ui-design-system.md §8.2, 04-ux/08-component-spec.md §11.5, decisions-log D150
  • Milestone: M5
  • Status: Partially automated (renderer source test: chip line-height + no leading-none)

US-UI-72 Apple-inspired global corner hierarchy (D210) ​

  • Open the empty home, a populated transcript, Settings, Plugins, Project archive, a menu, and a dialog at the default supported desktop size in light and dark themes.
  • Expect fixed corners to follow the global 4/6/8/10/12/14/16/18/20/24px ladder, with visually larger or more elevated surfaces receiving the larger radii.
  • Standard compact and medium buttons and fields remain rounded rectangles, not capsules. Pills, segmented selections, status labels, progress tracks, switches, equal-width circular icon controls, and dots retain their explicit capsule or circle shape.
  • Where a rounded child sits against a rounded parent corner, expect the radii to read concentrically with the intervening inset. Full-width sidebar, titlebar, and work-panel edges remain square rather than becoming floating cards.
  • Resize to the minimum supported window and inspect menus/dialogs near each edge. Rounded surfaces must not clip text, focus rings, actions, or scrollable content.
  • Specs linked: 04-ux/07-ui-design-system.md §6.2, ADR 0071
  • Milestone: M5
  • Status: Partially automated (radius token and shared-control source test)

US-UI-73 Composer mode selector has stable width ​

  • Open the empty home and a docked thread in both English and zh-CN.
  • Open the Composer mode chip and switch between Agent/Plan/Goal several times.
  • Expect the chip to list the three modes as checked menu items, to name only the committed mode, and to keep one fixed width sized for the longest built-in label (English "Agent" / zh-CN "智能体") plus its caret; the adjacent Thinking and permission controls, send button, and composer shell do not move or resize.
  • In Goal, expect the permission chip to remain visible with the same geometry, show Full auto / 全自动, and stay disabled without opening a permission menu. The Plan/Goal approval card remains the separate execution-policy control.
  • Specs linked: 04-ux/08-component-spec.md §11.3
  • Milestone: M5
  • Status: Partially automated (renderer style/source contract)

US-UI-74 macOS native sidebar vibrancy ​

  • Open the desktop app on macOS in both light and dark appearances with the sidebar expanded, then exercise the existing collapse/expand path.
  • Expect the main window to use native sidebar vibrancy with a thin theme tint behind .sidebar and any rendered .sidebar-rail: the material follows the app theme (nativeTheme.themeSource), so a dark shell stays on a dark plate and a light shell stays on a light plate. Desktop content stays perceptible through the material and the surface carries a top-to-bottom sheen rather than a flat fill. The dock carries no seam or hairline — the glass meets the opaque main pane flush, so no hard divider separates the two panes. Switching language or other non-theme settings does not rebuild the glass. Disabling or uninstalling a selected plugin theme returns native chrome to system.
  • Expect .main-pane, .main-titlebar, and .conversation-topbar to remain solid theme surfaces without whole-window transparency or a strong artificial blur/card treatment. Sidebar collapse/expand, resize, traffic-light placement, and drag/no-drag hit regions remain unchanged.
  • Specs linked: 04-ux/08-component-spec.md §1.7, §3.4; decisions-log D304 / D348
  • Milestone: M6
  • Status: Partially automated (macos-sidebar-vibrancy.test.mjs source contract); native visual verification Draft

US-UI-75 Light message-edit plate ​

  • Open a populated transcript in light and dark themes and choose Edit on a user prompt.
  • Expect the inline editor to be a --ds-tile-deep plate at --ds-composer-radius with no outer shadow and no hairline stroke. On light theme the 8% ink wash must remain distinct from the #ffffff chat surface; the composer glow must not appear or get clipped at the plate edges. Retry and Cancel stay legible in the footer.
  • Focusing the textarea paints an inset 2px accent ring inside the plate; Escape or Cancel restores the bubble.

E2E-123: asktool renders rich options, reminds the user, and returns answers ​

  • Preconditions: Agent, Plan, or Goal mode; a configured provider; a session with an active transcript.

  • Steps: 1) Ask the agent to call asktool with a single-select question, a multi-select question, and question/option text with Markdown emphasis, inline code, and a short list. 2) Confirm each card renders the formatting while keeping choices keyboard/selectable. 3) Answer the first question, click Next, and select two answers on the multi-select question. 4) Skip the final question without entering text. 5) Expand the completed tool row and inspect its question-and-answer summary, then inspect the next model response.

  • Expected: One question is visible at a time; the small indicators show answered, current, and skipped states in the composer approval area, at the same dock position used by Plan and Goal approval. The request has no countdown. Question text and options render at the compact card body size (--text-md, one step below the surrounding chat body), matching the permission card scale in the same dock area. The card shell uses the slim rail (2 px accent, 14 px × 16 px padding). Option rows and action buttons use the app's compact control density (30 px rows, 15 px marks, 8 px row gaps, compact buttons), and the card keeps a relaxed internal rhythm (12/14 px indicator margins, --leading-normal question line-height). The question's option list is bounded to the available viewport height; when a fixture contains enough options to overflow, only that list scrolls and the question header, custom-answer input, and Skip / Next / Submit actions remain reachable. Scrolling the list does not move the conversation page or hide the action row. A long question prompt is bounded the same way: the question body scrolls inside the card (min(208px, 24dvh), with a 24vh fallback) and the header actions stay outside that scroller. The question body is keyboard focusable with a visible focus ring. Next, Skip, and the step indicators open each question at the top of that body. The tool output is ordered as question:answer, uses 、 between multiple answers and \n---\n between questions, and keeps question: for the skipped question. Markdown renders as rich text without activating embedded links or loading images. Selecting a formatted option returns its original Markdown source label. Decline all produces empty placeholders for every question and still completes the tool call. The completed row shows the ordered questions and answers, keeps multi-select labels separate, marks skipped questions with localized copy, and does not expose the structured JSON details. Its model-facing result stays unchanged. A pending ask shows a stable localized title and the first question in its toast; generated session-title text never replaces that title. Background sessions retain the existing native notification policy. Exactly one soft chime plays for the ask; the toast and native banner do not double it. With a right-docked work panel, the toast stays horizontally centered within the main-content safe area and never intersects the panel tab strip, browser navigation controls, or URL field. It follows both safe-area resize and same-width horizontal shifts. Clicking the question toast (including from New task or Settings) opens its originating conversation and project, shows the pending card, and permits submitting an answer for that request. Enter and Space activate the focused message button. Dismiss and text selection do not navigate. Identical prompt text from two sessions preserves two independent navigation targets; ordinary info toasts stay non-interactive. A history-read failure produces a visible error and leaves both the original reminder and question pending. Retrying from that same reminder after the read recovers opens the correct chat and dismisses the reminder only after navigation succeeds.

  • Specs linked: 03-runtime/17-asktool-questions.md, 04-ux/11-asktool-question-card.md, ADR 0077

  • Acceptance: E (interactive tool output), C (inline card)

  • Milestone: M5

  • Automated coverage: pnpm test:e2e:asktool-card mounts the production component in Electron and covers long-prompt scrolling, keyboard reachability, visible header actions, and scroll reset on Next. pnpm test:e2e:question-notification exercises real renderer event handling, safe-area geometry under resize and horizontal shift, toast creation, session/project navigation, and answer submission in isolated Electron with only the IPC boundary stubbed, plus native keyboard input.

  • Status: Partially automated (component paths and long-prompt behavior are covered; full application journey pending)

  • Request transition regression: Keep a one-question request pending in chat B. In chat A, advance a two-question request to question two, then select B. B must show its first question with fresh answer state and a usable composer, without rendering the application error screen. Also queue two requests in one session: submit the first, answer the second differently, and verify both resolutions contain their own request IDs and selected values.

E2E-124: Window controls minimize to the taskbar and close to the chosen surface ​

  • Preconditions: Built desktop app on macOS, Windows, and Linux; English and zh-CN locales are available; a normal main window is open.
  • Steps: 1) On Windows, maximize the focused main window and click its taskbar button; confirm it minimizes while the Nib taskbar entry remains. Click the same taskbar button again; after the restore settles, confirm the window remains maximized within the display work area and the taskbar stays visible. Cover the window with another app, click the Nib taskbar entry, and confirm it comes to the front without entering the tray. 2) On
  • macOS, click the traffic-light minimize control and confirm it hides the window to the tray. On Windows/Linux, use the renderer minimize control and confirm the native taskbar entry remains available; click it again and confirm the same window restores and focuses. 3) Select Close to tray and close the Windows/Linux window; confirm it leaves the taskbar and is restored by the tray icon. Select Quit and repeat; confirm the process exits.
  1. Open the tray menu and choose Show, then repeat with Quit. 5) Repeat in zh-CN and invoke macOS app activation while the window is tray-hidden.
  • Expected: Windows native taskbar toggling and the Windows/Linux renderer minimize controls keep the taskbar entry and round-trip through native minimize/restore. Restoring a maximized frameless Windows window keeps it within the display work area and leaves the taskbar visible. A taskbar click on a covered window brings it to the front. Close to tray is the only Windows/Linux close path that hides the window, while Quit exits. On macOS the menu bar icon is a readable transparent monochrome PI mark without the rounded application tile, and native minimize remains tray-resident. Show/click/double-click/app activation restores the existing window; the localized menu contains Show Nib and Quit Nib. Quit runs the normal shutdown sequence and leaves no orphan host, sidecar, or tray process.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/07-process-model.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 08-meta/decisions-log.md (D216, D230, D252, D256), ADR 0078, ADR 0090, ADR 0117, ADR 0123
  • Acceptance: A (app lifecycle), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered; native cross-platform tray journey Draft (run only in a capable environment when this surface changes)

E2E-150: A second launch surfaces the running app instead of starting one ​

  • Preconditions: Built desktop app on macOS, Windows, and Linux, installed with the default data directory; one instance is running with at least one session in its sidebar.
  • Steps: 1) Launch the app again from the platform's normal entry point (Start menu/desktop shortcut, .AppImage, open -n on macOS) and observe both the window and the process list. 2) Minimize the window into the tray, then launch again. 3) On Windows/Linux with close behavior tray, close the window, then launch again. 4) While the app is running, launch a build with PI_DESKTOP_DATA_DIR set to an empty directory. 5) Still while the packaged app is running, start a development build (the dev script, with no PI_DESKTOP_DATA_DIR). 6) Quit the app, confirm no process remains, and launch once more.
  • Expected: Steps 1–3 never create a second window, tray icon, host-core, agent sidecar, or log file: the existing window is restored and focused, the duplicate process exits, and the running instance's session list, in-flight turn, and pi.sqlite are untouched. Step 4 starts normally as an independent instance against its own data directory. Step 5 also starts normally: the development build takes its own userData and ~/.pi-desktop-dev, so it neither waits for nor disturbs the running packaged app. Step 6 starts a clean single instance, proving the lock is released on exit and never leaves a stale block.
  • Specs linked: 03-runtime/07-process-model.md, 08-meta/decisions-log.md (D236, D599, D002), ADR 0094
  • Acceptance: A (app lifecycle), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered; native cross-platform relaunch journey Draft (run only in a capable environment when this surface changes)

E2E-125: Complete bilingual VitePress documentation remains usable ​

  • Preconditions: Docs dependencies are installed and the VitePress preview server is running from the repository.
  • Steps: 1) Open / at 1440×900 and verify the English landing page, system map, read-by-intent journeys, reference shelf, global search, Guide/Specs/ADRs navigation, and language selector. 2) Switch to /zh-CN/ and verify the translated hero, mirrored topic map, and Chinese specification links. 3) Open /spec/03-runtime/01-ipc-protocol, switch languages, and verify that /zh-CN/spec/03-runtime/01-ipc-protocol contains translated prose, preserved code identifiers, and a link back to the English source. 4) Search in each locale and open a matching result. 5) Repeat the homepage and a long table-heavy specification at 390×844 in light and dark mode.
  • Expected: Both locale entry points and every English/Chinese specification pair render without broken links or page-level horizontal overflow. Landing and reading columns are visually centered within their available layout; the mobile hero presents text before the system visual. Search returns local results. The mobile navigation opens and closes without shifting or obscuring the page. Code blocks and tables remain readable through contained scrolling, theme contrast stays clear, and every Chinese spec identifies the English page as its canonical source. Directly refresh /zh-CN/spec/README, /zh-CN/spec/03-runtime/01-ipc-protocol, /zh-CN/adr/, /spec/README, and /adr/README on Vercel; each route resolves through the documented cleanUrls configuration instead of returning 404.
  • Specs linked: 02-architecture/04-documentation-site.md, ADR 0079
  • Acceptance: Quality, documentation discoverability, responsive layout
  • Milestone: M6+
  • Status: Browser-rendered desktop/mobile verification is authorized for this documentation redesign; remote deployment refresh checks remain Draft.

E2E-126: Appearance card selects a global UI font ​

  • Preconditions: App running on macOS with an installed system font distinct from the built-in token stack (for example PingFang SC); a clean ~/.pi-desktop profile.

  • Steps:

    1. Open Settings → General and confirm the Appearance card shows a Font row below Theme and Language with a trigger labeled "System default".
    2. Open the Font picker and confirm it lists System default followed by installed system families only — no bundled open-licensed families and no license badge on any row; confirm the search input filters families and the current selection shows a check badge; confirm the menu opens as a floating layer above the card (not clipped or squeezed inside it) and stays readable when the card is near the bottom edge of the window; with many installed families, confirm the list opens without an input stall and scrolls immediately (only the visible rows are rendered, with an overscan buffer).
    3. Select an installed system family (for example PingFang SC) and confirm the trigger label and the whole UI re-render in that family without a reload, including CJK fallback rendering for Chinese text.
    4. Reopen the picker and confirm the family is still selected, then select System default and confirm the UI returns to the built-in token stack immediately.
    5. Restart the app and reopen Settings and confirm the default is still applied (the override is cleared, persisting an empty AppSettings.fontFamily).
    6. With a profile whose stored stack names a formerly bundled family (for example "Geist", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif), confirm the Font row shows that family under the Saved group, that no font file is loaded from the app, and that the UI renders through the system CJK fallback tier.
  • Expected: The Font row is a searchable picker whose trigger previews the current family in that face; options are System default and the installed system families enumerated by Electron main via pi-desktop/app/systemFonts (cached 60 s, hidden .-prefixed families excluded); the app ships no font files, so no bundled group and no license badge appear, and a stored stack that matches no option stays listed first under Saved; selection persists as a CSS stack in AppSettings.fontFamily and overrides --font-sans live; Chinese text stays readable through the system-only CJK fallback tier; the menu is a body-level floating layer that is never clipped by the settings card; the option list is windowed with fixed row heights and an overscan buffer so only the visible slice is in the DOM, keeping opening, scrolling, and typing responsive regardless of how many families are installed; System default clears the override by persisting an empty stack.

  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/07-ui-design-system.md, 03-runtime/01-ipc-protocol.md, ADR 0083, ADR 0298

  • Acceptance: A (core shell), H (localization)

  • Milestone: M5+

  • Status: Documented

E2E-193: Appearance card sets a global type scale ​

  • Preconditions: App running with a clean ~/.pi-desktop profile and an open conversation that shows transcript text, the composer, and the sidebar.
  • Steps:
    1. Open Settings → General and confirm the Appearance card shows a Font size row below Font, with Grande selected and the slider at 100%.
    2. Choose Venti. Confirm chat transcript, composer, settings labels, sidebar session titles, and Lucide chrome icons all enlarge without a reload, keeping their relative steps, and that the control shows 115%.
    3. Drag the slider to 125%. Confirm Trenta becomes selected and every --text-* surface and icon grows further. Confirm the UI never shows a px field.
    4. Open the Browser and Files work-panel tabs, then use Zoom In and Reset Zoom. Confirm window zoom still scales chrome, that the type scale remains 125% after reset, and that both docked plugin pages follow it: their text and controls keep the same proportion as the app, and the Browser guest page still fills the area under its per-type-size toolbar.
    5. Restart the app and confirm the 125% scale is still applied (AppSettings.fontScale = 1.25).
    6. Choose Grande. Confirm the whole UI returns to 100% immediately; restart and confirm the default remains.
  • Expected: Font size is Starbucks-style cup presets Tall / Grande / Venti / Trenta plus a percentage slider 80%–150% in 2.5% steps. Selection persists as AppSettings.fontScale (absent means 1) and sets --font-scale, multiplying every --text-* token and shared Lucide icon. Window Zoom In/Out/Reset remains independent. Docked plugin work-panel views (the bundled Browser and Files pages) repeat that multiplier — times the window zoom — as their own page zoom instead of importing the app tokens, so they stay in proportion at every setting, and a pi.browser guest rect is converted with the same factor. Detached plugin windows and widgets keep their own base size. Invalid values are rejected or clamped. No protocol or schema version bump.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/07-ui-design-system.md, ADR 0180, D343
  • Acceptance: A (core shell), B (settings), H (localization)
  • Milestone: M5+
  • Status: Unit-covered (packages/shared/src/font-size.test.ts, apps/desktop/test/settings-font-size.test.mjs, apps/desktop/test/plugin-view-scale.test.mjs, apps/desktop/test/browser-guest-hole-scale.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-127: macOS keeps the app in the Dock and Cmd+Tab ​

  • Preconditions: Built desktop app on macOS; the plugin launcher shortcut (Option+Space) is registered; at least two Spaces and one other application running fullscreen.
  • Steps:
    1. Launch the app without opening the launcher and confirm it appears in the Dock and in the Cmd+Tab switcher (lsappinfo list reports type="Foreground", not type="UIElement").
    2. Press Option+Space, confirm the launcher panel appears focused with a typable input, dismiss it, and confirm the app is still in Cmd+Tab.
    3. Put the main window in fullscreen, press Option+Space, and confirm the panel floats above it.
    4. Switch to a second regular Space and confirm Option+Space shows the panel there.
    5. Minimize the main window into the tray, switch to another app, then Cmd+Tab back to Nib and confirm the window returns focused; repeat with a Dock click and with the tray Show item.
    6. With the main window hidden, press Option+Space and confirm only the launcher appears — the main window stays hidden until it is restored.
  • Expected: The process never adopts the accessory activation policy, so Dock and Cmd+Tab presence survives launcher warm-up and every launcher invocation; the launcher stays focusable, covers all regular Spaces and the app's own fullscreen window (overlaying another app's fullscreen Space is out of scope and activates Nib instead); activation from Cmd+Tab, App Exposé, the Dock, or the tray restores a tray-hidden window, while launcher and plugin-panel activation leaves it hidden.
  • Specs linked: 03-runtime/07-process-model.md, ADR 0086, ADR 0078, ADR 0080
  • Acceptance: A (core shell)
  • Milestone: M5+
  • Status: Documented

E2E-128: Revealed work panel with no resource offers available views ​

  • Preconditions: App running with a project open and an active conversation that has produced no file, URL, or review artifact, so the session's work panel context holds no tabs.
  • Steps:
    1. Press Cmd/Ctrl + J and confirm the panel appears with an empty body that shows the title "New" and Review plus Browser/in-scope plugin-view rows — not a blank area below the title bar.
    2. Tab into the available rows and confirm each takes a visible focus ring and that hovering a row shows only a background fill.
    3. Activate Browser or a plugin view and confirm its singleton tab is created and selected; the empty body and its view list disappear.
    4. Click + to create a New launcher tab, activate the same view from its body, and confirm it selects the existing tab rather than creating a second one.
    5. Close the view tab and confirm the panel remains open on the New launcher when it was the last tab; press Cmd/Ctrl + J again and confirm it hides.
    6. Repeat step 1 in Chinese and in both light and dark themes, and at the 244px panel minimum, confirming the copy wraps rather than clipping.
  • Expected: Cmd/Ctrl + J reveals the panel without creating a tab, and the New launcher lists the same Review/plugin-view entries as its +-created page; a row creates or selects that singleton view. Each + click creates a separate closable New tab. Closing the final tab leaves the panel open on New. The no-tab empty body is not exposed as a tabpanel; explicit New tabs are labelled tabpanels. Their rows are buttons in a role="group" labelled Tools. Panel empty states share the app's empty-state proportions with no action button in the "open a project" states.
  • Specs linked: 04-ux/08-component-spec.md §5.2, §5.2.1, §5.3, §5.4, §5.5, 04-ux/07-ui-design-system.md, ADR 0108
  • Acceptance: A (core shell), H (localization)
  • Milestone: M5+
  • Status: Documented

E2E-129: A run row shows its command once and copies it from the head ​

  • Preconditions: App running with a project open and a conversation that has produced at least three command rows: one that succeeded with output, one that failed with both output and errors, and one still running.
  • Steps:
    1. Leave the running row collapsed while it produces output and confirm the row remains a compact one-line status; then click its disclosure while the command is still running and confirm the current output appears immediately. 1a) Keep the command running through enough output to exceed the output area's visible height and confirm the output stays inside its capped scroll region, rather than pushing the transcript out of view. Confirm later output replaces the same body in place while the row remains open. 1b) Expand the successful row and confirm the body shows the command's output as plain text — no Output heading, no bordered card, no per-block copy button — and that the command is not repeated inside the body, with no argument list in its place.
    2. Confirm each row's head states its outcome to the right of the summary: Done, Failed, Denied, or Working…, each with a dot in the matching tint, and that the running row shows the pulsing dot instead of the row spinner. 2a) Run a command that exits non-zero (pnpm test on a failing suite, or false) and confirm the row reads Failed with the error dot and opens itself, even though the tool call completed. Confirm the exit-code chip and the worded outcome agree. 2b) Interrupt a long command so the shell is killed with no exit code, and confirm the row reads Failed rather than Done.
    3. Hover the successful row and confirm a copy button and the chevron appear between the summary and the row's right edge; move the pointer away and confirm both fade while the status label stays visible.
    4. Activate the copy button and confirm the clipboard holds the command as it was issued — a multi-line command keeps its line breaks, unlike the single-line summary in the head — and that the button acknowledges the copy before returning to its idle icon.
    5. Confirm the hover fill covers the whole head, including the copy button and chevron, and that a row with nothing to expand takes no fill at all.
    6. Tab through the row and confirm the header is the only stop that toggles the body, the copy button is reachable and takes a visible focus ring (revealing itself on focus), and the chevron is never a tab stop.
    7. With a screen reader, expand and collapse the row and confirm the outcome is announced once, not twice.
    8. Ask the agent to run a command that requires approval and confirm the permission card still shows the command it is asking about.
    9. Enable "reduce motion" and confirm the running dot holds still and the copy button appears without a fade.
    10. Repeat steps 1–3 in Chinese and in both light and dark themes.
  • Expected: A command appears exactly once per row, in the head, alongside a copy control that yields it verbatim and a worded outcome that does not rely on the dot's color. The outcome reports what the command did — a non-zero or missing exit code reads Failed regardless of the tool call's own status — and a row with nothing to report states nothing instead of claiming Done. The running row is collapsed by default, and expanding it during execution shows the cumulative details.output stream in the stdout channel. The body keeps the live output in a capped internal scroll region and updates in place without rerendering unrelated rows; the completed details.stdout value wins over any older partial snapshot. The expanded body holds only what the command printed, as bare text, and a command that printed nothing opens empty rather than falling back to its arguments. Approval cards are unaffected, since they have no head of their own.
  • Specs linked: 04-ux/08-component-spec.md §9.2, §9.3, §9.5, §9.10
  • Acceptance: E (tools & permissions), H (localization)
  • Milestone: M5+
  • Status: Documented

E2E-130: Read mints a tag that an Edit consumes without re-reading ​

  • Preconditions: A project-bound Agent session with a writable workspace and a source file of at least 300 lines. The provider fixture can emit an exact Edit payload.
  • Steps:
    1. Read the file with no offset and record the [path#TAG] header, the tag field, and the N: prefixes on the returned lines.
    2. Emit Edit with that tag and a single PUT N.=M: whose body replaces two lines inside the read window.
    3. Confirm the successful result reports a new tag, then emit a second Edit with the returned tag and a PUT >$: append, with no intervening Read.
    4. Read a 200-line window at an offset, then Edit a line inside that window using the tag from the windowed read.
    5. Reopen the file on disk and compare it to the intended content byte for byte.
  • Expected: The header tag is the whole-file tag, not the window's, so a windowed read anchors correctly; line numbers are absolute and unaffected by offset. Both edits apply, the second without any re-read, and each success returns the post-write tag. The file on disk matches the intended content exactly, with its original line endings and BOM state preserved.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §3, §4.2, §5, §6, §9, 03-runtime/16-tool-result-limits.md §5, ADR 0087
  • Acceptance: E (tools & permissions)
  • Milestone: M5+
  • Status: Documented

E2E-131: An edit on never-displayed lines is rejected and the retry succeeds ​

  • Preconditions: A session that has read only lines 1–50 of a 400-line file. A second fixture file has one line longer than 16,384 characters.
  • Steps:
    1. Emit Edit with the correct tag and a PUT 300.=301: op.
    2. Inspect the error code and confirm the message inlines the current content of lines 300 and 301.
    3. Retry the identical Edit payload, unchanged, including the same tag.
    4. Emit Edit with the correct tag and a PUT 5.=60: op spanning 56 unseen lines, and inspect the reveal.
    5. Retry that identical payload unchanged.
    6. Read the second fixture, confirm the long line is clipped and counted in notice, then Edit that clipped line.
  • Expected: Step 1 fails with EDIT_LINES_UNSEEN and the file is unchanged. Step 3 applies, because the complete reveal merged those lines into the session's provenance. Step 4 fails with a reveal truncated at 40 lines that says to re-read the range, and step 5 fails again — a truncated reveal merges nothing, so the guard cannot be walked past in under-cap slices. Step 6 fails with EDIT_LINES_UNSEEN: a clipped line was never displayed.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §4.3, §9.1, §11, §12, 03-runtime/16-tool-result-limits.md §2, ADR 0087
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5+
  • Status: Documented

E2E-132: Gap inserts, deletes, and multi-op payloads apply against one snapshot ​

  • Preconditions: A read file whose content is known line by line.
  • Steps:
    1. Emit one Edit combining PUT <1:, PUT >40:, CUT 12.=14, and PUT 80.=80: in a single ops payload.
    2. Compare the result against the same four changes computed against the original line numbering.
    3. Emit PUT >$: on the same file and confirm the append lands after the final line with exactly one terminating newline.
    4. Emit an ops payload whose body row starts with a literal - written as +- item, and one with a literal + written as ++ item.
    5. Emit an Edit whose PUT body exactly reproduces the range's current content.
  • Expected: Every anchor indexes the tagged snapshot, so no op shifts another and the combined result equals the four independent changes. +- and ++ write a single leading - and +. Step 5 returns EDIT_NO_CHANGE rather than reporting a successful write of nothing, and leaves no review record.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §7.2, §7.3, §7.4, §8.1, §9.3, ADR 0087
  • Acceptance: E (tools & permissions)
  • Milestone: M5+
  • Status: Documented

E2E-133: Block ops resolve, echo their span, and decline instead of guessing ​

  • Preconditions: Read fixtures in a supported grammar (a Rust file with a decorated/attributed function), a Markdown file with nested headings, and a file in a language outside the supported grammar list.
  • Steps:
    1. Emit PUT N*: anchored on the fn line of a function whose declaration carries an #[attribute] line above it, and inspect the echoed {anchorLine, start, end, op}.
    2. Repeat anchored on the attribute line and compare the echoed span.
    3. Emit PUT >N*: on the same opener and confirm the insertion lands after the block's last line at sibling indentation.
    4. Emit CUT N* anchored on a lone } closer.
    5. Emit PUT N*: on a ## heading in the Markdown fixture and confirm the span reaches the next same-or-higher heading, not the next deeper one.
    6. Emit PUT N*: in the unsupported-language file.
    7. Introduce a syntax error into the Rust fixture, re-read, and emit a block op.
  • Expected: Step 1's span starts at the fn line and excludes the attribute; step 2's includes both — the difference is visible in the echo before the model has to infer it. Steps 4, 6, and 7 fail with EDIT_BLOCK_UNRESOLVED and a message naming the plain-range alternative; neither approximates a span. Range and gap ops still work in the unsupported language.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §8.2, §11, §12, ADR 0087 §4
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5+
  • Status: Documented

E2E-134: Registers move code within a call and across calls ​

  • Preconditions: Two read files in the same session.
  • Steps:
    1. In one Edit, emit CUT 20.=30 followed by PUT <5 with no register label, and confirm the lines moved within the file.
    2. In one Edit, emit two CUT ops with no labels followed by one unlabeled paste.
    3. Emit CUT 40* @fn on the first file, then in a separate Edit call emit PUT <10 @fn on the second file.
    4. Emit PUT <10 @missing for a register that was never set.
    5. Emit PUT 10.=12 @fn with a body row attached.
    6. Emit PUT <1 with no label in a fresh Edit call that performed no capture.
    7. Delete the source file, then paste @fn again in a later call.
  • Expected: Step 1 applies as one move with no duplicated or orphaned lines. Step 2 fails with EDIT_REGISTER_AMBIGUOUS instead of using the most recent capture. Step 3 completes the cross-file move across two calls, each with its own permission gate, review record, and artifacts row. Steps 4 and 6 fail with EDIT_REGISTER_EMPTY — the anonymous register did not survive the earlier call. Step 5 fails with EDIT_PARSE_FAILED. Step 7 still pastes: a register holds captured content, not a live reference.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §7.5, §8.3, §11, §13.2, ADR 0087 §5
  • Acceptance: E (tools & permissions)
  • Milestone: M5+
  • Status: Documented

E2E-135: A drifted file recovers when remapping is provable and fails when it is not ​

  • Preconditions: A read file whose tag is recorded. An external process can modify the file between the read and the edit.
  • Steps:
    1. Insert 10 unrelated lines above the edit target from outside the session, then emit the original Edit with the stale tag.
    2. Inspect the warning on the successful result and confirm the change landed at the shifted location, not at the original line numbers.
    3. Repeat with a change that modifies one of the anchor lines themselves.
    4. Repeat with a change that inserts lines between two anchors of a multi-op payload, so the anchors would move by different offsets.
    5. Repeat with a CUT whose captured interior lines were externally edited.
    6. Repeat after the session itself has written the file twice, using the tag from the first write.
    7. Repeat with a duplicated anchor line whose one neighboring context line matches but whose other does not.
  • Expected: Step 1 applies with a line-remap plus external-change warning. Steps 3, 4, 5, and 7 fail closed with EDIT_TAG_MISMATCH and current content; none of them writes. Step 6 applies with a session-chain warning instead of an external-change warning, because the corrective advice differs. No recovery path ever writes the tagged snapshot's content over the live file.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §9, §10, §11, ADR 0087 §6
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5+
  • Status: Documented

E2E-136: A stale tag still applies for head and tail inserts ​

  • Preconditions: A read file whose tag is recorded, plus an external writer.
  • Steps:
    1. Modify the middle of the file externally, then emit PUT >$: with the stale tag.
    2. Repeat with PUT <1: and the same stale tag.
    3. Repeat with a payload that mixes PUT >$: and an anchored PUT 50.=50:.
    4. Emit an Edit whose tag is well formed but was never recorded for that path in this session.
    5. Emit an Edit with a tag that is not four hex digits.
  • Expected: Steps 1 and 2 apply with a drift warning, because neither anchor can be moved by content drift. Step 3 does not take the position-stable path: it goes to recovery and, failing that, to EDIT_TAG_MISMATCH. Step 4 returns EDIT_TAG_UNKNOWN and step 5 returns EDIT_TAG_REQUIRED; neither is reported as a generic TOOL_FAILED.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §9, §11, 03-runtime/08-error-codes.md §3.4
  • Acceptance: E (tools & permissions)
  • Milestone: M5+
  • Status: Documented

E2E-137: Boundary repair fixes an off-by-one edge and refuses a tie ​

  • Preconditions: A read source file with nested closing delimiters.
  • Steps:
    1. Emit a PUT N.=M: whose range includes one trailing } that the body does not restate, and inspect the result and its warning.
    2. Emit a PUT N.=M: whose body restates a line that sits immediately outside the range.
    3. Emit a payload constructed so that two distinct repaired texts tie at the minimum repair cost.
    4. Emit a payload against a file that already fails to parse, and confirm the repair does not retain a row on parse-success evidence alone.
  • Expected: Steps 1 and 2 apply with a warning naming exactly what was repaired, so a silent structural change is impossible. Step 3 returns EDIT_REPAIR_AMBIGUOUS rather than choosing; step 4 does not invent a retention. In every case the file either contains the repaired result described in the warning or is untouched.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §8.4, §11, ADR 0087
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5+
  • Status: Documented

E2E-138: Move, remove, and rollback keep review evidence honest ​

  • Preconditions: A project-bound session with Review visible and a read file inside the workspace root.
  • Steps:
    1. Emit Edit with a PUT op plus MV DEST in the same ops payload.
    2. Inspect the review records for the tool call and the Review panel rows.
    3. Roll the change back and confirm both the source and the destination return to their pre-call state.
    4. Emit Edit with REM, then roll it back.
    5. After a rollback, emit an Edit using the tag the session held before the rollback.
    6. Emit Edit on a path that does not exist but whose basename and tag match exactly one file this session recorded, and inspect the warning.
    7. Repeat step 6 with two recorded candidates sharing that basename and tag.
    8. Emit MV to the source itself, ./source, sub/../source, and its absolute path, plus a directory symlink (Windows junction) pointing back to its directory; on a case-insensitive filesystem also use a case-only alias. Repeat with a content-changing PUT in the same call, then use the original Read tag for a valid content edit.
  • Expected: Step 1 records a source deletion and a destination creation under one tool call; step 3 restores both or neither. Step 4's rollback restores the captured bytes, hash-guarded on the full digest rather than the 16-bit tag. Step 5 fails rather than editing against content the rollback replaced. Step 6 rebinds to the real file with a warning, and the write-permission gate is evaluated against the rebound path; step 7 declines instead of picking one. Step 8 returns EDIT_NO_CHANGE without writing or deleting the source, and the original Read tag remains usable for the following valid edit.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §9.2, §13.1, 03-runtime/03-tools-and-permissions.md §4c, ADR 0043, ADR 0087
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5+
  • Status: Documented

E2E-139: Snapshot provenance is per session and bounded ​

  • Preconditions: A subagent-capable session (§5f), a second session on the same workspace, and a fixture with more files than the snapshot store's path bound.
  • Steps:
    1. Read a file in the parent session, then have a delegate Edit that file using the parent's tag without reading it first.
    2. Read a file in session A and emit the same Edit payload from session B.
    3. Read more distinct paths than the store retains, then edit the first-read path with its original tag.
    4. Read one path five times with changing content between reads, then edit using the tag from the first read.
    5. Read a file, then read the identical unchanged file twice more at different offsets, and confirm one tag covers all three windows.
    6. Restart the app, then emit an Edit with a tag from before the restart.
    7. Write a file through a path that a save hook reformats, then Edit using the tag the write returned.
  • Expected: Steps 1 and 2 fail — provenance is per reader, and no session hands another its tags. Steps 3, 4, and 6 fail with EDIT_TAG_UNKNOWN and an instruction to re-read, never with a wrong write. Step 5 applies anywhere in the union of the three windows without a fourth read. Step 7 applies, because the recorded tag describes the bytes that actually landed, and the drift is reported as a one-line warning rather than a whole-file diff.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §4.2, §4.4, §5.4, §13.5, 03-runtime/02-agent-runtime.md §5f, ADR 0087 §3
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5+
  • Status: Documented

E2E-140: Recoverable edit failures each get one retry before the guard counts three ​

  • Preconditions: A session with one file read, and a way to make the file drift on disk between calls.
  • Steps:
    1. Let the file drift, then emit an Edit with the now-stale tag whose anchors cannot be remapped, so it fails with EDIT_TAG_MISMATCH.
    2. Re-read, then emit an Edit anchored on lines the session never displayed, so it fails with EDIT_LINES_UNSEEN and a truncated reveal.
    3. Emit an Edit on that same path with a malformed op header.
    4. Emit a second Edit with a malformed op header.
    5. Emit a third Edit with a malformed op header.
  • Expected: Steps 1 and 2 return their own codes with no terminate hint — each recoverable code spends its single grace on that path, and the turn keeps going, so the agent can act on what the error handed it. Steps 3 and 4 count as attempts 1 and 2 and still do not terminate. Step 5 terminates. A successful Edit inserted anywhere before step 5 resets the count, so the following failure is attempt 1 again.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §9.3, §11, 03-runtime/03-tools-and-permissions.md §4d, ADR 0087, ADR 0207
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5+
  • Status: Documented

E2E-141: An exhausted retry budget ends the turn with a visible, retriable row ​

  • Preconditions: A session where Edit on one path fails with a non-recoverable code every time.
  • Steps:
    1. Emit three failing Edit calls on the same path within one prompt.
    2. Observe the transcript after the agent loop stops.
    3. Send a follow-up prompt in the same session.
    4. Repeat with three failing apply_patch shell commands instead of Edit.
  • Expected: The third call carries the termination hint and the loop stops, but the turn does not merely complete: the transcript ends on an assistant error row with MUTATION_RETRY_BUDGET_EXHAUSTED, marked retriable, naming the path and the next action, and the same code arrives as an error event. When the last error is EDIT_PARSE_FAILED, the row's recovery hint explains the syntax correction — for example, a body-bearing PUT 48.=48 must be written as PUT 48.=48: — and does not tell the agent to re-read solely to repair the malformed payload. The turn is recorded as failed rather than completed with no final message. Step 3 proceeds normally — the guard's counters are per prompt. Step 4 produces the same row with details.kind of patch-command.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §9.3, 03-runtime/03-tools-and-permissions.md §4d, 03-runtime/08-error-codes.md §3.3, ADR 0087
  • Acceptance: E (tools & permissions), C (chat & stream)
  • Milestone: M5+
  • Status: Documented

E2E-156: Edit tool succeeds on files with CRLF line endings ​

  • Preconditions: A workspace containing a file with Windows-style CRLF (\r\n) line endings.
  • Steps:
    1. Use Read to display the file content and record the whole-file tag.
    2. Issue an Edit with that tag and a PUT N.=N: whose body uses LF-only endings (as the model always produces from Read output).
    3. Inspect the file on disk after the edit.
  • Expected: The Edit succeeds and returns a new tag. The written file preserves CRLF line endings throughout — both in modified and unmodified lines. No MUTATION_RETRY_BUDGET_EXHAUSTED error occurs.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §3.1, 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (tools & permissions)
  • Milestone: M5
  • Status: Documented

E2E-EDIT-legacy-replacement-preserves-unmatched-text: A legacy Edit replaces only the matched text ​

  • Preconditions: A workspace with an LF file containing let x = foo;, value = 1; // keep me, and a two-line statement, plus a CRLF copy of the first line.
  • Steps:
    1. Issue an Edit with only path, old_string = foo;, and new_string= bar; (no tag, no ops).
    2. Issue a legacy Edit whose old_string is value = 1;, then one whose old_string starts mid-line and ends mid-line on the following line.
    3. Issue a legacy Edit whose old_string is a whole line including its newline and whose new_string is empty.
    4. Repeat step 1 on the CRLF file, then issue one legacy Edit whose old_string is not in the file.
    5. Compare every file on disk to the intended content byte for byte.
  • Expected: Each replacement succeeds and returns a new tag, and only the matched text changes: the line reads let x = bar;, the trailing comment survives, the multi-line match keeps the text before its start and after its end, the whole-line deletion removes that line without leaving a blank line or touching its neighbours, and the CRLF file keeps CRLF endings. The missing old_string fails with EDIT_LEGACY_MATCH_FAILED and leaves the file unchanged. A replacement whose only effect would be toggling the terminal newline fails with EDIT_NO_CHANGE and leaves the file unchanged.
  • Specs linked: 03-runtime/18-line-anchored-edit-contract.md §11
  • Acceptance: E (tools & permissions)
  • Milestone: M5+
  • Status: Automated (host-core unit tests: edit_legacy_replacement_preserves_unmatched_bytes, edit_legacy_identical_replacement_leaves_file_unchanged, edit_legacy_terminal_newline_only_change_reports_no_change, edit_accepts_legacy_old_string_new_string_shape)

E2E-142: Background delegation converges through TaskWait and honors permission scopes ​

  • Preconditions: A project-bound Agent session whose permission mode can be switched between ask, accept-edits, and auto, with a provider whose stream can be driven; the five builtin subagents (explorer, code-reviewer, test-runner, fixer, ui-designer) and a global ~/.agents/subagents/readonly.md definition. Builtins use the default permission: inherit behavior.
  • Steps:
    1. Prompt a turn in which the assistant emits two Task calls — explorer on one direction and a second explorer on another — in one assistant message, then, without ending the turn, continues its own tool calls and converges with TaskWait.
    2. Confirm the parent's visible text keeps streaming between Task and TaskWait (no dead turn), that the two Task rows form one delegation card that opens once, and that TaskWait's row shows both reports.
    3. With the session in ask, prompt a turn that delegates to fixer with a multi-file spec. Confirm its Write/Edit, Bash, and external-path calls each render a card naming fixer. Switch the session to accept-edits and confirm only Write/Edit inside the workspace are auto-allowed. Switch it to auto and confirm the same delegate's Write/Edit, Bash, and external Glob/Write calls all resolve without a second authorization card.
    4. Prompt a turn that starts three delegates and then calls TaskWait with mode: "any", minCompleted: 1; confirm it returns as soon as the first settles and that the still-running delegates keep running.
    5. Prompt a turn that starts a delegate and then ends the turn without TaskWait/TaskStop; confirm the delegate is stopped at run end and its node reads aborted, and that the next turn's model context contains no delegate rows.
    6. Prompt ten Task calls in one turn and one more; confirm the eleventh fails as a tool error naming the 10-delegate cap, and that TaskStop frees a slot so an eleventh delegation can start.
    7. Reload the session; confirm the delegation card, its nodes, and the TaskWait rows persist and re-render collapsed, and that TaskWait re-reads a settled delegation's report by id without re-running it.
    8. Prompt a turn where the agent starts two Task calls and then emits visible text before calling TaskWait (so Task and TaskWait land in different activity parts); confirm the topology card shows "completed" status on both nodes once TaskWait returns, not stuck at "running", and that each node shows a non-zero runtime duration derived from the delegation lifecycle timestamps rather than the immediate Task start call.
    9. Edit ~/.agents/subagents/readonly.md to declare permission: auto and reload the catalog; confirm the definition still loads but carries a warning, and that its delegate still resolves under the session's effective mode (a Write inside the workspace still raises a permission card).
    10. Prompt a turn that starts a delegate, lets TaskWait time out so the node still says running, then calls TaskStop; confirm the topology node and the TaskStop row both read stopped (not running). End the turn and reload the session; confirm the card is not labelled working and does not keep ticking elapsed.
    11. Inject synchronous SubagentRun initialization failure; confirm the Task result is an error and TaskList has no permanently running row.
    12. In a runtime fixture, make settled-row publication throw and confirm the parent's idle auto-resume still returns the report. Stop a parent blocked in auto-resume while a child ignores abort; confirm the parent ends and reports idle without a timeout loop.
  • Expected: Task returns immediately with a delegationId and the parent keeps working; TaskWait converges with per-delegation reports and statuses; every terminal child state wakes the parent even if transcript publication fails. A synchronous child initialization failure returns a tool error and leaves no running delegation. User Stop/Dispose abort the parent wait as well as delegates, and cancelled sessions do not remain AGENT_BUSY solely because a child is still settling. TaskList/TaskStop drive the lifecycle; a TaskStop result and a finished turn never leave a live “Subagent working” card; builtin fixer inherits the selected session permission mode, so auto also covers explicit external paths without a duplicate authorization prompt while ask and accept-edits retain their approval boundaries; a global definition's declared scope is dropped; the per-session running cap of 10 is enforced; no delegate outlives its turn; reloaded transcripts keep their delegation topology.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f/§5f.1/§7.1, 03-runtime/03-tools-and-permissions.md §10.2, 08-meta/decisions-log.md (D242 amends D231), ADR 0089 and ADR 0100
  • Acceptance: C (conversation), E (tools & permissions), F (persistence), Security, Quality
  • Milestone: M6+
  • Status: Unit coverage includes synchronous initialization failure, completion-publication failure, and Stop during parent auto-resume in packages/agent-runtime/src/runtime.test.ts; desktop journey pending

E2E-162 / E2E-173: Delegate workflow scrolling ​

  • Status: Superseded by the single-scroll live process behavior in E2E-198.

E2E-199: Subagent editor offers preset templates and a provider-bounded model picker ​

  • Preconditions: A project-bound Agent session. At least one configured, runnable provider with model bindings exists in Settings. The ~/.agents/subagents directory is empty. Builtins are present but no project subagent file overrides them.
  • Steps:
    1. Open Settings → Agent → Subagents, click New subagent, and confirm the sheet opens with a "Start from template" row of compact name chips (Explorer, Code reviewer, Test runner, Fixer, plus a blank chip). Chips show names only; the selected chip's one-line caption appears once under the row. Confirm there is no long subtitle, no per-chip Apply label, and that Advanced starts collapsed. Confirm hyphenated ids (code-reviewer, test-runner) render catalog names, not raw keys such as presetCode-reviewerName. At normal desktop width, confirm the name/description/select controls read as compact filled wells, the prompt editor is the only tall field, and the Save/Cancel actions stay visually subordinate to the form. Focus a field and confirm its accent ring remains visible without a permanent divider.
    2. Click the Explorer chip without touching any field. Confirm the form is pre-filled: name Explorer, the description from the builtin, the Read / Glob / Grep / Bash tool grant, and the full Explorer system prompt. Expand Advanced and confirm the model field is unchanged (still inherit).
    3. Reopen the sheet, click Fixer, and confirm the grant expands to Read / Glob / Grep / Edit / Write / Bash and the Fixer body. The mutating-hint line appears under the tools row. Expand Advanced and confirm the output limit starts empty.
    4. Expand Advanced. Open the model picker. Confirm the picker lists every model of every configured, runnable provider, grouped by provider name. Choose one and confirm the draft's model field becomes <vendorKey-or-name>/<modelId> (matches what the runtime resolver accepts in BUILTIN_SUBAGENT_DOCUMENTS). 4a. Configure a custom endpoint whose display name contains a space (for example My Gateway). Confirm the picker offers it, select it, and confirm the sheet saves with the pin <display name>/<modelId> and the save button enabled. The picker and the draft check must never disagree about what is saveable. 4b. Configure two providers with the same display name and vendor key, so their pins use distinct stored provider ids. Add one as a fallback model, save and reopen the sheet. Confirm the ordered row and its move/remove accessible names use the provider display name and model ID, not the id, while the saved pin still identifies the chosen provider. Add the second provider with the same model ID and confirm both rows and their accessible names include their provider IDs to distinguish them. Disable one provider and confirm its row and move/remove accessible names retain the configured provider name and model ID, with a localized disabled status; the row must not revert to a raw UUID label. Confirm the disabled provider is absent from the add menu, while its saved row remains movable and removable. Save and reopen, then re-enable the provider: its name remains visible, the disabled status clears, and its stored pin is unchanged. A genuinely missing or ambiguous provider binding keeps its raw pin with an unavailable status rather than borrowing another provider's name. Reorder or remove that row, save and reopen, and confirm all remaining pins and their order are preserved exactly.
    5. Confirm the picker offers no Custom (provider/model)… entry and the field renders no free-text input, so a model id can only come from the configured catalog. Switch the picker to Inherit session model, save, and confirm the draft's model field is empty and the sidecar falls back to the session model. 5a. In the open model menu, confirm the list scrolls inside the menu and never runs past the window edge, and that typing in the filter field narrows the rows (including by provider name). Confirm the menu is not an OS-drawn select popup: it stays inside the sheet's own layer.
    6. Disable every provider that contributes a model. Reopen the editor, expand Advanced, and confirm the model field renders an empty state with an action button instead of a free-text input, and that the action opens Models.
    7. Switch the locale to Simplified Chinese. Confirm the preset chips render the translated names (探索者, 代码审查员, 测试执行者, 修复者, 空白开始) and the picker labels (沿用当前会话的模型, 前往模型设置) resolve; no raw i18n keys appear in either locale.
  • Expected: The editor offers only models the user already configured, so the picker is the only way to set a model and every saved pin is resolvable. No free-text model id is accepted. When no provider offers a runnable model the field explains that and links to Models instead of asking the user to type an id the runtime could not resolve. Picking a preset overwrites the draft wholesale (description, tools, body) but never silently clears the user's other choices (model, thinking level, scope).
  • Specs linked: 04-ux/06-settings-ia.md §7, 03-runtime/13-model-catalog-and-selection.md §11, 03-runtime/11-provider-model-system.md §6.4, ADR 0062, ADR 0089
  • Acceptance: B (model config), C (conversation & stream), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered (packages/shared/src/subagent-presets.test.ts, apps/desktop/test/subagent-editor-presets.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-SUBAGENT-settings-lists-builtin-defaults ​

  • Preconditions: A running app. ~/.agents/subagents is empty. The five shipped builtins are present, none is switched off, and no user document shadows them.
  • Steps:
    1. Open Settings → Agent → Subagents. Confirm a Built-in group lists explorer, code-reviewer, test-runner, fixer, and ui-designer with localized names, Task(<handle>) copy, tool grants, a Built-in badge, Copy as mine, and an enablement switch in the on position. Confirm none of those rows has Reveal or Delete.
    2. Confirm the Global group still shows localized settings.subagentsEmpty copy and the New subagent action.
    3. Switch fixer off from its Built-in row. Confirm the row dims with its switch off, the toast names it, nothing appears in ~/.agents/subagents, and the row stays listed, because that switch is the way back on.
    4. Send a prompt with fixer switched off. Confirm the Task catalog does not offer it while the other four remain, then switch it back on and confirm the next prompt offers it again.
    5. Choose Copy as mine on explorer. Confirm the create sheet opens pre-filled from that definition (name, description, tools, body) with the Explorer template chip selected, not Blank. Save. Confirm explorer now appears only as a user-owned Global row and is omitted from Built-in, and the next prompt's Task catalog uses the user document.
    6. Disable the user explorer and reload the page. Confirm the user row is off and explorer reappears under Built-in (disabled user documents do not reach the loader, so the shipped definition wins again).
  • Expected: Settings shows the defaults the agent can actually delegate to, and every one of them can be turned off from its own row. Builtin activation is app-local state rather than a document, so a switched-off default keeps its row; copying a builtin stays the way to retune one, and reveal and delete remain file-backed actions on user-owned rows only.
  • Specs linked: 04-ux/06-settings-ia.md §2, 03-runtime/01-ipc-protocol.md §12c, 03-runtime/02-agent-runtime.md §5f, ADR 0062, ADR 0063, ADR 0270
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M6+
  • Status: Source/unit covered (apps/desktop/test/agent-capability-settings.test.mjs, apps/desktop/test/subagent-wiring.test.mjs, packages/agent-runtime/src/subagent-definitions.test.ts, packages/shared/src/subagent-presets.test.ts); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-198: A subagent task opens with a live conversation process ​

  • Preconditions: A project-bound Agent session with a mocked provider stream where one explorer delegate has a Task description and emits thinking, tool, and answer rows over time. The work panel is initially closed.
  • Steps: 1) Expand the activity group if needed and click the explorer topology node. 2) Click the selected explorer node again and confirm the right-side dock closes, then click it once more to reopen it. 3) Observe the right-side dock while the delegate streams. 4) Scroll the task/process conversation upward and then return to the latest output. 5) Switch sessions and return to the original session. 6) Let a delegate start and then fail, open its node, and read the foot of the dock; repeat with a delegate that completes and one that is still running.
  • Expected: The right dock shows a sticky identity header (avatar, name, and model caption on the left; status capsule and elapsed time trailing on the same row without wrapping), the Task call's description as a full-width inset grouped card under a Task section label, capped at four lines with an inline Show more / Show less control for longer tasks, and the delegate's live thinking/tool/answer process under an Activity section on one subtle vertical timeline using the same row components as the main conversation. The selected topology node is a full-row toggle without an extra disclosure chevron: its first click opens the dock and its second click closes it. New rows appear without a reload and follow the bottom while pinned. The panel has one body scrollbar; the process does not create a nested scrollbar or a second elevated card. At the minimum supported panel width, long commands, paths, and tool summaries remain contained within the dock without horizontal page overflow. A real upward gesture pauses follow and exposes jump-to-latest. The transcript remains the same height and keeps its own scroll state. Session switching hides the selection and returning never shows another session's task. In step 6, a delegate that starts and then fails closes the dock with an error card rather than a bare Failed capsule: the localized summary (the registered errors.<code> sentence when the runtime reported a known code, the localized chat.subagentStatus.* outcome otherwise), the stable code, the raw provider message behind a Show details / Hide details disclosure, and a copy control. The disclosure control stays reachable while the details are collapsed, and the delegate that completed or is still running shows no card at all.
  • Specs linked: 04-ux/08-component-spec.md §5.7, 04-ux/09-interaction-patterns.md §9.1
  • Acceptance: C (conversation), Quality
  • Milestone: M6+
  • Status: Documented; desktop journey pending. The failure card's data source is unit-tested in subagent-topology.test.mjs: a settled delegation's error: { code, message } read from TaskWait delegations[] and TaskStopstopped[], lifecycle row ordering, entries without an error, and the terminal Task snapshot that carries a failure before parent polling.

E2E-SUBAGENT-settlement-updates-before-parent-poll ​

  • Preconditions: An Agent session with two parallel delegates; one can finish while the other continues and the parent does not poll lifecycle tools.
  • Steps: 1) Start both delegates and open the first delegate's detail dock. 2) Optionally let TaskList report both as running. 3) Complete only the first delegate while the parent turn remains live. 4) Switch sessions and return, then reload history after the turn finishes. 5) Repeat with failed and stopped delegates, and with a delegate finishing before its Task result arrives.
  • Expected: The settled node stops spinning immediately, its status is completed (green) or the actual failure/stop outcome, and its elapsed time stops increasing. The sibling remains running; the aggregate reads one of two settled. The open dock updates with the same status and failure details. A stale running TaskList snapshot cannot undo settlement. Reload preserves the actual terminal outcome and original Task identity, arguments, and usage.
  • Specs linked: 03-runtime/02-agent-runtime.md §Subagents, 04-ux/08-component-spec.md delegation topology
  • Acceptance criterion: C, Quality
  • Milestone: M6+
  • Status: Runtime event-order and renderer projection regressions automated; desktop journey documented. Required suites: test:e2e, test:e2e:subagents.

E2E-SUBAGENT-resume-a-settled-delegation ​

  • Preconditions: An Agent session on a deterministic local transport that can complete, fail, stop, and hold a delegate open on request. Two user definitions, ~/.agents/subagents/scout.md (read-only) and ~/.agents/subagents/fixer.md; a workspace file src/report.ts whose conclusion is easy to locate by file and line; and a definition model binding that can be removed from the configuration. The parent catalog offers Task, TaskWait, TaskList, and TaskStop.
  • Steps:
    1. Delegate scout a brief that must end by naming one conclusion with its file and line number, and let it settle completed. Note the delegationId the Task result returns.
    2. Send a turn that passes that id as Task.resume and asks for the same conclusion plus a second occurrence the first run never reached. Expand the delegation card, then inspect what the parent itself received.
    3. Send a turn that only says "reuse what the scout already found", with no resume value.
    4. Start a long-running scout delegation and call Task.resume for it while it works; then, while a resumed run of another chain is still working, call Task.resume for that chain again.
    5. TaskStop one delegation, abort a second, and close the app while a third is still working. After relaunch, call Task.resume for each of those three ids as reported by TaskWait.
    6. Let a delegation fail after one successful read, then call Task.resume for it.
    7. Send one Task call that carries both resume and model.
    8. Remove the chain's recorded model from the configuration, then Task.resume the chain and read its delegation lifecycle details and card.
    9. Settle two scout chains, resume the older one, settle a third chain for the same definition, and read the reusable list the next prompt offers; then keep one chain running while two others settle for that definition.
    10. Accumulate more than 50,000 lines of read-only tool output in one chain, then call Task.resume with its id.
    11. Resume a scout chain while spelling Task.agent as Explorer, then as explorer.md, then as another definition's name.
    12. Let one chain read more than eight files and send two further prompts in the same session, comparing the reusable list each prompt composes.
    13. Relaunch the app and, without changing anything else, Task.resume a completed chain from before the restart.
    14. From a fixture session that resolves a chain but no longer holds any of its delegate rows, call Task.resume with that id.
  • Expected:
    • Step 2 resumes from the chain: the new run answers with the earlier conclusion's exact file and line, and its rows contain no fresh full read of that file. Task returns a new delegationId, and the parent's own context still holds one final report for that delegation and no delegate tool rows.
    • Step 3 is a cold start: a brand-new delegate with no earlier conclusion in its rows, a fresh delegationId, and no link to the earlier chain. Leaving the id out never inherits context, whatever the prompt says.
    • Step 4 fails twice as tool errors. The running delegation is reported as still running, with the instruction to converge through TaskWait first; nothing is started, nothing is queued, and the running delegate keeps working.
    • Step 5 refuses stopped, aborted, and the run the app closed while it was still working (which reads as interrupted after relaunch) as not resumable, each naming that reason and the fresh-delegation path instead. No run starts.
    • Step 6 resumes the failed chain like a completed one: the reads it already made seed the new run, and its failed assistant row is not replayed.
    • Step 7 is refused as a tool error: a resumed run keeps the chain's model, and the message points at starting a new delegation to change models.
    • Step 8 still resumes, on the binding the definition resolves to now, and the delegation's lifecycle details carry the previous model id as modelChangedFrom. The parent sees it through the lifecycle snapshot it polls and the card shows it with the run, so the switch is never silent.
    • Step 9 keeps at most two reusable chains per definition: the least recently active settled chain is evicted whole, so every id on it answers as an unknown delegation with the current reusable list, while a chain whose latest run is still working is never evicted — the group may sit over the bound rather than strand a live delegate.
    • Step 10 drops the over-budget chain from the reusable list without trimming its history: no later prompt offers it and a resume for it is refused as having read too much to resume cheaply, while an ordinary delegation for the same work still succeeds from cold.
    • Step 11 matches the definition name case-insensitively and with the document suffix — Explorer and explorer.md both resume the chain — while a different definition's name is a name mismatch that lists the agents that do have reusable chains.
    • Step 12 offers each chain's latest delegationId, its objective, and at most eight of the files it read, with a (+N more) suffix past that. A chain that just settled is already there in the same session, with no app restart and no new session; running, non-resumable, over-budget, and evicted chains never appear.
    • Step 13 rebuilds the chain relationship from the transcript, so the completed chain is offered again after relaunch and resumes as before.
    • Step 14 fails as having no recorded history, and that same id is not listed among the reusable ids in the error or in any later prompt: the chain leaves the reusable list.
    • Throughout, the transcript shows the chain as one continuous multi-turn conversation under its newest Task card, with no "resumed" marker, and the resumed run's counters start at 0 so its turn, tool, and usage numbers describe the new run while the earlier rounds stay readable above it.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, ADR 0279
  • Acceptance: C (conversation), Quality
  • Milestone: M6+
  • Not covered (二期): reviving a stopped/aborted delegation, in-chain compaction, task queuing, and cross-session resumption.
  • Status: Draft — chain resolution, resume validation, the reusable list, and transcript chain grouping are unit/regression covered (packages/agent-runtime/src/delegation-chain.test.ts, delegation-history.test.ts, runtime.test.ts, apps/desktop/test/assistant-turns.test.mjs); the desktop journey needs a capable environment. Required suites: test:e2e, test:e2e:subagents, test:e2e:transcript.

E2E-SUBAGENT-context-overflow-compacts-before-failing ​

  • Preconditions: An Agent session on a deterministic local transport whose model metadata declares a small context window (for example 16,000 tokens) and whose replies are scripted. The small window is injected through that fake provider, never a real one: real providers and paid APIs are not default test environments in this repository. A user definition ~/.agents/subagents/reader.md declares Read, Glob, and Grep, and the workspace holds files large enough that two or three reads cross the delegate's hard limit.
  • Steps:
    1. Delegate a brief that requires reading those files in sequence and record every request the transport receives, with its estimated size.
    2. Read the request that follows the boundary at which the delegate crosses its hard limit.
    3. Repeat with the crossing landing while a tool result is still pending, then with it landing on a completed turn.
    4. Repeat with the summary request scripted to fail.
    5. Repeat with a summary response containing only thinking blocks or whitespace, including a response stopped at the output limit.
    6. Run the same brief as the session Agent on the same fixture and compare its requests and transcript rows with a run recorded before this change.
    7. Inspect the delegation card, the transcript, the context inspector, and the parent's own model context after the delegate settles.
  • Expected: The delegate keeps working instead of failing. The request after the crossing is below the hard limit and carries a summary plus the applicable retained tail; no request is sent above the window. A pending tool result retains as an active turn (latest user message only), a completed turn retains none. A failed summary degrades to the original task brief plus the most recent message(s), the run still completes, and the report and lifecycle details say it was degraded rather than presenting a partial answer as complete. A thinking-only or whitespace-only summary also follows that degradation path, preserves the recent tool evidence, and never installs an empty successful checkpoint. Delegate compaction adds no transcript row, no host-core checkpoint, no warning toast, and no context-inspector line; the delegate's own rows stay complete and the parent's model context still holds only the report. The session Agent behaves exactly as it did before.
  • Specs linked: 03-runtime/02-agent-runtime.md §5.1, §5f, 03-runtime/08-error-codes.md §3.2, ADR 0299, ADR 0064, ADR 0136
  • Acceptance criterion: C — Conversation & stream; Quality
  • Milestone: M6+
  • Status: Draft. Required suites: test:e2e, test:e2e:subagents.

E2E-SUBAGENT-output-token-limit-is-a-visible-failure ​

  • Preconditions: An Agent session uses a deterministic local provider whose response ends with stopReason: "length" or "max_tokens" after emitting non-empty assistant text. The delegate has a valid report and no pending tool call.
  • Steps: 1) Delegate the task and let the provider end at its output-token limit. 2) Read the Task result, lifecycle details, and delegation card. 3) Resume or retry the same work with a provider response that ends normally.
  • Expected: The first run settles as failed, not completed, with SUBAGENT_OUTPUT_TRUNCATED and outputTruncated: true. The parent receives an explicit explanation and the bounded partial report under Its last output was:. A later turn that ends with stop clears the marker and settles as completed; the partial run never masquerades as a finished report.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, 03-runtime/08-error-codes.md §3.2
  • Acceptance: C (conversation), H (diagnostics), Quality
  • Milestone: M6+
  • Status: Unit covered by packages/agent-runtime/src/subagent.test.ts; the full desktop journey remains required: test:e2e, test:e2e:subagents.

E2E-SUBAGENT-context-overflow-reports-actionable-failure ​

  • Preconditions: The same injected small-window fake provider, sized so even the degraded context cannot fit. One definition declares two ordered fallbackModels: one whose window is no larger than the primary's and one that is larger. A second definition declares none.
  • Steps:
    1. Delegate a brief that overflows past both compaction and degradation, and read the tool result the parent receives plus the lifecycle details.
    2. Repeat for the definition that declares the two alternatives, recording which alternatives the transport is actually asked for.
    3. Repeat with every alternative at the same small window.
    4. Read the delegation card and the report in English and in Chinese.
    5. Continue the parent turn, then send a new prompt.
  • Expected: The run fails with SUBAGENT_CONTEXT_OVERFLOW, not retriable, and what the parent reads names what it can change — narrow the task, delegate to a model with a larger context window, read less at once. The provider's raw overflow sentence is not what the parent receives. An alternative whose own budget cannot hold the carried context is never requested and appears in modelFailures with that reason; the larger alternative is attempted and can succeed. With no alternative that fits, the outcome stays SUBAGENT_CONTEXT_OVERFLOW rather than the final provider error. The delegate's rows stay durable and visible, the session returns to idle, and the next prompt is not AGENT_BUSY.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, 03-runtime/08-error-codes.md §3.2, ADR 0299, ADR subagent-model-fallback
  • Acceptance criterion: C — Conversation & stream; Quality
  • Milestone: M6+
  • Status: Draft. Required suites: test:e2e, test:e2e:subagents, test:e2e:subagent-models.

E2E-SUBAGENT-resume-seeds-within-context-budget ​

  • Preconditions: The same injected small-window fake provider. One settled reader chain read enough to exceed the delegate hard limit while staying under MAX_RESUMABLE_READ_LINES; a second settled chain fits well inside the budget. The resumed fixture uses vendorKey: "deepseek" and includes a persisted assistant row with both answer text and thinking.
  • Steps:
    1. Task.resume the over-budget chain and capture its first provider request in full.
    2. Task.resume the chain that fits and capture the same request.
    3. Ask the resumed run for a conclusion the chain reached in its most recent round, and for one it reached in its first round.
    4. Relaunch the app, rebuild the chain index from the transcript, and resume the over-budget chain again.
    5. Accumulate more than MAX_RESUMABLE_READ_LINES of read-only output in a chain and read the reusable list the next prompt offers.
    6. Inspect the resumed assistant history in the captured request and verify its same-model reasoning remains in reasoning_content.
  • Expected: The first request of a resumed run is below the hard limit. It opens with the original task brief and holds the most recent turns; the oldest tool results are dropped first, and dropping an assistant message drops its tool calls with it, so no orphaned tool call reaches the provider. A chain that fits is seeded whole, exactly as before. The most recent conclusion is answered from the seeded context; the first round's may be gone, and the run says so rather than inventing it. A resume never fails with CONTEXT_TOO_LARGE or SUBAGENT_CONTEXT_OVERFLOW on its first request. With a vendorKey binding, restored assistant history uses the same model.provider / model.id identity as live requests, and its thinking stays in the native reasoning_content field. MAX_RESUMABLE_READ_LINES still removes an over-read chain from the reusable list; truncation does not make it resumable again.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, ADR 0299, ADR 0279
  • Acceptance criterion: C — Conversation & stream; Quality
  • Milestone: M6+
  • Status: Draft. Required suites: test:e2e, test:e2e:subagents.

E2E-161: A delegation lifecycle row reads as a subagent row ​

  • Preconditions: A project-bound Agent session with a mocked provider stream that starts two delegates (explorer, fixer) with Task, then calls TaskList, TaskWait and TaskStop.
  • Steps: 1) Start both delegates and inspect the collapsed TaskList row while both are running. 2) Let explorer complete and fixer fail, then inspect the TaskWait row's summary, badge and expanded body. 3) Start a third delegate reusing the explorer definition, call TaskList, and read the summary. 4) Stop a running delegate and inspect the TaskStop row. 5) Confirm the delegation card's own subagent count is unchanged by all three lifecycle rows. 6) Repeat in Chinese.
  • Expected: Every lifecycle row summarizes by agent name — never by its delegationIds argument, and no bare UUID appears in a collapsed row. Its badge uses the shared subagent status vocabulary: running while any member runs, Failed / “失败” once a member failed even though a sibling completed, and Stopped by request / “已按请求停止” for the stopped delegate — including when the persisted TaskStop snapshot still says running. The topology card's node matches that stopped outcome and is not labelled working after the turn ends. A repeated definition is counted (explorer ×2) rather than listed twice. The expanded body shows the joined reports as a notice followed by one named row per subagent with status, runtime and turns, and contains no pretty-printed delegations[] JSON. The delegation card still reports the number of Task calls only, so lifecycle rows never inflate the topology counts.
  • Specs linked: 04-ux/08-component-spec.md §9.9, 03-runtime/02-agent-runtime.md §5f, ADR 0062, ADR 0089, decisions-log D269
  • Acceptance: C (conversation), Quality

E2E-155: Subagent lifetime is parent-judged; runtime delivers reports ​

  • Preconditions: A project-bound Agent session with a Bash-capable explorer definition and a mocked provider stream.
  • Steps: 1) Start a delegate and let the parent stop calling tools while it still runs; confirm the durable turn stays open and the delegate is not aborted. 2) Let the delegate finish and confirm the parent is prompted with its report without the user sending “continue”. 3) Let a TaskWait expire while the delegate is still running and read the heartbeat the parent receives. 4) TaskList a running delegate and confirm elapsed / last-tool fields. 5) TaskStop and user Stop still abort. 6) Run a delegate whose document declares maxTurns: 2 and let it pass two tool-calling turns; confirm the key is ignored and the delegate keeps running. 7) Start a delegate on another model, exhaust the parent HTTP 429 budget, and click Continue; confirm leftover delegates abort, the session is idle, Continue is accepted, and the failed assistant error surface stays visible. 8) Define a delegate with an explicit maxTokens and one without, run both, and read the two outgoing provider requests. 9) Start enough delegates for their combined reports to exceed the bounded TaskWait result, then let the parent idle.
  • Expected: Idle and duration watchdogs never fire. Parent idle does not abort delegates. Completion reports are delivered into the same durable turn. TaskWait expiry reports “Still running after Ns”, includes a heartbeat, and states that this is not a failure. No turn count ends a delegate, and no status reports one. Explorer's catalog includes Bash while code-reviewer remains read-only. A terminal parent 429 aborts leftover delegates and Continue is not AGENT_BUSY (D352). In step 8 the capped delegate's request carries the declared output limit and the uncapped one carries the model's published limit, so the cap overrides the derived max_tokens / max_completion_tokens / max_output_tokens without disturbing the session's own requests (D383). In step 9, the reports omitted from the bounded TaskWait content are delivered once by the idle resume and are not replayed after they reach the parent.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, 03-runtime/08-error-codes.md, 03-runtime/09-logging-and-observability.md, ADR 0166, ADR 0189, decisions-log D328 / D352 / D383
  • Acceptance: C (conversation), E (tools & permissions), H (diagnostics), Quality
  • Milestone: M6+
  • Status: Covered by unit tests; full desktop journey pending. The output cap's parse and clamp are covered in packages/sharedsubagent-definition.test.ts, and its document round-trip in host-core user_subagents tests; the request-level assertion in step 8 stays manual.

E2E-157: Global scrollbars stay quiet while remaining discoverable ​

  • Preconditions: Nib is open with the expanded sidebar, more temporary sessions than the five-row cap, and enough retained project sessions to overflow the Projects region.
  • Steps: 1) Inspect the idle Sessions and Projects scrollbars in light and dark themes. 2) Move the pointer into each list and then away from it, confirming that only the hovered list's thumb appears. 3) Move the pointer back into each list, drag its thumb through the region, and keyboard-focus a row to confirm the focused list keeps its thumb available. 4) Scroll the list with the wheel or trackpad after moving the pointer away from the thumb. 5) Open a long conversation and a long right-side work-panel view, including the file manager view when the bundled plugin is enabled, and compare their idle, hovered, focused, and scrolling states on Windows.
  • Expected: Both regions remain independently scrollable and the footer stays fixed. Every in-app scrollbar is trackless, 6px wide, and transparent at rest; hovering or focusing the owning scroller reveals only its thumb, while scrolling reveals it for 300ms after the last scroll event. Dragging keeps it visible without changing the scroll region's width. Chat, code, Settings, and the right-side work-panel scrollbars all match this rule. A docked or detached plugin panel receives the same host-injected rule; an external page inside Browser keeps its own page-owned scrollbar.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: Quality (sidebar polish and independent navigation)
  • Milestone: M6+
  • Status: Unit-covered (interaction-polish.test.mjs); rendered scenario pending

E2E-158: Temporary sessions use isolated scratch workspaces ​

  • Preconditions: Nib has a project open, a temporary session can be created, and the host data directory is known. The temporary session starts with an empty transcript.
  • Steps:
    1. Create or select a temporary session while the project remains recently active, then inspect the empty-home hero. 2. Confirm the hero uses the temporary-session copy and has no project underline or project switcher; confirm a project session and no active session still use their own hero states.
    2. In the temporary session, use Read/Glob/Grep on a file under its <data_dir>/scratch/<sessionId> root, then Write/Edit a file with a workspace-relative path and run a bounded Bash command.
    3. Inspect the tool results and filesystem, then switch back to the project and confirm the project root and git status are unchanged.
    4. Enter Plan or Goal in the temporary session and confirm submission still fails with the existing project-root requirement.
  • Expected: A path-less session binds every native tool to its own scratch/<sessionId> directory, never to the visible or recently active project. Relative paths work inside that scratch root, containment and permission rules remain active, and no project artifact is created. The temporary hero is localized and has no project switcher; the project hero and the no-project generic hero are unchanged by the temporary session. Plan/Goal retain their project-root boundary.
  • Specs linked: 03-runtime/03-tools-and-permissions.md §4/§4b, 03-runtime/10-session-state-machine.md, 04-ux/01-ui-ia.md, 04-ux/02-i18n-english-first.md, ADR 0124
  • Acceptance: C (conversation), D (workspace), E (tools & permissions), F (persistence), Security, Quality
  • Milestone: M6+
  • Status: Unit-covered (crates/host-core/src/rpc/mod.rs, temporary-session-workspace.test.mjs); rendered desktop journey pending

E2E-159: A long transcript keeps a bounded mounted window ​

  • Preconditions: Nib is open on a session whose transcript is substantially longer than one session.get page (several hundred messages, including fenced code blocks and expanded tool activity), on a memory- constrained Windows machine where the regression was reported.
  • Steps:
    1. Activate the session and confirm the first paint lands at the newest message without a top-of-transcript flash.
    2. Record the renderer's transcript row count and heap usage, then scroll upward continuously to the oldest loaded message and past it so older pages are fetched.
    3. At each point where the view stops advancing, confirm it resumes: earlier rows appear without the rows under the cursor being pushed down, and the earlier-messages indicator appears only when a page is actually fetched.
    4. Re-record the row count and heap usage after paging far back, then scroll back to the bottom and send a new prompt.
    5. While the answer streams, confirm following stays pinned and typing in the composer stays responsive. Scroll up mid-stream and confirm follow releases and the jump-to-latest pill appears.
    6. Hover and click conversation minimap dashes at several heights, then switch to another session and back.
    7. Activate a long session whose newest page collapses to less than one viewport (one tool-heavy turn) and confirm the outline is present with its earlier-history continuation, that clicking it reveals earlier turns, and that it keeps advancing without a manual scroll gesture until the whole history is loaded and mounted.
    8. Confirm the continuation disappears once nothing earlier remains, and that a short completed conversation that fits one viewport still shows no rail.
  • Expected: Mounted transcript rows stay bounded by the window rather than growing with how far back the user scrolled, so the recorded row count and heap usage after paging far back stay close to the values recorded before it. Upward travel is continuous: growing the window and fetching a page both keep the viewport anchored to the row being read. Every minimap message dash jumps to a real row. Streaming stays smooth with the composer responsive, pinned-follow and jump-to-latest behave as specified, and switching away and back paints the retained pane at the position it was left. Earlier history is never stranded: with rows withheld or an older page pending, the outline stays available with an actionable earlier-history continuation and keeps advancing when the top boundary remains visible, even when the mounted tail does not overflow one viewport (D269). The continuation and the rail disappear once the full history is loaded and mounted and the transcript fits one page.
  • Specs linked: 04-ux/08-component-spec.md §7 and §8, 03-runtime/04-data-storage.md, ADR 0120, ADR 0127, ADR 0130, D108, D269
  • Acceptance: C (conversation), H (diagnostics), Quality
  • Milestone: M6+
  • Status: Unit-covered (transcript-window.test.mjs, conversation-minimap.test.mjs, interaction-performance.test.mjs); rendered desktop journey and the low-memory Windows measurement pending

E2E-160: Dragging the window across displays keeps the dropped position ​

  • Preconditions: Nib is open on a machine with two displays arranged side by side, ideally with different work areas (a menu bar or taskbar on one only, or different resolutions). Run the case once with the work panel closed and once with it open at a committed width.
  • Steps:
    1. Note the window position on the first display, then drag it by its titlebar onto the second display and release the pointer.
    2. Confirm the window stays where it was released: no jump, no snap to the display edge, and no vertical shift inherited from the first display.
    3. Drag the window so it straddles the boundary between the two displays and release it, then confirm it settles fully inside one display's work area without changing size.
    4. With the panel open, repeat the cross-display drag and confirm the panel remains an internal column at its committed renderer width; no reservation is re-planned and no panel-specific native geometry is applied.
    5. Drag the window back to the first display and confirm the application bounds continue to follow the dropped position without panel expansion.
    6. Leave the window on the second display, quit, and relaunch.
    7. Disconnect the second display while the window is on it, then reconnect it.
  • Expected: Every pointer release leaves the window at the position the user dropped it on the display they dropped it on. A straddling drop is normalized into one work area without a resize. Relaunch reopens the window on the display it was last used on rather than the one it started on. Removing the display the window occupied still relocates it to a live display, and reconnecting preserves the same application bounds contract; no work-panel reservation is restored.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/09-interaction-patterns.md §8, ADR 0132, ADR 0151
  • Acceptance: F (persistence), Quality
  • Milestone: M6+
  • Status: Unit-covered (work-panel-window.test.mjs: cross-display drag adoption, previous-display replan regression, work-area clamping); the two-display desktop journey and the relaunch/hotplug legs are pending

E2E-167: Window edge resize stays smooth and persists the settled bounds ​

  • Preconditions: Nib is open in a normal, non-maximized window on macOS, Windows, or Linux. On Windows, the left, bottom, and right rim must have no visible native border. Run the case with the work panel closed and once with it open at a committed width.
  • Steps:
    1. Drag each reachable window edge and one corner slowly, including a brief pause during the gesture, then release.
    2. Confirm the window follows the pointer continuously and does not jump to the default size or display edge while the pointer is down.
    3. With the work panel open, drag its inner divider slowly in both directions and confirm the panel width changes inside the existing window while the native bounds stay fixed. Repeat below the panel minimum and above its maximum, then verify the target follows the live budget (client width - 360px - expanded sidebar) instead of a fixed cap.
    4. Close and relaunch the app after the resize settles.
    5. On Windows, start an edge gesture, press Escape, and verify original bounds return. Maximize and enter fullscreen; corners must be square in both states. Restore the window and confirm the selected corner preference returns. Record native corner hit behavior rather than assuming that DWM corners pass clicks through.
    6. On Windows, inspect the default 12 DIP corner request before and after resizing. Apply authorized theme radii of 0 and 24 DIP, then return to a built-in theme. Radius 0 must be square. On Windows 11, positive values request the same system-rounded preference and are not asserted as exact DIP radii; on earlier Windows builds, the existing clip retains its exact DIP behavior. Reject an out-of-range radius without changing the surface.
    7. On Windows, minimize and restore the window, then confirm the selected corner preference is applied again. Open browser and plugin child views and confirm their pixels stay within the top-level silhouette. On Windows 11, also verify alpha theme colors are flattened over the built-in theme background on an opaque top-level window.
  • Expected: Native edge and corner hit regions remain available in frameless chrome, the minimum size remains 800×560 (capped to the display work area), and the recovery watchdog does not compete with a slow resize stream. The renderer-owned divider updates the bounded panel target without changing native bounds; the last settled window bounds and the committed panel width reopen after relaunch. Windows uses Electron's frameless native hit regions without the thick-frame rim; no left, bottom, or right native rim is visible. No temporary work-panel reservation width is persisted or restored. Windows 11 uses DWM's native rounded preference for positive radii and a square preference for radius 0, maximize, and fullscreen. The default request remains 12 DIP and authorized themes retain the integer 0..24 DIP range, but DWM selects the actual positive radius and the test does not require 12 and 24 to look different. Its top-level surface is opaque, including when a contributed alpha color is flattened over the built-in theme background. Earlier Windows builds retain the existing content clip and integer shape hit region. Browser/plugin child views stay within the top-level outline; Windows 11 corner click behavior is recorded as native system behavior and is not treated as setShape() click-through.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0029 / ADR 0151 / ADR 0317 / ADR 0325
  • Acceptance: A (app shell), F (persistence), Quality
  • Milestone: M6+
  • Status: test:e2e:window-controls covers corner preferences before and after minimize/restore, theme radius changes, fullscreen, maximize, and controls in an isolated profile. The isolated test:e2e:window-surface candidate samples controlled light/dark desktop backgrounds and child-view bounds. Windows 11 additionally verifies the opaque DWM path and system preference; earlier Windows builds verify the legacy clip and shape path. Both Windows suites still require a dedicated Windows desktop; source tests do not qualify native compositing. test:e2e:window-resize-native adds physical Windows left/right/bottom/corner drags and the 800×560 minimum; run it on a dedicated interactive desktop, since another app can take foreground or pointer input during the gesture. Relaunch persistence remains for native qualification; macOS/Linux native edge behavior is unchanged.

E2E-168: Expanded sidebar width follows an anchored resize gesture ​

  • Preconditions: Nib is open in the chat shell with the sidebar expanded and a retained project/session visible.
  • Steps:
    1. Drag the sidebar's right-edge handle from its default width toward both directions, including a slow drag with a brief pause, then release.
    2. Confirm the main pane reflows continuously and the sidebar does not jump when the pointer is pressed.
    3. With the work panel open or on a small supported window, continue the resize toward the maximum; inspect the composer toolbar while the main pane reflows.
    4. Repeat with a target below 240px and above the live maximum; release and confirm the sidebar width stops at 240px and the live cap (at most 520px) while MainChat stays at or above 450px. Continue the drag below 160px and confirm the sidebar collapses immediately without saving the in-progress width.
    5. Focus the edge handle and press ArrowLeft/ArrowRight, Home, and End; inspect the separator's current ARIA value. ArrowLeft at 240px must not collapse the sidebar.
    6. Start a resize, press Escape or cancel the pointer, then restart the app. Collapse and re-expand the sidebar as a separate check; the preferred expanded width must return.
    7. Drag the handle wide, double-click it, and confirm the width returns to the default 275px. Repeat after a minimum-width drag and on a squeezed window where the live cap is the binding bound.
  • Expected: The handle is discoverable on direct hover/focus without a full-height white/accent rail when the sidebar body is hovered, has no native window drag or text-selection side effect, and remains anchored to the press point. MainChat follows the live width until its 450px floor. Pointer release saves one clamped preferred width; Escape/cancellation restores the starting width without saving it. Double-clicking the handle restores the default 275px width, clamped to the live budget so the reset never breaches the 450px MainChat floor. A pointer width below 160px collapses the sidebar as a user action and restores the preferred expanded width on reopen. Keyboard changes commit immediately and expose localized width semantics. The saved width survives relaunch and is restored after sidebar collapse; collapse does not convert the preferred width into the icon-rail width. MainChat never falls below 450px, and the composer toolbar keeps its left and right groups on one row without squeezed buttons. Mode/permission labels remain single-line and ellipsized; no toolbar text is vertically split or overlapped.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0141, ADR 0238, ADR 0290, D280, D408, D459
  • Acceptance: A (app shell), F (persistence), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered (sidebar-preferences.test.mjs, sidebar-resize.test.mjs, sidebar-resize-math.test.mjs); rendered desktop drag and relaunch journey remains pending

E2E-162: A vendor account and an AI service offer the same model picker ​

  • Preconditions: One signed-in vendor (OAuth) account and one API-key AI service, both with discoverable models, and at least one of them exposing a reasoning-capable model.
  • Steps: 1) Open Settings → Model configuration. 2) Edit the AI service, expand a chosen model's Advanced disclosure, and note its context window, max output and thinking chips; cancel. 3) Edit the vendor account and do the same on one of its chosen models. 4) Toggle a thinking level on a reasoning-capable account model and save. 5) Reopen the account editor and read that model's chips. 6) For an OpenAI Codex account, inspect gpt-6-astra (or another account model also published under models.dev's openai provider) and confirm its published context/output limits and reasoning levels are present. For gpt-6.1-sol, verify a 1,050,000-token context and 128,000-token output cap. 7) In the account editor, hand-type a custom model ID the catalog does not publish, enable a thinking level on it, and save.
  • Expected: Both dialogs render the same picker — the same discovered list, the same search, the same free-form custom-model entry, the same chosen pane, the same Advanced disclosure and the same chips — so the account editor is no longer missing the advanced controls. A level enabled on an account model persists and reappears when the editor is reopened, including a level the catalog does not publish. OpenAI Codex's openai-codex adapter key resolves the matching openai models.dev record, so gpt-6-astra is not shown with generic 128,000 / 8,192 / no-reasoning defaults. The authenticated ChatGPT list comes from GET {base}/codex/models on the account token, so an id the pin does not know yet is selectable when that response includes it; pi-ai is only the OAuth/transport adapter; it supplies no sibling chat-model limits. models.dev enriches an ID only when its record matches. A model with no published record keeps its explicit levels, starts at off when no binding default is stored, and keeps all choices available for manual opt-in. The account's default model stays the head binding.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md §19, 03-runtime/11-provider-model-system.md §10, 08-meta/decisions-log.md (D270 refines D237/D240)
  • Acceptance: B (model config), Quality

E2E-163: Advanced model settings own the default thinking level and attachment capabilities ​

  • Preconditions: One AI service with discoverable models, including a reasoning-capable model that publishes at least three thinking levels, a vision-capable model, and a model models.dev describes as text-only. The service must persist model-local supportsImages overrides.
  • Steps: 1) Open Settings → Model configuration, edit the service and expand a reasoning-capable model's Advanced disclosure. 2) Enable at least three thinking levels and pick a default that is not the lowest enabled level, then save. 3) Reopen the editor and read the default selector. 4) Disable the level currently chosen as default and read the selector again. 5) Start a new session on that model and open the composer reasoning menu. 6) Back in Advanced, on the text-only model, turn Image input on, save, reopen and confirm the switch reports itself as overridden. 7) Open the Composer model menu and confirm that this model shows the vision badge. 8) On the published vision model, turn Image input off and confirm its Composer row no longer shows the vision badge. 9) Attach an image in a session on the text-only model. 10) Tick the same box back to the value models.dev publishes, save, and reopen. 11) Turn PDF input on for a model whose catalog entry omits it, save, and attach a PDF. 12) Configure a model, then point the service at an endpoint that no longer lists it, reopen the editor and read that model's capability boxes.
  • Expected: The default thinking level is selectable among the levels the binding enables and nothing else; it persists across reopen and is the level the home draft chip and a newly persisted session start at, not the strongest enabled level. Disabling the chosen default moves it to a still-enabled level rather than leaving a level the runtime would clamp away, and the selector is absent when a binding enables one level or none. An answered Image input switch overrides the published capability in both directions and survives reopen. The Composer model menu shows the vision badge for the effective true override on the published text-only model, hides it for an explicit false override on the published vision model, and follows the published value when the override is reset to null. The text-only model transports the attached image as an image content block. Ticking a box back to the published value stores "follow the catalog" rather than an equal-valued override, so a later catalog correction still reaches the binding without any separate reset control. All seven canonical thinking chips remain available even when the catalog publishes no reasoning support, so an endpoint can be opted in explicitly. PDF input records the capability without changing transport: the PDF stays a bounded file reference the model reads with its file tools. Each capability is one checkbox with a short label and no per-row explanatory copy. The Advanced body is a compact sheet: the alias hint is a title tooltip, limit fields hide native spinners, thinking chips span the pane with the default selector on the label row, and attachment plus delegation checkboxes share one wrapping row. The first chosen row starts expanded. A configured model absent from live discovery still shows its published capabilities rather than reading as undescribed.
  • Specs linked: 03-runtime/11-provider-model-system.md §6.2, 03-runtime/12-provider-config-schema.md, 03-runtime/13-model-catalog-and-selection.md §11.2, 04-ux/08-component-spec.md §11.7–11.8, 04-ux/06-settings-ia.md, ADR 0218
  • Acceptance: B (model config), Quality

E2E-164: Context compaction preserves the active task boundary ​

  • Preconditions: A provider fixture can complete multiple sequential tasks, trigger inline automatic compaction at 90% of hardLimit (before the hard boundary), exercise an active-turn checkpoint during a tool loop, and restart a session. The desktop transcript runner also replays a deterministic local provider-overflow recovery through the production runtime event handler, transcript store projection, and assistant-turn renderer.
  • Steps:
    1. Complete task A and task B in one session with distinct instructions and visible completion replies.
    2. Advance the context estimate to 91% of hardLimit, send task C, and capture the first provider request; assert that compaction is installed first.
    3. Trigger compaction during task D after tool results cross the 90% trigger while remaining below the hard limit; capture the follow-up provider request and confirm it uses the checkpoint.
    4. Force summary plus retained-tail recovery to fail once below hardLimit and once at/above it; the soft failure may proceed unchanged, while the hard-boundary failure must not send the provider request.
    5. Start an approved plan execution with the estimate at 91% of hardLimit; capture the first request and confirm preflight compaction runs before continue() while retaining the internal approved-plan instruction.
    6. Restart and reopen the session, then send another prompt.
    7. In the isolated overflow fixture, pause compaction after the first provider response exceeds the context window. Inspect the rendered assistant turn, then let the retry complete.
  • Expected: Automatic session compaction starts at floor(hardLimit * 0.9) for prompt, approved-plan, and in-run turn preflights; the estimate includes serialized messages, the active system prompt, and tool schemas. A request is never issued at or above hardLimit. A failed soft-trigger summary may proceed only while below the hard budget, and hard-limit failure remains fail-closed. A completed-turn checkpoint has an empty retained tail; the next request contains its summary plus task C and no bare A/B prompts. An approved-plan checkpoint uses active-turn retention so the instruction survives before continue(). An active checkpoint retains exactly the latest active user prompt, with no older user prompts or pre-boundary assistant/tool messages. Restart honors retainedTailMode, and legacy multi-user tails normalize to the latest user message. During recoverable overflow compaction, the existing assistant bubble stays streaming without an error card; a successful retry completes that same bubble once. A failed retry surfaces its terminal error. If automatic summary generation fails at the hard boundary, the fallback retains a bounded recent user tail. The visible transcript remains complete and checkpoint rows remain.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/04-data-storage.md, 03-runtime/16-tool-result-limits.md, 08-meta/decisions-log.md (D623), ADR 0064, ADR 0136
  • Acceptance: C (chat/stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (packages/agent-runtime/src/context-budget.test.ts, runtime.test.ts, subagent-context.test.ts); isolated overflow recovery fixture passed through pnpm test:e2e:transcript; broader provider/UI journey Draft

E2E-165: A2A and Peer tools are withdrawn ​

  • Preconditions: Agent mode; protocol v11 host. A user subagent definition lists A2A (or Peer) among its tools. Two Agent-mode sessions are open.
  • Steps: 1) Handshake and inspect host capabilities. 2) Inspect the parent Agent tool list and ToolSearch results. 3) Load the definition that names A2A/Peer. 4) Start two concurrent working-tool delegates. 5) Ask whether one conversation can see the other.
  • Expected: Handshake succeeds at protocol v11 and does not advertise "a2a". A2A and Peer are absent from the parent catalog, deferred tools, and ToolSearch. The unknown tool names are dropped with a parse warning; remaining working tools still spawn. Concurrent delegates report only through Task* — there is no sibling or parent-to-parent channel. a2a.* RPC methods return method-not-found. Schema v14 databases have no a2a_* tables.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f.2, 03-runtime/03-tools-and-permissions.md §10.2, 03-runtime/06-host-rpc-protocol.md §3–§4, 08-meta/decisions-log.md (D326), ADR 0165
  • Acceptance: E (tools & permissions) + C (chat/stream) + Security
  • Milestone: M5
  • Status: Draft

E2E-165b, E2E-165c, and E2E-165d (A2A push, cross-session A2A, parent A2A) are withdrawn with ADR 0165.

E2E-166: Subagent model selection ​

  • Preconditions: Agent mode; at least one provider has two configured model bindings; a builtin subagent definition is available.

  • Steps: 1) Enable the delegation checkbox for one model binding, save the provider, reopen it, and confirm the checkbox remains enabled; restart the app, reopen the provider again, and confirm it is still enabled. 2) Start a session and inspect the parent agent's system prompt for the delegation model summary. 3) Delegate a Task with model: "provider/modelId" pointing to the enabled binding. 4) Delegate a Task with model: pointing to a binding that is not enabled for subagents. 5) Delegate a Task with model: pointing to a model that is not configured at all. 6) Delegate a Task with no model: parameter and a definition that has a frontmatter model pin. 7) Delegate a Task with no model: parameter and a definition that has no frontmatter model pin. 8) With no enabled delegation models, delegate a Task once with no model: and once with model: repeating the current session's provider/modelId. 9) Pin an unselected model to one definition; attempt to use it as another definition's explicit override, then invoke its owner with no override. 10) Remove a previously enabled override key and start another prompt in the same idle session.

  • Expected:

    1. Saving and reopening the provider preserves the availableForSubagents opt-in, including after an application restart.
    2. The delegation model summary appears in the parent's system prompt listing every successfully resolved model marked availableForSubagents, with no definition-only pins. The Task definition catalog displays each default model and recommends omitting model to preserve it.
    3. The Task tool accepts the model parameter and the delegate runs on the specified model, not the session model; its delegation node shows the effective model id immediately after the subagent name.
    4. If the model is not configured or not enabled for delegation, the Task returns a tool error listing available models. With an empty override catalog, the error, system summary and Task description name the Settings → Models / model Advanced / "Available for AI delegation" / save path. A denied guessed override sends no child request; retrying with model omitted still uses the definition default or inherits the parent model.
    5. Resolution priority is Task.model parameter → definition frontmatter pin → session model.
    6. On-demand resolution succeeds for models enabled in provider settings via the provider.resolveSubagentModel RPC.
    7. When no delegation model is configured, omitting model: and explicitly repeating the current session provider/modelId both start the delegate on the session model; the latter is not reported as an unavailable model.
    8. A private pin remains usable by its definition when model is omitted or when Task.model repeats that definition's own pin key, but cannot be selected for another definition without opt-in. Rejection issues no child provider request. On-demand authorization does not retire an idle runtime; changed launch opt-in does, so a stale cached binding grants no selection rights.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, 03-runtime/11-provider-model-system.md §7, 03-runtime/12-provider-config-schema.md §2, 08-meta/decisions-log.md (D278)

  • Acceptance: C (chat/stream) + B (model configuration) + E (tools)

  • Milestone: M6+

  • Status: Partially automated. pnpm test:e2e:subagent-models drives the built sidecar over real NDJSON and a local deterministic SSE model fixture: empty-catalog guidance and inheritance after a denied override, private cross-definition rejection, own-pin echo, normal pin use, allowed override priority, on-demand authorization without runtime rebuild, exact-session inheritance, and revocation across two prompts all pass. Runtime unit tests cover the same selection gates and the desktop launch test exercises independent opt-in, revocation, and accounts sharing a vendor alias; the wiring test checks unique on-demand matching. The settings checkbox UI/persistence journey and live external provider execution remain manual; this fixture does not claim a complete native UI journey.

  • Authorization regression coverage (#841): pnpm test:e2e:subagent-models grants a model on demand, reuses the runtime, then revokes it without changing the launch catalog. The next prompt must reauthorize and issue no child request. A transcript-restored resume with a colliding model id must use the session binding rather than another definition's private account. Runtime tests also cover own pins/fallbacks, opted-in bindings, visible fallback metadata, live-key reauthorization, and late RPC responses crossing parent turns.

E2E-170: Shell titlebars use borderless chrome ​

  • Preconditions: Nib is open in chat, at least one destination page, and Settings on a supported light or dark theme. On Windows/Linux, renderer- drawn window controls are visible.
  • Steps: 1) Inspect the top band on the chat, destination, and Settings surfaces. 2) Switch between light and dark themes and repeat. 3) On Windows/Linux, inspect the window-control band and its boundary with the adjacent surface. 4) Drag the titlebar and activate each window-control button.
  • Expected: The shared 46px top band remains stable and draggable, but its lower edge has no visible border line in either theme or route. The Windows/Linux control band has no bottom line; only its existing faint side seam separates the controls from the adjacent surface. Focus rings, hover states, window actions, and content clearance remain unchanged.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md
  • Acceptance: A (app shell), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered (topbar-consistency.test.mjs, settings-drag-region.test.mjs, window-menu.test.mjs); rendered light/dark desktop journey remains pending

E2E-172: Mid-turn thinking pick does not collapse an unpinned session menu ​

  • Preconditions: The app default is a reasoning-capable custom provider/model whose binding publishes a sparse set such as low/high/max. A session exists on the default create path (provider_id/model_id NULL).
  • Steps: 1) Send a prompt so the session is running. 2) Open the Composer model × reasoning menu and select a different enabled thinking level. 3) Reopen the thinking submenu without waiting for the turn to finish. 4) Switch to another session and back. 5) Let the turn finish.
  • Expected: The chip shows the selected level (not Off). The submenu still lists every enabled binding level. Switching sessions does not collapse the menu. After agent_end, the queued configuration is durable. Re-saving a provider is not required to recover the menu.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/13-model-catalog-and-selection.md, 04-ux/08-component-spec.md §11
  • Acceptance: B (model config), C (chat and stream), Quality
  • Milestone: M6
  • Status: Unit-covered (session-thinking.test.mjs, thinking-ui.test.mjs, composer-send-state.test.mjs); full UI scenario Draft (run only in a capable environment when this surface changes)

E2E-174: Binding default thinking level seeds drafts and new sessions ​

  • Preconditions: One AI service with a reasoning model whose published levels omit off (for example low / high / max) and whose Advanced default thinking level is a non-max enabled level such as low.
  • Steps: 1) Open the home composer with no active session and read the model × reasoning chip. 2) Create a new task without opening the reasoning menu, then read the chip and the session's stored thinkingLevel. 3) On the home draft, switch to that model from the model menu and read the chip before sending. 4) Change the binding default to another enabled level, save, and repeat steps 1–2 on a fresh draft.
  • Expected: The home draft chip, a draft model switch, and the newly persisted session all start at the binding's stored default, not the strongest published or enabled level. Changing the default in Settings changes the next draft and new session and does not rewrite existing sessions.
  • Specs linked: 03-runtime/11-provider-model-system.md §6.2, 03-runtime/13-model-catalog-and-selection.md §4, 04-ux/08-component-spec.md §11.4 / §11.5, 08-meta/decisions-log.md (D303)
  • Acceptance: B (model config), C (chat/stream), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered (thinking-levels.test.ts, thinking-ui.test.mjs); rendered desktop journey remains pending

E2E-175: A paged Read is not shown as truncated ​

  • Preconditions: A project-bound Agent session; the workspace contains a text file of at least 3000 lines whose lines are shorter than 16,384 characters, plus a fixture whose first line exceeds that cap.
  • Steps:
    1. Read the long file with no offset/limit.
    2. Read the same file with offset equal to the reported next offset and a modest limit.
    3. Read the over-long-line fixture.
    4. Grep a token that matches more than the default headLimit.
  • Expected:
    • Step 1 returns the default 2000-line window, truncated: false, no truncated chip, totalLines of the whole file, and a notice naming the next offset. It does not tell the model to Grep.
    • Step 2 continues without overlap and stays truncated: false.
    • Step 3 sets truncated: true, counts the clipped line in notice, and shows the truncated chip.
    • Step 4 sets truncated: true because remaining matches exist, and shows the chip.
  • Specs linked: 03-runtime/16-tool-result-limits.md §2 / §5, 04-ux/08-component-spec.md §9.2, 08-meta/decisions-log.md (D306)
  • Acceptance: C (chat & stream), E (tools & permissions)
  • Milestone: M5
  • Status: Unit-covered (host-core tools tests)

E2E-176: Settings Info opens a prefilled GitHub bug form ​

  • Preconditions: Settings can be opened; the machine can launch a system browser.
  • Steps: 1) Open Settings → Info. 2) Confirm the Application row shows the current app version. 3) Search settings for the Report a problem label. 4) Activate Open GitHub.
  • Expected: The row is indexed by Settings search and stays on Info. The action calls pi-desktop/app/openFeedback with no URL from the renderer. Main opens https://github.com/secretkeyllc/nib/issues/new with template=bug_report.yml and prefills app-version, os, and environment. The GitHub bug form still requires description, reproduction steps, expected, actual, version, and OS; blank issues remain disabled.
  • Specs linked: 04-ux/06-settings-ia.md, 03-runtime/01-ipc-protocol.md, 06-delivery/03-ai-development-workflow.md, 08-meta/decisions-log.md (D313), ADR 0157
  • Acceptance: H (diagnostics), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered (github-feedback.test.ts, feedback.test.mjs); rendered desktop journey remains pending (run only in a capable environment when this surface changes)

E2E-177: Switching a long running session keeps the newest prompt in view ​

  • Preconditions: A session has stayed open long enough that the renderer holds more than the newest-100 durable page (hundreds of user/assistant/tool rows); a second session exists so a switch is possible.
  • Steps: 1) Interrupt an in-flight reply if needed, then send a new prompt. 2) While that turn is still running, switch to the other session and back. 3) Confirm the newest user row (and any streaming tail) is at the bottom of the transcript and the session still shows as running. 4) Optional: Stop, switch away and back; the same newest rows remain in chronological order. 5) With an older identical prompt already durable, send that same text again and immediately switch away and back before its persistence acknowledgement is applied.
  • Expected: Revalidation does not append older live history after the bounded durable page. The mounted trailing window still shows the just-sent prompt and the live tail. The turn continues in the background across the switch. The older same-text row remains distinct and cannot suppress the new optimistic prompt; the durable echo replaces that prompt in place. Stop is not required to make the prompt visible again.
  • Specs linked: 04-ux/08-component-spec.md §1.6 / §3.5, 04-ux/09-interaction-patterns.md (session isolation), ADR 0120, ADR 0137, 08-meta/decisions-log.md (D261, D317)
  • Acceptance: C (conversation & stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (session-transcript.test.mjs D317 and missed-ack same-text cases); full desktop journey Draft (run only in a capable environment when this surface changes)

E2E-183: Switching an idle session keeps a completed reply that is not on disk yet ​

  • Preconditions: Two conversations exist. The source has a completed user prompt and an assistant reply still on screen. The durable session.get page for that session still has only the user row (the persistence outbox has not flushed the assistant line).
  • Steps: 1) Wait until the turn is no longer running. 2) Switch to the other session and back. 3) Confirm the assistant reply is still visible. 4) Optional: wait until the outbox drains, switch away and back again; the reply remains and now also exists in the JSONL.
  • Expected: Idle revalidation stitches the live snapshot onto the durable page. A completed live-only assistant/tool row is not replaced by the user-only durable page. Live provenance is not cleared until that page contains every live id.
  • Specs linked: 04-ux/08-component-spec.md §1.6 / §3.5, 04-ux/09-interaction-patterns.md (session isolation), ADR 0137, 08-meta/decisions-log.md (D317, D324)
  • Acceptance: C (conversation & stream), F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (session-transcript.test.mjs D324 case, session-switch-performance.test.mjs); full desktop journey Draft (do not run E2E locally unless explicitly requested)

E2E-184: Completed AI replies survive closing and reopening the app ​

  • Preconditions: A session with at least one finished user prompt and assistant reply. The reply may still be in the persistence outbox or only in sessions/<id>.inflight.json when the process exits.
  • Steps: 1) Send a prompt and wait until the assistant reply is complete and the session is idle. 2) Quit the app (window close / tray Quit) and relaunch. 3) Open the same session. 4) Repeat with a hard kill of the process immediately after the reply appears on screen. 5) Repeat with several completed turns, then quit and reopen.
  • Expected: Every user prompt and every completed assistant reply is visible after relaunch. No session shows user rows with empty gaps where the answers were. A reply recovered from a leftover checkpoint of a completed turn is complete, not aborted. Tool rows that had reached tool_end are also present. Earlier turns already on disk are unchanged.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/07-process-model.md, 03-runtime/10-session-state-machine.md, ADR 0041, ADR 0153, 08-meta/decisions-log.md (D327)
  • Acceptance: C (conversation & stream), F (persistence)
  • Milestone: M5
  • Status: Unit-covered (sessions.rs D327 inflight tests, persistence-outbox.test.mjs, inflight-checkpoint.test.mjs); protocol reproduction in the issue-42 host+outbox harness; full desktop journey Draft (run only in a capable environment when this surface changes)

E2E-178: A missing sessions row is restored so the outbox can drain ​

  • Preconditions: A session has a live sessions/<id>.jsonl and queued appends in session-message-outbox.json, but its row is gone from pi.sqlite sessions (WAL/index loss). The queued head may carry a turn id whose row is missing or belongs to another session. For legacy replay, the JSONL may already contain repeated copies of that message id.
  • Steps: 1) Confirm the sidebar no longer lists the session and session.appendMessage would fail session not found. 2) Restart the app (or otherwise complete a host handshake that flushes the outbox). 3) Replay an unindexed message with a stale turn id after writing its transcript line more than once, with a later indexed message behind it. 4) Optional: delete the session and confirm its outbox entries are dropped rather than resurrected.
  • Expected: Host boot reinserts the sessions row from JSONL and rebuilds its index. The stale turn link is omitted without losing the message; the outbox drains and later messages keep transcript order. Replayed copies are updated in place and do not add another line; last_seq and search index match the deduplicated transcript. A user-deleted session is not recreated.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/07-process-model.md, ADR 0041, 08-meta/decisions-log.md (D318)
  • Acceptance: C (conversation & stream), F (persistence)
  • Milestone: M5
  • Status: Unit-covered (sessions.rs stale-turn/replay tests, persistence-outbox.test.mjs full-cap rejection and drain tests); full desktop journey Draft (run only in a capable environment when this surface changes).

E2E-179: Parent tools after a Task fan-out stay outside the delegation card ​

  • Preconditions: A project-bound Agent session whose provider stream can emit two Task calls in one assistant message and then keep working — think, Read, Grep — before a TaskWait.
  • Steps: 1) Prompt a turn that fans out two delegates, then continues with parent thinking and workspace reads while at least one delegate is still running. 2) Inspect the expanded delegation card and the rows below it. 3) Let the delegates settle and inspect elapsed time on the card versus the parent processing group. 4) Reload the session and re-expand the card.
  • Expected: The delegation card contains only the main-agent root and the two Task nodes. Parent thinking, Read, Grep, and TaskWait render in a separate processing group, not flush against the subagent tile and not under a “Subagent working” header. The card keeps inset from its tile edge. While a delegate is still running the card stays labelled working, remains open, and ticks elapsed from that fan-out's own timestamps even after the parent has moved on. Reload preserves the same split.
  • Specs linked: 04-ux/08-component-spec.md §9.9, ADR 0062, decisions-log D265, D319
  • Acceptance: C (conversation), Quality
  • Milestone: M6+
  • Status: Unit-covered (assistant-turns.test.mjs, subagent-topology.test.mjs, subagent-transcript.test.mjs); desktop journey pending (run only in a capable environment when this surface changes)

E2E-180: Sent file references stay chips and open on click ​

  • Preconditions: An Agent session in a workspace that contains a nested source file, an HTML file, and a file whose name contains whitespace, and a project group whose second folder holds a source file of its own. The composer can also paste an OS file into session scratch. The bundled File Manager plugin is loaded.
  • Steps: 1) Attach a workspace source file, a workspace HTML file, a whitespace-named file, and a pasted scratch file via composer chips, then send. 2) Inspect the user bubble. 3) Click the HTML chip, then the workspace source chip, then the scratch chip. 4) Attach the file that lives in the project's second folder and click its chip.
  • Expected:
    • Each sent reference renders as a compact leaf-name chip (icon + name), not as a full @path. The tooltip and accessible name keep the canonical path. Quoted and scratch-absolute paths are included.
    • A chip plus a short prompt keeps the user plate content-sized; it does not stretch to the min(82%, 600px) ceiling.
    • Clicking an unpositioned project or scratch HTML chip opens the work-panel browser on that file.
    • Clicking the workspace source chip opens it in the File Manager work-panel view on that file; the host file: tab and the OS default application are no longer what a chip click opens.
    • Clicking the scratch chip opens the host file: tab on that file's absolute path, because it lives outside the File Manager's project root.
    • Clicking the chip of the file in the project's second folder opens it in the File Manager view on that file: completion searches the whole project group, primary folder first, and a sibling-folder file is addressed by absolute path because a relative path always means the primary folder (ADR 0263).
    • The persisted user message still contains the canonical @path text for the agent.
  • Specs linked: 04-ux/08-component-spec.md §8.3 / §11.8, 04-ux/09-interaction-patterns.md §8a.2, 03-runtime/01-ipc-protocol.md, ADR 0163, ADR 0241, ADR 0262, ADR 0263, 08-meta/decisions-log.md (D320)
  • Acceptance: C (conversation & stream), Quality
  • Milestone: M5
  • Status: Unit-covered (chat-links.test.mjs, transcript-file-chips.test.mjs, fs-panel-guard.test.mjs, transcript-style.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-CHAT-shorthand-file-ref-opens-the-matching-file ​

  • Preconditions: A project group whose primary folder holds img/openimage.js, src/dir/a.ts, and a deeper second dir/a.ts such as packages/app/dir/a.ts, and whose second folder holds lib/only-here.ts. The open session's scratch store holds a file with the same leaf name openimage.js and one file no project folder has; the attachment store holds an attachments/<sha256> blob. The transcript renders assistant markdown.
  • Steps: 1) Prompt a turn whose reply names openimage.js as inline code and click it. 2) Prompt a turn that names dir/a.ts and click it. 3) Prompt a turn that names the scratch-only file, click it, then do the same for the attachments/<sha256> ref. 4) Prompt a turn that names an absolute path to a project file whose leaf name also exists in scratch, and click it. 5) Prompt a turn that names missing-helper.js and click it. 6) Prompt a turn that names only-here.ts and click it.
  • Expected:
    • Clicking openimage.js opens the project's img/openimage.js, although the scratch store holds a file with that same leaf name: the project is searched to exhaustion before the scratch store is considered at all.
    • dir/a.ts matches both src/dir/a.ts and packages/app/dir/a.ts, so the shorthand is ambiguous and opens nothing. The exact reference src/dir/a.ts opens that path.
    • A reference the project cannot answer resolves in the session scratch store; one that neither answers resolves in the attachment store, and an attachments/<sha256> ref opens that stored blob.
    • An absolute reference that names a real file inside a known root wins outright, whichever shorthand would otherwise have matched.
    • only-here.ts opens the second folder's lib/only-here.ts: the primary folder is searched first and to exhaustion, then the project group's other folders in the group's own order, and the match names the folder that answered (ADR 0263).
    • A file that answered from a sibling folder is addressed to the work panel by its absolute path, while a primary-folder file stays project-relative (ADR 0263).
    • A reference that matches nothing raises an error toast reading No file matches missing-helper.js and opens nothing: no new work-panel tab, no empty panel, no blank side-browser page, and the work panel and transcript keep the content they already had.
  • Specs linked: 03-runtime/01-ipc-protocol.md § fs, 04-ux/09-interaction-patterns.md §8a.2, ADR 0124, ADR 0163, ADR 0249, ADR 0262, ADR 0263
  • Acceptance: C (conversation & stream), D (workspace), Quality
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/chat-ref-resolve.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-CHAT-spaced-absolute-file-paths ​

  • Preconditions: On Windows, open a project rooted at a directory whose name contains a space. Create readme.md there through a chat Write tool call; keep a same-name file in another project directory and one file outside every allowed root. Repeat the path-recognition checks with a POSIX project path containing a space on macOS or Linux.
  • Steps: 1) Click the full path in the Write row. 2) Click the same full path as inline code and as ordinary text in the assistant reply. On Windows, also click it as a Markdown link with an angle-bracketed destination, for example [readme.md](<C:\workspace with spaces\readme.md>). 3) Click a relative path whose middle directory contains a space, then a first-segment spaced path using an explicit @"..." reference. 4) Click the outside absolute path.
  • Expected: Every allowed reference opens the exact file in the existing side file view; no path is truncated to its suffix or redirected to the same-name file. The Windows Markdown link keeps a valid sanitized address and reaches the existing file opener with the original drive path. The outside path opens nothing and reports the access limit, while a missing in-root file reports that no file matches.
  • Specs linked: 03-runtime/01-ipc-protocol.md § fs, 04-ux/08-component-spec.md §8.3.
  • Acceptance: C (conversation & stream), D (workspace), Quality
  • Milestone: M5
  • Status: Unit/component-covered (chat-links.test.mjs, chat-ref-resolve.test.mjs, tool-row-file-refs.test.mjs); full UI journey Draft.

E2E-CHAT-file-ref-opens-the-surface-that-owns-it ​

  • Preconditions: The bundled File Manager plugin is loaded and enabled and the work panel is closed. The open session's scratch store holds one HTML file and one other file, and the attachment store holds one blob. The project is a group whose primary folder contains an .html page and a text file, and whose second folder holds a text file of its own. The same session's transcript carries a Read row whose summary names a project file, a Glob result listing project paths, and a Grep result grouping its hits by file.
  • Steps: 1) Click a project file reference in an assistant reply. 2) Type an unsaved edit into that view and click the same reference again. 3) Click a reference that resolves into the session scratch store, then the attachments/<sha256> reference. 4) Click a workspace .html reference in an assistant reply and the same reference as a sent user chip; click the scratch HTML reference too. 5) Click the file path in the tool row's summary, then a path in the Glob result's file list and a path heading of the Grep result. 6) Disable the File Manager plugin, then click a project file reference and the tool row summary. 7) Click a reference that resolves in the project's second folder, then one that resolves in its primary folder. 8) Right-click the sent @path chip, the inline-code reference, the markdown link, the local image, a tool row's file path, a tool result's file list, and an attachment image chip; then right-click a reference that matches nothing. On an HTML reference, open the Open with submenu and choose File Manager, built-in browser, and default browser; repeat for the scratch HTML file. 9) Use Copy full path and Copy relative path, then do the same on a reference that resolves in the session scratch store.
  • Expected:
    • Right-clicking a file reference offers Copy full path and Copy relative path on each transcript surface that names it. The transcript menu has no Show in folder item. An unpositioned HTML reference also has a second-level Open with submenu for File Manager, built-in browser, and default browser; non-HTML references keep only the copy actions. Each open choice resolves the reference first, and a missing reference reports itself without opening a same-named file elsewhere.
    • A copy writes exactly what it names: the absolute address for the full copy and the project-relative spelling for the relative one; a scratch or attachment file reports that it has no relative path instead of copying an absolute one under that name.
    • A project file opens in the File Manager work-panel view on that file, with its ancestor folders expanded and the file selected; no host file: tab is added for it.
    • Clicking the same reference again does not reload the view: the unsaved edit is still in the editor and no second tab appears.
    • A scratch or attachment file opens in the host file: tab under Open resources, addressed by its absolute path, never in the File Manager view.
    • A plain click on an unpositioned .html / .htm reference from any project folder or session scratch opens it in the work-panel side browser. In the Open with submenu, File Manager opens the resolved file in that plugin view, built-in browser opens the same absolute path in the side browser, and default browser uses the host's validated OS file-open path. A positioned HTML reference keeps its line-aware host file tab behavior.
    • A tool surface reaches the destination of the file it names, not one of its own: the tool row's summary path and the paths of the Glob file list and the Grep path headings open the same project file in the File Manager view. A summary path that is a link opens the file without expanding the row, and only a summary without a resolvable target falls through to the row's own disclosure.
    • A reference that resolved in the project's second folder opens in the File Manager view on that file, reached by its absolute path, with no host file: tab; the reference from the primary folder opens in that same view addressed project-relative (ADR 0263).
    • With the plugin disabled, a direct project-file click from the reply, tool row, or result list starts the bundled File Manager within its existing scope and permission grant, then opens the requested file there. If the scope or grant excludes the current project, or the view still cannot start, the host file: tab opens with an unavailable notice.
  • Specs linked: 04-ux/08-component-spec.md §8.3, §9.6, 04-ux/09-interaction-patterns.md §8a.2, ADR 0104, ADR 0163, ADR 0241, ADR 0249, ADR 0262, ADR 0263
  • Acceptance: C (conversation & stream), G (plugins), Quality
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/chat-file-open-with-menu.test.mjs for menu destinations and resolved-path routing, apps/desktop/test/transcript-file-chips.test.mjs for the wiring, and apps/desktop/test/tool-row-file-refs.test.mjs for the work-panel entry each shape of resolution produces). The isolated Electron renderer journey in scripts/e2e-file-ref-line-scroll.mjs verifies a Markdown link enables the File Manager on demand and a positioned reference still opens in the host viewer. pnpm test:e2e:transcript also exercises the rendered second-level menu, destination selection, and keyboard focus return; the full file-reference journey through all three destinations remains Draft.

E2E-CHAT-mp4-attachment-opens-in-system-player ​

  • Preconditions: A conversation has one pasted MP4 in session scratch and two stored MP4 attachments whose content-addressed refs have no extension: one larger than 512 KiB and one smaller binary file. All retain video/mp4 metadata.
  • Steps: Click each attachment in the transcript, then choose Open with default application in the host file tab. Repeat with a workspace .mp4.
  • Expected: The large attachment reports that inline preview is unavailable because of size and the small attachment reports binary content; both offer the OS-open action. The host validates realpath containment and hands the OS an .mp4 alias of the same stored bytes. A file outside allowed roots or a symlink escape is refused. Failed OS handoff is visible to the user.
  • Specs linked: 03-runtime/01-ipc-protocol.md § fs, 04-ux/09-interaction-patterns.md §8a.2.
  • Acceptance: C (conversation & stream), Quality, Security
  • Milestone: M5
  • Status: Electron fixture covered for pasted scratch MP4 and both blob sizes (test:e2e:composer-paste); full installed-app/player journey Draft.

E2E-181: An imported skill is listed in the next session catalog ​

  • Preconditions: Settings > Agent > Skills is open. A conventional <skill>/SKILL.md document has a non-ASCII frontmatter name and a folded YAML description. An empty Agent session is available on the same project the skill will be imported into.
  • Steps:
    1. Import the SKILL.md into Global, then into the selected project.
    2. Confirm the Skills page shows the display name, the ASCII id (directory name, not skill), and the flattened description.
    3. Start a new Agent session on that project and ask the agent to use the skill by display name.
    4. Repeat with a second directory skill that also lacks an ASCII name, and with a skill whose description is a | block.
  • Expected:
    • Import succeeds. The catalog lists both skills with distinct ids.
    • The next session's system prompt includes each skill's id, name, and flattened description. The Skill tool loads the body by that id.
    • A misspelled Skill id lists user skill ids among the available skills, not only plugin ids.
    • Neither document is dropped because its title is non-ASCII or because both files are named SKILL.md.
  • Specs linked: 03-runtime/01-ipc-protocol.md §12b, 07-plugins/01-plugin-system.md §12.3, 08-meta/decisions-log.md (D174, D194)
  • Acceptance: E (tools & permissions), Quality
  • Milestone: M5
  • Status: Unit-covered (host-core user_skills / agent_capabilities tests, apps/desktop/test/plugin-skills.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-182: Relative file paths in chat and markdown preview open ​

  • Preconditions: An Agent session in a workspace that contains apps/desktop/src/App.tsx, docs/adr/0163-transcript-file-reference-chips.md, docs/spec/00-baseline.md, 核查报告.md, and a Unicode-named file such as 报告.pdf.
  • Steps: 1) Open an existing session whose transcript already contains assistant markdown. 2) Prompt a turn whose assistant reply mentions apps/desktop/src/App.tsx as a bare path, as inline code, a Unicode path such as 报告.pdf, and as a markdown link; also include [核查报告.md](核查报告.md). 3) Click each. 4) Open the ADR markdown file in the work-panel files viewer and click a ../spec/00-baseline.md link. 5) Include an absolute path under the workspace, an outside absolute path, and a ~/ path in chat; confirm only the under-root path becomes a target. 6) Send a user message 使用llama.cpp,给我迁移步骤,只读。, then a user message that names the real apps/desktop/src/App.tsx as a bare path and as @apps/desktop/src/App.tsx. 7) Add a workspace directory link to an outside page.md, a second in-root page.md, and a link to an in-root file; check the exact linked path and a shorthand page.md. Repeat with a session scratch directory link, and check a dangling link.
  • Expected:
    • Opening the session paints the transcript without throwing.
    • Each chat path opens apps/desktop/src/App.tsx in the File Manager work-panel view — the file view a chat click prefers — not a host file: tab.
    • Clicking [核查报告.md](核查报告.md) in the assistant reply opens that project Markdown file in the File Manager work-panel view.
    • Unicode filenames and multi-segment paths inside the workspace become targets, while an outside absolute path and a ~/ path stay plain text.
    • An absolute path under the workspace resolves to its workspace-relative target.
    • The markdown-file ../ link opens docs/spec/00-baseline.md, not a workspace-root spec/00-baseline.md.
    • A ../../../outside.ts link from docs/adr stays inert.
    • The user sentence containing 使用llama.cpp stays exact original text, not a file chip, when that path does not exist.
    • A user-message bare path becomes a chip only after fs/resolveRef confirms a real file; an explicit @path chips immediately.
    • Escaping and dangling exact links do not become file targets or redirect to the same-name in-root file. A shorthand selects the first currently valid in-root file, while an in-root link still opens its target. Scratch links follow the same containment rule.
  • Specs linked: 04-ux/08-component-spec.md §8.3, 08-meta/decisions-log.md (D322)
  • Acceptance: C (conversation & stream), D (workspace), Quality
  • Milestone: M5
  • Status: Unit-covered (chat-links.test.mjs, verified-chat-files.test.mjs, transcript-file-chips.test.mjs, markdown-prose-style.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-185: External URL opens stay on http(s) and mailto ​

  • Preconditions: A chat transcript can render markdown links. A plugin is granted shell.openExternal. The work-panel preview can load an http page and a workspace HTML file.
  • Steps: 1) Modified-click https, mailto, file:, javascript:, ms-msdt:, and a custom-scheme markdown link (target="_blank"). 2) From the plugin, call pi.shell.openExternal with https, mailto, and file:. 3) In the embedded preview, window.open an https URL and a file: URL; use Open in browser on the http page and on the workspace HTML file.
  • Expected:
    • https and mailto open in the OS handler. file:, javascript:, data:, ms-msdt:, and custom schemes do not.
    • Plugin file: fails with INVALID_ARGUMENT; mailto succeeds.
    • Preview window.open of a non-allowlisted scheme is denied in-app and does not call openExternal.
    • Open in browser for http(s) uses openExternal; for an in-root file preview it uses openPath, not a file: URL through openExternal.
  • Specs linked: 05-security/01-security.md, 07-plugins/04-plugin-security.md §8, 07-plugins/03-plugin-api.md, ADR 0109, ADR 0168, 08-meta/decisions-log.md (D330)
  • Acceptance: Security
  • Milestone: M5
  • Status: Unit-covered (safe-open-external.test.mjs, feedback.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-186: Token usage dashboard is plugin-owned; host still stores turn totals ​

  • Preconditions: A profile with at least one completed Agent turn that reported provider usage after this build. Settings is reachable. Plugin pi.token-insights may be installed.
  • Steps: 1) Complete a turn that also settled a subagent. 2) Open Settings. 3) Search settings for "tokens" / "用量". 4) Open Token Insights from the command palette (usage / 用量). 5) Confirm the transcript assistant chip.
  • Expected:
    • The rail has no Usage / 用量 destination. Preferences is General, AI, Shortcuts.
    • Settings search does not surface a usage tab.
    • stats.getTokenUsageHistory still returns completed-turn totals that include subagent spend.
    • The assistant chip under the transcript still shows parent-only provider usage.
    • Token Insights is the heatmap / KPI dashboard. When the plugin is installed, Nib remainders from the host turns table appear there without rewriting message.usage.
  • Specs linked: 04-ux/06-settings-ia.md, 03-runtime/01-ipc-protocol.md, 03-runtime/06-host-rpc-protocol.md, ADR 0171, ADR 0173, 08-meta/decisions-log.md (D331, D335)
  • Acceptance: F (persistence), Quality
  • Milestone: M5
  • Status: Unit-covered (agent-runtime usage split, host-core history aggregation, settings-search / i18n catalogs); plugin remainder merge covered in pi-desktop-plugins; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-187: History attachments and local markdown images render inline ​

  • Preconditions: An Agent session in a workspace that contains docs/pixel.png. The user has previously pasted an image so the session JSONL stores an attachments/<sha256> image ref with a stored mimeType.
  • Steps:
    1. Reopen the session. Confirm the pasted image renders as a chip like any other attachment, continuing the body text instead of heading it, and that hovering or focusing it reveals its preview card.
    2. Click the chip. Confirm the host files viewer opens on that attachment ref and shows the image.
    3. Send a turn whose assistant markdown includes ![](docs/pixel.png) and ![](/etc/passwd). Confirm the workspace image renders inline and the outside-root path does not load file bytes.
    4. Confirm fs/readImageDataUrl with ref: "/etc/passwd" and mimeType: "image/png" returns missing, not a data URL.
  • Expected:
    • Contained image refs display inline within the 5MB cap.
    • Outside-root paths and mime spoofing of non-image extensions stay closed.
    • Clicking a resolved image chip opens the host file: tab, not the OS handler.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 04-ux/08-component-spec.md §8.3, ADR 0172, 08-meta/decisions-log.md (D334)
  • Acceptance: C (conversation & stream), D (workspace), Security, Quality
  • Milestone: M5
  • Status: Unit-covered (fs-panel-guard.test.mjs, message-image-display.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-188: Plugin host APIs list models, read in-flight context, and complete ​

  • Preconditions: At least one authenticated provider; a development plugin granted models.list, session.read, and agent.complete; an Agent session with a prior user turn.
  • Steps:
    1. From the plugin process call pi.models.list(). Confirm only ready models are returned and no secret fields appear.
    2. Call pi.session.getLlmContext() outside a tool execution. Confirm INVALID_ARGUMENT.
    3. Ask the Agent to call the plugin tool. Inside execute, call getLlmContext() then agent.complete({ modelKey, includeSessionContext: true }). Confirm the tool result contains reviewer text and usage, not a secret.
    4. Repeat agent.complete until the eighth call in 60s succeeds and the ninth returns RATE_LIMITED.
    5. Make the completion fail at the provider (for example a model whose credential the user has revoked). Confirm the plugin reads the classified code — PROVIDER_UNAUTHORIZED — rather than a generic failure.
    6. Stop host-core and call pi.models.list(). Confirm [] and no warn line.
    7. Stop host-core and call pi.models.list(). Confirm [] and no warn line.
  • Expected: Credentials never leave Electron main. Audit lines record plugin id, model key, sizes, and usage — not transcript or completion text. Plan still returns PLUGIN_DISABLED_IN_PLAN for the plugin tool. A dead host transport returns an empty model list without a warning (D080).
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0174, D336
  • Acceptance: G (plugin agent tool), Security
  • Milestone: M5
  • Status: Unit-covered (plugin-complete.test.mjs, plugin-session-context.test.ts, subagent-wiring.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-189: Bundled Advisor plugin is temporarily unavailable ​

  • Preconditions: A packaged or development build of Nib.
  • Steps:
    1. Inspect the bundled plugin resources and confirm pi.advisor is absent.
    2. Open the command palette and plugin settings. Confirm /advisor, the Advisor plugin, and its advisor tool are absent.
  • Expected: The temporary removal does not expose an Advisor command, plugin, panel, skill, or agent tool. The general host-owned plugin completion APIs remain available to explicitly installed plugins.
  • Specs linked: 07-plugins/03-plugin-api.md, ADR 0174, D336
  • Acceptance: G (plugin agent tool), C (conversation)
  • Milestone: M5
  • Status: Unit-covered (bundled-plugins.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-190: Settings Network proxy applies to app-owned HTTP ​

  • Preconditions: A reachable local HTTP or SOCKS5 proxy, or a known-bad port for the failure path. A configured provider/model whose endpoint is reachable through the proxy is available for the model-request step. For System mode, the OS has a local system proxy or PAC fixture with a capture endpoint and a direct fallback route.
  • Steps:
    1. Open Settings → General. Confirm a Network card with Proxy modes System, Direct, and Custom. System is selected on a profile that never set a proxy.
    2. Choose Custom. Confirm a Proxy URL field, Bypass defaulting to localhost / 127.0.0.1 / ::1 / <local>, and Test. Enter not-a-proxy and blur. Confirm an inline invalid-URL error and that settings are not saved.
    3. Enter socks5://127.0.0.1:1080 or http://127.0.0.1:7890, then select System immediately without clicking elsewhere. Confirm settings.get retains the URL and Bypass while mode is system. Select Custom again and confirm both values return to the fields and mode is custom. With the valid URL saved, enter not-a-proxy and blur so the invalid draft remains visible. Switch to System and back to Custom; confirm the field returns to the remembered valid URL and settings.get still holds that URL instead of the built-in default.
    4. Click Test against a listening proxy. Confirm a Connected status. Click Test against a closed port. Confirm a failure status without changing the saved URL. Enter http://user:[email protected]:<auth-port> and socks5://user:[email protected]:<auth-port> against proxies that require those credentials. Confirm Test reports Connected rather than net::ERR_NO_SUPPORTED_PROXIES (issue #490).
    5. With Custom saved, send a short prompt through the configured provider. Confirm the provider request and response pass through the proxy, including when a SOCKS5 proxy returns the complete bind response in one TCP chunk. Confirm a subsequent marketplace refresh and a models.dev catalog refresh use the proxy (host-core curl --proxy, Electron net.fetch), and confirm a loopback URL in Bypass is not proxied.
    6. Switch to Direct. Confirm Chromium returns to mode: "direct", provider and marketplace traffic bypass the System relay, and workspace Bash does not inherit proxy variables from the setting.
    7. Switch to System. Confirm Chromium returns to mode: "system" and send a provider request and marketplace refresh. Confirm both follow the OS/PAC route selected for each destination, including ordered proxy-to-direct fallback, without restarting the app. Select Custom again. Confirm the URL and Bypass entered in step 3 come back from AppSettings.networkProxy, which kept them while System was selected.
  • Expected: Custom covers model calls, marketplace, updates, plugin net.fetch, and the in-app browser. System covers provider and marketplace requests through Electron's OS/PAC resolver. Workspace Bash env does not show HTTP_PROXY / ALL_PROXY from the setting. TLS verification remains enabled on all routes. OAuth still opens the system browser. Invalid schemes (file:, ftp:, and SOCKS4) and malformed percent-encoded credentials are rejected. Authenticated HTTP and SOCKS5 URLs Test and apply without net::ERR_NO_SUPPORTED_PROXIES (issue #490). No protocol or schema version bump.
  • Specs linked: 04-ux/06-settings-ia.md, 03-runtime/07-process-model.md, ADR 0177, D340
  • Acceptance: B (settings), F (providers), Security
  • Milestone: M5
  • Status: Custom-path unit coverage exists (network-proxy.test.ts, node-proxy.test.ts, authenticated-proxy-relay.test.ts, settings-general.test.mjs, host-core network_proxy tests); malformed credentials and unsupported SOCKS4 schemes are covered by shared parser tests. system-proxy-relay.test.ts covers authenticated provider traffic, PAC proxy fallback, HTTP proxy forwarding, and scheme detection on non-default ports. The stale-invalid-draft Custom → System → Custom restoration path is covered by pnpm test:e2e:settings-scroll. The full UI journey remains Draft (run only in a capable environment when this surface changes).

E2E-191: Newly emitted AppError codes stay registered ​

  • Preconditions: The shared package test suite is available.
  • Steps:
    1. Run the shared error helper tests.
    2. Inspect the newly emitted runtime and Edit code list used by the test.
  • Expected: Every code introduced by this update, including context-compaction, empty-response, and Edit recovery failures, exists in packages/shared/src/errors.ts with an identical key and value. Reserved codes remain excluded until they are emitted.
  • Specs linked: 03-runtime/08-error-codes.md
  • Acceptance: Quality
  • Milestone: M5
  • Status: Unit-covered (packages/shared/src/errors.test.ts); full UI journey not applicable

E2E-209: Import copies model configuration from local agent stores ​

  • Preconditions: At least one supported local config exists among ~/.claude/settings.json, ~/.codex/config.toml [model_providers.*], ~/.config/opencode/opencode.json, ~/.pi/agent/models.json, ~/.pi/agent/auth.json plus ~/.pi/agent/models-store.json, or ~/.cc-switch/cc-switch.db, including two API-key profiles with the same endpoint and different keys, one auth-only Pi API-key provider, and optionally one OAuth-only vendor. Nib may already have an equivalent provider.
  • Steps:
    1. Open Settings → Models and expand Import from other tools. Confirm its scan explanation appears as inline text without a help icon, and the workbench remains idle until Scan is activated.
    2. Scan model configuration. Confirm source groups start expanded, rows show name, model count, host, and an API key / No API key badge, and no secret value appears in the UI or scan IPC payload. Confirm the auth-only Pi provider appears in the Pi group with its cached model count and an API key badge.
    3. Import the selected providers. Confirm both same-endpoint profiles appear as separate rows under Settings → Models and remain selectable in the Composer model menu. Re-import the same selection and confirm those unchanged credentials are skipped.
    4. If the app had no default model, confirm the first imported provider becomes the default. If a default already existed, confirm it is unchanged.
    5. Confirm an OAuth-only source account is absent from the candidate list.
  • Expected: Explicit scan only (D007). Stored API keys land in the host secret store. Pi providers connected through auth.json are paired with the cached models-store.json catalog; OAuth grants and !command keys are not imported. Only equivalent providers (normalized URL + API style + same credential) skip; different credentials at one endpoint remain separate. The expanded importer shows its scan explanation directly without a question-mark control. No protocol or schema version bump.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md §18.5, 03-runtime/01-ipc-protocol.md, 03-runtime/11-provider-model-system.md, ADR 0179, D342
  • Acceptance: B (model configuration)
  • Milestone: M4
  • Status: Unit-covered (packages/shared/src/model-config-import.test.ts, apps/desktop/test/model-config-import.test.mjs); full UI journey Draft (run only in a capable environment when this surface changes)

E2E-SETTINGS-inline-capability-imports: Import skills and MCP in their owning pages ​

  • Preconditions: A supported external skill and MCP configuration are available. At least two projects exist. One MCP source is disabled and its URL includes userinfo and query credentials.
  • Steps:
    1. Open Settings → Skills, select Project and a project, then expand Scan other tools. Confirm its scan explanation is visible without a help icon and that opening the panel does not scan. Scan, select a skill, choose its copy or link mode, and import it.
    2. Confirm the new skill belongs to the selected project. Change the project and confirm the prior candidates and selection are cleared.
    3. Open Settings → MCP, select Project and the same project, expand Scan other tools. Confirm its scan explanation is visible without a help icon and that opening the panel does not scan. Scan, select the disabled MCP candidate, and import it.
    4. Confirm the server belongs to that project, remains disabled, and the candidate row shows only the URL host without userinfo, path, or query.
    5. Change project scope and confirm the prior MCP candidates and selection are cleared.
  • Expected: Skills and MCP scans start only after an explicit Scan action. Project scans and writes carry the selected project path; scope changes clear stale candidates. MCP disabled state is preserved and URL credentials never render in candidate metadata. Scan explanations render inline; the skill mode explanation remains on its existing help control.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md §18.3 / §18.6 / §18.7, ADR 0319 / D645
  • Acceptance: F (persistence), Security, Quality
  • Milestone: M6+
  • Status: Source-contract and scoped IPC tests covered (settings-import-page.test.mjs, agent-capability-settings.test.mjs, agent-import-ipc.test.mjs, import-format.test.mjs). The project MCP scan, selection, import, visible result, and scoped payload are exercised by pnpm test:e2e:settings-inline-import in an isolated Electron fixture.

E2E-210: Documentation screenshots resolve from GitHub and VitePress ​

  • Preconditions: The repository contains the gallery assets under docs/public/screenshots/app/; documentation dependencies are installed.
  • Steps:
    1. Open docs/guide/screenshots.md and docs/zh-CN/guide/screenshots.md from the GitHub file view. Confirm the gallery images resolve to files under docs/public/screenshots/.
    2. Open the English and Chinese screenshot pages in the VitePress preview. Confirm representative images from the home, panel, and settings sections render.
    3. Confirm the navigation logo loads docs/public/app-icon.png on both locale pages.
    4. Confirm each locale footer includes an AIUO.NET link to https://aiuo.net.
    5. Run pnpm docs:build and inspect the generated pages for image load failures.
  • Expected: GitHub renders every gallery image instead of requesting a repository-root /screenshots/ path; both VitePress locale pages continue to render the gallery from the same checked-in assets. The navigation uses the app icon, both footers expose the https://aiuo.net link, and the docs build succeeds.
  • Specs linked: ADR 0079, docs/README.md, docs/guide/screenshots.md, docs/zh-CN/guide/screenshots.md
  • Acceptance: Quality
  • Milestone: M5
  • Status: Static/documentation check covered (pnpm docs:build and path audit); remote GitHub and browser journey pending
  • Preconditions: A chat transcript can render an HTTP(S) Markdown link. The work-panel Browser view and the system browser opener are available. The clipboard can be observed or stubbed for the copy action.
  • Steps:
    1. In Settings → AI → Defaults, select Work panel browser and click the link from a chat reply.
    2. Select Default OS browser and click the same link again.
    3. Right-click the link and activate each context-menu item with the pointer: Open in default browser, Open in work panel, and Copy link address. Repeat the menu actions with keyboard focus and Arrow/Home/End navigation.
    4. Repeat a link click with Ctrl/Cmd, Shift, and Alt held.
    5. Render and use links whose HTTP(S) destination is wrapped as a nested linked-host Markdown destination, such as [#1106](([github.com](https://github.com/vastsa/PI-Desktop/issues/1106))).
  • Expected:
    • Wrapped linked-host destinations resolve to their inner HTTP(S) URL before sanitization, so the link is clickable and its right-click menu offers both browser destinations and Copy link address.
    • The Work panel browser is the default plain-click destination.
    • The Default OS browser setting routes chat, transcript, and plugin HTTP(S) clicks through the main-owned external opener, including markdown links, autolinked URLs, and inline-code URLs in assistant replies; changing the setting persists after reload.
    • The body-level context menu remains interactive when clicked. Its external and work-panel actions open the requested destination, and Copy link address updates the clipboard before showing the success toast. A rejected clipboard write shows an error toast instead of a success toast.
    • Modifier clicks continue to open links externally regardless of the setting.
    • Plugin/settings clicks that want the work panel return to chat so the dock is visible, without recording a navigation hop. A missing session falls back to the OS browser.
    • Workspace HTML preview, BrowserPreview, OAuth, and Feedback keep their existing destinations.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md §8.3, 03-runtime/01-ipc-protocol.md, 08-meta/decisions-log.md (D330)
  • Acceptance: B (settings), C (conversation & stream), Security, Quality
  • Milestone: M5
  • Status: Wrapped destinations, plain-click routing, and pointer context-menu presence are covered in pnpm test:e2e:transcript; settings persistence, browser-action activation, and keyboard menu navigation remain Draft. Unit coverage: apps/desktop/test/markdown-link-menu.test.mjs, apps/desktop/test/markdown-link-destinations.test.mjs, apps/desktop/test/markdown-math-rendering.test.mjs, locale catalog tests, and apps/desktop/test/open-http-url.test.mjs.

E2E-201: Alias a configured model and copy a model id ​

  • Preconditions: One provider saved with at least two model bindings, at least one of which the catalog publishes with a display name.
  • Steps: 1) Open Settings → Model configuration and reopen the provider. 2) Drag-select a model id in the live model list and copy it; confirm the clipboard holds the id and the row's checkbox did not toggle. 3) Click the same row's checkbox without selecting text; confirm it still toggles. 4) Expand Advanced on a chosen row and type a short alias. 5) Save and open the Composer model picker; confirm the alias names that model while the other row keeps its published name. 6) Search the picker by the alias and by the real id; both reach the row. 7) Clear the alias, save, and confirm the published display name returns. 8) Re-enter the alias, save, reopen the provider, restart the app, and confirm the alias is still there. 9) Type more than 60 characters into the alias input; confirm the field keeps only the first 60, then send a direct providers.update RPC with a 61-character alias and confirm it fails with MODEL_ALIAS_TOO_LONG.
  • Expected: The alias is a display label only — the provider request still carries models[].id, and the configuration row keeps showing the real id beside the alias chip. Model ids and names are selectable inside the non-selectable shell, and a click that carries a selection never toggles the row checkbox. A blank or cleared alias falls back to the catalog display name.
  • Specs linked: 03-runtime/12-provider-config-schema.md, 04-ux/08-component-spec.md, ADR 0192
  • Acceptance: B (model configuration), Quality
  • Milestone: M2
  • Status: Unit-covered (composer-models.test.mjs, provider-model-config.test.mjs); rendered UI journey Draft (run only in a capable environment when this surface changes)

E2E-MODEL-selected-order-persists: Selected model order survives saving ​

  • Preconditions: An AI service and an OAuth vendor account each have at least three selected models. Include a model with an alias and non-default Advanced settings. Record their binding values, the app-level default provider/model, and an explicitly bound session's model choice. A controllable save response is available to hold either editor busy.
  • Steps:
    1. Open Settings → Model configuration and edit the AI service. Drag the last selected model by its handle before the first row, then drag it after the last row. Confirm the insertion location and resulting order.
    2. Configure the selected IDs in this order: shown-a, hidden-a, shown-b, hidden-b, shown-c. Filter by shown-, then drag shown-c before shown-a. Clear the filter and inspect all five bindings.
    3. Apply the filter again. Focus shown-c's reorder handle and press Down, then Up. Confirm it moves past the neighboring visible row each time and focus remains on the moved model's handle. Try Up on the first visible model and Down on the last; neither changes the order.
    4. Start a drag and cancel it, then start another and drop outside the selected rows. Neither action changes the draft order.
    5. Drag-select and copy a model ID in the discovery list and in the selected pane. Confirm no reorder or checkbox toggle occurs. Click a discovery checkbox, toggle Advanced, edit an alias, and remove a selected model; each action retains its existing behavior without starting a drag.
    6. Save the reordered bindings, reopen the editor, then restart the app and reopen it again. Check order, aliases, model IDs, and Advanced settings. Check the provider default and existing session. Repeat with the service set as the app's default provider and with a different default provider.
    7. Change the order again, cancel the editor, and reopen it. The last saved order remains.
    8. Repeat the drag, keyboard, save/reopen, and cancel checks in the vendor account editor, including a filtered list with hidden selected bindings.
    9. Hold a save in flight and try drag and keyboard reordering. Release the save, then filter down to one visible selected row and try again.
  • Expected: Both editors persist the complete ordered binding array. In step 2 the full order becomes shown-c, shown-a, hidden-a, shown-b, hidden-b; the hidden bindings remain present and retain their relative order. Moving a binding never resets its ID, alias, or Advanced overrides. Saving keeps the provider's compatibility default at the head binding. When that service or account is the app's default provider, saving also updates the app's default model to the head, preserving E2E-005A's existing behavior. Editing another provider leaves the app default unchanged. An explicitly bound session keeps its stored model choice. Canceled/outside drags leave the draft unchanged, editor cancellation discards unsaved moves, and busy or single-visible-row forms disable the reorder handles. Text copying and existing row actions remain independent.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md §2, 03-runtime/12-provider-config-schema.md; ADR 0114, ADR 0192
  • Acceptance: B (model configuration), F (persistence), Quality
  • Milestone: M6+
  • Status: Documented; full desktop journey has not been run.

E2E-202: Subagent thinking follows its exact model binding ​

  • Preconditions: A configured provider has a model binding marked availableForSubagents. The catalog either reports that model as non-reasoning or omits one of the levels the binding explicitly enables. A user subagent definition and the builtin Task catalog are available.
  • Steps: 1) In Settings → Model configuration, enable medium and high for the delegation model and mark it available for subagents. 2) Set the subagent definition's thinking level to high, save, and restart the app. 3) Run the definition with its frontmatter model pin. 4) Run a builtin with Task.model selecting the same binding, including the on-demand resolution path. 5) Run a definition with no model pin while the parent session is set to medium.
  • Expected: The pinned and explicitly selected delegates retain the binding's enabled thinking levels and send the selected non-off level even when models.dev says reasoning is unavailable or publishes a sparse set. The unpinned delegate inherits the parent's effective level. A binding with no non-off level still resolves to off.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/11-provider-model-system.md, ADR 0144 / D283
  • Acceptance: B (model configuration) + C (chat/stream) + Quality
  • Milestone: M6+
  • Status: Unit/wiring-covered (model-capabilities.test.ts, apps/desktop/test/subagent-wiring.test.mjs); full UI journey Draft (do not run E2E locally unless explicitly requested)

E2E-203: Omit subagent thinking override and read selected levels in dark mode ​

  • Preconditions: A configured subagent model supports reasoning, and the application has both light and dark themes available.
  • Steps: 1) Open Settings → Agent → Subagents and inspect the thinking selector. 2) Confirm it contains inherit-session, do-not-send, off, and the canonical levels. 3) Choose do-not-send, save, and confirm the document contains thinkingLevel: omit. 4) Run the subagent through a provider with a meaningful adapter default and inspect the outbound request. 5) Switch to dark mode, open Settings → Model configuration, expand a model's Advanced section, and select multiple thinking-level chips.
  • Expected: Inherit continues to use the parent level; do-not-send persists and sends no provider thinking override; explicit off remains an explicit disable. Selected thinking chips have a solid high-contrast fill and readable text in both themes, and each selected level is visually distinguishable from the track.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 03-runtime/13-model-catalog-and-selection.md, 04-ux/06-settings-ia.md, ADR 0194 / D356
  • Acceptance: B (model configuration) + C (chat/stream) + Quality
  • Milestone: M6+
  • Status: Unit/source-contract-covered; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-203a: Session thinking omit sends no provider override ​

  • Preconditions: A configured reasoning model is selected in Composer.
  • Steps: 1) Open the model × reasoning menu and confirm omit is the first reasoning option, followed by the binding's enabled canonical levels. 2) Choose omit and confirm the chip shows omit and the session stores thinkingLevel: omit. 3) Send a turn and inspect the outbound request. 4) Choose explicit off and send again. 5) In Settings → Model configuration, expand the same model's Advanced row and confirm the default picker lists omit first; save omit as the default and start a new session.
  • Expected: omit persists and the request has no thinking/reasoning field. Explicit off still serializes a disable. A non-reasoning model keeps an off-only menu. A new session whose binding default is omit starts at omit.
  • Specs linked: 03-runtime/01-ipc-protocol.md, 03-runtime/02-agent-runtime.md, 03-runtime/13-model-catalog-and-selection.md, ADR 0295 / D456
  • Acceptance: C (chat/stream) + Quality
  • Milestone: M6+
  • Status: Unit/source-contract-covered; full UI journey Draft

E2E-211: Windows portable ZIP launches after extraction (D603) ​

  • Preconditions: A Windows x64 tag or dist:win package has produced both Nib-Setup-<version>.exe and Nib-Portable-<version>.zip from the shared electron-builder config; a clean user profile is available; the account is a standard user without administrator elevation.
  • Steps: 1) Inspect the release directory and latest.yml. 2) Extract the portable ZIP to a user-writable directory without running the NSIS installer. 3) Launch the extracted Nib.exe. 4) Confirm there is no administrator prompt and that the running app has the Nib icon and taskbar entry. 5) Open Settings → Info and confirm Manual is selected by default; briefly select Automatic to inspect its replacement warning, then restore Manual. 6) Invoke Check for Updates. 7) Confirm Settings → Info offers the releases page rather than Restart to update. 8) Quit and relaunch the extracted executable.
  • Expected: Both Windows artifacts are space-free and uploaded. latest.yml points at the NSIS installer only. The extracted ZIP app starts without a setup wizard or administrator prompt, keeps the normal Nib taskbar identity/icon, uses the existing application data directory, and reports update preference manual by default and effective update mode manual. Its resources/app-update.yml points to the same GitHub feed, so the manual check settles as up-to-date or available instead of failing with ENOENT. When an update is available, Settings offers the Releases page; it does not download or run Nib-Setup-<version>.exe. Relaunch restores sessions from that same profile.
  • Specs linked: 01-product/01-product-scope.md, 06-delivery/06-release-runbook.md, 03-runtime/07-process-model.md, ADR 0197 / D603
  • Acceptance: Quality (release packaging)
  • Milestone: M6+
  • Status: Unit/source-contract covered (auto-update.test.mjs); native Windows launch remains runner validation (run only in a capable environment when this surface changes)

E2E-UPDATE-preference-and-once-only-reminder ​

  • Preconditions: A disposable packaged profile on a supported automatic installer lane and a Windows portable ZIP profile. The update discovery path can provide the same stable available version across repeated checks without running a production installer.
  • Steps: 1) Open Settings → Info on the installed package and confirm Automatic is selected by default. 2) Choose Manual, close/reopen Settings, then restart the app and confirm Manual persists. 3) Surface one available stable version; verify no download/install starts and one notice appears. Dismiss it, navigate away and back, repeat the check, then restart and check again. 4) Confirm the same version does not raise another notice, while the Settings row still shows it and opens Releases. 5) Select Automatic and surface an available version. Dismiss the in-app banner while downloading; verify the transfer is cancelled and the update will not install on quit. Restart, check the same version again, and verify it remains dismissed and does not download. Then surface a newer version and verify automatic downloading resumes. 6) Launch the portable ZIP profile and confirm Manual is the default; inspect the warning before explicitly selecting Automatic.
  • Expected: The preference persists per installation. Manual performs discovery only and stores the last reminded version so repeated checks and app restarts do not repeat the notice; the Info row remains actionable. A dismissed version stays ignored across restarts in either mode. In-app dismissal cancels an active transfer and prevents install-on-quit for that version; a newer release clears the dismissal and resumes automatic delivery. ZIP and legacy portable builds default to Manual, and Automatic is an explicit, warned opt-in that can replace the extracted copy with NSIS.
  • Specs linked: 03-runtime/07-process-model.md, 04-ux/09-interaction-patterns.md, ADR 0022 / D628
  • Acceptance: Quality (settings interaction and release safety)
  • Milestone: M6+
  • Status: Setting selection/persistence covered by pnpm test:e2e:settings-scroll; dismissal and download cancellation covered by updater-controller.test.mjs. Packaged Windows installer journey remains runner validation.

E2E-213: The first Composer model menu paint keeps configured aliases ​

  • Preconditions: A runnable provider has at least one configured model with a non-empty alias and a cached or discoverable model record for that ID. The app has restarted, or the provider model cache has been invalidated.
  • Steps: 1) Open the Composer model × reasoning chip. 2) Enter the Model submenu immediately and observe the first visible frame. 3) Keep the menu open until the cached/live model refresh completes. 4) Close and reopen the menu.
  • Expected: The first visible row already uses the configured alias (or the configured ID when no alias exists), and the list never flashes empty or replaces the alias with a catalog name/wire ID during hydration. The same single display name remains after refresh and on the next open; selecting the row still sends the configured model ID.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md, 04-ux/08-component-spec.md
  • Acceptance: Quality (stable first paint), B (model selection)
  • Milestone: M6+
  • Status: Unit/source-contract covered (composer-models.test.mjs); full UI journey remains runner validation (run only in a capable environment when this surface changes)

E2E-214: Plugin session import and ownership boundary ​

  • Preconditions: A test plugin declares two contributes.sessionSources entries and receives only the P0/P1 session permissions. A second plugin has no access to the first plugin's sessions.
  • Steps: 1) Import a session with external project/provider/model history, user, assistant, and tool messages. 2) Repeat the same import and verify idempotent skip with the same host-generated id. 3) List, get, and page messages, including descending order and content truncation. 4) Attempt an undeclared source, a system/running message role, invalid timestamps, an oversized/deep tool value, and each missing permission. 5) Repeat list/get/ messages/rename/delete from the second plugin.
  • Expected: Source declarations and permissions are enforced in Electron before host dispatch; ids are generated by host-core; original bindings are history-only; returned messages are marked external; reserved tool metadata is removed; invalid inputs fail with stable error codes; the second plugin sees neither the row nor its transcript.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md, 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0200, D367
  • Acceptance: Security, Quality
  • Milestone: M6+
  • Status: Unit/RPC/wiring-covered; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-215: Plugin batch and delete lifecycle ​

  • Preconditions: The test plugin can call session.importBatch, rename, and delete. Host-core starts with an empty v14 database.
  • Steps: 1) Run a skip batch containing valid, duplicate, and invalid items. 2) Run a fail batch containing one invalid or conflicting item and verify no item from that batch lands. 3) Rename an owned session and verify list ordering/metadata. 4) Trash it, verify normal core/plugin reads omit it, then purge it and import the same external id again. 5) Exercise page-size, payload, batch-size, and rolling import/delete limits. 6) Restart on a migrated v13 database and verify existing core sessions remain active.
  • Expected: Skip is partial and fail is atomic; list/get/message operations stay ownership-scoped; trash is recoverable only through the owner purge; purge removes transcript files and permits re-import; bounds return LIMIT_EXCEEDED and rolling limits return RATE_LIMITED; migration produces schema v14 without project rows for plugin history.
  • Specs linked: 03-runtime/04-data-storage.md, 03-runtime/06-host-rpc-protocol.md, ADR 0200, D367
  • Acceptance: Security, Quality, Recovery
  • Milestone: M6+
  • Status: Host/RPC/unit-covered; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-PLUGIN-usage-listTurns: Plugin usage fact listing ​

  • Preconditions: A test plugin is granted usage.read. The host database has completed turns across live and soft-deleted sessions.
  • Steps: 1) Call pi.usage.listTurns without the permission. 2) Call it with the permission, page by cursor, and filter by session/project/window. 3) Pass inverted bounds, a window longer than 365 days, and a malformed cursor. 4) Confirm rows include token counters and titles but no message bodies, and that trashed sessions are absent.
  • Expected: Missing permission returns PERMISSION_DENIED and does not hit the host. Valid calls return keyset pages of completed-turn facts. Invalid params return INVALID_PARAMS. Empty titles are null.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, 03-runtime/06-host-rpc-protocol.md, ADR 0173, D335
  • Acceptance: Security, Quality
  • Milestone: M6+
  • Status: Unit/RPC/wiring-covered (plugin-session-api.test.mjs, host-core plugin_usage); full UI journey Draft

E2E-216: Explicit plugin project binding and host-owned sidebar refresh ​

  • Preconditions: A test plugin has project.create and session.import permissions, declares a session source, and the renderer is showing the existing sidebar. A project path is available without changing the active workspace.
  • Steps: 1) Call pi.project.create({ path }) and record the returned projectId. 2) Import one session with that id and one session without it. 3) Observe the renderer while the plugin call completes. 4) Repeat the import with the same external id and then rename/delete an owned session.
  • Expected: Project creation returns a durable id without activating or replacing the current workspace. Only the import with an explicit id has an active project binding; an omitted id stays unbound and its projectPath is history only. Each successful write causes one host-owned pi-desktop/session/event/changed, the renderer refreshes through refreshSessions(), and the sidebar does not require a plugin-emitted event. Skipped imports do not trigger a redundant refresh, and closed project tabs are not reopened merely because their session list was refreshed.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, 03-runtime/01-ipc-protocol.md, 03-runtime/06-host-rpc-protocol.md, ADR 0201, D368
  • Acceptance: C (sessions), Security, Quality
  • Milestone: M6+
  • Status: Unit/source-contract-covered; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-217: Windows host starts on a clean x64-emulated ARM64 install ​

  • Preconditions: A clean Windows 11 x64 or ARM64 machine/profile without a separately installed Visual C++ Redistributable, Node.js, or another local agent runtime; the x64 NSIS installer is available.
  • Steps: 1) Install Nib. 2) Launch it for the first time. 3) Wait for the startup splash to yield to the main shell. 4) Inspect the runtime logs, then open Settings → Info.
  • Expected: The bundled x64 pi-desktop-host-core.exe starts and completes app.handshake without 0xC0000135 (STATUS_DLL_NOT_FOUND), the shell does not remain on “Can't reach the local service”, host status is healthy, and Settings → Info reports the host version instead of host unknown. The package uses the statically linked MSVC CRT; no separate runtime installer is required. Native Windows ARM64 artifacts remain out of scope.
  • Specs linked: 03-runtime/07-process-model.md, 06-delivery/06-release-runbook.md
  • Acceptance: A (app startup), Quality (clean-install packaging)
  • Milestone: M6+
  • Status: Source-contract-covered; clean-machine Windows x64 and ARM64 qualification remains runner validation (run only in a capable environment when this surface changes)

E2E-218: An installed Composer transform preserves inline attachment chips ​

  • Preconditions: A plugin that contributes a Composer transform is installed, enabled, and granted composer.transform. An Agent session has a draft with ordinary prompt text and inline file and image chips.
  • Steps: 1) Add file and image chips to the draft. 2) Invoke the plugin's Composer action. 3) Observe the plugin request and updated draft. 4) Undo the transform and confirm the draft and chips return.
  • Expected: The plugin action is visible only while its plugin is loaded and its permission is granted. The transform callback receives the visible draft text and optional model key, but no image bytes, attachment metadata, file reference token, session id, or conversation history. The file and image chips remain in place and removable through the transform and its one-step undo. The transform does not create a transcript row. Without the plugin, no prompt-enhancement action appears.
  • Specs linked: 04-ux/12-prompt-enhancement.md, 07-plugins/02-plugin-manifest-schema.md, 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md
  • Acceptance: C (conversation & stream), Quality
  • Milestone: M6+
  • Status: Covered by test:e2e:plugin-ui-slots for plugin-process dispatch, draft-only update, inline file/image chip preservation, one-step undo, and no transcript row. Attachment sending is covered by the separate Composer send E2E.

E2E-259: Installing the prompt-enhancement plugin migrates preferences once ​

  • Preconditions: The profile contains legacy host prompt-enhancement settings and has no pi.prompt-enhancement plugin data. The standalone plugin is available from a configured catalog or repository; a Composer session is ready.
  • Steps: 1) With the plugin absent, inspect the Composer and Settings and confirm there is no prompt-enhancement action or settings card. 2) Install the plugin explicitly and grant its requested permissions. 3) Inspect the plugin-owned settings and confirm valid legacy model, thinking, and enabled custom-template values were copied before the plugin loaded. 4) Transform a draft, undo it, and send it; confirm the action does not create a transcript row. 5) Clear a migrated plugin value, reload the plugin, and confirm the migration does not restore it. 6) Uninstall the plugin and confirm legacy host values remain.
  • Expected: Installing the plugin is the only path that exposes the action. Existing plugin settings win over legacy values; invalid or inactive custom templates are skipped. A private marker makes migration one-time. Clearing a plugin setting does not cause a later import, and uninstalling the plugin does not delete legacy host values. Plugin transforms preserve draft/session race safety and attachment chip positions.
  • Specs linked: 04-ux/12-prompt-enhancement.md, 04-ux/06-settings-ia.md, 03-runtime/04-data-storage.md, 07-plugins/03-plugin-api.md, ADR 0324
  • Acceptance: C (conversation & stream), Quality, compatibility
  • Milestone: M6+
  • Status: Migration and plugin-runtime integration tests cover the copy, precedence, one-time marker, permission gate, and callback; full UI journey Draft (run only in a capable environment when this surface changes)

E2E-220: Local MCP control drives a running desktop ​

  • Preconditions: Start Nib with PI_DESKTOP_MCP_CONTROL=1 and a clean profile. A local project directory is available, the Electron user-data directory is writable, and the desktop has completed backend boot.
  • Steps: 1) Read mcp-control.json and use its URL and bearer token. 2) Call initialize, tools/list, and pi_control_describe. 3) Call pi_project_open with the fixture project. 4) Call pi_session_create, pi_session_get, and pi_agent_status. 5) Call pi_agent_prompt and observe the existing desktop session-change event select the target session. 6) Call pi_desktop_invoke for a reviewed read operation. 7) Attempt pi_session_delete and pi_session_configure without confirmation, then repeat with confirm: true. 8) Stop the app and inspect the manifest.
  • Expected: An unauthenticated request receives 401; initialize with an unsupported protocol version negotiates 2025-06-18; the authenticated MCP handshake and tool catalog succeed; project/session/Agent operations use the same IPC validation and host permission boundaries as the renderer; mutating calls refresh/select the visible project and session while pi_session_get does not; destructive operations and pi_session_configure fail with a confirmation error until acknowledged; secret writes, plugin/loadDev, and native-picker channels are absent from pi_control_describe; secret-shaped fields are stripped; the endpoint binds loopback only; disallowed Origins and unsupported protocol version headers are rejected; and the manifest changes to active: false on shutdown.
  • Specs linked: 02-architecture/01-architecture.md, 03-runtime/01-ipc-protocol.md, 05-security/01-security.md, ADR 0203, D370, D372
  • Acceptance: A (app control), C (sessions), Security, Quality
  • Milestone: M6+
  • Status: MCP protocol/unit-covered by apps/desktop/test/mcp-control.test.mjs; full Electron journey documented and remains deferred by the no-local-E2E policy

E2E-MCP-session-get-projects-large-compaction ​

  • Preconditions: Start Nib with PI_DESKTOP_MCP_CONTROL=1. A durable session exists whose session.compaction record (summary / retainedTail / details.modifiedFiles) alone serializes to more than the 512 KiB MCP result limit. A second durable session exists whose session.compactions history alone does — the newest compaction is already compact, but several history entries each carry their own summary / retainedTail / details.modifiedFiles (mocode #506).
  • Steps: 1) Read mcp-control.json, use its URL and bearer token, and complete the MCP handshake. 2) Call pi_session_get for that session with any messageLimit / contentLimit / messageBefore. 3) Inspect structuredContent. 4) Repeat for the history-heavy session with the smallest page (messageLimit: 1, contentLimit: 1). 5) Repeat for a small session.
  • Expected: The answer is not the {truncated: true, reason: "MCP_RESULT_LIMIT", preview} envelope; session.messages carries the requested transcript page; session.compaction keeps createdAt and details.generation while summary, retainedTail, and details.modifiedFiles are absent. Every session.compactions entry keeps its scalar identity and drops those same unbounded fields, so the history-heavy session returns a real page even at messageLimit: 1 / contentLimit: 1. The small session's answer is unchanged.
  • Specs linked: 03-runtime/01-ipc-protocol.md §13d
  • Acceptance: C (sessions), Quality
  • Milestone: M6+
  • Status: The local MCP server contract test in apps/desktop/test/mcp-control.test.mjs exercises authenticated JSON-RPC tools/call for oversized (current record, compaction history, and history-only) and under-limit pi_session_get results. The separate full Electron-to-Host journey remains release qualification.

E2E-234: Workspace security denylist and ignore layers ​

  • Preconditions: A project containing .env, .env.example, server.pem, keys/id_rsa, notes.txt, node_modules/pkg/index.js, generated/out.txt, debug.log, and a root .pi-desktopignore with generated/. Every file contains the word needle. The session is Agent in auto permission mode.
  • Steps: 1) Ask for Read of .env, then of .env.example. 2) Ask for Write to keys/id_rsa. 3) Run an unscoped Grep and Glob for needle. 4) Run Grep with path: node_modules/pkg and with path: generated. 5) Repeat step 1 with a system rg installed and with PI_DESKTOP_DISABLE_RG=1.
  • Expected: Steps 1 and 2 fail with WORKSPACE_PATH_DENIED, the .env.example read succeeds, and no keys/id_rsa file is created. The unscoped search lists notes.txt and .env.example only: .env, server.pem, node_modules, generated, and debug.log are absent. The explicit-path searches return one hit each. The in-process walker and the rg fast path produce the same file set.
  • Specs linked: 03-runtime/15-workspace-ignore-rules.md, 03-runtime/08-error-codes.md §3.3, D032
  • Acceptance: B (workspace tools), Security
  • Milestone: M3+
  • Status: unit-covered by crates/host-core/src/tools/mod.rs (security_denylist_blocks_read_write_edit_and_hides_search_results, default_ignores_and_workspace_ignore_file_hide_unscoped_walks_only) and tools/ignore_rules.rs; the Electron journey is documented and deferred by the no-local-E2E policy
  • Preconditions: A project containing dangling -> /tmp/outside/planted.txt where the target does not exist, and inner -> ./not-yet.txt. Agent mode, auto permission.
  • Steps: 1) Ask for Write to dangling. 2) Ask for Write to dangling-dir/new.txt where dangling-dir -> /tmp/outside/dir. 3) Ask for Write to inner.
  • Expected: Steps 1 and 2 fail with PATH_OUTSIDE_WORKSPACE and nothing appears under /tmp/outside. Step 3 creates not-yet.txt inside the project. A symlink loop fails with a canonicalize error rather than hanging.
  • Specs linked: 03-runtime/03-tools-and-permissions.md, 03-runtime/15-workspace-ignore-rules.md §3
  • Acceptance: B, Security
  • Milestone: M3+
  • Status: unit-covered by crates/host-core/src/workspace.rs (blocks_dangling_symlink_escape, dangling_symlink_inside_workspace_resolves_to_its_target, dangling_symlink_loop_is_rejected)
  • Preconditions: A dev plugin granted desktop.control whose panel calls pi.desktop.invoke({ operation: "session/delete", args: [id], confirm }). One disposable session exists.
  • Steps: 1) Invoke with confirm: false. 2) Invoke with confirm: true and press Escape on the dialog. 3) Invoke with confirm: true and click Deny. 4) Invoke with confirm: true and click Allow once. 5) Invoke a read operation.
  • Expected: Step 1 fails with CONFIRMATION_REQUIRED and no dialog appears. Steps 2 and 3 fail with PERMISSION_DENIED; the session still exists. The dialog names session/delete and the catalog description, never panel-authored text. Step 4 deletes the session and the sidebar refreshes. Step 5 shows no dialog. Every invocation is audited with plugin id, operation, and risk.
  • Specs linked: 07-plugins/03-plugin-api.md (desktop control), 07-plugins/04-plugin-security.md §8.2, 07-plugins/13-plugin-permissions-matrix.md, ADR 0203, ADR 0208, D370, D372, D377
  • Acceptance: D (plugins), Security
  • Milestone: M6+
  • Status: runtime-covered by apps/desktop/test/plugin-desktop-control.test.mjs; the native dialog journey is documented and deferred by the no-local-E2E policy

E2E-PLUGIN-session-orchestrator-real-workers: Session Orchestrator creates and coordinates durable sessions ​

  • Preconditions: The marketplace pi.session-orchestrator plugin is installed and enabled; the parent Agent session has a configured authenticated provider/model and a project path. The parent is in Agent mode.
  • Steps: 1) Ask the parent to review Frontend, Electron, and Rust in parallel. 2) Confirm that SessionTask.spawn returns three distinct real durable sessionId values and one host messageId per delivery; each worker is visible in the normal session list. 3) Confirm all three workers receive prompts without using session/fork and can run concurrently. 4) While a worker is busy, send a follow-up to that exact Session ID and verify it is queued against the target inbox rather than starting a second turn. 5) Inspect status from the Agents panel and a sidebar hover card; confirm both use bounded host projections, the hover card marks currently running created-session links, and neither surface fetches a complete worker transcript. 6) Wait for one worker, query result by its exact messageId and turnId, and inspect the parent transcript for one host-generated completion message. 7) Re-read the result and repeat the settlement notification path; confirm the callback and transcript row are not duplicated. 8) Send a parent-to-worker and worker-to-parent message in separate active plugin Agent tool turns; verify source/target provenance and real target turn binding in both transcripts. 9) Open one worker from the Agents panel, send it a follow-up using the exact returned sessionId, and stop another worker. 10) Restart the host/plugin with a queued delivery and confirm it remains held, while an interrupted turn is not replayed. Repeat the parallel creation step with a large existing session list while keeping the parent visible. 11) With one model's «Available for AI delegation» left off, ask the parent to spawn a worker on it by modelKey, then spawn again with no modelKey.
  • Expected: Each worker is a real durable session with the parent's project/model/thinking/permission ceiling and an independent empty transcript at creation. The host ledger binds every delivery to the actual target durable turn; result text and error status derive from that turn's terminal state, not from polling assistant text. The parent receives only bounded, at-most-once completion messages; full worker transcripts remain inspectable in their own sessions. sessionId is the only canonical worker identity and follow-up send reuses that same session and context without creating a replacement. A session-message row is visibly distinct from human input and its provenance survives reload. wait returns timedOut within its bound instead of occupying the host tool deadline. Cancel interrupts only the selected delivery/turn without deleting the session. Sends without an active plugin tool invocation, forged source ids, targets above the source permission ceiling, worker fan-out overflow, inbox overflow, and autonomous callback loops fail closed. A spawn naming a model the user has not enabled for AI delegation is refused with PERMISSION_DENIED before a worker exists, while omitting modelKey — or naming the default model's own key — still inherits. Unrelated sessions and the existing Task family are unchanged, and no localhost MCP call or token access occurs. Bursts of worker notifications serialize and coalesce session-list refreshes while preserving the final worker list and the foreground session.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 07-plugins/11-plugin-storage-isolation.md, 03-runtime/01-ipc-protocol.md, 03-runtime/06-host-rpc-protocol.md, 03-runtime/04-data-storage.md, 03-runtime/11-provider-model-system.md, ADR 0237, ADR 0239, ADR subagent-model-opt-in
  • Acceptance: C (parallel durable sessions), D (plugin security), Quality
  • Milestone: M6+
  • Status: host ledger coverage is automated by pnpm test:e2e:collaboration; marketplace plugin tests cover the plugin runtime, and host-core/desktop unit tests cover the additive host primitives. The full live provider/Electron journey remains runner validation under the no-local-E2E policy

E2E-SESSION-independent-top-level-communication: SessionTask discovers and communicates with existing sessions ​

  • Preconditions: The marketplace pi.session-orchestrator plugin is installed and enabled. Two existing Agent sessions were created from the normal New Task flow and are not linked as Session Orchestrator workers. The caller is an active Agent session with a configured authenticated provider.
  • Steps: 1) Call SessionTask with action: "list" and identify both existing sessions by their durable sessionId. 2) Send a message to one independent session and verify it is admitted against that session's inbox. 3) From the target session, send a reply to the original session. 4) Call status and result with the returned IDs and inspect the two transcripts.
  • Expected: list includes bounded references to existing communicable Agent sessions without requiring plugin-owned history or treating them as workers. send works in either direction using the real target Session ID, preserves each target's existing model/project/context/permissions, and never creates a replacement session. The host records source and target provenance, results remain bound to the actual durable turns, and the list response contains no transcript, project path, credentials, or message previews. Non-Agent sessions remain rejected by the existing host policy.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 03-runtime/01-ipc-protocol.md, 03-runtime/04-data-storage.md, ADR 0239, ADR 0240
  • Acceptance: C (conversation & stream), D (plugin security), G (plugins), Quality
  • Milestone: M6+
  • Status: host discovery and bidirectional delivery are automated by pnpm test:e2e:collaboration; plugin and host-core regression coverage is automated. The live multi-session provider/Electron journey remains runner validation under the no-local-E2E policy

E2E-SESSION-completion-notice-allows-silence: A trusted completion notice may finish without an acknowledgement ​

  • Preconditions: A candidate commit has its own built host-core and runtime sidecar. The local SSE provider deterministically returns visible text or a successful empty response; no live credentials are required.
  • Steps: 1) Deliver a task through the real Host collaboration ledger and sidecar, read its successful result, and complete the coordinator summary. 2) Resolve the queued completion callback through the production Main input resolver and run the recipient against an empty SSE response. 3) Run another human request, copied completion framing, a ledger task, and a ledger message against empty responses on the same recipient runtime.
  • Expected: The original result remains unchanged. The completion has one provider request, no error, one terminal lifecycle, completed ledger status, and no acknowledgement callback. Each ordinary input still retries once and ends with EMPTY_MODEL_RESPONSE, and no request the recipient sends after the silent notice carries an empty assistant message. Unit coverage additionally rejects missing reply-to IDs/wrong targets, spends the exception on a tool batch, keeps it across a provider retry, revokes it once accepted user steering enters the context, and keeps the accepted silence out of the runtime entries and pi transcript state.
  • Specs linked: 03-runtime/02-agent-runtime.md §5e, 03-runtime/08-error-codes.md, ADR 0239 (D446 amendment)
  • Acceptance: C (conversation & stream), D (provenance), Quality
  • Milestone: M6+
  • Status: Automated by pnpm test:e2e:session-completion on the committed, rebased candidate in its dedicated worktree. The harness drives real Host RPCs, the production provenance resolver, sidecar, and local SSE, and persists runtime messages before Host settlement. It does not exercise Electron's queue/outbox UI or a live provider. Candidate/base SHAs and results belong in the validation report; existing ledger coverage runs separately through pnpm test:e2e:collaboration.
  • Preconditions: The app has one collaboration-created session, one independent session, and a configured provider/model with readable catalog names. The sidebar contains both sessions.
  • Steps: 1) Hover or keyboard-focus the collaboration-created session. 2) Inspect the model metadata, creator reference, and created-session list. 3) Activate the creator and one created-session reference with the keyboard. 4) Delete (or otherwise remove) one referenced session, or use a session whose reference is already stale, then revisit the card. 5) Inspect an independent session's card as well.
  • Expected: The card shows the model's display name, falling back to the provider's readable name, instead of the provider ID. A collaboration-created session shows its creator, and a creator shows its bounded created-session list. Each live reference is a native keyboard-focusable button with an accessible open-session name; activating it opens that durable session and focuses the Composer. A reference whose session no longer exists is presented as text with an "unavailable" indication and is not a keyboard-focusable navigation control; activating a session that no longer exists (for example a session deleted between the snapshot and the click) surfaces a visible error instead of switching to an empty transcript. An independent session remains a valid local session without a fabricated creator link. Hover polling remains bounded and does not load a transcript: a read that exceeds its deadline is abandoned, and an unfocused window keeps a slower idle poll instead of stopping or overrunning.
  • Specs linked: 03-runtime/01-ipc-protocol.md §5.7, 03-runtime/04-data-storage.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR 0240
  • Acceptance: C (conversation & stream), Quality
  • Milestone: M6+
  • Status: source-contract and projection tests are automated; rendered pointer/keyboard validation remains runner validation

E2E-237: Plugin fetch re-checks egress on every redirect ​

  • Preconditions: A dev plugin with net.domains: ["allowed.test"] and net.fetch. A local server on allowed.test answers /hop with a 302 to http://undeclared.test/leak, /ok with 200, and /limited with 429 and Retry-After: 2.
  • Steps: 1) Call pi.net.fetch({ url: "https://allowed.test/ok" }). 2) Call pi.net.fetch({ url: "https://allowed.test/hop" }). 3) Call pi.net.fetch({ url: "https://allowed.test/limited" }).
  • Expected: Step 1 returns 200. Step 2 fails with PERMISSION_DENIED naming undeclared.test, and the undeclared server records no request. The audit log shows the denied hop. Step 3 returns 429 with its Retry-After header intact, the server records exactly one request for it, and the audit log shows ok: false with the advertised retryAfter — the host retries nothing.
  • Specs linked: 07-plugins/04-plugin-security.md §8.0, 07-plugins/03-plugin-api.md §7
  • Acceptance: D, Security
  • Milestone: M4+
  • Status: runtime-covered by apps/desktop/test/plugin-egress.test.mjs (per-hop egress and failed-call audit)

E2E-238: Tool requests for an unknown session do not fall back ​

  • Preconditions: host-core running; a JSON-RPC probe attached to its stdio.
  • Steps: 1) Send tools.execute with sessionId: "missing" and a Read of README.md. 2) Send plans.enter with the same id.
  • Expected: Both fail with SESSION_NOT_FOUND (numeric 1007 and PLAN_SESSION_NOT_FOUND respectively); no file under the last-opened workspace is read.
  • Specs linked: 03-runtime/06-host-rpc-protocol.md §7, 03-runtime/08-error-codes.md §3.1
  • Acceptance: B, Security
  • Milestone: M3+
  • Status: unit-covered by crates/host-core/src/rpc/mod.rs (temporary_session_uses_its_own_scratch_workspace)

E2E-239: An older build names the newer data schema instead of looping ​

  • Preconditions: a data directory last opened by a newer Nib whose host-core migrated it past the schema this build supports.
  • Steps: 1) Launch the older packaged app on that data directory. 2) Observe the banner and logs/app/runtime.log.
  • Expected: host-core exits once; no further restart attempts are logged. The fatal banner says this Nib is older than the local data, shows both schema numbers, and tells the user to install the newer version. The data directory is not modified.
  • Specs linked: 03-runtime/07-process-model.md (boot outcomes)
  • Acceptance: B
  • Milestone: M3+
  • Status: source-contract covered by apps/desktop/test/host-boot-diagnostics.test.mjs

E2E-240: An Intel macOS build on Apple Silicon points at the native download ​

  • Preconditions: Apple Silicon Mac; the x64 macOS package installed and running under Rosetta 2.
  • Steps: 1) Launch the app. 2) Read the banner under the title bar. 3) Click its dismiss action.
  • Expected: The app boots normally. A dismissible hint says this is the Intel build on an Apple Silicon machine and asks the user to install the Apple Silicon build. Dismissing hides it for the session; the native arm64 package shows no hint.
  • Specs linked: 03-runtime/07-process-model.md (boot outcomes)
  • Acceptance: B
  • Milestone: M3+
  • Status: source-contract covered by apps/desktop/test/host-boot-diagnostics.test.mjs

Remote Agent Control target scenarios (post-MVP) ​

The following scenarios require the approved remote harness. They are documented now so the protocol and security work has an explicit acceptance target. Do not run them against a local desktop or a production Gateway unless the request explicitly authorizes that environment. D374 amended every scenario in this section to the revised contract: { epoch, sequence } cursors, ephemeral deltas, the Host-owned turn queue, the full local approval vocabulary, the remote permission ceiling, the Host link relay, and the browser cookie profile. D375 re-sequenced the milestones: E2E-231 and E2E-232 are the acceptance targets of the scheduled SSH-tunnel and integration milestones, while E2E-227 and E2E-228 run when the Gateway and browser milestones are scheduled.

E2E-221: RACP initialization negotiates capabilities and policy ​

  • Preconditions: A test Agent Host and an authenticated client support RACP-WS v1. The Host has one visible idle Session and a configured remoteMaxPermissionMode and approvalLifetimeMs.
  • Steps: 1) Connect without initialization and send a request. 2) Send connection/initialize with supported bindings and the turnQueue, hostEvents, and history capabilities. 3) Send notifications/initialized. 4) Repeat with an unsupported major version. 5) Call host/list on the direct Host connection.
  • Expected: Pre-initialization requests are rejected; the valid handshake returns the negotiated protocol, connection id, principal roles, limits including maxQueuedTurnsPerSession and replayWindowEvents, the advertised capabilities, and the informational policy block; the unsupported major version returns PROTOCOL_MISMATCH; host/list returns METHOD_NOT_FOUND because no Gateway is present.
  • Specs linked: 03-runtime/19-remote-agent-control-protocol.md §3 and §7.7, 05-security/02-remote-control-security.md §3
  • Acceptance: Security, Quality
  • Milestone: Post-MVP
  • Status: Draft; remote harness required

E2E-222: Remote turn streams ordered durable events and live deltas ​

  • Preconditions: An authenticated controller is attached to an idle Session with a deterministic fixture Agent whose turn has two model rounds, one tool call, and one compaction.
  • Steps: 1) Attach and subscribe from the current cursor. 2) Call turn/start. 3) Collect every event envelope until the terminal event, separating durable events (with sequence) from ephemeral events (with afterSequence). 4) Compare the final snapshot with the state rebuilt from durable events alone. 5) Compare each payload.event with the local AgentEvent recorded by the desktop for the same turn.
  • Expected: turn/start returns quickly with a turnId; durable sequences are strictly increasing inside one epoch; item.delta, tool.progress, and turn.activity carry afterSequence and never a sequence; exactly one turn.completed is emitted, at the local agent_end, and none at the intermediate turn_end; the compaction appears as an item with itemType: "compaction"; the terminal state is immutable; and the final snapshot equals the state reconstructed from durable events plus the content of item.completed payloads.
  • Specs linked: 03-runtime/19-remote-agent-control-protocol.md §§5–8, 03-runtime/10-session-state-machine.md, 02-architecture/05-remote-agent-control.md §8
  • Acceptance: C (conversation & stream), Quality
  • Milestone: Post-MVP
  • Status: Draft; remote harness required

E2E-223: Reconnect replays durable events or resynchronizes by epoch ​

  • Preconditions: A turn is producing at least five durable events and a long streaming assistant message; the harness can close and reopen the client connection and restart the Host.
  • Steps: 1) Record the last applied cursor. 2) Disconnect in the middle of a streaming message. 3) Reconnect and subscribe with after. 4) Repeat after evicting the cursor from the bounded replay window. 5) Restart the Host during an idle period and reconnect with the old cursor. 6) Repeat step 3 over RACP-HTTP using Last-Event-ID in the epoch:sequence form.
  • Expected: A retained cursor replays every later durable event exactly once and no delta; the snapshot's activeItems carry the text streamed before the disconnect; an evicted cursor returns resync.required with a complete snapshot; a cursor from the previous epoch returns CURSOR_EXPIRED and a snapshot with a new epoch; a durable gap never causes the client to guess or apply out-of-order state; and the SSE path behaves identically.
  • Specs linked: 03-runtime/19-remote-agent-control-protocol.md §§5.3, 5.4, 7.2, and 8, 02-architecture/05-remote-agent-control.md §8
  • Acceptance: Recovery, Quality
  • Milestone: Post-MVP
  • Status: Draft; remote harness required

E2E-224: Remote approvals carry the local vocabulary and stay host-owned ​

  • Preconditions: A Session policy requires a tool approval; the fixture Agent submits a Plan and asks one multi-select asktool question in the same run; Host policy allows remote session grants; a second Session is configured with permissionMode: "auto" and the Host ceiling is ask.
  • Steps: 1) Start the turn from a controller. 2) Observe the tool approval request. 3) Attempt a decision from a viewer, then with a stale revision. 4) Resolve from an authorized approver with allow-session. 5) Attach a third client after the Plan approval is raised and read its snapshot. 6) Resolve the Plan with approve and no permissionMode, then with permissionMode: "accept-edits". 7) Answer the input request with one multi-select answer and one null. 8) Repeat step 4 after expiry. 9) Start a turn on the auto Session from a controller without approver.
  • Expected: The viewer and stale responses fail closed with FORBIDDEN and APPROVAL_STALE; allow-session resumes the turn and a later call of the same tool in that Session needs no approval; the late client's snapshot lists the pending Plan approval; the mode-less approve is rejected and the explicit one queues execution in Agent mode with accept-edits; the desktop's own approval card closes when the remote decision lands; the asktool answers reach the Agent as the local AskToolResolution; expiry returns APPROVAL_EXPIRED and never executes the tool; and the auto Session turn reports effectivePermissionMode: "ask" and raises an approval while the durable session mode stays auto.
  • Specs linked: 03-runtime/19-remote-agent-control-protocol.md §§5.5, 7.3, 7.5, and 9, 05-security/02-remote-control-security.md §§4 and 7, 03-runtime/10-session-state-machine.md
  • Acceptance: E (tools & permissions), Security, Recovery
  • Milestone: Post-MVP
  • Status: Draft; remote harness required

E2E-225: A turn continues when its client disconnects and the queue is host-owned ​

  • Preconditions: Two authenticated controllers can access the same Session; the fixture Agent has a delayed deterministic turn; the local desktop renderer is open on the same Session.
  • Steps: 1) Client A starts the turn. 2) Disconnect A while it is running. 3) Observe the Session from client B. 4) Reconnect A as a viewer. 5) From B, call turn/start with admission: "reject_if_busy", then with admission: "queue" twice. 6) Cancel the second queued turn from B. 7) Call turn/stop from B. 8) Fill the queue to maxQueuedTurnsPerSession and start once more.
  • Expected: The turn continues after A disconnects; B observes the same turn and durable sequence; A catches up by cursor; the first start returns AGENT_BUSY; the queued starts return turn.queued with positions and the desktop renderer shows the same queued prompts; the cancel emits turn.canceled; turn/stop ends the active turn as completed at the next boundary and the remaining queued turn starts; the overflowing start returns AGENT_BUSY with details.queueFull.
  • Specs linked: 02-architecture/05-remote-agent-control.md §§6, 9, and 10, 03-runtime/19-remote-agent-control-protocol.md §§7.3–7.4 and 8
  • Acceptance: C (conversation & stream), Recovery
  • Milestone: Post-MVP
  • Status: Draft; remote harness required

E2E-226: Roles, scopes, and revocation are enforced ​

  • Preconditions: One tenant, two Hosts, two Sessions, and principals with viewer, controller, approver, and owner roles exist. A multi-tenant harness profile, when available, adds a second tenant.
  • Steps: 1) Try every catalog operation, including session/history, turn/cancel, and host-scope events/subscribe, with each role. 2) Substitute a Session, Host, and approval id from the other Host, and from the other tenant when the multi-tenant profile is active. 3) Queue a turn as a controller, then revoke that principal and retry its existing connection.
  • Expected: The role matrix is enforced, including the allow-session policy row; foreign identifiers return FORBIDDEN or NOT_FOUND without existence leakage; revocation closes the connection, blocks new mutations, and cancels the revoked principal's queued turn; and audit records identify the denied principal without recording prompt or secret content. The cross-tenant cases are required only under the multi-tenant profile.
  • Specs linked: 05-security/02-remote-control-security.md §§3–9 and §11, 03-runtime/19-remote-agent-control-protocol.md §§6 and 13
  • Acceptance: Security, Quality
  • Milestone: Post-MVP
  • Status: Draft; remote harness required
  • Preconditions: The Agent Host runs behind a test firewall that rejects inbound connections. The Gateway is publicly reachable by two test clients.
  • Steps: 1) Enroll the Host with a one-time credential. 2) Establish the outbound mTLS Host link. 3) Attach both clients and run a turn that raises a tool approval. 4) Answer the relayed server request from the client that received it, then attempt to answer it again from the other client. 5) Upload an attachment through the Gateway upload target and reference it from a turn. 6) Drop the link and allow it to reconnect. 7) Revoke the Host and attempt re-enrollment with the old credential.
  • Expected: No inbound desktop port is opened; the Gateway routes only to the authenticated Host; the approval request is delivered on exactly one logical connection and the second answer returns the stored result with alreadyResolved; attachment bytes cross the link in bounded chunks, the Host verifies the hash, and the Gateway's copy is gone after attachment/complete; local execution continues across the link interruption and both clients resume from resumable cursors; reconnect does not duplicate the turn; and the revoked credential cannot re-enroll.
  • Specs linked: 02-architecture/05-remote-agent-control.md §5.3, 03-runtime/19-remote-agent-control-protocol.md §§10 and 11.4, 05-security/02-remote-control-security.md §§3.2 and 6, 06-delivery/07-remote-control-rollout.md §2
  • Acceptance: Security, Recovery, Quality
  • Milestone: Post-MVP
  • Status: Draft; unscheduled until the Gateway milestone is scheduled (D375)

E2E-228: Shipped bindings and browser profiles preserve semantic behavior ​

  • Preconditions: The same deterministic command/event fixture is available through RACP-WS and RACP-HTTP, and through RACP-GRPC only if that reserved binding has shipped. A browser harness can hold a Gateway session cookie.
  • Steps: 1) Run the fixture through each shipped binding on the header profile. 2) Disconnect during the event stream. 3) Retry a mutation with the same idempotency key. 4) Compare normalized responses, errors, durable sequences, and final snapshots. 5) From the browser harness, open the WebSocket and the SSE stream on the cookie profile with an allowed Origin, then with a disallowed Origin, then with a token in the URL, then send a mutation without a CSRF token.
  • Expected: All shipped bindings accept and reject the same operations, preserve durable event order and terminal state, return the same semantic error codes, and produce one mutation result despite retries; the cookie profile succeeds only with an allowed Origin; the disallowed Origin, the URL token, and the CSRF-less mutation are rejected.
  • Specs linked: 03-runtime/19-remote-agent-control-protocol.md §§11 and 14, 05-security/02-remote-control-security.md §§3.1 and 5, 06-delivery/07-remote-control-rollout.md §§3–4
  • Acceptance: Quality, Recovery, Security
  • Milestone: Post-MVP
  • Status: Draft; the browser-profile steps are unscheduled until the browser milestone is scheduled (D375); the parity steps run when a second binding ships

E2E-229: Attachment and workspace boundaries are enforced remotely ​

  • Preconditions: A Session has a project root and private scratch root. A controller can upload one valid fixture and one invalid fixture, directly and through a Gateway relay.
  • Steps: 1) Upload bytes with a correct hash and reference the attachment from a turn. 2) Repeat with a wrong hash, oversized body, local absolute path, file:// URL, and an expired upload target. 3) Repeat step 2 through the Gateway relay. 4) Attempt a tool path outside the Session root. 5) Call project/list and session/create without a path.
  • Expected: Only the verified attachment is accepted; invalid uploads fail before turn admission on both paths; no local client path reaches the Host; project/list returns labels and ids without absolute paths; session/create binds the project by id; and the existing PATH_OUTSIDE_WORKSPACE boundary remains authoritative.
  • Specs linked: 03-runtime/19-remote-agent-control-protocol.md §§5.7, 7.7, and 10, 05-security/02-remote-control-security.md §6, 03-runtime/03-tools-and-permissions.md
  • Acceptance: E (tools & permissions), Security
  • Milestone: Post-MVP
  • Status: Draft; remote harness required

E2E-230: Failure recovery does not replay admitted work ​

  • Preconditions: A deterministic Host, Gateway, and client can inject process, link, and response-loss failures; one turn is running and two are queued.
  • Steps: 1) Drop the response after turn/start is admitted. 2) Retry with the same idempotency key. 3) Crash the Host during the running turn. 4) Restart the Host and reconnect from the last cursor. 5) Drop an approval response after it is accepted and retry it.
  • Expected: The first retry returns the original turn; execution occurs once; Host recovery marks the interrupted turn according to the local recovery policy; the reconnect receives a new epoch and a snapshot whose queue is empty, and neither queued turn is started or replayed; no completed item is replayed as a new turn; and the retried approval returns the stored decision with alreadyResolved without executing twice.
  • Specs linked: 02-architecture/05-remote-agent-control.md §10, 03-runtime/19-remote-agent-control-protocol.md §§7–8 and 9.3, 06-delivery/07-remote-control-rollout.md §4
  • Acceptance: Recovery, Security, Quality
  • Milestone: Post-MVP
  • Status: Draft; remote harness required

E2E-REMOTE-MVP: Legacy remote Agent compatibility ​

  • Preconditions: Current Desktop and complete same-platform Host bundle, isolated data/profile, and a loopback mock model. No production credentials.
  • Steps: Copy an explicitly selected eligible provider and repeat the copy; pair, register a project, create/open an Agent/Ask session and send text. Drop the client connection while a real Bash approval is pending, reconnect and approve once. Read the resulting transcript/file/diff, reject a path outside the project, remove the host and attempt another prompt.
  • Expected: Import preserves the default and is idempotent. The command and turn execute once, in the Host project only; stale routing never falls back locally. Snapshot recovery restores pending requests and clears peer-resolved cards. Local workspace/model state is unchanged.
  • Compatibility coverage: Existing remote Agent session routing remains covered by remote-mvp-renderer.test.mjs; the Settings inventory exposes no session-list or new-session controls. New projects follow E2E-REMOTE-HOME.
  • Automation: scripts/e2e-remote-mvp.mjs (packaged Host, real Rust/Agent runtime and Desktop main routing, mocked model); scripts/e2e-remote-mvp-ui.mjs (real components, mocked IPC). Neither is a full SSH/Desktop acceptance result.
  • Status: Automated narrow-MVP scenario. Linux sshd, release download, password authentication and full R2 remain separately gated by E2E-231.

E2E-REMOTE-HOME: Create and continue a remote workspace as a local project ​

  • Preconditions: Isolated Desktop profile, connected Host directory fixtures, a local provider fixture, and local/remote directories with the same basename. No user profile, production host, credentials or paid model calls.
  • Steps: Open Settings → Remote Hosts and click New project on the intended host. Confirm the shared Create project dialog has Remote selected and that host preselected. Browse through a separate folder dialog, cancel/reopen, navigate parent/child and confirm a directory. Enter a custom name, create, rename and reopen the project. Start a normal local chat, choose a local model and send a request that invokes Read and Bash remotely. Approve locally, cancel a command, disconnect/reconnect and retry explicitly.
  • Expected: Focus and drafts survive picker cancellation. Late host responses cannot replace the current host selection. Project metadata and sessions are local and stable after reload, including name/memory/instructions and ordinary sidebar/archive operations. Model requests and provider secrets never appear in remote transport. Only locally admitted workspace tools execute on Host. Equal paths on two hosts or on Desktop never collide. Failed calls never execute locally or replay automatically. Existing remote histories remain readable.
  • Specs: 02-architecture/05-remote-agent-control.md, 04-ux/01-ui-ia.md, ADR local-agent-remote-workspace-tools.
  • Acceptance / milestone: Local Agent remote-workspace execution; additive project KV metadata and authenticated workspace-tool endpoints.
  • Automation: remote-project-dialog-user-path.test.mjs, remote-workspace-tools.test.mjs, host-core remote project/relay tests, pi-host workspace-tool tests and Agent runtime remote-workspace tests; PI_HOST_CORE_BINARY=<candidate binary> node scripts/e2e-local-agent-remote-tools.mjs runs a local model fixture through production Desktop relay, WebSocket RACP and real Rust Read/Bash execution, asserting zero remote providers, projects and sessions. Both local and remote Rust Hosts are real; the model service and the fixture's user approval action are controlled test boundaries.
  • Status: Candidate implementation; real SSH release validation remains the separately provisioned E2E-231 scenario.

E2E-231: The desktop drives a remote Host over an SSH tunnel ​

  • Preconditions: A Linux test machine runs sshd and holds a project the desktop can reach with the user's SSH key. A GitHub Releases fixture serves the pi-host bundle for that platform at the desktop's version, a bundle at another version, and a tampered bundle with a wrong checksum. The desktop has one local session open, one user MCP server configured, and one installed plugin whose tool requires workspace access.
  • Steps: 1) Add the remote machine from the desktop and let the uploaded bootstrap script download, verify, and start pi-host over SSH. 2) Observe the pairing exchange and the resulting device token. 3) Create a session under a remote project through project/list and session/create. 4) Start a turn whose fixture reads, edits, and runs a command in the remote project, and approve the command from the desktop card. 5) Switch the session to Plan mode and back with session/configure while idle, then attempt it while a turn runs. 6) Open the files tab and the diff tab for the remote session. 7) Advertise relay from the desktop, run a turn that calls the desktop MCP tool, then close the desktop during a second call. 8) Kill the SSH session mid-turn, restore it, and let the desktop reconnect. 9) Inspect the remote tool catalog. 10) Attempt to connect from a non-loopback address on the remote machine, then with a reused pairing token. 11) Point the bootstrap at the tampered bundle, then at the other version, and reconnect.
  • Expected: Files change only on the remote machine and the command runs there; the approval card appears in the desktop with the local vocabulary; the remote host-core binds loopback only; session/configure succeeds while idle and returns CONFLICT while running; files and diff come from the remote session root and a path outside it returns PATH_OUTSIDE_WORKSPACE; the desktop MCP tool executes on the desktop and its result reaches the remote transcript, while the second call fails with TOOL_FAILED and the turn continues; reconnect resumes by cursor without a duplicate. The remote catalog lists the relayed MCP tool but not the workspace-requiring plugin tool. Non-loopback peers and reused pairing tokens are rejected. Tampered bundles are refused before start; SSH login failures are surfaced safely; version mismatch offers a matching-release recovery. The local session is untouched throughout.
  • Specs linked: 02-architecture/05-remote-agent-control.md §§5.2 and 6.3, 03-runtime/19-remote-agent-control-protocol.md §§6.2, 9.4, and 11.1, 05-security/02-remote-control-security.md §§3.4, 4.3, 5.1, and 7, 06-delivery/07-remote-control-rollout.md §2
  • Acceptance: E (tools & permissions), Security, Recovery, Quality
  • Milestone: Post-MVP (rollout R2)
  • Status: Draft; remote harness with a Linux SSH target required

E2E-REMOTE-SSH-OPTIMIZATION: Verified deployment, local Agent and recovery ​

  • Preconditions: Isolated Desktop profile and a Linux x64/arm64 SSH target with sshd, POSIX shell, archive/hash tools and kernel flock; no system Node, systemd, public listener, production credential or paid model. A controlled release fixture serves a matching Host archive and digest.
  • Steps: Install through SSH with remote download; repeat bootstrap and verify immutable installed files are reused. In a separate isolated account, block remote release access and verify one bounded Desktop-download/SSH-upload path. Corrupt the archive and alter an installed file; verify integrity failures do not fall back or replace a working install. Reboot/kill the Host and repeat readiness; rotate Host identity and revoke a device token. Create a local project bound to the remote workspace, use local model credentials and remote AGENTS.md with local global instructions, then run Read and permissioned Bash. Drop SSH during Write and inspect the remote file before any manual retry. Exercise offline project browsing and the Settings connection status labels. Repoint the fixture release so the paired Host no longer matches the desktop, then select a remote folder and verify the Host is realigned over the same verified install, re-paired in place and connected without removing it.
  • Expected: Bundled Node starts the Host without a system Node or remote release access. Reuse validates the external release receipt and installed manifest; failed staging leaves the previous current intact. Daily readiness starts/reuses the same Host without re-pairing, resetting credentials, killing another client’s Host or replaying tools. Identity/authentication failures stop automatic retry. Local model requests, API keys, sessions and permissions stay local; workspace tools/files and root-only repository instructions come from the remote canonical root. A lost mutation reply reports REMOTE_MUTATION_OUTCOME_UNKNOWN and is not replayed or executed locally. A Host that falls behind the desktop release is realigned over the same verified install and re-paired in place — once per release per host, and again on an explicit user reconnect — while a Host that still cannot serve this desktop keeps the upgrade message instead of looping or dropping the pairing.
  • Automation: remote-host-realign.test.mjs, remote-host-supervisor.test.mjs, SSH bootstrap shell, packaging, artifact transfer, concurrent readiness/auth lifecycle and UI contract suites; PI_HOST_CORE_BINARY=<candidate binary> node scripts/e2e-local-agent-remote-tools.mjs covers local model/instructions through real loopback RACP and Rust Read/Bash, but is not SSH qualification.
  • Acceptance: Security, Recovery, Quality; release remains gated on the real Linux SSH run and a new matching Desktop/Host release. This scenario does not claim reverse-relay, interactive-terminal or complete R2 acceptance.
  • Status: Candidate implementation; Linux SSH and release qualification pending.

E2E-REMOTE-HOST-ssh-password-authentication ​

  • Preconditions: Settings → Remote Hosts is available with its Experimental badge, independently of developer mode. A Linux test machine runs sshd with PasswordAuthentication yes and PubkeyAuthentication no, so the login is only reachable with a password; the user's local SSH agent holds no usable key for it. A GitHub Releases fixture serves the pi-host bundle for that platform at the desktop's version. A second machine accepts the user's key only.
  • Steps: 1) In Settings → Remote Hosts, open the Add form's SSH tab, choose a local private-key file in key mode, and confirm its path is displayed. Cancel a second file selection and confirm the existing choice remains; select the same file again and confirm it can be reselected. Clear the selection once to confirm the SSH agent/config fallback is available, then choose the key again. 2) Switch the authentication mode to password, confirm the identity-file picker is replaced by a masked password field, and reveal it once. 3) Try to submit an empty password. 4) Point the form at the password-only machine, enter the password, and bootstrap it. 5) Quit the desktop, relaunch it, and let the host reconnect. 6) Change the saved password to a wrong value through a re-pair, then force a reconnect. 7) Bootstrap the key-only machine with a password supplied. 8) Inspect the desktop's process arguments and remote-hosts.json while a password-authenticated host is connected. 9) Attempt to submit a password containing a newline through the IPC channel directly.
  • Expected: Key mode lets the user choose a local identity file; its path is read-only in the form and is passed to the existing ssh -i flow. The app never reads or uploads the key contents. Cancel preserves the prior choice, the same file can be selected again, and Clear returns to SSH agent/config lookup. The key-authenticated SSH behavior remains unchanged: BatchMode=yes and no credential file anywhere. In password mode the picker is replaced by a masked password field, the reveal toggle shows and re-hides the value, and an empty password keeps the submit disabled. The password-only machine installs and pairs. After the relaunch the host reconnects with no prompt, proving the saved credential is used. The wrong password yields exactly one failed attempt per connect (no helper-driven retry storm) and the host shows as offline rather than crashing the app or showing a rejected-login dialog. The Settings toast carries ssh's last stderr line (for example Permission denied), not a bare exit code 255. The key-only machine fails under password mode because that mode sets PubkeyAuthentication=no; a user with a key picks key mode. No ssh argument contains the password, no askpass file or directory remains after the connect completes, and the on-disk record stores the password only as keychain ciphertext — never in cleartext. A password containing a newline is refused with INVALID_ARGUMENT before any remote command runs.
  • Specs linked: 05-security/02-remote-control-security.md §§3.4 and 13 (gate 21), 02-architecture/05-remote-agent-control.md §5.2, 06-delivery/07-remote-control-rollout.md §2
  • Acceptance: Security, D (surfaces), Quality
  • Milestone: Post-MVP (rollout R2b)
  • Status: Draft; remote harness with a password-only Linux SSH target required. The chooser control is covered by apps/desktop/test/settings-remote-hosts.test.mjs; the SSH credential seam is covered offline by apps/desktop/test/remote-host-ssh-password.test.mjs.

E2E-REMOTE-HOST-settings-compact-inventory ​

  • Preconditions: The desktop Settings window can open. No paired remote host is required. Developer mode is off in persisted settings. The isolated renderer harness can return a successful pair result without network access.
  • Steps: 1) Open Settings with developer mode off and confirm Remote Hosts and its search hits are available. 2) Open the page and confirm the rail and title carry the Experimental badge. 3) Confirm the SSH and Pair action row is above the inventory and neither dialog is open. Open SSH; verify the dialog is named, modal, and focuses its first field. Cancel and confirm focus returns to SSH. Open Pair, submit the fixture pairing form, and confirm success closes the dialog and clears the draft. Change language and confirm action labels update. Reopen Pair, enter a draft, cancel, and confirm the dialog closes and the draft clears. Open SSH password mode, enter a fixture password, cancel, reopen, and confirm the password is empty. Press Escape and verify the dialog closes and focus returns to SSH. 4) Toggle developer mode and confirm the destination stays selected and available. 5) On a paired SSH host, open the historical sessions browser and verify model-copy and remote-create controls are absent. New workspace creation stays on Home.
  • Expected: Experimental availability is consistent across packaged and development builds. Add actions precede inventory; the forms open in the shared modal surface, one at a time. Cancel, Escape, backdrop dismissal, and successful submission close the dialog and clear drafts; focus returns to the initiating action. Existing pairing toasts remain. The inline browser preserves existing remote histories; new SSH workspace projects use the local model and project controls from Home.
  • Specs linked: 04-ux/06-settings-ia.md §1, §3, 06-delivery/07-remote-control-rollout.md §2 R2, 02-architecture/05-remote-agent-control.md §5.2
  • Acceptance: D (surfaces), Quality
  • Milestone: Post-MVP (rollout R2b)
  • Status: Offline coverage in settings-remote-hosts.test.mjs and remote-mvp-renderer.test.mjs; isolated Electron/Chromium interaction coverage in scripts/e2e-remote-mvp-ui.mjs, including disclosure, success and cancel paths. Real SSH acceptance is separate.

E2E-232: The outbound messaging integration relays events and commands ​

  • Preconditions: A Host runs with the integration adapter configured against a webhook sink and a long-polling bot fixture, with one linked chat and one unlinked chat. No inbound port is open on the Host machine.
  • Steps: 1) Run a turn to completion. 2) Start a turn that raises a tool approval and leave it pending. 3) Send the stop command from the linked chat during a running turn. 4) Send the same command from the unlinked chat. 5) Make the webhook sink return errors for one minute, then recover. 6) Inspect every payload delivered.
  • Expected: The sink and the bot receive redacted summaries of turn.completed and approval.requested with ids and bounded summary text only; the linked chat's stop maps to turn/stop under the linked principal's roles and the turn ends at the next boundary; the unlinked chat's command has no effect and is audited; delivery failures are retried within the bound and never delay or block the turn; and the Host opens no listener.
  • Specs linked: 02-architecture/05-remote-agent-control.md §4, 06-delivery/07-remote-control-rollout.md §2 (R3), 05-security/02-remote-control-security.md §10
  • Acceptance: Security, Quality
  • Milestone: Post-MVP (rollout R3)
  • Status: Draft; integration fixture required

Trusted extension scenarios (R7 v1) ​

The following scenarios are the acceptance targets of D387 / ADR 0214 and 07-plugins/16-trusted-extensions.md. The headless runner generates its six plugin-form fixtures in an isolated temporary directory at runtime.

E2E-241: Discovery lists trusted extensions and enablement is explicit ​

  • Preconditions (D388): a pi extension directory hello/ with index.ts, and a plugin package declaring contributes.agentExtensions with agent.extension whose activation scope is limited to the fixture project; a second project outside that scope.
  • Steps: 1) Plugins → Import pi extension, accept the confirm, pick hello/. 2) Send a prompt in the fixture project. 3) Open the imported plugin's row details. 4) Disable the plugin and send a prompt. 5) Switch to the second project and send a prompt. 6) Load a plugin whose manifest lists agentExtensions without the permission.
  • Expected: The import creates plugins/imported/hello with a manifest holding agent.extension and lists the plugin with the agentExtension capability and the permission chip; the next turn lists its tools and the row shows loaded with the registered names; disabling the plugin retires the runtime and the next turn has no extension tools; the project-scoped plugin contributes nothing outside its projects; the manifest without the permission is rejected as PLUGIN_INVALID; ~/.pi/agent/settings.json is never written.
  • Specs linked: 07-plugins/16-trusted-extensions.md §2, §3, §11; D007; D387
  • Acceptance: Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Partially automated (pnpm test:e2e:trusted-extensions); the headless journey covers plugin discovery/projection, project scope, load state, and diagnostics, while native picker import and explicit enablement remain renderer/platform validation.

E2E-PLUGIN-imported-pi-package-wrapper: Imported package module types preserve plugin initialization ​

  • Preconditions: Isolated local Pi packages declare type: module, type: commonjs, or no type; each has an extension and skill contributions. A fourth fixture represents an older imported ESM package with the generated CommonJS main.js and matching manifest.
  • Steps: Run node --test apps/desktop/test/imported-package-skills-runtime.test.mjs. Generate each plugin through the production importer, load it through PluginRuntime and the real child-process plugin host, read its skill catalog and bodies, and inspect its declared extension. Re-import the older fixture without loading it, then load the older fixture itself.
  • Expected: Each new manifest points to an existing main.cjs; initialization succeeds for all three package types. Source and both copied package.json files retain the same bytes. The repeated import has a distinct path/id and loads successfully; generating it leaves the older manifest, wrapper, and package files unchanged. Loading the older fixture rewrites its generated main.js in place to main.cjs, keeps the copied package.json bytes, and initializes. A customized main.js is not rewritten.
  • Specs linked: 07-plugins/16-trusted-extensions.md §3.2; ADR 0215.
  • Acceptance: Quality
  • Status: Automated import-to-plugin-host fixture. Run against the committed task candidate after incorporating the latest origin/main; record the tested candidate, base, result, and environment in delivery evidence. Native picker, npm dependency installation, Windows execution, and third-party extension execution during a provider turn are not covered by this fixture.

E2E-PLUGIN-imported-pi-package-skills: Explicit package import exposes skills through plugin grants ​

  • Preconditions: A local fixture package under an npm-style node_modules/@fixture/package-skills path declares pi.extensions and pi.skills. Its skills include a direct Markdown file, a directory with SKILL.md, and a collection containing two different SKILL.md files. Reference files, assets, a node_modules-note.txt resource, and an internal node_modules dependency are present. A second fixture declares only pi.skills. No downloaded third-party code or dependency installation is needed.
  • Steps:
    1. Choose the mixed package through Plugins → Import pi extension and inspect the generated manifest and copied resources. For headless validation, pass the explicit selected path to the same importer.
    2. Load the generated directory in the real PluginRuntime; inspect getSkills() and read every document with loadSkillBody(id).
    3. Reload with only agent.extension granted, then reload with only agent.prompt.inject granted. Unload the plugin and try the old skill IDs.
    4. Import the skill-only package; ensure a helper index.js is not treated as an extension. Install a generated two-skill fixture through the real Host plugins.installFromPath, list it, read its installed manifest and skill files, then uninstall it.
    5. Attempt declarations with absolute paths, .., missing or unsupported files, an internal dependency path, or descendant symbolic links. Exceed the 32-skill or 256-directory scan limit and inspect failure cleanup.
  • Expected: All four declared skills appear with independent stable IDs, including the repeated SKILL.md basenames, and loading them returns the correct body with frontmatter removed. The imported extension remains a separate contribution. Resources and their relative paths survive copying; npm installation ancestors do not suppress the package, and only dependency directory segments within the selection are excluded. Missing skill grants leave no skill catalog entries and produce the existing permission audit; grants can be restored without changing IDs. Unload removes both catalogs, and old skill IDs return NOT_FOUND. Skill-only imports declare only agent.prompt.inject, and Host reports the skills capability while retaining both skill documents. Invalid declarations fail without importing outside data or retaining a partial copied plugin. Nothing automatically imports ~/.pi or runs npm lifecycle scripts; the explicit dependency path may run the bounded npm installer described in E2E-PLUGIN-import-extension-installs-dependencies.
  • Specs linked: 07-plugins/16-trusted-extensions.md §3.2; 07-plugins/02-plugin-manifest-schema.md; D007
  • Acceptance: E (tools & permissions), G (plugins), Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Partially automated. imported-package-skills.test.mjs covers import discovery, resource copying, grants, and invalid-path handling. imported-package-skills-runtime.test.mjs drives the generated no-op plugin through the real plugin host subprocess and verifies catalog/body loading, grant removal/restoration, and unload. On 2026-09-13, a separate temporary two-skill fixture passed real Host plugins.installFromPath → plugins.list → installed manifest/body reads → plugins.uninstall; it reported only agent.prompt.inject and the skills capability. The native picker, rendered plugin row, and a provider turn invoking the imported Skill have not been executed for this scenario; no full desktop journey is claimed.

E2E-242: Extension tools and hooks take effect in a turn ​

  • Preconditions: An enabled fixture extension that registers tool fx_add, handles before_agent_start by appending a marker to the system prompt, tool_call by blocking bash with a reason, and tool_result by replacing fx_add output.
  • Steps: 1) Start a turn in Agent mode whose fixture model calls fx_add then bash. 2) Inspect the provider request. 3) Inspect the tool results. 4) Switch to Plan mode and repeat. 5) Register a second extension declaring a tool named read.
  • Expected: The system prompt carries the marker; fx_add executes in the sidecar with no permission prompt and its result is the replaced value; bash is blocked with the extension's reason and the block is visible in the transcript; an audit line records extension id, tool name, and duration and no parameters; in Plan mode fx_add follows the non-core mode gate; the read collision is rejected with a diagnostic and the core tool is unchanged.
  • Specs linked: 07-plugins/16-trusted-extensions.md §6, §7; ADR 0214
  • Acceptance: B (agent), Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Partially automated (pnpm test:e2e:trusted-extensions); Agent-mode tool dispatch, ToolSearch deferral, hooks, blocking, and result replacement pass, while Plan-mode gating and core-tool collision remain additional validation.

E2E-HOOKS-prompt-chain: Multiple extensions preserve prompt additions ​

  • Preconditions: Two or three enabled fixture extensions append different markers using event.systemPrompt in before_agent_start; the provider is a local deterministic HTTP/SSE service.
  • Steps: Run two turns through the real runtime and inspect both provider requests. Exercise multiple handlers, explicit replacements (including an empty string), missing/malformed returns, exceptions, timeout/late input mutation, and Stop/disposal while a handler waits.
  • Expected: All markers reach the provider in extension/registration order, once per turn. Identical inputs produce identical prompt bytes. Full string replacements remain supported. Only timely returned strings enter the chain; failed handlers do not discard earlier additions, cancelled turns send no request, and the next turn can proceed. Other events retain existing folds.
  • Specs linked: 07-plugins/16-trusted-extensions.md §6; ADR 0214; #1023.

E2E-HOOKS-context-every-request: Context handlers shape every provider request ​

  • Preconditions: An enabled fixture extension appends a marker message in context; the provider is a local deterministic HTTP/SSE service whose first reply is a tool call.
  • Steps: Run two turns through the real runtime: the first turn makes a tool call and a follow-up request, the second answers directly. Inspect every provider request and the persisted transcript rows.
  • Expected: context fires once per provider request, including the first request of each run, and every request carries the marker exactly once. The marker never reaches the persisted transcript.
  • Specs linked: 07-plugins/16-trusted-extensions.md §6; ADR 0214; #1838.
  • Acceptance: B (agent), Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Automated by extensions/prompt-chain.test.ts, extensions/runner.test.ts, and extensions/runtime-lifecycle.test.ts. pnpm test:e2e:trusted-extensions additionally checks that two plugin-form extensions reach the provider in the desktop's extension ID order.

E2E-TRUSTED-EXTENSION-custom-agent-stream-and-binding: Plugin-owned agent streams and session binding ​

  • Preconditions: An enabled trusted extension calls registerAgent with one model and a fixture stream/complete implementation. The fixture provider has no Host provider row or Host secret.
  • Steps: 1) Load the extension and inspect ctx.modelRegistry for the redacted model. 2) Call pi.setModel(model) while idle. 3) Run a turn and inspect the callback's model/context/options. 4) Restart or create the next turn. 5) Attempt to read Host provider keys/secret refs through the registry.
  • Expected: registerAgent appears in the loaded contract and the model is selectable; setModel persists only the current session binding under an extension-agent: provider id; the plugin callback streams the assistant response and receives cancellation; the next turn reloads the extension and restores the same agent implementation; the registry exposes model metadata and auth availability but no Host key, secret ref, OAuth token or arbitrary Host headers. registerProvider with the same plugin-owned stream shape has equivalent behavior.
  • Specs linked: 07-plugins/16-trusted-extensions.md §5, §10; ADR 0258; D426
  • Acceptance: B (agent), C (conversation & stream), Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Partially automated (pnpm test:e2e:trusted-extensions): the loaded contract, both registration forms (registerAgent and the registerProvider alias, in each call form), the custom-agent names on the plugin row, the redacted registry, idle setModel persistence under the extension-agent: id, and the next turn restored through the plugin's own transport pass. Cancellation through options.signal, the stream-form callback's model/context/options inspection, and cross-session module sharing remain additional validation.

E2E-243: Extension commands and UI prompts round-trip through the renderer ​

  • Preconditions: An enabled fixture extension registering command greet that calls ui.input, then ui.select, then ui.confirm, then ui.notify, and renames the session.
  • Steps: 1) Open global search and inspect the Commands section. 2) Run /greet from the composer. 3) Answer each prompt. 4) Run /greet again and abort the turn while the input prompt is open. 5) Run /greet with no active session. 6) Attach a remote controller (fixture) and run /greet.
  • Expected: greet is listed after built-in and plugin commands with the extension label; each prompt shows the extension label and path; answers reach the extension in order; the toast appears; the session is renamed and session_info_changed fires; the aborted prompt resolves undefined and the command ends; with no session the entry is disabled with a tooltip; under remote control the prompt fails with UNSUPPORTED and the command reports it.
  • Specs linked: 07-plugins/16-trusted-extensions.md §8, §9, §10; 07-plugins/09-plugin-command-palette.md
  • Acceptance: A (app control), Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Partially automated (pnpm test:e2e:trusted-extensions); global/composer command discovery, prompt broker round-trip, abort, session rename, exec, and Host-owned queue pass, while no-session and remote-control cases remain additional validation.

E2E-TRUSTED-EXTENSION-temporary-session-cwd-is-scratch: Extensions in a temporary session work in its scratch ​

  • Preconditions: An enabled fixture extension registering command where that reports ctx.cwd, ctx.sessionManager.getCwd(), and the working directory of a child started with pi.exec without a cwd option.
  • Steps: 1) Start a temporary session (no project) and run /where before any tool call. 2) Run /where in a project session.
  • Expected: In the temporary session all three values are the session's scratch/<sessionId> directory, which exists and the child starts in; none is the sidecar's process directory. In the project session all three are the project root, and no scratch directory is created for the extension.
  • Specs linked: 07-plugins/16-trusted-extensions.md §7; D114
  • Acceptance: A (app control), Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Unit-covered (packages/agent-runtime/src/extensions/runtime-lifecycle.test.ts); Electron journey Documented

E2E-244: Unsupported APIs, load errors, and handler timeouts degrade to diagnostics ​

  • Preconditions: Four enabled fixture extensions: one importing @earendil-works/pi-tui at top level and calling ui.setWidget; one importing an unsupported named export from @earendil-works/pi-coding-agent; one whose module throws at load; one whose context handler never resolves.
  • Steps: 1) Start a turn. 2) Open the diagnostics drawer for each entry. 3) Confirm the unsupported export stays undefined. 4) Wait past the 30 s handler limit. 5) Disable the throwing extension and start another turn.
  • Expected: The pi-tui import succeeds, setWidget returns an inert dispose, and one diagnostic per member is recorded; the throwing coding-agent export stays unavailable and reports unsupported_api; the throwing extension shows error with message and stack, the composer shows a one-line notice, and the other extensions still load; the stalled handler is abandoned after 30 s with a diagnostic and the turn completes with the unmodified context; after disabling, the notice disappears at the next turn boundary and no running turn was interrupted.
  • Specs linked: 07-plugins/16-trusted-extensions.md §4.2, §4.4, §5, §6
  • Acceptance: Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Partially automated (pnpm test:e2e:trusted-extensions); missing static named exports are covered through the real Jiti loader by runner.test.ts, while diagnostics-drawer rendering for that import, the stalled-handler timeout, and disable-at-boundary journey remain additional validation.

E2E-245: The packaged sidecar loads a TypeScript extension through jiti ​

  • Preconditions: A packaged build of the desktop app; a fixture ~/.pi/agent/extensions/typed.ts that uses TypeScript syntax, imports @earendil-works/pi-coding-agent and typebox, and registers a tool.
  • Steps: 1) Launch the packaged app. 2) Enable typed.ts. 3) Start a turn whose fixture model calls the tool. 4) Inspect the sidecar bundle manifest for the three pi package versions.
  • Expected: The extension loads without a transpile or resolution error; the aliased imports resolve to the sidecar's copies; the tool executes; the three pi package versions are identical and the CI version-lock check passes.
  • Specs linked: 07-plugins/16-trusted-extensions.md §4.2, §13; ADR 0214
  • Acceptance: Quality, Release
  • Milestone: Post-MVP (R7 v1, delivered first as the bundling spike)
  • Status: Unit-covered by packages/agent-runtime/src/extensions/bundle.test.ts: an esbuild bundle run from a temp directory, plus a kernel-loader case that loads a typebox-importing extension through @earendil-works/pi-coding-agent's own loader from a bundle with no node_modules above it; the packaged-app jiti journey remains Draft and is not faked by the headless runner.

E2E-PLUGIN-import-extension-installs-dependencies: Importing an extension with npm dependencies installs them before first load ​

  • Preconditions: A local pi extension package with package.json, pi.extensions, a pinned pure-JavaScript [email protected] dependency, a workspaces field, and no node_modules; the built workspace packages and npm are available.
  • Steps: 1) Generate the imported plugin from the local directory. 2) Run the real bounded installer. 3) Inspect the copied package, lockfile, installed module, lifecycle marker, and trusted-extension load report.
  • Expected: The plugin root holds the copied package.json with workspaces stripped. The installer runs the two registry-only, --ignore-scripts npm steps; every lockfile resolved URL is registry-only, node_modules/is-number exists, no lifecycle marker is written, and the trusted-extension runner reports loaded with the dependency-backed command registered.
  • Specs linked: 07-plugins/16-trusted-extensions.md §3.2, §10.2; ADR 0244
  • Acceptance: Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Automated by pnpm test:e2e:plugin-import-deps for the deterministic installer boundary; the full picker/renderer/turn journey remains a separate validation surface.

E2E-PLUGIN-import-extension-reports-missing-dependency: A failed dependency install or unloadable dependency is surfaced, never silent ​

  • Preconditions: Three local pi extension packages whose package.json dependencies use unsupported file:, git, and HTTP tarball sources; none has node_modules or a lockfile.
  • Steps: 1) Generate each imported plugin. 2) Invoke the real dependency installer. 3) Inspect the returned error and the generated plugin directory.
  • Expected: Each failure is explicit and occurs before npm starts; the imported plugin and manifest remain registered, while no loadable node_modules or generated lockfile remains. The renderer toast/load-error journey is covered separately.
  • Specs linked: 07-plugins/16-trusted-extensions.md §3.2, §4.4, §10.2; ADR 0244
  • Acceptance: Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Automated by pnpm test:e2e:plugin-import-deps for the deterministic registry-source rejection boundary; renderer warning-toast and load_error behavior remains a separate validation surface.

E2E-PLUGIN-import-extension-recovers-missing-npm: Choose a trusted npm installation and resume the same import ​

  • Preconditions: A desktop build with an isolated data directory; npm/Node.js unavailable on its inherited PATH; a local extension declaring a pinned registry dependency and an install-script marker; a trusted Node.js/npm installation outside PATH (on macOS, include a hidden ~/.nvm directory).
  • Steps: 1) Import the package through Plugins and accept the trust confirm. 2) In the native missing-npm message choose "Choose npm". Select an invalid executable, then an npm without usable Node.js; choose again after each warning. 3) Select working npm alongside node. Inspect the resulting plugin and saved path. 4) Restart the app and import another package; then remove or move the saved executable and repeat. 5) Repeat with Cancel in the message box, picker, and invalid-selection warning. 6) Make saving the path fail while keeping the plugin destination writable and retry with a valid selection. 7) With working npm, force an ordinary registry/network failure. Repeat dialog checks in all eight shipped locales.
  • Expected: Only structured npm/Node unavailability prompts for a path. Native copy explains trusted Node.js, installing Node.js if absent, and remembering the selection; hidden directories are visible. Invalid selections are rejected by bounded version checks and never saved; choose-again/cancel stays available. A valid choice resumes installation in the original generated directory with the original plugin id and writes <dataDir>/npm-path.json atomically. Restart reuses the validated path without prompting; a stale saved path prompts again. Save failure warns but the current install still uses the valid choice. Every completion, cancellation, and dependency error loads/registers that generated plugin exactly once without recopying it; dependency failures remain visible. Network/registry failures never open the npm picker. Only the install child gains the selected directory in PATH; shell probing, global environment changes, credential inheritance, and lifecycle marker execution do not occur. The registry-only proxy and two-step dependency validation remain enforced. Dialogs use localized standalone bodies and existing localized Cancel, with dynamic diagnostics appended on a new line.
  • Specs linked: 07-plugins/16-trusted-extensions.md §3.2; ADR 0244
  • Acceptance: F, G, Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Automated at the native-dialog I/O boundary by pnpm test:e2e:plugin-import-deps: the real import handler and npm installer recover from an empty PATH, keep registry-only resolution and disabled scripts, persist the selected path, and register the same plugin once. The desktop npm-install-recovery.test.mjs suite covers cancellation, invalid/stale paths, preference reload, save failure, localization, and ordinary network errors. Physical native-picker interaction (including macOS symlink selection), full desktop restart, and visual checks in every locale remain manual/not run.

E2E-233: Icon-only actions explain their purpose in the active language ​

  • Preconditions: The desktop app is running with a project, a chat error, toast, update notice, dialog, sidebar row, pull request, and work-panel file available as applicable; the UI language can be changed between English and Simplified Chinese.
  • Steps: 1) Hover each icon-only action in the error, toast/update, dialog, capability search, sidebar, pull-request, and file-viewer surfaces. 2) Focus the same controls with the keyboard. 3) Repeat after switching the UI language to Simplified Chinese.
  • Expected: Each control exposes a localized action purpose on hover and focus, has the same localized accessible name, and does not expose a raw icon name or URL as its action label. Decorative icons remain silent to assistive technology. English and Simplified Chinese show different catalog values.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md
  • Acceptance: Accessibility, Quality
  • Milestone: M6+
  • Status: Source-contract covered; desktop hover/focus automation pending

E2E-PLAN-005: Plan-mode plugin tools with planSafeActions are read-only (D384) ​

  • Preconditions: Nib is built with the bundled Browser plugin (pi.browser) enabled and a workspace that exposes one http(s) URL the planner can reach. The catalog list is the default bundled one; no third-party plugin needs to be installed for this scenario.
  • Steps: 1) Create a new session and switch it to Plan mode from the mode selector. 2) Send the prompt "Use the browser plugin to read https://example.com, summarize the page, and tell me what to change." 3) Wait for the planner to call plugin_pi_browser_Browser with action="navigate" followed by action="snapshot", and to submit a plan with the requested summary. 4) Approve the plan and confirm the Agent run completes. 5) Reject the plan, re-send the same prompt in Plan mode, and confirm the planner can still call navigate + snapshot. 6) Ask the planner to "click the sign-in button" through the browser plugin and confirm the call is rejected with PERMISSION_DENIED (a Plan call can never click). 7) Inspect the active session tool list and confirm it shows the Browser plugin with the description suffix Plan mode: only navigate, snapshot, screenshot, console actions. 8) Switch back to Agent mode and confirm the same prompt lets the model call click and fill without the suffix.
  • Expected: Plan mode can drive the Browser plugin for the four declared read-only actions, the description tells the model which actions are allowed, and any mutating action is denied with a structured PERMISSION_DENIED error before the plugin sees the call. Agent mode keeps the full plugin surface.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/03-tools-and-permissions.md, 07-plugins/README.md, ADR 0211
  • Acceptance: Functional, Quality
  • Milestone: M6
  • Status: Partially automated: test:e2e:plan covers Plan-mode host admission, durable-mode/action-list forwarding, and fixture-boundary mutation denial; the full Electron Browser journey remains Draft (run only in a capable environment when this surface changes)

E2E-250: Context usage display preference switches the inspector's leading figure ​

  • Preconditions: An Agent session has completed at least one turn that reported token usage. Settings → AI → Defaults is reachable.
  • Steps:
    1. Confirm the composer toolbar context ring shows remaining capacity (ring nearly full, percentage ≈ remaining %, tooltip and aria-label use remaining vocabulary).
    2. Open Settings → AI → Defaults and switch the Context usage display segmented control from Remaining to Used.
    3. Return to the chat and inspect the context ring: the ring arc now fills by usedRatio (nearly empty at low occupancy), the percentage shows ≈ used %, the popover heading shows used tokens + percentage, and the tooltip/aria-label use used-capacity vocabulary.
    4. Confirm warning/critical ring colors still follow remaining capacity: at remaining > 25 % the ring stays neutral even when used % is high.
    5. Switch back to Remaining and confirm the original display returns.
  • Expected: The display mode flips the ring arc, percentage, token count, heading, tooltip, and aria-label consistently. Color thresholds remain based on remaining capacity in both modes. The default for a fresh profile is Remaining.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, ADR 0223, 08-meta/decisions-log.md (D398)
  • Acceptance: C (conversation & stream), Quality (preference)
  • Milestone: M5
  • Status: Unit-covered (context-usage.test.mjs, settings-general.test.mjs); full scenario Draft

E2E-251: Custom dropdowns float without changing page layout ​

  • Preconditions: A desktop build with a configured provider, at least one project/session, and the default dark theme. The Settings, Plugins, Projects, chat composer, work panel, and sidebar surfaces are reachable.
  • Steps: 1) Open each available custom dropdown/menu from Settings, Projects, Plugins, the sidebar, the composer, Plan approval, and Scope. 2) Repeat with the trigger near the bottom and right edges of the window, and while the surrounding page/card has scrollable content. 3) Scroll the owning pane and resize the window while a menu remains open. 4) Close each menu with Escape and by pressing outside it.
  • Expected: Every custom dropdown is a body-level fixed layer that overlays content without increasing row/card height or changing page/sidebar/work-panel allocation. It stays within the viewport, flips or clamps when space is tight, follows its trigger after scroll/resize, is not clipped by settings cards or page overflow, and restores focus to its trigger on close. Native <select> popups are excluded because they are rendered by the operating system.
  • Specs linked: 04-ux/07-ui-design-system.md, 04-ux/09-interaction-patterns.md
  • Acceptance: Quality, responsive layout, Accessibility
  • Milestone: M5+
  • Status: Source-contract covered (fixed-dropdown-surfaces.test.mjs); desktop journey Draft (run only in a capable environment when this surface changes)

E2E-253: Project groups support manual drag and keyboard reordering ​

  • Preconditions: The sidebar contains at least three project groups, including one pinned or archived project, and each project has a stable host workspace/path/directory.
  • Steps:
    1. Press a project title, move it above or below another project group, inspect the insertion line, and release.
    2. Click a project title and confirm it still selects the project and toggles collapse without changing order.
    3. Focus the same title and press ArrowUp or ArrowDown; repeat once in each direction.
    4. Restart the app and inspect the project order.
    5. Open a session from a reordered project and confirm its host workspace, path, and directory are unchanged.
  • Expected: The project groups render in the released order and the manual order survives restart. There is no reorder grip and no 400ms delay. An insertion line shows drop placement. The title exposes a keyboard-accessible reorder action, a short click does not reorder, Escape cancels an active drag, and the existing pinned / archived priority rules remain intact. Reordering never changes a project's host workspace, path, directory, or session sort.
  • Specs linked: 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, 03-runtime/04-data-storage.md, 08-meta/decisions-log.md (D399, D402, D403)
  • Acceptance: D (workspace), F (persistence), Quality
  • Milestone: M5
  • Status: Source-contract covered (app-store-sidebar.test.mjs, sidebar-preferences.test.mjs, sidebar-project-reorder.test.mjs); rendered desktop journey Draft

E2E-254: A skill loads on the first Agent turn ​

  • Preconditions: At least one Skill is active for the current project, a provider is configured, and the session runs in Agent mode with another on-demand capability present (for example BrowserPreview or a plugin tool).
  • Steps:
    1. Open a new Agent conversation and send a prompt that matches the active Skill's description.
    2. Inspect the first provider request and its tool list.
    3. Confirm the model calls Skill with the exact id without calling ToolSearch first, and that the returned document is the skill body.
    4. Select a Skill and send /skill:<skill-id> from the composer; inspect the following turn and confirm the original Skill id reaches the tool. 4a. Add a second active Skill with / after the first token, submit the prompt, switch away from the session, and reopen it.
    5. Switch the session to Plan mode and inspect the tool list again.
    6. Disable or remove every Skill and start another Agent turn.
  • Expected: Whenever the skill catalog is non-empty, Skill ships with the first request and never appears under # On-demand tools, so both a matching task and a /skill:<skill-id> invocation load the body without a discovery round trip. Both explicit Skills load in their selected order; after reopening, each remains a separate transcript chip beside the user's prompt text. An unprefixed name that matches a Skill remains an ordinary command or template, not a Skill alias. ToolSearch still exists for the other on-demand capabilities and never returns Skill. Plan mode omits the tool and the # Skills section, and an empty catalog registers no Skill tool at all.
  • Specs linked: 03-runtime/02-agent-runtime.md (§7.1), 03-runtime/03-tools-and-permissions.md (§2.1), 04-ux/04-builtin-commands.md (§8), 08-meta/decisions-log.md (D404), ADR 0048, ADR 0219, ADR 0230
  • Acceptance: C (conversation & stream), E (tools & permissions), Quality
  • Milestone: M5
  • Status: Unit-covered (packages/agent-runtime/src/runtime.test.ts); rendered desktop journey Draft (run only in a capable environment when this surface changes)

E2E-SKILL-builtin-activation: Manage bundled skills from Settings ​

  • Preconditions: An isolated Desktop profile and an ordinary project workspace. Keep a separate active user or plugin Skill so the Skill tool remains available after imagegen is disabled.
  • Steps:
    1. Open Settings > Agent > Skills and inspect the Built-in skills group. Confirm pi-desktop/imagegen and pi-desktop/plugin-development are listed and enabled by default.
    2. Turn off pi-desktop/imagegen and open a new session in the ordinary workspace. Inspect its skill catalog and Composer's / menu.
    3. Invoke the local Skill tool with the stale imagegen id.
    4. Turn imagegen back on and open another session. Restart the app and inspect the setting and a new session catalog again.
  • Expected: The disabled imagegen skill is absent from the prompt catalog and the skill:pi-desktop/imagegen slash entry. A stale direct Skill load reports that the built-in skill is disabled. Re-enabling restores both entries on a new session, and the setting survives restart. The plugin-development row stays visible in Settings but its session entry remains limited to plugin workspaces. Associated agent tools remain independently available.
  • Specs linked: 07-plugins/01-plugin-system.md (§12.3), 03-runtime/01-ipc-protocol.md (§12b), 03-runtime/02-agent-runtime.md (§7.0.0), 04-ux/04-builtin-commands.md (§8)
  • Acceptance: C (conversation & stream), E (tools & permissions), F (persistence), Quality
  • Coverage: settings-inline-import-user-path.test.mjs exercises the Settings switch in isolated Electron and asserts the persisted IPC request and visible off state. image-generation.test.mjs, composer-mcp.test.mjs, host-core user-skill/RPC tests cover catalog filtering, stale loads, persistence, and the activation wire contract. The restart and full new-session journey remain part of desktop acceptance.
  • Milestone: M6+
  • Status: Rendered desktop journey Draft

E2E-255: An ideographic comma opens the slash menu ​

  • Preconditions: A Chinese IME is available, the composer draft is empty, and at least one slash entry exists (builtin alias, template, plugin command, or Skill).
  • Steps:
    1. With the draft empty, type 、 and inspect the composer.
    2. Continue typing a command name and accept the highlighted row.
    3. Type a draft that contains 、 between other characters.
    4. Send a draft whose first character was typed as 、 without accepting any row.
  • Expected: The committed 、 is rewritten to / in place, the ordinary slash menu opens with the same filtering and keyboard behavior as a typed /, and the caret stays after the substituted character. A 、 anywhere later in the draft stays untouched text, and the @ file menu never reacts to the mark.
  • Specs linked: 04-ux/04-builtin-commands.md (§9), 04-ux/08-component-spec.md (§11), 08-meta/decisions-log.md (D405), ADR 0024, ADR 0231
  • Acceptance: C (conversation & stream), Localization, Quality
  • Milestone: M2
  • Status: Unit-covered (packages/shared/src/composer-trigger.test.ts, apps/desktop/test/composer-ime.test.mjs); rendered desktop journey Draft (run only in a capable environment when this surface changes)

E2E-256: Empty-home project name switches among sidebar projects ​

  • Preconditions: At least two local projects are open in the sidebar; the visible chat is an empty project-bound session, and one open project has no session at all.
  • Steps:
    1. Confirm the hero title underlines the current project name.
    2. Click the underlined name and inspect the menu.
    3. Search for a sidebar project, select a different one, and inspect the hero and sidebar.
    4. Reopen the menu and choose Open project, then pick a folder or cancel.
    5. Reopen the menu, choose Clone git project, paste a repository URL, then pick a parent folder or cancel.
    6. Open a temporary empty session and confirm the underline is absent.
    7. Click the session-less project in the sidebar and inspect the hero.
  • Expected: The click opens a searchable, fixed switcher of the sidebar's open projects instead of the folder picker. Choosing another project activates it and lands on that project's empty home (reusing an empty session when one exists). Open project still uses the folder picker. Clone git project asks for a URL, then a folder, runs git clone, and opens the cloned project. Temporary heroes and the no-project home (no workspace) stay without the switcher, while a project the user opens without any session still names that project and keeps the switcher, because a task sent from that empty home joins the project. Escape and outside click dismiss the menu.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md
  • Acceptance: Quality (navigation and accessibility)
  • Milestone: M5
  • Status: Unit-covered (home-project-switcher.test.mjs, home-project-name.test.mjs, git-clone.test.mjs, sidebar-preferences.test.mjs); full UI scenario Draft (run only in a capable environment when this surface changes)

E2E-CLONE-accepts-a-lan-remote-and-rejects-metadata ​

  • Preconditions: The home project switcher Clone git project action is available.
  • Steps: 1) Enter https://192.168.1.5/org/repo.git, http://10.0.0.7/org/repo.git, and [email protected]:org/repo.git. 2) Enter https://169.254.169.254/org/repo.git and https://metadata.google.internal/org/repo.git. 3) Enter https://github.com/org/repo.git and [email protected]:org/repo.git.
  • Expected: A LAN or loopback remote the user typed is accepted and parses to a folder name, because the remote is the user's own address. Cloud metadata hosts stay rejected, as do file: and password-bearing URLs, before git clone runs. Public GitHub HTTPS and SSH remotes still parse.
  • Specs linked: 04-ux/01-ui-ia.md, ADR 0247, ADR 0304, D416
  • Acceptance: Security, D (workspace)
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/git-clone.test.mjs)

E2E-258: Create project dialog can start from a git repository ​

  • Preconditions: The Create project dialog opens from the Projects heading (no existing project is required); git is installed.
  • Steps:
    1. Open the Create project dialog and click Add folder twice quickly; confirm that only one native folder picker opens, then cancel it.
    2. Switch the source selector to Git repository.
    3. Paste https://github.com/octocat/Hello-World.git and confirm the project name field is seeded with Hello-World, then type a custom name.
    4. Choose a clone destination folder and confirm the destination row shows it.
    5. Confirm Create and inspect the workspace, sidebar, and project archive.
    6. Reopen the dialog, switch to Git repository, and paste a private or malformed remote.
  • Expected: Repeated folder-picker clicks are ignored while the native picker is open. The dialog swaps the folder list for a repository URL field plus a clone destination row and keeps one project name field. Source options and fields are filled tiles without strokes; keyboard focus uses the shared accent-tinted ring. Create stays disabled until the URL parses and a folder is chosen; a name left empty falls back to the parsed repository name. Confirming runs git clone into the chosen folder with the renderer still owning project creation: the checkout becomes the primary root and the entered name names the group. Private, loopback, link-local, credential-bearing, and malformed remotes leave Create disabled (ADR 0247) and no folder is written.
  • Specs linked: 03-runtime/01-ipc-protocol.md §9, 04-ux/08-component-spec.md, ADR 0273, ADR 0233, ADR 0247
  • Acceptance: Quality (project entry), D (workspace)
  • Milestone: M5
  • Status: Unit-covered (apps/desktop/test/project-create-dialog.test.mjs, apps/desktop/test/git-clone.test.mjs); full UI scenario Draft (run only in a capable environment when this surface changes)

E2E-261: Creating from a closed project folder reopens its group ​

  • Preconditions: A durable project group exists for a local folder and is closed from the sidebar; its directory remains available.
  • Steps: Open Create project, select only that folder, and click Create.
  • Expected: The existing group reopens at its primary root, appears in the sidebar, and the dialog closes. Its existing name, roots, chats, instructions, and memory remain intact; no duplicate project group is created.
  • Specs linked: 04-ux/08-component-spec.md, ADR 0249.
  • Acceptance: The production creation action resolves the host group before attempting creation and activates the existing primary path.
  • Milestone: M5 (project organization maintenance).
  • Status: Unit/source-contract covered (apps/desktop/test/project-create-dialog.test.mjs); full UI scenario remains Draft.

E2E-257: Plugin session import into an archived project restores its visibility ​

  • Preconditions: A durable project has been archived in the renderer sidebar preferences and is hidden from the default sidebar. A test plugin can import a new session with an explicit host project id.
  • Steps:
    1. Open Settings → Project archive and confirm the archived project remains available there while the default sidebar omits it.
    1. Use the plugin's session.importBatch with the existing project's id and wait for the host session refresh event.
    1. Confirm the project and its newly imported session appear in the default sidebar, then archive the project again.
    1. Refresh sessions without importing anything, then use session.importBatch for a pathless session and for a duplicate or skipped session.
  • Expected: A successful plugin import that adds a project-bound session clears the archived presentation state for that exact normalized project path and makes the project/session discoverable. Ordinary refreshes, pathless sessions, skipped imports, and repeated imports leave archive state unchanged; no host project row or transcript is deleted or recreated.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, 03-runtime/04-data-storage.md, ADR 0236, D407
  • Acceptance: C (conversation & stream), F (persistence), G (plugins), Quality
  • Milestone: M6+
  • Status: Unit/source-contract covered (sidebar-session-groups.test.mjs, project-import-archive.test.mjs, plugin-session-refresh.test.mjs); rendered desktop journey Draft (run only in a capable environment when this surface changes)

E2E-IMPORT-codex-scan-filters-synthetic-titles ​

  • Preconditions: A Codex archive whose sessions open with synthetic injections (# Context from my IDE setup:, # In app browser:, # Browser comments:, # Files mentioned by the user:, # Diff comments:, # Selected text:, # Review findings:, # AGENTS.md, You are Codex, <environment>) and at least one session whose stored timestamps are corrupt or out of range.
  • Steps:
    1. Run node --test test/importer-codex-scan.test.mjs from apps/desktop.
    2. Confirm test coverage for synthetic title filtering and timestamp fallback.
    3. Confirm an archive above the file cap reports truncation metadata and includes only the newest 250 files.
  • Expected: Candidate titles come from the first real user message — synthetic injections never surface as titles, while genuinely pasted markdown that starts with # (for example # Role: …) is kept. Sessions whose user messages are all synthetic do not appear as candidates. A corrupt or out-of-range stored timestamp falls back to the source file's mtime, never to the import moment. An archive above 250 session files returns the newest 250 by folder date plus truncated-count metadata; Settings no longer renders the retired session-import cap note.
  • Specs linked: 03-runtime/01-ipc-protocol.md, D320
  • Acceptance: C (conversation & stream), F (persistence), Quality
  • Milestone: M6+
  • Status: Unit-covered (importer-codex-scan.test.mjs); no Settings UI journey

E2E-LAYOUT-three-column-width-priority ​

  • Preconditions: A desktop session is open in a non-Settings route with a persisted preferred work-panel width, on a window wide enough for the three columns.
  • Steps:
    1. Open the work panel and request the user's preferred width.
    2. Drag the inner divider toward MainChat's left edge, including during pointer preview, then release.
    3. Manually reopen the sidebar after the layout collapsed it. Repeat in an 866px viewport, where the reopen narrows the panel below 244px, then collapse the sidebar again and drag the divider toward the panel.
    4. Close the work panel and confirm the sidebar returns; repeat after manually collapsing the sidebar.
    5. Repeat divider changes with ArrowLeft, ArrowRight, Home, and End.
    6. Navigate to the real Plugins and Scheduled routes with the work panel closed, then collapse the sidebar. In light and dark themes, measure both titlebar actions and compare their rest/hover styling with the shared work-panel toggle. Reopen the sidebar, collapse it again, and use New Task to return to an editable chat composer on each route.
  • Expected: The ordinary .main-titlebar actions (without a preview chrome ancestor) render as centered 28px square targets, with the shared transparent rest surface, secondary ink, radius, and semantic hover wash/primary ink. Hover does not change geometry; sidebar and New Task actions remain usable. The native window width never changes. MainChat never measures below 450px — including mid-drag and while sidebar-out still occupies flex space. The effective panel maximum is the client width minus the 450px MainChat floor and the expanded sidebar width, with no fixed pixel cap. When that budget is exhausted the expanded sidebar collapses immediately, and the panel may keep growing afterwards. A manual reopen spends panel width first; MainChat is preserved where possible and otherwise lands on the 460px reopen target. That width is lent, not stored: the stored preferred width is unchanged, collapsing the sidebar again restores it, and divider drags still stop at 244px. Closing the panel restores only a sidebar the layout collapsed. The separator's ARIA minimum/maximum follow the same dynamic budget. The panel header's +, maximize, and viewport-fixed collapse toggle resolve to a single control gap (--ds-work-panel-control-gap) with no divider, inset, or margin of the action group's own, and all three are the shared chrome icon control: 28px square on a transparent seat with only a semantic hover wash, so the header shows quiet icons rather than filled or raised squares. The collapse toggle's open state changes its glyph and ink only.
  • Overlay coverage measured in the running app: on the Plugins route a plugin modal opens; the modal veil is the topmost hit at the work-panel toggle inside the titlebar band; the titlebar band is not the topmost hit there, so it does not paint over the modal veil; and closing the modal leaves the route clean. pnpm test:e2e:layout asserts those four hits as renderer DOM/CDP evidence, not as native hit-test proof.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md §10, 04-ux/08-component-spec.md §1 and §5, 04-ux/09-interaction-patterns.md §8, ADR 0238
  • Acceptance: F (persistence), Quality
  • Milestone: Post-M6 desktop shell maintenance
  • Status: Automated (scripts/e2e-three-column-layout.mjs via pnpm test:e2e:layout — fixed-window width invariance, the 450px floor across a pointer drag, one non-wrapping composer toolbar at that floor with the model chip collapsed to its 32px icon, sidebar yield/restore, the 460px reopen target, the panel action group's shared control gap, preview mode, and ordinary Plugins/Scheduled titlebar Source contracts in chrome-control-geometry.test.mjs also cover the shared disabled state and panel controls' transparent seat; the panel surface still needs the eyes-on pass above. DOM/CDP checks establish renderer behavior, not native Windows/Linux hit testing; native platform checks remain separate. Unit coverage in work-panel-resize.test.mjs

E2E-LAYOUT-work-panel-maximize ​

  • Preconditions: A desktop session is open with the work panel visible and the bundled Browser plugin active on a loaded page.
    1. Note the current panel width. If the sidebar is expanded, collapse it; then click the panel header's + action to open a real work-panel tab.
    2. Click the panel header's preview toggle.
    3. While preview remains active, collapse and reopen the sidebar. Compare the Browser/plugin placeholder's viewport-relative rectangle with the latest native bounds report after each layout settles.
    4. Inspect the header border box and tab alignment with the sidebar collapsed and expanded on macOS windowed/fullscreen and Windows/Linux. Confirm the overlay row and spacer declare neither drag nor no-drag and do not cover the panel controls with opaque paint.
    5. Using native pointer input, click the centers and edges of sidebar and New Task controls; confirm sidebar toggles and New Task exits preview to an editable composer. Reenter preview, operate tabs, close, add, restore, panel toggle and native controls, then drag empty header space and confirm the native window moves. Repeat in light/dark themes and both sidebar states.
  • Expected: Entering preview mode stops rendering MainChat and hands its width to the panel, so the panel spans the client area minus the expanded sidebar (the whole client area when the sidebar is collapsed). The native window never changes size. The divider is inert while preview mode is on (aria-disabled). The native Browser/plugin surface matches the complete renderer placeholder rect after both sidebar transitions, including when only its absolute position changes; no stale page edge or uncovered strip remains beside it. Leaving preview mode restores the previous panel width and keeps whatever sidebar state the user chose last. The mode is transient: it is not persisted and ends when the panel closes. Preview mode keeps the shell's new-task, sidebar, and system-window actions reachable while MainChat is absent. The panel header is the sole drag owner in the preview pane. Its actual border box and first tab start at least 8px after the shell actions, including expanded-sidebar New Task, on every platform. Left inset is 8px except collapsed-sidebar windowed macOS (88px). The preview row renders the shared --ds-window-lead-inset (76px native cluster edge plus a 12px gap) as its own left padding; the check reads the resolved padding rather than restating the number. Fullscreen retains the action lane with an 8px inset. The right native-control border exclusion remains intact. Controls receive native clicks without moving the window; empty header space still drags it. Header-height background paint fills the excluded lane without hiding controls.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md §10, 04-ux/08-component-spec.md §5, 04-ux/09-interaction-patterns.md §8, ADR 0238 §6, issue #289
  • Acceptance: F (persistence), Quality
  • Milestone: Post-M6 desktop shell maintenance
  • Status: Partially automated (scripts/e2e-three-column-layout.mjs — preview entry/exit, DOM action behavior, header border-box exclusion, row/spacer drag ownership, and all-platform/fullscreen CSS fixtures in both sidebar states). plugin-view-bounds-sync.test.mjs mounts the production PluginViewTab in Electron and covers a position-only layout commit with unchanged surface dimensions. DOM clicks and CDP input are not native hit-test proof; native pointer, window-drag, and visual checks remain required on each platform. Branch runs are exploratory and do not satisfy the integrated-main gate.

E2E-WORK-PANEL-overflow-tab-drag-exclusion ​

  • Preconditions: On Windows, open a desktop session and create enough work-panel tabs to overflow the horizontal tab strip.
  • Steps: Scroll the strip toward both ends, switch to another conversation and back, reorder a tab with the keyboard, then close tabs until one and none remain. Query native WM_NCHITTEST over the adjacent sidebar and conversation titlebars, visible tabs, header actions, and spare header space.
  • Expected: Offscreen tabs do not block native dragging outside the visible scroll viewport. Adjacent titlebars and unused header space return HTCAPTION; visible tabs and actions return HTCLIENT. Switching, reordering, and closing tabs preserve those hit regions.
  • Specs linked: 04-ux/07-ui-design-system.md §10
  • Acceptance: Quality
  • Status: Windows native regression covered by scripts/e2e-work-panel-drag.mjs (pnpm test:e2e:work-panel-drag). macOS/Linux native dragging remains unqualified.

E2E-WORK-PANEL-active-tab-close-visibility ​

  • Preconditions: Render the work panel with enough long-named tabs to overflow the horizontal strip.
  • Steps: Activate an overflowing tab in the narrow layout and inspect its close-button rectangle and elementFromPoint hit target. Resize the layout while keeping the same tab active and repeat. Then partially scroll the strip to an inactive tab, hover it, and inspect its close button the same way.
  • Expected: The active close button stays fully inside the strip after activation and resize. Hovering a partially visible inactive tab scrolls its close button into the viewport too. In all three states the button is visible and hit testable without requiring a middle click, keyboard shortcut, or widening the panel.
  • Specs linked: 04-ux/07-ui-design-system.md §10, issue #1762
  • Acceptance: Quality
  • Milestone: Post-M6 desktop shell maintenance
  • Status: Electron geometry and hit-target coverage runs in scripts/e2e-work-panel-tab-close.mjs; apps/desktop/test/work-panel.test.mjs also covers the reveal contract. The Windows probe in scripts/e2e-work-panel-drag.mjs checks native hit testing separately.

E2E-LAYOUT-sidebar-project-group-fold ​

  • Preconditions: Five retained sidebar project groups, seeded through the host: one holding five sessions with recent and old reply dates, one holding a single session, one holding none, and one holding ten pinned sessions. A fifth group has twelve unpinned sessions for the project history pager. prefers-reduced-motion is unset.
  • Steps:
    1. Inspect the groups: body layering, row and per-row date counts, no time headings, the empty state, the tail each expanded group contributes to the next one, and the non-project lists' budgets.
    2. Collapse the multi-row group with a real pointer click on its directory row — scrolled into view and confirmed to hit that button — and read the group body's height, opacity, resolved grid-template-rows, and the distance to the next group on every frame for about half a second, while recording the fold's own transitionrun / transitionend.
    3. Expand it again and confirm the open geometry returns.
    4. Collapse and re-expand it within the same motion.
    5. Repeat the collapse with prefers-reduced-motion: reduce emulated.
    6. Scroll the pinned list to its last row.
  • Expected: A project group is one grid row (grid-template-rows: 1fr) that animates to 0fr over the 200ms normal duration — no max-height clamp, no opacity transition — so the fold is a single continuous height ramp with no plateau followed by a snap, and opacity stays 1 on every frame: the rows are clipped, never faded. The fold fires one transition and its own event reports the 200ms normal duration. The rows are clipped by an inner min-height: 0 box, and the 1px row gap plus the group's 2px / 7px inset sit on the list inside that clip, so the inset travels with the rows. An expanded group's 7px inset plus the 1px scroller gap read as an 8px tail to the group below it; the last group in the list has no neighbour, so it is checked against its own inset and clip instead. A folded group's tail leaves with its rows, its section is its header plus the 1px scroller gap, and its rows stay mounted past the clipped edge while the group is aria-hidden and inert. A reversal mid-flight turns on the frame it reached and settles back on the open height without overshooting, and the empty group folds its empty state the same way. Under reduced motion both endpoints are kept and the travel is dropped. The pinned list holds eight rows inside min(233px, 30vh) and scrolls to its remaining rows, and the standalone list keeps its flex column and 146px budget. The group indent, ordering, and workspace state are unchanged. The five seeded project sessions remain in one flat list with no time headings and each has its own date label.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/07-ui-design-system.md §6.1 and §13, 04-ux/08-component-spec.md §6.2, 08-meta/decisions-log.md (2026-09-16, sidebar list rhythm and project-group fold)
  • Acceptance: Quality
  • Milestone: Post-M6 desktop shell maintenance
  • Status: Automated (scripts/e2e-three-column-layout.mjs via pnpm test:e2e:layout — host-seeded groups and pins, a hit-tested CDP pointer click, per-frame height and opacity sampling across the real fold, the transition's own reported duration, mid-flight reversal, and reduced-motion emulation). Unit coverage in apps/desktop/test/sidebar-collapse-animation.test.mjs and apps/desktop/test/sidebar-pinned-rendering.test.mjs. The sampled values are renderer geometry, not an eyes-on visual pass.

E2E-LAYOUT-sidebar-row-states ​

  • Preconditions: Host-seeded project, pinned and standalone conversations; the sidebar Session time preference enabled; a current workspace; built desktop with isolated data and profile directories.
  • Steps: In dark and light themes, select a project conversation, hover its project title and an unselected conversation, then hover the selected row. Check that a project session date is visible at rest and replaced by its action in the same trailing slot on hover and focus; verify no divider appears before the date, dates through seven calendar days are relative, and older dates show month and day without a year. Exercise the window-blur handler, drop-target styling, project action hover and keyboard Tab/Shift+Tab focus. Fold and reopen its group. Select pinned and standalone conversations. Open Settings and return. Enable reduced motion and inspect both row transition durations. In a separate twelve-session project, load the remaining two sessions, fold and reopen the group, and verify only the initial ten sessions remain visible.
  • Expected: Project and conversation hover backgrounds, radii and transitions match. The title button stays transparent. Only a conversation uses selected fill, which wins over hover; workspace identity remains a separate dot with no persistent header fill. Folding never promotes the project to selected. Pinned and standalone rows use the same selected surface. Keyboard focus retains an outline, action buttons retain local feedback, drop-target paint wins over hover, and blur releases hover without clearing selection. Settings replaces sidebar navigation, and returning restores conversation and workspace context without a second selected row. Project session rows show dates at rest in the same trailing slot used for session actions, with no divider. Hover and focus replace the date with the action in that slot. Dates through seven calendar days are relative and older dates show month and day without a year. Loading more sessions reveals all twelve; folding and reopening restores the initial ten and the two-session remainder control. Rendered component tests additionally cover no selected session, a pending destination and non-chat page state.
  • Specs linked: 04-ux/01-ui-ia.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md §9.1c
  • Acceptance: C, D, Quality
  • Milestone: Post-M6 desktop shell maintenance
  • Status: Automated via pnpm test:e2e:layout and scripts/e2e/sidebar-row-states.mjs: real CDP pointer/keyboard input and computed-style assertions, plus the load-more / fold / reopen session-history flow. Window blur/focus events and the drop-target class are injected for those styling checks; this is not a native focus/drag test. Unit coverage: sidebar-navigation.test.mjs, sidebar-pinned-rendering.test.mjs, and sidebar-project-sessions.test.mjs.

E2E-LAYOUT-sidebar-settings ​

  • Preconditions: Built desktop, isolated host/profile, visible chat sidebar.
  • Steps:
    1. In dark/light palettes and darwin/win32/linux CSS branches, compare the home sidebar and Settings rail color, image layers, size and position. Inspect transparent ancestors and opaque settings content/titlebar.
    2. Before entering Settings, retain the current .chat-surface DOM node. While Settings is open, verify the chat shell is hidden, inert, and still contains that connected node. Return through Back to app and verify the same node is revealed while tracing sidebar insertion, width and animationstart events. Repeat quick round trips, a previously collapsed sidebar, and settings navigation interrupting an entrance.
    3. Explicitly reopen a collapsed sidebar, then repeat settings return with reduced motion. Repeat material comparisons with legacy and canonical theme color overrides and a sidebar background image.
  • Expected: Both navigation surfaces share one material. Settings navigation and shell have no entrance animation; only a nested content-enter wrapper inside its opaque pane animates, and that motion is opacity-only so it cannot trap position: fixed overlays. Settings dialogs cover the full window, including the rail. On macOS all ancestors behind the rail are transparent, while right content and titlebar stay opaque. Returning to an expanded sidebar starts and stays at 275px without sidebar-in events; a collapsed sidebar stays absent. A real reopen still produces sidebar-in and a width ramp. Legacy theme color input remains supported for both rails, and a canonical override wins. Portaled layers owned by the hidden chat surface stay hidden while Settings is open; the shared app-level search, toast, and extension-prompt layers remain available.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/07-ui-design-system.md, 04-ux/08-component-spec.md §1.4 and §1.7
  • Acceptance: A, C, Quality
  • Milestone: Post-M6 desktop shell maintenance
  • Status: Automated via pnpm test:e2e:layout and scripts/e2e/sidebar-settings.mjs, with trusted CDP pointer/keyboard input, mutation-time and subsequent geometry samples, animation events and computed styles. CDP focus emulation keeps the isolated page painting while its native window is occluded; otherwise Chromium can freeze CSS motion and hover input. Platform branches and palettes are renderer emulation, not native Windows/Linux or OS material/theme validation. Optional PI_DESKTOP_LAYOUT_ARTIFACT_DIR captures renderer screenshots. State tests in sidebar-settings-return.test.mjs cover initial presentation, both interrupted phases, hidden-state changes and reversals. The CDP layout journey verifies that Settings preserves the same chat surface node across round trips. settings-dialog-overlay.test.mjs covers the full-window overlay contract. pnpm test:e2e:theme-surfaces verifies the opaque fallback and legacy theme override in real Chromium.

E2E-RUNTIME-renderer-crash-recovery ​

  • Preconditions: Built desktop with an isolated profile, the main window ready on chat, and CDP enabled by the E2E harness.
  • Steps: Set a renderer-only sentinel, trigger Page.crash, then reconnect to the existing page target if the renderer's CDP connection closes. Wait for the chat surface to return and inspect the isolated diagnostics log.
  • Expected: The renderer document is recreated in the same app window, the chat surface becomes usable, and renderer.process.gone records the exit reason, exit code, and reload decision. The app does not open a second window.
  • Specs linked: 03-runtime/07-process-model.md, 03-runtime/09-logging-and-observability.md
  • Acceptance: A, H, Quality
  • Milestone: Post-M6 desktop shell maintenance
  • Status: Automated by pnpm test:e2e:layout with CDP Page.crash; the deterministic renderer-recovery.test.mjs covers clean exit, accepted close, app shutdown, stale windows, destroyed contents, and future Electron reasons.

E2E-AGENT-alt-enter-steers-active-turn: Enter follows up and Alt+Enter steers the active turn ​

  • Preconditions: A session with a configured model and a controllable streaming response/tool; an image-capable model for the attachment case.
  • Steps:
    1. Start a prompt, then type a follow-up and press Enter. Confirm a FIFO row.
    2. During the same turn, type a correction and press Alt+Enter. Repeat with an image chip and with two corrections before the current request ends.
    3. Finish the current response/tool batch and inspect the next model input, transcript and durable turn id. Let the turn finish and observe follow-up.
    4. Repeat with Enter-to-send off, an open autocomplete menu, Shift+Enter, Alt+Shift+Enter and a Chinese IME candidate confirmation. Inspect the Send tooltip on macOS (⌥+Enter) and Windows/Linux (Alt+Enter).
    5. Race steering against turn completion, Stop, and a pending plan approval; switch sessions while a rejected request is pending.
    6. Change the next-turn model while running, then steer. Verify the active model and permission configuration remain unchanged.
    7. Steer while the parent waits for background delegates; leave them running and verify the parent receives the correction before their reports finish.
    8. Reload after completion and simulate a crash after a streaming reply was reserved by steering. Inspect row order, recovered text and owning turn.
    9. Reload the renderer after steering is accepted but before its reply starts, then press Stop and inspect the persisted transcript.
  • Expected: Enter queues an ordinary follow-up. Alt+Enter creates a user row in the current turn with no queue row or new public agent_start. Started tools finish, then the next request contains the corrections/images. The ordinary FIFO starts only after durable turn finalization. IME and newline actions never submit; idle Alt+Enter sends normally. A stale/closed target keeps the draft in its own session and never fails the active turn. Accepted input is not replayed independently after Stop. Completed replies and accepted steering input remain in history after renderer reload and Stop. Terminal assistant snapshots replace provisional snapshots in place; crash recovery preserves the latest checkpoint and adjacent steering rows without duplicates.
  • Specs linked: 03-runtime/01-ipc-protocol.md (§5.1a), 03-runtime/02-agent-runtime.md (§4.0), 03-runtime/04-data-storage.md, 04-ux/09-interaction-patterns.md (§3.5), ADR active-turn-steering
  • Acceptance: C (conversation & stream), E (tools & permissions), Quality
  • Milestone: M5
  • Status: Draft. Existing regression suites cover surrounding behavior; the rendered steering journey has not been run (do not run E2E locally unless explicitly requested).

E2E-SESSION-content-search-and-message-navigation ​

  • Scope: Desktop global search, host search projections, and original conversation navigation (issue #270, ADR session-content-search).
  • Preconditions: At least 65 visible sessions with a shared body keyword; one session has 125 matching user/assistant messages. Include a body-only keyword, a metadata-only match, an archived session, a soft-deleted session, one/two-character CJK terms, literal %, _, quotes, and a path. Include a long session with a match beyond the latest 100 messages and a message whose matching text follows 100,000 characters. Include an actively streaming conversation and a fixture with repeated physical message lines. Include a nested assistant answer whose Task is outside its 60-line page, a later terminal copy of that Task, collapsed activity, hidden Markdown link destinations, emphasis delimiters, and file-chip directories. Add short matching sentences surrounded by unrelated Chinese/English sentences, multiple short lines before a match, a quoted sentence, a file path containing periods, and a matching sentence longer than 180 characters.
  • Steps: Search body-only user and assistant terms, then rename the owning session and repeat. Check aggregated counts and sender/time/snippet labels. Load every result page. Open a session heading and each of its two snippets; verify that each snippet closes search, opens the same original conversation, and scrolls to its own matching text. The heading selects its first snippet. Check normal Markdown, message actions, and composer. Repeat with two assistant fragments in one turn, an old target outside the latest page, and a match after 100,000 characters. Verify a visible highlight and that layout settling does not pull the target away. Read upward and load later messages without gaps. Use the latest-message control to resume the live transcript. Scroll within the active conversation, reopen search, and select its own result; locate the target inside the same pane. Edit, retry, branch, and delete an old message. Reopen search and check the retained query. Repeat with CJK and symbols. Verify that a contiguous Chinese phrase matches, while inserting a space between its words only matches text with that space. Change queries rapidly while delayed first-page and later-page requests resolve out of order. Close/reopen during loading. Retry after a transient search error. Open the nested answer and verify that the owning Task is revealed and its existing dock scrolls to the answer. Search the hidden URL, delimiters, and chip directory after a long prefix; verify the corresponding visible element is highlighted. Interrupt layout correction with a wheel/key gesture. Page ordinary history while output streams, then interrupt a pending page with search, another result, a new turn, and return to latest. Change a revision without changing its message ID. Edit/retry a visible but display-capped message and verify that the canonical full text becomes the action input. Search a running conversation and return to its live stream, then switch conversations. Use arrows, Enter, Escape, Tab, and CJK IME confirmation, and exercise page/settings/plugin-command results. Search a word in the middle sentence and verify that the result shows that sentence with a background highlight on the literal match. Repeat after multiple line breaks and at narrow window widths in light and dark themes. Long-sentence truncation must keep the whole matching query visible; short previews must omit unrelated adjacent sentences.
  • Expected: Every matching visible session is reachable; counts cover all 125 messages, with no duplicate session rows. Archived visibility follows the existing explicit-search rule and deleted sessions never appear. Each snippet opens its owning conversation directly without an intermediate context reader, plain-text replacement, or Back to conversation action. Each selected snippet lands at its exact message and matching rendered text, including old history and individual assistant fragments. Reading-window loads do not overwrite live output. New turns return to the live transcript. Actions on historical messages work normally and do not leave obsolete rows. Nested answers land in the original Task dock; source-only matches land on their rendered owner. Ordinary paging retains current streamed content. Interrupted reads cannot reopen a dock or restore stale/loading views, and same-ID edits replace obsolete displayed content. Message actions never use clipped text as input. Rapidly selecting another result rejects stale target/page completions. Deleting a result before selection reports failure instead of landing at the tail. Later query ownership wins over stale results/errors. IME Enter does not execute an action. Search transport failures are explicit. Existing commands, pages, settings, and keyboard navigation still work.
  • Status: Draft; Rust and renderer unit regressions cover the data/query boundaries. Full rendered E2E requires an explicitly authorized run.

MCP market scenarios (pnpm test:e2e:mcp-market, headless protocol-level) ​

IDScenarioVerification
E2E-MCP-MARKET-NET-BOUNDARYThe URL guard rejects credentials, cloud metadata, unspecified, multicast and reserved forms (trailing dot included) even when the user typed them, and rejects loopback, private, special-use IPv4, v4-mapped, ULA, site-local and link-local forms for third-party hops — a redirect target or a catalog body; the same forms typed by the user into a source field are accepted, with plain http behind networkPolicy.allowInsecureUserEndpoints; direct/unknown routes pin the checked address by default, while a fully proxied route uses the session transport and an explicit allowFakeIp opt-in can cover transparent-router fake-IP sources without allowing real private answersdeterministic guard assertions; source-contract coverage for DNS pin, proxy route selection, explicit fake-IP scope and bounded responses
E2E-MCP-MARKET-SEMANTICSRegistry records map to install templates preserving package versions, named/positional runtime/package arguments and required/optional env variables; a remote header variable is recognized in both the registry's {name} and the catalog's ${NAME} spelling, prompts for declared editable values, preserves unbound brace literals, and resolves header-local defaults, fixed values, and optional flags without merging same-named inputs across headers (ADR registry-header-variable-spelling)deterministic mapping assertions
E2E-MCP-MARKET-INSTALLBuiltin catalog entry resolves through resolveCatalogEntry and installs via the host mcp.upsert RPC; record lands in ~/.agents/servers/real host binary, isolated temp HOME
E2E-MCP-MARKET-HEADER-SCOPERegistry header-local {token} resolves only in its header; same-named URL path/query tokens remain literal through mapping, resolution, host upsert/list and persistence. URL templates retain only legacy ${NAME} substitution. When headerBindings exists (even empty or partial), unbound tokens in every header stay literal and never consume another header's input or defaultshared regressions plus real host binary with isolated temporary storage; remote entry disabled, no network call
E2E-MCP-MARKET-partial-header-bindings-stay-literalResolve a catalog with only Authorization bound and another header using the same {token} / ${token}; an undeclared ${UNBOUND} in a third header also remains literal through host upsert/list and disk persistencereal host binary, disabled remote entry, synthetic input and isolated temporary storage; no network call
E2E-MARKET-CATALOG-RETRYIn Skill Market and MCP Market, a failed query and then a partial source failure expose Retry; clicking it announces loading, refreshes the same query, and clears the failure when the source recoversreal React components in Electron with deterministic mocked market API responses; no live network

E2E-SKILL-MARKET-NET-BOUNDARY: A user-supplied source reaches the LAN, third-party content does not ​

  • Preconditions: Shared public-network helpers and the main-process public-HTTPS client with injectable fetch/DNS/route.
  • Steps: 1) Classify trailing-dot localhost, IPv4 loopback, IPv4-mapped IPv6, ULA, link-local, RFC1918, and http:// URLs twice: once as a source URL the user typed, once as a document URL that arrived inside a catalog. 2) Resolve a public hostname to a private A record. 3) Follow a 302 whose Location is https://127.0.0.1/. 4) Report a proxied route and a TUN fake-IP answer (198.18.0.1), the same answer on a DIRECT route, on an unreadable route, and on a route list that offers DIRECT. 5) Let a first hop be proxied and its redirect target direct. 6) On a direct route, return both a public address and a ULA address, then return a TUN benchmark fake-IP with a ULA address under the existing fake-IP opt-in. 7) Return a ULA address without any acceptable companion address.
  • Expected: A source URL the user typed may be a loopback or LAN catalog — https always, http only under networkPolicy.allowInsecureUserEndpoints — while the same address as a document URL inside a catalog, or as a redirect target, is rejected. Cloud metadata, unspecified, multicast, and reserved addresses are rejected on every input. A user-supplied source resolving to a private address is fetched; a third-party hop resolving only to a private address throws a policy error without fetching that target. A local resolver with no answer is retried and reported as NETWORK_RESOLVE_FAILED (kind unresolved), not as an address-check refusal. A direct request with an acceptable address beside a rejected ULA pins the acceptable address; a ULA-only answer stays refused. An address in a proxy's fake-IP range (198.18.0.0/15, Clash's default) is accepted on a proxied route. On a direct route it is refused unless the existing fake-IP opt-in allows Main to pin that benchmark address; unreadable routes remain strict. A refusal for a benchmark address is reported as kindfake-ip, addressKind benchmark, and reason non-public-address; refusals for real private targets remain kind policy, addressKindprivate. Every refusal carries the host, reason, address class, and route so the install sheet and market list can distinguish policy blocks from network failures. Every redirect hop is judged on its own route (ADR 0272, ADR 0321).
  • Specs linked: 05-security/01-security.md, ADR 0243, ADR 0272, ADR 0321, 03-runtime/01-ipc-protocol.md §12b
  • Acceptance: Security, Quality
  • Milestone: M6+
  • Status: Automated (pnpm test:e2e:skill-market, apps/desktop/test/public-https-fetch.test.mjs, apps/desktop/test/public-https-fetch-route.test.mjs, apps/desktop/test/skill-market-scan.test.mjs, apps/desktop/test/skill-market-failure.test.mjs, apps/desktop/test/skill-market-policy-refusal.test.mjs, packages/shared/src/public-network.test.ts)

E2E-MARKET-CATALOG-RETRY: Retry a failed or partially available market query ​

  • Preconditions: Rendered Skill Market and MCP Market panels with the market APIs stubbed at the IPC boundary. Each API returns a total failure, then a result with one failed source, then a complete result. A second run loads a result first, fails afterwards, and recovers on retry; a third run changes the query after a success so the failure carries a snapshot taken for another query; a fourth run fails with nothing loaded.
  • Steps: Open each market; click Retry after the total failure; confirm the loading announcement and recovered entry; click Retry again after the partial failure. Then load a list, fail the next query, retry, and change the query before failing once more.
  • Expected: Both markets repeat the current search, show loading while the request runs, retain a Retry action for partial failures, and clear the failed source after recovery. After a successful load, a later failure keeps that list on screen, says when it was taken, retains each remote source badge, and still offers Retry; the retained list disappears once a retry succeeds, and it never stands in for a different query or for a failure with nothing loaded. The test makes no live network request and does not change the main-process network policy.
  • Specs linked: 04-ux/06-settings-ia.md, ADR 0177, ADR 0272
  • Acceptance: Quality
  • Milestone: M6+
  • Status: Automated (pnpm test:e2e:market-retry, apps/desktop/test/skill-market-panel.test.mjs, apps/desktop/test/mcp-market-panel.test.mjs)

E2E-SKILL-MARKET-EXPANSION: Adjacent markdown resources inline before install ​

  • Preconditions: A jsDelivr skill document whose directory lists FORMS.md and REFERENCE.md (mocked listing in unit tests; expansion helper in E2E).
  • Steps: Split SKILL.md, expand listed sibling markdown files as fenced appendices, and confirm a document over 128 KiB is flagged too large.
  • Expected: The preview/install body contains the skill text plus # Attached resource: appendices. Non-markdown siblings are omitted. A body that would exceed host MAX_SKILL_BYTES is not written.
  • Specs linked: 04-ux/06-settings-ia.md, ADR 0243
  • Acceptance: Quality
  • Milestone: M6+
  • Status: Automated (pnpm test:e2e:skill-market, apps/desktop/test/skill-market-scan.test.mjs)

E2E-SKILL-MARKET-INSTALL: Market install writes a user skill through skills.create ​

  • Preconditions: Host binary; isolated HOME. A builtin catalog entry with an assembled markdown body.
  • Steps: Handshake; skills.create with the assembled name/description/body; read ~/.agents/skills/pdf.md; skills.list.
  • Expected: The file has rendered frontmatter and the instruction body. The skill appears in skills.list. No path besides skills.create is used.
  • Specs linked: 03-runtime/01-ipc-protocol.md §12b, ADR 0243
  • Acceptance: Quality
  • Milestone: M6+
  • Status: Automated (pnpm test:e2e:skill-market)

E2E-SKILL-MARKET-ID-ALIGN: Scanned skill ids match host valid_capability_id ​

  • Preconditions: Shared sanitizeSkillCatalogId.
  • Steps: Sanitize Frontend_Design, 1-pdf, and an empty remainder.
  • Expected: Host-legal slugs (frontend-design, 1-pdf, skill-7) so installedIds matches the created record.
  • Specs linked: 03-runtime/01-ipc-protocol.md §12b
  • Acceptance: Quality
  • Milestone: M6+
  • Status: Automated (pnpm test:e2e:skill-market)

E2E-TRAY-bounded-session-navigation ​

  • Scope: Native tray groups, hidden/recreated window activation, and unread semantics (issue #293, ADR tray-session-shortcuts).
  • Preconditions: At least four running, four unread, and four pinned sessions across two projects; include overlaps, read-latest/older-unread notifications, archived/deleted sessions, an archived project, empty titles, multiline titles, long CJK/emoji titles, and literal ampersands. Also cover a single populated group of seven and one of more than nine, with the other two groups empty, to exercise reclaimed share. Use an isolated profile. Repeat native activation on macOS and Windows/Linux.
  • Steps: Hide the main window and open the tray menu. Inspect group order, counts, duplicates, titles, and unchanged unread records. Choose the third row from another project, then View more from Settings with a collapsed sidebar and a retained search query. Finish/abort tasks while hidden; read a result, pin/unpin, rename, archive/restore, and delete a session. Close the macOS window while a task runs and let it finish, then activate its tray row while the new renderer bootstraps a pending plan. Delay a Host read while a newer preference update, delete, or Host restart arrives. Retry a transient read failure by hovering/right-clicking the tray on Windows/Linux; macOS retries from the next session or inbox event instead of mouse-enter. Repeat after clearing all group memberships and changing shipped locales. Choose Quit then Cancel, then Quit and confirm.
  • Expected: Running → Unread → Pinned; at most nine rows in total. Each non-empty group keeps up to three rows and overflowing groups reclaim the share smaller groups leave unused, in priority order: seven running sessions with no unread or pinned show all seven, and a single group holding more than nine shows nine behind View more. Deduplicate before limits, so hidden Running overflow cannot appear as Unread/Pinned. Empty groups and stale shortcuts disappear. Unread uses the latest terminal result per session, newest first. Titles remain one line within the 32-column cap, half as many characters for CJK/emoji, including literal ampersands. Opening the macOS menu leaves the window hidden and records unread. A row opens exactly that session/project, acknowledges it normally, and wins over startup navigation. View more returns from Settings, closes search, and expands session navigation. Hidden/closed windows receive fresh groups; stale reads, archived/deleted targets, and a failed backend cannot restore stale shortcuts. Open, localization, quit cancellation, and shutdown work.
  • Specs linked: 03-runtime/01-ipc-protocol.md §13b, 03-runtime/07-process-model.md, 04-ux/08-component-spec.md, 04-ux/09-interaction-patterns.md, ADR tray-session-shortcuts.
  • Acceptance: A (app/window lifecycle), C (conversation navigation), F (unread persistence), Quality (bounded localized menu).
  • Milestone: Post-M6 desktop shell maintenance.
  • Status: Draft; native E2E requires an explicitly authorized run.

E2E-PLUGIN-turn-ended-once-per-host-turn: A plugin observes exactly one turn-end event per host turn ​

  • Preconditions: A plugin with a tool and an event listener for session:turnEnded is loaded and enabled; its panel records each received payload and the turnId its tool receives through the tool context.
  • Steps:
    1. Submit a prompt whose reply issues three tool calls in one turn.
    2. Record the number of session:turnEnded payloads the plugin receives and compare the turnId with the one the plugin's tool saw.
    3. Submit another prompt, then stop it with Cmd/Ctrl + ..
    4. Submit a third prompt that fails, so the turn ends with an error.
    5. Inspect the Plugins settings page for a new permission review.
  • Expected: Step 2 receives exactly one session:turnEnded whose reason is completed, and its turnId equals the tool context's turnId. Step 3 receives exactly one event with reason aborted — never a second completed after the abort. Step 4 receives exactly one event with reasonerror. A turn that never started emits nothing, and no plugin receives two events for one turn even when the terminal event arrives more than once. Step 5 shows no new permission review, and subscribing to an unknown event name does not surface an error.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0252
  • Acceptance: Quality (protocol and plugin contract)
  • Milestone: M6+
  • Status: Module-covered (apps/desktop/test/session-turn-ended.test.mjs, apps/desktop/test/queued-turn-finalization.test.mjs); desktop journey Draft (run only in a capable environment when this surface changes)

E2E-SESSION-native-pi-continue-appends-original-jsonl ​

  • Preconditions: A synthetic Pi v3 session contains branches, compaction metadata (including retainedTail), model/thinking changes, custom and context-bearing custom-message entries, and a valid project cwd. A faux local model/auth binding is configured in the fixture Pi agent directory; Desktop provider secrets are absent. A fixture extension restores custom state in session_start, contributes a skill through resources_discover, and uses the 0.87 turn_end boundary to propose a context_edit for the settled assistant entry.
  • Steps: Start Nib with fixture-only agent/session directories; refresh sessions; open the native row beside a Desktop row; submit one text prompt; stop or let the faux response settle; send the same text again with a retryable response before success; reselect/refresh repeatedly; reopen through a fresh Pi SessionManager; invoke native compact and queue push/list.
  • Expected: The original fixture JSONL receives native SDK entries whose parent starts at the previous current leaf; all prior bytes/unknown entries remain unchanged; the new leaf is visible after refresh; no Desktop SQLite session or Desktop transcript copy is created; the Desktop-owned row still follows its existing runtime and storage path. Native requests expose zero built-in or extension model tools; startup and discovered skill are effective. Exactly two durable SDK-ID user rows remain, including after abort; smart-stop never rewrites native history. Retries/compaction remain running and stoppable until one terminal completion; the owned idle lease accepts the second send. The context_edit is appended under the same lease, affects the next model request, and leaves the original assistant row visible in native detail. Compact and queue push/list reject before host access. Opaque host-turn queue remove/prioritize remain Desktop-only because native paths create no entries.
  • Specs: runtime §12; storage §12; security §12; ADR 0254.
  • Status: Documented; run after integration into main.

E2E-SESSION-native-pi-external-change-fails-closed ​

  • Preconditions: A writable synthetic native v3 fixture is listed.
  • Steps: Acquire Desktop continuation ownership, then simulate a foreign append/replacement before the extension's appendContextEdit; also attempt a second Desktop lease and stale leases whose owners are live, remote, malformed, or uncertain. Refresh list/detail with a dead same-host owner and unchanged or complete append-only extended bytes, then prompt through normal UI capabilities.
  • Expected: The second writer is refused; external divergence tears down continuation with a visible read-only/error reason; no requested entry is appended and no bytes are truncated or rewritten; a lease releases on normal dispose and is reclaimed after crash only with a dead same-host owner and an unchanged target or same-file prefix-preserving complete parent-chain extension. The context-edit append is refused by the same byte and parent check as other SDK writes. Uncooperative Pi clients can still race the OS append; no shared-lock guarantee is claimed.
  • Specs: runtime §12; storage §12; security §12; ADR 0254.
  • Status: Documented; run after integration into main.

E2E-SESSION-native-pi-incompatible-session-is-read-only ​

  • Preconditions: Fixtures cover missing cwd, missing trailing newline, v1/v2 header, unavailable saved provider/auth, and untrusted project resources.
  • Steps: Refresh and open each fixture.
  • Expected: Each remains browseable with a specific reason, the composer is disabled, no migration/repair/fallback occurs, and source bytes and mtimes are unchanged.
  • Specs: IPC native routing; runtime §12; storage §12; security §12.
  • Status: Documented; run after integration into main.

E2E-SESSION-native-pi-duplicate-id-collapses ​

  • Preconditions: An isolated Pi agent directory contains a valid native v3 session and a copied JSONL file under a nested directory with the same header.id.
  • Steps: Start Nib with the fixture-only agent directory, refresh the session list, open the listed native session, and search its transcript.
  • Expected: The list contains one row for that native id. Detail and search resolve through the selected file; duplicate files remain byte-identical and are not rewritten or deleted.
  • Specs: runtime §12; storage §12; ADR 0254.
  • Status: Source-regression covered; full Desktop journey Draft.

E2E-SESSION-native-pi-project-session-dir ​

  • Preconditions: A local project has .pi/settings.json with "sessionDir": ".pi/sessions" and a valid native v3 JSONL in that directory. The default Pi agent session root also contains a valid session. A nested JSONL and a symlink that leaves .pi/sessions are present. A second fixture sets an absolute global sessionDir and no project path. A third sets a relative global sessionDir.
  • Steps: Refresh the session list, open the project-local session, and fork it. Repeat list and open for the absolute global directory. Refresh the relative global fixture once with no known project and once after that project is known, including a remote project id in the known set.
  • Expected: The project-local session and the default-root session both appear. The nested file and the escaping symlink do not. Opening the project-local session returns its transcript. Fork publishes one new JSONL beside the parent and does not rewrite the parent. The absolute global directory is listed without a project path. The relative global directory is listed only for the known local project. Remote project ids are ignored. Untrusted project resources stay browseable and read-only.
  • Specs: runtime §12; storage §12; security §12; ADR 0254.
  • Status: Source-regression covered by packages/agent-runtime/src/native-pi-session.test.ts (the project fixture asserts readOnlyReason: project-untrusted and canPrompt: false) and apps/desktop/test/native-pi-sessions.test.mjs; full Desktop journey Draft.

E2E-SUBAGENT-ordered-model-fallback-preserves-work ​

  • Preconditions: A configured primary and at least three alternatives use deterministic local transports; one definition declares the ordered alternatives. A second definition has no alternatives, and none are opted in for Task overrides.
  • Steps: Add two alternatives in Settings, reorder them, save and reopen; change another field without changing the list. Run the child, complete one tool call, then fail the primary request. Fail the first alternative and complete the second. Run a matrix with zero, one, two, and three unavailable models before a successful model, leaving an unused model after success. Repeat with all four models unavailable, mixed 401/403/404 failures, exhausted transient/429 retries on two consecutive models, unavailable/duplicate pins, an explicit authorized Task primary, and Stop during recovery. Remove all alternatives, save, and reopen.
  • Expected: List order and clear/preserve semantics round-trip. Provider retry budgets precede fallback; each distinct configured binding is used once. The original task and completed tool results reach the next model; completed tools are not replayed. Adapter/auth/headers and thinking match each selected model. Failure diagnostics and total usage survive settlement; effective model/thinking survive reload. Stop cancels the chain; host/tool errors do not switch models. Exhaustion fails explicitly. Alternatives never authorize a Task override for the second definition. No alternatives retains the existing single-model behavior.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f, 03-runtime/13-model-catalog-and-selection.md §Subagent editor, ADR subagent-model-fallback.
  • Acceptance criterion: C — Conversation & stream; Quality (compatibility and permission isolation).
  • Milestone: M6+.
  • Status: Automated registry and runtime coverage through shared/runtime regression tests and test:e2e:subagents / test:e2e:subagent-models. The sidecar suite checks the zero-to-three failure matrix and four-model exhaustion against actual HTTP request order, live/settled Task metadata, ordered failure diagnostics, and the successful child report. Completed-tool preservation and independent retry budgets use real-transport runtime tests. The configuration-editor journey passed under WSL; task-transcript reload acceptance remains outstanding. Required post-integration suites: test:e2e, test:e2e:subagents, test:e2e:subagent-models.

E2E-PLUGIN-provider-catalog-add-service: Select a plugin provider from its custom Add Service category ​

  • Preconditions: A loaded plugin has provider.register and declares two API-key providers with a baseUrl, a localized category, and a short localized description; one provider has static models and the other has an empty model list. Both Host-owned rows have no saved key. Include another row whose key is already configured and a provider contribution from a plugin without provider.register.
  • Steps: 1) Open Settings → Models → Add provider. 2) Find the declared category and hover or keyboard-focus a provider tile. 3) Select the provider, enter a fixture key, and save. 4) Reopen Add provider and search the remaining entry by category, provider name, introduction, endpoint, and model ID. 5) Change the app language between English and Simplified Chinese and reopen the chooser. 6) Load a manifest with malformed localized category/description fields, an empty category/description, and values over their length limits.
  • Expected: Step 1 groups unconfigured API-key contributions under the category for the active locale. Tiles show only provider names; hover/focus shows the one-sentence introduction, and the tile's accessible description remains available to assistive technology. The chooser has no provider-count cap and omits configured providers, OAuth / no-auth rows, and contributions whose plugin lacks the grant. Step 2 opens the existing Host-owned provider key form; the endpoint and plugin remain visible there for a destination check. Step 3 saves through the Host provider secret API; only an empty-model declaration triggers Host model discovery after save. Discovered models are cached for that plugin-owned row. The key is never returned to plugin code or included in catalog metadata. The provider row and key remain in the service list, and that entry disappears from Add Service while the other unconfigured entry remains. Step 4 finds the remaining tile by the listed search fields and never offers the saved row. Step 5 switches the declared localized text correctly. Step 6 rejects malformed metadata without partially loading the plugin. Existing plugin row ids, ownership, reconciliation, and key retention remain unchanged.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md §5.4, 07-plugins/03-plugin-api.md, 07-plugins/04-plugin-security.md, 07-plugins/13-plugin-permissions-matrix.md, 04-ux/06-settings-ia.md, 04-ux/08-component-spec.md, ADR 0322; D650.
  • Acceptance: B (model config), E (tools & permissions), F (persistence), G (plugins), Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: The representative Add Service → category → key save path runs in apps/desktop/test/plugin-provider-catalog-user-path.test.mjs under an isolated Electron renderer with a fake Host IPC boundary. Catalog filtering, locale resolution, description tooltip, category validation, filtering, and search have focused tests. The user-path test also confirms that model discovery follows key save and saving one entry hides only that configured row while another entry remains available.

E2E-PLUGIN-declared-provider-appears-in-the-native-provider-list: Plugin providers use Host-owned rows, permissions, and OAuth sign-in ​

  • Preconditions: An installed local plugin declares one API-key provider with provider.register and one OAuth provider with both provider.register and provider.oauth. Both use a fixture baseUrl and one model; the API-key model has thinkingLevels: ["off", "low", "high"] and defaultThinkingLevel: "high". Its onProviderOAuth callback uses only a local OAuth fixture and host-rendered pi.providers.oauth interactions.
  • Steps: 1) Enable the plugin and open Settings → Providers. 2) Inspect the model binding and Composer thinking selector, then select the row as the session model and run a turn. 3) Change the session thinking level and run a second turn. 4) Try to edit the provider, then delete it, through the user path. 5) Open Vendor accounts, sign in to the plugin OAuth provider using its host-rendered prompt/device-code step, then select that provider and run a turn. 6) Expire the fixture access token and run another turn to exercise the plugin refresh callback. 7) Cancel a second login and confirm the callback's abort signal fires; sign out and then disable/re-enable the plugin. 8) Uninstall the plugin; reinstall and enable it, then remove the declaration from its manifest and reload. 9) Load manifests that omit provider.register or provider.oauth, have OAuth metadata without authKind: "oauth", or lack the onProviderOAuth module export. 10) Load manifests whose model uses a non-array thinkingLevels, a non-string entry, or a non-string defaultThinkingLevel.
  • Expected: Step 1 shows one row in the native provider list with ownerPluginId set to the plugin and the row id plugin:<pluginId>:<declaredId>. Its model binding contains the normalized levels off, low, and high in declaration order, with high as the default. Step 2 shows those declared levels in the thinking selector and starts the session at high; the turn uses that choice. Step 3 persists and uses the changed session level without changing the provider declaration. Step 4 refuses both actions with an error whose message begins PROVIDER_OWNED_BY_PLUGIN and leaves the row unchanged. Step 5 completes login through the native OAuth dialog and stores one encrypted credential under secret:provider:<plugin-row-id>:oauth; the renderer sees only status and the non-secret account label. Step 6 invokes the refresh callback with the plugin's own credential, but only the new access token reaches the Agent Runtime; refresh tokens stay in Electron main and the Host secret store. Step 7 stops the callback on cancellation, clears the OAuth secret on sign-out, keeps both manifest-owned rows across disable/enable, and restores sign-in status only for credentials that remain stored. Step 8 removes both rows and their credential refs when uninstalled or undeclared. Step 9 fails closed: missing permissions fail manifest validation, and a missing callback fails plugin load without exposing a sign-in action. Step 10 rejects malformed thinking-level fields with PLUGIN_INVALID and leaves enablement unchanged.
  • Specs linked: 07-plugins/02-plugin-manifest-schema.md §4, §5.4, §7; 07-plugins/03-plugin-api.md (provider OAuth); 07-plugins/04-plugin-security.md; 07-plugins/13-plugin-permissions-matrix.md; 03-runtime/04-data-storage.md §4.3, §7; 03-runtime/11-provider-model-system.md §6.2; 03-runtime/12-provider-config-schema.md §2, §9; 03-runtime/06-host-rpc-protocol.md; ADR 0259; D427
  • Acceptance: B (model config), E (tools & permissions), F (persistence), G (plugins), Security, Quality
  • Milestone: Post-MVP (R7 v1)
  • Status: Runs in the Linux CI integration candidate through pnpm test:e2e:trusted-extensions under Xvfb, using local synthetic OAuth credentials: the API-key and OAuth declared rows, permission projection, Settings sign-in picker, Host-rendered device-code and secret prompt, encrypted-credential handoff, refresh, model request auth, cancellation, and sign-out are exercised through the real Electron renderer and plugin process. Ownership refusal, disable/enable, cleanup, and malformed manifest cases remain covered by host-core checks. No live identity provider is used.

E2E-CHAT-disclosure-toggle-keeps-reading-position ​

  • Scope: Manual disclosure of a tool, thinking or activity title in a transcript or a delegate run dock while the scroller is pinned to the bottom (issue #324).
  • Preconditions: A session longer than one screen, sitting at the bottom with follow mode on, holding a tool row, a thinking row and an activity group whose expanded detail is taller than its header, plus one expanded delegate run that owns a nested scroller. Repeat with a finished turn (isRunning false) and while the turn streams.
  • Steps: Click the title of a tool row, a thinking row and an activity group while pinned at the bottom, and again after the turn has finished. Repeat with the viewport parked in the middle of the transcript, with a keyboard activation (Enter, then Space, on a focused title), and from the collapse rail. Expand a tool row inside an expanded delegate dock. Then scroll with the wheel, press ArrowUp with a title focused, click the jump-to-latest control, and send a new prompt.
  • Expected: The clicked title keeps its on-screen position while the detail animates open and closed, and the transcript never re-bottoms underneath it; follow mode is left and the newest-message control appears. The held position survives every frame of an animated activity group and also covers a simultaneous height change above the title. A nested dock holds its own position and the transcript behind it does not re-bottom either. Real scroll input, the jump control, a new turn and every navigation release the hold and follow the live tail again, while a reader who scrolled up keeps their place. Space still activates a focused title and arrow keys still scroll.
  • Specs linked: 04-ux/09-interaction-patterns.md §9.1; ADR transcript-reading-ownership; D287, D302, D430
  • Acceptance: C (conversation & stream), E (tools & permissions), Quality
  • Milestone: M6+
  • Status: Partially automated. pnpm test:e2e:transcript-disclosure mounts the real transcript scroll hook and a real tool row in a real 600 CSS px Electron viewport and clicks the title with a real DOM click, asserting the title's offset from the scroller top and the scroll offset for the transcript and for a nested follow scroller; without the fix the same fixture reports the title moving by the full height of the opened detail. The app stylesheet is not linked, so the heights come from inline filler and the components' own intrinsic size. disclosure-anchor.test.mjs covers the pure anchor and input math, and transcript-disclosure-reading.test.mjs covers the wiring. Keyboard, wheel and animated-activity-group paths remain additional validation.

E2E-PLUGIN-fs-root-follows-the-calling-session: A plugin tool's fs root follows its own session, not the visible workspace ​

  • Preconditions: Two projects, A and B, each holding a text file the other does not, and one enabled plugin that registers an agent tool whose execute calls pi.fs.readText and pi.fs.glob on a root-relative path and declares fs.read with the workspace root. Both projects have a live session with the plugin tool available, and the window shows project A.
  • Steps:
    1. Invoke the tool from session A on the file only project A holds, then from session B (project B) on the file only project B holds, without switching the window's visible workspace.
    2. Switch the visible workspace to project B and repeat both calls.
    3. With the visible workspace on project B, ask session A's tool for a path that exists only under project A, and session B's tool for the same path.
    4. Open the plugin's panel and call the same read over the panel bridge with project A visible, then with project B visible.
    5. Move one session to a temporary chat so no workspace is visible, invoke the tool from that session, then call the panel bridge in the same state.
  • Expected: Steps 1 and 2 answer from the invoking session's own project every time: session A reads and globs project A's file, session B project B's file, and neither answer changes when the visible workspace moves between the two projects. Step 3 returns project A's file for session A and NOT_FOUND for session B, so a session can never reach the other project's root. Step 4 is unchanged from before: each panel call resolves the visible workspace, so its answer follows the project on screen rather than either session. Step 5 keeps working per session -- the session's plugin tool resolves its own project -- and the panel call still fails with NOT_FOUND ("No workspace is open"). No gate is relaxed anywhere: a denied name, a .. or absolute path, a symlink out of the root, and a path outside the declared scope behave as they do for a single visible workspace.
  • Specs linked: 07-plugins/03-plugin-api.md §3, 07-plugins/13-plugin-permissions-matrix.md §6, ADR 0016, ADR 0249, ADR 0266, D093
  • Acceptance: G (plugins), Security, Quality
  • Milestone: M6+
  • Status: Partially automated (apps/desktop/test/plugin-fs-session-root.test.mjs): a tool call writes and reads under the project of the invoking session, a panel call and an untracked session fall back to the visible workspace, a userSelected mode keeps the picked directory, and a session root stands in when the window shows no project. The two-live-sessions desktop journey and the panel step are Draft (run only in a capable environment when this surface changes)

E2E-MODEL-catalog-window-correction-reaches-saved-bindings ​

  • Goal: a models.dev limit correction — the context window or the output cap — reaches an already saved binding without deleting and re-adding the model, while a number the user entered in Settings is never overwritten.
  • Steps:
    1. Configure a provider, select a model models.dev publishes a context window for, and save. Open the row's Advanced body and read the context-window field and its hint.
    2. Serve a corrected models.dev record for that model (a different published window), reopen Settings, and read the row, the context inspector, and the window a new session launches with.
    3. Type a window in the Advanced field — the preset ladder once and a hand-typed 128000 once — save, then serve another catalog correction and reopen Settings and the inspector.
    4. Save and reopen a provider row whose binding carries no contextWindowSource: once with the generic 128000 seed, once with any other stored value.
    5. Repeat step 4 with a row whose stored output cap is the generic 8192, and one whose cap the user typed, and read the cap in the settings row and in the request a new session launches with.
  • Expected: Step 1 shows the published number with the "follows the catalog" hint. Step 2 shows the corrected number everywhere the effective window is used (settings row, context inspector, session launch) with no delete and re-add. Step 3 keeps the entered number in the settings row, in the inspector, and in the launched request, including a hand-typed 128000 for a model whose published window is larger, and the hint is gone. Step 4 resolves deterministically: the 128000 seed follows the catalog, every other value stays as stored. Step 5 applies the same provenance rule to the output cap: an 8192 seed follows the published limit.output, and a cap the user entered is kept. Every step keeps the marker across the save/read round trip of the provider row, and a config written before the marker stays readable.
  • Specs linked: 03-runtime/13-model-catalog-and-selection.md §9.1, 03-runtime/12-provider-config-schema.md §2, 03-runtime/11-provider-model-system.md §2, 04-ux/06-settings-ia.md §2
  • Acceptance: B (model config), F (persistence), Quality
  • Milestone: M6+
  • Status: Partially automated: apps/desktop/test/model-binding-catalog-source.test.mjs drives the main-process resolver (catalog-sourced correction reaches the exposed row, a user value survives it, the generic seed still follows the catalog, an inherited value stays marked); packages/shared/src/model-catalog.test.ts covers the four source rules; crates/host-core/src/providers/catalog.rs covers the config round trip, the unmarked record, and the dropped unknown marker. The end-to-end settings journey

E2E-PLUGIN-official-channel-resolves-through-the-platform: An official-channel install uses platform mirrors when resolve answers promptly ​

  • Preconditions: A clean profile on the official channel, a plugin present in plugins.aiuo.net/catalog.json, and a request log for the platform and both mirror hosts (a local stub may stand in for each).
  • Steps: 1) Open Extensions → Marketplace and confirm the catalog loads. 2) Capture the catalog request and verify it used plugins.aiuo.net. 3) Install the plugin. 4) Capture the request the platform received. 5) Inspect which mirror served the package. 6) Install a second plugin, then install the same version of the first one again.
  • Expected: The marketplace UI has no source selector; exactly one POST /api/v1/download/resolve is sent per install or update, with a JSON body carrying deviceId, pluginId, and the version when one was picked; when resolve answers within three seconds, the package comes from the first entry in downloads that answers, and its bytes match the returned sha256 and sizeBytes before anything is extracted; a mirror that is unreachable or fails is abandoned and the next one is used without user interaction; if resolve exceeds its deadline, the install switches to the catalog URL and verifies its catalog digest; reinstalling the same version issues a fresh resolve call rather than reusing the earlier answer, because the response is never cached; the installed plugin passes the ordinary permission review and its record names the official channel as its provider.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §2, 07-plugins/15-plugin-center.md §10
  • Acceptance: G (remote marketplace source)
  • Milestone: M6+
  • Status: Draft

E2E-PLUGIN-mirror-digest-mismatch-falls-through-to-the-next-mirror: A mirror whose bytes fail the digest is rejected before extraction ​

  • Preconditions: An official-channel install whose downloads list has at least two entries, with the first mirror serving bytes that do not match the returned sha256 (a stale distribution, or a stub that serves the CNB-era bytes for pi.todo-0.6.5), plus a view of the install cache and the plugin directory.
  • Steps: 1) Start the install. 2) Watch the first mirror's download and the digest check. 3) Inspect the install cache and the plugin directory before the install finishes. 4) Let the install continue. 5) Repeat with a stub whose first mirror fails only the announced sizeBytes.
  • Expected: The mismatching bytes are discarded without being extracted or handed to the installer, nothing lands in the plugin directory, and the rejection is reported in the install progress instead of being swallowed; the next mirror's bytes are verified against the same digest and the install completes from there; the size-mismatch case behaves identically; when every returned mirror fails digest or size validation, the install ends as a reported failure rather than falling back to different bytes or leaving a partial plugin.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §2
  • Acceptance: G (remote marketplace source) + Security
  • Milestone: M6+
  • Status: Draft

E2E-PLUGIN-platform-unreachable-install-falls-back-to-the-catalog-url: A slow or unavailable platform resolve falls back quickly to the catalog URL ​

  • Preconditions: The official catalog has been refreshed. A local stub can delay POST /api/v1/download/resolve beyond three seconds or return 429, 503 NO_DOWNLOAD_SOURCE, and the publication refusals.
  • Steps: 1) Install a plugin while the resolve stub delays its response beyond three seconds. 2) Confirm the UI leaves Resolve and begins downloading from the catalog URL. 3) Repeat with a 429 and 503 NO_DOWNLOAD_SOURCE. 4) Return 403 NOT_PUBLISHED, 403 PLUGIN_ARCHIVED, and 404 for a selected version.
  • Expected: A slow resolve call is abandoned after three seconds; timeout, 429, and 503 NO_DOWNLOAD_SOURCE proceed through the catalog's artifactBaseUrl plus relative url, and bytes still pass the catalog digest check before installation; rate limiting is not retried or waited out; 403 NOT_PUBLISHED, 403 PLUGIN_ARCHIVED, and 404 remain explicit failures and never install the fallback package; archived plugins remain hidden from install and update selection; the selected channel does not change.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §2
  • Acceptance: G (remote marketplace source)
  • Milestone: M6+
  • Status: Draft

E2E-PLUGIN-device-identifier-is-stable-and-never-the-machine-code: The device identifier is stable across launches and is not the raw machine code ​

  • Preconditions: A host whose machine identifier is readable (Windows MachineGuid, the macOS platform UUID, or /etc/machine-id), a second environment where it is not readable, and a request-logging stub for POST /api/v1/download/resolve.
  • Steps: 1) Trigger two installs in one session and compare the recorded deviceId values. 2) Restart the app and trigger a third install. 3) Compare the value with the raw machine identifier of the host. 4) Search the settings, the Marketplace surface, and the installed-plugin detail view for the value. 5) Repeat steps 1 and 2 where no machine identifier is readable, then inspect the application data directory.
  • Expected: Every resolve request from one installation carries the same 64-character lowercase hex value, including after a restart and after an app reinstall while the machine identifier is unchanged; the value is neither the machine code nor a prefix of it, and it equals sha256("pi-desktop.device.v1:" + <machine identifier>); the identifier never appears in the UI and no setting can reveal or reset it; in the unreadable case the value is a different 64-hex string that is generated once and persisted under plugins/market/device.json, then repeated across restarts.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §2, ADR 0276
  • Acceptance: G (remote marketplace source) + Security
  • Milestone: M6+
  • Status: Draft

E2E-PLUGIN-install-progress-shows-phases-and-mirror: An official-channel install reports its phases and the mirror it is using, then completes ​

  • Preconditions: A clean profile on the official channel, a plugin whose resolve answer lists at least two entries, a first mirror that fails or is slow so a second attempt is observable, and a renderer subscribed to plugin.installProgress.

  • Steps: 1) Start a manual install from the marketplace detail sheet. 2) Record the reports that arrive while it runs. 3) Hover the dialog after the install succeeds. 4) Look at the installed plugin once the install ends. 5) Install again with a large package and count the reports over a window of at least one second.

  • Expected: The dialog shows the phases in order — resolve, download, verify, install, enable — with mirror n/N · name and a determinate bar from receivedBytes / totalBytes; it is mounted on the viewport overlay host and remains fully above the expanded sidebar, including its cancel/close controls; every report carries pluginId and version, only the report that names a mirror carries source, and attempt counts 1-based within attempts while a mirror switch increments attempt without changing attempts; byte reports arrive at most once per 200 ms, with one extra report per phase change and one terminal report; the install ends with no error and the plugin is installed and enabled after the ordinary permission review; the dialog closes about two seconds after success, that countdown pauses while it is hovered, and a background auto-update installs the same way without opening the dialog at all.

  • Specs linked: 07-plugins/07-plugin-marketplace.md §2, 07-plugins/15-plugin-center.md §10, ADR 0276 §7

  • Acceptance: G (remote marketplace source)

  • Milestone: M6+

  • Status: Draft

  • Local fallback regression: Set the process default data directory to a different temporary root, then create a plugin manager with its own root. All bundled fallback package URLs must stay beneath the manager's root; installing the bundled package must still report byte progress, validate its size and checksum, and preserve cancellation behavior.

E2E-PLUGIN-cancel-during-download-installs-nothing: Cancelling during the download stops the install, leaves nothing installed, and closes the dialog without an error ​

  • Preconditions: An official-channel install of a package large enough or a mirror slow enough that the download phase lasts, a way to answer market.cancelInstall, and a view of the plugin directory, the install cache, and the Installed list.
  • Steps: 1) Start the install and wait for the download phase. 2) Press the cancel action in the dialog. 3) Watch the dialog and capture the RPC answer. 4) Inspect the plugin directory, the install cache, and the Installed list after the install ends. 5) Send market.cancelInstall again for the same id, for an install that is not running, and once after the download has finished.
  • Expected: The cancel call answers { cancelled: true, id } for the running install, and the install fails with PLUGIN_CANCELLED (JSON-RPC code 1019); the dialog closes without reporting an error; nothing is installed — no plugin directory, no installed row, no enabled plugin — and no partial package survives in the cache; the second call for the same id, the call for an install that is not running, and a call after the download finished answer { cancelled: false, id } and change nothing, so a cancel can never interrupt the write of the plugin directory.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §2, ADR 0276 §7
  • Acceptance: G (remote marketplace source) + Security
  • Milestone: M6+
  • Status: Draft

E2E-PLUGIN-failed-install-lists-tried-mirrors: A failed install keeps the dialog open and lists the mirrors it tried with a copy action ​

  • Preconditions: An official-channel install whose every mirror fails — for example a digest mismatch on the first and a network error on the second — with a renderer subscribed to plugin.installProgress and a clipboard readback.
  • Steps: 1) Start the install. 2) Let every mirror fail. 3) Read the terminal report and the dialog. 4) Use the copy action, then the retry action once the mirrors serve valid bytes again.
  • Expected: The terminal report carries error and a tried[] entry per mirror in the order they were tried, each naming its source, url, and the error that mirror answered; the dialog stays open and shows the readable error plus that list; the copy action puts the tried mirrors on the clipboard; the retry action starts a new install of the same version and completes it when the mirrors answer, without reusing the failed attempt's partial state; nothing was installed by the failed attempt.
  • Specs linked: 07-plugins/07-plugin-marketplace.md §2, ADR 0276 §7
  • Acceptance: G (remote marketplace source)
  • Milestone: M6+
  • Status: Draft

E2E-CHAT-turn-process-and-thinking-display ​

  • Preconditions: A turn with progress paragraph A, multiple searches plus thinking, progress paragraph B, multiple commands plus thinking, and a final answer; the Show thinking preference on and off; the Transcript display preference at Flat, Commands first, and Fully folded; a restart with saved settings; legacy message-level transcript search targets.
  • Steps: Confirm a live active group starts open, let that same turn finish without interacting, and confirm the process closes so only the final answer (and any error or pending-action surface) remains. Reopen it and confirm a group with at most four visible items and any Task topology group stay open, while a larger ordinary group is closed. Confirm tool and search payloads stay closed, and Fully folded keeps each visible reasoning row open when Show thinking is on. On a second turn, explicitly open a completed group and confirm the choice survives subsequent updates; then close the turn's disclosure and confirm the group closes with it, and reopen to confirm the group choice is restored. In Fully folded, confirm run rows stay inside their activity groups and that only ordinary assistant progress text splits a group. In Commands first, confirm Bash/run rows render as standalone rows beside the groups, that a normal assistant progress message or a command ends the current group, adjacent calls stay separate, and Thinking rows default expanded. In Flat, confirm no nested activity group renders, the outer disclosure lists tools, commands, thinking and progress in transcript order, and tool details stay collapsed. In every mode confirm the turn renders exactly one outer disclosure and that closing it hides, then reopening restores, the rows under it. Turn Show thinking off and confirm no completed reasoning row remains and only a live whole-run TextShimmer Thinking indicator appears while reasoning streams, with no spinner, pulsing dot or per-character animation and a static presentation under reduced motion; turn it back on and confirm the completed rows return. Select each Transcript display value, restart, and confirm it persists; remove the saved value and confirm it defaults to Fully folded. Confirm the header reads running, finished, stopped, or failed for each case, names compaction and recovery while those stages are live, omits the duration when none was recorded, and never repeats the current command. Confirm the top-level header shows exactly status, recorded duration and direct tool/search count; its caret sits immediately after the tool count; a hairline underlines the row; hovering changes only the text colour with no background fill; and a failed turn does not add an issue count. Review the nested path, including independent group/item toggles, a singleton segment, literal-final-item leaf selection, failure/denial/recovery, retained-pane remounts, run rows keeping their details closed until opened, and a legacy search reveal. In AI settings, check the display selector in English and Simplified Chinese at 800px, 1000px, and 1440px window widths with 100% and 150% font scales. Select all three values and verify each remains visibly selected and saved.
  • Expected: A turn renders exactly one process disclosure containing every pre-answer row, leaving the final answer, assistant errors, stopped trailing text and pending actions outside it; when no final answer text exists, the process header and those error or pending-action surfaces remain. An untouched process closes at completion. In Flat there are no nested activity groups, and active tools, commands, thinking and progress rows follow transcript order with tool details collapsed. In Commands first, Bash/run rows are standalone beside the groups, adjacent calls stay separate, and a normal assistant progress message or a command ends a group, so the middle progress paragraph and each command start a new group. In Fully folded (the default), run rows stay inside their groups and only ordinary assistant progress text splits a group, so the middle progress paragraph ends the preceding group and the following activity starts a new one. A group appears only with at least two visible items; a singleton has no group. Every group stays open while its turn runs; after completion, groups with at most four visible items, Task topology groups and groups that still render a Thinking row remain open, while larger ordinary groups of plain tool rows start closed. Thinking rows default expanded in every mode when Show thinking is on. Explicit user choices persist and are restored when the enclosing disclosure is reopened, and never pin an ancestor; an automatic close is deferred only while focus or a selection remains inside. Apart from visible reasoning, group defaults do not depend on Show thinking. The header reports running, finished, stopped, or failed with failure and stop outranking a normal finish, reads Thinking while the live step is reasoning, names compaction and recovery while those stages are live, omits a duration the transcript did not record, and never repeats the current command. It carries only status, duration and direct tool/search count, with the caret immediately after the tool count, a hairline under the row, text-colour-only hover, and no issue count. Nested group headers and run rows share the turn header's left edge. When Show thinking is off, no completed reasoning row renders, and only the live whole-run TextShimmer Thinking indicator remains while reasoning streams; turning it back on restores the rows. No item payload opens itself except the display mode's Thinking default: a tool/search row stays closed until the user opens it. Parent/child/sibling states remain independent, pane-owned user choices survive updates, preference changes and remounts, and renderer restart reapplies defaults. Search reveals the turn process and, where one exists, the activity group that own the named message once per request; item-level targeting is not part of this change, and reasoning hidden by the preference stays hidden until the user turns Show thinking on. A saved transcriptDisplayMode survives restart and an absent or unknown value resolves to Fully folded; saved showThinking survives restart, and an absent value falls back to the legacy thinkingDisplayMode (compact hides, detailed/unknown/absent shows). The Settings selector grows to its labels beyond the standard 240px field cap. Option labels remain single-line and unclipped; the control and whole options wrap only as needed, preserving the title/help space and preventing horizontal row overflow. Wide windows keep the title and control inline.
  • Validation scope for the 2026-09-20 change: Nested disclosure and activity group presentation only; precise item-level transcript search targeting is out of scope and keeps the existing message-level search behavior.
  • Automation: pnpm test:e2e:transcript covers the command-first and fully-folded layouts, active-to-completed process disclosure behavior, the automatic folding and reading-hold transitions, and the activity issue counter's inline layout at 320/800px and 100%/150% zoom in English and Simplified Chinese; pnpm test:e2e:transcript-disclosure verifies manual-open retention and viewport anchoring; apps/desktop/test/turn-process.test.mjs covers mode segmentation, apps/desktop/test/activity-group-folding.test.mjs covers the settled group defaults and the issue-counter contract, apps/desktop/test/transcript-display-modes-ui.test.mjs covers the renderer's mode wiring, Thinking defaults and TextShimmer, apps/desktop/test/turn-process-header.test.mjs covers the top-level header's status/duration/tool-count composition and the absence of issue counts, apps/desktop/test/turn-process-header-style.test.mjs covers its hairline and text-only hover, apps/desktop/test/transcript-display-mode-settings.test.mjs covers the Settings row and search indexing, and apps/desktop/test/settings-roundtrip.test.mjs covers normalization, write validation and the fully-folded default. node scripts/e2e-settings-scroll.mjs additionally exercises the mounted Settings page's selector layout and settings-save path across both locales, three window widths, and both font scales with isolated Electron state.
  • Specs: 04-ux/06-settings-ia, 04-ux/08-component-spec, 04-ux/09-interaction-patterns; ADR turn-process-and-thinking-display.

E2E-RPC-unicode-separators ​

  • Preconditions: Built host-core, shared package and agent runtime; isolated temporary data directory; loopback-only fixture provider. No live credentials.
  • Steps: Append a user message containing U+2028/U+2029, CJK text, emoji and escaped CR/LF; read it, restart the host, and read it again. Send a Unicode prompt through AgentSidecar; restore history through the parent host proxy; stream and persist a Unicode answer. Send an unknown method containing the same characters, then a health request.
  • Expected: Text survives unchanged across persistence and all stdio directions. Requests settle without RPC timeouts; error replies and subsequent requests remain usable. No migration of existing sessions is needed.
  • Automation: pnpm test:e2e:rpc-unicode; packages/shared/src/ndjson.test.ts additionally checks every UTF-8 split boundary, consecutive frames, CRLF, EOF and disposal.
  • Specs: 03-runtime/06-host-rpc-protocol §2.
  • Acceptance: A (runtime), C (sessions).
  • Milestone: M6+.
  • Status: Automated; run against the task/PR integration candidate.

E2E-PROVIDER-ORDER: Reorder configured AI services ​

  • Open Model configuration with three providers and drag the last before the first, then after the last; verify pointer-following cards, live slot preview, and saved order. Check movement threshold, action-button exclusion, edge scrolling, Escape cancellation, and automatic-scroll cleanup.
  • Cancel a drag and use Up/Down on a focused provider card. Verify no configuration or default-model changes. Failed saves report an error and retain accepted order; overlapping moves are blocked and late catalog reads cannot undo a move.
  • Reopen the Composer model menu: groups follow the provider order. Restart the host and renderer and verify persistence. Check English and Chinese labels.
  • Automated: pnpm test:e2e:provider-order runs the real settings component, sandboxed preload, provider IPC registrar, and an isolated Rust host. It uses synthetic Chromium pointer/key events and test providers without credentials; model-catalog/OAuth discovery is stubbed and no provider requests are sent.
  • Host unit tests cover no metadata, disabled/plugin rows, new/deleted IDs, stale moves, no-op moves, preserved configuration, and reopening the database.

The provider-order suite also runs a 200-card fixture under a controlled animation clock. A burst of 200 pointer events may arm React state once, but stationary frames must produce no further renders or pending callbacks. Card geometry is read at press time, and move/release/cancel/unmount paths must clear transient transforms and queued frames. Release before the scheduled frame must still save the latest destination. These assertions measure work counts, not device FPS.

E2E-CHROME-window-controls-survive-work-panel ​

  • Preconditions: A built desktop and matching host-core binary; isolated profile and data directory with one local session; no real provider calls.
  • Steps: Open the work panel from the titlebar, toggle the sidebar, maximize and restore the panel, maximize and restore the native window, close the panel, visit Settings and return, reopen the panel, minimize/restore the window, then click Close with close-to-tray configured in the disposable profile. Also enter and leave native fullscreen with the panel open. Check the three controls with Chromium hit testing throughout, including light/dark Windows/Linux CSS.
  • Expected: Windows/Linux minimize, maximize/restore, and close stay visible and hit-testable at the window edge. Native actions work with the panel open. Preview sidebar navigation remains clickable. On macOS no duplicate renderer window controls appear; the native traffic-light and fullscreen contracts stay unchanged. CSS emulation does not qualify another operating system's native UI.
  • Specs: 04-ux/01-ui-ia.md titlebar; ADR 0021 and ADR 0025.
  • Acceptance: Window actions remain accessible independently of pane state.
  • Milestone: Post-MVP regression coverage.
  • Automation: pnpm test:e2e:window-controls; macOS traffic-light geometry also has apps/desktop/test/traffic-light-reserve.test.mjs contract coverage.
  • Status: Automated for the executing native platform; run on macOS/Linux runners for native qualification. Optional screenshots are written only to PI_DESKTOP_CHROME_ARTIFACT_DIR.

E2E-PLUGIN-slow-tool-is-not-cut-off-by-host-dispatch ​

  • Preconditions: A loaded plugin that registers an agent tool; the tool awaits work longer than 60s but shorter than the 110s plugin tool budget (for example an agent.complete call that takes ~70s).
  • Steps: Ask the agent to use the tool, approve it if prompted, and wait for the call to finish. Repeat with the permission card left open for a while before approving.
  • Expected: The call returns the plugin's result. host-core does not answer TOOL_TIMEOUT before Electron's budget expires, and no transport layer reports host RPC timeout, sidecar RPC timeout, or parent host proxy timeout for the call. Budgets nest from the inside out: plugin agent.complete 90s < MCP call 100s < plugin tool 110s < host-core dispatch 150s, while the widest MCP leg (a lazy handshake, then a tools/list traversal, then the call) is 140s. The transport deadline covers the permission wait, the admission queue wait, and that dispatch, so a call that waited for a saturated plugin class before it was dispatched is still inside it.
  • Specs: 07-plugins/12-plugin-ipc-and-host-services.md agent-tool dispatch; ADR 0038; ADR 0174.
  • Acceptance: G (plugin agent tool).
  • Milestone: Post-MVP regression coverage.
  • Automation: apps/desktop/test/plugin-timeout-budgets.test.mjs checks the budget order and the dispatch call site across the TypeScript and Rust sources; packages/shared/src/protocol.test.ts covers the transport deadline; host-core desktop_dispatch_outlasts_every_electron_budget_it_wraps covers the dispatch default.
  • Status: Contract-covered; no end-to-end driver waits out a real 70s call.

E2E-PLUGIN-crash-report-names-the-exit-code ​

  • Preconditions: A loaded plugin whose host process dies on its own — process.exit(7) after writing one line to stderr is the fixture — with a resident service so the supervisor path is exercised too. Isolated desktop profile; no marketplace or network access.
  • Steps: Load the plugin and let the host process die. Read the load error, the failed service state, the plugin.crash audit record and the plugin log channel. Repeat with a hard fault (a Windows 0xC0000005-class exit) if one is available, and then quit the app while a plugin host is alive.
  • Expected: Every one of those surfaces names the exit code (exit code 7, and exit code 3221225477 (0xC0000005) for the fault), while the fixture's stderr line is absent from the load error and crash audit record. A clean quit reports no crash at all: quitting is a shutdown, not a crash.
  • Specs: 07-plugins/05-plugin-lifecycle.md §3.1 / §8, 08-meta/decisions-log.md D607.
  • Acceptance: G (plugin host lifecycle), Quality (diagnosability).
  • Milestone: Post-MVP regression coverage.
  • Automation: apps/desktop/test/plugin-services.test.mjs forks a real host process, kills it with a fixture exit code and asserts the code plus the absence of raw stderr in the crash audit record; plugin-isolation.test.mjs and the shutdown cases cover the "quit is not a crash" half.
  • Status: Automated at the runtime level; no UI driver reads the plugin page's error text.

E2E-DIALOG-native-selection-across-boundaries ​

  • Preconditions: Built renderer, isolated Electron profile, real production React dialogs/hover card and stylesheet; only preload IPC is stubbed. Reveal and dismissal delays use a controlled clock, not arbitrary sleeps.
  • Steps: Drag from release-note/table text onto the backdrop and copy the selected text with Chromium's native Copy command; repeat content-to-backdrop and reverse drags in project instructions and provider setup. Open provider Advanced, drag across its boundary, then deliberately click its backdrop. Cancel a backdrop press or blur the window before release, then try a fresh outside click. Save project instructions while trying outside click/Escape, then use the close button. Hover a session row, drag card text outside, advance past the grace period while held and after release, Copy, revisit/leave the row, clear selection, and repeat ordinary leave, cancellation, Escape and outside press.
  • Expected: Cross-boundary drags preserve the modal and native selection; deliberate backdrop clicks still dismiss. Cancelled/blurred presses never dismiss; the next fresh click works. Nested clicks close only Advanced and saving protections remain effective. The hover card survives dragging and remains copyable while its text selection exists; row timers cannot override retention. Clearing selection or ending an unselected/cancelled drag resumes ordinary 160ms dismissal. Escape/outside press still dismiss selected cards.
  • Specs: 04-ux/09-interaction-patterns.md sections 1.10 and 9.1b; 04-ux/07-ui-design-system.md section 11.4.
  • Automation: pnpm test:e2e:overlay-selection; production Chromium mouse and keyboard input with deterministic host replies. On macOS the fixture uses Chromium's native copy command because Electron's synthetic Command+C input does not invoke the system clipboard. No running user app or real provider is used.
  • Status: Automated on Windows and macOS; Linux native evidence pending.

E2E-DIALOG-long-text-boundaries ​

  • Preconditions: Built renderer assets and an isolated Electron component fixture; production components, store, translations and stylesheet are used. Only the preload IPC boundary is replaced with deterministic host replies.
  • Steps: Open an extension input prompt with a long Windows source path; type, submit, reopen and dismiss. Repeat long title/message/option prompts in light/dark themes and Chinese at 520×480. Inspect long project/plugin names in rename, instructions, memory, delete, install, settings, permission-review and OAuth dialogs; exercise dismissal controls.
  • Expected: Full source paths and other long text wrap without clipping; horizontal overflow is absent, and the 420px prompt width is unchanged. Tall prompts scroll inside the viewport. Close, Escape, typing and radio submission work.
  • Specs: 04-ux/08-component-spec.md, Dialog long-text containment.
  • Acceptance: Bounded text and reachable modal controls.
  • Milestone: Post-MVP maintenance.
  • Automation: pnpm test:e2e:dialog-overflow; source/contract dialog suites supplement, but do not replace, real Chromium geometry and pointer checks.
  • Status: Implemented. Native Windows evidence; macOS/Linux not qualified.

E2E-OUTPUT-CAP-716: Keep model requests inside the published context window ​

  • Preconditions: A deterministic provider fixture exposes a published context window, supports parent and subagent requests, and records request payloads.
  • Steps: Configure a user context override larger than the published window; send a CJK-heavy parent prompt, invoke a subagent, and run a one-shot completion. Repeat the parent request with thinkingLevel: "omit". Restore a session whose transcript holds many large instruction updates on a Responses gateway without mid-conversation system support, then continue it and run a subagent whose updates follow its last usage report.
  • Expected: Each request carries a concrete output budget no larger than the remaining published window after input estimation and safety reserve. The runtime compacts against the published safety ceiling, and no request is sent with input + output beyond that ceiling. Small ASCII requests retain the configured budget when it fits. The restored session and the subagent send one folded instruction message and keep the configured output budget instead of the provider's minimum.
  • Specs linked: 03-runtime/02-agent-runtime.md, 03-runtime/13-model-catalog-and-selection.md
  • Acceptance: F (runtime provider requests), C (chat and stream)
  • Automation: packages/agent-runtime/src/output-cap-request.test.ts drives the restored-session and subagent steps through a loopback Responses gateway.
  • Status: Unit-covered; folded-instruction steps covered by a loopback gateway; remaining deterministic provider fixture pending

E2E-PROVIDER-certificate-trust-and-terminal-errors ​

  • Preconditions: Built request candidate incorporating current origin/main, Electron installed, isolated test process/profile and loopback HTTPS fixture. No real provider, credentials, user profile, or OS certificate-store writes.
  • Steps: Run node scripts/e2e-provider-certificates.mjs. Launch the actual desktop sidecar and submit a chat prompt against an untrusted localhost certificate. Relaunch with its CA in NODE_EXTRA_CA_CERTS, then request the same certificate through a hostname absent from its SAN.
  • Expected: The child's default CA set includes every non-expired system root and extra CAs, while expired system-store entries are omitted. Bundled roots and inherited extra CAs remain available. The first request fails once with a non-retriable certificate error and no retry status; the trusted request returns text; the hostname mismatch still fails once. TLS and hostname verification remain enabled.
  • UI: Run node scripts/e2e-provider-certificate-ui.mjs for the real error component in isolated Chromium. Certificate errors get localized guidance; DNS and protocol errors retain the generic summary. Errno/raw details remain visible, and details can be closed and reopened. Optional PI_CERTIFICATE_EVIDENCE_DIR records a screenshot; --baseline uses the upstream error component with the same fixture and stylesheet.
  • Lower-level coverage: provider-certificate-flow.test.ts enters main session prompt() and delegate run() through real Agent/pi-ai wiring, with only the external fetch mocked. Both stop after one request and retain the certificate cause. Error classification and recovery suites cover direct, nested, flattened, non-certificate and wrapped certificate failures.
  • Limits: OS-root inclusion is checked without installing a root. The TLS success fixture uses a child-only extra CA; it does not reproduce a specific antivirus installation or claim native macOS/Linux verification.

E2E-PROVIDER-stored-binding-array-reads-entry-by-entry ​

  • Preconditions: A throwaway data directory and the host-core binary; a provider created through providers.create with at least three complete bindings; no real provider or credentials.
  • Steps: Call providers.list and confirm every binding returns. Edit the stored config_json to delete one binding's maxTokens and list again. Restore the field and list again. Then set one binding's contextWindow to a string and list once more. Attempt providers.update with the readable subset and an unrelated provider change.
  • Expected: All three bindings return in every case except the malformed entry, where the readable bindings return in their stored order. The binding that lost maxTokens reads with the generic default output cap, and restoring the field restores the value. The host log names the provider id, the entry's index and the reason for the entry that could not be decoded. The explicit model-array update is rejected with MODEL_BINDINGS_DEGRADED, while the stored config_json remains unchanged.
  • Specs linked: 03-runtime/12-provider-config-schema.md §2, 08-meta/decisions-log.md D610
  • Acceptance: F (persistence), Quality
  • Status: Unit-covered (providers::catalog::tests, providers::tests::a_stored_array_survives_an_entry_that_lost_a_field); the host RPC path is exercised by scripts/e2e-smoke.mjs for provider create and list, but no suite drives a hand-edited config_json.

E2E-CONTEXT-estimate-calibration-stays-safe ​

  • Preconditions: Deterministic provider fixture whose reported usage can be scripted; a session whose occupancy is close to the hard boundary; no real provider credentials.
  • Steps: Report usage below the prediction repeatedly and confirm the displayed occupancy never falls below the floor and compaction still fires; report usage far outside the plausible band and confirm the value does not move; run a CJK-heavy session and compare the displayed occupancy with the reported usage.
  • Expected: The calibrated occupancy stays within 0.85×–6× of the raw estimate, moves up as soon as there is evidence, moves down only after agreeing samples, and a projection at 1.18× the hard limit still compacts. Misreports move it in neither direction. CJK text no longer reads at a quarter of its measured cost.
  • Specs linked: 03-runtime/02-agent-runtime.md §5.1, 08-meta/decisions-log.md D606
  • Acceptance: C (chat and stream), F (persistence), Quality
  • Status: Unit-covered (context-calibration.test.ts, including the CJK and boundary cases); desktop E2E pending

E2E-RUNTIME-unique-tool-call-ids-per-request ​

  • Preconditions: A session transcript that carries the same tool call twice (a retried append leaves the call under a second row id), loaded into a recreated runtime against a deterministic provider fixture. No real provider credentials.

  • Steps: Send one prompt so the runtime assembles and issues a request. Read the outgoing request the fixture received, and the agent log channel. Repeat with a transcript whose calls are already unique.

  • Expected: The outgoing request carries exactly one toolCall for that id and exactly one matching result, so no provider can answer tool_use ids must be unique. The log channel gets one line naming the session and the ids. The unique transcript's request is unchanged, byte for byte, and logs nothing.

  • Specs: 03-runtime/02-agent-runtime.md §5, 08-meta/decisions-log.md D608.

  • Acceptance: C (conversation and stream), Quality.

  • Milestone: Post-MVP regression coverage.

  • Automation: packages/agent-runtime/src/runtime.test.ts covers both halves against a real runtime built from a duplicated history (drop + one log line) and a unique one (identity, no log line).

  • Status: Unit-covered; no end-to-end driver issues a real provider request against a duplicated transcript.

  • Reused-id regression (#1815): Run two actual tool executions whose provider responses reuse the same id, then inspect the next request. Both results must remain, with distinct paired ids and no synthetic placeholder. Repeat after context trimming and restored history, and verify cancellation, metadata and replayed pending snapshots retain their semantics. runtime.test.ts covers the parent prompt path; subagent-fallback.test.ts drives the real Pi adapter over local HTTP with two executions and three requests; tool-call-identity.test.ts covers retained identities after trim.

E2E-RUNTIME-system-update-during-tool-call-restore ​

  • Preconditions: A session transcript with a model system record saved while a tool call ran: it sits after the assistant row that made the call and before that call's tool row (beforeMessageId is the tool row id). Loaded into a recreated runtime against a deterministic Anthropic Messages fixture with mid-conversation system messages enabled. No real provider credentials.

  • Steps: Send one prompt so the runtime assembles and issues a request. Read the outgoing request body the fixture received.

  • Expected: The tool call stays on the assistant message that made it, next to that message's text, and no call-only assistant is synthesized. Every mid-conversation system message follows a user message, so the provider does not answer role 'system' must follow a 'user' message or an 'assistant' message ending in a server tool result.

  • Specs: 03-runtime/02-agent-runtime.md §5c and §7.0.0, 08-meta/decisions-log.md D127.

  • Acceptance: C (conversation and stream), Quality.

  • Milestone: Post-MVP regression coverage.

  • Automation: packages/agent-runtime/src/system-transcript-runtime.test.ts restores the rows into a real runtime and passes the captured request through Pi's Anthropic adapter.

  • Status: Unit-covered; no end-to-end driver restores such a transcript against an Anthropic provider fixture.

E2E-RUNTIME-loop-context-ownership ​

  • Preconditions: A deterministic provider fixture that answers the first two rounds with one tool call each and stops the turn after the second round the way a user Stop does. No real provider credentials.
  • Steps: Send one prompt so the runtime runs both tool rounds and ends the turn on a tool round. Send a second prompt. Read the state the runtime kept and the outgoing request the fixture received.
  • Expected: The kept state holds each streamed assistant message and each tool result exactly once; no result is separated from the call it answers; the fixture's request carries exactly one result per call. No duplicate-drop line is logged, because the request guard had nothing to drop.
  • Specs: 03-runtime/02-agent-runtime.md §5c, 08-meta/decisions-log.md D620.
  • Acceptance: C (conversation and stream), Quality.
  • Milestone: Post-MVP regression coverage.
  • Automation: packages/agent-runtime/src/runtime.test.ts (loop context ownership) drives the real pi loop through runtime.prompt() and reads the view convertToLlm handed the provider; subagent-loop-context.test.ts covers the delegate's turn boundary. The adapter's own output is out of reach of both, so tool-call-dedupe.test.ts (request wire contract) drives pi-ai's message transform directly to pin the two outputs one call id receives when a request separates a call from its result.
  • Status: Unit-covered; no end-to-end driver issues a real provider request from a context the loop appended to.

E2E-MCP-HTTP-ACK — HTTP acknowledgement and authorization status ​

  • Preconditions: A local mock Streamable HTTP server returns JSON for initialize, 202 with plain-text Accepted for notifications/initialized, SSE for tools/list, and JSON for tools/call. No provider credentials needed.
  • Steps: Connect, discover a tool, and call it. Render the MCP settings row with idle, connecting, ready, non-authentication failure, and authentication failure statuses, including an expired stored OAuth credential.
  • Expected: The acknowledgement does not enter the JSON parser; discovery, calls, and session headers remain functional. Only explicit authRequired status shows the authorization-required badge. Non-authentication failures retain their original error. Authentication failures remain actionable.
  • Specs: 07-plugins/01-plugin-system §12.2; ADR 0038; ADR 0283.
  • Acceptance: HTTP client integration and settings component rendering.
  • Milestone: Maintenance.
  • Status: Covered by the existing HTTP client integration fixture and a focused component-render validation; no live IDA process required.

E2E-MCP-HTTP-SSE-held-open — A streamable HTTP reply lands before the server ends the stream (issue #1188) ​

  • Preconditions: A local mock Streamable HTTP server writes its JSON-RPC reply immediately and keeps the text/event-stream body open well past the client's handshake budget — the shape https://gitmcp.io/docs shows, where initialize is answered in about two seconds and the stream only ends about twelve seconds later. No provider credentials needed.
  • Steps: Configure that server with the http transport and a handshake budget shorter than the stream lifetime; connect, discover the tools, and call one. Repeat with a server that keeps the stream open and never answers.
  • Expected: Handshake, discovery, and the call complete as soon as their reply event arrives, so the connection reports ready with the discovered tools instead of mcp initialize timed out after <budget>ms. A server that never replies still fails with TIMEOUT inside the same budget, and a stream left open past its request is aborted rather than kept open.
  • Specs: 07-plugins/01-plugin-system §12.2; ADR 0038.
  • Acceptance: HTTP transport integration and the main-process MCP handshake.
  • Milestone: Maintenance.
  • Status: Automated by apps/desktop/test/plugin-mcp.test.mjs (held-open reply case); no live app process required.

E2E-IMAGE-generation-and-editing ​

  • Preconditions: Built task candidate containing latest origin/main; isolated host data directory, local image HTTP fixture, no production credentials.
  • Steps: Choose a model in Advanced, cancel and verify no change; save and replace it through another provider's Advanced settings. Clear the binding via the test settings API to verify recovery. Generate same-prompt variants and distinct images; confirm each result appears once in the conversation gallery while the assistant confirms completion in text. Edit a generated image, inspect partial failures, then restart the host/session. Attempt the tool in a durable Plan session and verify no HTTP request occurs.
  • Expected: One image binding persists without changing the chat default; generated files, edit sources and transcript references survive restart. Each successful image appears once in its result card instead of being repeated as a Markdown embed; unconfigured errors navigate to Models settings.
  • Specs: 03-runtime/21-image-generation; 03-runtime/13-model-catalog-and-selection.
  • Acceptance: Configured image generation/editing, safe cancellation and persistence.
  • Milestone: Post-MVP.
  • Status: Automated by scripts/e2e-image-generation.mjs and scripts/e2e-image-generation-ui.mjs; backend test uses real Rust/stdio/HTTP/files, UI test uses real Chromium and production components with API-boundary fixtures.
ScenarioAcceptanceSpecificationAutomation
E2E-IMAGE-generation-and-editingImage capability and recovery03-runtime/21-image-generationHost and UI suites above
E2E-IMAGES-result-downloadDownload and locate a generated result03-runtime/21-image-generationscripts/e2e-image-chat.mjs

E2E-IMAGES-result-download ​

  • Preconditions: Agent conversation with two successful generated PNGs and a local image fixture.
  • Steps: Verify one selected card and two adjacent thumbnails, download the first result, select and download the second, inspect the narrow chat layout, open the selected image in the full-window viewer, navigate both directions, zoom in, close with Escape, and inspect Show in folder.
  • Expected: The selected card and download target follow thumbnail selection. The selected thumbnail has a light gray ring, and switching loaded images keeps the card and viewer image present with stable fit dimensions throughout. Rapid reverse navigation cancels a pending decode. Both downloaded PNGs match their saved bytes and have safe filenames. Chat and viewer thumbnails remain reachable at narrow widths; viewer selection, zoom and focus restoration work, and the selected image remains selected in chat. The originals remain available. The folder action uses the contained file reveal path.
  • Specs: 03-runtime/21-image-generation.
  • Acceptance: Result actions and image browsing work without changing image storage or preview access.
  • Milestone: Post-MVP.
  • Status: Download and viewer interactions automated by scripts/e2e-image-chat.mjs; folder reveal uses the existing contained IPC path.

E2E-CHAT-parenthesized-url: Complete URLs in user messages ​

  • Steps: Send https://en.wikipedia.org/wiki/React_(software) in a user message and click its link. Repeat with the URL wrapped in prose parentheses, followed by a sentence-ending period, and followed immediately by another link or file reference.
  • Expected: The complete URL, including (software), opens the React software article. The outer prose closing parenthesis and sentence-ending punctuation after the URL parenthesis are not part of the link. Adjacent references remain independently clickable. Nested parentheses, query/fragment parentheses and percent-encoded parentheses remain intact.
  • Coverage: chat-links.test.mjs; native desktop click-through with the normal browser destination.
  • Specs linked: 04-ux/08-component-spec.md §8.3.
  • Steps: Send 介绍一下这个项目:https://github.com/secretkeyllc/nib in a user message. Drag from the start of the message past the end of the URL, then copy. Click the URL once afterwards.
  • Expected: The selection highlights the prose and the URL together, and the copied text is the whole message including the URL. A plain click on the URL still opens it through the Link open destination.
  • Coverage: transcript-style.test.mjs asserts .chat-text-link opts back into text selection over the unselectable button baseline; drag-and-copy checked in Chromium against the full renderer stylesheet (issue #1243).
  • Specs linked: 04-ux/08-component-spec.md §8.3.

E2E-IME-escape: Composition cancellation preserves drafts ​

  • Steps: Edit a user message; type a draft; dispatch composing Escape and Cmd/Ctrl+Enter. Open global search and dispatch composing Escape from its input. Repeat with legacy keyCode 229, then ordinary Escape and retry shortcuts.
  • Expected: Composition events neither discard the edit nor close search nor submit a retry. Ordinary Escape still cancels/closes, including search focus outside its input; ordinary Cmd/Ctrl+Enter still retries.
  • Coverage: node scripts/e2e-ime-escape.mjs exercises production components with bubbling DOM keyboard events and host-boundary fixtures. This does not verify an operating system IME candidate window.

E2E-SCHEDULED-manual-to-hourly ​

  • Preconditions: Built host candidate, isolated data directory, no provider.
  • Steps: Through tools.execute, create a paused Manual task, update only its id and cadence to Hourly, rename it, restart, and list tasks again.
  • Expected: Update succeeds without calendar fields; the next occurrence is one hour away. Prompt, paused state and saved configuration survive. Rename does not reset the interval. RPC tests also cover required Daily/Weekly times and retention of an existing custom schedule.
  • Specs: 04-ux/01-ui-ia §3.3.
  • Acceptance: C / F — task configuration and persistence.
  • Milestone: Maintenance.
  • Status: node scripts/e2e-scheduled-hourly-update.mjs exercises the real Rust host, permission path, stdio and SQLite; no model inference or desktop UI.

E2E-SCHEDULED-manual-workspace-binding ​

  • Preconditions: Built request candidate; isolated host data and two project directories. No provider credentials or paid API.
  • Steps: Create Manual and Hourly tasks in project A and without a project; restart the host, switch to B, Run now, rename through the editor payload, and Run now again.
  • Expected: Each result session and edited task retain the original binding, including no-project tasks. Legacy cadence-only tasks keep their previous fallback until explicitly configured (covered by host RPC tests).
  • Specs: 04-ux/01-ui-ia §3.3.
  • Acceptance: Saved workspace binding across run, edit and restart.
  • Milestone: Maintenance.
  • Status: Automated by node --experimental-strip-types scripts/e2e-scheduled-workspace.mjs, using production Electron dispatch and real Rust/stdio/SQLite. Only external inference is replaced with an observer.

E2E-SESSION-temporary-attachment-fork: Preview and independent branch inputs ​

  • Steps: In a task without a project, paste text above the long-paste threshold, send it, and click its transcript attachment. Return from the preview, create a branch, and open the same attachment in the child. Delete the source task and reopen the child attachment; branch the child again.
  • Expected: Every preview shows the original saved bytes. Each branch path belongs to that branch's scratch input directory. No project is required, and no access to another task's scratch directory is granted. A bounded fork excludes later-only and unreferenced inputs.
  • Automation: scripts/e2e-composer-paste.mjs covers long-text save, real Electron file read, temporary-task preview, and back navigation. Host tests fork_preserves_referenced_pasted_files_independently and sessions::fork_files cover ownership, deletion, repeated/bounded forks, retained checkpoint paths, expired inputs, rollback, and symlink rejection.

E2E-SETTINGS-destination-scroll-reset ​

  • Open Settings → AI and scroll midway down. Select Shortcuts: its title and first settings appear at the top. Scroll and return to AI: it starts at top.
  • Re-select the active destination and update settings without navigating: the content keeps its scroll position.
  • Repeat for built-in → plugin, plugin → plugin, and plugin → the previously selected built-in destination. Re-selecting a plugin keeps its position.
  • Follow a global search setting anchor into AI from another destination, from an open plugin destination, and within AI: the plugin page is gone and the target row is visible before the next paint. Consuming the anchor keeps that position. An external tab change with no anchor also leaves the plugin and starts at the top.
  • Run in light and dark themes.
  • Automated coverage: pnpm test:e2e:settings-scroll mounts the production SettingsPage, store, translations, and built CSS in isolated Electron. Only preload data is stubbed; search navigation uses SearchDialog's public store entry points. The development UI path checks both destinations and their Experimental badge surfaces; settings-developer-only-destinations.test.mjs separately verifies packaged visibility/search and developer-only fallback. This covers renderer interaction, not host persistence or the full global-search dialog.

E2E-SCHEDULED-dispatch ​

  • Scenario: Independent task dispatch.
  • Expected: The Electron runner admits independent due tasks without awaiting another task's prompt setup. Local in-flight ownership is keyed by task ID and Host instance until setup settles; Host remains authoritative for enabled, due and overlap checks. A replaced Host's completion cannot clear its successor's local ownership. Stop prevents new polls; admitted work keeps the existing execution/failure lifecycle. Failures remain observable and the 90-second late policy is unchanged.
  • Automation: node --experimental-strip-types scripts/e2e-scheduled-dispatch.mjs uses an isolated real Host and SQLite profile. Inference is not sent to a live provider.

E2E-SCHEDULED-project-removal ​

  • Scenario: Project removal and automations.
  • Expected: Removing a project pauses its bound scheduled tasks without deleting their definitions, schedule, workspace binding or run history. Session references in history may become null when the project's conversations are removed. Already admitted task runs block removal even before a conversation turn starts. Tasks belonging to other projects and unbound legacy tasks are unaffected. Explicit resume or Run now may recreate a project from the preserved path; automatic polling cannot do so while paused.
  • Automation: node --experimental-strip-types scripts/e2e-scheduled-project-removal.mjs uses an isolated real Host and SQLite profile. Inference is not sent to a live provider.

E2E-SCHEDULED-legacy-pause ​

  • Scenario: Legacy task maintenance.
  • Expected: Agent tools allow title, prompt and pause updates on legacy automatic tasks without a schedule, including an echoed unchanged cadence. These edits do not arm the task or capture the foreground workspace. Explicit enabling, a cadence change or a supplied schedule still follows schedule validation. Resume requires an explicit valid schedule; Manual-to-Hourly retains its existing default interval behavior.
  • Automation: node --experimental-strip-types scripts/e2e-scheduled-legacy-maintenance.mjs uses an isolated real Host and SQLite profile. Inference is not sent to a live provider.

E2E-SCHEDULED-calendar-intent ​

  • Scenario: Calendar intent.
  • Expected: The optional config_json.calendarConfigured boolean distinguishes an explicitly configured Daily/Weekly calendar from Hourly's internal schedule placeholder. Without the key, legacy Daily/Weekly schedules are treated as configured; legacy Hourly schedules retain their values but require an explicit schedule when converting to Daily/Weekly. Known calendar intent survives Hourly and restart, including midnight. Clearing or replacing the calendar with a different non-calendar placeholder clears intent. This additive extension needs no table/schema migration; older versions ignore it and cannot enforce the new conversion guard. Metadata-only edits and Manual-to-Hourly remain unchanged.
  • Automation: node --experimental-strip-types scripts/e2e-scheduled-calendar-intent.mjs uses an isolated real Host and SQLite profile. Inference is not sent to a live provider.

E2E-SCHEDULED-paths ​

  • Scenario: Workspace identity.
  • Expected: Stored workspace bindings use the existing project canonicalization contract on both write and read. On Windows, slash direction, case, trailing separators and extended path prefixes do not hide a task from its own project's conversation. The distinction between missing legacy bindings and explicit null remains unchanged. Foreign-project tools cannot list or mutate bound tasks.
  • Automation: node --experimental-strip-types scripts/e2e-scheduled-paths.mjs uses an isolated real Host and SQLite profile. Inference is not sent to a live provider.

E2E-MARKDOWN-table-actions ​

  • Setup: Render a conversation containing two Markdown tables, including aligned columns, formatted text, Chinese text, commas, quotes, and <br> cells.
  • Steps: Copy the first table; download its CSV; expand it; use copy inside the modal; close with Escape and with Close. Append a streamed row while the preview is open. Repeat the preview at a narrow width in light/dark themes and English/Chinese. Deny clipboard writes at the browser boundary.
  • Expected: Actions operate only on their own table. Markdown retains inline syntax and alignment. CSV decodes as UTF-8 and preserves fields and line breaks. The modal fits the viewport, traps focus, updates streamed rows, blocks native work-panel surfaces, and returns focus on dismissal. Narrow previews keep short headers on one line and scroll horizontally with visible scrollbars on overflowing axes; sticky headers fully cover the rows behind them. Failed copies report an error, not success. Existing table wrapping remains intact.
  • Automated coverage: node --test apps/desktop/test/markdown-table.test.mjs and node scripts/test-markdown-table.mjs after building the desktop. The latter mounts the production Markdown component in an isolated Electron window and exercises real clipboard/download boundaries. It does not call a model or use the user's app profile.

Hosted-search continuation contract (offline sidecar) ​

Scope: ADR 0297; provider-hosted search content, estimation, local tools, Task delegation, and saved-history recovery. No real model or search service.

Entry: pnpm test:e2e:hosted-search, which rebuilds the shared package and @pi-desktop/agent-runtime sidecar bundle. The test starts that bundle over its ordinary stdio RPC interface, with an isolated data root, a synthetic Host and a loopback-only Responses endpoint. Source-only mocks or an already installed desktop application do not satisfy this test.

CaseRequired observation
Search then new user promptValid search item is replayed and the second turn completes.
Search then ordinary local toolA real tool round completes and the next provider request is prepared without type/estimation failure.
Search then TaskActual delegation orchestration runs against a synthetic provider; parent continuation finishes without repeated local errors.
Saved search history restoredA fresh runtime preserves search replay without adding fake tool fields or rewriting the original record.
Usage invalidated by a real prefix changeThe isolated sidecar changes instructions during a real Read round; the prior nonzero usage no longer covers that prefix, and continuation succeeds.
Invalid saved replay containerRestore returns safe INTERNAL/context-validation, sends no provider request or persistence Host RPC, and keeps the process healthy. In-memory immutability is checked by the replay unit tests.
Unknown saved search phaseThe same explicit rejection is observed without silently discarding the search record.

Companion gates: Responses/Azure/Anthropic/Codex adapter contracts; typed search phases; zero/valid/stale usage; changed/unchanged system prefix and tool ledger; search content growth and CJK output budgets; structured local preparation failures without retry/fetch, ordinary transport retry and cancellation. The Codex contract uses a synthetic JWT payload, captures the request before fetch, proves opt-out omits the tool, and never contacts a provider. Dependency upgrades must run these gates against the locked version and record the tested bundle.

Evidence: record build and test exit codes, baseline SHA, dependency versions, artifact identity and independent review in docs/project/hosted-search-contract-verification.md. Never record private conversation content or credentials. A skipped test remains NOT RUN, not PASS.

E2E-HOOKS-cancel-and-dispose ​

  • Issue #816 extension: Cancel a command while its prompt is visible and verify the real renderer removes the dialog, no later prompts or exec occur, and the next command/turn succeeds. The trusted-extension Electron driver checks the DOM via CDP. Runner tests cover long commands, stopped context waits, tool cancellation and late updates, detached header payloads and recovery; process tests use a real parent/child tree with readiness synchronization.

  • Preconditions: An isolated Desktop profile, deterministic local provider, and a trusted plugin with a waiting preflight handler.

  • Steps: Start a prompt; stop while the handler waits; release the old handler; send another prompt. Repeat with runtime disposal. Load another fixture with stalled startup/shutdown handlers and a deferred event.

  • Expected: No provider request starts for the stopped/disposed prompt; the next prompt completes. Late results cannot restart work. Shutdown runs once, waits time out per handler, and deferred registrations are diagnosed without disabling supported handlers.

  • Specs: 07-plugins/16 §6.

  • Acceptance: Responsive cancellation and bounded extension lifecycle waits.

  • Milestone: Hooks P0.

  • Status: Runner and real Runtime/local-HTTP integration automated in extensions/runner.test.ts and extensions/runtime-lifecycle.test.ts. The trusted-extension Electron driver exercises the Stop path through the real renderer via CDP and records the visible and retired dialog states.

Composer command source, manual compaction, and empty transcript reads (#795) ​

Scope: the composer's slash dispatch, the manual compaction RPC, and the renderer's durable transcript reads. No real model or provider is contacted.

ScenarioExpected
/compact typed while composer/commands failsThe submission is refused with chat.slashCommandSourceUnavailable, the draft survives, and no prompt reaches the session. The failed read is not cached, so the next submit retries it.
A warm command sourceA builtin still dispatches locally, while a template and an unknown alias still travel as prompt text.
A manual compaction that outlives its transport deadlineThe host re-reads the durable compaction record and reports success when a new checkpoint landed, logs the mismatch, and rethrows the timeout when none did. A verdict the sidecar reported itself is never reconciled.
A transcript window that reads empty for a session with historyThe selection re-reads once, keeps the snapshot the user already has, and otherwise reports chat.sessionTranscriptEmpty; the empty page is never cached, so hover prefetch cannot re-serve it.

Automation: node --test apps/desktop/test/slash-command-source.test.mjs, node --test apps/desktop/test/session-transcript-empty-read.test.mjs, node --test apps/desktop/test/plugin-timeout-budgets.test.mjs, pnpm --filter @pi-desktop/shared test, and pnpm --filter @pi-desktop/host-runtime test.

  • Preconditions: Isolated Electron/Chromium, production provider form, synthetic provider responses; no live keys or services.
  • Steps: Open an existing DeepSeek, xAI and legacy OpenAI Completions entry. Check search directly, cancel, reopen, check and save, reopen, uncheck and save. Apply format advice on relay /responses and /messages operation URLs.
  • Expected: One service entry and one checkbox; no separate search preset or interface-switch action. Save changes only the model opt-in, retaining URL, format, name, key and other model settings; Cancel writes nothing. A selected explicit format wins over hostname defaults. Relay advice preserves origin.
  • Specs: 03-runtime/12 endpoint guidance; 03-runtime/11 search setup guidance; ADR 0297 official-route amendment.
  • Acceptance: English/Chinese interaction flows and the previous custom and Codex account paths pass. Real adapter contracts verify opt-in request routes, credentials and search tools. A DeepSeek conversation continues off/on/off, retains prior text, and does not replay encrypted search blocks to Completions.
  • Milestone: Provider configuration maintenance.
  • Status: pnpm test:e2e:provider-api-style and official-native-search.test.ts; shared route tests reject lookalike hosts, unsafe URLs and unknown gateways. The UI fixture does not prove Host/SQLite persistence or live provider availability.

E2E-260: Windows updater cache can be relocated and reclaimed safely ​

  • Preconditions: Packaged Windows x64 NSIS install, isolated user profile, and a writable cache directory on a non-system volume. Use a local updater-feed fixture; do not contact GitHub or a paid service.
  • Steps: 1) Seed the legacy %LOCALAPPDATA% updater cache with installer.exe, current.blockmap, and a staged update. 2) Launch with PI_DESKTOP_UPDATE_CACHE_DIR set to the isolated cache base. 3) Confirm Main adopts the baselines and staged update into the configured cache and removes the legacy cache. 4) Complete the staged update and launch the new version. 5) When the fixture reports no newer version, inspect the configured cache.
  • Expected: app-update.yml determines the cache subdirectory; the pending update and differential baselines survive relocation; when the running version is current only pending/ is removed, while installer.exe and current.blockmap remain for the next delta. The default path remains unchanged when the override is unset. The app does not write the download into Program Files.
  • Specs: 03-runtime/07-process-model.md, ADR 0022.
  • Acceptance: Cache-path, migration, and cleanup unit tests pass; Windows task-candidate validation confirms the updater feed transport, installer handoff, and filesystem behavior without a live release feed.
  • Milestone: M6+
  • Status: Unit and source-contract covered (update-cache.test.mjs, auto-update.test.mjs); Windows installer/E2E validation remains required.

E2E-OAUTH-pi-installation-identity-and-standalone-load ​

  • Preconditions: Pi 1.1.0; temporary Host secrets and account fixtures; network/browser/callback I/O mocked; no user account or paid service.
  • Steps: Run installation-identity.test.mjs, vendor-oauth-login.test.mjs and oauth-standalone-bundle.test.mjs under apps/desktop/test.
  • Expected: One lazy, persisted UUID per installation; failed persistence never reaches login and is retryable. Multiple same-vendor accounts retain separate credentials. The standalone bundle executes the actual ChatGPT and Meta OAuth flows from outside the repository with empty NODE_PATH, including PKCE, callback validation and token conversion. Legacy Codex credentials remain unchanged; account deletion leaves the installation identity and other accounts.
  • Acceptance: Offline identity/account/module-loading contracts pass.
  • Status: Offline integration covered. Installed Electron artifact loading, Anthropic/Codex/Copilot flow execution and final migration packaging NOT RUN.

E2E-PI-0991-account-operations-and-usage ​

  • Scope: pi-ai and pi-agent-core; coding-agent design review is reference-only.
  • Offline provider/catalog flows preserve same-vendor account isolation, live entitlement, unavailable-account errors, relay matching and explicit overrides.
  • Native Pi image operation contract tests cover generations/edits, cancellation, downloads, multiple artifacts, text/response IDs and physical usage provenance.
  • Shared/Main/Host tests replay parent, delegate and image records, including late old-turn usage, and assert one ledger entry per physical operation.
  • Production sidecar hosted-search E2E covers seven scenarios with offline providers, delegation and persisted restore. Standalone OAuth tests execute actual login modules outside the repository with mocked external I/O.
  • Installed Electron, real account/paid API and cross-version rollback are separate release qualification. No MCP, Codemode or virtual-router migration is included. See docs/project/pi-0991-adoption.md for candidate evidence.

E2E-SYSTEM-TRANSCRIPT: Ordered model state survives restart and compaction ​

  • Fixture: isolated Host data directory, production AgentSidecar transport and separate Node runtime/Pi process, local SSE provider; no real provider credentials or user's Desktop process. The harness persists completed messages; it does not exercise Electron's UI or persistence outbox.

  • Send a prompt, activate BrowserPreview through ToolSearch, and verify the next request includes the schema. Confirm the baseline and delta are acknowledged by Host and survive stopping/restarting both processes without duplicate rows.

  • Resume the session, update a skill catalog on the same runtime, and confirm the next request uses the new catalog while keeping a second unchanged entry. Runtime/adapter tests verify the native delta contains only the modified entry, and fallback models receive the complete current catalog with removals applied. Invoke Skill through the actual sidecar bridge with a fixture body supplied by the embedding host. Compact, restart the sidecar, and verify current skills and active tools survive without replaying old tool results. Remove the final skill and verify its catalog and executable schema disappear without recreating the runtime.

  • Automated: node scripts/e2e-system-transcript.mjs with PI_DESKTOP_HOST_BIN pointing to the candidate Host binary. Requires built shared/host-runtime/agent-runtime packages. Renderer projection tests ensure model-state records stay hidden while ordinary system notices remain visible.

  • Adapter contract tests cover exact model/API/endpoint capability binding, partial support, unverified relay fallback, removals/redefinitions and model switching without canonical-history mutation. Real cache-hit improvement is a separately authorized provider experiment, not an offline-test claim.

  • Flash regression: load the pinned Pi deepseek-flash model, serialize its Desktop projection, and pass skill updates/removals through the real adapter. The official binding preserves the earlier wire prefix; changed bindings retain the folded fallback. Covered by flash-transcript.test.ts.

  • Authorized live Flash acceptance: use isolated development sessions with short and long contexts. Alternate unchanged turns and single-skill updates, confirm current Skill bodies execute, and compare actual outgoing prefixes plus reported usage. After restart, verify no duplicate system updates and same-model assistant reasoning remains separate from visible content. Adapter regressions also cover legacy identities and genuine account/model changes. Cache percentages are observations, not deterministic pass thresholds.

E2E-FIXED-TOOL-DECLARATIONS: Stable Flash schemas with independent activation ​

  • Fixture: production AgentSidecar and isolated Host, official Pi Flash binding, and a child-process fetch boundary redirected to local HTTP/SSE. Credentials are dummy values and synthetic tools have no external side effects.

  • Prompt, search Alpha, execute Alpha, search Beta and execute Beta. HTTP payload tests assert identical ordered tools and unchanged prior message prefixes. Directly calling a declared but inactive tool must fail before the Host.

  • Restart both processes, then compact and restart again: Alpha stays activated, Beta remains inactive despite its declaration. Activate Beta, then remove it from the catalog and verify it cannot execute. New schema/route epochs must not restore old grants. Host rejection and Plan guards remain effective.

  • Runtime contracts additionally cover legacy migration, interrupted activation, malformed metadata, temporary prompt replacement, deterministic catalog order, the 128-tool boundary and insufficient-context fallback.

  • Automated: node scripts/e2e-fixed-tool-declarations.mjs with built shared/host-runtime/agent-runtime and PI_DESKTOP_HOST_BIN set to the candidate Host binary; fixed-tool-runtime.test.ts and fixed-tool-declarations.test.ts cover HTTP payload and policy contracts. Electron UI/outbox is outside this fixture's scope.

  • Separately authorized official Flash experiments compare several independent on-demand/fixed sessions with the same synthetic catalog and call sequence. Record cold requests, both activations, follow-up cache hits/misses and cumulative input cost. Report the larger first request and possible short-chat cost increase, alongside any longer-conversation benefit. Offline test success alone is not evidence of provider cache behavior.

  • Overflow/system-update integration: recover a provider context overflow with a system delta after the failed assistant message. Keep that delta, remove the failed assistant before compaction, and reuse one visible assistant message through successful recovery. Terminal failure and Stop retain their existing closure behavior. Covered by the parameterized runtime overflow user-path test.

E2E-262: Transcript positioned file references open and scroll the host file viewer ​

  • Preconditions: Isolated Electron/Chromium, an active workspace and session, the bundled file-manager view available, and deterministic filesystem IPC fixtures.
  • Steps: Render a real transcript path:line-range reference followed by sentence punctuation; click its verified file chip and wait for the host file viewer to load. Unit coverage also exercises path:line:column and GitHub path#Lline[-Lend] references.
  • Expected: Despite the bundled file-manager view being available, the positioned reference opens in the host's read-only file tab. The file request retains the range's starting line, and the viewer scrolls that line into the visible center area. The path and file contents remain the same as the reference target.
  • Specs: 04-ux/08 §11.8; ADR 0262.
  • Status: node scripts/e2e-file-ref-line-scroll.mjs mounts production LinkifiedText and FilesTab in isolated Electron with filesystem IPC fixtures; no real project files or provider are used.

E2E-263: Collapsing a project resets its loaded session history ​

  • Preconditions: One retained project has at least twelve unpinned sessions, and its group can be expanded in the sidebar.
  • Steps: 1. Expand the project and confirm the latest ten sessions plus a Load more control are visible. 2. Click Load more and confirm all twelve sessions appear. 3. Collapse the project, then expand it again. 4. Inspect the visible sessions and the Load more count.
  • Expected: Reopening the project shows only the latest ten unpinned sessions, and Load more reports the two hidden sessions. The collapse clears only that project's loaded-more state; session selection and other project groups remain unchanged.
  • Specs: 04-ux/08-component-spec.md §3.9; 04-ux/09-interaction-patterns.md §1.6.
  • Acceptance: C / D / Quality.
  • Milestone: Post-MVP maintenance.
  • Coverage: scripts/e2e/sidebar-row-states.mjs exercises the full user path with a host-seeded project and real CDP pointer clicks. The unit test sidebar-project-sessions.test.mjs covers the initial page, Load more, collapse reset, and project isolation. app-store-sidebar.test.mjs checks that sidebar collapse and Load more use those transitions.

Imported-extension GUI executable discovery regression (#1173) ​

Start dependency installation with a GUI-like PATH that cannot resolve Node/npm and a trusted fixture installation in ~/.local/bin. Validation and both npm stages must use that installation, preserve the application PATH, and avoid shell startup probing. Explicit selections keep their bin directory first. Missing installations still return npm-unavailable and retain native picker recovery. The executable integration fixture uses real isolated child processes and checks registry-only installation and credential isolation.

E2E-CHAT-subagent-parent-error-resume ​

  • Preconditions: Built agent runtime and a loopback provider fixture; real Agent, SDK transport, Task/TaskStop and delegate history. Host Read is a fixture.
  • Steps: Spawn two delegates, let each finish a Read, then leave both provider streams active. Fail the parent request, send a later prompt with both resume ids, and inspect the resumed requests. Repeat with user Stop and TaskStop.
  • Expected: Parent failure returns the session to idle; both delegates settle failed with SUBAGENT_PARENT_FAILED and resume with their own prior Read context. Explicit Stop/TaskStop produce aborted/stopped; both resume attempts are rejected without starting another delegate. No automatic restart occurs.
  • Specs linked: 03-runtime/02-agent-runtime.md §5f; ADR 0279.
  • Acceptance: C, E, Quality. Milestone: Maintenance.
  • Status: Automated headless runtime/provider integration via node scripts/e2e-subagent-parent-error.mjs; no real accounts or Desktop data. Runtime tests separately cover exhausted parent 429 and Stop racing settlement.

E2E-PLAN-HISTORY: Immutable submissions retain current status in chat ​

Run node scripts/e2e-plan-history.mjs against the committed candidate host. Use an isolated Electron profile/workspace and the production session IPC, preload, API normalization, store events, ToolRow, and Markdown renderer.

  1. Submit Plan and read a capped history page: expand the full Markdown and open its exact artifact path in the owning session's work panel.
  2. Approve and deliver a stale pending tool echo: history still says approved. Cover approval-before-message and inactive retained-session events.
  3. Complete execution, submit/reject a revision, and read both newest/older pages: retain both exact bodies and statuses, mark only the older Plan superseded, and leave unrelated sessions/Goal untouched.
  4. Submit/reject Goal and check localized Chinese status.
  5. Continue chat, append a compaction checkpoint, restart host and renderer: both versions remain readable; no historical card has approval controls.

No provider credentials or paid model calls are required. The fixture ends at work-panel file-request routing; artifact bytes are verified from the real host-created files. The full app's file-preview viewer is covered separately.

Composer recent chat models ​

  • Send accepted messages using four distinct configured chat models across providers. Open the combined model menu: exactly the latest three available pairs appear in recency order, with provider labels. Select an older recent row: order stays unchanged until a message is accepted, at which point that model moves first.

  • Search immediately without expanding Other models; choose using keyboard navigation. Expand Other models inline and verify no duplicate recent rows. Move from the third recent row to Other models, and tab out of search after arrow navigation: no stale keyboard highlight remains on a model row. Without history, all configured models are directly visible. Verify thinking defaults still follow the selected binding and manual same-model levels survive.

  • Create a new chat: it inherits the last actually used model. Reload local preferences and create another chat: the same binding is restored. An existing chat keeps its own binding, including when opened after a different model was used. Opening an old chat or changing a model or reasoning alone does not alter history. Send in an older chat: accepted submission updates new-chat inheritance; rejected submission does not. Deferred configuration alone does not count as usage.

  • Preconditions: Isolated Electron profile, fake Host IPC/secret storage, and a globalThis.fetch fixture for https://api.typesafe.ai/v1/systemone. Build workspace JS packages with pnpm build:js, then run pnpm test:e2e:jev. Do not use a real TypeSafe key or endpoint.

  • Steps: 1) On the service chooser's add path, confirm Jev is offered in its own Classifiers group and absent when an existing row changes service, and that no Jev card is on the model configuration page yet. 2) Open the Jev form, paste a sentinel key and Check and save: the fixture answers the check, the key reaches Host secure storage, Jev is on, and the card appears. Confirm AI services (with catalog status and refresh) precede Jev and image generation, and that the card's privacy explanation is available from its heading help control instead of as a persistent paragraph. 3) Resolve a session launch with Jev enabled, then disabled and in Plan mode. 4) Through the runtime's deferred catalog, request Jev in Agent mode and inspect Plan/Goal catalogs. 5) Call JevClassify with one choice, one score and one boolean question over a small JSON state. 6) Answer a check with 401 for a second key: nothing is written and Jev stays off. 7) Start a check and close the dialog while it is still in flight: the key is not stored and Jev stays off. 8) In the Jev card, switch Jev off and remove the key; the card leaves with it.

  • Expected: The check runs before any write, in the order check, store, then enable, so a refused key leaves no secret and no enabled setting behind, and the refusal is reported with TypeSafe's status. The card is on the page only once Jev has been added, and it leaves when the key does. The UI never returns the key to settings state, and removal disables Jev before deleting it. Only the Jev title, key status and controls occupy the card; explanatory privacy copy appears on demand from the heading help control. AI services stay above Jev, followed by image generation. An enabled Agent launch reads the key and passes it ephemerally to the sidecar. JevClassify appears in the Agent's deferred catalog only with a key and never in Plan or Goal. Closing the dialog cancels an in-flight check the same way a refused key does: nothing stored, nothing enabled. The fixture receives the TypeSafe System One payload and bearer header; the tool returns bounded structured answers and usage.

  • Specs: Tools and permissions, provider/model system, secrets storage, settings IA.

  • Acceptance: No paid or real-provider call. The suite verifies the UI user path, the ordered check-then-store gate, the refused-key path, the fixed secret reference, opt-in Agent launch boundary, deferred mode catalog, request body, bearer auth, usage, error redaction, cancellation, timeout, malformed and oversized input rejection, and key removal.

  • Disable/remove a provider or model and mark a model for image generation: unavailable history entries are skipped for inheritance and recent menu rows.

  • Settings contains no fixed chat-default picker or Make default service action; image model selection and provider configuration remain available.

  • Coverage: recent-models.test.mjs, recent-model-flow.test.mjs, default-model-picker.test.mjs, and scripts/e2e-composer-model-selection.mjs.

E2E-CHAT-subagent-direct-stop ​

  • Environment: isolated Desktop profile, real Main/sidecar/Host and a local streaming provider fixture; no real API account or user data.
  • Start two delegates, open A's detail panel and stop A. Verify B and the parent remain active, A's partial output remains readable, and no model request was needed. Stop all from the group header; B stops and the parent remains active.
  • Terminal cards remove their Stop controls and persisted Task snapshots record stopped. An empty explicit ID list is rejected rather than stopping all.
  • Run node scripts/e2e-subagent-stop.mjs. Runtime tests additionally cover cross-session isolation and uncooperative cancellation remaining pending; e2e-subagent-parent-error.mjs covers explicit cancellation/resume semantics.

BOM-marked UTF-16 text tools ​

  • Create a UTF-16LE PowerShell build log with a BOM and CRLF, then ask the agent to Read it. The tool and the next model request contain readable log lines.
  • Edit a displayed line. The original BOM, endian and CRLF bytes are preserved.
  • Repeat with UTF-16BE Chinese text. Ordinary binary files remain rejected.
  • Automated coverage: read_powershell_utf16le_log, read_and_edit_utf16be_chinese_text, and the existing binary/CRLF tool tests.

Delegate mutation recovery isolation ​

  • Start two Task delegates editing the same file. Delegate A produces text and fails three Edits; B fails twice and then completes. A is failed with MUTATION_RETRY_BUDGET_EXHAUSTED, B completes, and the parent has no mutation error. Resume A with a corrected task and verify successful completion.
  • Run the same flow with failing shell patch commands. Repeat with one delegate to prove that text preceding exhaustion does not become a completed report.
  • While a delegate continues, a new parent prompt resets only the parent's recovery counters. Both default and explicit delegate permissions retain the same isolation.
  • Automated provider-boundary flow: node scripts/e2e-subagent-edit-isolation.mjs with optional --single and --patch; runtime tests cover parent restart.

Mutation recovery file aliases ​

  • Retry a failing Edit using relative, absolute, ./.., directory-link and Windows case-alias spellings of the same existing file. The third counted failure terminates; changing spelling does not grant another recoverable-code grace. Distinct files retain independent budgets.
  • Successfully Edit or Write through an alias and retry: the count and grace reset. Removing the file through a linked path clears its pre-mutation identity.
  • Repeat relative/absolute aliases in a temporary session: its scratch root is the relative base. Probe the directory's actual case sensitivity: distinct case-sensitive files remain separate, while case aliases share an identity.
  • Automated filesystem/runtime boundary coverage: runtime.test.ts and mutation-recovery.test.ts; no UI, production profile or real provider is used.

Regenerate archival during quit ​

  • Regenerate a completed answer and quit as soon as the terminal event arrives. Restart the isolated profile: both revisions and their final messages remain readable, and no host-core disposed archival failure appears in the logs.
  • Hold the archive RPC while requesting quit. Host disposal waits for successful archival; storage errors are logged. An unresponsive archive warns and allows quit after the existing two-second bounded wait.
  • Automated coverage: shutdown-regenerate-persistence.test.mjs and node scripts/e2e-regenerate-quit.mjs (built Desktop, sidecar and host required; Playwright can be supplied through PI_TEST_PLAYWRIGHT). Only the model server is simulated in the Electron flow. The fixture profile and screenshots stay under .artifacts/ for inspection; no user profile or paid model is used.

E2E-COMPOSER-configured-context-window ​

  • Steps: Save a 500K user context limit for a model whose catalog publishes 1M. Open the Composer model list, then send a short message and inspect context usage. Repeat with a catalog-owned limit and with discovery unavailable.
  • Expected: The user-configured row and context inspector show 500K; a catalog-owned row follows the published limit; a configured model without discovery retains its saved context label. Model selection remains unchanged.
  • Status: Automated unit regression in apps/desktop/test/composer-models.test.mjs covers user overrides, catalog inheritance, and missing discovery; the full save → picker → message → context-inspector path remains a manual validation scenario.

E2E-PLUGIN-automatic-jsx-activation ​

  • Preconditions: a granted renderer plugin whose compiled entry imports react/jsx-runtime or react/jsx-dev-runtime, with React externalized to the host.
  • Steps: activate production and development entries through the host import map; render automatic JSX components that read a host context with a hook.
  • Expected: both entries resolve and render the supplied context value without bundling a second React instance. Legacy React bindings remain unchanged.
  • Automated regression: apps/desktop/test/plugin-renderer-import-map.test.mjs evaluates the generated shims and renders an automatic JSX component using the host hooks and context. Isolated Electron coverage uses scripts/e2e-plugin-jsx-runtime.mjs; no user profile or provider is used.

E2E-264: Reopening an archived project restores sidebar visibility ​

  • Preconditions: A durable project group exists for a local folder and can be archived through the renderer-owned sidebar preferences.
  • Steps:
    1. Archive the project and confirm its row is hidden from the default sidebar but remains available in Settings → Project archive.
    2. Use the project's Open action in Project archive.
    3. Archive it again, open Add project, select the same folder, and create.
    4. Archive it again and open one of its sessions from Project archive.
  • Expected: Each successful activation clears the path's archived presentation state, returns to chat, and makes the project visible in the sidebar. Selecting an inspector row without activating it and ordinary session refreshes leave the archive state unchanged. Reopening the same folder reuses the existing group without duplicating project or session data.
  • Specs linked: 04-ux/06-settings-ia.md, 04-ux/09-interaction-patterns.md, ADR 0294, D659, issue #1653.
  • Acceptance: Renderer project activation restores the normalized path's local archive metadata only after the host project activation succeeds.
  • Status: Source-contract coverage in apps/desktop/test/app-store-sidebar.test.mjs and apps/desktop/test/project-create-dialog.test.mjs; full UI scenario remains Draft and is run only when explicitly requested in a capable environment.

E2E-266: Project headers show running activity while collapsed ​

  • Preconditions: At least two local project groups with sessions, one of them with a running turn (and optionally a session waiting for a permission prompt), plus a connected remote host group with a running session. Settings → General → Sidebar switches are at their defaults.
  • Steps:
    1. Start a turn in one project and collapse that project group.
    2. Inspect the collapsed header, then expand it again.
    3. Start a second concurrent turn in a different project and a turn in the remote host's project.
    4. Open Settings → General → Sidebar and turn off Show running activity on projects, then re-check the sidebar.
    5. Turn the master switch back on and turn on Activity rail.
    6. Turn off Count badge only and re-check; turn it back on.
    7. Turn off Activity rail only and re-check; turn it back on.
    8. Enable the OS "reduce motion" setting and re-check the rail.
  • Expected: A project with live work marks its header — the collapsed header shows only the count badge by default; the activity rail is off. Running and permission-waiting counts are separate figures and never merge into one number. The remote host group shows the same default. Turning on the activity rail displays it alongside the badge. The master switch removes both affordances at once and leaves the two sub-switches disabled while retaining their stored value, so re-enabling the master restores the previous choice. Each sub-switch hides only its own affordance. Under reduced motion both affordances stay visible with their animation removed. Settings persist across a renderer reload.
  • Specs linked: 04-ux/01-ui-ia.md §3.2, 04-ux/06-settings-ia.md (General → Sidebar card).
  • Acceptance: The "is anything running under here?" answer is available from a collapsed project group, and each affordance can be switched off independently through a persisted setting.
  • Status: Draft. Existing unit coverage in apps/desktop/test/sidebar-project-runs.test.mjs covers count classification, master/sub-switch gating, both render call sites, and theme-token and reduced-motion guarantees. It does not exercise settings API default normalization or the full interaction; those remain manual validation in a capable environment.

E2E-267: A plugin follows the selected session and opens its own view ​

  • Preconditions: A development plugin that declares one contributes.views entry and holds ui.view, and whose command calls pi.session.getActive(), subscribes to pi.events.on("session:activated"), and then calls pi.ui.revealView({ location: "src/App.tsx" }). Two sessions exist in different projects; one of them is running a turn.
  • Steps:
    1. Select the idle session and run the plugin's command.
    2. Switch to the other project's session and start a turn; re-check the payload the plugin reported.
    3. While that turn runs, run the plugin's command and inspect the work panel.
    4. Switch to the first session, then run the plugin's command again from a stale reference to the previous session id.
    5. Disable the plugin and re-open the same work-panel tab.
  • Expected: pi.session.getActive() answers with the session on screen, its project path, and activeTurn: null while it is idle. session:activated arrives once per real change — a selection switch, a project change, and the turn starting or finishing — and the running session reports the durable turnId with its start time rather than an inferred one. pi.ui.revealView opens the plugin's view as a work-panel tab of the active session and focuses it, and the view receives src/App.tsx through piViewOpen/view:open without being reloaded when it is already open. A call naming a session the user left fails with INVALID_ARGUMENT and opens nothing. Revealing without ui.view fails with PERMISSION_DENIED. With the plugin disabled the tab reports the missing view instead of showing stale content.
  • Specs linked: 07-plugins/03-plugin-api.md §ui, §session follow, §5 events, §6 panel bridge; ADR 0331.
  • Acceptance: A plugin can follow the selected session and show its own view beside it without importing host internals, and a stale session can never be revealed onto a panel the user is no longer looking at.
  • Status: Draft. Unit coverage in apps/desktop/test/plugin-session-follow.test.mjs (snapshot assembly, announcement deduplication, stale-turn and stale-session refusal, ui.view gating, panel-bridge channel) and the wiring contract in apps/desktop/test/main-wiring-contract.test.mjs; the interactive Electron scenario remains a manual validation run.

E2E-FAST-MODE: /fast asks for the priority tier only on an allowed model ​

  • Preconditions: A ChatGPT subscription (Codex) account and a Responses API-key provider, each with two configured models. No real provider is required for the automated path; the manual path uses a capturing local endpoint or the user's authorized account.
  • Steps:
    1. In Settings → Models, check Fast mode on one model of each provider. Confirm the checkbox is disabled on a Chat Completions provider.
    2. Start a new task on the allowed Codex model, type /fast, send a prompt.
    3. Send /fast again, then send another prompt.
    4. Switch the session to the model without the opt-in and send /fast.
    5. Fork the session while Fast is on; restart the app and reopen both.
  • Expected: Step 1 persists fastModeAllowed on the binding only. Step 2 shows the Fast mark on the model chip and the request body carries service_tier: "priority"; a Responses API-key model shows the per-token price toast instead. Step 3 clears the mark and the field. Step 4 refuses with the model-settings hint and stores nothing. Step 5 keeps the switch on the fork and across restart. Subagent and compaction requests never carry the field. Remote sessions refuse /fast. A trusted extension that switches the session to its own Responses model sends its next request without the field.
  • Specs linked: 03-runtime/11-provider-model-system.md, 03-runtime/04-data-storage.md (v27), 04-ux/04-builtin-commands.md, ADR 0334, issue #1657.
  • Status: Automated coverage: apps/desktop/test/fast-mode-command.test.mjs (draft → created session, toggle, refusals, remote, chip), apps/desktop/test/fast-mode-session-launch.test.mjs (launch resolution), packages/agent-runtime/src/fast-mode.test.ts (request body through the real pi-ai Responses and Completions adapters), packages/agent-runtime/src/fast-mode-reuse.test.ts (mid-session switch on a reused runtime; an extension model switch drops the tier), host-core migration, RPC and fork tests. The full settings → /fast → captured request path is a manual validation scenario.

E2E-PROVIDER-optional-client-certificate ​

  • Preconditions: The locked Electron 43 runtime, built workspace packages, and an isolated profile. Only a local HTTPS fixture and local proxy are used; trust is confined to the test process/session and fixture certificate.
  • Steps: Run node scripts/e2e-provider-client-certificate.mjs. Confirm one real net.fetch fails or stalls before HTTP. Replay that isolated fixture failure for subsequent probes to avoid a second Chromium TLS handshake, then exercise model discovery and connection through the real metadata fallback; repeat with an invalid fixture key and an authenticated custom HTTP proxy. Return a cross-origin redirect, then cancel a pending response.
  • Expected: Valid credentials return the fixture model; invalid credentials reach HTTP 401. The retry follows the proxy route, sends no client identity, refuses cross-origin redirects and aborts with the original signal.
  • Security coverage: provider-https-metadata.test.mjs additionally checks mandatory client authentication, untrusted server certificates, hostname mismatch, compressed responses, and late-tunnel cleanup. The metadata policy tests reject fallback on other TLS/network errors or HTTP failures.
  • Specs linked: 03-runtime/07-process-model.md, ADR provider-metadata-client-certificate-retry, issue #1676.
  • Acceptance: Recovery without an Electron major upgrade, TLS verification bypass, direct proxy bypass, or change to persisted provider bindings.
  • Milestone: Maintenance.
  • Status: Automated fixture; record the tested head/base and execution result in the delivery report.

E2E-1175: A plugin suggestion rides in the Composer placeholder and is taken with Tab ​

  • Preconditions: A plugin that declares composer.suggest is installed, enabled, and granted the permission, and it offers one line on session:turnEnded after its own completion.
  • Steps: 1) Run an Agent turn in a session and let it complete while that session is on screen and its Composer is empty. 2) Observe the placeholder layer. 3) Press Tab. 4) Repeat, then press Esc. 5) Repeat, then type one character. 6) Repeat, then switch session before the offer arrives.
  • Expected: The suggestion appears in the placeholder layer of an empty draft, brighter than the shell placeholder and on one line with an ellipsis, with a Tab / Esc hint and one polite announcement. Tab writes the exact text into the draft with the caret at the end and the input focused, keeping any staged attachment; Esc drops it; a first character drops it; a missed offer answers { shown: false, reason } and shows nothing. No transcript row, toast, or overlay appears, and the plugin's call is audited.
  • Specs linked: 07-plugins/03-plugin-api.md, 07-plugins/13-plugin-permissions-matrix.md, ADR 0333.
  • Acceptance: A (Composer entry path), Security (permission-gated, never sent by the plugin).
  • Milestone: M6+
  • Status: Unit/source-contract-covered by composer-suggestion-state.test.mjs, composer-suggestion-relay.test.mjs, and plugin-composer-suggestion.test.mjs; the rendered journey remains a manual/Electron check when this surface changes.

E2E-REMOTE-FILE-MANAGER: Scoped remote plugin reads ​

Run node scripts/e2e-remote-file-manager.mjs to validate the shipped copy in apps/desktop/resources/plugins/pi.file-manager. This is the release acceptance path: passing against a separate development plugin does not prove the bundled user path is repaired. The runner records the loaded directory in its output. An optional <built-file-manager-directory> argument selects the companion plugin's rebuilt views/ or unpacked official PluginPack artifact for development validation only. Before accepting #1711, update the bundled release, its upstream tag/commit and per-file checksums, then rerun the default path on the updated PR integration candidate.

A development integration may exercise a Desktop hotfix in that actual bundled directory, with its immutable source commit, local patch and artifact checksums recorded honestly in UPSTREAM.md. A passing hotfix journey does not claim that the companion upstream release or the maintainer's final acceptance is complete. PI_FILE_MANAGER_QA_DIR may point to an existing plugin tests/electron installation providing Playwright; PI_ELECTRON_BINARY selects an existing compatible Electron runtime. PI_E2E_ARTIFACTS optionally preserves screenshots.

The isolated production plugin process and file:// view use Desktop's actual SDK broker, durable-binding reader, loopback RACP transport and Host filesystem service. Only the fixture's project-record lookup and authentication bootstrap are supplied by the harness. The remote fixture root is registered explicitly outside the Host folder picker's browse root. Verify nested listings, text/image bytes, hidden credentials, read-only editor/save/menu controls, visible disconnect errors and refresh recovery, rejected late previews and ancestor opens after a project switch, and restored local editing/Browser/editor detection. No model, user profile or external SSH server participates. Real SSH transport and native Windows/Linux behavior require their separate platform acceptance.

E2E-GENUI-inline-visualization-card: a visualization renders inline and stays sandboxed ​

  • Preconditions: The app is running with a local Agent session, Settings → AI → Inline visualizations switched on, and the builtin pi-desktop/visualize skill enabled. A second session uses a remote workspace.
  • Steps: 0) With the switch off (the default), ask for a chart and confirm no directory, skill, or card appears; then switch it on. 1) Ask for an interactive chart (for example "show a slider that changes a sine wave's frequency"). 2) Wait for the turn to finish, then drag the slider. 3) Switch the app theme between light and dark. 4) Press the card's expand button, then Esc. 5) Ask the model to write a non-.html file into the visualization directory, and to write a fragment that calls fetch("https://example.com"). 6) Fork the session, then delete the source session. 7) Repeat step 1 in the remote session. 8) Relocate the data directory, then fork a transcript that still names its old visualization path. 9) Try redirected visualization roots and a .html FIFO, and open two document URLs from callers that omit the iframe sandbox. 10) Expand a card while eight inline cards are live.
  • Expected:
    • No permission card appears for the fragment write. While streaming, the reply shows plain text; after completion the ::visualize line is replaced by a card whose height fits the content (at most 2000 px).
    • The slider works; theme changes recolor the card without a reload.
    • Expand opens a large modal copy; Esc closes it and the inline card keeps its state.
    • The non-.html write fails with a tool error; fetch requests are blocked, and the frame cannot open popups or navigate remotely. The ADR's WebRTC ICE limitation remains explicit rather than a blanket no-network claim.
    • The fork retains its own card after the source session is deleted, including a fork made after data relocation. The source's directory is removed.
    • Redirected roots cannot bypass Ask write confirmation or let cleanup delete unrelated data. FIFO requests fail without blocking other filesystem work.
    • Every document response enforces an opaque sandbox; a caller that omits iframe sandbox cannot read another session's DOM.
    • Expanded and inline frames share the eight-frame quota, and the expanded card's original inline control state remains unchanged.
    • The remote session gets no visualization directory or skill.
  • Coverage: pnpm test:e2e:gen-ui runs the offline Electron card journey, including real protocol responses, bundled assets, the preloaded viz helpers (viz.chart sizing, viz.render charts and their date, year, re-render, and donut edge cases), a fragment script error shown in the card, focus/guest Escape, viewport release, and unsandboxed callers through a real isolated Host. Native directory, FIFO, and ABA regressions run in visualizations/files; gen-ui-file-safety.test.mjs validates the private Host-to-Main boundary. Rust visualization RPC/lifecycle/fork tests cover authorization, deletion safety, Windows path spelling, and relocation.
  • Status: Automated without a paid provider or a user's running instance.

E2E-PROVIDER-reasoning-effort-override: Preserve an explicit gateway reasoning parameter ​

  • Setup: A custom Chat Completions provider with a DeepSeek-family model; start with a legacy binding that omits supportsReasoningEffort.
  • Steps: In English, Chinese and German at 850/1000/1400px widths, open model settings, confirm Auto, select Send, save and reopen; repeat for Do not send and Auto. Switch to Responses and Anthropic and confirm the Completions-only control is hidden.
  • Expected: Each saved binding retains true, false, or null respectively. With High selected, the real Completions adapter sends reasoning_effort: high only when enabled; Auto keeps the existing DeepSeek compatibility default. Responses continues using its own reasoning contract. Provider creation, update/read, copying, and sync preserve the explicit override.
  • Automation: node scripts/e2e-provider-api-style.mjs --reasoning-effort exercises real React/Chromium controls and captures the Host API payload (the IPC boundary is stubbed). Rust provider tests independently verify real SQLite persistence; provider-reasoning-effort.test.ts captures requests from the real pi-ai adapter with fixture SSE responses. No live gateway credentials are required.

E2E-RUNTIME-malformed-tool-call-continue-recovery: Continue after malformed tool output ​

  • Preconditions: An isolated sidecar and loopback Responses fixture can emit an empty tool name, then a corrected Read call. Unit fixtures also cover missing names/IDs, empty Responses wire IDs, non-object arguments, retained malformed context, compaction tails and resumed delegates.
  • Steps: Return one malformed response, then invoke the same agent.prompt entry used by Continue three times. Repeat with three consecutive malformed responses followed by a corrected response. Keep the runtime alive throughout.
  • Expected: Each malformed batch settles with one local context-validation error before tool execution, with no transport retry. All three later prompts complete; the corrected Read executes once, and the runtime is reused rather than restarted. Model context retains valid history and system updates while excluding malformed calls and associated results. Optional usage estimation does not lose terminal tool events. The durable transcript and Host permissions are unchanged; unrelated malformed hosted search still fails strict validation.
  • Specs linked: 03-runtime/02-agent-runtime.md §5d.
  • Acceptance: Conversation, runtime recovery and quality.
  • Milestone: M5 hardening.
  • Automation: pnpm test:e2e:hosted-search runs the production sidecar and real Responses adapter against synthetic SSE, including one-error and three-error recovery cases. malformed-tool-recovery.test.ts, tool-call-identity.test.ts and subagent.test.ts cover retained state, event/result agreement, batch rejection and delegate restoration.
  • Status: Automated offline; native UI clicking and live providers are outside this fixture's coverage.

Local-first · Model-agnostic · Plugin-powered. nib.am