diff --git a/AGENT.md b/AGENT.md index 4171b0a..896a26d 100644 --- a/AGENT.md +++ b/AGENT.md @@ -17,7 +17,7 @@ agent-wiki-ui (già `LlmRun`; il nome ha un'unica sorgente, vedi [docs/README.md - Build: `dotnet build` dalla radice (soluzione `agent-wiki-ui.slnx`: app in `src/agent-wiki-ui/`, test in `tests/`; `net10.0`, Avalonia) - GUI: `dotnet run --project src/agent-wiki-ui`; CLI: `dotnet run --project src/agent-wiki-ui -- "prompt"` - Publish single-file (uno per sistema): `scripts/publish.sh` (Linux/WSL/macOS) o `scripts/publish.ps1` (Windows PowerShell): creano `dist/win-x64/` e `dist/linux-x64/` (`dist/` è ignorata da git); i due script vanno tenuti allineati, oppure a mano `dotnet publish src/agent-wiki-ui -c Release -r win-x64 --self-contained -p:PublishSingleFile=true` e `-r linux-x64` -- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale; `tests/FakeClaude` è il `claude` finto per i test di processo). Per ora coprono solo le immagini del renderer (MD-14, MD-19…MD-30); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. +- Test: `dotnet test` dalla radice (xUnit v3 + Avalonia.Headless con Skia reale; `tests/FakeClaude` è il `claude` finto per i test di processo). Per ora coprono le immagini e le formule del renderer, il `FakeClaude` e il motore Antigravity (AG-01…AG-12, UP-21); il resto è pianificato in docs/TESTING.md (Fase 6). Per verificare la GUI senza schermo si può usare `Avalonia.Headless` con `CaptureRenderedFrame()` in un progetto di prova fuori dal repo. ## Architettura diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 54985cd..829a2ac 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -14,7 +14,7 @@ | Motori | Claude Code, Codex, Antigravity | Una chat = un motore; sessione di Codex (`thread_id`) e Antigravity (`conversation_id`) lette dall'output. Sola lettura di default ([ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md)). | | Aggiornamento dei CLI | Funzionante, non provato in reale | Controllo preventivo e pulsante evidenziato; l'`update` vero non è mai stato lanciato dall'app ([ADR-0004](adr/0004-aggiornamento-dei-cli.md)). | | Eseguibili standalone | Funzionanti | `win-x64` e `linux-x64` creati da entrambi i sistemi con `scripts/publish.sh` / `.ps1` (circa 47 MB). Windows senza console al doppio clic. | -| Test automatici | **Non iniziati** | Piano e matrice pronti ([ADR-0003](adr/0003-strategia-di-test.md)); nessun test scritto. Vedi debiti. | +| Test automatici | **Avviati** | Piano e matrice pronti ([ADR-0003](adr/0003-strategia-di-test.md)); scritti i test di immagini e formule del renderer, `FakeClaude` e motore Antigravity (AG-01…AG-12, UP-21). Il resto della matrice manca. Vedi debiti. | | Documentazione | Allineata al codice | Doc-first; ogni funzione ha le righe della matrice in TESTING.md. | ## Criteri di accettazione dell'MVP @@ -28,7 +28,8 @@ Da [MVP.md](MVP.md). Restano non spuntati finché non hanno test automatici (reg | Prompt con virgolette e spazi intatto | Sì, con un `claude` finto su Linux | No (CL-01) | ## Debiti noti -- **Test automatici assenti** (Fase 6): contraddice la regola "nuova funzione = nuovi test" per tutto il codice scritto finora. È il debito principale. +- **Antigravity mai provato con `agy` reale**: flag, forma del JSON, sola lettura in headless, nomi dei modelli e URL dell'auto-updater sono dedotti dal lavoro di Benedetta e coperti solo da test con output finto. +- **Test automatici incompleti** (Fase 6): restano scoperti `Core/` (tranne Antigravity), `MainViewModel`, finestra e criteri di accettazione; contraddice la regola "nuova funzione = nuovi test" per tutto il codice scritto finora. È il debito principale. - **Verifiche su macchine reali**: GUI su Windows nativo e su una distribuzione Linux pulita; doppio clic senza console e riaggancio al terminale (ADR-0005); tema che segue il sistema su Linux; `update` reale dei CLI. - **Impostazioni non salvate su disco**: modello, effort e motore tornano ai valori iniziali a ogni avvio (si parte dal modello più piccolo con effort `low`). - **Nessuno streaming nella GUI**: la risposta compare a fine generazione. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8898e23..c4c2dbf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -32,7 +32,7 @@ Dettagli dell'MVP in [MVP.md](MVP.md). Stadio attuale e debiti: [PROJECT_STATE.m Decisione, tabella dei comandi e rischi: [ADR-0002](adr/0002-motori-cli-claude-codex-antigravity.md). Qui solo i passi. Il punto unico di avvio processo l'ha estratto la Fase 5. - [x] Verificare i flag reali di `codex` e `agy` (provati su Linux/WSL, ADR-0002 accettata per Claude Code, Codex e Antigravity); Windows da verificare -- [x] Astrazione "motore" in C# (`Core/Engine.cs`, `Core/EngineRunner.cs`) +- [x] Astrazione "motore" in C# (`src/agent-wiki-ui/Core/Engine.cs`, `src/agent-wiki-ui/Core/EngineRunner.cs`) - [x] Rilevamento dei motori nel PATH; motori assenti disabilitati, messaggio chiaro - [x] Il JSON di ogni chat memorizza il motore e l'id di sessione; cambiare motore apre una nuova chat - [x] GUI: interruttore CLI nel riquadro Impostazioni; modelli ed effort in base al motore; motore della chat indicato nell'interfaccia diff --git a/docs/TESTING.md b/docs/TESTING.md index 69e878e..34327af 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -25,9 +25,10 @@ ADR (se serve) → MVP.md (requisito e criterio) → riga in questa matrice → ``` agent-wiki-ui.slnx src/agent-wiki-ui/ app (Core/, Gui/, Program.cs) -tests/agent-wiki-ui.Tests/ Support/ (TestApp, PngFactory), Gui/ (R, G), Process/ (P), Core/ (U, quando arrivano), E2E/ (E) +tests/agent-wiki-ui.Tests/ Support/ (TestApp, PngFactory), Gui/ (R, G), Process/ (P), Core/ (U), E2E/ (E) tests/FakeClaude/ eseguibile finto `claude` (vedi sotto) ``` +I percorsi `Core/` e `Gui/` citati in questo documento sono relativi a `src/agent-wiki-ui/`. Una cartella per livello, creata quando c'è il primo test; il livello si dichiara anche con `[Trait("Category", "Unit" | "Gui" | "Process" | "E2E")]`. ### FakeClaude @@ -101,18 +102,18 @@ Le righe CL valgono per ogni motore; queste sono specifiche di Codex (ADR-0002). Le righe CL valgono per ogni motore; queste sono specifiche di Antigravity CLI (`agy`, ADR-0002). | ID | Requisito | Liv. | Test | | |---|---|---|---|---| -| AG-01 | Primo messaggio: `--output-format json --print-timeout 120s … -p ` (senza `--mode`, sola lettura garantita dal default in headless) | U | `Agy_primo_messaggio` | [ ] | -| AG-02 | Ripresa: `--output-format json --conversation … -p ` (senza `--mode`) | U | `Agy_ripresa` | [ ] | -| AG-03 | Tutti i flag precedono rigorosamente `-p ` | U | `Agy_flag_prima_di_p` | [ ] | -| AG-04 | Modello ⇒ `--model`; effort ⇒ `--effort`; vuoti ⇒ omessi | U | `Agy_modello_ed_effort` | [ ] | -| AG-05 | Il parser legge `conversation_id` dall'output JSON (`OwnsSessionId => true`) | U | `Agy_conversation_id` | [ ] | -| AG-06 | La risposta è estratta dal campo `response` del JSON | U | `Agy_estrazione_risposta` | [ ] | -| AG-07 | `status` diverso da SUCCESS o campo `error` ⇒ errore; exit ≠ 0 ⇒ Failed(code, stderr) | U | `Agy_errore_status` | [ ] | -| AG-08 | Avvisi o testo spurio prima/dopo il JSON sono isolati correttamente | U | `Agy_estrazione_json_robusta` | [ ] | -| AG-09 | Exit 0 con `response` vuoto ⇒ errore "agy non ha restituito nessuna risposta" | U | `Agy_nessuna_risposta` | [ ] | -| AG-10 | In modalità CLI (`structured = false`) non si usa `--output-format json` | U | `Agy_cli_senza_json` | [ ] | -| AG-11 | Stdin chiuso subito e `--print-timeout 120s` prevengono il blocco con output rediretto | P | `Agy_timeout_e_stdin` | [ ] | -| AG-12 | Catalogo modelli con i 6 modelli attesi ed effort fino a max; default `gemini-3.0-flash-low`, `low` | U | `Agy_catalogo_modelli` | [ ] | +| AG-01 | Primo messaggio: `--output-format json --print-timeout 120s … -p ` (senza `--mode`, sola lettura garantita dal default in headless) | U | `Agy_primo_messaggio` | [x] | +| AG-02 | Ripresa: `--output-format json --conversation … -p ` (senza `--mode`) | U | `Agy_ripresa` | [x] | +| AG-03 | Tutti i flag precedono rigorosamente `-p ` | U | `Agy_flag_prima_di_p` | [x] | +| AG-04 | Modello ⇒ `--model`; effort ⇒ `--effort`; vuoti ⇒ omessi | U | `Agy_modello_ed_effort` | [x] | +| AG-05 | Il parser legge `conversation_id` dall'output JSON (`OwnsSessionId => true`) | U | `Agy_conversation_id` | [x] | +| AG-06 | La risposta è estratta dal campo `response` del JSON | U | `Agy_estrazione_risposta` | [x] | +| AG-07 | `status` diverso da SUCCESS o campo `error` ⇒ errore; exit ≠ 0 ⇒ Failed(code, stderr) | U | `Agy_errore_status` | [x] | +| AG-08 | Avvisi o testo spurio prima/dopo il JSON sono isolati correttamente | U | `Agy_estrazione_json_robusta` | [x] | +| AG-09 | Exit 0 con `response` vuoto ⇒ errore "agy non ha restituito nessuna risposta" | U | `Agy_nessuna_risposta` | [x] | +| AG-10 | In modalità CLI (`structured = false`) non si usa `--output-format json` | U | `Agy_cli_senza_json` | [x] | +| AG-11 | Stdin chiuso subito e `--print-timeout 120s` prevengono il blocco con output rediretto | P | `Agy_timeout_e_stdin` | [x] | +| AG-12 | Catalogo modelli con i 6 modelli attesi ed effort fino a max; default `gemini-3.0-flash-low`, `low` | U | `Agy_catalogo_modelli` | [x] | ### Aggiornamento dei CLI — `Core/EngineUpdater.cs`, `Gui/MainViewModel.cs` (ADR-0004) Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`; nessun test aggiorna un CLI reale. @@ -138,7 +139,7 @@ Si provano con `FakeClaude`/`FakeCodex` che rispondono a `--version` e `update`; | UP-18 | Disponibile ⇒ riga con "Aggiornamento disponibile: X", pulsante evidenziato e puntino sul pulsante Impostazioni; aggiornato ⇒ nessuno dei tre | U/G | `Evidenziazione_aggiornamento` | [ ] | | UP-19 | Il controllo parte all'avvio e all'apertura del riquadro solo se l'ultimo ha più di 5 minuti; dopo un aggiornamento riuscito si ripete; non blocca invio né interfaccia | U | `Quando_si_controlla` | [ ] | | UP-20 | Il controllo non installa nulla e non modifica i CLI | P | `Controllo_in_sola_lettura` | [ ] | -| UP-21 | Antigravity: l'ultima versione si legge dal servizio auto-updater ufficiale (stringa "Stable Version: X.Y.Z") | U | `Ultima_versione_agy` | [ ] | +| UP-21 | Antigravity: l'ultima versione si legge dal servizio auto-updater ufficiale (stringa "Stable Version: X.Y.Z") | U | `Ultima_versione_agy` | [x] | ### Cronologia — `Core/ChatStore.cs`, `Core/AppInfo.cs` | ID | Requisito | Liv. | Test | | diff --git a/docs/adr/0001-gui-multipiattaforma-avalonia.md b/docs/adr/0001-gui-multipiattaforma-avalonia.md index 4c464dd..10bdc17 100644 --- a/docs/adr/0001-gui-multipiattaforma-avalonia.md +++ b/docs/adr/0001-gui-multipiattaforma-avalonia.md @@ -29,7 +29,7 @@ Oggi non è possibile: WinForms e WebView2 sono solo Windows, il csproj punta a ## Decisione La GUI viene riscritta con **Avalonia UI**: - Layout in XAML, logica in C# con `ViewModel` e binding. `ui.html`, il protocollo `WebMessage` e le dipendenze WinForms/WebView2 spariscono. -- Rendering markdown con un **renderer proprio** che trasforma l'albero di Markdig in controlli Avalonia (`Gui/MarkdownView.cs`), al posto della conversione in HTML. Si è scelto di non usare `Markdown.Avalonia` perché l'ultima versione pubblicata (11.0.x) è per Avalonia 11, mentre si usa Avalonia 12. I link `http(s)` si aprono nel browser di sistema. +- Rendering markdown con un **renderer proprio** che trasforma l'albero di Markdig in controlli Avalonia (`src/agent-wiki-ui/Gui/MarkdownView.cs`), al posto della conversione in HTML. Si è scelto di non usare `Markdown.Avalonia` perché l'ultima versione pubblicata (11.0.x) è per Avalonia 11, mentre si usa Avalonia 12. I link `http(s)` si aprono nel browser di sistema. - Un solo progetto con target `net10.0` (non più `net10.0-windows`). Due publish single-file self-contained: `win-x64` e `linux-x64`. - La CLI (`Program.cs` con argomenti) resta com'è e funziona su entrambi i sistemi. - La cronologia usa `Environment.SpecialFolder.LocalApplicationData` (`%LOCALAPPDATA%` su Windows, `~/.local/share` su Linux), sottocartella `/chats`. Il nome viene da un'unica costante (vedi [Nome del progetto](../README.md#nome-del-progetto)). diff --git a/tests/agent-wiki-ui.Tests/Core/AgyEngineTests.cs b/tests/agent-wiki-ui.Tests/Core/AgyEngineTests.cs new file mode 100644 index 0000000..c010dec --- /dev/null +++ b/tests/agent-wiki-ui.Tests/Core/AgyEngineTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using AgentWikiUi.Core; + +namespace AgentWikiUi.Tests; + +/// Motore Antigravity (docs/TESTING.md → Antigravity, AG-01…AG-10, AG-12, UP-21). +[Trait("Category", "Unit")] +public sealed class AgyEngineTests +{ + static readonly Engine Agy = Engines.Agy; + + sealed class FakeHandler(string body) : HttpMessageHandler + { + public Uri? Requested; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + Requested = request.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); + } + } + + [Fact] // AG-01 + public void Agy_primo_messaggio() => + Assert.Equal(["--output-format", "json", "--print-timeout", "120s", "-p", "ciao"], + Agy.BuildArgs("ciao", null, resume: false, model: "", effort: "", structured: true)); + + [Fact] // AG-02 + public void Agy_ripresa() => + Assert.Equal(["--output-format", "json", "--conversation", "conv-1", "--print-timeout", "120s", "-p", "ancora"], + Agy.BuildArgs("ancora", "conv-1", resume: true, model: "", effort: "", structured: true)); + + [Fact] // AG-03 + public void Agy_flag_prima_di_p() + { + var args = Agy.BuildArgs("ciao", "conv-1", true, "gemini-3.0-flash-low", "high", true); + Assert.Equal(["-p", "ciao"], args[^2..]); + Assert.Single(args, a => a == "-p"); + } + + [Fact] // AG-04 + public void Agy_modello_ed_effort() + { + var con = Agy.BuildArgs("x", null, false, "claude-opus-4-6-thinking", "max", true); + Assert.Equal("claude-opus-4-6-thinking", con[con.IndexOf("--model") + 1]); + Assert.Equal("max", con[con.IndexOf("--effort") + 1]); + + var senza = Agy.BuildArgs("x", null, false, "", "", true); + Assert.DoesNotContain("--model", senza); + Assert.DoesNotContain("--effort", senza); + } + + [Fact] // AG-05 + public void Agy_conversation_id() + { + Assert.True(Agy.OwnsSessionId); + var r = Agy.Parse(0, """{"conversation_id":"abc-123","response":"ok","status":"SUCCESS"}""", ""); + Assert.Equal("abc-123", r.SessionId); + } + + [Fact] // AG-06 + public void Agy_estrazione_risposta() + { + var r = Agy.Parse(0, """{"conversation_id":"c","response":" Ciao **mondo**\n","status":"SUCCESS"}""", ""); + Assert.Equal("Ciao **mondo**", r.Text); + Assert.Null(r.Error); + } + + [Fact] // AG-07 + public void Agy_errore_status() + { + var stato = Agy.Parse(0, """{"conversation_id":"c","response":"","status":"FAILED"}""", ""); + Assert.Equal("Stato FAILED", stato.Error); + + var campo = Agy.Parse(0, """{"conversation_id":"c","status":"SUCCESS","error":"quota esaurita"}""", ""); + Assert.Equal("quota esaurita", campo.Error); + + var uscita = Agy.Parse(2, "", "boom"); + Assert.Equal("", uscita.Text); + Assert.Contains("agy è uscito con codice 2", uscita.Error); + Assert.Contains("boom", uscita.Error); + } + + [Fact] // AG-08 + public void Agy_estrazione_json_robusta() + { + var out_ = "Avviso: aggiornamento disponibile\n{\"conversation_id\":\"c9\",\"response\":\"risposta\",\"status\":\"SUCCESS\"}\nfine\n"; + var r = Agy.Parse(0, out_, ""); + Assert.Equal("risposta", r.Text); + Assert.Equal("c9", r.SessionId); + Assert.Null(r.Error); + } + + [Fact] // AG-09 + public void Agy_nessuna_risposta() + { + Assert.Equal("agy non ha restituito nessuna risposta.", Agy.Parse(0, "", "").Error); + Assert.Equal("agy non ha restituito nessuna risposta.", Agy.Parse(0, """{"conversation_id":"c","response":" ","status":"SUCCESS"}""", "").Error); + Assert.Equal("solo stderr", Agy.Parse(0, "non json", "solo stderr").Error); + } + + [Fact] // AG-10 + public void Agy_cli_senza_json() + { + var args = Agy.BuildArgs("ciao", null, false, "", "", structured: false); + Assert.DoesNotContain("--output-format", args); + Assert.DoesNotContain("json", args); + Assert.Equal(["-p", "ciao"], args[^2..]); + } + + [Fact] // AG-12 + public void Agy_catalogo_modelli() + { + Assert.Equal( + ["gemini-3.0-flash-low", "gemini-3.0-flash-medium", "gemini-3.0-flash-high", + "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium"], + Agy.Models.Select(m => m.Id)); + Assert.Equal(["low", "medium", "high", "xhigh", "max"], Agy.Efforts); + Assert.Equal("gemini-3.0-flash-low", Agy.DefaultModelId); + Assert.Equal("low", Agy.DefaultEffort); + Assert.Contains(Agy.Models, m => m.Id == Agy.DefaultModelId); + Assert.Same(Agy, Engines.Find("agy")); + } + + [Theory] // UP-21 + [InlineData("Stable Version: 1.4.2\nBuild: abc", "1.4.2")] + [InlineData("Stable Version: v2.0", "2.0")] + [InlineData("1.9.9", "1.9.9")] + public async Task Ultima_versione_agy(string body, string expected) + { + var handler = new FakeHandler(body); + using var http = new HttpClient(handler); + Assert.Equal(expected, await Agy.FetchLatestVersionAsync(http, CancellationToken.None)); + Assert.NotNull(handler.Requested); + } + + [Fact] + public void Comando_manuale_di_aggiornamento() + { + Assert.Equal("brew upgrade agy", EngineUpdater.ManualCommand(Agy, InstallMethod.Homebrew)); + Assert.Equal("agy update", EngineUpdater.ManualCommand(Agy, InstallMethod.Npm)); + } +} diff --git a/tests/agent-wiki-ui.Tests/Process/FakeClaudeTests.cs b/tests/agent-wiki-ui.Tests/Process/FakeClaudeTests.cs index 7d6cd28..9342f14 100644 --- a/tests/agent-wiki-ui.Tests/Process/FakeClaudeTests.cs +++ b/tests/agent-wiki-ui.Tests/Process/FakeClaudeTests.cs @@ -86,4 +86,21 @@ public sealed class FakeClaudeTests : IDisposable } finally { if (!p.HasExited) p.Kill(); } } + + [Fact] // AG-11 + public void Agy_timeout_e_stdin() + { + var psi = AgentWikiUi.Core.EngineRunner.CreateStart(AgentWikiUi.Core.Engines.Agy, "ciao", captured: true); + Assert.True(psi.RedirectStandardInput); + Assert.Equal(AppContext.BaseDirectory, psi.WorkingDirectory); + + // Gli argomenti di agy, con stdin chiuso subito, bastano a far terminare il processo finto: nessun blocco. + using var p = Start(psi.ArgumentList); + p.StandardInput.Close(); + Assert.True(p.WaitForExit(10_000)); + + using var line = JsonDocument.Parse(File.ReadAllLines(LogPath).Single()); + var args = line.RootElement.GetProperty("args").EnumerateArray().Select(a => a.GetString()).ToList(); + Assert.Equal("120s", args[args.IndexOf("--print-timeout") + 1]); + } }