From 3e2d08c1531896a48dc99d4924616531489acd41 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 00:41:28 +0000 Subject: [PATCH] =?UTF-8?q?Fase=207=20Onda=2013:=20Modelo=20&=20Onboarding?= =?UTF-8?q?=20=E2=80=94=20par=C3=A2metro=20real=20por=20sess=C3=A3o=20+=20?= =?UTF-8?q?doctor=20real?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit model/agent (SendMessageBody, existentes desde a Onda 1, nunca populados pelo frontend) agora chegam de verdade via Modelo.tsx -> AppContext -> sendMessage -> POST /api/session/:id/message. Fronteira provada por comportamento observável (override real de plan+bash=deny via RuleStore muda o resultado conforme o agent enviado), não só "o campo viajou". GET /api/doctor (forge-cli, zero dependência nova além de forge-tools já usado por sandbox_console) agrega providers/uv/docker/git — reusa Sandbox::ping() e crate::git_sha(), uv com PATH injetável para teste determinístico. Onboarding.tsx sai do DOCTOR_OUTPUT fabricado. max_autonomy_level (SquadTask) permanece intencionalmente NÃO wireado à UI (ADR 0021 nova): confirmado ignorado ponta-a-ponta pelo orquestrador Python hoje (ProgressiveAutonomyManager/agent_trust_scores decide por agente, não por teto de tarefa) — wire-lo seria só "o campo viajou" sem efeito real. Modelo.tsx's seção de autonomia vira bloco informativo, não seletor. Decisões completas em pendencias.md (seção "Onda 13"), incluindo por que esta onda não depende do merge da Onda 12 (branch aberta direto de origin/main, sem sobreposição de arquivo). --- crates/forge-cli/src/doctor_console.rs | 194 ++++++++++++++++++ crates/forge-cli/src/main.rs | 5 +- crates/forge-cli/src/squad.rs | 8 + crates/forge-cli/src/squad_agent.rs | 4 + crates/forge-cli/src/web_agent.rs | 117 +++++++++++ ...021-autonomia-progressiva-escopo-fase-7.md | 91 ++++++++ pendencias.md | 105 ++++++++++ web/src/api/models.ts | 57 ++--- web/src/api/onboarding.ts | 43 ++-- web/src/components/screens/user/Modelo.tsx | 37 ++-- .../components/screens/user/Onboarding.tsx | 52 +++-- web/src/components/screens/user/Sessao.tsx | 2 +- web/src/state/SessionContext.tsx | 13 +- .../doctor-real-backend.spec.ts | 54 +++++ 14 files changed, 696 insertions(+), 86 deletions(-) create mode 100644 crates/forge-cli/src/doctor_console.rs create mode 100644 docs/adr/0021-autonomia-progressiva-escopo-fase-7.md create mode 100644 web/tests/e2e-integration/doctor-real-backend.spec.ts diff --git a/crates/forge-cli/src/doctor_console.rs b/crates/forge-cli/src/doctor_console.rs new file mode 100644 index 0000000..e1cb7ba --- /dev/null +++ b/crates/forge-cli/src/doctor_console.rs @@ -0,0 +1,194 @@ +//! Fase 7 Onda 13 (Modelo & Onboarding): `GET /api/doctor` agrega checagens +//! já existentes mas espalhadas (env vars do gateway, `uv --version`, ping ao +//! Docker via bollard, git) numa única resposta — mesmo padrão de agregação +//! de `GET /api/sandbox`/`GET /api/mcp` (Ondas 7/10). Mora aqui (não em +//! `forge-server`) porque a checagem de Docker precisa de +//! `forge_tools::sandbox::Sandbox` — regra de posicionamento de rota da fase. + +use axum::response::{IntoResponse, Json}; +use axum::routing::get; +use axum::Router; +use forge_tools::sandbox::Sandbox; +use serde::Serialize; + +#[derive(Serialize)] +struct DoctorCheck { + id: &'static str, + ok: bool, + detail: String, +} + +#[derive(Serialize)] +struct DoctorView { + checks: Vec, +} + +/// Mesma ordem/conjunto de `forge-server`'s `KNOWN_PROVIDERS` (Onda 12) — +/// duplicada, não importada: a direção de dependência entre os dois crates +/// só permite `forge-cli` depender de `forge-server`, nunca o contrário, e +/// isto mora em `forge-cli`. Mesma convenção já usada por `git_sha`/ +/// `now_rfc3339`. +const KNOWN_PROVIDERS: [&str; 3] = ["anthropic", "deepseek", "openai"]; + +fn providers_check() -> DoctorCheck { + let gateway = forge_llm::gateway::Gateway::from_env(); + let available: std::collections::HashSet = gateway.available().into_iter().collect(); + let configured = KNOWN_PROVIDERS + .iter() + .filter(|id| available.contains(**id)) + .count(); + DoctorCheck { + id: "providers", + ok: configured > 0, + detail: format!( + "{configured}/{} provider(s) configurado(s)", + KNOWN_PROVIDERS.len() + ), + } +} + +/// `uv --version` com PATH injetável — permite ao teste simular "uv ausente" +/// apontando pra um PATH garantidamente vazio, sem depender do PATH real do +/// processo de teste (mesmo espírito de `Sandbox::ping_with` receber um +/// client já configurado em vez de só `ping()`). Confere `status.success()`, +/// não só se o processo conseguiu ser criado — diferente do guard de teste +/// `uv_missing()` (duplicado em vários `#[cfg(test)]` deste workspace, que só +/// quer saber "existe pra pular o teste"), este é o doctor real mostrado ao +/// usuário, então um `uv` presente mas quebrado deve aparecer como ausente. +fn uv_check_with_path(path_override: Option<&str>) -> DoctorCheck { + let mut cmd = std::process::Command::new("uv"); + cmd.arg("--version"); + if let Some(path) = path_override { + cmd.env("PATH", path); + } + let ok = cmd.output().is_ok_and(|o| o.status.success()); + DoctorCheck { + id: "uv", + ok, + detail: if ok { + "uv encontrado — sidecar Python (squad/PromptForge) disponível".into() + } else { + "uv ausente do PATH — squad/PromptForge ficam indisponíveis".into() + }, + } +} + +/// Reusa `crate::git_sha()` (`main.rs`) — mesma checagem que já formata o sha +/// no cabeçalho do `forge verify`, aqui só reinterpretada como um check +/// ok/detail. `pub(crate)` não é necessário: `git_sha` já é visível a +/// qualquer módulo deste crate (item privado do módulo raiz). +fn git_check() -> DoctorCheck { + match crate::git_sha() { + Some(sha) => DoctorCheck { + id: "git", + ok: true, + detail: format!( + "repositório git detectado — HEAD {}", + &sha[..sha.len().min(8)] + ), + }, + None => DoctorCheck { + id: "git", + ok: false, + detail: "git ausente do PATH ou fora de um repositório".into(), + }, + } +} + +async fn docker_check() -> DoctorCheck { + let ok = Sandbox::ping().await; + DoctorCheck { + id: "docker", + ok, + detail: if ok { + "daemon Docker alcançável — sandbox de skills de terceiro disponível".into() + } else { + "daemon Docker inalcançável — skills de terceiro não rodam confinadas".into() + }, + } +} + +/// `GET /api/doctor` — as 4 checagens de `forge init`, hoje espalhadas, +/// agregadas numa resposta só. Reexecuta tudo a cada request (mesmo estilo +/// síncrono-por-request de `/api/sandbox`/`/api/mcp`) sem cache — o custo é +/// baixo (1 spawn de processo cada + 1 ping Docker com timeout curto). +async fn get_doctor() -> impl IntoResponse { + let checks = vec![ + providers_check(), + uv_check_with_path(None), + docker_check().await, + git_check(), + ]; + Json(DoctorView { checks }) +} + +/// Router aditivo do doctor — `.merge()`ado ao router do agente web, mesma +/// composição de `sandbox_console::router`/`lsp_console::router`. +pub fn router() -> Router { + Router::new().route("/api/doctor", get(get_doctor)) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + /// Determinístico, sem depender do PATH real do processo de teste: aponta + /// pra um PATH vazio, garantidamente sem `uv`. + #[test] + fn uv_ausente_do_path_da_check_false() { + let check = uv_check_with_path(Some("")); + assert!(!check.ok); + assert!(check.detail.contains("ausente")); + } + + /// Fronteira: `providers` bate por igualdade com os mesmos env vars reais + /// que uma sessão real leria — mesma técnica de isolamento de + /// `providers_reflete_env_vars_reais` (`forge-server`, Onda 12): nenhum + /// outro teste deste crate lê essas 3 chaves, então não há disputa com + /// outro teste rodando em paralelo no mesmo binário. `uv`/`docker`/`git` + /// não têm valor fixo afirmado aqui (variam por ambiente — mesmo espírito + /// do teste de `/api/sandbox`); só que vieram bem formados. + #[tokio::test] + async fn doctor_agrega_as_4_checagens_com_providers_real() { + std::env::remove_var("DEEPSEEK_API_KEY"); + std::env::remove_var("OPENAI_API_KEY"); + std::env::set_var("ANTHROPIC_API_KEY", "test-key-onda-13"); + + let app = router(); + let resp = app + .oneshot( + Request::builder() + .uri("/api/doctor") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let checks = json["checks"].as_array().unwrap(); + assert_eq!(checks.len(), 4); + + let ids: Vec<&str> = checks.iter().map(|c| c["id"].as_str().unwrap()).collect(); + assert_eq!(ids, vec!["providers", "uv", "docker", "git"]); + + let providers = checks.iter().find(|c| c["id"] == "providers").unwrap(); + assert_eq!(providers["ok"], true); + assert_eq!(providers["detail"], "1/3 provider(s) configurado(s)"); + + for id in ["uv", "docker", "git"] { + let c = checks.iter().find(|c| c["id"] == id).unwrap(); + assert!( + c["ok"].is_boolean(), + "{id} deveria devolver um bool bem formado" + ); + assert!(c["detail"].is_string()); + } + } +} diff --git a/crates/forge-cli/src/main.rs b/crates/forge-cli/src/main.rs index 0c2a726..1799c62 100644 --- a/crates/forge-cli/src/main.rs +++ b/crates/forge-cli/src/main.rs @@ -6,6 +6,7 @@ //! `squad` ativa o sidecar Python na Fase 4; `verify` completa na Fase 5. mod cache; +mod doctor_console; mod lsp_console; mod mcp_console; mod memory_console; @@ -296,12 +297,14 @@ async fn run_dashboard(port: u16, web_agent: bool) -> Result<()> { let memory_router = memory_console::router(memory_service); let sandbox_router = sandbox_console::router(); let lsp_router = lsp_console::router(root.clone()); + let doctor_router = doctor_console::router(); let extra_router = squad_router .merge(prompt_router) .merge(mcp_router) .merge(memory_router) .merge(sandbox_router) - .merge(lsp_router); + .merge(lsp_router) + .merge(doctor_router); web_agent::serve_with_agent( telemetry, prompt_library, diff --git a/crates/forge-cli/src/squad.rs b/crates/forge-cli/src/squad.rs index 9dbc0f8..f0dbcee 100644 --- a/crates/forge-cli/src/squad.rs +++ b/crates/forge-cli/src/squad.rs @@ -224,6 +224,14 @@ async fn try_squad( let verification_evidence_json = serde_json::to_string(&evidence) .map_err(|e| format!("falha ao serializar evidência: {e}"))?; + // `max_autonomy_level` hardcoded (não uma flag de CLI, nem lido do + // request web): confirmado nesta onda que o campo é ignorado + // ponta-a-ponta hoje — `forge_squad/server.py::ExecuteTask` nunca lê + // `request.max_autonomy_level`; a autonomia real vem de + // `ProgressiveAutonomyManager`/`agent_trust_scores` (`hitl.py`), + // desconectado deste campo do proto. Wire-lo até a UI seria só "o campo + // viajou" sem efeito nenhum — descope explícito (ADR 0021), não + // esquecimento. Ver `pendencias.md` (Onda 13). let stream = client .execute_task(SquadTask { task_id: format!("s{pid:x}", pid = std::process::id()), diff --git a/crates/forge-cli/src/squad_agent.rs b/crates/forge-cli/src/squad_agent.rs index 8b2878d..2fa9314 100644 --- a/crates/forge-cli/src/squad_agent.rs +++ b/crates/forge-cli/src/squad_agent.rs @@ -332,6 +332,10 @@ where .map_err(|e: SidecarError| e.to_string())?; let mut client = lease.client().clone(); + // Hardcoded, não lido de `RunSquadBody`: o campo é ignorado + // ponta-a-ponta pelo Python hoje (ver mesmo comentário em `squad.rs`) — + // wire-lo até a UI seria "o campo viajou" sem efeito real. Descope + // explícito da Onda 13 (ADR 0021), não esquecimento. let mut stream = client .execute_task(SquadTask { task_id: task_id.clone(), diff --git a/crates/forge-cli/src/web_agent.rs b/crates/forge-cli/src/web_agent.rs index a6c6961..be2f001 100644 --- a/crates/forge-cli/src/web_agent.rs +++ b/crates/forge-cli/src/web_agent.rs @@ -1602,6 +1602,123 @@ mod tests { std::env::set_current_dir(orig_cwd).unwrap(); } + /// Onda 13 (Modelo & Onboarding): `model`/`agent` já existiam em + /// `SendMessageBody` desde a Onda 1, mas o frontend nunca os populava — + /// `unwrap_or_else` sempre caía no default. Esta onda liga o frontend; + /// este teste prova que o campo `agent` do corpo HTTP produz + /// comportamento OBSERVÁVEL diferente via `load_rule_overrides(&root, + /// &opts.agent)` — não só "o campo viajou": um override real (mesmo + /// mecanismo persistido da matriz de permissão, Onda 2) para + /// `plan`+`bash` = deny faz a MESMA mensagem roteirizada terminar direto + /// em `tool_denied` quando `agent: "plan"` é enviado, e pedir + /// confirmação (`permission_requested`, o default real de `build`, sem + /// override) quando `agent` nem é enviado. + #[tokio::test(flavor = "multi_thread")] + async fn post_message_respeita_o_agent_do_corpo_via_override_persistido() { + let _guard = lock_cwd().await; + std::env::set_var("FORGE_SCRIPTED", "1"); + let dir = tempfile::tempdir().unwrap(); + let orig_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir.path()).unwrap(); + + open_rule_store(dir.path()) + .unwrap() + .set( + "plan", + "bash", + None, + forge_store::RuleDecision::Deny, + &crate::session::now_rfc3339(), + ) + .unwrap(); + + let hub = SessionHub::new(8, Duration::from_secs(5)); + let app = router(hub.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let client = reqwest::Client::new(); + + // agent="plan": override deny já decide, nunca pede confirmação. + let sse_plan = client + .get(format!("http://{addr}/api/session/plan1/events")) + .send() + .await + .unwrap(); + let mut stream_plan = sse_plan.bytes_stream(); + client + .post(format!("http://{addr}/api/session/plan1/message")) + .json(&serde_json::json!({"message": "diga oi", "agent": "plan"})) + .send() + .await + .unwrap(); + let mut buf_plan = String::new(); + loop { + let chunk = stream_plan.next().await.unwrap().unwrap(); + buf_plan.push_str(std::str::from_utf8(&chunk).unwrap()); + if extract_events(&buf_plan) + .iter() + .any(|e| e.get("type").map(|t| t == "done").unwrap_or(false)) + { + break; + } + } + let events_plan = extract_events(&buf_plan); + assert!( + events_plan + .iter() + .any(|e| e.get("type").map(|t| t == "tool_denied").unwrap_or(false)), + "agent=plan com override deny deveria negar bash sem perguntar: {events_plan:?}" + ); + assert!( + !events_plan.iter().any(|e| e + .get("type") + .map(|t| t == "permission_requested") + .unwrap_or(false)), + "não deveria pedir confirmação — o override já decide sozinho" + ); + + // Sem `agent` no corpo: cai no default "build" (sem override) — bash + // pede confirmação de verdade, comportamento diferente do caso acima. + let sse_build = client + .get(format!("http://{addr}/api/session/build1/events")) + .send() + .await + .unwrap(); + let mut stream_build = sse_build.bytes_stream(); + client + .post(format!("http://{addr}/api/session/build1/message")) + .json(&serde_json::json!({"message": "diga oi"})) + .send() + .await + .unwrap(); + let mut buf_build = String::new(); + let request_id = loop { + let chunk = stream_build.next().await.unwrap().unwrap(); + buf_build.push_str(std::str::from_utf8(&chunk).unwrap()); + let found = extract_events(&buf_build).into_iter().find_map(|e| { + if e.get("type")? == "permission_requested" { + Some(e.get("request_id")?.as_str()?.to_string()) + } else { + None + } + }); + if let Some(id) = found { + break id; + } + }; + client + .post(format!("http://{addr}/api/session/build1/permission")) + .json(&serde_json::json!({"request_id": request_id, "allow": true})) + .send() + .await + .unwrap(); + + std::env::set_current_dir(orig_cwd).unwrap(); + } + #[tokio::test] async fn post_message_alem_do_teto_de_sessoes_recebe_429() { let hub = SessionHub::new(1, Duration::from_secs(5)); diff --git a/docs/adr/0021-autonomia-progressiva-escopo-fase-7.md b/docs/adr/0021-autonomia-progressiva-escopo-fase-7.md new file mode 100644 index 0000000..8e06549 --- /dev/null +++ b/docs/adr/0021-autonomia-progressiva-escopo-fase-7.md @@ -0,0 +1,91 @@ +# ADR 0021 — escopo da autonomia progressiva na Fase 7 (`max_autonomy_level`) + +- Status: aceita +- Data: 2026-07-07 + +## Contexto + +O plano-mestre da Fase 7 (Onda 13, "Modelo & Onboarding") previa duas rotas +possíveis para o campo `max_autonomy_level` (`schemas/proto/squad.proto`, +`SquadTask`): (a) só ligar o valor real escolhido na UI até o campo HTTP, +deixando o orquestrador continuar ignorando-o, com a tela declarando isso +explicitamente; ou (b) também mudar o orquestrador Python para respeitar um +teto de autonomia por tarefa, produzindo comportamento observável diferente +por nível — a fronteira que o próprio plano exige caso "autonomia entre": +"dois `SquadTask` com `max_autonomy_level` diferentes produzem comportamento +diferente (aprovação pedida num caso, não no outro) — não só 'o campo +viajou'". + +Investigação desta onda confirmou que o campo é **ignorado ponta-a-ponta** +hoje, nos dois pontos de entrada: + +- `crates/forge-cli/src/squad.rs` (CLI, `forge squad`) e + `crates/forge-cli/src/squad_agent.rs` (`POST /api/squad/run`, web) — ambos + hardcodam `max_autonomy_level: 3` ao montar `SquadTask`; nenhum dos dois lê + de uma flag de CLI ou de um campo do corpo HTTP (`RunSquadBody` só tem + `task: String`). +- `python/packages/forge-squad/src/forge_squad/server.py`'s `ExecuteTask` + nunca lê `request.max_autonomy_level` ao montar o `task` interno repassado + ao orquestrador — confirmado por grep no pacote inteiro (a única outra + ocorrência do nome é o `FileDescriptorProto` serializado dentro do stub + gerado, `squad_pb2.py`). +- A autonomia que de fato roda vem de um mecanismo **completamente + desconectado** desse campo: `ProgressiveAutonomyManager` + (`forge_squad/hitl.py`), que decide por **agente**, via + `agent_trust_scores` (score inicial 0.5 → nível 2, ajustado ±0.02/-0.1 por + sucesso/falha em `record_action`) — nunca por um teto vindo de fora na + tarefa. + +## Decisão + +**Piso, não degrau — opção (a).** A Onda 13 liga `model`/`agent` (que já +tinham campo em `SendMessageBody` desde a Onda 1, nunca populados pelo +frontend) até a sessão de chat real, porque esses dois genuinamente mudam +comportamento observável (perfil de agente seleciona overlay de permissão +real via `load_rule_overrides`). `max_autonomy_level` **não** é wireado até +a UI. A tela (`Modelo.tsx`) mantém os 3 níveis como um bloco informativo, +não um seletor com efeito: cada um descreve o que acontece hoje (`interativo`: +toda ferramenta "ask" pede confirmação, sem teto por tarefa; `automático`: +não implementado; `somente leitura`: use o perfil de agente "plan", que já +nega edits por padrão) com uma nota explícita de que nada disso é aplicado +pelo orquestrador ainda. + +**Por que não a opção (b):** mudar o orquestrador para respeitar um teto por +tarefa é uma mudança arquitetural real no mecanismo de HITL do squad — trocar +ou aumentar `ProgressiveAutonomyManager`'s decisão-por-agente por um teto +externo por tarefa, cross-linguagem (Rust decide o valor, Python decide o +efeito). Isso está fora do escopo de "Modelo & Onboarding" (uma onda de +telas), e o próprio plano-mestre trata essa mudança como uma decisão +separada, não uma consequência tácita de expor o campo na UI. + +**Por que não wireá-lo mesmo assim, "só para viajar":** faria exatamente o +que o texto do plano nomeia como insuficiente — "o campo viajou" sem +nenhum efeito, uma segunda camada de teatro sobre um campo que já era +teatro (hardcoded `3`, nunca lido). Pior que não wireado: pareceria +funcional (o valor chega ao `SquadTask` de verdade) sem jamais mudar uma +decisão de aprovação. + +## Não-escopo explícito + +- Mudança no orquestrador Python (`ProgressiveAutonomyManager`/`hitl.py`) + para aceitar e respeitar um teto de autonomia por tarefa: não implementada + nesta fase. Fica re-declarada como pendência real, mesmo padrão da + pendência de consenso→ledger da Fase 6 e do próprio `max_autonomy_level` + já citado como descope na Fase 7 original. +- `RunSquadBody`/flag de CLI para autonomia: não adicionados — não faria + sentido adicionar o transporte sem o consumidor real do outro lado. +- Os dois hardcodes (`squad.rs`, `squad_agent.rs`) permanecem `3` — agora + com comentário explícito apontando esta ADR, não silenciosos. + +## Consequências + +- `Modelo.tsx` não tem mais um controle de autonomia com efeito fabricado — + o nível de detalhe da tela cai (não há mais um "●" de seleção), mas nada + nela finge um efeito que não existe. +- Se um futuro trabalho decidir implementar (b), o ponto de entrada já + documentado é: `SquadTask.max_autonomy_level` chega intacto ao Python + (proto já carrega o valor); falta só `ExecuteTask` lê-lo e + `ProgressiveAutonomyManager` (ou um mecanismo novo) passar a respeitá-lo, + mais os dois hardcodes virarem parâmetros reais. +- Nenhum ADR anterior precisa mudar — `ProgressiveAutonomyManager` + (ADR 0006/0007) continua sendo o mecanismo de autonomia real do squad. diff --git a/pendencias.md b/pendencias.md index 11de038..b157407 100644 --- a/pendencias.md +++ b/pendencias.md @@ -990,3 +990,108 @@ ADR 0019, sem decisão em aberto que precisasse deste arquivo. providers vêm antes da legenda), não com um seletor mais específico — mesma classe de armadilha já registrada para `getByText('read-only')` na Onda 10. + +## Onda 13 — Modelo & Onboarding + +- **[achado] `model`/`agent` já existiam em `SendMessageBody` + (`web_agent.rs`) desde a Onda 1, mas o frontend nunca os populava** — + `sendMessage(text)` só mandava `{message}`, então o `unwrap_or_else` + sempre caía no default hardcoded (`"claude-sonnet-5"`/`"build"`), + independente do que `Modelo.tsx` mostrasse selecionado. Zero mudança de + backend foi necessária para o piso desta onda — só `SessionContext.tsx`'s + `sendMessage` ganhar um parâmetro opcional `{model, agent}` e + `Sessao.tsx`'s call site passar `{model: primaryModelName(modelTier), + agent: agentProfile}`. **Fronteira provada, não só "o campo viaja":** um + override real (mesmo mecanismo persistido de `RuleStore` que a matriz de + permissão da Onda 2 usa) para `plan`+`bash` = deny faz a mensagem + roteirizada terminar em `tool_denied` sem perguntar quando `agent: "plan"` + é enviado, e em `permission_requested` (ask, default real de `build`, sem + override) quando `agent` nem é mandado — dois caminhos HTTP idênticos + exceto o campo, comportamento observável diferente + (`post_message_respeita_o_agent_do_corpo_via_override_persistido`). +- **[decisão] `primaryModelName(tier)` (já existente, só usado até agora + pra exibir texto no cabeçalho da sessão) virou o valor real enviado no + campo `model`.** Não fabriquei um catálogo de model-id novo — reusei a + string que o usuário já vê na tela (`MODEL_TIERS[tier].models.split(' + · ')[0]`). Ressalva honesta: `small`'s entrada (`"haiku"`) não é + necessariamente um id de modelo resolvível por um provider real (ao + contrário de `"claude-sonnet-5"`, que já aparece como default em 3 outros + lugares do código) — isso é uma imprecisão PRÉ-EXISTENTE em `MODEL_TIERS` + (Onda 7), não introduzida aqui; corrigir o catálogo de modelos é fora do + escopo de "Modelo & Onboarding" (que é sobre telas, não sobre curar a + lista de modelos suportados). +- **[decisão] `selectTier`/`selectAgentProfile`/`selectAutonomy` + (`api/models.ts`) removidos — eram `simulateLatency(150); return + `, uma chamada fake sem efeito.** Tier/agente não são mais + "selecionados" via uma chamada à parte: a escolha em `Modelo.tsx` fica só + no `AppContext` (dispatch local) e é aplicada de verdade só quando a + próxima mensagem é enviada — parâmetro por sessão/tarefa, mirroring do + CLI (`--model`/`--agent` são flags por invocação, não uma preferência + persistida), não um store de preferência novo. Os toasts refletem isso + ("aplica à próxima mensagem enviada"), não mais um "selecionado"/"ativo" + que sugeria um round-trip imediato ao backend. +- **[decisão — ADR 0021] `max_autonomy_level` NÃO foi wireado até a UI — + descope explícito, não esquecimento.** Confirmado nesta onda: o campo é + ignorado ponta-a-ponta hoje (hardcoded `3` em `squad.rs`+`squad_agent.rs`, + nunca lido por `forge_squad/server.py::ExecuteTask`; a autonomia real vem + de `ProgressiveAutonomyManager`/`agent_trust_scores`, `hitl.py`, + desconectado deste campo do proto). A própria fronteira que o plano exige + para "se autonomia entrar" — comportamento observável diferente por + nível, não só o campo viajando — não seria alcançável sem mudar o + orquestrador Python, uma mudança arquitetural fora do escopo de uma onda + de telas. `Modelo.tsx`'s seção de autonomia virou um bloco informativo + (sem botões, sem estado local, sem toast de sucesso fake) com nota + explícita "não aplicado pelo orquestrador ainda". Os dois hardcodes + (`squad.rs`/`squad_agent.rs`) ganharam comentário apontando a ADR, não + ficaram silenciosos. Detalhe completo na ADR 0021. +- **[decisão] `GET /api/doctor` novo (`crates/forge-cli/src/doctor_console.rs`) + agrega 4 checagens: providers (reusa o mesmo `Gateway::from_env() + .available()` + `KNOWN_PROVIDERS` da Onda 12, duplicado — não importado, + mesma convenção de `git_sha`/`now_rfc3339` entre os dois crates), `uv + --version` (novo, com PATH injetável pra teste determinístico — ver + abaixo), ping Docker (reusa `Sandbox::ping()` da Onda 10 sem mudança), e + git (reusa `crate::git_sha()` de `main.rs` DIRETO — mesmo crate que + `doctor_console.rs`, então nenhuma duplicação faz sentido aqui, diferente + do caso cross-crate forge-server/forge-cli). Mora em `forge-cli` (não + `forge-server`) porque a checagem de Docker precisa de + `forge_tools::sandbox` — mesma regra de posicionamento de rota das + demais telas admin desta fase. +- **[decisão] `uv_check_with_path` checa `status.success()`, não só se o + processo subiu — diferente do guard de teste `uv_missing()` (duplicado em + ~7 arquivos `#[cfg(test)]` deste workspace, que só quer saber "existe pra + pular o teste").** Este é o doctor mostrado ao usuário real: um `uv` + presente mas quebrado (exit ≠0) deve aparecer como ausente, não como + presente. PATH é injetável (`Option<&str>`) pro teste simular "uv + ausente" apontando pra um PATH vazio, sem depender do PATH real do + processo de teste — mesmo espírito de `Sandbox::ping_with` receber um + client já configurado em vez de só `ping()`. +- **[decisão] `onboarding.ts`/`Onboarding.tsx` reescritos: `ENV_KEYS`/ + `DOCTOR_OUTPUT` (arrays estáticos, sempre "tudo verde" exceto os + fallbacks marcados de propósito) saem, `fetchDoctor()` real entra.** + Deliberadamente NÃO importei `fetchProviders`/`api/providers.ts` da Onda + 12 pro card "Chaves de API" — a Onda 12 ainda não tinha mergeado quando + esta onda começou (branch aberta direto de `origin/main`, sem + sobreposição de arquivo com `claude/fase-7-onda-12`, de propósito, pra + não empilhar sem necessidade). O card usa o resumo agregado que o próprio + `/api/doctor` já calcula (`"N/3 provider(s) configurado(s)"`) em vez de + duplicar a lógica de `KNOWN_PROVIDERS` uma terceira vez só pra ter uma + lista por-key aqui também — o detalhe por provider individual mora na + tela Providers, não duplicado nesta. +- **[nota] `AutonomyLevel` (`web/src/types/domain.ts`) ficou sem uso** + depois que `Modelo.tsx` trocou pra um union type literal local — deixado + no arquivo de propósito (não removido) pra não tocar `domain.ts`, que a + Onda 12 (ainda não mergeada) também edita por perto (`ProviderInfo`); + remover é cosmético e pode esperar uma onda que já vá mexer nesse + arquivo por outro motivo. +- **[decisão] Fronteira do doctor por Playwright usa 2 gêmeos genuinamente + determinísticos, por motivos opostos — nenhum hardcoded por + conveniência.** `uv`: gêmeo POSITIVO real (o job `web` do CI instala via + `astral-sh/setup-uv@v5`, precondição já existente pro squad e2e — PATH + herdado pelo processo do dashboard tem `uv` de verdade). `git`: gêmeo + NEGATIVO real (o dashboard roda com `cwd` no `workDir` temporário de + `run-integration-server.mjs`, que nunca é um repositório git — `git + rev-parse HEAD` falha de verdade ali). `docker`/`providers` ficam sem + valor afirmado: docker varia por ambiente (mesma cautela da Onda 10); + providers já tem fronteira determinística a nível Rust + (`doctor_agrega_as_4_checagens_com_providers_real`, com isolamento de env + var) — reafirmar no Playwright seria uma segunda cópia do mesmo teste. diff --git a/web/src/api/models.ts b/web/src/api/models.ts index c3b9b63..40c46db 100644 --- a/web/src/api/models.ts +++ b/web/src/api/models.ts @@ -1,5 +1,4 @@ -import { simulateLatency } from './client' -import type { AgentProfile, AutonomyLevel, ModelTier, ModelTierId } from '../types/domain' +import type { ModelTier, ModelTierId } from '../types/domain' export const MODEL_TIERS: ModelTier[] = [ { id: 'small', models: 'haiku · deepseek-chat', label: 'step-discipline' }, @@ -13,26 +12,36 @@ export function primaryModelName(tier: ModelTierId): string { return found ? found.models.split(' · ')[0] : tier } -export const AUTONOMY_LEVELS: { id: AutonomyLevel; label: string; enabled: boolean }[] = [ - { id: 'interativo', label: 'Interativo', enabled: true }, - { id: 'automatico', label: 'Automático (em dev)', enabled: false }, - { id: 'somente_leitura', label: 'Somente leitura', enabled: true }, +/** + * Fase 7 Onda 13: `selectTier`/`selectAgentProfile`/`selectAutonomy` (mocks + * de `simulateLatency` que só devolviam o mesmo valor recebido) foram + * removidos — tier/agente não são mais "selecionados" via uma chamada à + * parte; a escolha feita em `Modelo.tsx` fica só no `AppContext` (parâmetro + * por sessão/tarefa, mirroring do CLI — sem store de preferência novo) e é + * aplicada de verdade quando a próxima mensagem é enviada + * (`SessionContext::sendMessage`'s `model`/`agent`, que já chegam a + * `SendMessageBody` real no Rust). + * + * `AUTONOMY_LEVELS` continua só informativo: `max_autonomy_level` + * (`SquadTask`) é ignorado ponta-a-ponta pelo orquestrador Python hoje + * (`ProgressiveAutonomyManager`/`agent_trust_scores` decide de verdade, + * `hitl.py`) — descope explícito registrado na ADR 0021, não wireable sem + * fabricar um efeito que não existe. + */ +export const AUTONOMY_LEVELS: { id: 'interativo' | 'automatico' | 'somente_leitura'; label: string; detail: string }[] = [ + { + id: 'interativo', + label: 'Interativo', + detail: 'hoje: toda ferramenta "ask" pede confirmação — não há teto por tarefa.', + }, + { + id: 'automatico', + label: 'Automático', + detail: 'não implementado — o orquestrador não aceita um teto de autonomia por tarefa ainda.', + }, + { + id: 'somente_leitura', + label: 'Somente leitura', + detail: 'use o perfil de agente "plan" (acima) — edits já são negados por padrão nesse perfil.', + }, ] - -/** // TODO: backend Fase 5 — persiste seleção de tier no forge-core, propaga ao gateway LLM. */ -export async function selectTier(tier: ModelTierId): Promise { - await simulateLatency(150) - return tier -} - -/** // TODO: backend Fase 5 — muda o agente ativo (build/plan), recalcula matriz de permissões. */ -export async function selectAgentProfile(profile: AgentProfile): Promise { - await simulateLatency(150) - return profile -} - -/** // TODO: backend Fase 6 — liga a nível de autonomia do hitl.py (ProgressiveAutonomyManager). */ -export async function selectAutonomy(level: AutonomyLevel): Promise { - await simulateLatency(150) - return level -} diff --git a/web/src/api/onboarding.ts b/web/src/api/onboarding.ts index 88ea5f4..74bda2c 100644 --- a/web/src/api/onboarding.ts +++ b/web/src/api/onboarding.ts @@ -1,28 +1,29 @@ -import { simulateLatency } from './client' +/** + * Fase 7 Onda 13 (Modelo & Onboarding): `ENV_KEYS`/`DOCTOR_OUTPUT` (arrays + * estáticos, sempre "tudo verde" exceto os fallbacks marcados de propósito) + * saem do mock — `GET /api/doctor` (`forge-cli`, `doctor_console.rs`) agrega + * as 4 checagens reais (providers do gateway, `uv --version`, ping Docker, + * git). A checagem de providers aqui é só o resumo agregado (`N/3 + * configurado(s)`) — nunca um preview de key mascarada (o backend nunca + * expôs isso, "keys só no Rust", e esta onda não muda essa fronteira). O + * detalhe por provider individual mora na tela Providers (Onda 12) — não + * duplicado aqui; esta onda foi deliberadamente aberta sem depender do + * merge da Onda 12 (branches sem sobreposição de arquivo), então não + * importa `api/providers.ts` daquela onda. + */ +import { fetchJson } from './client' -export interface EnvKeyStatus { - name: string - detected: boolean - masked?: string +export interface DoctorCheck { + id: 'providers' | 'uv' | 'docker' | 'git' + ok: boolean + detail: string } -export const ENV_KEYS: EnvKeyStatus[] = [ - { name: 'ANTHROPIC_API_KEY', detected: true, masked: 'sk-ant-••••9f3a' }, - { name: 'DEEPSEEK_API_KEY', detected: false }, - { name: 'OPENAI_API_KEY', detected: false }, -] - -export const DOCTOR_OUTPUT = [ - '✓ ANTHROPIC_API_KEY definida', - '○ DEEPSEEK_API_KEY ausente (fallback)', - '○ OPENAI_API_KEY ausente (fallback)', - '✓ uv encontrado — sidecar Python disponível', - '✓ git repositório detectado', - '✓ ledger não inicializado — será criado na 1ª sessão', -] +export async function fetchDoctor(): Promise { + const view = await fetchJson<{ checks: DoctorCheck[] }>('/api/doctor') + return view.checks +} -/** // TODO: backend Fase 5 — grava no clipboard via IPC do terminal alvo, hoje usa a Clipboard API do navegador. */ export async function copyToClipboard(text: string): Promise { - await simulateLatency(80) if (navigator.clipboard) await navigator.clipboard.writeText(text) } diff --git a/web/src/components/screens/user/Modelo.tsx b/web/src/components/screens/user/Modelo.tsx index 3ce5159..dc5b6c6 100644 --- a/web/src/components/screens/user/Modelo.tsx +++ b/web/src/components/screens/user/Modelo.tsx @@ -1,15 +1,12 @@ -import { useState } from 'react' import { Card } from '../../primitives/Card' import { useToast } from '../../primitives/Toast' import { useAppDispatch, useAppState } from '../../../state/AppContext' -import { AUTONOMY_LEVELS, MODEL_TIERS, selectAgentProfile, selectAutonomy, selectTier } from '../../../api/models' -import type { AutonomyLevel } from '../../../types/domain' +import { AUTONOMY_LEVELS, MODEL_TIERS } from '../../../api/models' export function Modelo() { const toast = useToast() const dispatch = useAppDispatch() const { modelTier: tier, agentProfile: agent } = useAppState() - const [autonomy, setAutonomy] = useState('interativo') return (
@@ -20,7 +17,7 @@ export function Modelo() {
NÍVEL DE AUTONOMIA
+ {/* Fase 7 Onda 13: informativo, não um seletor — `max_autonomy_level` + (SquadTask) é ignorado ponta-a-ponta pelo orquestrador hoje (ADR + 0021); wire-lo até aqui seria fabricar um efeito que não existe. + A autonomia real é decidida por agente via `agent_trust_scores` + (`ProgressiveAutonomyManager`, hitl.py), não por um teto de tarefa. */}
{AUTONOMY_LEVELS.map((lvl) => ( - + {lvl.label} + {lvl.detail} +
))} +

+ não aplicado pelo orquestrador ainda — descope explícito (ADR 0021), não um controle de verdade. +

janela 200k · cache on · compaction ~75% tier-gated diff --git a/web/src/components/screens/user/Onboarding.tsx b/web/src/components/screens/user/Onboarding.tsx index 8b82822..1e4c4fc 100644 --- a/web/src/components/screens/user/Onboarding.tsx +++ b/web/src/components/screens/user/Onboarding.tsx @@ -1,8 +1,11 @@ +import { useEffect } from 'react' import { Card } from '../../primitives/Card' import { Button } from '../../primitives/Button' +import { AsyncStatus } from '../../primitives/AsyncStatus' import { useAppDispatch } from '../../../state/AppContext' import { useToast } from '../../primitives/Toast' -import { copyToClipboard, DOCTOR_OUTPUT, ENV_KEYS } from '../../../api/onboarding' +import { useAsyncAction } from '../../../hooks/useAsyncAction' +import { copyToClipboard, fetchDoctor } from '../../../api/onboarding' function CodeBlock({ code }: { code: string }) { const toast = useToast() @@ -33,6 +36,12 @@ function CodeBlock({ code }: { code: string }) { export function Onboarding() { const dispatch = useAppDispatch() + const doctorState = useAsyncAction(fetchDoctor) + + useEffect(() => { + void doctorState.run() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) return (
@@ -50,17 +59,22 @@ export function Onboarding() { ◑ Chaves de API etapa atual
- {ENV_KEYS.map((k) => ( -
- {k.name} - - {k.detected ? `✓ definida (${k.masked})` : 'ausente · fallback'} - -
- ))} + void doctorState.run()}> + {(checks) => { + const providers = checks.find((c) => c.id === 'providers') + return ( +
+ providers + + {providers?.detail ?? '—'} + +
+ ) + }} +

- 🔑 keys vivem SÓ no processo Rust. + 🔑 keys vivem SÓ no processo Rust — detalhe por provider na tela Providers.

@@ -87,12 +101,18 @@ export function Onboarding() {
-
$ forge init
- {DOCTOR_OUTPUT.map((line) => ( -
- {line} -
- ))} +
$ forge doctor
+ void doctorState.run()}> + {(checks) => ( + <> + {checks.map((c) => ( +
+ {c.ok ? '✓' : '○'} {c.detail} +
+ ))} + + )} +
diff --git a/web/src/components/screens/user/Sessao.tsx b/web/src/components/screens/user/Sessao.tsx index 4153968..81f9e52 100644 --- a/web/src/components/screens/user/Sessao.tsx +++ b/web/src/components/screens/user/Sessao.tsx @@ -64,7 +64,7 @@ export function Sessao() { const text = input setInput('') try { - await sendMessage(text) + await sendMessage(text, { model: primaryModelName(modelTier), agent: agentProfile }) } catch { toast.push('error', 'falha ao enviar mensagem') } diff --git a/web/src/state/SessionContext.tsx b/web/src/state/SessionContext.tsx index e8a796e..edf099d 100644 --- a/web/src/state/SessionContext.tsx +++ b/web/src/state/SessionContext.tsx @@ -26,7 +26,14 @@ interface SessionContextValue { lastError: string | null /** Contagem real do último `Session::verify()` — `null` antes do 1º turno concluir. */ ledgerVerified: number | null - sendMessage: (message: string) => Promise + /** + * `opts.model`/`opts.agent` (Fase 7 Onda 13) vão direto no corpo de + * `POST .../message` — os mesmos campos que `SendMessageBody` (Rust) já + * aceita desde a Onda 1, antes nunca populados pelo cliente. Parâmetro + * por chamada, não estado persistido (mirroring do CLI: `--model`/ + * `--agent` são flags por invocação, não uma preferência salva). + */ + sendMessage: (message: string, opts?: { model?: string; agent?: string }) => Promise resolvePermission: (allow: boolean) => Promise } @@ -107,7 +114,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { }, [sessionId]) const sendMessage = useCallback( - async (message: string) => { + async (message: string, opts?: { model?: string; agent?: string }) => { setLastError(null) setBusy(true) setTranscript((prev) => [...prev, { id: nextTurnId('u'), kind: 'user', text: message }]) @@ -115,7 +122,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { await fetchJson(`/api/session/${encodeURIComponent(sessionId)}/message`, { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ message }), + body: JSON.stringify({ message, ...opts }), }) } catch (e) { setBusy(false) diff --git a/web/tests/e2e-integration/doctor-real-backend.spec.ts b/web/tests/e2e-integration/doctor-real-backend.spec.ts new file mode 100644 index 0000000..04b964f --- /dev/null +++ b/web/tests/e2e-integration/doctor-real-backend.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '@playwright/test' + +/** Fase 7 Onda 13 (Modelo & Onboarding): prova a fronteira do doctor por + * EXECUÇÃO. `GET /api/doctor` (`forge-cli`, `doctor_console.rs`) agrega 4 + * checagens reais — a tela deixa de mostrar `DOCTOR_OUTPUT` fabricado + * (sempre "tudo verde"). + * + * `uv`/`git` são as duas checagens com valor determinístico NESTE fixture, + * por motivos opostos e ambos genuínos (não hardcoded pra conveniência do + * teste): `uv` está de verdade no PATH herdado pelo processo do dashboard + * (o job `web` do CI instala via `astral-sh/setup-uv@v5`, precondição já + * usada pelo squad e2e) — gêmeo POSITIVO. `git`, ao contrário, é um gêmeo + * NEGATIVO real: o dashboard roda com `cwd` num diretório temporário + * (`run-integration-server.mjs`'s `workDir`), que nunca é um repositório + * git — `git rev-parse HEAD` falha de verdade ali, então o doctor deve + * mostrar "ausente", não "tudo verde" por padrão. `docker`/`providers` não + * têm valor fixo afirmado aqui: `docker` varia por ambiente (mesma cautela + * já usada em `sandbox-real-backend.spec.ts`); a fronteira determinística + * de `providers` já está provada a nível Rust + * (`doctor_agrega_as_4_checagens_com_providers_real`, com isolamento de env + * var) — reafirmar aqui seria uma segunda cópia do mesmo teste. + */ +test('tela de onboarding mostra o doctor real — uv presente, git ausente (workDir não é repo)', async ({ page }) => { + await page.goto('/') + await page.getByRole('button', { name: 'Primeiros passos' }).click() + await expect(page.getByRole('heading', { name: 'Primeiros passos' })).toBeVisible() + + const terminal = page.locator('.mono', { hasText: 'forge doctor' }) + await expect(terminal).toBeVisible({ timeout: 10_000 }) + + await expect(terminal.getByText(/uv encontrado/)).toBeVisible() + await expect(terminal.getByText(/git ausente/)).toBeVisible() + + // 4 checagens reais, nunca a lista fixa antiga (sempre 6 linhas "✓"/"○"). + const providersCard = page.getByText('providers', { exact: true }) + await expect(providersCard).toBeVisible() +}) + +test('backend do doctor fora do ar mostra erro explícito, não o array mock antigo', async ({ page }) => { + await page.route('**/api/doctor', (route) => + route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"boom","code":"forced_failure"}' }), + ) + await page.goto('/') + await page.getByRole('button', { name: 'Primeiros passos' }).click() + await expect(page.getByRole('heading', { name: 'Primeiros passos' })).toBeVisible() + + // A tela tem 2 pontos de leitura do MESMO `doctorState` (card de chaves + + // terminal) — os dois mostram o erro de forma independente, daí `.first()` + // em vez de exigir exatamente 1 match. + await expect(page.getByText('boom').first()).toBeVisible() + await expect(page.getByRole('button', { name: 'tentar de novo' }).first()).toBeVisible() + // O mock antigo tinha essa linha fixa — não pode sobreviver por trás do erro. + await expect(page.getByText('ledger não inicializado')).toHaveCount(0) +})