diff --git a/docs/superpowers/plans/2026-10-18-isycode-tui-stability.md b/docs/superpowers/plans/2026-10-18-isycode-tui-stability.md new file mode 100644 index 0000000..17c7629 --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-isycode-tui-stability.md @@ -0,0 +1,217 @@ +# ISyCode TUI Stability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Status:** Approved for direct execution by the user on 2026-10-18, including the three reported chat/OAuth fixes below and the sibling-folder alias fix added after a new reproduction. Gmail Hive remains deferred. + +**Goal:** Resolve demonstrated TUI reliability risks, preserve completed chat turns across later responses, make the signed-in ChatGPT model selector usable, make the official OAuth link actionable, and let users register sibling folders with natural capitalization, while preserving security gates and truthful evidence. + +**Architecture:** Establish a clean baseline first. Diagnose the Sentinel journal startup worker and prior test flake without assuming a product defect. Reproduce the reported multi-turn overwrite and OAuth UX issues with offline TUI tests, fix only their owning flows, then profile synthetic content and reasoning streams without a live provider and optimize only if measurement shows material UI cost. + +**Tech Stack:** Python 3.10/3.12, pytest, Textual 8.2.8, GitHub Actions Linux and Windows jobs; no new runtime dependencies. + +**Spec:** [ISyCode Stability and Gmail Hive design spec](../specs/2026-10-18-isycode-stability-and-gmail-hive-design.md), approved 2026-10-18. + +## Global Constraints + +- Stability ships before Gmail Hive; this plan does not implement Gmail OAuth or remote polling. +- ChatGPT subscription changes are limited to the existing provider's post-login model selection and login-link presentation. Do not use the user's live account in automated tests, add credential storage, broaden OAuth scopes, or send a real sign-in request. +- Change production code only when a reproducible failure or profile supports the change. +- Preserve all prior user and assistant messages, full streamed/reasoning text, selectable content, cancellation behavior, and Authority/ISySentinel/one-use approval checks. +- Never select a ChatGPT model silently; keep `auto` until the user explicitly chooses an available model. +- Do not weaken assertions, rewrite historical evidence, manufacture gate approvals, or mark a gate complete without fresh proof. +- Refresh roadmap audit metadata only after a real re-audit; this work does not complete the roadmap audit. +- Record exact command, environment, result, and changed artifact hash in the result report. + +## Review Focus + +1. **Consecutive chat turns:** a completed assistant widget must never be reused as the next turn's streaming widget; prior user/assistant/tool output remains intact. Separate actual replacement from expected scroll-offscreen behavior. +2. **OAuth model selection:** ChatGPT subscription choices come from the authenticated account catalog or a clearly labeled supported catalog; `auto` is never silently replaced. +3. **OAuth link activation:** the long URL remains actionable when wrapped; only HTTPS URLs on approved ChatGPT auth hosts may open. +4. **Journal startup overlap on Windows:** do not read the journal or its lock file while the `sentinel-journal` worker is still verifying it. +5. **TUI teardown with a pending worker:** app shutdown must leave no active worker or unsafe journal access. +6. **Native-login cancellation:** the private challenge code may not enter chat history or persisted state. +7. **Fragmented or partial streams:** coalescing rendering must not lose, duplicate, or expose failed-step output, and steering must preserve its existing semantics. +8. **Large reasoning and content streams:** the UI must remain responsive, preserve the complete selectable result, and keep updates working when the active lane is off screen. +9. **Sibling-folder registration:** a user-entered alias such as `Github` should be accepted and stored canonically as `github`; case-only duplicates remain rejected and folder grants stay scoped to that exact selected path. + +--- + +### Task 1: Re-establish the failure baseline + +**Files:** +- Create: `docs/superpowers/results/2026-10-18-isycode-stability-baseline.md` +- Test: `tests/test_chatgpt_auth_tui.py::test_native_subscription_login_and_cancel_never_save_challenge` +- Test: `tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume` + +**Interfaces:** +- Consumes: current `main` baseline and the existing `test-windows` CI job. +- Produces: a timestamped report that separates reproduced failures from historical observations. + +- [ ] **Step 1: Capture the exact baseline.** Record the checked-out commit, OS, Python, pytest, Textual version, and historical run [38974723145](https://github.com/DannyBaanks/ISyCode/actions/runs/38974723145) with its PermissionError. Do not label the inferred worker race as the cause. +- [ ] **Step 2: Run both focused tests on the local host.** + + Run: `python -m pytest -q tests/test_chatgpt_auth_tui.py::test_native_subscription_login_and_cancel_never_save_challenge tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume` + + Expected: record the real exit status and full output. A pass here does not clear the historical Windows failure. + +- [ ] **Step 3: Stress the previously intermittent resume test.** + + Run: + ```bash + for run in $(seq 1 20); do + python -m pytest -q tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume || exit 1 + done + ``` + + Expected: either 20/20 passes or a saved failing iteration and traceback. Do not add `pytest-repeat` or another dependency for this check. + +- [ ] **Step 4: Record the test-windows CI result.** On a Windows runner, execute the focused parametrized login test and preserve the run URL/output. If a Windows runner is unavailable before implementation, mark Windows reproduction `NOT_DEMONSTRATED`; do not infer success from Linux. +- [ ] **Step 5: Save the report and verify it.** Include raw command output or a stable reference to it, exact exit codes, environment, and SHA-256. No product files change in this task. + +### Task 2: Fix the journal race only if evidence confirms it + +**Files:** +- Modify: `tests/test_chatgpt_auth_tui.py` +- Possible, only if a product lifecycle defect is proven: `src/isycode/tui.py`, `src/isycode/tui_app_rail.py`, or `src/isycode/action_audit.py` +- Test, only if a product lifecycle defect is proven: `tests/test_tui_startup.py` + +**Interfaces:** +- Consumes: Task 1's reproduction and the existing `sentinel-journal` worker group. +- Produces: deterministic test synchronization or a narrow, regression-tested product fix; no change when the cause is not demonstrated. + +- [ ] **Step 1: Make the overlap reproducible.** If the failure reproduces, instrument the existing test to capture whether the `sentinel-journal` worker is active while the test walks and reads `tmp_path/state`. Preserve the exact failing path and exception. +- [ ] **Step 2: If the conflict is only in test timing, synchronize the test.** Use the established Textual pattern `await app.workers.wait_for_complete(workers)` for workers selected by `worker.group == "sentinel-journal"` before scanning persisted state. Keep the challenge-secret assertions intact. +- [ ] **Step 3: If production lifecycle behavior is at fault, add a focused failing test first.** Pin the observed start/stop/lock contract in `tests/test_tui_startup.py`; change only the owning source method among `TUIApp.on_mount`, `_load_sentinel_journal`, or journal verification. Do not relax journal locking or failure-closed behavior. +- [ ] **Step 4: Run the focused regression on the affected platform.** + + Run: `python -m pytest -q tests/test_chatgpt_auth_tui.py::test_native_subscription_login_and_cancel_never_save_challenge tests/test_tui_startup.py` + + Expected: PASS with the original secret-absence checks unchanged. If Task 1 did not reproduce the issue, do not add speculative synchronization or production code; record `NOT_DEMONSTRATED` and close this task as investigation-only. + +- [ ] **Step 5: Run the Windows focused check.** Use CI's `test-windows` runner after the regression change. Expected: both parametrized cancellation states pass; a Linux pass is not a substitute. + +### Task 3: Preserve completed assistant messages across turns + +**Files:** +- Test: `tests/test_chat_sequence.py` +- Possible, only if the regression reproduces: `src/isycode/tui_app_chat.py` or the owning session method + +**Interfaces:** +- Consumes: the existing `TUIApp._run_chat()` streaming path and active `ConversationLane`. +- Produces: a regression proving two completed assistant answers remain separately visible and unchanged after a later turn; a minimal fix if the lane reuses a completed stream widget. + +- [ ] **Step 1: Write a two-turn regression first.** Use an offline fake provider returning distinct answers for sequential user turns, mount both prompts through the normal submission path, then assert both prompts and both answers remain in the mounted chat and in `_history`. +- [ ] **Step 2: Run the regression before changing production code.** Expected: if the issue reproduces, the first answer's widget is updated to the second answer or the prior turn is otherwise missing. Record the failure. If the test passes, inspect scroll position and record content loss as `NOT_REPRODUCED`; do not alter normal tail-follow behavior just to keep all history within a 24-row viewport. +- [ ] **Step 3: If reproduced, make the smallest lifecycle fix.** Ensure only an unfinished response owns `lane.stream_widget`; release that reference once the no-tool final response is committed, while preserving tool-followup, interruption, retry, and steering paths. +- [ ] **Step 4: Verify adjacent behavior.** Run `python -m pytest -q tests/test_chat_sequence.py tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume`. +- [ ] **Step 5: Inspect the diff and commit only exact chat source/test paths.** If no source change is justified, retain a test only if it covers a distinct contract not already protected; otherwise record the investigation in the baseline report. + +### Task 4: Make ChatGPT subscription model selection explicit + +**Files:** +- Test: `tests/test_chatgpt_auth_tui.py` +- Test: `tests/test_model_button.py` +- Possible: `src/isycode/tui_app_providers.py`, `src/isycode/tui.py`, or the owning model-selection module + +**Interfaces:** +- Consumes: the existing ChatGPT OAuth completion path, active model control, and provider/account catalog returned through the existing authority owner. +- Produces: a visible, tested route from a signed-in ChatGPT subscription to an explicit model choice without changing the default automatically. + +- [ ] **Step 1: Inspect the existing post-OAuth provider-selection and model-catalog path.** Confirm whether the active model button exposes model choices for `chatgpt`, and whether `Provider.models()` is backed by the authenticated Codex endpoint. Do not make a live request. +- [ ] **Step 2: Write a failing offline TUI test.** Simulate OAuth success with a fake connector, open the visible model control, and assert the user can reach a catalog-backed ChatGPT model choice while `auto` stays active until Enter selects one. +- [ ] **Step 3: Run the test before implementation.** Expected: the current route either offers only provider choices or cannot present/select ChatGPT account models. Preserve the failure output. +- [ ] **Step 4: Implement the smallest route/catalog fix.** Keep provider switching separate from model selection; send model-list requests only through existing `ProviderNetworkOwner` and Authority grants. If the authenticated endpoint supplies no catalog, show that state clearly and use only an explicitly labeled supported catalog; never invent IDs or claim account availability. +- [ ] **Step 5: Run focused auth/model tests.** Run `python -m pytest -q tests/test_chatgpt_auth_tui.py tests/test_model_button.py tests/test_resolved_chat_model.py tests/test_model_availability.py`. +- [ ] **Step 6: Verify opening, choosing, cancellation, focus return, and default `auto`; commit only exact source/test paths.** + +### Task 5: Make the ChatGPT OAuth URL actionable when wrapped + +**Files:** +- Test: `tests/test_chatgpt_auth_tui.py` +- Modify, only as needed: `src/isycode/provider_auth_screens.py` +- Possible: the narrow link-opening owner used by the current Textual UI + +**Interfaces:** +- Consumes: the challenge URL returned by the existing approved ChatGPT connector. +- Produces: a readable, mouse/keyboard actionable official sign-in link that remains usable at 80×24, without exposing it to conversation or persistent state. + +- [ ] **Step 1: Add a failing screen test** with a long HTTPS URL on `auth.openai.com`; assert the rendered control targets the exact URL and remains activatable after wrapping at 80×24. +- [ ] **Step 2: Run the test and confirm it fails** because the current screen exposes only plain static URL text. +- [ ] **Step 3: Implement a native actionable link or explicit Open button** using Textual's supported link/browser-opening path. Validate scheme and hostname against existing `AUTH_HOSTS`; reject unrelated hosts and keep cancellation reachable. +- [ ] **Step 4: Run login/cancel and link tests.** Run `python -m pytest -q tests/test_chatgpt_auth_tui.py tests/test_provider_auth_methods.py`. +- [ ] **Step 5: Confirm the one-use code and challenge URL remain absent from `_history` and persisted state; commit only exact source/test paths.** + +### Task 6: Accept natural casing for sibling-folder aliases + +**Files:** +- Test: `tests/test_workspace_folders.py` +- Test: `tests/test_workspace_folders_tui.py` +- Modify: `src/isycode/workspace_folders.py` + +**Interfaces:** +- Consumes: the native sibling picker result and `WorkspaceFolders.add()` validation. +- Produces: a canonical lowercase alias for mixed-case user input, with duplicate detection against the canonical form and no change to path or grant boundaries. + +- [ ] **Step 1: Add failing unit and TUI regressions.** Enter `Github` for an actual sibling folder; assert it registers as `github`, resolves under that alias, and a later `GITHUB` registration cannot create a case-only duplicate. +- [ ] **Step 2: Run the regressions before the fix.** Expected: the current lowercase-only validator rejects `Github`; preserve the exact failure. +- [ ] **Step 3: Canonicalize aliases at the store boundary.** Lowercase string aliases before the existing strict syntax, reserved-name, path, identity, capacity, and duplicate checks. Preserve fail-closed path and Authority behavior. +- [ ] **Step 4: Run folder tests.** Run `python -m pytest -q tests/test_workspace_folders.py tests/test_workspace_folders_tui.py`; then inspect that the test uses the normal add-folder screen and exact selected path. +- [ ] **Step 5: Commit only the exact folder source/test paths.** + +### Task 7: Measure content and thinking render cost offline + +**Files:** +- Create: `scripts/profile_tui_streaming.py` +- Create: `docs/superpowers/results/2026-10-18-isycode-stream-profile.md` +- Reference tests: `tests/test_daily_tui.py`, `tests/test_chat_sequence.py`, `tests/test_focused_box_expansion.py` + +**Interfaces:** +- Consumes: `TUIApp._run_chat(text: str, *, resume: Any | None = None)`, with `isycode.tui.provider_complete` replaced by a synthetic provider in `TUIApp.run_test()`. +- Produces: `main(argv: list[str] | None = None) -> int` diagnostic script that reports per-run wall time, process CPU time, cumulative process-CPU time spent in `RichMarkdown` construction and `ThoughtBlock._paint_preview`, and each call count without making a CI timing assertion. + +- [ ] **Step 1: Add an offline profile harness.** The fake provider emits no network traffic and returns the emitted text in the same shape as existing provider fixtures. Support content-only and reasoning-only runs with 16,384 total characters, chunk sizes of 16 and 128 characters, and five runs per combination. Add CLI options `--runs`, `--payload-chars`, and `--chunk-chars`; default to the values above and permit a 65,536-character scale run when requested. +- [ ] **Step 2: Capture a baseline profile.** + + Run: `python scripts/profile_tui_streaming.py` + + Expected: report median and min/max wall/CPU time, cumulative time spent in each measured renderer, and render counts per mode and chunk size. Store Python/Textual/OS versions, command, and raw output in the result file. Do not use real provider credentials or count network/model time. + +- [ ] **Step 3: Decide whether optimization is justified.** Treat the renderer as a material bottleneck only if the same-host baseline shows repeated cumulative Markdown rendering or reasoning-preview work is responsible for at least 20% of measured CPU time in the synthetic stream. Otherwise preserve code and record that the user's perceived freeze was not reproduced by this profile. +- [ ] **Step 4: If the threshold is met, write behavioral tests before changing rendering.** Add tests in `tests/test_chat_sequence.py` proving that a burst of chunks is coalesced, the final content and reasoning remain byte-for-byte complete and selectable, and pending content is flushed on successful finish, failed partial stream, cancellation, and lane switch. +- [ ] **Step 5: Implement only the measured bottleneck.** If content Markdown formatting dominates, coalesce UI paints while retaining the full source text and flush at turn boundaries. If reasoning preview dominates, change `ThoughtBlock` only with the same boundary guarantees. Do not change stream transport, provider chunking, or Idea Box timing as a rendering workaround. +- [ ] **Step 6: Re-profile with identical samples.** Expected: the targeted render cost drops at least 20% against the same-host baseline; all full-content and boundary tests pass. If the target is missed, revert the optimization and retain the profile result only. + +### Task 8: Verify and close the stability delivery + +**Files:** +- Update: `docs/superpowers/results/2026-10-18-isycode-stability-baseline.md` +- Update: `docs/superpowers/results/2026-10-18-isycode-stream-profile.md` if Task 3 ran +- Verify: `.github/workflows/ci.yml` jobs `test`, `test-textual-current`, and `test-windows` + +**Interfaces:** +- Consumes: completed diagnostics and any narrow regression/profile changes from Tasks 1–7. +- Produces: a reviewable evidence record and an explicit release/hold recommendation for Stability A. + +- [ ] **Step 1: Run the full local non-integration suite.** + + Run: `python -m pytest -q -m "not integration"` + + Expected: record exact summary and exit status; resolve new regressions rather than weakening tests. + +- [ ] **Step 2: Run the focused failure and streaming tests again.** Run the tests named in Tasks 1–3 and compare them with the baseline. Expected: no challenge secret in chat/state, no duplicate or lost stream text, and no active journal worker during the persisted-state scan. +- [ ] **Step 3: Obtain platform CI evidence.** Require Linux 3.10, Linux/Textual 8, and Windows CI jobs from `.github/workflows/ci.yml` before calling the change verified. Preserve each job URL and exact status; unavailable jobs remain `NOT_DEMONSTRATED`. +- [ ] **Step 4: Review evidence and working tree.** Run `git diff --check`, inspect every changed path, and hash the final result files. Do not update `docs/ROADMAP.md`'s overall audit date unless a full fresh roadmap audit was actually performed. +- [ ] **Step 5: Commit the stability change and its evidence by exact paths.** Keep unrelated files out of the commit; recommend a separate commit for any profile-only diagnostic if it is retained. +- [ ] **Step 6: Recommend ship, revise, or hold.** A ship recommendation requires the spec's acceptance criteria and relevant platform CI to pass. A non-reproduced historical failure or unavailable Windows evidence stays explicitly open. + +## Self-review + +- Spec coverage: Task 1 covers baseline and historical flake; Task 2 covers the Windows journal race; Tasks 3–6 cover the reported chat/OAuth and sibling-folder issues; Task 7 profiles content and reasoning and permits only measured optimization; Task 8 covers full-suite/platform validation and evidence. Gmail remains deferred. +- Review Focus coverage: consecutive-turn widgets are checked in Task 3; model catalog ownership and explicit selection are checked in Task 4; OAuth host validation and cancellation-secret absence are checked in Task 5; canonical alias handling and path-scoped grants are checked in Task 6; worker overlap is in Task 2; fragmented/failed/cancelled stream boundaries are tested in Task 7; large output and active-lane behavior are profiled without a timing assertion in CI. +- No task presumes the root cause. A missing reproduction yields a documented non-fix, not a speculative source edit. +- The 20% profile threshold is a proposed operational definition of “material” for review; it is not a product performance claim. + +## Execution handoff + +The plan and additions above are approved. Execute directly in this isolated branch, task by task, and require a final review before recommending a merge. Gmail Hive is still a separate later delivery. diff --git a/docs/superpowers/results/2026-10-18-isycode-stability-baseline.md b/docs/superpowers/results/2026-10-18-isycode-stability-baseline.md new file mode 100644 index 0000000..61f7eaf --- /dev/null +++ b/docs/superpowers/results/2026-10-18-isycode-stability-baseline.md @@ -0,0 +1,65 @@ +# ISyCode TUI stability baseline — 2026-10-18 + +## Scope and environment + +This baseline covers the previously reported native ChatGPT login/cancel test and interrupted-turn resume test. No production files were changed during the baseline runs. The checked-out source at baseline was commit `1b7a3ed4a28dc576d3bbaa4d2603e9cd40c54eff` on `codex/gmail-hive`. + +- Host: Linux `DannyISyCo 7.0.0-38-generic`, Ubuntu 24.04 x86_64 +- Python: 3.12.3 +- pytest: 9.1.1 +- Textual: 8.2.8 +- Captured: `2026-10-18T01:44:56-06:00` + +## Focused baseline + +Exact command: + +```bash +python -m pytest -q tests/test_chatgpt_auth_tui.py::test_native_subscription_login_and_cancel_never_save_challenge tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume +``` + +Result: exit 0, **3 passed in 4.90s**. This Linux pass does not clear the historical Windows failure. + +Raw captured output: `.superpowers/sdd/2026-10-18-isycode-tui-stability/task-1-focused.log` + +SHA-256: `6be18f71891a89285d6fb0b850dc02e3754e549d742fb9f46332665e87702c9f` + +## Resume stress + +The exact resume test was run 20 separate times with `python -m pytest -q`. **20/20 passed**; no failing iteration or traceback was observed. The complete per-iteration output is in `.superpowers/sdd/2026-10-18-isycode-tui-stability/task-1-resume-stress.log`. + +SHA-256: `bdabfeb31c687754aa69f74ef7cbb36c908ad916a82b9e7eb75b56f1bb928691` + +## Historical CI observation + +Run [38974723145](https://github.com/DannyBaanks/ISyCode/actions/runs/38974723145) concluded `failure`. The Windows job [Tests (Windows)](https://github.com/DannyBaanks/ISyCode/actions/runs/38974723145/job/117221278935) failed; Linux, Linux/Textual 8, and gate-evidence jobs succeeded. The failure was previously described as a `PermissionError` while reading test state. GitHub's `gh run view --job 117221278935 --log` and `--log-failed` returned no log lines in this environment, so the exact stack trace and cause are **NOT_DEMONSTRATED** here. The failure was not reproduced locally because this host is Linux; a Windows runner is still required. + +## Reproduction status + +- Native login/cancel test: passes on this Linux host; Windows historical failure remains unverified. +- Interrupted-turn resume: passes locally and in 20/20 repetitions. +- Journal worker race as a cause of the Windows failure: **NOT_DEMONSTRATED**. No lifecycle fix is justified by this baseline alone. +- Later multi-turn rendering regression: tracked and tested separately in the approved plan; it is not inferred from the historical Windows job. + +## Post-fix verification (2026-10-18, follow-up session) + +Follow-up verification on the same host and branch (`codex/gmail-hive`), after the Task 3–7 fixes were applied: + +- Focused chat/streaming: `python3 -m pytest -q tests/test_chat_sequence.py tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume` → **15 passed in 16.72 s**. +- Focused auth/model/folders: `python3 -m pytest -q tests/test_model_button.py tests/test_model_decision_surface.py tests/test_chatgpt_auth_tui.py tests/test_provider_auth_methods.py tests/test_workspace_folders.py tests/test_workspace_folders_tui.py` → **66 passed in 50.57 s**. +- Task 7 profile: targeted Markdown render CPU reduced by at least 97% against the pre-change baseline (see `2026-10-18-isycode-stream-profile.md`). +- Full local suite `python3 -m pytest -q -m "not integration"`: + - Plain run: 2044 passed, 151 skipped, 11 deselected; 2 failures in subprocess-import tests (`test_native_cli_stdio`, `test_provider_metadata_native`) caused by running without an editable install — both pass immediately with `PYTHONPATH=src`, which matches CI's `pip install -e '.[test]'`. + - Re-run with `PYTHONPATH=src`: **2045 passed**, 151 skipped, 11 deselected, 1 failure: `tests/test_file_preview_tui.py::test_copy_path_for_selected_directory`. That test passes standalone (2.00 s) and as a whole file (2 passed, 3.66 s); it failed only under a loaded full-suite run and is recorded as a timing flake, not a regression of this change (cause `NOT_DEMONSTRATED`). + - Log: `.superpowers/sdd/2026-10-18-isycode-tui-stability/task-8-full-suite-pythonpath.log`, sha256 `e1781961e1c46036e8f6ddff02ed9b1a73ee45edf77b709604bd383ea05666fb`. +- Platform CI on PR [#53](https://github.com/DannyBaanks/ISyCode/pull/53) (head `3d5fed5`), `pull_request` run [39114476126](https://github.com/DannyBaanks/ISyCode/actions/runs/39114476126): **Tests (Linux)** passed ([job 117637334718](https://github.com/DannyBaanks/ISyCode/actions/runs/39114476126/job/117637334718)), **Tests (Linux, Textual 8)** passed ([job 117637334893](https://github.com/DannyBaanks/ISyCode/actions/runs/39114476126/job/117637334893)), **Gate evidence** passed ([job 117637334999](https://github.com/DannyBaanks/ISyCode/actions/runs/39114476126/job/117637334999)), **Tests (Windows)** passed with `1857 passed, 339 skipped, 11 deselected, 1 xfailed in 1111.56s` ([job 117637335051](https://github.com/DannyBaanks/ISyCode/actions/runs/39114476126/job/117637335051)); package jobs linux-x86_64, windows-x86_64 and macos-arm64 passed; the draft release job is skipped by design on a branch. +- Parallel `push` run [39114441006](https://github.com/DannyBaanks/ISyCode/actions/runs/39114441006) passed the same jobs, and the native platform contracts workflow run [39114475979](https://github.com/DannyBaanks/ISyCode/actions/runs/39114475979) passed on `ubuntu-latest` and `windows-latest`. +- `tests/test_chatgpt_auth_tui.py` and `tests/test_ux_polish_and_resume.py` carry no platform skip guards, so their parametrized cases ran inside the green Windows suite; the historical failure's cause remains `NOT_DEMONSTRATED`, but the current head passes Windows. +- Flake observations on head `688d826` (docs-only change; every comparison below is within the same commit): + - `pull_request` run [39116023304](https://github.com/DannyBaanks/ISyCode/actions/runs/39116023304) failed once on **Tests (Linux, Textual 8)** with `textual.pilot.WaitForScreenTimeout` in `tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume`; the parallel `push` run [39116020134](https://github.com/DannyBaanks/ISyCode/actions/runs/39116020134) passed the same job, and the failed job's rerun also passed. This matches Task 1's intermittent observation; the cause stays `NOT_DEMONSTRATED`. + - `push` run 39116020134 (**Tests (Windows)**): the suite reported `1 failed, 1766 passed, 294 skipped, 11 deselected, 1 xfailed in 3562.27s` with `tests/test_visual_tui.py::test_long_workspace_header_keeps_status_and_path_end` ("workspace banner did not fit after sidebar layout", 10 s deadline) on a runner that took 59:22 for the suite; the job then hit its 60-minute timeout and GitHub marked it cancelled. The same commit's `pull_request` Windows job passed the full suite in 18:31 (`1857 passed`). Local log: `task-8-windows-timeout-job.log`, sha256 `2e380bf16d6e717c71c82e7f95d6be419649bbdc968836ac5e1131a3baff99f5`. + - Both are load/timing flakes on slow or unlucky runners; neither reproduces in the parallel green job on the same commit, and no product change is justified by them. + +## Release recommendation + +Ship after human review and merge: local evidence plus PR #53 platform CI (Linux 3.10, Linux/Textual 8, Windows, gate evidence, and packages) are green on head `3d5fed5`. The historical Windows failure keeps its cause `NOT_DEMONSTRATED`, but it does not fail the current head. This evidence commit re-runs CI; its result is visible in the PR checks. Gmail Hive remains deferred and is not part of this delivery. diff --git a/docs/superpowers/results/2026-10-18-isycode-stream-profile.md b/docs/superpowers/results/2026-10-18-isycode-stream-profile.md new file mode 100644 index 0000000..9fcd268 --- /dev/null +++ b/docs/superpowers/results/2026-10-18-isycode-stream-profile.md @@ -0,0 +1,56 @@ +# ISyCode streaming render profile — 2026-10-18 + +Offline measurement for stability Task 7 (content and thinking render cost). No network traffic and no provider credentials are involved: the harness replaces `isycode.tui.provider_complete` with a synthetic provider inside `TUIApp.run_test()`. + +## Environment and commands + +- Host: Linux `DannyISyCo 7.0.0-38-generic`, Ubuntu 24.04 x86_64 +- Python 3.12.3, pytest 9.1.1, Textual 8.2.8 +- Harness: `scripts/profile_tui_streaming.py` (sha256 `95bc3edc6d675d36f65566f01aff5d4d7c65715c2e6ede575e53c08b8e6883c6`), defaults `--runs 5 --payload-chars 16384 --chunk-chars 16 128` +- Exact command: `python3 scripts/profile_tui_streaming.py` +- Local raw logs (gitignored evidence directory `.superpowers/sdd/2026-10-18-isycode-tui-stability/`): + - pre-change baseline `task-7-profile-raw.json`, sha256 `15bb9ae26cbb53b70d55986599e9ac84e2f6e3852ffbddc835013737dcca8bc4` + - post-change `task-7-profile-after.json`, sha256 `933f9c6d443b6f239a672e0427b7bcfac7991e202af060949152e1521b37c5b6` + - independent re-run of the post-change tree `task-7-profile-verify.json`, sha256 `4baa0913502e337099b44a731f17aae53e603668552ef26ac24bc03cf7b985a7` + +## Baseline and threshold decision + +Medians over 5 samples per case, 16,384 payload characters: + +| mode | chunk | wall median | CPU median | Markdown CPU | Markdown calls | thinking CPU / calls | +|---|---|---|---|---|---|---| +| content | 16 | 1.196 s | 1.155 s | 0.997 s (86.3% of CPU) | 1025 | 0 / 0 | +| content | 128 | 0.225 s | 0.179 s | 0.126 s (70.6% of CPU) | 129 | 0 / 0 | +| reasoning | 16 | 0.090 s | 0.059 s | 0 | 0 | 0.00075 s / 5 (1.3% of CPU) | +| reasoning | 128 | 0.099 s | 0.057 s | 0 | 0 | 0.00077 s / 5 (1.4% of CPU) | + +The plan's materiality threshold (≥20% of measured CPU in repeated renderer work) is met by content Markdown construction; the thinking preview remains around 1.3% of CPU, so `ThoughtBlock` was left unchanged. + +## Change (measured bottleneck only) + +`src/isycode/tui_app_chat.py` (sha256 `e5514120868df3edf21cc5b793af9c48fbd6692144c73ccab782895fc7083367`; commit `fbdb849`): coalesce content repaints at `CONTENT_PAINT_INTERVAL_S = 1/30`, skip unchanged content, and force a final flush on successful finish, transport failure, cancellation, and lane switch. Full streamed source text, selectable content, `_history` records, transport, provider chunking, and the Idea Box are untouched. The lane-switch guard also stops writing into the detached widget and continues painting into the new lane widget. + +Boundary tests (`tests/test_chat_sequence.py`, sha256 `0e5e34ed6da2d631cb6dd9641fd366a3ef8a15f2ae685d2b2f72aaeaf2402a7c`): + +- burst of 96 chunks: Markdown constructions stay under half the chunk count (observed 3), and the full answer is byte-exact, selectable, and recorded exactly in `_history`; +- failure after automatic retries are exhausted, and cancellation: the latest partial text stays visible and selectable, history stays empty, and `_interrupted_turn.partial_text` preserves the final attempt without mixing discarded retries; +- lane switch: content keeps painting in the new lane widget and the reasoning block keeps its exact full text. + +Exact command: `python3 -m pytest -q tests/test_chat_sequence.py tests/test_ux_polish_and_resume.py::test_area_f_interrupted_turn_capture_and_resume` → **15 passed in 16.72 s**. + +## Result (same harness, same host) + +| mode | chunk | wall median | CPU median | Markdown CPU | Markdown calls | +|---|---|---|---|---|---| +| content | 16 | 0.104 s | 0.056 s | 0.0026 s | 3 | +| content | 128 | 0.235 s | 0.059 s | 0.0030 s | 3 | +| reasoning | 16 | 0.109 s | 0.060 s | 0 | 0 | +| reasoning | 128 | 0.323 s | 0.077 s | 0 | 0 | + +Delta on the targeted renderer: Markdown CPU −99.7% (16-char chunks) and −97.7% (128-char); total content CPU −95.1% and −67.2%. The independent re-run reproduces 3 Markdown constructions per sample in both chunk sizes (0.0026 s Markdown CPU). The ≥20% target is met. + +## Limitations + +- Synthetic payloads of 16,384 characters; live provider phrasing was not measured. +- `wall` includes Textual event-loop pacing and host load, so wall medians vary between runs; CPU medians are the criterion. +- Windows/macOS hosts were not measured; platform CI evidence remains outstanding (`NOT_DEMONSTRATED`) because this branch is not pushed to `origin`. diff --git a/docs/superpowers/specs/2026-10-18-isycode-stability-and-gmail-hive-design.md b/docs/superpowers/specs/2026-10-18-isycode-stability-and-gmail-hive-design.md new file mode 100644 index 0000000..15a1c67 --- /dev/null +++ b/docs/superpowers/specs/2026-10-18-isycode-stability-and-gmail-hive-design.md @@ -0,0 +1,137 @@ +# ISyCode Stability and Gmail Hive — Design Spec + +**Date:** 2026-10-18 +**Status:** Approved for Delivery A implementation; Delivery B deferred +**Design approval:** The user approved “stability first, then Gmail Hive,” then approved adding the three reported chat/OAuth UX issues to Delivery A on 2026-10-18. +**Baseline checkout:** `main` at `7038d0dee866f6e3237f336ebd73f0532c1a2bf7` (verified against `origin/main` during this design pass). + +## 1. Purpose + +Define two reviewable deliveries for ISyCode: + +1. Reduce known reliability and responsiveness risks in the current TUI, validating each change with repeatable evidence. +2. Add an opt-in Gmail “Hive” input that can surface selected email metadata as visible, human-reviewed events in ISyCode. + +The second delivery depends on the first. This document specifies goals, boundaries, and acceptance criteria. It is not an implementation plan; the user reviews this spec before a milestone plan is written. + +## 2. Current-state evidence and uncertainty + +The following are observations from repository/CI inspection. Historical measurements are labeled as such; they are not claims about a fresh test run on this branch. + +- **[CURRENT, code inspected]** The Idea Box is a short visible status note. Its approximately 150-second nudge asks the model to refresh that note during an active chat/tool lane; it does not poll Gmail or another remote inbox. Relevant implementation: `src/isycode/idea_box.py`, `src/isycode/tui_app_chat.py`, and `src/isycode/tui_app_sessions.py`. +- **[CURRENT, code inspected]** ISyCode has a local bounded event store (`src/isycode/agent_events.py`) and a polling cursor with interval/backoff for local agent events (`src/isycode/agent_polling.py`). These are useful primitives, but they are not a remote Gmail synchronizer. +- **[CURRENT, code inspected]** Gateway MCP invocation is mediated by Authority/Sentinel and approval owners. A background sync must not be smuggled in as an unrestricted MCP tool call or use the Idea Box timer as hidden permission. +- **[CURRENT, sibling code inspected]** The ISyCo monorepo Gateway has Gmail list/search/get/read and send operations. The Drive Mirror OAuth path requests read and send scopes. It does not currently expose a Gmail History cursor contract. The separate `ISyCo Drive Mirror/v0.9-opencode-plugin` folder is Drive-focused; it is not proof that a deployed Gmail MCP exists. +- **[NOT DEMONSTRATED]** Live Gateway deployment, Gmail History synchronization, and a production OAuth flow for this feature have not been demonstrated. Integration feasibility from source code is not proof of a live service. +- **[HISTORICAL, previous audit]** A full local pytest run recorded 2,040 passed, 160 skipped, and one failure in `test_area_f_interrupted_turn_capture_and_resume`; that test passed when rerun alone. This indicates possible load sensitivity, not a confirmed deterministic defect. +- **[HISTORICAL, CI]** Windows CI failed in `test_native_subscription_login_and_cancel_never_save_challenge[True]` with a PermissionError while reading the Sentinel journal lock during a TUI test. A background startup scan racing test teardown is a plausible cause, but remains an inference until reproduced and isolated. +- **[NOT DEMONSTRATED]** Rebuilding `RichMarkdown` from cumulative streamed content may contribute to long-response slowdown. It requires profiling before any optimization. +- **[USER-REPORTED, code-path cause identified; regression test pending]** After a completed assistant response, `lane.stream_widget` can still reference that response. A later `_content_line()` may reuse that mounted widget and replace the prior answer. The fix must preserve prior turn widgets while allowing only the active turn's stream widget to update. +- **[USER-REPORTED, implementation gap under investigation]** After ChatGPT subscription OAuth, the model selector should let the user choose a model actually available to the signed-in Codex account. Do not guess models or switch away from `auto` unless the user chooses. +- **[USER-REPORTED, code gap identified]** The subscription-login screen displays the raw long OAuth URL as plain static text, so the terminal does not expose an actionable link. Make the official URL clearly clickable while preserving host validation, cancellation, and secret-handling behavior. +- **[USER-REPORTED, reproduced from screenshot and code]** The native sibling-folder dialog accepts a display alias such as `Github`, but the store accepts only lowercase aliases and rejects the registration. Canonicalize casing at the store boundary, keep case-insensitive duplicates from registering twice, and preserve the exact sibling path and existing per-folder grants. +- `docs/ROADMAP.md` still identifies its static audit as 2026-10-09 / commit `49cb0a1`; its status text should not be treated as a fresh audit of the 2026-10-18 baseline. +- The prior workflow-gate validation reported historical evidence/approval mismatches. This work must not rewrite evidence, manufacture approvals, or mark gates complete without fresh proof. + +## 3. Delivery A — Stability first + +### Goal + +Turn the observed reliability risks into reproducible findings, fix only verified causes, and preserve current safety behavior. + +### Scope + +- Reproduce the Windows Sentinel-journal CI failure with a focused test and determine whether test teardown races a still-running startup worker. +- Make a minimal lifecycle/synchronization change only if the race is reproduced; add a regression test that proves the worker has completed before the journal is inspected or that teardown waits safely. +- Reproduce the reported multi-turn chat behavior with consecutive user/assistant turns, including a tool round if needed. Preserve prior user and assistant messages in the visible transcript and conversation history; distinguish actual replacement from normal scroll-offscreen behavior. +- Make the ChatGPT subscription model selector usable after OAuth. It must source choices from the authenticated provider/account model catalog (or explicitly report that the account did not provide one), retain `auto` as the default, and never select a model silently. +- Make the official ChatGPT sign-in URL actionable in the OAuth modal, including at 80×24 and when displayed across wrapped terminal rows. Keep URL validation restricted to the official auth hosts and do not append the challenge URL or one-use code to chat history or persisted state. +- Make native sibling-folder registration accept natural mixed-case aliases by canonicalizing them to lowercase, reject case-only duplicates, and retain the current direct-sibling path checks and exact folder-scoped Authority grants. +- Rerun the focused test and relevant TUI/auth tests on Windows CI, then the required local suite. Record exact commands, commit, results, and any platform gaps. +- Profile streaming response rendering with a representative long response. Optimize only if measurements show a material bottleneck; preserve streaming order, visible thinking behavior, and message history. +- Refresh roadmap audit metadata only after the relevant state has actually been re-audited. Do not change milestone acceptance status as a side effect. +- Report the workflow-gate findings separately. Repair a gate only when the underlying contract or evidence is wrong and the change is supported by new evidence. + +### Acceptance criteria + +1. The prior Windows failure is either reproduced with a clear root cause and fixed with a regression test, or documented as not reproduced with the exact CI/local evidence and next diagnostic boundary. +2. A completed assistant response remains unchanged after later user turns and assistant/tool output; the regression test covers the observed consecutive-turn sequence. If only scroll position explains the screenshot, record that separately instead of claiming content loss. +3. After ChatGPT OAuth, the model control opens a selector that reflects the signed-in account's available model catalog; `auto` remains selected until the user chooses a concrete model. +4. The sign-in URL can be activated from the OAuth screen at supported terminal sizes, opens only an approved official auth URL, and the private one-use code/URL stays out of chat history and persistent state. +5. A mixed-case sibling alias such as `Github` registers canonically as `github`; case-only duplicates are rejected and permissions remain scoped to the selected folder. +6. Relevant tests pass on the supported CI platform(s); no assertion is weakened to hide the failure. +7. Any rendering optimization has before/after profiling evidence and does not regress stream correctness or TUI behavior. If no material bottleneck is measured, no optimization is made. +8. No historical evidence, approvals, or gate records are rewritten to create a pass. +9. The stability change is reviewable independently from Gmail Hive. + +## 4. Delivery B — Gmail Hive, after stability + +### User-visible behavior + +Gmail Hive is an optional, foreground feature that can monitor one user-selected Gmail label while the ISyCode TUI is open. It surfaces concise metadata events in a visible queue. The user chooses which event to process. Processing creates a normal user-visible task/context item; it does not authorize tools or send mail. + +The Idea Box remains a status display. It may show a concise “checking / N new items” status only while the explicitly enabled Hive session is active, but it is not the scheduler, cursor, queue, or authority source. + +### Sync and event contract + +1. The user explicitly connects Gmail, selects a label, and enables monitoring. The feature is **off by default**. +2. The connector reads message metadata only: stable message ID, thread ID, selected labels, sender, subject, and timestamp. It does not fetch message bodies or attachments in the initial version. +3. The Gateway owns Gmail API access. ISyCode uses a narrow read-only integration contract; it does not store OAuth refresh tokens in workspace files or expose them to a model. +4. The Gateway provides incremental Gmail History synchronization with a durable cursor. ISyCode stores only the minimum local cursor and deduplication/event state required for restart-safe display. +5. Every candidate event is checked against the configured label ID in the application layer. Gmail query text alone is not an authorization boundary. +6. Events enter the existing local event-store pattern as untrusted input, are sanitized and deduplicated, and appear in an explicit pending-events view. +7. The user must select **Process** before content is added to the chat/task. **Dismiss** and **Pause** are available. Merely receiving or displaying mail never invokes a tool. +8. Any later tool action still goes through the existing workspace authority, ISySentinel, and one-use approval flows. An email cannot grant permissions, modify allowlists, or bypass policy. +9. Polling occurs only while the user has enabled the feature and the TUI is running. It uses a bounded cadence with backoff and visible last-sync/error status. No hidden daemon, Gmail push notification, or background service is part of V1. +10. If Gmail reports an expired history cursor (including 404), the connector performs a bounded resynchronization for the selected label, deduplicates by message ID, then persists the new cursor. The UI must show that a resync occurred. +11. Authentication revocation, permission errors, rate limits, network errors, cursor expiry, duplicate delivery, and app restart are explicit states. Failures pause or back off; they do not silently skip events or invoke model actions. +12. Stored state is minimal and private. Message body, attachment content, and full email archive are out of scope. + +### Permissions and privacy + +- Use the narrowest Google scope that supports the chosen metadata contract; the initial design target is `gmail.metadata`, not `gmail.readonly` or `gmail.send`. +- Google classifies Gmail metadata/read scopes as restricted. OAuth verification and any applicable security-assessment obligations must be reviewed before distribution. The design does not claim these requirements are waived. +- The Gateway service permission should be a distinct read-only capability such as `isyco.email.metadata.read`; it must not inherit `email.send`. +- The feature must disclose the selected label, fields read, polling state, local retention, and how to disconnect/purge its own state. +- Gmail content is untrusted data. Email text, sender names, and headers cannot become system/developer instructions or change tool policy. + +### Explicit non-goals for V1 + +- Sending, replying, forwarding, drafting, labeling, archiving, deleting, or marking mail read. +- Reading bodies, attachments, or arbitrary mailbox contents. +- Watching all inbox mail by default. +- Running while ISyCode is closed, hidden, or in a daemon/service. +- Gmail Pub/Sub push, browser automation, or using the user's Chrome session. +- Treating an email as a command, tool grant, approval, or trusted agent message. +- Replacing ISyCode's existing Authority/Sentinel or local event-store contracts. + +### Acceptance criteria + +1. Disabled-by-default behavior is proven; no Gmail request occurs until the user connects and explicitly enables the selected label. +2. The selected label, metadata field allowlist, and read-only Gateway permission are enforced in code and covered by tests. +3. Incremental History sync survives restart, deduplicates repeated events, and visibly recovers from an expired cursor without losing the user’s pending queue. +4. No message body, attachment, send operation, or model/tool invocation occurs during polling. +5. Pending items require an explicit user action to enter chat context; an email cannot change Authority, Sentinel, or approval state. +6. Tests cover revoked auth, 403/429, network timeout, duplicate events, cursor expiry, pause/resume, and restart using offline fixtures. +7. A live test, if later approved, uses a dedicated test label/account and records exactly which metadata was read. It is not a prerequisite for writing this spec or plan. +8. OAuth verification and data-handling obligations are documented before enabling this for general users. + +## 5. Shared design decisions + +- Treat Gmail as an external source of untrusted events, not a source of authority. +- Keep transport (Gateway), persistence (local cursor/event store), presentation (visible queue), and user authorization as separate responsibilities. +- Do not add a second polling framework if the existing event-store/polling patterns can be extended safely. Confirm that with a plan and tests first. +- Any failure to meet a safety or data-retention contract blocks release of Gmail Hive, even if the demo appears to work. +- Each delivery has its own tests, evidence, review, and release decision. + +## 6. Review boundary + +Delivery A's product behavior and scope are approved for implementation by the user. Gmail Hive will get its own implementation plan only after Delivery A has passed its review gate. This approval does not authorize Gmail OAuth scopes, live Gmail account access, or external service writes. + +## 7. References + +- ISyCode local event store and poller: `src/isycode/agent_events.py`, `src/isycode/agent_polling.py`. +- ISyCode Idea Box and TUI integration: `src/isycode/idea_box.py`, `src/isycode/tui_app_chat.py`, `src/isycode/tui_app_sessions.py`. +- Google, [Choose Gmail API scopes](https://developers.google.com/workspace/gmail/api/auth/scopes). +- Google, [Gmail API `users.history.list`](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.history/list). +- Historical project roadmap: `docs/ROADMAP.md` (its audit date is stale against this baseline and is not used as current proof). diff --git a/scripts/profile_tui_streaming.py b/scripts/profile_tui_streaming.py new file mode 100644 index 0000000..d602330 --- /dev/null +++ b/scripts/profile_tui_streaming.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Measure offline TUI rendering cost for synthetic content/reasoning streams.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import platform +import statistics +import sys +import tempfile +import time +from contextlib import ExitStack +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +import textual +from rich.markdown import Markdown as RichMarkdown + +from isycode.tui import TUIApp, ThoughtBlock +from isycode.user_defaults import UserDefaultsStore +from isycode.workspace_authority import WorkspaceAuthority +from isycode.workspace_trust import WorkspaceTrust + + +def arguments(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--runs", type=int, default=5, + help="samples for each mode and chunk size (default: 5)") + parser.add_argument("--payload-chars", type=int, default=16_384, + help="characters per sample, maximum 65536 (default: 16384)") + parser.add_argument("--chunk-chars", type=int, nargs="+", default=[16, 128], + help="stream chunk sizes (default: 16 128)") + args = parser.parse_args(argv) + if args.runs < 1 or not 1 <= args.payload_chars <= 65_536: + parser.error("--runs must be positive and --payload-chars must be 1..65536") + if not args.chunk_chars or any(size < 1 for size in args.chunk_chars): + parser.error("--chunk-chars values must be positive") + return args + + +def _median(records: list[dict], key: str) -> float: + return statistics.median(record[key] for record in records) + + +async def _profile_case(mode: str, chunk_chars: int, runs: int, payload_chars: int, + counters: dict[str, float | int]) -> list[dict]: + samples = [] + with tempfile.TemporaryDirectory(prefix="isycode-stream-profile-") as temporary: + root = Path(temporary) + project = root / "project" + project.mkdir() + env = { + "ISYCODE_STATE_HOME": str(root / "state"), + "XDG_STATE_HOME": str(root / "xdg-state"), + "XDG_CONFIG_HOME": str(root / "xdg-config"), + "ISYCODE_PROVIDER": "openai", + "ISYCODE_MODEL": "gpt-6-luna", + "OPENAI_API_KEY": "profile-only-not-a-real-key", + } + + async def no_external_catalog(self): + return None + + async def complete(_provider, _messages, **kwargs): + counters["provider_calls"] += 1 + remaining = payload_chars + while remaining: + size = min(chunk_chars, remaining) + chunk = ("A" * size) if mode == "content" else ("thought " * ((size + 7) // 8))[:size] + kwargs["on_chunk"]("content" if mode == "content" else "reasoning", chunk) + counters["chunks"] += 1 + remaining -= size + return {"text": "A" * payload_chars if mode == "content" else "", + "tool_calls": []} + + original_unmount = TUIApp.on_unmount + + async def test_unmount(self, event): + await original_unmount(self, event) + asyncio.get_running_loop().call_later(0.5, lambda: None) + + with ExitStack() as stack: + stack.enter_context(patch.dict(os.environ, env)) + stack.enter_context(patch("isycode.providers.Provider.models", + lambda provider: [provider.model])) + stack.enter_context(patch.object(TUIApp, "_refresh_openisy", no_external_catalog)) + stack.enter_context(patch.object(TUIApp, "_check_gateway_async", no_external_catalog)) + stack.enter_context(patch.object(TUIApp, "_check_model", no_external_catalog)) + stack.enter_context(patch.object(TUIApp, "on_unmount", test_unmount)) + stack.enter_context(patch("isycode.tui.provider_complete", complete)) + previous_cwd = Path.cwd() + os.chdir(project) + try: + UserDefaultsStore().update(new_workspace="temporary", + new_workspace_mode="classic", locale="en") + authority = WorkspaceAuthority(project) + authority.set_mode("classic") + WorkspaceTrust().decline(authority) + app = TUIApp() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + for sample in range(runs): + before_counts = {key: counters[key] for key in counters} + wall_start = time.perf_counter() + cpu_start = time.process_time() + await app._run_chat(f"Synthetic {mode} sample {sample + 1}.") + await pilot.pause(0.02) + samples.append({ + "wall_s": time.perf_counter() - wall_start, + "cpu_s": time.process_time() - cpu_start, + "markdown_cpu_s": counters["markdown_cpu_s"] - before_counts["markdown_cpu_s"], + "markdown_calls": counters["markdown_calls"] - before_counts["markdown_calls"], + "thought_cpu_s": counters["thought_cpu_s"] - before_counts["thought_cpu_s"], + "thought_calls": counters["thought_calls"] - before_counts["thought_calls"], + "provider_calls": counters["provider_calls"] - before_counts["provider_calls"], + "chunks": counters["chunks"] - before_counts["chunks"], + }) + finally: + os.chdir(previous_cwd) + return samples + + +async def _run(args: argparse.Namespace) -> dict: + counters: dict[str, float | int] = { + "markdown_cpu_s": 0.0, "markdown_calls": 0, + "thought_cpu_s": 0.0, "thought_calls": 0, + "provider_calls": 0, "chunks": 0, + } + + def measured_markdown(*args, **kwargs): + started = time.process_time() + try: + return RichMarkdown(*args, **kwargs) + finally: + counters["markdown_cpu_s"] += time.process_time() - started + counters["markdown_calls"] += 1 + + original_paint = ThoughtBlock._paint_preview + + def measured_paint(widget, *args, **kwargs): + started = time.process_time() + try: + return original_paint(widget, *args, **kwargs) + finally: + counters["thought_cpu_s"] += time.process_time() - started + counters["thought_calls"] += 1 + + results = [] + with ExitStack() as stack: + stack.enter_context(patch("isycode.tui_app_chat.RichMarkdown", measured_markdown)) + stack.enter_context(patch.object(ThoughtBlock, "_paint_preview", measured_paint)) + for chunk_chars in args.chunk_chars: + for mode in ("content", "reasoning"): + samples = await _profile_case(mode, chunk_chars, args.runs, + args.payload_chars, counters) + results.append({ + "mode": mode, + "chunk_chars": chunk_chars, + "runs": len(samples), + "samples": samples, + "summary": { + "wall_s_median": _median(samples, "wall_s"), + "wall_s_min": min(sample["wall_s"] for sample in samples), + "wall_s_max": max(sample["wall_s"] for sample in samples), + "cpu_s_median": _median(samples, "cpu_s"), + "cpu_s_min": min(sample["cpu_s"] for sample in samples), + "cpu_s_max": max(sample["cpu_s"] for sample in samples), + "markdown_cpu_s_median": _median(samples, "markdown_cpu_s"), + "markdown_calls_median": _median(samples, "markdown_calls"), + "thought_cpu_s_median": _median(samples, "thought_cpu_s"), + "thought_calls_median": _median(samples, "thought_calls"), + }, + }) + return { + "python": sys.version, + "textual": textual.__version__, + "platform": platform.platform(), + "payload_chars": args.payload_chars, + "runs_per_case": args.runs, + "chunk_sizes": args.chunk_chars, + "network": "disabled; synthetic provider only", + "cases": results, + } + + +def main(argv: list[str] | None = None) -> int: + args = arguments(argv) + print(json.dumps(asyncio.run(_run(args)), indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/isycode/file_preview.py b/src/isycode/file_preview.py index d6500f8..08791fd 100644 --- a/src/isycode/file_preview.py +++ b/src/isycode/file_preview.py @@ -19,8 +19,6 @@ def guess_syntax_language(path: str | Path | None, code: str | None = None) -> s name_mapping = { "dockerfile": "bash", "containerfile": "bash", - "makefile": "bash", - "gnumakefile": "bash", "procfile": "bash", ".gitattributes": "bash", ".gitignore": "bash", @@ -36,8 +34,7 @@ def guess_syntax_language(path: str | Path | None, code: str | None = None) -> s return name_mapping[name] if (name.startswith(".env") or name.startswith("dockerfile") or name.endswith(".dockerfile") or name.startswith("containerfile") - or name.endswith(".containerfile") or name.startswith("makefile") - or name.endswith(".mk")): + or name.endswith(".containerfile")): return "bash" ext_mapping = { diff --git a/src/isycode/provider_auth_screens.py b/src/isycode/provider_auth_screens.py index 106f336..f76593d 100644 --- a/src/isycode/provider_auth_screens.py +++ b/src/isycode/provider_auth_screens.py @@ -1,38 +1,79 @@ """Login challenge UI: process-only, never appended to conversation history.""" import asyncio +from urllib.parse import urlsplit + from isycode.localization import tr +from isycode.provider_auth import AUTH_HOSTS from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Vertical, VerticalScroll +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen from textual.widgets import Button, Static + +def _approved_login_url(value: object) -> str | None: + """Return only official HTTPS sign-in URLs suitable for browser opening.""" + if not isinstance(value, str) or not value or len(value) > 4096: + return None + try: + parsed = urlsplit(value) + if (parsed.scheme != 'https' or parsed.hostname not in AUTH_HOSTS + or parsed.username is not None or parsed.password is not None + or parsed.port not in (None, 443) or '\\' in value + or any(char.isspace() for char in value)): + return None + except ValueError: + return None + return value + + class SubscriptionLoginScreen(ModalScreen[None]): CSS = ''' SubscriptionLoginScreen { align: center middle; background: #000000 65%; } #subscription-card { width: 90; max-width: 96%; height: 90%; padding: 0 1; border: solid #718291; background: #000000; } #subscription-copy { height: 1fr; } #subscription-copy Static { height: auto; margin-bottom: 1; } - #subscription-cancel { dock: bottom; width: 100%; height: 3; } + #subscription-actions { dock: bottom; height: 3; align-horizontal: right; } + #subscription-actions Button { width: auto; min-width: 18; margin-left: 1; } + #subscription-open-link { min-width: 28; } ''' BINDINGS = [Binding('escape','cancel','Cancel')] def __init__(self, challenge): super().__init__() self.challenge=challenge + self.login_url = _approved_login_url(challenge.get('url')) self.ready=asyncio.Event() def compose(self) -> ComposeResult: with Vertical(id='subscription-card', classes='isy-dialog'): yield Static(tr('Sign in with ChatGPT'), classes='isy-dialog-title', markup=False) with VerticalScroll(id='subscription-copy', classes='isy-dialog-body'): yield Static(tr('Open this official link in your browser. Browser login requires the browser on the same computer as ISyCode; use device code for remote/headless machines.'),markup=False) - yield Static(self.challenge['url'],markup=False) + if self.login_url: + yield Static(self.login_url, id='subscription-url', markup=False) + else: + yield Static(tr('Sign-in link failed safety validation; it will not be opened.'), + id='subscription-url', markup=False) + yield Static('', id='subscription-open-status', markup=False) if self.challenge.get('user_code'): yield Static(tr('Enter this one-use code: ')+self.challenge['user_code'],markup=False) yield Static(tr('Waiting for sign-in… Your plan determines available models and usage limits. This does not add API credits.'),markup=False) - yield Button(tr('Cancel sign-in'),id='subscription-cancel') + with Horizontal(id='subscription-actions'): + yield Button(tr('Open sign-in in browser'), id='subscription-open-link', + disabled=self.login_url is None) + yield Button(tr('Cancel sign-in'),id='subscription-cancel') def on_mount(self): self.query_one('#subscription-cancel',Button).focus() self.ready.set() def on_button_pressed(self,event): - event.stop();self.dismiss(None) + event.stop() + if event.button.id == 'subscription-open-link': + if self.login_url: + try: + self.app.open_url(self.login_url, new_tab=True) + except Exception: + self.query_one('#subscription-open-status', Static).update( + tr('Could not open the browser. Copy the official URL and open it manually.')) + return + if event.button.id == 'subscription-cancel': + self.dismiss(None) def action_cancel(self): self.dismiss(None) diff --git a/src/isycode/tui.py b/src/isycode/tui.py index 918629c..45a0cf8 100644 --- a/src/isycode/tui.py +++ b/src/isycode/tui.py @@ -844,7 +844,7 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: elif button_id == "providers-button": self._open_provider_menu() elif button_id == "model-button": - self._open_provider_menu() + self._open_scoped_models(selected_provider_name()) elif button_id == "role-button": self._open_role_menu() elif button_id == "settings-button": diff --git a/src/isycode/tui_app_chat.py b/src/isycode/tui_app_chat.py index 752d398..0cb1ce8 100644 --- a/src/isycode/tui_app_chat.py +++ b/src/isycode/tui_app_chat.py @@ -93,6 +93,8 @@ def _recovery_label(kind: str) -> str: from isycode.tui_widgets import ThoughtBlock from textual.widgets import TextArea +CONTENT_PAINT_INTERVAL_S = 1 / 30 + class ChatMixin: async def _summarize_older(self, provider, owner, older: list[dict], @@ -544,6 +546,8 @@ async def _consume_chat_stream() -> None: holder: dict = {"widget": None} assistant_sent_at: str | None = None prompt_persisted = False + last_content_paint_at = 0.0 + last_painted_content: str | None = None thought_started = _time.monotonic() provider_name = selected_provider_name() @@ -567,8 +571,8 @@ async def _consume_chat_stream() -> None: else: messages = self._image_attachments.prepare(messages, provider.name, provider.model) - def _content_line() -> None: - nonlocal assistant_sent_at + def _content_line(*, force: bool = False) -> None: + nonlocal assistant_sent_at, last_content_paint_at, last_painted_content if not step_content: return lane = self._active_lane() @@ -578,11 +582,24 @@ def _content_line() -> None: lane.stream_widget = None holder["widget"] = None return + now = _time.monotonic() + if not force and now - last_content_paint_at < CONTENT_PAINT_INTERVAL_S: + return + if content == last_painted_content: + return + last_content_paint_at = now + last_painted_content = content chat_now = self.query_one(ChatArea) w = holder["widget"] + if w is not None and w.parent is not chat_now: + # Repainting a lane replaces its widgets while the stream task + # continues in the background. Do not keep writing into the + # detached widget captured before the lane switch. + w = None + holder["widget"] = None if w is None: live = lane.stream_widget - if live is not None: + if live is not None and live.parent is chat_now: w = live holder["widget"] = w if w is None: @@ -712,6 +729,8 @@ async def send_provider_request(): holder["widget"] = None step_content.clear() step_reason.clear() + last_content_paint_at = 0.0 + last_painted_content = None content_mark, reason_mark = len(content_buf), len(reason_buf) if self._pending_steering: steer_trial = {"messages": list(messages), "history": list(self._history), "instructions": list(self._pending_steering)} @@ -797,7 +816,10 @@ async def send_provider_request(): await wait_fixed(decision.delay_s) continue if recovery_failures: - discard_failed_step(content_mark, reason_mark) + # Keep the final attempt's partial output available to the + # interrupted-turn handler below. It stays uncommitted and + # is surfaced as partial text for /resume; earlier retry + # attempts were already removed before their replacement. self._append( f" ⚠ Recovery stopped after {recovery_failures} automatic " f"attempt{'s' if recovery_failures != 1 else ''} · " @@ -853,6 +875,7 @@ async def send_provider_request(): self._append("Steering accepted · continuing the turn.", CYAN) if not step_content and isinstance(response.get("text"), str): on_chunk("content", response["text"]) + _content_line(force=True) finish_step() calls = response.get("tool_calls", []) if not calls: @@ -928,7 +951,7 @@ async def send_provider_request(): }) except asyncio.CancelledError: if content_buf: - _content_line() + _content_line(force=True) self._append( " Response stopped. This partial answer was not added to chat history.", YELLOW) @@ -958,7 +981,7 @@ async def send_provider_request(): self._retry_prompt = text if content_buf or reason_buf: if content_buf: - _content_line() + _content_line(force=True) self._append( f" Stream interrupted ({_transport_reason(exc)}). Use /resume to continue without repeating completed tools.", YELLOW) @@ -982,7 +1005,7 @@ async def send_provider_request(): "nothing was executed.") content_buf[:] = [full] step_content[:] = [full] - _content_line() + _content_line(force=True) elif attempted_tool: full = ( f"ISyCode did not run this `{attempted_tool}` request: chat has no " @@ -992,7 +1015,7 @@ async def send_provider_request(): ) content_buf[:] = [full] step_content[:] = [full] - _content_line() + _content_line(force=True) if full: if not prompt_persisted: self._persist_chat_message("user", text, sent_at=self._pending_user_sent_at) @@ -1003,6 +1026,7 @@ async def send_provider_request(): done_lane.partial = "" done_lane.partial_reason = "" done_lane.partial_reason_chunks = [] + done_lane.stream_widget = None if step_reason: done_lane.lines.append(("reasoning", "".join(step_reason), _time.monotonic() - thought_started)) diff --git a/src/isycode/tui_app_providers.py b/src/isycode/tui_app_providers.py index c049f6b..47db302 100644 --- a/src/isycode/tui_app_providers.py +++ b/src/isycode/tui_app_providers.py @@ -97,13 +97,15 @@ def _open_provider_menu(self) -> None: current = key == active or (key == "openai" and active == "chatgpt") mark = "*" if current else " " note = tr(" · Current") if current else "" - default_model = (provider_default_model("chatgpt") or PRESETS["chatgpt"].get("default_model") or DEFAULT_MODEL) if (key == "openai" and active == "chatgpt") else (provider_default_model(key) or preset.get("default_model") or DEFAULT_MODEL) + display_provider = "chatgpt" if key == "openai" and active == "chatgpt" else key + display_preset = PRESETS.get(display_provider, preset) + catalog_default = provider_default_model(display_provider) or display_preset.get("default_model") or DEFAULT_MODEL entries.append(self._entry( tr("{mark} {label}{note}", mark=mark, label=label, note=note), "provider", key, tr("{blurb}. {status}. Default model: {default}", blurb=tr(blurb), status=status, - default=default_model), + default=catalog_default), translate=False)) entries[-1]["display_name"] = label else: diff --git a/src/isycode/tui_widgets.py b/src/isycode/tui_widgets.py index a751c46..c4048b4 100644 --- a/src/isycode/tui_widgets.py +++ b/src/isycode/tui_widgets.py @@ -47,12 +47,11 @@ def activate_on_second_click(host, event, key) -> bool: """Return whether this click should open a row that is already selected. - The first click only arms that row. Textual reports a double-click only - when the second press lands on the exact same cell, so a one-cell jitter - stays at chain 1 and used to do nothing. A later click on the same row - opens it anyway. + The first click only arms that row. Textual click chains can also span + different widgets, so event.chain alone must never activate a newly + selected row. A later click on that same row opens it. """ - if getattr(event, "chain", 0) >= 2 or getattr(host, "_activate_key", None) == key: + if getattr(host, "_activate_key", None) == key: host._activate_key = None return True host._activate_key = key @@ -327,8 +326,6 @@ async def _on_click(self, event: Click) -> None: if node is not None: if activate_on_second_click(self, event, line): await self._activate_node(node) - else: - self.select_node(node) async def action_select_cursor(self) -> None: if self.cursor_node is not None: diff --git a/src/isycode/workspace_folders.py b/src/isycode/workspace_folders.py index 3e2edc3..dccd61e 100644 --- a/src/isycode/workspace_folders.py +++ b/src/isycode/workspace_folders.py @@ -161,6 +161,10 @@ def binding(self, alias: str = 'main') -> tuple: def add(self, alias: str, path: str, *, editable: bool) -> None: data = self._load() + # Folder aliases are identifiers passed through model tools. Canonicalize + # human-entered casing so labels like "Github" remain valid identifiers. + if isinstance(alias, str): + alias = alias.lower() if (not isinstance(alias, str) or not ALIAS.fullmatch(alias) or alias == 'main' or type(editable) is not bool or not isinstance(path, str) or len(path) > 4096): raise ValueError('Use an alias like other-project and an absolute sibling folder path') diff --git a/tests/test_chat_sequence.py b/tests/test_chat_sequence.py index da79f9c..932951e 100644 --- a/tests/test_chat_sequence.py +++ b/tests/test_chat_sequence.py @@ -1,11 +1,13 @@ from isycode.tui import plain_text import asyncio import json +import pytest from textual.widgets import Static from isycode.tui import ChatArea, TUIApp, ThoughtBlock from isycode.conversation_lane import ConversationLane +from isycode.tui_widgets import SelectableText from test_daily_tui import configure @@ -245,6 +247,53 @@ async def scenario(): asyncio.run(scenario()) +def test_completed_answers_remain_separate_after_later_user_turns(tmp_path, monkeypatch, capsys): + configure(tmp_path, monkeypatch) + answers = ["Answer alpha.", "Answer beta.", "Answer gamma."] + requests = [] + + async def complete(provider, messages, **kwargs): + prompt = messages[-1]["content"] + requests.append(prompt) + answer = answers[len(requests) - 1] + kwargs["on_chunk"]("content", answer) + return {"text": answer, "tool_calls": []} + + monkeypatch.setattr("isycode.tui.provider_complete", complete) + + async def scenario(): + app = TUIApp() + async with app.run_test(size=(100, 35)) as pilot: + await pilot.pause() + prompt = app.query_one("#prompt-input") + questions = ["Question alpha?", "Question beta?", "Question gamma?"] + for question in questions: + prompt.load_text(question) + app._accept_prompt(prompt, prompt.text) + for _ in range(100): + await pilot.pause(0.02) + if app._foreground_lane().loop_task is None: + break + assert app._foreground_lane().loop_task is None + + visible = [widget for widget in app.query_one(ChatArea).query(SelectableText)] + user_text = [widget.selection_text for widget in visible if widget.has_class("user-turn")] + assistant_text = [widget.selection_text for widget in visible + if not widget.has_class("user-turn") + and widget.selection_text in answers] + assert user_text == questions + assert assistant_text == answers + assert requests == user_text + assert [(item["role"], item["content"]) for item in app._history] == [ + ("user", "Question alpha?"), ("assistant", answers[0]), + ("user", "Question beta?"), ("assistant", answers[1]), + ("user", "Question gamma?"), ("assistant", answers[2]), + ] + + with capsys.disabled(): + asyncio.run(scenario()) + + def test_reasoning_stream_batches_expensive_thought_paints(tmp_path, monkeypatch, capsys): configure(tmp_path, monkeypatch) chunks = [f"reasoning-{index:03d} " + ("detail " * 40) for index in range(80)] @@ -288,11 +337,12 @@ def test_reasoning_stream_reuses_block_after_switching_lanes(tmp_path, monkeypat async def complete(provider, messages, **kwargs): kwargs["on_chunk"]("reasoning", "First thought. ") + kwargs["on_chunk"]("content", "First content. ") first_chunk.set() await continue_stream.wait() kwargs["on_chunk"]("reasoning", "Second thought.") - kwargs["on_chunk"]("content", "Answer.") - return {"text": "Answer.", "tool_calls": []} + kwargs["on_chunk"]("content", "Second content.") + return {"text": "First content. Second content.", "tool_calls": []} monkeypatch.setattr("isycode.tui.provider_complete", complete) @@ -318,6 +368,96 @@ async def scenario(): assert len(blocks) == 1 assert blocks[0]._full_text == "First thought. Second thought." assert blocks[0].collapsed + content = [widget.selection_text for widget in app.query_one(ChatArea).query(SelectableText)] + assert "First content. Second content." in content + + with capsys.disabled(): + asyncio.run(scenario()) + + +def test_content_burst_coalesces_markdown_paints_and_flushes_exact_answer(tmp_path, monkeypatch, capsys): + configure(tmp_path, monkeypatch) + chunks = [f"chunk-{index:03d} " + ("sample text " * 8) for index in range(96)] + answer = "".join(chunks) + + async def complete(provider, messages, **kwargs): + for chunk in chunks: + kwargs["on_chunk"]("content", chunk) + return {"text": answer, "tool_calls": []} + + monkeypatch.setattr("isycode.tui.provider_complete", complete) + from isycode.tui_app_chat import RichMarkdown as OriginalMarkdown + markdown_calls = 0 + + def count_markdown(*args, **kwargs): + nonlocal markdown_calls + markdown_calls += 1 + return OriginalMarkdown(*args, **kwargs) + + monkeypatch.setattr("isycode.tui_app_chat.RichMarkdown", count_markdown) + + async def scenario(): + app = TUIApp() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + await app._run_chat("Render this burst.") + await pilot.pause() + rendered = [widget.selection_text for widget in app.query_one(ChatArea).query(SelectableText)] + assert answer in rendered + assert app._history[-1]["content"] == answer.strip() + assert markdown_calls < len(chunks) // 2 + + with capsys.disabled(): + asyncio.run(scenario()) + + +@pytest.mark.parametrize("ending", ["failure", "cancel"]) +def test_content_flushes_latest_partial_on_failure_and_cancel(tmp_path, monkeypatch, capsys, ending): + configure(tmp_path, monkeypatch) + started = asyncio.Event() + partial = "Latest partial text, fully selectable." + attempts = 0 + + async def complete(provider, messages, **kwargs): + if ending == "cancel": + kwargs["on_chunk"]("content", partial[:12]) + kwargs["on_chunk"]("content", partial[12:]) + started.set() + await asyncio.Event().wait() + nonlocal attempts + attempts += 1 + latest = partial if attempts == 4 else f"Discarded retry {attempts}." + kwargs["on_chunk"]("content", latest[:12]) + kwargs["on_chunk"]("content", latest[12:]) + from isycode.streaming import StreamError + raise StreamError("synthetic stream failure") + + monkeypatch.setattr("isycode.tui.provider_complete", complete) + + async def no_retry_delay(_delay): + return None + + monkeypatch.setattr("isycode.continuity_recovery.wait_fixed", no_retry_delay) + + async def scenario(): + app = TUIApp() + async with app.run_test(size=(100, 30)) as pilot: + await pilot.pause() + task = asyncio.create_task(app._run_chat("Keep the partial text.")) + if ending == "cancel": + await asyncio.wait_for(started.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + await task + await pilot.pause() + if ending == "failure": + assert attempts == 4 + assert app._interrupted_turn.partial_text == partial + rendered = [widget.selection_text for widget in app.query_one(ChatArea).query(SelectableText)] + assert partial in rendered + assert app._history == [] with capsys.disabled(): asyncio.run(scenario()) diff --git a/tests/test_chatgpt_auth_tui.py b/tests/test_chatgpt_auth_tui.py index 9da0835..23a4665 100644 --- a/tests/test_chatgpt_auth_tui.py +++ b/tests/test_chatgpt_auth_tui.py @@ -131,3 +131,53 @@ async def run(): with capsys.disabled(): asyncio.run(run()) + + +def test_subscription_login_opens_valid_wrapped_url_without_closing_screen(tmp_path, monkeypatch, capsys): + configure(tmp_path, monkeypatch) + url = 'https://auth.openai.com/oauth/authorize?' + 'scope=' + ('long-value-' * 24) + opened = [] + monkeypatch.setattr(TUIApp, 'open_url', lambda self, value, **kwargs: opened.append((value, kwargs))) + + async def run(): + from isycode.provider_auth_screens import SubscriptionLoginScreen + app = TUIApp() + async with app.run_test(size=(80, 24)) as pilot: + screen = SubscriptionLoginScreen({'url': url, 'user_code': 'PRIVATE-CODE'}) + await app.push_screen(screen) + await pilot.pause() + button = screen.query_one('#subscription-open-link') + assert button.region.bottom <= 24 + assert not button.disabled + assert url in str(screen.query_one('#subscription-url').render()) + await pilot.click('#subscription-open-link') + assert opened == [(url, {'new_tab': True})] + assert app.screen is screen # opening the browser does not cancel polling + await pilot.click('#subscription-cancel') + assert app.screen is not screen + assert all('PRIVATE-CODE' not in item['content'] for item in app._history) + + with capsys.disabled(): + asyncio.run(run()) + + +def test_subscription_login_disables_unapproved_browser_destinations(tmp_path, monkeypatch, capsys): + configure(tmp_path, monkeypatch) + opened = [] + monkeypatch.setattr(TUIApp, 'open_url', lambda self, value, **kwargs: opened.append(value)) + + async def run(): + from isycode.provider_auth_screens import SubscriptionLoginScreen + app = TUIApp() + async with app.run_test(size=(80, 24)) as pilot: + screen = SubscriptionLoginScreen({'url': 'https://evil.example/login'}) + await app.push_screen(screen) + await pilot.pause() + button = screen.query_one('#subscription-open-link') + assert button.disabled + assert 'failed safety validation' in str(screen.query_one('#subscription-url').render()).lower() + await pilot.click('#subscription-open-link') + assert opened == [] + + with capsys.disabled(): + asyncio.run(run()) diff --git a/tests/test_file_preview_syntax.py b/tests/test_file_preview_syntax.py index 8d53581..4b33053 100644 --- a/tests/test_file_preview_syntax.py +++ b/tests/test_file_preview_syntax.py @@ -8,7 +8,8 @@ def test_guess_syntax_language_basenames(): assert guess_syntax_language(Path(".gitignore")) == "bash" assert guess_syntax_language(Path("Dockerfile")) == "bash" assert guess_syntax_language(Path("Dockerfile.dev")) == "bash" - assert guess_syntax_language(Path("Makefile")) == "bash" + assert guess_syntax_language(Path("Makefile")) is None + assert guess_syntax_language(Path("rules.mk")) is None assert guess_syntax_language(Path("compose.yaml")) == "yaml" def test_guess_syntax_language_extensions(): @@ -27,7 +28,8 @@ def test_guess_syntax_language_shebang(): assert guess_syntax_language(Path("unknown_file"), code="plain text without shebang") is None def test_user_defaults_sidebar_file_preview(tmp_path): - store = UserDefaultsStore(directory=tmp_path) + # Let WindowsStorage create a private child with its own restrictive ACL. + store = UserDefaultsStore(directory=tmp_path / "private-defaults") defaults = store.load() assert defaults.get("sidebar_file_preview") is False diff --git a/tests/test_m9_adversarial.py b/tests/test_m9_adversarial.py index 2715309..3d46abd 100644 --- a/tests/test_m9_adversarial.py +++ b/tests/test_m9_adversarial.py @@ -88,6 +88,8 @@ def test_a_hostile_file_shown_in_the_preview_cannot_drive_the_terminal(tmp_path, async def scenario(app, pilot): app._set_rail_view("files") + # Inline preview is an opt-in preference; display it to test sanitization. + app._apply_sidebar_file_preview(True) await app._preview_file(str(app._workspace_root / "evil.txt")) await pilot.pause() out = terminal_bytes(app) diff --git a/tests/test_model_button.py b/tests/test_model_button.py index 3eded15..331bfd4 100644 --- a/tests/test_model_button.py +++ b/tests/test_model_button.py @@ -1,11 +1,8 @@ -"""M-UX3.1: current model shown inline in the composer command bar. - -The button is a pure presentation shortcut: it opens the existing provider -selector and never changes provider, model, or grants by itself. -""" +"""The composer model control opens the active provider's explicit picker.""" import pytest from isycode.tui import TUIApp +from isycode.providers import Provider, resolved_chat_model, selected_provider_name @pytest.mark.asyncio @@ -20,14 +17,82 @@ async def test_model_button_visible_with_current_model(): @pytest.mark.asyncio -async def test_model_button_opens_provider_selector(tmp_path, monkeypatch): +async def test_model_button_opens_chatgpt_account_model_picker(tmp_path, monkeypatch): from test_daily_tui import configure - configure(tmp_path, monkeypatch) + original_models = Provider.models + root = configure(tmp_path, monkeypatch) + from isycode.workspace_authority import WorkspaceAuthority + from isycode.tui import ModelsScreen + + executable = tmp_path / "codex" + executable.write_text("#!/bin/sh\nexit 0\n") + executable.chmod(0o700) + monkeypatch.setenv("ISYCODE_CODEX_EXECUTABLE", str(executable)) + monkeypatch.setenv("ISYCODE_PROVIDER", "chatgpt") # post-OAuth session + monkeypatch.setenv("ISYCODE_MODEL", "auto") + monkeypatch.setattr(Provider, "models", original_models) + authority = WorkspaceAuthority(root) + from isycode.provider_auth import AUTH_HOSTS + authority.set_grant( + "provider.authenticate", enabled=True, targets=["chatgpt"], + executables=[str(executable)], network_hosts=list(AUTH_HOSTS)) + authority.set_grant( + "provider.request", enabled=True, + network_hosts=["chatgpt.com", *AUTH_HOSTS]) + + catalog_calls = [] + + class AccountConnector: + def __init__(self, *args, **kwargs): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return None + + async def models(self): + catalog_calls.append("chatgpt") + return ["fixture-chat-model"] + + monkeypatch.setattr("isycode.codex_connector.CodexConnector", AccountConnector) + + async def wait_for_picker(app, pilot): + for _ in range(200): + await pilot.pause(0.05) + if (isinstance(app.screen, ModelsScreen) + and any(entry.get("value") == "chatgpt|fixture-chat-model" + for entry in app.screen.entries)): + return app.screen + raise AssertionError(("ChatGPT account model picker did not load", + catalog_calls, getattr(app, "_account_models_attempted", set()), + getattr(app, "_account_model_status", ""))) + app = TUIApp() async with app.run_test(size=(100, 30)) as pilot: await pilot.click("#model-button") - await pilot.pause() - assert app._menu_mode == "providers" + assert isinstance(app.screen, ModelsScreen) + screen = await wait_for_picker(app, pilot) + assert screen.provider_scope == "chatgpt" + assert any(entry.get("value") == "chatgpt|fixture-chat-model" + for entry in screen.entries), { + "calls": catalog_calls, "status": app._account_model_status, + "rows": [(entry["kind"], entry["value"]) + for entry in screen.entries], + } + assert selected_provider_name() == "chatgpt" + assert resolved_chat_model("chatgpt") == "auto" + + choice = next(button for button in screen.query(".model-choice") + if screen.choices[button.id].get("value") == "chatgpt|fixture-chat-model") + choice.focus() + await pilot.press("enter") + for _ in range(100): + await pilot.pause(0.02) + if resolved_chat_model("chatgpt") == "fixture-chat-model": + break + assert resolved_chat_model("chatgpt") == "fixture-chat-model" @pytest.mark.asyncio diff --git a/tests/test_model_decision_surface.py b/tests/test_model_decision_surface.py index 0ca47eb..f9c922c 100644 --- a/tests/test_model_decision_surface.py +++ b/tests/test_model_decision_surface.py @@ -88,7 +88,10 @@ def provider_entry(app, name): async def open_nvidia(app, pilot): - await pilot.click("#model-button") + await pilot.click("#settings-button") + assert await until(pilot, lambda: app._menu_mode == "settings") + app._select_menu_entry(next(entry for entry in app._menu_entries + if entry["kind"] == "providers_open")) assert await until(pilot, lambda: app._menu_mode == "providers") app._select_menu_entry(provider_entry(app, "nvidia")) # The picker is rebuilt when the account catalog arrives; wait until the same diff --git a/tests/test_workspace_folders.py b/tests/test_workspace_folders.py index f0d2386..f2da3a3 100644 --- a/tests/test_workspace_folders.py +++ b/tests/test_workspace_folders.py @@ -104,6 +104,18 @@ def test_aliases_duplicates_and_limits_are_validated(tmp_path, monkeypatch): with pytest.raises(ValueError): store.add('extra', str(extra), editable=True) + +def test_alias_casing_is_normalized_and_case_insensitive_for_duplicates(tmp_path, monkeypatch): + store, _, other = make_store(tmp_path, monkeypatch) + store.add('Github', str(other), editable=False) + assert store.list()[0]['alias'] == 'github' + assert store.resolve('github') == other + + second = other.parent / 'Another Project' + second.mkdir() + with pytest.raises(ValueError, match='already registered'): + store.add('GITHUB', str(second), editable=False) + @pytest.mark.parametrize('name', ['.ssh', '.git', 'state']) def test_sensitive_roots_and_internal_state_cannot_be_attached(tmp_path, monkeypatch, name): store, _, _ = make_store(tmp_path, monkeypatch) diff --git a/tests/test_workspace_folders_tui.py b/tests/test_workspace_folders_tui.py index a4cfdb2..a39ed07 100644 --- a/tests/test_workspace_folders_tui.py +++ b/tests/test_workspace_folders_tui.py @@ -225,15 +225,17 @@ async def scenario(): if button.region.width and button.region.height and not button.disabled: break assert isinstance(app.screen, AddWorkspaceFolderScreen) - app.screen.query_one('#folder-alias', Input).value = 'sibling' + # A human-readable provider name should be canonicalized to the + # lowercase alias format instead of silently failing to attach. + app.screen.query_one('#folder-alias', Input).value = 'Github' button = app.screen.query_one('#folder-add') assert button.region.bottom <= 24 await pilot.pause(.1) assert await pilot.click('#folder-add') await asyncio.wait_for(task, 10) - assert store.resolve('sibling') == sibling + assert store.resolve('github') == sibling app._close_menu() - task = asyncio.create_task(app._dispatch_chat_tool(call('workspace_edit', folder='sibling', + task = asyncio.create_task(app._dispatch_chat_tool(call('workspace_edit', folder='github', path='file.py', old_text='value = 1', new_text='value = 2'))) for _ in range(200): await pilot.pause(.05) @@ -253,13 +255,13 @@ async def scenario(): await pilot.click('#auto-edit-enable') _, result = await asyncio.wait_for(task, 10) assert json.loads(result)['approval_mode'] == 'delegated' - assert store.auto_edit_allowed('sibling') + assert store.auto_edit_allowed('github') assert not store.auto_edit_allowed('main') - _, result = await app._dispatch_chat_tool(call('workspace_edit', folder='sibling', + _, result = await app._dispatch_chat_tool(call('workspace_edit', folder='github', path='file.py', old_text='value = 2', new_text='value = 3')) assert json.loads(result)['status'] == 'written' assert (sibling / 'file.py').read_text() == 'value = 3\n' - await app._set_folder_auto_edit('sibling', False) + await app._set_folder_auto_edit('github', False) with capsys.disabled(): asyncio.run(scenario())