diff --git a/AGENTS.md b/AGENTS.md index e7729216..c1fb0c44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ - Anything rendered by the composer must be styled from an always-loaded stylesheet: `architect.css` (and other lazily imported CSS) only arrives with its module, which is how the Team button once changed size on first click. - Native: `workspace::run_process` is the one bounded runner for external tools (`git`, `gh`); user text goes to `gh` as `--flag=value`. - Turn speed and tokens: the fixed app instructions (sidebar titles, `pandacode-run.sh`, change tracking; `app_instructions()` in `lib.rs`) go to Claude-family harnesses once as `--append-system-prompt`, which caches with the prefix; other harnesses get them in the message on a new conversation and every 8th turn. Only a one-line current-title note rides each message. After a successful Claude turn, `providers/warm.rs` starts the next turn's process in the background (at most 2, 10 idle minutes); a follow-up adopts it only when model, mode, effort, folder, resume ID, instructions and tool catalog all match (the MCP server is rebound to the new turn at the same address), so it skips the CLI's startup. Anything else starts cold. -- Harnesses: adding a provider means touching every `"kimi" | "opencode" | "antigravity"`-style match in `providers.rs`/`acp.rs`, the allowlists in `storage.rs`, `overlay.rs`, `architect*.rs`, `terminal.rs`, `slash_commands*.rs`, `provider_settings.rs`, plus `ProviderId` and its `Record` maps on the frontend (tsc flags the maps). ACP harnesses share `acp.rs`; provider quirks (Grok's `authenticate` step, plan-mode policy, `_meta.totalTokens` usage) live behind `config.provider` checks there. Record real frames in the tests, and keep an ignored live smoke test per installed CLI. Key-based Claude Code relays (GLM, MiMo) each get a `providers/.rs` with `environment()/arguments()/configure()/models()`, a `Credential` in `provider_settings.rs` (own secure-store account + env var), and an entry in the `keyed` table of `src/provider-settings.ts`, which renders the card's key form and status. Provider environment variables ([docs/providers.md](docs/providers.md#environment-variables)): a global set plus per-provider lists in `provider-settings.json` (`provider_settings.rs`; names-only IPC, plain values, connection's `valid_env_name` rules) are merged by `environment()` (global unless the provider opted out, then its own) and applied by `providers::provider_command`/`apply_environment` at every harness spawn — `run_inner`, warm agents (whose key carries `environment_fingerprint`, so an edit starts cold), `terminal.rs`'s harness console, and the catalog/discovery/usage/integrations probes; saving invalidates that provider's usage, catalog and slash-command caches. Cloud runs inherit nothing by default: `cloud_agents/transport.rs::inherited_environment` adds the harness's saved variables only when `cloud::Settings.providers[]["inheritEnvironment"] == "true"` (`cloud_set_inherit_environment`, the checkbox in `src/cloud-settings.ts`), refuses loopback proxy values at start, chains them before credentials so credentials win, and mirror tasks on other computers always use that computer's own settings. Schema is at `user_version` 36 (version 36 added machine orchestration in one step: `machine_threads` with `reported_by`, the wake message that reported a thread, `sessions.restart_interrupted`, set when startup interrupts a running task (mirrors keep their status), and `sessions.stop_requested`, the user's Stop saved before the run is signalled so a quit right after it never counts as a restart interruption; version 35 added `task_schedules`, `task_schedule_runs` and `scheduler_settings` for task schedules; version 34 added `task_usage_links`, every harness session id a task has run under, so per-task usage totals can be summed from the agents' own logs; version 33 added `messages.remote_cut`; version 32 `sessions.remote_backfill_base`; version 31 `remote_cursors`; version 30 added `message_attachments`, the images a user message carried: captures copied to `/attachments//`, moodboard images linked by id; served to phones by `task.attachment`; version 29 added `bots.skills`/`bots.mcp`, a bot's skill and MCP references, and `bot_tasks`, the bot a project task started with; version 28 added `bots.soul`, a bot's optional personality; version 27 added `sessions.cloud` for cloud agents; version 26 added the mirror columns `sessions.remote_device`, `remote_session` and `remote_synced_id`; the bot tables in `bots::SCHEMA`; version 24 added `sessions.limited_at`, `limited_until` and `resume_at_reset`; version 23 `queued_messages`, the durable per-task queue in `message_queue.rs`; version 22 added `sessions.draft`, set for a New task until its first saved prompt publishes it; version 21 added `sessions.pinned` and `sessions.sidebar_order`, version 20 `sessions.title_origin`, version 19 `sessions.last_prompt_at`). Drafts are excluded from `list_sessions`; empty ones are deleted on leave (`discard_draft`) and at startup. Adding a migration step means bumping `storage::SCHEMA_VERSION` (the outer guard reads it; a test checks it matches the newest `PRAGMA user_version=`) and updating the tests that assert the number. +- Harnesses: adding a provider means touching every `"kimi" | "opencode" | "antigravity"`-style match in `providers.rs`/`acp.rs`, the allowlists in `storage.rs`, `overlay.rs`, `architect*.rs`, `terminal.rs`, `slash_commands*.rs`, `provider_settings.rs`, plus `ProviderId` and its `Record` maps on the frontend (tsc flags the maps). ACP harnesses share `acp.rs`; provider quirks (Grok's `authenticate` step, plan-mode policy, `_meta.totalTokens` usage) live behind `config.provider` checks there. Record real frames in the tests, and keep an ignored live smoke test per installed CLI. Key-based Claude Code relays (GLM, MiMo) each get a `providers/.rs` with `environment()/arguments()/configure()/models()`, a `Credential` in `provider_settings.rs` (own secure-store account + env var), and an entry in the `keyed` table of `src/provider-settings.ts`, which renders the card's key form and status. Provider environment variables ([docs/providers.md](docs/providers.md#environment-variables)): a global set plus per-provider lists in `provider-settings.json` (`provider_settings.rs`; names-only IPC, plain values, connection's `valid_env_name` rules) are merged by `environment()` (global unless the provider opted out, then its own) and applied by `providers::provider_command`/`apply_environment` at every harness spawn — `run_inner`, warm agents (whose key carries `environment_fingerprint`, so an edit starts cold), `terminal.rs`'s harness console, and the catalog/discovery/usage/integrations probes; saving invalidates that provider's usage, catalog and slash-command caches. Cloud runs inherit nothing by default: `cloud_agents/transport.rs::inherited_environment` adds the harness's saved variables only when `cloud::Settings.providers[]["inheritEnvironment"] == "true"` (`cloud_set_inherit_environment`, the checkbox in `src/cloud-settings.ts`), refuses loopback proxy values at start, chains them before credentials so credentials win, and mirror tasks on other computers always use that computer's own settings. Schema is at `user_version` 37 (version 37 added `sessions.auto_writes`, the per-task Auto-approve writes toggle that pre-answers write-class approval requests in Standard and Accept edits modes (Standard only for Codex); version 36 added machine orchestration in one step: `machine_threads` with `reported_by`, the wake message that reported a thread, `sessions.restart_interrupted`, set when startup interrupts a running task (mirrors keep their status), and `sessions.stop_requested`, the user's Stop saved before the run is signalled so a quit right after it never counts as a restart interruption; version 35 added `task_schedules`, `task_schedule_runs` and `scheduler_settings` for task schedules; version 34 added `task_usage_links`, every harness session id a task has run under, so per-task usage totals can be summed from the agents' own logs; version 33 added `messages.remote_cut`; version 32 `sessions.remote_backfill_base`; version 31 `remote_cursors`; version 30 added `message_attachments`, the images a user message carried: captures copied to `/attachments//`, moodboard images linked by id; served to phones by `task.attachment`; version 29 added `bots.skills`/`bots.mcp`, a bot's skill and MCP references, and `bot_tasks`, the bot a project task started with; version 28 added `bots.soul`, a bot's optional personality; version 27 added `sessions.cloud` for cloud agents; version 26 added the mirror columns `sessions.remote_device`, `remote_session` and `remote_synced_id`; the bot tables in `bots::SCHEMA`; version 24 added `sessions.limited_at`, `limited_until` and `resume_at_reset`; version 23 `queued_messages`, the durable per-task queue in `message_queue.rs`; version 22 added `sessions.draft`, set for a New task until its first saved prompt publishes it; version 21 added `sessions.pinned` and `sessions.sidebar_order`, version 20 `sessions.title_origin`, version 19 `sessions.last_prompt_at`). Drafts are excluded from `list_sessions`; empty ones are deleted on leave (`discard_draft`) and at startup. Adding a migration step means bumping `storage::SCHEMA_VERSION` (the outer guard reads it; a test checks it matches the newest `PRAGMA user_version=`) and updating the tests that assert the number. - Devin CLI (`providers/devin.rs`, docs/providers.md#devin-cli) runs `devin acp` with `ACP_BACKEND` removed (Devin Desktop terminals set it, and then the server ignores the CLI sign-in); PandaCode never calls `authenticate`. Each model has its own `thought_level`, reported only after switching to it, so discovery switches every non-Fusion model across parallel empty sessions (about 2.5 s; Fusion switches take ~1 s each and are listed without efforts) and deletes them, as command discovery does, so Devin's history stays clean. Modes: Standard/Accept edits → `accept-edits`, Plan → `plan` (its `exit_plan_mode` request is the "Implement this plan?" approval, answered `plan_accept_edits`), Full → `bypass`; approvals only ever pick `allow_once`. Unchanged mode/model/effort are not resent on resumed sessions; steering is live. Live tests: `cargo test --lib installed_devin -- --ignored` (one sends two short prompts and deletes its session). - ZCode (`providers/zcode.rs`, docs/providers.md#zcode) has no ACP mode; PandaCode speaks z.ai's private `zcode app-server` protocol (NDJSON stdio, `session/create|subscribe|send`, events on `session/event`, permissions as `interaction/requestPermission` reverse requests answered with the offered `response` object). One server per run, deferred persistence, resume by saved `sess_`; model/effort are set and verified against the returned snapshot. Modes: Standard → `build`, Accept edits → `edit`, Plan → `plan`, Full → `yolo`; steering holds for the next turn; screenshots and interactive questions are refused with a note; slash-command discovery and the harness console are not offered. Protocol shapes are pinned per recorded version (`.agents/state/zcode-harness/protocol.md`); live test `cargo test --lib installed_zcode_lists_models -- --ignored` (catalog only, never a prompt). - Queue and steering: while an agent runs, Enter queues (`queue_*` commands, shown by `src/message-queue.ts` above the composer) and ⌘/Ctrl Enter steers (`steer_task` → `RunInput::Steer`, `providers/steering.rs`). Steering never stops a run: Claude-family harnesses steer on stream input (launched with `--replay-user-messages` so a late steer keeps the run open for its turn), Codex uses `turn/steer`, Pi `steer`, OpenCode a second `session/prompt` (`acp.rs`); anything a turn cannot take (startup, between turns, refused, other ACP agents) is held by the run and sent as the next turn of the same session (`mode: "next-turn"`). `steer` agent events report `pending`/`delivered`/`failed` by id; the composer shows the pending state and requeues a failed steer. A clean run end delivers the next entry; a failure, stop or restart holds the queue until Resume. The prompt stays enabled during runs, so `renderComposer` must keep focus and the caret. The wider roadmap is in `docs/parity-plan.md`. diff --git a/docs/remote-access.md b/docs/remote-access.md index f75a287f..a9fad6b6 100644 --- a/docs/remote-access.md +++ b/docs/remote-access.md @@ -260,7 +260,7 @@ prevents accidents; it is not a boundary against a device that can also answer a | `projects.icons` | `{ projectIds: string[] }` (1..=50; use project ids from `tasks.list`) | `{ icons: { [projectId]: string \| null } }`. Known non-bot local projects map to their icon `data:` URL, or `null` when no icon exists or it exceeds 64 KiB. Unknown ids are omitted. Icons stop being added before the result exceeds 180 KiB, so omitted ids can be requested again. | | `providers.list` | `{ force?: boolean }` | the host's `list_providers` result: `[{ id, name, installed, path, version, models }]`. `force` drops the same ACP catalog caches. If it would not fit one reply, the longest `models` lists are halved until it does. | | `providers.models` | `{ provider, force?: boolean }` | the host's `list_provider_models` result: `[{ id, name, description, reasoningEfforts, defaultReasoningEffort, contextWindow }]`, its tail dropped to fit one reply. `provider` must be a known harness id (`Unknown provider.`). | -| `task.start` | `{ repo?: "owner/name", projectId?: string, branch?, worktree, prompt, provider, model, mode?, effort?, startId? }` (exactly one of `repo` or `projectId`) | `{ sessionId }`. A repeated `startId` from the same device runs nothing new: it waits for (or returns) the first start's result. | +| `task.start` | `{ repo?: "owner/name", projectId?: string, branch?, worktree, prompt, provider, model, mode?, autoWrites?, effort?, startId? }` (exactly one of `repo` or `projectId`) | `{ sessionId }`. A repeated `startId` from the same device runs nothing new: it waits for (or returns) the first start's result. `autoWrites` sets the task's Auto-approve writes toggle. | | `task.startStatus` | `{ startId }` | `{ state: "running" \| "done" \| "failed" \| "unknown", sessionId?, error? }`. Finished starts are remembered for an hour. | | `task.cancelStart` | `{ startId }` | `{ cancelled: true }` once a running start was told to stop (its callers get `Start cancelled.` at once, the clone's process group is killed); `{ cancelled: false, sessionId }` when it had already made its task. | | `task.messages` | `{ sessionId, afterId?, tail?, beforeId?, limit?, budget? }` (`limit` is 1..400; defaults to 100 for `tail`/`beforeId`, 400 for `afterId`; `budget` is the page's JSON bytes, see [Message copies](#message-copies)) | `{ messages: [{ id, role, content, createdAt, turnDurationMs, attachments }], hasMore, hasOlder, title, lastId }`. `attachments` is always an array; saved images include `{ id, kind: "image", name, mime, bytes, width, height }`, while document and folder context markers carry `{ id, kind, name }`. `tail: true` returns the newest page; `beforeId` returns the page immediately before that message id. Both are oldest-first. Use only one of `afterId`, `tail: true` or `beforeId`; mixing them is refused. `hasMore` means newer rows follow this page, `hasOlder` means older rows precede its first message, and `lastId` is the newest message id (or `null` for an empty thread). | @@ -272,7 +272,7 @@ prevents accidents; it is not a boundary against a device that can also answer a | `task.send` | `{ sessionId, text }` | `{ queued: boolean }`. Queued if the task is running, like Enter. If the run ended between the check and the queue add, the host takes the entry back and sends it. | | `task.stop` | `{ sessionId }` | `{}`. For a task whose team is running or waiting this is the desktop Stop button (`architect_control` with `stop`): the team is paused, unsettled workers are marked stopped, and the architect's and workers' runs are stopped, also between architect turns when no agent of the task itself runs. Otherwise it stops the task's run (`stop_session`). | | `task.steer` | `{ sessionId, text }` | `{}` (as `steer_task`) | -| `task.configure` | `{ sessionId, provider?, model?, effort?, mode? }` | `{}`. `provider` and `model` together (a provider not installed there is refused as in `task.start`); `effort: null` clears it; `mode` changes permissions. | +| `task.configure` | `{ sessionId, provider?, model?, effort?, mode?, autoWrites? }` | `{}`. `provider` and `model` together (a provider not installed there is refused as in `task.start`); `effort: null` clears it; `mode` changes permissions; `autoWrites` sets the Auto-approve writes toggle. | | `task.pull` | `{ sessionId }` | `{ pull: PullDetail \| null }`. `PullDetail` is `{ number, title, body, bodyTruncated, state, url, author, base, head, additions, deletions, changedFiles, createdAt, updatedAt, mergedAt, mergeable, reviewDecision, labels: string[], checks: [{ name, state, url }], files: [{ path, additions, deletions }], commits: [{ oid, headline, author, date }], timeline: [{ kind, author, body, state, createdAt }], truncated }`. `truncated` is true if Connect trimmed the detail's body, timeline, files or other fields (or the GitHub body was already truncated); the whole result fits under 180 KiB. | | `task.pullDiff` | `{ sessionId, path? }` | Without `path`, `{ files: [{ path, additions, deletions }], truncated }` with the tail dropped to fit 180 KiB. With `path`, `{ patch, truncated }` for that file; patches are cut at 120 KiB. | | `task.pullMerge` | `{ sessionId, method: "merge" \| "squash" \| "rebase" }` | `{}`. Uses the same merge operation and refusals as the desktop merge button; an unlinked pull request is refused. | diff --git a/playwright.config.ts b/playwright.config.ts index a1a8f0bf..e4a3fe5f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,7 +13,7 @@ export const perfSpecs = process.env.PERF === '1' ? ['**/diff-perf.spec.ts'] : [ export default defineConfig({ testDir: './tests', - testMatch: ['**/external-links.spec.ts', '**/remove-project.spec.ts', '**/run-project.spec.ts', '**/harness-privacy.spec.ts', '**/terminal.spec.ts', '**/thread-order.spec.ts', '**/sidebar-sections.spec.ts', '**/thread-panels.spec.ts', '**/agent-updates.spec.ts', '**/thread-deletion.spec.ts', '**/agent-usage.spec.ts', '**/grok.spec.ts', '**/compact-menus.spec.ts', '**/attachments.spec.ts', '**/sketch.spec.ts', '**/worktrees.spec.ts', '**/github.spec.ts', '**/github-hub.spec.ts', '**/remote.spec.ts', '**/simulator.spec.ts', '**/colors.spec.ts', '**/token-saver.spec.ts', '**/support.spec.ts', '**/jev-live.spec.ts', '**/jev-assist.spec.ts', '**/jev.spec.ts', '**/message-actions.spec.ts', '**/opencode-models.spec.ts', '**/overlay.spec.ts', '**/moodboards.spec.ts', '**/provider-settings.spec.ts', '**/ui.spec.ts', '**/browser.spec.ts', '**/board.spec.ts', '**/streaming.spec.ts', '**/provider-handoff.spec.ts', '**/concurrency.spec.ts', '**/subagents.spec.ts', '**/architect.spec.ts', '**/team-profiles.spec.ts', '**/agent-teams.spec.ts', '**/vm.spec.ts', '**/deploy.spec.ts', '**/turn-changes.spec.ts', '**/wallpapers.spec.ts', '**/wallpaper-settings.spec.ts', '**/settings.spec.ts', '**/layout-preferences.spec.ts', '**/agent-input.spec.ts', '**/agent-tools.spec.ts', '**/slash-commands.spec.ts', '**/file-mentions.spec.ts', '**/command-center.spec.ts', '**/goal-workflow.spec.ts', '**/conversation-import.spec.ts', '**/message-queue.spec.ts', '**/steering.spec.ts', '**/usage-limit.spec.ts', '**/bots.spec.ts', '**/bots-web.spec.ts', '**/bots-create.spec.ts', '**/bots-thread.spec.ts', '**/bots-channels.spec.ts', '**/queue-parity.spec.ts', '**/markdown.spec.ts', '**/document-viewer.spec.ts', '**/office-viewer.spec.ts', '**/generative-ui.spec.ts', '**/search-panel.spec.ts', '**/design-canvas.spec.ts', '**/skills-library.spec.ts', '**/jj.spec.ts', '**/capabilities.spec.ts', '**/recipes.spec.ts', '**/github-hub-keyboard.spec.ts', '**/chats.spec.ts', '**/connections.spec.ts', '**/inspector-new-task.spec.ts', '**/inspector-tabs.spec.ts', '**/changes-view.spec.ts', '**/cloud-agents.spec.ts', '**/tray-popover.spec.ts', '**/model-picker.spec.ts', '**/remote-progress.spec.ts', '**/remote-images.spec.ts', '**/remote-first-message.spec.ts', '**/remote-connect-ux.spec.ts', '**/remote-pairing.spec.ts', '**/remote-diagnostics.spec.ts', '**/remote-thread-open.spec.ts', '**/remote-subagents.spec.ts', '**/agent-traces.spec.ts', '**/sidebar-keyboard.spec.ts', '**/model-routing.spec.ts', '**/model-tiers.spec.ts', '**/thread-header-overlay.spec.ts', '**/action-dock.spec.ts', '**/thinking.spec.ts', '**/agents-dock.spec.ts', '**/tool-trace-gallery.spec.ts', ...perfSpecs, '**/progress-pip.spec.ts', '**/schedules.spec.ts', '**/schedules-settings.spec.ts', '**/machine-orchestration.spec.ts', '**/btw.spec.ts', '**/composer-undo.spec.ts', '**/project-relink.spec.ts', '**/cloud-settings.spec.ts'], + testMatch: ['**/approval-modes.spec.ts', **/external-links.spec.ts', '**/remove-project.spec.ts', '**/run-project.spec.ts', '**/harness-privacy.spec.ts', '**/terminal.spec.ts', '**/thread-order.spec.ts', '**/sidebar-sections.spec.ts', '**/thread-panels.spec.ts', '**/agent-updates.spec.ts', '**/thread-deletion.spec.ts', '**/agent-usage.spec.ts', '**/grok.spec.ts', '**/compact-menus.spec.ts', '**/attachments.spec.ts', '**/sketch.spec.ts', '**/worktrees.spec.ts', '**/github.spec.ts', '**/github-hub.spec.ts', '**/remote.spec.ts', '**/simulator.spec.ts', '**/colors.spec.ts', '**/token-saver.spec.ts', '**/support.spec.ts', '**/jev-live.spec.ts', '**/jev-assist.spec.ts', '**/jev.spec.ts', '**/message-actions.spec.ts', '**/opencode-models.spec.ts', '**/overlay.spec.ts', '**/moodboards.spec.ts', '**/provider-settings.spec.ts', '**/ui.spec.ts', '**/browser.spec.ts', '**/board.spec.ts', '**/streaming.spec.ts', '**/provider-handoff.spec.ts', '**/concurrency.spec.ts', '**/subagents.spec.ts', '**/architect.spec.ts', '**/team-profiles.spec.ts', '**/agent-teams.spec.ts', '**/vm.spec.ts', '**/deploy.spec.ts', '**/turn-changes.spec.ts', '**/wallpapers.spec.ts', '**/wallpaper-settings.spec.ts', '**/settings.spec.ts', '**/layout-preferences.spec.ts', '**/agent-input.spec.ts', '**/agent-tools.spec.ts', '**/slash-commands.spec.ts', '**/file-mentions.spec.ts', '**/command-center.spec.ts', '**/goal-workflow.spec.ts', '**/conversation-import.spec.ts', '**/message-queue.spec.ts', '**/steering.spec.ts', '**/usage-limit.spec.ts', '**/bots.spec.ts', '**/bots-web.spec.ts', '**/bots-create.spec.ts', '**/bots-thread.spec.ts', '**/bots-channels.spec.ts', '**/queue-parity.spec.ts', '**/markdown.spec.ts', '**/document-viewer.spec.ts', '**/office-viewer.spec.ts', '**/generative-ui.spec.ts', '**/search-panel.spec.ts', '**/design-canvas.spec.ts', '**/skills-library.spec.ts', '**/jj.spec.ts', '**/capabilities.spec.ts', '**/recipes.spec.ts', '**/github-hub-keyboard.spec.ts', '**/chats.spec.ts', '**/connections.spec.ts', '**/inspector-new-task.spec.ts', '**/inspector-tabs.spec.ts', '**/changes-view.spec.ts', '**/cloud-agents.spec.ts', '**/tray-popover.spec.ts', '**/model-picker.spec.ts', '**/remote-progress.spec.ts', '**/remote-images.spec.ts', '**/remote-first-message.spec.ts', '**/remote-connect-ux.spec.ts', '**/remote-pairing.spec.ts', '**/remote-diagnostics.spec.ts', '**/remote-thread-open.spec.ts', '**/remote-subagents.spec.ts', '**/agent-traces.spec.ts', '**/sidebar-keyboard.spec.ts', '**/model-routing.spec.ts', '**/model-tiers.spec.ts', '**/thread-header-overlay.spec.ts', '**/action-dock.spec.ts', '**/thinking.spec.ts', '**/agents-dock.spec.ts', '**/tool-trace-gallery.spec.ts', ...perfSpecs, '**/progress-pip.spec.ts', '**/schedules.spec.ts', '**/schedules-settings.spec.ts', '**/machine-orchestration.spec.ts', '**/btw.spec.ts', '**/composer-undo.spec.ts', '**/project-relink.spec.ts', '**/cloud-settings.spec.ts'], fullyParallel: true, workers: Number(process.env.PW_WORKERS ?? 4), timeout: 20_000, diff --git a/src-tauri/src/agent_tools.rs b/src-tauri/src/agent_tools.rs index bee97afa..93b73dd9 100644 --- a/src-tauri/src/agent_tools.rs +++ b/src-tauri/src/agent_tools.rs @@ -606,11 +606,11 @@ fn mutate_task( } match name { "pandacode_rename_task" => { - store.update(id, Some(string(args, "title")), None, None, None)?; + store.update(id, Some(string(args, "title")), None, None, None, None)?; Ok(json!({"renamed":true})) } "pandacode_archive_task" => { - store.update(id, None, args["archived"].as_bool(), None, None)?; + store.update(id, None, args["archived"].as_bool(), None, None, None)?; Ok(json!({"updated":true})) } "pandacode_set_task_stage" => { diff --git a/src-tauri/src/architect/tests.rs b/src-tauri/src/architect/tests.rs index d8c8ec33..57b02c66 100644 --- a/src-tauri/src/architect/tests.rs +++ b/src-tauri/src/architect/tests.rs @@ -1770,7 +1770,7 @@ fn permission_revocation_applies_to_worker_retry_and_failed_work_can_be_abandone .store .lock() .unwrap() - .update(&sid, None, None, None, Some("ask".into())) + .update(&sid, None, None, None, Some("ask".into()), None) .unwrap(); control_state(&state, &sid, "resume").unwrap(); let action = next_action_state(&state, &mut |_| {}).unwrap().unwrap(); diff --git a/src-tauri/src/bots.rs b/src-tauri/src/bots.rs index 654d6c7b..ab50c9a0 100644 --- a/src-tauri/src/bots.rs +++ b/src-tauri/src/bots.rs @@ -763,7 +763,7 @@ pub(crate) fn thread_session(store: &Store, bot: &Bot, channel_id: &str, externa store.db.execute("DELETE FROM bot_threads WHERE session_id=?1", [session]).map_err(|e| e.to_string())?; } let task = store.create_session(&bot.project_id, &bot.provider, &bot.model, &bot.mode)?; - store.update(&task.id, Some(clean(title, 120)), None, None, None)?; + store.update(&task.id, Some(clean(title, 120)), None, None, None, None)?; if !bot.reasoning_effort.is_empty() { store.db.execute("UPDATE sessions SET reasoning_effort=?2 WHERE id=?1", params![task.id, bot.reasoning_effort]).map_err(|e| e.to_string())?; } diff --git a/src-tauri/src/cloud_agents/tasks.rs b/src-tauri/src/cloud_agents/tasks.rs index 5f486efc..eac77ae6 100644 --- a/src-tauri/src/cloud_agents/tasks.rs +++ b/src-tauri/src/cloud_agents/tasks.rs @@ -294,7 +294,7 @@ fn task_row(store: &Store, draft: Option<&str>, project_id: &str, harness: &str, if !created { store.configure(&id, harness, model, None)?; if let Some(mode) = mode { - store.update(&id, None, None, None, Some(mode.to_owned()))?; + store.update(&id, None, None, None, Some(mode.to_owned()), None)?; } } if effort.is_some() { diff --git a/src-tauri/src/connections.rs b/src-tauri/src/connections.rs index 78a7081c..09e0905f 100644 --- a/src-tauri/src/connections.rs +++ b/src-tauri/src/connections.rs @@ -546,6 +546,7 @@ mod tests { reasoning_effort: None, mode: "full".into(), cwd: dir.clone(), + auto_writes: false, resume_id: None, prompt: "hello".into(), instructions: String::new(), diff --git a/src-tauri/src/goal_workflow_tests.rs b/src-tauri/src/goal_workflow_tests.rs index 7968073a..80c7a024 100644 --- a/src-tauri/src/goal_workflow_tests.rs +++ b/src-tauri/src/goal_workflow_tests.rs @@ -157,7 +157,7 @@ fn goal_maintenance_adds_no_user_turn_and_preserves_the_previous_stage() { assert!(store.messages(&task.id, None).unwrap().is_empty()); assert_eq!(store.session(&task.id).unwrap().task_stage, "pending"); store - .update(&task.id, None, Some(true), None, None) + .update(&task.id, None, Some(true), None, None, None) .unwrap(); assert!(store.begin_goal_operation(&task.id).is_err()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2958ab16..cf5a5610 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -896,6 +896,7 @@ async fn set_reasoning_effort( .set_reasoning_effort(&session_id, reasoning_effort.as_deref()) } #[tauri::command] +#[allow(clippy::too_many_arguments)] async fn update_session( app: tauri::AppHandle, state: State<'_, AppState>, @@ -904,14 +905,18 @@ async fn update_session( archived: Option, model: Option, mode: Option, + auto_writes: Option, ) -> Result<()> { // A mirror's permissions are its host's; the local row follows below. if let Some(mode) = &mode { remote::route(&app, &state, &session_id, "task.configure", serde_json::json!({"mode": mode})).await?; } + if let Some(auto_writes) = auto_writes { + remote::route(&app, &state, &session_id, "task.configure", serde_json::json!({"autoWrites": auto_writes})).await?; + } if let Some(title) = title .as_deref() - .filter(|_| model.is_none() && mode.is_none() && archived.is_none()) + .filter(|_| model.is_none() && mode.is_none() && archived.is_none() && auto_writes.is_none()) { let task = state .store @@ -923,7 +928,7 @@ async fn update_session( } } let agents = state.agents.lock().map_err(|e| e.to_string())?; - if (model.is_some() || mode.is_some()) && agents.contains_key(&session_id) { + if (model.is_some() || mode.is_some() || auto_writes.is_some()) && agents.contains_key(&session_id) { return Err("Stop the agent before changing its model or permissions.".into()); } let restored = archived == Some(false); @@ -931,7 +936,7 @@ async fn update_session( .store .lock() .map_err(|e| e.to_string())? - .update(&session_id, title, archived, model, mode)?; + .update(&session_id, title, archived, model, mode, auto_writes)?; drop(agents); if restored { remote::orchestrate::retry(&app, &session_id); @@ -1694,6 +1699,7 @@ fn prepare_task_run_in_project( model: session.model, reasoning_effort: session.reasoning_effort, mode: session.mode, + auto_writes: session.auto_writes, cwd, resume_id: session.resume_id, prompt, @@ -1751,6 +1757,7 @@ fn prepare_cloud_attach( model: session.model, reasoning_effort: session.reasoning_effort, mode: session.mode, + auto_writes: session.auto_writes, cwd, resume_id: session.resume_id, prompt: String::new(), diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs index 54c09ad8..0a712aa6 100644 --- a/src-tauri/src/providers.rs +++ b/src-tauri/src/providers.rs @@ -87,6 +87,10 @@ const TOOL_INPUT_LIMIT: usize = 4096; const TOOL_OUTPUT_LIMIT: usize = 8192; const TOOL_CACHE_LIMIT: usize = 64; const PREVIEW_TRUNCATED: &str = "\n[Preview truncated]"; +/// Write-class tools answered by the per-task auto-approve-writes toggle in Standard and +/// Accept edits modes. Reads, questions and Plan mode still ask; the allowlist is explicit +/// so a `!plan_read_only` shortcut can never wave through reads of credential paths. +const AUTO_WRITE_TOOLS: &[&str] = &["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash", "TodoWrite"]; /// A frame buffer grown past 1 MiB by one large message gives the memory back once drained. fn should_shrink(len: usize, capacity: usize) -> bool { @@ -137,6 +141,7 @@ pub struct RunConfig { pub model: String, pub reasoning_effort: Option, pub mode: String, + pub auto_writes: bool, pub cwd: PathBuf, pub resume_id: Option, pub prompt: String, @@ -2181,8 +2186,15 @@ impl Protocol { } }, "item/commandExecution/requestApproval"|"item/fileChange/requestApproval"|"item/permissions/requestApproval" => { - let label=if params["command"].is_string() { string(params,"command") } else if params["reason"].is_string() {string(params,"reason")} else {"Allow this agent action?".into()}; - self.approval(value,label,&mut step)?; + // The task's auto-approve-writes toggle accepts command and file-change + // approvals in Standard mode itself; escalations and the Approve-for-me + // reviewer (auto mode) still reach the user. + if self.config.auto_writes && self.config.mode == "ask" && method != "item/permissions/requestApproval" { + step.write.push(json!({"id":value["id"],"result":{"decision":"accept"}})); + } else { + let label=if params["command"].is_string() { string(params,"command") } else if params["reason"].is_string() {string(params,"reason")} else {"Allow this agent action?".into()}; + self.approval(value,label,&mut step)?; + } }, "turn/completed" => { self.goal_turn_completed(params, &mut step)?; @@ -2386,7 +2398,15 @@ impl Protocol { if update["sessionUpdate"] == "available_commands_update" { self.commands_changed(&value, &mut step); } if update["sessionUpdate"] == "config_option_update" { self.kimi_config_options(update); } // session/load can replay history before its response; UI already owns that history. - if self.phase==0{return Ok(step);} + // Keep replayed tool names cached without events: DeepSeek names its edits in the + // tool call's title, and the permission request that follows carries only a kind. + if self.phase==0{ + if matches!(string(update,"sessionUpdate").as_str(), "tool_call"|"tool_call_update") { + let mut cached=Step::default(); + self.kimi_tool(update,&mut cached); + } + return Ok(step); + } match string(update,"sessionUpdate").as_str() { "usage_update"=>{if let Some(used)=update["used"].as_u64() {self.usage(used,update["size"].as_u64(),&mut step);}}, "agent_message_chunk"=>{ @@ -2726,6 +2746,10 @@ impl Protocol { // Plan mode investigates: reads and web lookups anywhere run; // Claude's own deny rules were applied before it asked. step.write.push(json!({"type":"control_response","response":{"subtype":"success","request_id":value["request_id"],"response":{"behavior":"allow","updatedInput":value["request"]["input"]}}})); + } else if self.config.auto_writes && matches!(self.config.mode.as_str(), "ask" | "auto") && AUTO_WRITE_TOOLS.contains(&name.as_str()) { + // The task's auto-approve-writes toggle answers write tools itself; + // reads, questions and restricted modes fall through and keep asking. + step.write.push(json!({"type":"control_response","response":{"subtype":"success","request_id":value["request_id"],"response":{"behavior":"allow","updatedInput":value["request"]["input"]}}})); } else { self.approval( value, @@ -3808,6 +3832,7 @@ mod tests { model: "default".into(), reasoning_effort: None, mode: "ask".into(), + auto_writes: false, cwd: PathBuf::from("/tmp"), resume_id: None, prompt: "Explain this project".into(), @@ -5416,6 +5441,7 @@ read -r a; echo '{{"jsonrpc":"2.0","id":3,"result":{{"stopReason":"end_turn"}}}} model: model.clone(), reasoning_effort: None, mode: "ask".into(), + auto_writes: false, cwd: root.clone(), resume_id: resume.clone(), prompt: prompt.into(), diff --git a/src-tauri/src/providers/acp.rs b/src-tauri/src/providers/acp.rs index 3b59b7f2..bf66df1b 100644 --- a/src-tauri/src/providers/acp.rs +++ b/src-tauri/src/providers/acp.rs @@ -541,7 +541,13 @@ impl Protocol { || (deepseek && self.tools.get(field(&value["params"]["toolCall"], "toolCallId")) .is_some_and(|tool| DEEPSEEK_EDIT_TOOLS.contains(&tool.name.as_str()))); - let auto = self.config.mode == "full" || (self.config.mode == "auto" && edit); + // The auto-approve-writes toggle covers every write kind, including the + // edits DeepSeek reports by tool name rather than kind and moves. + let write_kind = edit + || matches!(value["params"]["toolCall"]["kind"].as_str(), Some("delete" | "execute" | "move")); + let auto = self.config.mode == "full" + || (self.config.mode == "auto" && edit) + || (self.config.auto_writes && matches!(self.config.mode.as_str(), "ask" | "auto") && write_kind); let structured_question = options.is_some_and(|options| { options.iter().any(|option| { field(option, "optionId") @@ -2251,6 +2257,184 @@ mod tests { assert_eq!(full["agent"]["plan"]["permission"]["*"], "allow"); } + fn write_permission(kind: &str) -> Value { + let mut request = permission(); + request["params"]["toolCall"]["kind"] = json!(kind); + request + } + + fn deepseek_tool(name: &str) -> Value { + json!({"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ds-1","update":{"sessionUpdate":"tool_call","toolCallId":"c1","title":name,"kind":"other","status":"in_progress","rawInput":{}}}}) + } + + /// With the per-task "Auto-approve writes" toggle on, every write kind is + /// answered once in Standard and Accept edits modes, without asking. + #[test] + fn auto_writes_answers_write_kinds_in_ask_and_auto_modes() { + for mode in ["ask", "auto"] { + for kind in ["edit", "delete", "execute"] { + let mut cfg = config("opencode"); + cfg.mode = mode.into(); + cfg.auto_writes = true; + let mut p = Protocol::new(cfg); + p.remote_id = "remote".into(); + let step = p.receive(write_permission(kind)).unwrap(); + assert_eq!( + step.write, + vec![json!({"jsonrpc":"2.0","id":"p1","result":{"outcome":{"outcome":"selected","optionId":"yes"}}})], + "{mode} {kind}" + ); + assert!( + step.events.iter().all(|event| event.kind != "approval"), + "{mode} {kind} must not ask" + ); + assert!(p.pending.is_empty(), "{mode} {kind}"); + } + } + } + + /// DeepSeek reports edits by tool name, not kind: the name fallback keeps + /// answering those with the toggle on. + #[test] + fn auto_writes_answers_deepseek_edit_tool_names_without_a_kind() { + for name in ["edit", "write", "str_replace_editor"] { + let mut cfg = config("deepseek"); + cfg.mode = "ask".into(); + cfg.auto_writes = true; + let mut p = Protocol::new(cfg); + p.remote_id = "ds-1".into(); + p.receive(deepseek_tool(name)).unwrap(); + let step = p + .receive(json!({"jsonrpc":"2.0","id":9,"method":"session/request_permission","params":{"sessionId":"ds-1","toolCall":{"toolCallId":"c1","kind":"other"},"options":[{"optionId":"allow-once","kind":"allow_once"},{"optionId":"reject-once","kind":"reject_once"}]}})) + .unwrap(); + assert_eq!( + step.write, + vec![json!({"jsonrpc":"2.0","id":9,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}})], + "{name}" + ); + assert!( + step.events.iter().all(|event| event.kind != "approval"), + "{name} must not ask" + ); + } + } + + /// Reads and unknown kinds keep asking with the toggle on, the one-grant + /// and structured-question guards stay, and Accept edits answers edits + /// today exactly as it did before the toggle existed. + #[test] + fn auto_writes_keeps_other_kinds_questions_and_extra_grants_asking() { + for (mode, flag, kind) in [ + ("ask", true, "read"), + ("ask", true, "search"), + ("ask", true, "fetch"), + ("ask", true, "think"), + ("ask", true, "other"), + ("ask", false, "edit"), + ("ask", false, "execute"), + ("auto", false, "execute"), + ] { + let mut cfg = config("opencode"); + cfg.mode = mode.into(); + cfg.auto_writes = flag; + let mut p = Protocol::new(cfg); + p.remote_id = "remote".into(); + let step = p.receive(write_permission(kind)).unwrap(); + assert!( + step.write.is_empty(), + "{mode} flag={flag} kind={kind} must wait" + ); + assert!( + step.events.iter().any(|event| event.kind == "approval"), + "{mode} flag={flag} kind={kind} reaches the user" + ); + } + for options in [ + ( + json!([{"optionId":"q0_opt_0","kind":"allow_once"},{"optionId":"no","kind":"reject_once"}]), + false, + ), + ( + json!([{"optionId":"one","kind":"allow_once"},{"optionId":"two","kind":"allow_once"}]), + false, + ), + (json!([{"optionId":"no","kind":"reject_once"}]), true), + ] { + let mut cfg = config("opencode"); + cfg.mode = "ask".into(); + cfg.auto_writes = true; + let mut p = Protocol::new(cfg); + p.remote_id = "remote".into(); + let mut request = write_permission("execute"); + request["params"]["options"] = options.0; + let step = p.receive(request).unwrap(); + // Structured questions and ambiguous grants keep today's path: + // cancelled as structured input, never answered with an allow. + if options.1 { + assert!(step.write.is_empty(), "a lone reject option still asks"); + assert!(step.events.iter().any(|event| event.kind == "approval")); + } else { + assert_eq!( + step.write[0]["result"]["outcome"]["outcome"], + "cancelled", + "questions stay for chat" + ); + assert!(step + .events + .iter() + .any(|event| event.text.contains("structured input"))); + } + } + // Accept edits already answered plain edits; the toggle must not narrow that. + let mut cfg = config("opencode"); + cfg.mode = "auto".into(); + let mut p = Protocol::new(cfg); + p.remote_id = "remote".into(); + let step = p.receive(write_permission("edit")).unwrap(); + assert_eq!(step.write[0]["result"]["outcome"]["optionId"], "yes"); + } + + /// Plan mode keeps declining state changes with the toggle on: the write + /// answer must sit below every provider plan handler. + #[test] + fn auto_writes_leaves_plan_mode_declines_in_charge() { + let mut cfg = config("kilo"); + cfg.mode = "plan".into(); + cfg.auto_writes = true; + let mut p = Protocol::new(cfg); + p.remote_id = "remote".into(); + let step = p.receive(write_permission("execute")).unwrap(); + assert_eq!(step.write[0]["result"]["outcome"]["optionId"], "no"); + assert!(step + .events + .iter() + .any(|event| event.text.contains("Plan mode declined"))); + + let (mut p, _) = grok_ready("plan"); + p.config.auto_writes = true; + let step = p.receive(grok_permission("execute", false)).unwrap(); + assert_eq!(step.write[0]["result"]["outcome"]["optionId"], "reject-once"); + assert!(step + .events + .iter() + .any(|event| event.text.contains("Plan mode declined"))); + + let mut cfg = config("deepseek"); + cfg.mode = "plan".into(); + cfg.auto_writes = true; + let mut p = Protocol::new(cfg); + p.remote_id = "ds-1".into(); + p.receive(deepseek_tool("write")).unwrap(); + let step = p + .receive(json!({"jsonrpc":"2.0","id":9,"method":"session/request_permission","params":{"sessionId":"ds-1","toolCall":{"toolCallId":"c1","kind":"other"},"options":[{"optionId":"allow-once","kind":"allow_once"},{"optionId":"reject-once","kind":"reject_once"}]}})) + .unwrap(); + assert_eq!(step.write[0]["result"]["outcome"]["optionId"], "reject-once"); + assert!(step + .events + .iter() + .any(|event| event.text.contains("Plan mode declined"))); + } + #[test] fn acp_streaming_preserves_boundaries_tools_usage_and_isolation() { for provider in ["opencode", "antigravity"] { @@ -2607,4 +2791,29 @@ mod tests { assert!(!step.done); assert_eq!(step.write[0]["params"]["prompt"][0]["text"], "retry me"); } + + /// A move is a write: the toggle answers it like edit, delete and execute. + #[test] + fn auto_writes_answers_move_kind_like_other_writes() { + let mut cfg = config("cline"); + cfg.mode = "ask".into(); + cfg.auto_writes = true; + let mut p = Protocol::new(cfg); + p.remote_id = "remote".into(); + let step = p.receive(write_permission("move")).unwrap(); + assert_eq!( + step.write, + vec![json!({"jsonrpc":"2.0","id":"p1","result":{"outcome":{"outcome":"selected","optionId":"yes"}}})], + "move is answered with the toggle on" + ); + assert!(step.events.iter().all(|event| event.kind != "approval")); + + let mut cfg = config("cline"); + cfg.mode = "ask".into(); + let mut p = Protocol::new(cfg); + p.remote_id = "remote".into(); + let step = p.receive(write_permission("move")).unwrap(); + assert!(step.write.is_empty(), "without the toggle a move still asks"); + assert!(step.events.iter().any(|event| event.kind == "approval")); + } } diff --git a/src-tauri/src/providers/claude_stream_tests.rs b/src-tauri/src/providers/claude_stream_tests.rs index ee093dcc..34dd7d50 100644 --- a/src-tauri/src/providers/claude_stream_tests.rs +++ b/src-tauri/src/providers/claude_stream_tests.rs @@ -199,3 +199,126 @@ fn thinking_deltas_surface_as_thinking_events() { assert_eq!(thinking, "Check the tests."); assert_eq!(text(&events), "Done"); } + +fn can_use(id: &str, tool: &str, input: Value) -> Value { + json!({"type":"control_request","request_id":id,"request":{"subtype":"can_use_tool","tool_name":tool,"input":input}}) +} + +fn write_tool_input(tool: &str) -> Value { + match tool { + "Write" => json!({"file_path":"src/main.rs","content":"fn main() {}"}), + "Edit" => json!({"file_path":"src/main.rs","old_string":"a","new_string":"b"}), + "MultiEdit" => json!({"file_path":"src/main.rs","edits":[]}), + "NotebookEdit" => json!({"notebook_path":"notes.ipynb"}), + "TodoWrite" => json!({"todos":[{"content":"Ship","status":"completed"}]}), + _ => json!({"command":"git status"}), + } +} + +/// With the per-task "Auto-approve writes" toggle on, write-class tools are +/// answered as allowed in Standard and Accept edits modes without asking. +#[test] +fn auto_writes_answers_write_tools_without_asking_in_ask_and_auto_modes() { + for mode in ["ask", "auto"] { + for tool in ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash", "TodoWrite"] { + let mut cfg = config("claude"); + cfg.mode = mode.into(); + cfg.auto_writes = true; + let mut protocol = Protocol::new(cfg); + let input = write_tool_input(tool); + let step = protocol + .receive(can_use("auto-write", tool, input.clone())) + .unwrap(); + assert_eq!( + step.write, + vec![json!({"type":"control_response","response":{"subtype":"success","request_id":"auto-write","response":{"behavior":"allow","updatedInput":input}}})], + "{mode} {tool}" + ); + assert!( + step.events.iter().all(|event| event.kind != "approval"), + "{mode} {tool} must not ask" + ); + assert!(protocol.pending.is_empty(), "{mode} {tool}"); + } + } +} + +/// Reads, MCP tools and unknown tools keep asking with the toggle on: the +/// explicit allowlist must not widen into "everything but plan reads". +#[test] +fn auto_writes_keeps_reads_mcp_and_unknown_tools_asking() { + for (tool, input) in [ + ("Read", json!({"file_path":"/tmp/notes.txt"})), + ("Read", json!({"file_path":"~/.ssh/id_rsa"})), + ("Grep", json!({"pattern":"todo"})), + ("Glob", json!({"pattern":"**/*.rs"})), + ("LS", json!({"path":"."})), + ("NotebookRead", json!({"notebook_path":"notes.ipynb"})), + ("WebFetch", json!({"url":"https://example.com"})), + ("WebSearch", json!({"query":"rust sqlite"})), + ("mcp__someclient__tool", json!({"query":"data"})), + ("SlashCommand", json!({"command":"/deploy"})), + ] { + let mut cfg = config("claude"); + cfg.mode = "ask".into(); + cfg.auto_writes = true; + let mut protocol = Protocol::new(cfg); + let step = protocol.receive(can_use("still-asks", tool, input)).unwrap(); + assert!(step.write.is_empty(), "{tool} must wait for the user"); + let approval = step + .events + .iter() + .find(|event| event.kind == "approval") + .unwrap_or_else(|| panic!("{tool} reaches the user")); + assert!(approval.request_id.is_some(), "{tool}"); + } +} + +/// Plan, agent-reviewed and Full access modes never pick up the toggle, and +/// without the toggle every mode keeps today's behavior. +#[test] +fn auto_writes_never_bypasses_plan_review_full_or_a_missing_flag() { + for (mode, flag) in [("plan", true), ("review", true), ("full", true), ("auto", false), ("ask", false)] { + let mut cfg = config("claude"); + cfg.mode = mode.into(); + cfg.auto_writes = flag; + let mut protocol = Protocol::new(cfg); + let step = protocol + .receive(can_use("gated", "Write", write_tool_input("Write"))) + .unwrap(); + assert!(step.write.is_empty(), "{mode} flag={flag} still asks"); + assert!( + step.events.iter().any(|event| event.kind == "approval"), + "{mode} flag={flag} reaches the user" + ); + } +} + +/// The branches ahead of the write answer keep their shape: questions are +/// denied, and Plan mode still runs its own read-only allowlist only. +#[test] +fn auto_writes_keeps_question_denials_and_plan_read_allows_intact() { + let mut cfg = config("claude"); + cfg.auto_writes = true; + let mut protocol = Protocol::new(cfg); + let step = protocol + .receive(can_use("question", "AskUserQuestion", json!({"questions":[{"question":"Which?","options":[]}]}))) + .unwrap(); + assert_eq!(step.write[0]["response"]["response"]["behavior"], "deny"); + assert!(step.events.iter().all(|event| event.kind != "approval")); + + let mut cfg = config("claude"); + cfg.mode = "plan".into(); + cfg.auto_writes = true; + let mut protocol = Protocol::new(cfg); + let step = protocol + .receive(can_use("plan-read", "Read", json!({"file_path":"/tmp/notes.txt"}))) + .unwrap(); + assert_eq!(step.write[0]["response"]["response"]["behavior"], "allow"); + // Credential reads stay behind a prompt even in plan mode. + let step = protocol + .receive(can_use("plan-key", "Read", json!({"file_path":"~/.ssh/id_rsa"}))) + .unwrap(); + assert!(step.write.is_empty(), "plan mode never auto-runs a key read"); + assert!(step.events.iter().any(|event| event.kind == "approval")); +} diff --git a/src-tauri/src/providers/codex_stream_tests.rs b/src-tauri/src/providers/codex_stream_tests.rs index 38b35fd2..b88df4e4 100644 --- a/src-tauri/src/providers/codex_stream_tests.rs +++ b/src-tauri/src/providers/codex_stream_tests.rs @@ -7,6 +7,7 @@ fn protocol() -> Protocol { model: "default".into(), reasoning_effort: None, mode: "ask".into(), + auto_writes: false, cwd: PathBuf::from("/tmp"), resume_id: None, prompt: "Explain this project".into(), @@ -231,3 +232,77 @@ fn reasoning_summary_deltas_surface_as_thinking_events() { } assert_eq!(thinking, "Plan\n\nAct"); } + +/// The per-task "Auto-approve writes" toggle answers command and file-change +/// approvals itself in Standard mode: the accept response goes straight back +/// and no approval event reaches the user. +#[test] +fn auto_writes_pre_accepts_command_and_file_change_approvals_in_ask_mode() { + for (id, method, mut params) in [ + ( + json!(41), + "item/commandExecution/requestApproval", + json!({"itemId":"c1","command":"npm test","callId":"call-1"}), + ), + ( + json!(42), + "item/fileChange/requestApproval", + json!({"itemId":"f1","changes":[{"op":"write","path":"src/main.rs"}]}), + ), + ] { + let mut p = protocol(); + p.config.auto_writes = true; + params["threadId"] = json!("main"); + params["turnId"] = json!("turn-main"); + let step = p + .receive(json!({"id":id,"method":method,"params":params})) + .unwrap(); + assert_eq!( + step.write, + vec![json!({"id":id,"result":{"decision":"accept"}})], + "{method}" + ); + assert!( + step.events.iter().all(|event| event.kind != "approval"), + "{method} must not ask" + ); + assert!(p.pending.is_empty(), "{method} leaves nothing pending"); + } +} + +/// Escalations still ask, and "Approve for me" keeps its reviewer: only +/// Standard mode pre-accepts. +#[test] +fn auto_writes_keeps_escalations_and_the_auto_reviewer_asking() { + let mut p = protocol(); + p.config.auto_writes = true; + let permissions = json!({"fileSystem":{"write":["/tmp"]}}); + let step = p + .receive(json!({"id":43,"method":"item/permissions/requestApproval","params":{"threadId":"main","turnId":"turn-main","itemId":"p1","reason":"Work outside the sandbox","permissions":permissions}})) + .unwrap(); + assert!(step.write.is_empty(), "escalations wait for the user"); + let approval = step + .events + .iter() + .find(|event| event.kind == "approval") + .expect("escalation reaches the user"); + let request_id = approval.request_id.as_ref().unwrap(); + assert_eq!( + p.approve(request_id, true).unwrap(), + json!({"id":43,"result":{"permissions":permissions,"scope":"turn"}}) + ); + let mut p = protocol(); + p.config.mode = "auto".into(); + p.config.auto_writes = true; + let step = p + .receive(json!({"id":44,"method":"item/commandExecution/requestApproval","params":{"threadId":"main","turnId":"turn-main","itemId":"c2","command":"rm -rf build","callId":"call-2"}})) + .unwrap(); + assert!( + step.write.is_empty(), + "auto mode (Approve for me) never pre-accepts" + ); + assert!(step + .events + .iter() + .any(|event| event.kind == "approval")); +} diff --git a/src-tauri/src/providers/mcp_host.rs b/src-tauri/src/providers/mcp_host.rs index 3846c9b0..e442e4b1 100644 --- a/src-tauri/src/providers/mcp_host.rs +++ b/src-tauri/src/providers/mcp_host.rs @@ -670,7 +670,7 @@ mod tests { for turn_kind in ["user", "review", "retry", "manual"] { if turn_kind == "manual" { - db.update(&sid, Some("My parser investigation".into()), None, None, None).unwrap(); + db.update(&sid, Some("My parser investigation".into()), None, None, None, None).unwrap(); } let coordination = matches!(turn_kind, "review" | "retry") .then_some(crate::architect::internal_turns::CoordinationKind::WorkerReview); diff --git a/src-tauri/src/providers/warm.rs b/src-tauri/src/providers/warm.rs index d18fb723..ce20d0d3 100644 --- a/src-tauri/src/providers/warm.rs +++ b/src-tauri/src/providers/warm.rs @@ -47,7 +47,7 @@ fn key_with(config: &RunConfig, tools: &[Value], connection: &str) -> String { json!({ "namedConnection": connection, "provider": config.provider, "model": config.model, "effort": config.reasoning_effort, - "mode": config.mode, "cwd": config.cwd, "resume": config.resume_id, + "mode": config.mode, "autoWrites": config.auto_writes, "cwd": config.cwd, "resume": config.resume_id, "instructions": config.instructions, "tools": tools, // The login a process starts with (API key or subscription) is fixed at spawn. "connection": format!("{:?}", crate::provider_settings::claude_connection_mode()), @@ -201,6 +201,7 @@ mod tests { for different in [ changed(|c| c.model = "claude-sonnet-5-5".into()), changed(|c| c.mode = "full".into()), + changed(|c| c.auto_writes = true), changed(|c| c.reasoning_effort = Some("high".into())), changed(|c| c.resume_id = Some("another".into())), changed(|c| c.instructions = "new".into()), diff --git a/src-tauri/src/remote/host.rs b/src-tauri/src/remote/host.rs index a717fa55..a5e34a47 100644 --- a/src-tauri/src/remote/host.rs +++ b/src-tauri/src/remote/host.rs @@ -145,7 +145,7 @@ pub(crate) enum Call { PutImage(images::Chunk), Stop { session_id: String }, Steer { session_id: String, text: String }, - Configure { session_id: String, model: Option<(String, String)>, effort: Option>, mode: Option }, + Configure { session_id: String, model: Option<(String, String)>, effort: Option>, mode: Option, auto_writes: Option }, Approve { session_id: String, request_id: String, approve: bool }, Answer { session_id: String, request_id: String, response: Value }, Pull { session_id: String }, @@ -408,7 +408,8 @@ fn configure(params: &Value) -> Result { _ => return Err("Send a provider with the model.".into()), }; let effort = params.get("effort").map(|_| name_param(params, "effort")).transpose()?; - Ok(Call::Configure { session_id: id_param(params, "sessionId")?, model, effort, mode: name_param(params, "mode")? }) + let auto_writes = params.get("autoWrites").map(|value| value.as_bool().ok_or_else(|| "Invalid autoWrites.".to_string())).transpose()?; + Ok(Call::Configure { session_id: id_param(params, "sessionId")?, model, effort, mode: name_param(params, "mode")?, auto_writes }) } /// Validates a request. Everything is refused while hosting is off. @@ -1484,13 +1485,16 @@ async fn run_one(app: &tauri::AppHandle, from: &str, call: Call, full_access: bo allow_task_mode(&local_task(&state, &session_id).await?.mode, None, full_access)?; message_queue::steer_task(app.clone(), state, session_id, text).await } - Call::Configure { session_id, model, effort, mode } => { + Call::Configure { session_id, model, effort, mode, auto_writes } => { allow_task_mode(&local_task(&state, &session_id).await?.mode, mode.as_deref(), full_access)?; if let Some((provider, _)) = &model { start::require_installed(provider).await?; } if let Some(mode) = mode { - crate::update_session(app.clone(), state.clone(), session_id.clone(), None, None, None, Some(mode)).await?; + crate::update_session(app.clone(), state.clone(), session_id.clone(), None, None, None, Some(mode), None).await?; + } + if let Some(auto_writes) = auto_writes { + crate::update_session(app.clone(), state.clone(), session_id.clone(), None, None, None, None, Some(auto_writes)).await?; } if let Some((provider, model)) = model { crate::configure_session(app.clone(), state.clone(), session_id.clone(), provider, model, None).await?; @@ -3705,9 +3709,10 @@ mod tests { assert!(parse(true, "image.put", &json!({"uploadId": "u1", "index": 0, "size": 6 * 1024 * 1024, "data": "iVBO"})).is_err()); assert_eq!(parse(true, "task.messages", &json!({"sessionId": "s1", "afterId": 7})), Ok(Call::Messages { session_id: "s1".into(), page: MessagePaging::After { after_id: 7, limit: PAGE_ROWS }, budget: PAGE_BUDGET })); assert_eq!(parse(true, "task.messages", &json!({"sessionId": "s1", "afterId": -3})), Ok(Call::Messages { session_id: "s1".into(), page: MessagePaging::After { after_id: 0, limit: PAGE_ROWS }, budget: PAGE_BUDGET })); - assert_eq!(parse(true, "task.configure", &json!({"sessionId": "s1", "effort": null})), Ok(Call::Configure { session_id: "s1".into(), model: None, effort: Some(None), mode: None })); - assert_eq!(parse(true, "task.configure", &json!({"sessionId": "s1", "mode": "ask"})), Ok(Call::Configure { session_id: "s1".into(), model: None, effort: None, mode: Some("ask".into()) })); - assert_eq!(parse(true, "task.configure", &json!({"sessionId": "s1", "provider": "codex", "model": "gpt"})), Ok(Call::Configure { session_id: "s1".into(), model: Some(("codex".into(), "gpt".into())), effort: None, mode: None })); + assert_eq!(parse(true, "task.configure", &json!({"sessionId": "s1", "effort": null})), Ok(Call::Configure { session_id: "s1".into(), model: None, effort: Some(None), mode: None, auto_writes: None })); + assert_eq!(parse(true, "task.configure", &json!({"sessionId": "s1", "mode": "ask"})), Ok(Call::Configure { session_id: "s1".into(), model: None, effort: None, mode: Some("ask".into()), auto_writes: None })); + assert_eq!(parse(true, "task.configure", &json!({"sessionId": "s1", "provider": "codex", "model": "gpt"})), Ok(Call::Configure { session_id: "s1".into(), model: Some(("codex".into(), "gpt".into())), effort: None, mode: None, auto_writes: None })); + assert_eq!(parse(true, "task.configure", &json!({"sessionId": "s1", "autoWrites": true})), Ok(Call::Configure { session_id: "s1".into(), model: None, effort: None, mode: None, auto_writes: Some(true) })); assert!(parse(true, "task.configure", &json!({"sessionId": "s1", "model": "gpt"})).is_err()); assert!(parse(true, "task.send", &json!({"sessionId": "s1", "text": "x".repeat(MAX_TEXT + 1)})).is_err()); assert!(parse(true, "task.send", &json!({"sessionId": "s1", "text": " "})).is_err()); diff --git a/src-tauri/src/remote/mirror.rs b/src-tauri/src/remote/mirror.rs index 24751b1a..a1c2b19c 100644 --- a/src-tauri/src/remote/mirror.rs +++ b/src-tauri/src/remote/mirror.rs @@ -1611,6 +1611,7 @@ pub async fn remote_start_task( provider: String, model: String, mode: Option, + auto_writes: Option, effort: Option, worktree: Option, branch: Option, @@ -1618,7 +1619,7 @@ pub async fn remote_start_task( start_id: Option, image_ids: Option>, ) -> Result { - let request = Launch { device: device_id, project_id, prompt, provider, model, mode, effort, worktree: worktree.unwrap_or(false), branch, draft: session_id, start_id, image_ids: image_ids.unwrap_or_default(), ..Default::default() }; + let request = Launch { device: device_id, project_id, prompt, provider, model, mode, auto_writes, effort, worktree: worktree.unwrap_or(false), branch, draft: session_id, start_id, image_ids: image_ids.unwrap_or_default(), ..Default::default() }; let (local, note) = commit(&app, request).await?.start.await.map_err(|e| e.to_string())??; let mut session = serde_json::to_value(lock(&state)?.session(&local)?).map_err(|e| e.to_string())?; session["sessionId"] = json!(local); @@ -1638,6 +1639,8 @@ pub(crate) struct Launch { pub provider: String, pub model: String, pub mode: Option, + /// The task's Auto-approve writes toggle, carried to the host task. + pub auto_writes: Option, pub effort: Option, pub worktree: bool, pub branch: Option, @@ -1724,8 +1727,8 @@ pub(super) fn commit_row(inner: &mut super::Inner, store: &Store, request: &mut /// The `task.start` params of a committed launch. pub(super) fn start_body(request: Launch, repo: &str, branch: Option<&str>, start_id: Option<&str>, image_ids: &[String]) -> Value { - let Launch { prompt, provider, model, mode, effort, worktree, .. } = request; - json!({"repo": repo, "branch": branch, "worktree": worktree, "prompt": prompt, "provider": provider, "model": model, "mode": mode, "effort": effort, "startId": start_id, "imageIds": image_ids}) + let Launch { prompt, provider, model, mode, auto_writes, effort, worktree, .. } = request; + json!({"repo": repo, "branch": branch, "worktree": worktree, "prompt": prompt, "provider": provider, "model": model, "mode": mode, "autoWrites": auto_writes, "effort": effort, "startId": start_id, "imageIds": image_ids}) } /// What a `task.start` reply means for its mirror. @@ -1899,7 +1902,7 @@ async fn cancel_start(app: &tauri::AppHandle, state: &AppState, local: &str) -> /// Makes (or converts an empty draft into) the local mirror row, published and running. fn mirror_row(store: &Store, request: &Launch) -> Result<(String, bool)> { - let Launch { device, project_id, provider, model, mode, effort, draft, parent, title, .. } = request; + let Launch { device, project_id, provider, model, mode, auto_writes, effort, draft, parent, title, .. } = request; if let Some(parent) = parent { super::orchestrate::check_capacity(store, parent)?; } @@ -1914,12 +1917,15 @@ fn mirror_row(store: &Store, request: &Launch) -> Result<(String, bool)> { if !created { store.configure(&id, provider, model, None)?; if let Some(mode) = mode { - store.update(&id, None, None, None, Some(mode.to_owned()))?; + store.update(&id, None, None, None, Some(mode.to_owned()), None)?; } } if effort.is_some() { store.set_reasoning_effort(&id, effort.as_deref())?; } + if auto_writes.is_some() { + store.update(&id, None, None, None, None, *auto_writes)?; + } store.db.execute("UPDATE sessions SET remote_device=?2,remote_session=NULL,remote_synced_id=0,draft=0 WHERE id=?1", params![id, device]).map_err(|e| e.to_string())?; if let Some(title) = title { store.db.execute("UPDATE sessions SET title=?2,title_origin='manual' WHERE id=?1", params![id, title]).map_err(|e| e.to_string())?; @@ -2154,6 +2160,16 @@ mod tests { } } + #[test] + fn a_mirror_row_keeps_the_auto_writes_flag() { + let store = Store::open(Path::new(":memory:")).unwrap(); + let project = store.add_project(&std::env::temp_dir()).unwrap(); + let (on, _) = mirror_row(&store, &Launch { device: "host".into(), project_id: project.id.clone(), provider: "claude".into(), model: "sonnet".into(), auto_writes: Some(true), ..Default::default() }).unwrap(); + assert!(store.session(&on).unwrap().auto_writes, "the local mirror row stores the toggle"); + let (off, _) = mirror_row(&store, &Launch { device: "host".into(), project_id: project.id.clone(), provider: "claude".into(), model: "sonnet".into(), ..Default::default() }).unwrap(); + assert!(!store.session(&off).unwrap().auto_writes); + } + #[test] fn a_turn_ends_once() { let (store, local) = store(); diff --git a/src-tauri/src/remote/orchestrate/tests.rs b/src-tauri/src/remote/orchestrate/tests.rs index 64699a9c..cadf4a71 100644 --- a/src-tauri/src/remote/orchestrate/tests.rs +++ b/src-tauri/src/remote/orchestrate/tests.rs @@ -171,14 +171,14 @@ fn version_36_adds_machine_threads() { store.db.execute_batch("DROP TABLE machine_threads; ALTER TABLE sessions DROP COLUMN restart_interrupted; ALTER TABLE sessions DROP COLUMN stop_requested; PRAGMA user_version=35;").unwrap(); } let store = Store::open(&path).unwrap(); - assert_eq!((crate::storage::SCHEMA_VERSION, store.db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)).unwrap()), (36, 36)); + assert_eq!((crate::storage::SCHEMA_VERSION, store.db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)).unwrap()), (37, 37)); let columns: i64 = store.db.query_row("SELECT (SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name IN ('restart_interrupted','stop_requested'))+(SELECT COUNT(*) FROM pragma_table_info('machine_threads') WHERE name='reported_by')", [], |r| r.get(0)).unwrap(); assert_eq!(columns, 3); // Running the step again (a half-applied upgrade) changes nothing. store.db.execute_batch("PRAGMA user_version=35;").unwrap(); drop(store); let store = Store::open(&path).unwrap(); - assert_eq!(store.db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)).unwrap(), 36); + assert_eq!(store.db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)).unwrap(), 37); let indexed: bool = store.db.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='index' AND name='machine_threads_parent')", [], |r| r.get(0)).unwrap(); assert!(indexed); let project = store.add_project(&std::env::temp_dir()).unwrap(); diff --git a/src-tauri/src/remote/start.rs b/src-tauri/src/remote/start.rs index 3fa7d070..db4029f3 100644 --- a/src-tauri/src/remote/start.rs +++ b/src-tauri/src/remote/start.rs @@ -76,6 +76,7 @@ pub(crate) struct Start { pub provider: String, pub model: String, pub mode: Option, + pub auto_writes: Option, pub effort: Option, /// Images the viewer sent with `image.put` for the first prompt. pub image_ids: Vec, @@ -109,6 +110,7 @@ pub(crate) fn parse(params: &Value) -> Result { provider: name_param(params, "provider")?.ok_or("Missing provider.")?, model: name_param(params, "model")?.ok_or("Missing model.")?, mode: name_param(params, "mode")?, + auto_writes: params["autoWrites"].as_bool(), effort: name_param(params, "effort")?, image_ids: super::images::ids_param(params)?, }) @@ -660,6 +662,9 @@ pub(super) fn create_task(store: &crate::storage::Store, project: &str, start: & if start.effort.is_some() { store.set_reasoning_effort(&task.id, start.effort.as_deref())?; } + if start.auto_writes.is_some() { + store.update(&task.id, None, None, None, None, start.auto_writes)?; + } Ok(task) } @@ -783,6 +788,22 @@ mod tests { } } + #[test] + fn a_start_can_ask_for_auto_approved_writes() { + let ok = json!({"repo": "orbit-labs/orbit", "prompt": "go", "provider": "claude", "model": "sonnet", "autoWrites": true}); + assert_eq!(parse(&ok).unwrap().auto_writes, Some(true)); + let plain = json!({"repo": "orbit-labs/orbit", "prompt": "go", "provider": "claude", "model": "sonnet"}); + assert_eq!(parse(&plain).unwrap().auto_writes, None); + // The host stores the flag on the session it creates, the way effort is applied. + let store = crate::storage::Store::open(Path::new(":memory:")).unwrap(); + let project = store.add_project(&std::env::temp_dir()).unwrap(); + let task = crate::architect::profiles::create_user_task(&store, &project.id, "claude", "sonnet", None, None).unwrap(); + store.update(&task.id, None, None, None, None, Some(true)).unwrap(); + assert!(store.session(&task.id).unwrap().auto_writes); + let quiet = crate::architect::profiles::create_user_task(&store, &project.id, "claude", "sonnet", None, None).unwrap(); + assert!(!store.session(&quiet.id).unwrap().auto_writes, "absent keeps the default off"); + } + #[test] fn clone_progress_is_read_from_git() { assert_eq!(clone_percent("Receiving objects: 50% (500/1000), 1.20 MiB | 2.00 MiB/s"), Some(40)); diff --git a/src-tauri/src/schedules/tests.rs b/src-tauri/src/schedules/tests.rs index 1099da75..b0cfc7d6 100644 --- a/src-tauri/src/schedules/tests.rs +++ b/src-tauri/src/schedules/tests.rs @@ -27,8 +27,8 @@ fn once(store: &Store) -> TaskSchedule { #[test] fn schema_version_is_28_with_schedule_tables() { let store = store(); - assert_eq!(crate::storage::SCHEMA_VERSION, 36); - assert_eq!(store.db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)).unwrap(), 36); + assert_eq!(crate::storage::SCHEMA_VERSION, 37); + assert_eq!(store.db.pragma_query_value(None, "user_version", |r| r.get::<_, i64>(0)).unwrap(), 37); assert!(list(&store, None).unwrap().is_empty()); assert_eq!(settings(&store).unwrap(), SchedulerSettings::default()); } diff --git a/src-tauri/src/session_titles.rs b/src-tauri/src/session_titles.rs index e3e1a6cb..012099ac 100644 --- a/src-tauri/src/session_titles.rs +++ b/src-tauri/src/session_titles.rs @@ -389,7 +389,7 @@ mod tests { let run = start(&db, &sid, "Repair Windows startup paths"); run.suggest(json!({"title":"Automatic replacement"})) .unwrap(); - db.update(&sid, Some(run.original.clone()), None, None, None) + db.update(&sid, Some(run.original.clone()), None, None, None, None) .unwrap(); assert!(!complete(&db, &run)); assert_eq!(db.session(&sid).unwrap().title, run.original); @@ -401,7 +401,7 @@ mod tests { #[test] fn explicitly_named_board_and_placeholder_titles_are_protected() { let (db, sid) = task(); - db.update(&sid, Some("New conversation".into()), None, None, None) + db.update(&sid, Some("New conversation".into()), None, None, None, None) .unwrap(); let id = db.begin_task_turn(&sid, "A very different title").unwrap(); assert_eq!(db.session(&sid).unwrap().title, "New conversation"); @@ -476,7 +476,7 @@ mod tests { let first = start(&db, &sid, "Repair Windows startup paths"); complete(&db, &first); if manual { - db.update(&sid, Some("My investigation".into()), None, None, None) + db.update(&sid, Some("My investigation".into()), None, None, None, None) .unwrap(); } let fork = crate::conversation_fork::at_message(&db, &sid, first.turn).unwrap(); @@ -527,6 +527,7 @@ mod tests { None, None, None, + None, ) .unwrap(); drop(db); @@ -667,7 +668,7 @@ mod tests { "command" => db.begin_task_turn(&sid, "/review all changes").unwrap(), "maintenance" => 0, _ => { - db.update(&sid, Some("My rollout".into()), None, None, None).unwrap(); + db.update(&sid, Some("My rollout".into()), None, None, None, None).unwrap(); db.begin_task_turn(&sid, "Continue from where you left off.").unwrap() } }; diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index f3ab74e1..551b7244 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -72,6 +72,7 @@ pub struct Session { pub reasoning_effort: Option, pub context_usage: Option, pub mode: String, + pub auto_writes: bool, pub status: String, pub created_at: i64, pub updated_at: i64, @@ -161,7 +162,7 @@ pub struct Store { type Result = std::result::Result; /// The newest `PRAGMA user_version`. Every migration step below must run for any /// database older than this, so the guard reads this constant, never a literal. -pub const SCHEMA_VERSION: i64 = 36; +pub const SCHEMA_VERSION: i64 = 37; const SIDEBAR_ORDER: &str = "pinned DESC,sidebar_order IS NULL,sidebar_order,COALESCE(MAX(last_response_at,last_prompt_at),last_response_at,last_prompt_at,created_at) DESC,id"; @@ -531,6 +532,15 @@ impl Store { migration.execute_batch("CREATE INDEX IF NOT EXISTS machine_threads_reported_by ON machine_threads(reported_by) WHERE reported_by IS NOT NULL; PRAGMA user_version=36;").map_err(|e| e.to_string())?; } + if version < 37 { + // The per-task "Auto-approve writes" toggle (issue #200). Older migration + // fixtures may already retain the column. + let has_auto_writes: bool = migration.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('sessions') WHERE name='auto_writes')", [], |r| r.get(0)).map_err(|e| e.to_string())?; + if !has_auto_writes { + migration.execute_batch("ALTER TABLE sessions ADD COLUMN auto_writes INTEGER NOT NULL DEFAULT 0 CHECK(auto_writes IN (0,1));").map_err(|e| e.to_string())?; + } + migration.execute_batch("PRAGMA user_version=37;").map_err(|e| e.to_string())?; + } migration.commit().map_err(|e| e.to_string())?; } let recovery = db.transaction().map_err(|e| e.to_string())?; @@ -807,6 +817,7 @@ impl Store { provider: r.get(3)?, model: r.get(4)?, mode: r.get(5)?, + auto_writes: r.get(31)?, status: r.get(6)?, created_at: r.get(7)?, updated_at: r.get(8)?, @@ -843,7 +854,7 @@ impl Store { pub fn sessions(&self) -> Result> { // Lists only need a card preview. Full details remain in SQLite and are // fetched for the selected task or directly for an agent's Start prompt. - let mut stmt=self.db.prepare(&format!("SELECT id,project_id,title,provider,model,mode,status,created_at,updated_at,archived,resume_id,last_response_at,worktree_path,reasoning_effort,context_used_tokens,context_window,task_stage,substr(task_description,1,512),completed_at,goal_state,codex_settings,worktree_branch,last_prompt_at,pinned,sidebar_order,draft,limited_at,limited_until,resume_at_reset,remote_device,cloud FROM sessions WHERE draft=0 AND id NOT IN (SELECT session_id FROM architect_workers) ORDER BY {SIDEBAR_ORDER} LIMIT 500")).map_err(|e|e.to_string())?; + let mut stmt=self.db.prepare(&format!("SELECT id,project_id,title,provider,model,mode,status,created_at,updated_at,archived,resume_id,last_response_at,worktree_path,reasoning_effort,context_used_tokens,context_window,task_stage,substr(task_description,1,512),completed_at,goal_state,codex_settings,worktree_branch,last_prompt_at,pinned,sidebar_order,draft,limited_at,limited_until,resume_at_reset,remote_device,cloud,auto_writes FROM sessions WHERE draft=0 AND id NOT IN (SELECT session_id FROM architect_workers) ORDER BY {SIDEBAR_ORDER} LIMIT 500")).map_err(|e|e.to_string())?; let rows = stmt .query_map([], Self::session_row) .map_err(|e| e.to_string())?; @@ -854,7 +865,7 @@ impl Store { Ok(sessions) } pub fn session(&self, sid: &str) -> Result { - let mut session = self.db.query_row("SELECT id,project_id,title,provider,model,mode,status,created_at,updated_at,archived,resume_id,last_response_at,worktree_path,reasoning_effort,context_used_tokens,context_window,task_stage,task_description,completed_at,goal_state,codex_settings,worktree_branch,last_prompt_at,pinned,sidebar_order,draft,limited_at,limited_until,resume_at_reset,remote_device,cloud FROM sessions WHERE id=?1",[sid],Self::session_row).optional().map_err(|e|e.to_string())?.ok_or_else(||String::from("Conversation not found."))?; + let mut session = self.db.query_row("SELECT id,project_id,title,provider,model,mode,status,created_at,updated_at,archived,resume_id,last_response_at,worktree_path,reasoning_effort,context_used_tokens,context_window,task_stage,task_description,completed_at,goal_state,codex_settings,worktree_branch,last_prompt_at,pinned,sidebar_order,draft,limited_at,limited_until,resume_at_reset,remote_device,cloud,auto_writes FROM sessions WHERE id=?1",[sid],Self::session_row).optional().map_err(|e|e.to_string())?.ok_or_else(||String::from("Conversation not found."))?; self.attach_links(std::slice::from_mut(&mut session))?; Ok(session) } @@ -1530,6 +1541,7 @@ impl Store { archived: Option, model: Option, mode: Option, + auto_writes: Option, ) -> Result<()> { crate::architect::check_manual(self, sid)?; let session = self.session(sid)?; @@ -1545,17 +1557,18 @@ impl Store { if mode.as_ref().is_some_and(|s| !valid_permission_mode(s)) { return Err("Unknown permission mode.".into()); } - if (model.is_some() || mode.is_some()) + if (model.is_some() || mode.is_some() || auto_writes.is_some()) && matches!(session.status.as_str(), "running" | "waiting") { return Err("Stop the agent before changing its model or approval behavior.".into()); } let changed = self.db.execute("UPDATE sessions SET title=COALESCE(?2,title),title_origin=CASE WHEN ?2 IS NOT NULL THEN 'manual' ELSE title_origin END,archived=COALESCE(?3,archived),model=COALESCE(?4,model),mode=COALESCE(?5,mode), + auto_writes=COALESCE(?7,auto_writes), completed_at=CASE WHEN archived=1 AND ?3=0 AND task_stage='completed' THEN ?6 ELSE completed_at END, reasoning_effort=CASE WHEN model<>COALESCE(?4,model) THEN NULL ELSE reasoning_effort END, context_used_tokens=CASE WHEN model<>COALESCE(?4,model) THEN NULL ELSE context_used_tokens END, context_window=CASE WHEN model<>COALESCE(?4,model) THEN NULL ELSE context_window END, - updated_at=?6 WHERE id=?1 AND ((?4 IS NULL AND ?5 IS NULL) OR status NOT IN ('running','waiting'))",params![sid,title,archived,model,mode,now()]).map_err(|e|e.to_string())?; + updated_at=?6 WHERE id=?1 AND ((?4 IS NULL AND ?5 IS NULL AND ?7 IS NULL) OR status NOT IN ('running','waiting'))",params![sid,title,archived,model,mode,now(),auto_writes]).map_err(|e|e.to_string())?; if changed == 0 { return Err("Stop the agent before changing its model or approval behavior.".into()); } @@ -1992,6 +2005,10 @@ fn handoff_from_provider(content: &str) -> Option { #[path = "storage_sidebar_tests.rs"] mod sidebar_tests; +#[cfg(test)] +#[path = "storage_auto_writes_update_tests.rs"] +mod auto_writes_update_tests; + #[cfg(test)] mod tests { use super::*; @@ -2199,7 +2216,7 @@ mod tests { #[test] fn task_search_matches_titles_and_messages_within_the_project() { let (store, task) = empty_session(); - store.update(&task.id, Some("Fix the login redirect".into()), None, None, None).unwrap(); + store.update(&task.id, Some("Fix the login redirect".into()), None, None, None, None).unwrap(); let other = store.create_session(&task.project_id, "codex", "gpt-5", "full").unwrap(); store.message(&other.id, "user", "Please make the billing page load 100% faster").unwrap(); store.message(&other.id, "assistant", "I will profile the billing page first.").unwrap(); @@ -2413,7 +2430,7 @@ mod tests { store.begin_task_turn(&task.id, "More feedback").unwrap(); store.finish_task_turn(&task.id, true, false).unwrap(); store - .update(&task.id, None, Some(true), None, None) + .update(&task.id, None, Some(true), None, None, None) .unwrap(); assert!(store .begin_task_turn(&task.id, "Cannot run archived") @@ -2687,6 +2704,7 @@ mod tests { None, Some("sonnet".into()), Some("plan".into()), + None, ) .unwrap(); let renamed = store.session(&session.id).unwrap(); @@ -2702,7 +2720,7 @@ mod tests { .unwrap(); store.record_context_usage(&session.id, &usage).unwrap(); store - .update(&session.id, None, None, Some("sonnet".into()), None) + .update(&session.id, None, None, Some("sonnet".into()), None, None) .unwrap(); let updated_model = store.session(&session.id).unwrap(); assert!(updated_model.reasoning_effort.is_none()); @@ -2752,6 +2770,7 @@ mod tests { Some("Must not rename".into()), None, Some("opus".into()), + None, None ) .is_err()); @@ -3196,7 +3215,7 @@ mod tests { store.finish_task_turn(&session.id, true, false).unwrap(); store.begin_task_turn_with_kind(&session.id, "Internal worker check", Some(crate::architect::internal_turns::CoordinationKind::WorkerUpdate)).unwrap(); store.message(&session.id, "tool", "Tool output").unwrap(); - store.update(&session.id, Some("Renamed".into()), None, None, None).unwrap(); + store.update(&session.id, Some("Renamed".into()), None, None, None, None).unwrap(); let saved = serde_json::to_value(store.sessions().unwrap().remove(0)).unwrap(); assert_eq!(saved["lastPromptAt"], prompt_at); } @@ -3255,6 +3274,7 @@ mod tests { Some(true), Some("opus".into()), Some("ask".into()), + None, ) .unwrap(); store @@ -3344,6 +3364,123 @@ mod tests { assert_eq!(newest, SCHEMA_VERSION, "bump SCHEMA_VERSION with every new migration step"); } + /// Schema 37 carries the per-task auto_writes flag: NOT NULL, default 0, + /// and a CHECK that admits only 0 or 1. + #[test] + fn auto_writes_column_arrives_in_the_newest_schema_with_default_zero_and_a_strict_check() { + let (store, session) = empty_session(); + assert_eq!(SCHEMA_VERSION, 37, "the newest schema introduces sessions.auto_writes"); + let version: i64 = store.db.pragma_query_value(None, "user_version", |row| row.get(0)).unwrap(); + assert_eq!(version, 37); + let (notnull, default): (i64, Option) = store + .db + .query_row( + "SELECT \"notnull\", dflt_value FROM pragma_table_info('sessions') WHERE name='auto_writes'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!((notnull, default.as_deref()), (1, Some("0"))); + let stored: i64 = store + .db + .query_row("SELECT auto_writes FROM sessions WHERE id=?1", [&session.id], |row| row.get(0)) + .unwrap(); + assert_eq!(stored, 0, "existing rows default to off"); + assert!(!store.session(&session.id).unwrap().auto_writes); + let invalid = store + .db + .execute("UPDATE sessions SET auto_writes=2 WHERE id=?1", params![session.id]) + .err() + .map(|error| error.to_string()); + assert!( + invalid.as_deref().is_some_and(|error| error.contains("CHECK")), + "only 0 and 1 are storable, got {invalid:?}" + ); + } + + /// Both SELECT lists map the column onto the session card, serialized as + /// autoWrites for the frontend. + #[test] + fn auto_writes_maps_from_the_column_into_session_cards() { + let (store, session) = empty_session(); + store + .db + .execute("UPDATE sessions SET auto_writes=1 WHERE id=?1", params![session.id]) + .unwrap(); + let loaded = store.session(&session.id).unwrap(); + assert!(loaded.auto_writes, "session() maps the column"); + let listed = store.sessions().unwrap(); + assert!(listed + .iter() + .any(|task| task.id == session.id && task.auto_writes), "sessions() maps the column"); + assert_eq!( + serde_json::to_value(&loaded).unwrap()["autoWrites"], + serde_json::json!(true) + ); + } + + /// A database left before the column existed still receives it, with its + /// rows off, through the guarded ALTER path. + #[test] + fn auto_writes_column_is_added_to_an_older_database() { + let dir = std::env::temp_dir().join(format!("pandacode-writes-{}", id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("writes.db"); + let sid = { + let store = Store::open(&path).unwrap(); + let project = store.add_project(&std::env::temp_dir()).unwrap(); + let task = store + .create_session(&project.id, "claude", "sonnet", "ask") + .unwrap(); + store + .db + .execute_batch( + "ALTER TABLE sessions DROP COLUMN auto_writes; PRAGMA user_version=34;", + ) + .unwrap(); + task.id + }; + let reopened = Store::open(&path).unwrap(); + let version: i64 = reopened + .db + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, SCHEMA_VERSION); + let (notnull, default): (i64, Option) = reopened + .db + .query_row( + "SELECT \"notnull\", dflt_value FROM pragma_table_info('sessions') WHERE name='auto_writes'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!((notnull, default.as_deref()), (1, Some("0"))); + let stored: i64 = reopened + .db + .query_row("SELECT auto_writes FROM sessions WHERE id=?1", [&sid], |row| row.get(0)) + .unwrap(); + assert_eq!(stored, 0, "migrated rows keep the toggle off"); + assert!(!reopened.session(&sid).unwrap().auto_writes); + drop(reopened); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Extending the running guard to the toggle must not loosen it for model + /// and mode changes. + #[test] + fn auto_writes_leaves_the_model_and_mode_running_guard_intact() { + let (store, session) = empty_session(); + store.status(&session.id, "running").unwrap(); + let refused = store + .update(&session.id, None, None, Some("sonnet".into()), Some("ask".into()), None) + .unwrap_err(); + assert!(refused.contains("Stop the agent"), "{refused}"); + store.status(&session.id, "idle").unwrap(); + store + .update(&session.id, None, None, Some("sonnet".into()), Some("ask".into()), None) + .unwrap(); + } + #[test] fn migration_30_creates_message_attachments_index() { let (store, _) = empty_session(); @@ -3434,6 +3571,7 @@ mod tests { Some(true), None, None, + None, ) .unwrap(); store @@ -3474,7 +3612,7 @@ mod tests { .create_session(&source.id, "claude", "sonnet", "auto") .unwrap(); store - .update(&task.id, Some("Draft task".into()), None, None, None) + .update(&task.id, Some("Draft task".into()), None, None, None, None) .unwrap(); store .db @@ -3921,7 +4059,8 @@ mod tests { Some("New title".into()), None, None, - Some("full".into()) + Some("full".into()), + None ) .unwrap_err() .contains("Stop the agent")); @@ -3929,7 +4068,7 @@ mod tests { assert_eq!(unchanged.mode, "ask"); assert_eq!(unchanged.title, "New conversation"); store - .update(&session.id, Some("Renamed task".into()), None, None, None) + .update(&session.id, Some("Renamed task".into()), None, None, None, None) .unwrap(); store .update( @@ -3938,16 +4077,17 @@ mod tests { None, None, None, + None, ) .unwrap(); } store.status(&session.id, "idle").unwrap(); store - .update(&session.id, None, None, None, Some("plan".into())) + .update(&session.id, None, None, None, Some("plan".into()), None) .unwrap(); assert_eq!(store.session(&session.id).unwrap().mode, "plan"); assert!(store - .update(&session.id, None, None, None, Some("invalid".into())) + .update(&session.id, None, None, None, Some("invalid".into()), None) .is_err()); assert_eq!(store.session(&session.id).unwrap().mode, "plan"); } diff --git a/src-tauri/src/storage_auto_writes_update_tests.rs b/src-tauri/src/storage_auto_writes_update_tests.rs new file mode 100644 index 00000000..342ebae5 --- /dev/null +++ b/src-tauri/src/storage_auto_writes_update_tests.rs @@ -0,0 +1,64 @@ +// Store::update auto-approve-writes toggle (issue #200): setting and clearing +// the flag, and the refusal while a task is running or waiting. Wired as +// `mod auto_writes_update_tests` next to the sidebar tests in storage.rs. +use super::*; + +fn task() -> (Store, Session) { + let store = Store::open(Path::new(":memory:")).unwrap(); + let project = store.add_project(&std::env::temp_dir()).unwrap(); + let session = store + .create_session(&project.id, "claude", "sonnet", "ask") + .unwrap(); + (store, session) +} + +fn stored_flag(store: &Store, sid: &str) -> i64 { + store + .db + .query_row("SELECT auto_writes FROM sessions WHERE id=?1", [sid], |row| { + row.get(0) + }) + .unwrap() +} + +/// The toggle is a plain per-task setting: set it, clear it, and leave it +/// alone when the update does not mention it. +#[test] +fn auto_writes_updates_set_and_clear_the_flag() { + let (store, session) = task(); + store + .update(&session.id, None, None, None, None, Some(true)) + .unwrap(); + assert_eq!(stored_flag(&store, &session.id), 1); + assert!(store.session(&session.id).unwrap().auto_writes); + store + .update(&session.id, None, None, None, None, Some(false)) + .unwrap(); + assert_eq!(stored_flag(&store, &session.id), 0); + assert!(!store.session(&session.id).unwrap().auto_writes); + // An update about something else never touches the toggle. + store + .update(&session.id, Some("Renamed".into()), None, None, None, None) + .unwrap(); + assert_eq!(stored_flag(&store, &session.id), 0); +} + +/// A running or waiting task keeps its approval posture: the toggle refuses +/// exactly like a model or mode change, and the row stays off. +#[test] +fn auto_writes_updates_are_refused_while_the_task_runs() { + let (store, session) = task(); + for status in ["running", "waiting"] { + store.status(&session.id, status).unwrap(); + let refused = store + .update(&session.id, None, None, None, None, Some(true)) + .unwrap_err(); + assert!(refused.contains("Stop the agent"), "{status}: {refused}"); + assert_eq!(stored_flag(&store, &session.id), 0, "{status} keeps the flag off"); + } + store.status(&session.id, "idle").unwrap(); + store + .update(&session.id, None, None, None, None, Some(true)) + .unwrap(); + assert_eq!(stored_flag(&store, &session.id), 1); +} diff --git a/src-tauri/src/storage_sidebar_tests.rs b/src-tauri/src/storage_sidebar_tests.rs index c4577d9a..db384416 100644 --- a/src-tauri/src/storage_sidebar_tests.rs +++ b/src-tauri/src/storage_sidebar_tests.rs @@ -61,7 +61,7 @@ fn sidebar_preferences_persist_without_changing_activity_or_execution() { assert_eq!(pinned.sidebar_order, Some(0)); // Archive/restore can retain the preference; unpin keeps its rank. store - .update(&pinned.id, None, Some(true), None, None) + .update(&pinned.id, None, Some(true), None, None, None) .unwrap(); assert!(store.session(&pinned.id).unwrap().pinned); let unpinned = store.pin_session(&pinned.id, false).unwrap(); @@ -89,7 +89,7 @@ fn sidebar_filtered_reorder_preserves_hidden_slots_and_activity_fallback() { assert_eq!(ids(&store), expected, "manual order survives new activity"); // An unrelated archived row still has its same place in the global order. store - .update(&tasks[2].id, None, Some(true), None, None) + .update(&tasks[2].id, None, Some(true), None, None, None) .unwrap(); store .reorder_sessions(&[expected[3].clone(), expected[1].clone()]) diff --git a/src-tauri/src/task_controls.rs b/src-tauri/src/task_controls.rs index 9bb7f05c..4104a610 100644 --- a/src-tauri/src/task_controls.rs +++ b/src-tauri/src/task_controls.rs @@ -533,7 +533,7 @@ pub(super) async fn archive_codex_task( .store .lock() .map_err(|e| e.to_string())? - .update(&session_id, None, Some(true), None, None) + .update(&session_id, None, Some(true), None, None, None) } #[tauri::command] @@ -611,7 +611,7 @@ pub(super) async fn rename_task(state: &AppState, sid: &str, title: &str) -> Res .store .lock() .map_err(|e| e.to_string())? - .update(sid, Some(title.into()), None, None, None) + .update(sid, Some(title.into()), None, None, None, None) } #[tauri::command] diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 25de172c..e83b16d6 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -2276,6 +2276,7 @@ mod tests { Some(true), None, None, + None, ) .unwrap(); store diff --git a/src/approval-modes.ts b/src/approval-modes.ts index 63d577ff..178d58f3 100644 --- a/src/approval-modes.ts +++ b/src/approval-modes.ts @@ -4,6 +4,13 @@ export interface ApprovalMode { id: Mode; label: string; description: string; ic const CLAUDE_FAMILY: ProviderId[] = ['claude', 'glm', 'mimo']; +/** Harnesses whose write-class approvals PandaCode can answer through the per-task toggle (issue #200). */ +const AUTO_WRITE_PROVIDERS: ProviderId[] = [...CLAUDE_FAMILY, 'opencode', 'antigravity', 'grok', 'deepseek', 'commandcode', 'cline', 'kilo', 'github-copilot', 'hermes', 'codex']; + +export function supportsAutoWrites(provider: ProviderId): boolean { + return AUTO_WRITE_PROVIDERS.includes(provider); +} + /** Labels describe the native policy for each harness, not a simulated approval layer. */ export function approvalModes(provider: ProviderId, current?: Mode): ApprovalMode[] { // omp is a Pi fork driven through the same bridge, so it shares Pi's policy. diff --git a/src/main.ts b/src/main.ts index b20b5f21..eb02744e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -57,7 +57,7 @@ import { currentTheme, setTheme } from './theme'; import { palette } from './palette'; import { contextSummary, fallbackModel, parseContextUsage, tokenCount } from './model-catalog'; import { openCodeChoices, type OpenCodeModels } from './preview-opencode'; -import { approvalMode, approvalModes } from './approval-modes'; +import { approvalMode, approvalModes, supportsAutoWrites } from './approval-modes'; import { renderTaskBoard, renderTaskDetails, taskStage } from './task-board'; import { streamMarkdown } from './streaming-markdown'; import { TerminalPanel } from './terminal-panel'; @@ -4633,8 +4633,19 @@ async function showMode(anchor: HTMLElement, target = session()): Promise const generation = state.generation, inPanel = s.id !== state.sessionId; const current = () => inPanel ? (btwSideTask() ?? btwDraftTask())?.id === s.id : s.id === state.sessionId; const modes = approvalModes(s.provider, s.mode); - await showChoices(anchor, { title: 'Permissions', width: 320, items: modes.map(mode => ({ ...mode, selected: s.mode === mode.id })), onSelect: async id => { + const autoWritesRow = supportsAutoWrites(s.provider) ? [{ id: 'auto-writes', label: 'Auto-approve write requests', icon: 'edit', separator: true, description: 'Approve file writes and commands automatically. Reads, questions and Plan mode still ask.', selected: s.autoWrites }] : []; + await showChoices(anchor, { title: 'Permissions', width: 320, items: [...modes.map(mode => ({ ...mode, selected: s.mode === mode.id })), ...autoWritesRow], onSelect: async id => { if (!current() || generation !== state.generation || isRunning(s.id) || configuringSessions.has(s.id)) throw new Error('Select the task again to change its permissions.'); + if (id === 'auto-writes') { + configuringSessions.add(s.id); + try { + const next = !s.autoWrites; + await call('update_session', { sessionId: s.id, autoWrites: next }); + s.autoWrites = next; + const entry = state.sessions.find(item => item.id === s.id); if (entry) entry.autoWrites = next; + } finally { configuringSessions.delete(s.id); } + return; + } const chosen = modes.find(mode => mode.id === id); if (!chosen) throw new Error('Choose an available approval behavior.'); if (isBtwDraft(s)) { saveBtwDraft(s, { mode: id as Mode }); return; } configuringSessions.add(s.id); @@ -5548,7 +5559,7 @@ async function sendRemote(s: Session, target: RemoteTarget, draft: string, text: // A failure shows in the strip above the composer with Retry, so it is not also a toast. const started = await trackRemoteStart(s.id, target, startId => call<{ sessionId: string; note?: string }>('remote_start_task', { // The empty New task draft becomes the mirror, so no stray draft is left behind. - sessionId: s.id, deviceId: target.deviceId, projectId: s.projectId, prompt: content, provider: s.provider, model: s.model, mode: s.mode, + sessionId: s.id, deviceId: target.deviceId, projectId: s.projectId, prompt: content, provider: s.provider, model: s.model, mode: s.mode, autoWrites: s.autoWrites, effort: s.reasoningEffort ?? null, worktree: Boolean(s.worktreePath), branch: s.worktreeBranch || state.git?.branch || check.branch || null, startId, imageIds, }), undefined, onStage).catch((error: unknown) => { if (!remoteStartCancels.has(s.id)) console.error(error); return undefined; }); if (!started || started.sessionId !== s.id) { diff --git a/src/overlay-client.ts b/src/overlay-client.ts index 4a9e592f..876e154a 100644 --- a/src/overlay-client.ts +++ b/src/overlay-client.ts @@ -100,7 +100,7 @@ export async function overlayCall(command: string, args: Record message.id === args.messageId && ['user','assistant'].includes(message.role)); if (boundary < 0) throw new Error('Choose a saved message to fork from.'); const id = crypto.randomUUID(), now = Date.now(); - const fork: Session = {id,projectId:source.projectId,worktreePath:null,title:`${source.title} (fork)`,provider:source.provider as ProviderId,model:'default',mode:'ask',status:'idle',createdAt:now,updatedAt:now,lastResponseAt:null,archived:false,resumeId:null,taskStage:'in_progress',taskDescription:'',completedAt:null}; + const fork: Session = {id,projectId:source.projectId,worktreePath:null,title:`${source.title} (fork)`,provider:source.provider as ProviderId,model:'default',mode:'ask',autoWrites:false,status:'idle',createdAt:now,updatedAt:now,lastResponseAt:null,archived:false,resumeId:null,taskStage:'in_progress',taskDescription:'',completedAt:null}; tasks.unshift({id,sessionId:id,kind:'task',title:fork.title,projectId:fork.projectId,projectName:source.projectName,provider:fork.provider,status:fork.status,updatedAt:now}); threads.set(`${id}:`,{messages:history.slice(0,boundary+1).filter(message => !['internal','changes'].includes(message.role)).map((message,index) => ({...message,id:index+1,sessionId:id,turnChanges:null,turnDurationMs:null})),hasOlder:false,liveText:'',status:'idle'}); return fork as T; diff --git a/src/preview.ts b/src/preview.ts index 8f2b1aae..d9e316ff 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -28,15 +28,15 @@ const sampleTestOutput = 'PASS src/components/search.test.ts\n ✓ filters resu function encodeTool(tool: ToolActivity): string { return JSON.stringify({ type: 'tool_activity', version: 1, ...tool }); } let projects: Project[] = [{ id: 'project-1', name: 'orbit', path: '/Users/you/Projects/orbit' }, { id: 'project-2', name: 'pandacode', path: '/Users/you/Projects/pandacode' }]; let sessions: Session[] = [ - { id: 'session-1', projectId: 'project-1', title: 'Make search feel instant', provider: 'claude', model: 'sonnet', mode: 'ask', status: 'idle', createdAt: now - 420000, updatedAt: now, archived: false, worktreePath: null, resumeId: 'preview-session', lastResponseAt: now - 300000 }, - { id: 'session-2', projectId: 'project-1', title: 'Refine the navigation', provider: 'codex', model: '', mode: 'ask', status: 'idle', createdAt: now - 8200000, updatedAt: now - 8000000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null }, - { id: 'session-3', projectId: 'project-1', title: 'Add keyboard shortcuts', provider: 'kimi', model: '', mode: 'ask', status: 'idle', createdAt: now - 86400000, updatedAt: now - 86400000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null }, - { id: 'session-4', projectId: 'project-2', title: 'Explore the project', provider: 'codex', model: '', mode: 'ask', status: 'idle', createdAt: now - 345600000, updatedAt: now - 172800000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: now - 259200000 }, + { id: 'session-1', projectId: 'project-1', title: 'Make search feel instant', provider: 'claude', model: 'sonnet', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 420000, updatedAt: now, archived: false, worktreePath: null, resumeId: 'preview-session', lastResponseAt: now - 300000 }, + { id: 'session-2', projectId: 'project-1', title: 'Refine the navigation', provider: 'codex', model: '', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 8200000, updatedAt: now - 8000000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null }, + { id: 'session-3', projectId: 'project-1', title: 'Add keyboard shortcuts', provider: 'kimi', model: '', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 86400000, updatedAt: now - 86400000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null }, + { id: 'session-4', projectId: 'project-2', title: 'Explore the project', provider: 'codex', model: '', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 345600000, updatedAt: now - 172800000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: now - 259200000 }, ]; -if (typeof location !== 'undefined' && new URLSearchParams(location.search).has('trace')) sessions = [...sessions, { id: 'session-5', projectId: 'project-1', title: 'Polish the trace view', provider: 'claude', model: 'sonnet', mode: 'ask', status: 'idle', createdAt: now - 60000, updatedAt: now + 1000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: now }]; +if (typeof location !== 'undefined' && new URLSearchParams(location.search).has('trace')) sessions = [...sessions, { id: 'session-5', projectId: 'project-1', title: 'Polish the trace view', provider: 'claude', model: 'sonnet', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 60000, updatedAt: now + 1000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: now }]; if (typeof location !== 'undefined' && new URLSearchParams(location.search).has('trace')) sessions = [...sessions, - { id: BRIEF_ID, projectId: 'project-1', title: 'Morning brief', provider: 'claude', model: 'sonnet', mode: 'ask', status: 'idle', createdAt: now - 50000, updatedAt: now + 2000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null }, - { id: GALLERY_ID, projectId: 'project-1', title: 'Tool gallery', provider: 'claude', model: 'sonnet', mode: 'ask', status: 'idle', createdAt: now - 40000, updatedAt: now + 3000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: now - 100000 }]; + { id: BRIEF_ID, projectId: 'project-1', title: 'Morning brief', provider: 'claude', model: 'sonnet', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 50000, updatedAt: now + 2000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null }, + { id: GALLERY_ID, projectId: 'project-1', title: 'Tool gallery', provider: 'claude', model: 'sonnet', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 40000, updatedAt: now + 3000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: now - 100000 }]; // The preview persists sidebar preferences only, never sample conversation data. const THREAD_ORDER_KEY = 'pandacode.preview.threadOrder'; type ThreadOrder = { id: string; pinned: boolean; sidebarOrder: number | null }; @@ -78,7 +78,7 @@ for (const s of sessions) { sessions[2].issue = { kind: 'issue', number: 131, url: 'https://github.com/orbit-labs/orbit/issues/131', title: 'Keyboard shortcut cheat sheet', state: 'open', checkedAt: now }; // Sidebar sections showcase: opt in with ?sections so default session counts stay unchanged. if (typeof location !== 'undefined' && new URLSearchParams(location.search).has('sections')) { - const sample = (id: string, projectId: string, title: string, extra: Partial): Session => ({ id, projectId, title, provider: 'claude', model: 'sonnet', mode: 'ask', status: 'idle', createdAt: now - 7200000, updatedAt: now - 60000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null, pinned: false, sidebarOrder: null, taskStage: 'review', taskDescription: '', completedAt: null, ...extra }); + const sample = (id: string, projectId: string, title: string, extra: Partial): Session => ({ id, projectId, title, provider: 'claude', model: 'sonnet', mode: 'ask', autoWrites: false, status: 'idle', createdAt: now - 7200000, updatedAt: now - 60000, archived: false, worktreePath: null, resumeId: null, lastResponseAt: null, pinned: false, sidebarOrder: null, taskStage: 'review', taskDescription: '', completedAt: null, ...extra }); sessions.push( sample('sections-running', 'project-1', 'Speed up the search index', { status: 'running', taskStage: 'in_progress', lastPromptAt: now - 95000 }), sample('sections-approval', 'project-2', 'Migrate settings storage', { provider: 'codex', model: '', status: 'waiting_approval', taskStage: 'in_progress', lastPromptAt: now - 240000 }), @@ -1270,7 +1270,7 @@ export async function invoke(command: string, args: Record): Pro const boundary = history.findIndex(message => message.id === args.messageId); if (!Number.isSafeInteger(args.messageId) || args.messageId <= 0 || boundary < 0 || !['user', 'assistant'].includes(history[boundary].role)) throw new Error('Choose a saved user message or assistant response to fork.'); const boundaryProvider = historicalProvider(history[boundary], history, source.provider); - const created: Session = { ...structuredClone(source), id: crypto.randomUUID(), pinned: false, sidebarOrder: null, title: [...source.title].slice(0, 193).join('') + ' (fork)', resumeId: null, status: 'idle', archived: false, goalState: null, contextUsage: null, taskStage: 'in_progress', completedAt: null, lastResponseAt: null, lastPromptAt: null, createdAt: Date.now(), updatedAt: Date.now() }; + const created: Session = { ...structuredClone(source), id: crypto.randomUUID(), pinned: false, sidebarOrder: null, title: [...source.title].slice(0, 193).join('') + ' (fork)', resumeId: null, autoWrites: false, status: 'idle', archived: false, goalState: null, contextUsage: null, taskStage: 'in_progress', completedAt: null, lastResponseAt: null, lastPromptAt: null, createdAt: Date.now(), updatedAt: Date.now() }; const copied: Message[] = history.slice(0, boundary + (args.exclusive ? 0 : 1)).filter(message => !['internal', 'changes'].includes(message.role)).map(message => { let content = message.content; if (message.role === 'tool') { @@ -1296,7 +1296,7 @@ export async function invoke(command: string, args: Record): Pro if (previous) { result = previous; break; } if (args.remoteId !== 'preview-remote-conversation') throw new Error('That conversation is not available in this project.'); } else if (!source.resumeId) throw new Error('Start the conversation before forking it.'); - const created: Session = { ...source, id: crypto.randomUUID(), pinned: false, sidebarOrder: null, title: args.remoteId ? 'Review project architecture' : source.title + ' (fork)', resumeId: args.remoteId ?? crypto.randomUUID(), status: 'idle', archived: false, goalState: null, taskStage: 'in_progress', createdAt: Date.now(), updatedAt: Date.now() }; + const created: Session = { ...source, id: crypto.randomUUID(), pinned: false, sidebarOrder: null, title: args.remoteId ? 'Review project architecture' : source.title + ' (fork)', resumeId: args.remoteId ?? crypto.randomUUID(), autoWrites: false, status: 'idle', archived: false, goalState: null, taskStage: 'in_progress', createdAt: Date.now(), updatedAt: Date.now() }; sessions.unshift(created); messages[created.id] = args.remoteId ? [{ id: ++id, sessionId: created.id, role: 'assistant', content: 'This is a sample imported conversation. No remote data was read.', createdAt: Date.now() }] : (messages[source.id] ?? []).map(message => ({ ...message, id: ++id, sessionId: created.id })); result = created; break; } case 'archive_codex_task': { const task = codexTask(args.sessionId); assertIdle(task); task.archived = true; task.updatedAt = Date.now(); break; } @@ -1488,7 +1488,7 @@ export async function invoke(command: string, args: Record): Pro emit('sessions-deleted', { sessionIds: deletedSessionIds }); emit('sessions-changed', undefined); result = { deletedSessionIds }; break; } - case 'create_session': { const s: Session = { id: crypto.randomUUID(), ...args, pinned: false, sidebarOrder: null, draft: Boolean(args.draft), mode: args.mode ?? 'full', title: 'New conversation', status: 'idle', createdAt: Date.now(), updatedAt: Date.now(), archived: false, worktreePath: null, resumeId: null, lastResponseAt: null, lastPromptAt: null, reasoningEffort: null, contextUsage: null, taskStage: 'pending', taskDescription: '', completedAt: null } as Session; sessions.unshift(s); messages[s.id] = []; architect.inherit(s.id); result = s; break; } + case 'create_session': { const s: Session = { id: crypto.randomUUID(), ...args, pinned: false, sidebarOrder: null, draft: Boolean(args.draft), mode: args.mode ?? 'full', autoWrites: false, title: 'New conversation', status: 'idle', createdAt: Date.now(), updatedAt: Date.now(), archived: false, worktreePath: null, resumeId: null, lastResponseAt: null, lastPromptAt: null, reasoningEffort: null, contextUsage: null, taskStage: 'pending', taskDescription: '', completedAt: null } as Session; sessions.unshift(s); messages[s.id] = []; architect.inherit(s.id); result = s; break; } case 'chat_create': { const project = { id: crypto.randomUUID(), name: 'Chat', path: `/Users/you/PandaCode Chats/chat-${crypto.randomUUID().slice(0, 8)}` }; projects.push(project); result = await invoke('create_session', { projectId: project.id, provider: args.provider, model: args.model, mode: args.mode }); @@ -1635,7 +1635,7 @@ export async function invoke(command: string, args: Record): Pro case 'create_worktree': { const source = session(args.sessionId); const worktree = previewWorktree(source, args); - const created: Session = { ...source, id: crypto.randomUUID(), pinned: false, sidebarOrder: null, worktreePath: worktree.path, worktreeBranch: worktree.branch, title: 'New conversation', mode: 'full', status: 'idle', createdAt: Date.now(), updatedAt: Date.now(), archived: false, resumeId: null, lastResponseAt: null, lastPromptAt: null, contextUsage: null, taskStage: 'pending', taskDescription: '', completedAt: null, issue: null, pullRequest: null }; + const created: Session = { ...source, id: crypto.randomUUID(), pinned: false, sidebarOrder: null, worktreePath: worktree.path, worktreeBranch: worktree.branch, title: 'New conversation', mode: 'full', autoWrites: false, status: 'idle', createdAt: Date.now(), updatedAt: Date.now(), archived: false, resumeId: null, lastResponseAt: null, lastPromptAt: null, contextUsage: null, taskStage: 'pending', taskDescription: '', completedAt: null, issue: null, pullRequest: null }; sessions.unshift(created); messages[created.id] = []; architect.inherit(created.id); result = created; break; } case 'attach_worktree': { diff --git a/src/types.ts b/src/types.ts index 0774e5ae..30e40f7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,7 @@ export interface ContextUsage { usedTokens: number; contextWindow: number | null /** Token totals for one task, summed from the harness's own local session logs. */ export interface TaskTokenUsage { input: number; cacheRead: number; cacheWrite: number; output: number; firstUsed: number | null; lastUsed: number | null; } export interface TaskLink { kind: 'issue' | 'pull'; number: number; url: string; title: string; state: string; checkedAt: number; } -export interface Session { pinned?: boolean; sidebarOrder?: number | null; /** Opened from New task; not listed until its first prompt. */ draft?: boolean; /** A usage limit hit by the last run: when, when it resets (null if unknown), and whether to continue then. */ limitedAt?: number | null; limitedUntil?: number | null; resumeAtReset?: boolean; goalState?: import('./command-center').GoalState | null; codexSettings?: { collaborationMode?: { mode: string }; serviceTier?: string | null } | null; id: string; projectId: string; worktreePath: string | null; worktreeBranch?: string | null; title: string; provider: ProviderId; model: string; mode: Mode; status: string; createdAt: number; updatedAt: number; lastResponseAt: number | null; lastPromptAt?: number | null; archived: boolean; resumeId: string | null; reasoningEffort?: string | null; contextUsage?: ContextUsage | null; taskStage?: TaskStage; taskDescription?: string; completedAt?: number | null; issue?: TaskLink | null; pullRequest?: TaskLink | null; /** Set when the task runs on another computer (PandaCode Connect). */ remoteDevice?: { id: string; name: string } | null; /** Set when the task's agent runs on a cloud machine (docs/cloud-agents.md). */ cloud?: { provider: string; label: string; machine: string | null; stage: 'creating' | 'installing' | 'credentials' | 'uploading' | 'ready' | 'running' | 'reconnecting' | 'syncing' | 'stopped' | 'error'; detail: string | null } | null; } +export interface Session { pinned?: boolean; sidebarOrder?: number | null; /** Opened from New task; not listed until its first prompt. */ draft?: boolean; /** A usage limit hit by the last run: when, when it resets (null if unknown), and whether to continue then. */ limitedAt?: number | null; limitedUntil?: number | null; resumeAtReset?: boolean; goalState?: import('./command-center').GoalState | null; codexSettings?: { collaborationMode?: { mode: string }; serviceTier?: string | null } | null; id: string; projectId: string; worktreePath: string | null; worktreeBranch?: string | null; title: string; provider: ProviderId; model: string; mode: Mode; /** Auto-approve write requests (Standard and Accept edits modes only). */ autoWrites: boolean; status: string; createdAt: number; updatedAt: number; lastResponseAt: number | null; lastPromptAt?: number | null; archived: boolean; resumeId: string | null; reasoningEffort?: string | null; contextUsage?: ContextUsage | null; taskStage?: TaskStage; taskDescription?: string; completedAt?: number | null; issue?: TaskLink | null; pullRequest?: TaskLink | null; /** Set when the task runs on another computer (PandaCode Connect). */ remoteDevice?: { id: string; name: string } | null; /** Set when the task's agent runs on a cloud machine (docs/cloud-agents.md). */ cloud?: { provider: string; label: string; machine: string | null; stage: 'creating' | 'installing' | 'credentials' | 'uploading' | 'ready' | 'running' | 'reconnecting' | 'syncing' | 'stopped' | 'error'; detail: string | null } | null; } export interface BranchList { current: string; branches: string[]; defaultBranch: string; } export interface WorktreeStatus { exists: boolean; branch: string; changes: number; clean: boolean; hasUpstream: boolean; ahead: number; merged: boolean; } export interface WorktreeMerge { branch: string; target: string; commits: number; switchedFrom: string | null; } diff --git a/tests/approval-modes.spec.ts b/tests/approval-modes.spec.ts index 9768ee57..7927ac06 100644 --- a/tests/approval-modes.spec.ts +++ b/tests/approval-modes.spec.ts @@ -6,6 +6,11 @@ const savedPermission = (page: Page) => page.evaluate(async () => { const id = document.querySelector('.task-item.selected')?.dataset.id; return id ? (await invoke('get_task', { sessionId: id }))?.mode : undefined; }); +const savedAutoWrites = (page: Page) => page.evaluate(async () => { + const { invoke } = await import((window as any).__pandacodePreviewUrl); + const id = document.querySelector('.task-item.selected')?.dataset.id; + return id ? (await invoke('get_task', { sessionId: id }))?.autoWrites : undefined; +}); test.beforeEach(async ({ page }) => { await page.route('**/src/preview.ts*', async route => { @@ -20,7 +25,7 @@ test('offers Approve for me to Claude Code and keeps it with the task', async ({ const trigger = page.locator('#mode-picker-trigger'); const menu = page.getByRole('dialog', { name: 'Permissions', exact: true }); await trigger.click(); - await expect(menu.getByRole('menuitemradio')).toHaveCount(5); + await expect(menu.getByRole('menuitemradio')).toHaveCount(6); await menu.getByRole('menuitemradio', { name: 'Approve for me', exact: true }).click(); await expect(trigger).toHaveText('Approve for me'); await expect(trigger).toHaveAttribute('title', /auto mode/); @@ -60,3 +65,20 @@ test('describes Plan mode investigation for Claude Code', async ({ page }) => { await expect(page.locator('#mode-picker-trigger')).toHaveAttribute('title', /web lookups run, even outside this project/); await expect.poll(() => savedPermission(page)).toBe('plan'); }); + +test('toggles auto-approve writes on a Claude task and keeps it off the menu for Kimi', async ({ page }) => { + const trigger = page.locator('#mode-picker-trigger'); + const menu = page.getByRole('dialog', { name: 'Permissions', exact: true }); + const row = menu.getByRole('menuitemradio', { name: 'Auto-approve write requests', exact: true }); + await trigger.click(); + await expect(row).toHaveAttribute('aria-checked', 'false'); + await row.click(); + await expect.poll(() => savedAutoWrites(page)).toBe(true); + await trigger.click(); + await expect(row).toHaveAttribute('aria-checked', 'true'); + await page.keyboard.press('Escape'); + // Kimi routes its own permission requests, so the toggle is not offered there. + await page.locator('.task-item[data-id="session-3"]').click(); + await trigger.click(); + await expect(menu.getByRole('menuitemradio', { name: 'Auto-approve write requests', exact: true })).toHaveCount(0); +}); diff --git a/tests/grok.spec.ts b/tests/grok.spec.ts index b0d6607b..1eab7b51 100644 --- a/tests/grok.spec.ts +++ b/tests/grok.spec.ts @@ -55,7 +55,7 @@ test('Grok approval modes describe its permission modes, including read-only pla await newGrokTask(page); await page.locator('#mode-picker-trigger').click(); const items = page.getByRole('dialog', { name: 'Permissions', exact: true }).getByRole('menuitemradio'); - await expect(items).toHaveText([/^Standard/, /^Accept edits/, /^Plan mode/, /^Full access/]); + await expect(items).toHaveText([/^Standard/, /^Accept edits/, /^Plan mode/, /^Full access/, /^Auto-approve write requests/]); const details = await items.evaluateAll(nodes => nodes.map(node => node.querySelector('[data-tip]')?.getAttribute('data-tip') ?? node.querySelector('.choice-menu-description')?.textContent)); expect(details[0]).toContain('Grok asks before edits and commands'); expect(details[2]).toContain('edits and commands are declined'); diff --git a/tests/provider-settings.spec.ts b/tests/provider-settings.spec.ts index 1d89355f..e1da9c22 100644 --- a/tests/provider-settings.spec.ts +++ b/tests/provider-settings.spec.ts @@ -65,7 +65,7 @@ test('provider connections expose Kilo models, approval modes and executable set await expect(trigger).toContainText('Kilo'); await page.locator('#mode-picker-trigger').click(); const permissions = page.getByRole('dialog', { name: 'Permissions', exact: true }); - await expect(permissions.getByRole('menuitemradio')).toHaveCount(4); + await expect(permissions.getByRole('menuitemradio')).toHaveCount(5); await permissions.getByRole('menuitemradio', { name: /Plan mode/ }).click(); await expect(page.locator('#mode-picker-trigger')).toContainText('Plan mode'); await page.getByRole('button', { name: 'Settings', exact: true }).click(); @@ -307,9 +307,9 @@ test('GitHub Copilot CLI has CLI sign-in guidance, a path override, Default mode await expect(page.locator('#model-picker-trigger .provider-logo[data-provider="github-copilot"]')).toBeVisible(); await page.locator('#mode-picker-trigger').click(); const modes = page.getByRole('dialog', { name: 'Permissions', exact: true }).getByRole('menuitemradio'); - await expect(modes).toHaveCount(3); + await expect(modes).toHaveCount(4); await expect(modes.filter({ hasText: 'Accept edits' })).toHaveCount(0); - await expect(modes.filter({ hasText: 'Plan mode' })).toHaveCount(1); + await expect(modes.filter({ hasText: /^Plan mode/ })).toHaveCount(1); await page.keyboard.press('Escape'); const model = page.getByRole('button', { name: 'Choose harness and model', exact: true }); await model.click(); @@ -447,7 +447,7 @@ test('DeepSeek has a key card, four approval modes and effort levels', async ({ await page.locator('.new-task-button').click(); await page.locator('#mode-picker-trigger').click(); const items = page.getByRole('dialog', { name: 'Permissions', exact: true }).getByRole('menuitemradio'); - await expect(items).toHaveCount(4); + await expect(items).toHaveCount(5); await expect(items.filter({ hasText: 'Accept edits' })).toHaveCount(1); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Settings', exact: true }).click(); @@ -465,7 +465,7 @@ test('Command Code has a key card, four approval modes and its own model list', await page.locator('.new-task-button').click(); await page.locator('#mode-picker-trigger').click(); const items = page.getByRole('dialog', { name: 'Permissions', exact: true }).getByRole('menuitemradio'); - await expect(items).toHaveCount(4); + await expect(items).toHaveCount(5); await expect(items.filter({ hasText: 'Accept edits' })).toHaveCount(1); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Settings', exact: true }).click(); @@ -487,9 +487,9 @@ test('Cline has a sign-in card, four approval modes and thinking levels', async await expect(page.locator('.new-task-welcome')).toBeVisible(); await page.locator('#mode-picker-trigger').click(); const items = page.getByRole('dialog', { name: 'Permissions', exact: true }).getByRole('menuitemradio'); - await expect(items).toHaveCount(4); + await expect(items).toHaveCount(5); await expect(items.filter({ hasText: 'Accept edits' })).toHaveCount(1); - await expect(items.filter({ hasText: 'Plan mode' })).toHaveCount(1); + await expect(items.filter({ hasText: /^Plan mode/ })).toHaveCount(1); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Settings', exact: true }).click(); await page.getByRole('region', { name: 'Settings', exact: true }).getByRole('tab', { name: 'Providers', exact: true }).click(); @@ -512,9 +512,9 @@ test('Hermes Agent has an install card, four approval modes, and executable sett await expect(page.locator('.new-task-welcome')).toBeVisible(); await page.locator('#mode-picker-trigger').click(); const items = page.getByRole('dialog', { name: 'Permissions', exact: true }).getByRole('menuitemradio'); - await expect(items).toHaveCount(4); + await expect(items).toHaveCount(5); await expect(items.filter({ hasText: 'Accept edits' })).toHaveCount(1); - await expect(items.filter({ hasText: 'Plan mode' })).toHaveCount(1); + await expect(items.filter({ hasText: /^Plan mode/ })).toHaveCount(1); await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'Settings', exact: true }).click(); await page.getByRole('region', { name: 'Settings', exact: true }).getByRole('tab', { name: 'Providers', exact: true }).click(); diff --git a/tests/ui.spec.ts b/tests/ui.spec.ts index 83329d93..73a674c8 100644 --- a/tests/ui.spec.ts +++ b/tests/ui.spec.ts @@ -1173,8 +1173,8 @@ test('updates permissions after leaving and reselecting a task while the save is }); for (const configuration of [ - { provider: 'Claude Code', sessionId: 'session-1', labels: ['Standard', 'Accept edits', 'Approve for me', 'Plan mode', 'Full access'], selected: 'Plan mode', mode: 'plan' }, - { provider: 'Codex', sessionId: 'session-2', labels: ['Standard', 'Approve for me', 'Read only', 'Full access'], selected: 'Approve for me', mode: 'auto' }, + { provider: 'Claude Code', sessionId: 'session-1', labels: ['Standard', 'Accept edits', 'Approve for me', 'Plan mode', 'Full access', 'Auto-approve write requests'], selected: 'Plan mode', mode: 'plan' }, + { provider: 'Codex', sessionId: 'session-2', labels: ['Standard', 'Approve for me', 'Read only', 'Full access', 'Auto-approve write requests'], selected: 'Approve for me', mode: 'auto' }, { provider: 'Kimi Code', sessionId: 'session-3', labels: ['Standard', 'Ask when needed', 'Plan mode', 'Full access'], selected: 'Full access', mode: 'full' }, ]) { test(`shows supported ${configuration.provider} approval behaviours and remembers the selection for its task`, async ({ page }) => {