diff --git a/.plans/opencc-agent-proxy-GRILLED_PLAN.md b/.plans/opencc-agent-proxy-GRILLED_PLAN.md deleted file mode 100644 index 83f9caa..0000000 --- a/.plans/opencc-agent-proxy-GRILLED_PLAN.md +++ /dev/null @@ -1,893 +0,0 @@ -# GRILLED_PLAN - `cc-agent-proxy` + programme Lua `opencc` - -> Compagnon de `.plans/opencc-agent-proxy-plan.md`. Chaque question a une -> recommandation par defaut et un bloc `TODO: answer` que tu remplis. Un -> prochain `/grill-me` lira ce fichier et le condensera en plan v2 pret a -> executer. -> -> Convention : -> - Remplace `TODO: answer` par ta reponse (1-3 lignes suffisent). -> - Si tu choisis une option listee, ecris `Option N` + raison breve. -> - Si tu inventes une 5e option, ecris-la in extenso. -> - Si une question devient sans objet apres une reponse majeure, ecris -> `N/A - voir Q`. -> - Laisse `TODO: answer (optionnel)` tel quel si pas d'avis. - -## Regles de base du PoC - -- Une turtle, un OpenCode, un proxy, une reponse utilisable. Rien de plus. -- Contraintes explicites > design generique. -- Pas de packaging tant que la boucle n'a pas tourne in-game. -- Aucun secret commit. -- La forme des reponses OpenCode est suspecte tant qu'on ne l'a pas vue en vrai. - ---- - -## A. Les 8 questions determinantes - -A repondre en premier. Le reste devient souvent trivial une fois ces choix -trances. - -### A1. Critere d'acceptation du PoC - -Quand dit-on "c'est fini" ? - -- **Option 1 - curl roundtrip** : `curl /ask` retourne une vraie reponse - OpenCode. Pas d'in-game. -- **Option 2 - CraftOS-PC roundtrip** : `opencc "salut"` repond dans - l'emulateur, en pointant sur le proxy local. -- **Option 3 - in-game ATM10** : `opencc` marche sur une vraie turtle apres - config `http.rules`. -- **Option 4 - conversation in-game** : Option 3 + verifier que la session - persistante garde le contexte sur plusieurs prompts. - -Recommandation : **Option 4**. C'est ce qui prouve que le pipeline tient bout -en bout. 1-3 sont des jalons intermediaires. Premier prompt anodin (`dis -bonjour`) avant prompts libres. - -Pourquoi ca compte : decide si async, multi-turtle, logs riches, quotas, -packaging et UX terminale sont hors scope. - -Option 4. Le PoC est fini seulement quand `opencc` marche in-game sur une -vraie turtle et que deux prompts successifs prouvent la session persistante. -Les curls OpenCode faits sur `4242` restent des jalons de contrat API. - -### A2. Autorite et workspace d'OpenCode - -Le proxy parle a un `opencode serve` qui a, potentiellement, acces complet a -des fichiers et a un shell. Une turtle qui prompt = un acteur exterieur qui -peut indirectement declencher des actions cote hote. - -- **Option 1 - workspace dedie jetable** : `opencode serve` lance dans un dir - temporaire (`/tmp/opencc-poc`) sans secrets, sans repo perso. -- **Option 2 - workspace projet dedie** : un dossier `~/opencc-workspace/` - vide ou minimal, persistant entre runs. -- **Option 3 - repo perso** : non, trop risque. -- **Option 4 - sandbox container** : OpenCode dans Docker avec mount limite. - Plus de cout pour le PoC. - -Recommandation : **Option 1**. Workspace jetable, recree a chaque session de -test. Documenter le dir exact dans `.env`. - -Pourquoi ca compte : c'est la vraie frontiere de confiance, plus que le -token Lua. - -Option 1. `opencode serve` doit tourner dans un workspace jetable dedie au PoC, -pas dans ce repo : les endpoints serveur peuvent piloter le TUI et declencher -le LLM, donc la frontiere de confiance est le workspace OpenCode. - -### A3. Cycle de vie de la session - -- **Option 1 - session unique partagee** : le proxy cree une session au boot, - toutes les turtles tapent dedans. Conflits de memoire conversationnelle. -- **Option 2 - session par turtle, persistee cote turtle** : la turtle stocke - `sessionId` dans `settings` ; le proxy se contente de relayer. -- **Option 3 - session ephemere par prompt** : chaque `/ask` cree+detruit. Pas - de continuite. Plus simple, perd le contexte. -- **Option 4 - session par turtle, mappee cote proxy** : table - `turtleId -> sessionId` cote proxy. Necessite d'identifier la turtle. - -Recommandation : **Option 2**. Le proxy reste sans etat ; la turtle envoie -`sessionId` optionnel, le proxy en cree une si absent et le renvoie dans la -reponse. - -Option 2. La turtle garde `opencc.session_id` dans `settings`; le proxy cree -une session si absent et renvoie toujours le `sessionId`. Cela evite un etat -proxy et colle au contrat reel `POST /session` puis `/session/:id/message`. - -### A4. Sync vs async pour `/ask` - -`POST /session/:id/message` peut bloquer longtemps. CC:Tweaked a des -timeouts HTTP (~30s typiques, depend du serveur). - -- **Option 1 - sync stricte** : le proxy attend la fin et renvoie. Risque de - timeout cote turtle. -- **Option 2 - async + polling** : `POST /ask` -> `jobId`, `GET /ask/:jobId` - pour poll. Plus robuste, plus complexe. -- **Option 3 - keepalive / chunked** : inutile en HTTP CC standard. -- **Option 4 - sync now, async later** : on commence en sync (prompts courts - pour PoC), on documente la limite, on bascule async si on l'atteint. API - `/ask` reste compatible. - -Recommandation : **Option 4**. - -Option 4. On demarre en sync avec prompts courts, car `POST /session/:id/message` -attend bien la reponse LLM quand `noReply` est absent. Si les timeouts CC -apparaissent, on ajoute async/polling sans changer le happy path `/ask`. - -### A5. Topologie de deploiement - -Le plan source dit "deploiement HTTPS, RP, firewall, exposition publique -geres separement par Guillaume". - -- **Option 1 - tout sur ta machine perso** : `opencode serve` + - `cc-agent-proxy` en localhost, expose via tunnel (Cloudflare, ngrok). -- **Option 2 - VPS dedie** : meme hote distant, HTTPS via Caddy/nginx. -- **Option 3 - homelab** : Option 2 mais chez toi, derriere ton RP existant. -- **Option 4 - deux hotes separes** : OpenCode sur GPU dedie, proxy ailleurs. - -Conditionne : auth (A6), `OPENCODE_BASE_URL`, qui termine TLS. - -Recommandation : **Option 3** si tu as un homelab, sinon Option 1. Proxy + -opencode sur le meme hote, loopback entre les deux, proxy expose en HTTPS. - -Option 3 pour moi : homelab/RP si expose a une turtle ATM10 reelle. Proxy et -OpenCode restent sur le meme hote, OpenCode en loopback, seul le proxy sort en -HTTPS. Pour dev local, Option 1 avec tunnel reste acceptable. - -### A6. Modele d'authentification turtle <-> proxy - -- **Option 1 - token unique partage** : un seul `PROXY_TOKEN`, meme valeur - sur toutes les turtles via `settings`. Pas de revocation granulaire. -- **Option 2 - token par turtle** : table de tokens cote proxy. Permet - revocation par turtle. -- **Option 3 - HMAC signe** : turtle envoie `os.getComputerID()` + HMAC. - Plus complexe, peu de gain sans rotation. -- **Option 4 - allowlist IP** : impossible, la turtle sort via le proxy HTTP - du serveur Minecraft. - -Recommandation : **Option 1**. Helper `isTokenValid(token)` pour permettre -une table plus tard sans refactor. - -Option 1 pour le PoC. Token unique partage, header seulement, helper -`isTokenValid` pour permettre token par turtle plus tard sans changer le -programme Lua. - -### A7. Forme de la reponse renvoyee a la turtle - -OpenCode retourne `parts`, tool calls, code blocks. La turtle a peu de RAM. - -- **Option 1 - texte plat** : `{ reply }`. Tout ce qui n'est pas texte est - jete. -- **Option 2 - texte + meta** : `{ reply, model, tokens, sessionId }`. -- **Option 3 - parts brutes** : la turtle parse. Trop couteux. -- **Option 4 - texte plat + troncature** : `{ reply, sessionId, truncated }` - avec `MAX_REPLY_CHARS` (ex. 4 KiB). Logs proxy gardent le complet. - -Recommandation : **Option 4**. - -Option 4. Le proxy renvoie `{ reply, sessionId, truncated }`; il concatene les -parts `text` uniquement et tronque pour la turtle. Les parts tools/reasoning -observees dans OpenCode doivent rester cote proxy/logs, pas cote turtle. - -### A8. Scope minimal v1 (in et out) - -- **Option 1 - hello world** : turtle envoie "ping", recoit "pong". Une seule - route, sync, pas de session persistante. -- **Option 2 - conversation 1 turtle / 1 session** : echange sur plusieurs - prompts, sessionId persistante. -- **Option 3 - + outils OpenCode** : agent peut lire/ecrire un fichier dans le - workspace. -- **Option 4 - + multi-turtle** : deux turtles en parallele sans collision. - -Recommandation : **Option 2**. - -Hors-scope explicite du PoC : async, multi-turtles, package ccpm, deploiement -auto, Docker, quotas, UI terminale riche, streaming OpenCode (`/event`). - -Option 2. Scope v1 = une turtle, une session persistante, un prompt par -invocation. Les endpoints TUI sont utiles pour piloter un client humain, mais -le proxy turtle doit utiliser `/session/:id/message` directement. - ---- - -## B. Scope, contraintes de temps, cleanup - -### B1. Limite de temps et plan de bascule - -Si la forme de l'API OpenCode bloque, qu'est-ce qu'on fait ? - -Recommandation : **une seule passe d'implementation**. Si l'API OpenCode bloque, -on arrete d'abstraire et on capture le contrat reel via curl avant de coder. - -Confirme. Une seule passe : avant de coder le client OpenCode, capturer le -contrat reel par curl. On a deja valide `GET /global/health`, `POST /session`, -`POST /session/:id/message`, `GET /session/:id/message` et `/tui/*`. - -### B2. Rollback / cleanup post-PoC - -Comment on nettoie une fois la demo faite ? - -Recommandation : stop proxy, stop `opencode serve`, supprimer sessions de -test, rotation du `PROXY_TOKEN` et du `OPENCODE_PASSWORD` utilises. - -Confirme. Stopper proxy + `opencode serve`, supprimer les sessions PoC si besoin, -et rotation du `PROXY_TOKEN`/`OPENCODE_SERVER_PASSWORD`. Ne jamais garder les -sorties `/provider` dans les logs partages. - ---- - -## C. OpenCode upstream : contrat et comportement - -### C1. Version OpenCode et Basic Auth verifies au premier run - -OpenCode evolue. Le plan suppose Basic Auth active. A valider. - -Recommandation : enregistrer `opencode --version` utilise lors de la -validation ; pinger `/global/health` avec Basic Auth via curl avant de coder -le client. - -Valide sur OpenCode `1.16.2`. `/global/health` retourne -`{"healthy":true,"version":"1.16.2"}`. Basic Auth reste a retester avec -`OPENCODE_SERVER_PASSWORD`; sans password, le serveur avertit qu'il est unsecured. - -### C2. Spec exacte de `POST /session/:id/message` - -Plan : `{ parts: [{ type: "text", text }], model? }`. Marque comme inconnu. - -Recommandation : `curl http://localhost:4096/doc` une fois OpenCode lance, -capturer un sample reel de reponse, le commiter sanitize dans -`cc-agent-proxy/test/fixtures/`. - -Contrat valide : `POST /session/:id/message` body minimal -`{"parts":[{"type":"text","text":"..."}]}`. `noReply:true` cree seulement -le message user; sans `noReply`, OpenCode declenche le LLM et attend la reponse. - -### C3. Provider et modele LLM - -`OPENCODE_MODEL` optionnel. Quel provider Guillaume utilise (anthropic, -openai, ollama local) ? - -Recommandation : laisser defaut OpenCode, ne passer `model` que si -`OPENCODE_MODEL` est defini dans `.env`. - -Confirme. Laisser le modele par defaut OpenCode pour le PoC. Ne passer -`model:{providerID,modelID}` que si `OPENCODE_MODEL` est defini, car l'appel -sans model marche deja avec la session courante. - -### C4. Timeout amont vers OpenCode - -Recommandation : `REQUEST_TIMEOUT_MS` configurable, defaut 60s. Au dela -> -504 cote proxy. - -Confirme. `REQUEST_TIMEOUT_MS=60000` par defaut cote proxy. Le timeout doit -englober l'appel bloquant `/session/:id/message`; 504 public si depassement. - -### C5. Exposition des erreurs OpenCode au turtle - -Recommandation : pas de stack trace ni secret. Renvoyer -`{ error: "...", code: "..." }` avec message public court ; detail dans les -logs proxy uniquement. - -Confirme. Reponse publique courte, code stable, aucun detail provider/config. -Attention particuliere : des endpoints comme `/provider` peuvent exposer du -materiel d'auth, donc logs sanitize obligatoires. - -### C6. SessionId auto-creation et recovery si stale - -Deux comportements distincts : -- `sessionId` absent dans la requete -> proxy en cree un (recommande **oui**). -- `sessionId` fourni mais 404 amont -> ? - - **Option A** : recreer silencieusement. - - **Option B** : renvoyer `{ error: "session_expired" }`, laisser la - turtle decider (`opencc --new`). - -Recommandation : **Option B**. Auto-recreation cacherait une perte de -contexte deroutante. - -Option B. Si `sessionId` absent, creer via `POST /session`. Si `sessionId` -fourni renvoie 404/stale, repondre `session_expired`; la turtle pourra faire -`opencc --new` plutot que perdre le contexte silencieusement. - -### C7. Comportement quand OpenCode est down - -- 502/503 brutal ? -- Retry interne avec backoff ? -- `/health` reflete-t-il l'etat upstream ? - -Recommandation : pas de retry auto, **502** + JSON `{ error: "upstream_down" }`. -`/health` inclut un champ `opencode: "up"/"down"`. - -Confirme. Pas de retry auto. `/health` proxy ping `/global/health`; si down, -`{ proxy: "ok", opencode: "down" }`. `/ask` renvoie 502 -`{ error: "upstream_down" }`. - -### C8. Gestion des parts non-textuelles (tools, code blocks) - -Recommandation : concatener les parts texte, ignorer les non-textuelles dans -la `reply`, mais logger un compteur cote proxy (`parts_text`, `parts_tool`, -`parts_other`). - -Confirme. Les reponses reelles contiennent `text`, `reasoning`, `tool`, -`step-start`, `step-finish`. La `reply` turtle concatene seulement les parts -`text`; les compteurs de parts restent dans les logs proxy. - ---- - -## D. Proxy API shape - -### D1. Routes minimales du proxy - -Plan : `/health`, `/ask`, `/session`. - -- `/session` obligatoire ou implicite via `/ask` ? -- `/health` inclut upstream ? - -Recommandation : -- `GET /health` -> `{ proxy: "ok", opencode: "up"/"down" }`. -- `POST /ask` -> obligatoire, body `{ prompt, sessionId? }`, - response `{ sessionId, reply, truncated }`. -- `POST /session` -> garder, retourne `{ sessionId }`. Pratique pour - initialiser ou nommer une session ; ne casse pas le PoC si on ne l'expose - pas a la turtle. - -Confirme. `/session` peut rester une route debug/initialisation, mais `/ask` -doit creer implicitement si `sessionId` absent. `/health` inclut l'etat -OpenCode via `/global/health`. - -### D2. Codes HTTP stables pour la turtle - -Recommandation : -- `200` succes -- `400` requete invalide (prompt vide, body malforme) -- `401` token absent/invalide -- `413` prompt trop long -- `502` OpenCode down -- `504` OpenCode timeout -- `500` erreur proxy inattendue - -Confirme ces codes. Ajouter `409` uniquement si un jour on gere des jobs async -concurrents par session; hors PoC, la liste suffit. - -### D3. Limites taille de requete - -- Max prompt length ? -- Max body size HTTP ? - -Recommandation : prompt max **8 KiB** (`MAX_PROMPT_CHARS` env), body max -**32 KiB** cote Fastify. Pas de rate-limit dans le PoC. - -Confirme. `MAX_PROMPT_CHARS=8192`, body JSON max 32 KiB, pas de rate-limit dans -l'app. Ces limites protegent surtout CC/Turtle RAM et les logs. - -### D4. Request IDs / correlation - -Recommandation : `requestId` auto-genere cote proxy, present dans tous les -logs, non expose dans la reponse JSON (juste dans header `x-request-id` pour -debug optionnel). - -Confirme. `requestId` genere cote proxy, logge partout, expose seulement via -header `x-request-id` pour debug. Ne pas le mettre dans le JSON turtle par defaut. - ---- - -## E. Stack Node / TypeScript - -### E1. Runtime et gestionnaire de paquets - -Recommandation : **Node 20+**, **pnpm**. Sinon **npm** si tu prefers vanilla. - -Confirme : Node 20+ et pnpm. Si le repo n'a pas deja pnpm, garder npm possible, -mais le proxy doit rester isole dans son sous-dossier. - -### E2. Strictness TypeScript - -Recommandation : `"strict": true` + `noUncheckedIndexedAccess`. -`exactOptionalPropertyTypes` evite (bruit pour peu de valeur en PoC). - -Confirme : `strict` + `noUncheckedIndexedAccess`, sans -`exactOptionalPropertyTypes` pour limiter le bruit PoC. - -### E3. Framework HTTP et validation - -Plan : Fastify + Zod. - -Recommandation : **Fastify** + **Zod** + `fastify-type-provider-zod` pour -validation automatique des routes. Confirme ou propose autre. - -Confirme : Fastify + Zod. Le contrat proxy est petit et beneficie de schemas -explicites pour `prompt`, `sessionId`, `reply`, `truncated`. - -### E4. Logging - -Recommandation : **Pino** (default Fastify). `LOG_LEVEL=info` par defaut, -`debug` en dev. Flag `LOG_BODIES=true` pour inclure prompt/reply ; **off -par defaut**. Jamais logger token ni `OPENCODE_PASSWORD`. Logs : status, -duree, `sessionId` (12 premiers chars), `requestId`, longueurs prompt/reply. - -Confirme. Pino/Fastify, `LOG_BODIES=false` par defaut. Ne pas logger les sorties -brutes OpenCode sensibles (`/provider`, config, headers auth) ; loguer seulement -longueurs, status, duree, requestId, prefix sessionId. - -### E5. Process / lancement / deploiement - -- `tsx` en dev, `node dist/index.js` apres `tsc` en prod ? -- Dockerfile ? systemd ? pm2 ? -- Bind `0.0.0.0` ? Shutdown gracieux ? - -Recommandation : tsx en dev, node dist en prod, **pas de Docker**, pas de -systemd dans le repo (tu deploies a la main). `0.0.0.0` car expose derriere -RP. Shutdown : `app.close()` sur SIGTERM, rien de plus. - -Confirme. Dev avec `tsx`, prod `node dist/index.js`, pas de Docker/systemd dans -le repo. Bind proxy `0.0.0.0` derriere RP, OpenCode en `127.0.0.1`. - -### E6. Tests Node - -Plan : vitest, fetch mocke, `app.inject()`. Pas d'objectif de couverture %. - -Recommandation : couvrir `/ask` happy path, `401`, OpenCode 5xx mock, -prompt vide, prompt trop long, sessionId 404 amont. Fixture OpenCode reelle -(cf C2) utilisee dans `opencode.test.ts`. - -Confirme. Tests vitest avec fetch mocke + `app.inject()`. Ajouter fixture reelle -sanitized de `/session/:id/message` incluant text/reasoning/tool/step parts. - -### E7. Variables d'env (en plus du tableau du plan) - -A ajouter : -- `LOG_LEVEL` (info/debug) -- `LOG_BODIES` (true/false) -- `MAX_REPLY_CHARS` (defaut 4096) -- `MAX_PROMPT_CHARS` (defaut 8192) -- `REQUEST_TIMEOUT_MS` (defaut 60000) -- `OPENCODE_WORKSPACE` (info uniquement, documente le dir A2) - -Recommandation : oui, ajouter les six. `.env.example` les liste, jamais de -secret reel. - -Confirme les six variables. Ajouter aussi un commentaire `.env.example` pour -`OPENCODE_BASE_URL=http://127.0.0.1:4242` et credentials Basic Auth si actives. - -### E8. CORS, body parsing, etat en memoire - -- CORS : non, ce n'est pas un navigateur. -- Body : JSON only. -- Etat : stateless (cf A3 Option 2). - -Recommandation : **garder tel quel**. Confirme ou contredit. - -Confirme. Pas de CORS, JSON only, proxy stateless. Les endpoints `/tui/*` ne -sont pas utilises par le proxy turtle ; ils restent une piste d'integration TUI. - ---- - -## F. Auth, secrets, exposition publique - -### F1. Generation du `PROXY_TOKEN` - -Recommandation : genere a la main (`openssl rand -hex 32`), ecrit dans -`.env`. Si absent au boot, le proxy refuse de demarrer (fail-fast). - -Confirme. Token genere manuellement, fail-fast si absent. Ne jamais le passer en -query string ni l'afficher dans les logs. - -### F2. HTTPS vs HTTP cote turtle - -ATM10 accepte les deux si dans `http.rules`. Token nu en HTTP = leak. - -Recommandation : **HTTPS uniquement**, meme en homelab, via le RP existant. - -Confirme : HTTPS uniquement entre turtle et proxy des que ca sort de localhost. -OpenCode peut rester en HTTP loopback derriere le proxy. - -### F3. Saisie du token sur la turtle - -Le secret ne doit jamais etre dans un fichier Lua commit. - -Recommandation : `settings set opencc.proxy_token ` + `settings.save()` -manuellement dans l'environnement de chaque turtle. Documenter dans le -quickstart. - -Confirme. Configuration manuelle via `settings`; rien de secret dans les fichiers -Lua commit. Documenter commandes fish/bash separement cote host si besoin. - -### F4. Token en query string ? - -Recommandation : **non**, header uniquement. Les query strings se retrouvent -dans les logs RP/proxy. - -Confirme : non. Token en header seulement, jamais query string. - -### F5. Headers acceptes : Bearer et X-Proxy-Token - -Plan : les deux pour simplicite CC. - -Recommandation : accepter `Authorization: Bearer ` ET `X-Proxy-Token`, -documenter Bearer comme prefere. `X-Proxy-Token` reste seulement si CC pose -souci avec `Authorization` (a tester). - -Confirme. Bearer prefere ; `X-Proxy-Token` accepte pour compat CC si -`Authorization` pose souci, a valider en CraftOS-PC/ATM10. - -### F6. Comparaison constant-time - -Recommandation : `crypto.timingSafeEqual` apres normalisation (longueur -egalisee) pour eviter le throw sur taille differente. - -Confirme. `timingSafeEqual` avec buffers normalises pour eviter throw et timing -trivial. - -### F7. Rotation du token - -Recommandation : **hors scope PoC**. Redemarrer proxy + reconfigurer -turtles = acceptable. - -Confirme. Rotation hors scope PoC : redemarrage proxy + reconfiguration turtle. - -### F8. Rate-limit et allowlist IP - -Recommandation : ni l'un ni l'autre dans l'app. Si exposition publique le -demande, c'est au RP / firewall de gerer (donc en dehors du repo). - -Confirme. Rate-limit/allowlist hors app, gere par RP/firewall si necessaire. - -### F9. Basic Auth OpenCode - -Recommandation : credentials uniquement via env (`OPENCODE_USERNAME`, -`OPENCODE_PASSWORD`), valides au boot, sinon fail-fast. - -Renommer cote proxy en `OPENCODE_SERVER_USERNAME`/`OPENCODE_SERVER_PASSWORD` ou -documenter clairement le mapping, car OpenCode utilise ces noms pour proteger -`serve`. Valider au boot par `/global/health` avec Basic Auth. - ---- - -## G. Programme Lua `opencc` - -### G1. Emplacement et packaging - -Plan : `apis/libopencc.lua`, `programs/opencc.lua`, `tests/opencc.lua`. - -Recommandation : pas de `packages/tos-agent/ccpm.json` avant validation -in-game (cf I3). Fichiers nus dans `apis/` et `programs/` pendant le PoC. - -Confirme. Pas de packaging tant que l'in-game n'a pas tourne. Fichiers nus -`apis/libopencc.lua`, `programs/opencc.lua`, `tests/opencc.lua` pour le PoC. - -### G2. Source de config (settings vs fichier vs CLI) - -Clefs `settings` : -- `opencc.proxy_url` (URL complete HTTPS recommandee) -- `opencc.proxy_token` -- `opencc.session_id` (auto-remplie) -- `opencc.timeout` (optionnel) - -Recommandation : **`settings` uniquement** + flags CLI (`--url`, `--token`) -qui overrident pour le debug. Si `proxy_url` ou `proxy_token` manque, -message clair + exit non-zero. Pas de host/port separes. - -Confirme : `settings` uniquement + overrides CLI debug. `opencc.proxy_url`, -`opencc.proxy_token`, `opencc.session_id`, `opencc.timeout` suffisent. - -### G3. Format CLI - -- `opencc --help`, `--version` -- `opencc "prompt"` (argv) ou interactif via `read()` si pas d'arg -- REPL multi-tour ? - -Recommandation : pas de REPL. Un prompt par invocation. Le contexte vient -de la `session_id` persistante. - -Confirme : pas de REPL. Un prompt par invocation ; le contexte vient du -`session_id` persistant. - -### G4. Persistance et reset de la session - -- Persistance : `settings.set('opencc.session_id', id); settings.save()` ou - fichier dedie ? -- Reset : `opencc --new` ? `--reset-session` ? - -Recommandation : **`settings`** (coherence G2) ; flag **`opencc --new`** qui -efface `opencc.session_id` et cree une nouvelle session via le prochain -`/ask`. - -Confirme. `opencc --new` efface `opencc.session_id`; le prochain `/ask` cree -une session. Pas besoin d'appeler `/session` explicitement cote turtle. - -### G5. Affichage de la reponse - -- Print brut ? -- Word-wrap manuel ? -- Pagination ? -- Print metadata (sessionId, model) ? - -Recommandation : print + word-wrap simple sur largeur terminal. Pas de -pagination. Pas de metadata sauf en mode `--verbose` (a ajouter plus tard). - -Confirme. Print avec word-wrap simple, pas de pagination. Metadata uniquement -si `--verbose` est ajoute plus tard. - -### G6. Erreurs reseau et `http.rules` - -`http.post` peut renvoyer `nil` (handshake ko, host bloque par `http.rules`) -ou un response status != 200. - -Recommandation : -- `nil` -> "proxy injoignable. Verifie `opencc.proxy_url` et que l'hote - est autorise dans `http.rules`". -- `401` -> "token invalide". -- `413` -> "prompt trop long". -- `502/504` -> "agent indisponible / trop lent". -- Autre -> message generique + status code. - -Confirme ces messages. Ajouter un cas "session expiree" pour `session_expired` : -"session perdue, relance avec `opencc --new`". - -### G7. Timeout HTTP CC - -`http.post` n'a pas de parametre timeout standard. CC:Tweaked >= 1.97 a -`http.request` avec timeout ; a verifier sur ATM10. - -Recommandation : exposer `opencc.timeout`. Si CC l'accepte, on l'utilise ; -sinon documenter la limite et compter sur A4 Option 4 pour la suite. - -Confirme. Tester si ATM10/CC:Tweaked accepte un timeout via `http.request`; si -non, documenter et garder sync court pour le PoC. - -### G8. Pattern factory et JSON - -Repo : `apis/libccpm.lua` est une factory avec `opts.http` injectable. Meme -chose pour `libopencc` ? Encodage JSON via `textutils.serialiseJSON` ? - -Recommandation : **factory** : `local createOpencc = require('/apis/libopencc'); local opencc = createOpencc({ http = http, settings = settings, computerCraftTimeout = ... })`. JSON via `textutils.serialiseJSON` / `unserialiseJSON`. - -Confirme. Factory injectable comme `libccpm.lua`; JSON via `textutils.serialiseJSON` -et `textutils.unserialiseJSON`. - -### G9. Trim / multiline / vide - -Recommandation : trim final, refus si prompt vide apres trim. Multiline -preserve. - -Confirme. Trim final, refuser prompt vide apres trim, preserver multiline. - -### G10. Tests Lua - -Plan : injection d'un faux `http` via `libtest`. - -Recommandation : `tests/opencc.lua` avec `libtest`, faux `http` qui verifie -URL, headers (Bearer + Content-Type), body JSON, parsing succes, parsing -erreur. Hors-scope : E2E reel avec vrai proxy. - -Confirme. Tests `libtest` avec faux `http`, headers, body JSON, success, -erreurs proxy et sauvegarde `session_id`. - ---- - -## H. Tests et validation - -### H1. Fixture OpenCode capturee - -Recommandation : oui (cf C2). Sanitize, store dans -`cc-agent-proxy/test/fixtures/session-message.json`. Sert de reference pour -`opencode.test.ts`. - -Oui. Capturer une fixture sanitized de la reponse reelle OpenCode. Important : -ne pas y inclure sortie `/provider` ni headers/auth. - -### H2. E2E manuel vs automatise - -Recommandation : **manuel** pour le PoC. Documenter les commandes (cf H7). - -Confirme : E2E manuel pour le PoC. Les commandes de validation doivent etre -documentees, avec versions fish si utilisees par Guillaume. - -### H3. CraftOS-PC headless avant l'in-game - -Recommandation : oui si `just craftos --headless` peut faire `http.post`. -Premier roundtrip en local avant ATM10. - -Oui. CraftOS-PC headless avant ATM10 si possible, mais sans ajouter de harness -standalone Lua. Utiliser les recettes repo existantes. - -### H4. Pre-requis ATM10 a documenter - -A documenter : -- `http` enable cote serveur. -- Hote du proxy autorise dans `http.rules` (allow `*.tondomaine.tld:443` ou - IP). -- DNS / IP joignable depuis le serveur Minecraft. -- Token + URL definis dans les settings de la turtle. -- Provider LLM authentifie cote OpenCode. - -Recommandation : checklist dans `docs/opencc-quickstart.md` ecrite **apres** -la premiere validation reelle. - -Confirme. Ecrire la checklist apres premiere validation reelle, pas avant de -figer des details faux. - -### H5. Hooks `just check` et `just test` - -`pre-commit` = `just check test` (Lua + luacheck). Faut-il y mettre les -tests Node ? - -Recommandation : **non**. Recipe separee `just node-check` (eslint+prettier) -et `just node-test` (vitest) dans un sous-Justfile de `cc-agent-proxy/`. -Eviter une dependance implicite Node pour les commits Lua. - -Confirme. Ne pas coupler Node aux hooks Lua existants pour le PoC ; recipes -Node separees dans le sous-dossier proxy. - -### H6. CI - -Repo : pas de CI publique declaree, juste hooks locaux. - -Recommandation : pas de CI pour le PoC. Plus tard, eventuellement un workflow -GH Actions `node-test` + `lua-check`. - -Confirme. Pas de CI PoC. - -### H7. Transcript de validation manuelle - -Recommandation : apres premier roundtrip reussi, enregistrer les commandes -exactes et outputs sanitize dans `docs/opencc-quickstart.md`, incluant un -deuxieme `/ask` avec le meme `sessionId` pour prouver la persistance. - -Confirme. Transcript manuel sanitize dans `docs/opencc-quickstart.md` apres -roundtrip : `/health`, premier `/ask`, deuxieme `/ask` meme `sessionId`. - ---- - -## I. Architecture / coherence repo - -### I1. Reuse de `apis/net.lua` - -Recommandation : **non**. `net.lua` est modem/rednet. `http.post` direct -cote Lua est le bon choix. - -Confirme. `apis/net.lua` est hors sujet pour HTTP externe. - -### I2. Reuse de `apis/eventloop.lua` - -Recommandation : **non**. Le PoC est request/response synchrone. Re-evaluer -si A4 passe en async (G1 post-PoC). - -Confirme. Pas d'eventloop Lua pour le PoC sync. - -### I3. Packaging ccpm - -Recommandation : **post-PoC**. Apres validation in-game, creer -`packages/tos-agent/ccpm.json` : -- `files` : `apis/libopencc.lua`, `programs/opencc.lua` -- `dependencies` : `tos-core` -- `autostart` : aucun -- Ajouter dans `packages/index.json`. - -Declencheur : PoC reussi + au moins une raison de reinstaller/partager. - -Confirme. Packaging seulement apres PoC in-game reussi et besoin de diffusion. - -### I4. ADR - -Recommandation : ADR-0012 "external agent bridge via HTTPS proxy" ecrit -apres validation. Justifie la presence d'un sous-dossier Node dans un repo -Lua. - -Confirme. ADR apres validation, pour justifier proxy Node + pont agent externe. - -### I5. Conformite CLAUDE.md - -- Bump `_VERSION` sur chaque module Lua modifie. -- `--version` / `--help` sur `opencc`. -- Lua : 2-space indent, `;`, `local function`. -- Node : suit Prettier default. - -Recommandation : appliquer tel quel. - -Confirme. Respecter `_VERSION`, `--help`/`--version`, indent 2 spaces, -semicolons Lua, `local function`. - ---- - -## J. Post-PoC (evolutions, hors scope mais a ne pas peindre dans un coin) - -Reponds seulement si avis fort. - -### J1. Mode async - -Trigger : reponses LLM frequemment > timeout CC/proxy. - -Recommandation : ajouter `POST /ask?async=1` + `GET /ask/:jobId/status`. -Backward-compatible. `opencc` apprend a poll. - -Avis : garder ce design. Le test direct a confirme que sync fonctionne, mais -`noReply:true` donne deja un equivalent "enqueue sans reponse" cote OpenCode si -on doit construire un mode async plus tard. - -### J2. SSE / WebSocket pour streaming - -Pas de SSE en CC:Tweaked, mais `http.websocket` existe. - -Recommandation : proxy WS dedie si streaming devient utile. Hors-scope PoC. - -Avis : ne pas confondre avec `/event` OpenCode. Le SSE existe et marche pour -observer le serveur, mais CC ne le consomme pas bien. Pour piloter un TUI, les -endpoints `/tui/append-prompt`, `/tui/submit-prompt`, `/tui/select-session` -sont utiles, mais hors proxy turtle. - -### J3. Multi-turtle isolation et identite - -Trigger : > 1 turtle/utilisateur sur le meme proxy. - -Recommandation : avec A3 Option 2 c'est deja gratuit niveau sessions. Pour -quotas, ajouter `turtleId` (= `os.getComputerID()`) au body et a la table de -tokens. - -Avis : confirmer. Ajouter `turtleId` plus tard seulement pour quotas/audit ; -la vraie isolation conversationnelle reste le `sessionId` garde cote turtle. - -### J4. Outils OpenCode (lecture/ecriture fichiers) - -Recommandation : sandbox stricte (mount lecture-seule + un dir scratch). -Hors-scope PoC. - -Avis fort : sandbox obligatoire avant d'activer des outils capables d'ecrire. -Le test `/tui/*` prouve qu'un client externe peut declencher le LLM ; le -workspace OpenCode ne doit donc jamais etre un repo sensible pour le PoC. - -### J5. Replacement de `http.post` par `apis/net.lua` - -Recommandation : probablement jamais. `net.lua` est routeur/rednet, pas -sortie HTTP. - -Confirme : probablement jamais. HTTP direct est le bon chemin pour sortir vers -le proxy ; `net.lua` reste pour modem/rednet. - -### J6. Observabilite (metrics, traces) - -Recommandation : ignorer en PoC. Pino logs suffisent. Prometheus exporter -plus tard si quotas. - -Confirme : Pino logs suffisent. Ajouter metrics seulement si plusieurs turtles -ou quotas reels. - ---- - -## K. Inconnues a valider au premier run - -Pas de reponse texte ; coche au fur et a mesure. - -- [x] Version exacte d'OpenCode utilisee (`opencode --version`) : `1.16.2`. -- [x] Format reel des `parts` retournees par `POST /session/:id/message` : - `text`, `reasoning`, `tool`, `step-start`, `step-finish` observes. -- [x] Comportement OpenCode si `model` est passe vs absent : absent fonctionne - avec le modele/session par defaut ; `noReply:true` cree sans LLM. -- [ ] Basic Auth vraiment necessaire dans la config de Guillaume. -- [ ] Timeout effectif `http.post` cote CC sur ATM10 (et si CC:Tweaked - version supporte un parametre timeout). -- [ ] Reaction OpenCode si on hammer une session morte (404 ? autre ?). -- [ ] Limite de longueur d'un message accepte par OpenCode. -- [ ] Cas ou le provider LLM est down : OpenCode renvoie quoi ? Ne pas tester - via `/provider` en logs partages, cet endpoint peut exposer l'auth. -- [ ] CraftOS-PC headless peut-il faire `http.post` HTTPS vers le proxy en - local ? - ---- - -## L. Quoi repondre en priorite - -Si tu veux aller vite, reponds dans cet ordre. Le reste decoule. - -1. A1 - critere d'acceptation -2. A2 - workspace OpenCode (frontiere de confiance) -3. A3 - cycle session -4. A4 - sync vs async -5. A5 - topologie deploiement -6. A6 - auth turtle <-> proxy -7. A7 - forme de la reponse -8. A8 - scope v1 - -Une fois ces 8 trances, on peut souvent ignorer les sections B-J pour le -"go/no-go" et juste valider les recommandations par defaut. Les detailler -n'apporte de la valeur que si tu veux devier d'une recommandation precise. diff --git a/.plans/opencc-agent-proxy-plan.md b/.plans/opencc-agent-proxy-plan.md deleted file mode 100644 index fd0ba00..0000000 --- a/.plans/opencc-agent-proxy-plan.md +++ /dev/null @@ -1,217 +0,0 @@ -# Plan - PoC `cc-agent-proxy` + programme Lua `opencc` - -> A executer ailleurs, pas sur cette machine. Deploiement HTTPS, reverse proxy, firewall et exposition publique geres separement par Guillaume. - -## 1. Objectif - -Piloter un agent OpenCode depuis une turtle ComputerCraft sur Minecraft / ATM10. - -```text -Turtle --HTTP + token--> cc-agent-proxy --HTTP Basic Auth--> opencode serve -opencc.lua Node/TypeScript proxy OpenCode headless -``` - -## 2. Faisabilite - -`opencode serve` lance un serveur HTTP headless exposant une spec OpenAPI 3.1 sur `/doc`. - -Routes utiles pour le PoC : - -| Methode | Route | Usage | -|---|---|---| -| `GET` | `/global/health` | healthcheck amont | -| `POST` | `/session` | cree une session | -| `POST` | `/session/:id/message` | envoie un message et attend la reponse | -| `POST` | `/session/:id/prompt_async` | envoie en asynchrone | -| `GET` | `/session/:id/message` | liste les messages | -| `GET` | `/event` | flux SSE | - -Conclusion : le proxy n'est pas strictement necessaire, mais il rend le client Lua beaucoup plus simple. - -## 3. Pourquoi garder le proxy - -- Expose une API turtle simple : `POST /ask { prompt, sessionId? }` vers `{ sessionId, reply }`. -- Gere le Basic Auth OpenCode cote Node. -- Garde cote turtle un token Bearer simple. -- Aplatit les reponses OpenCode pour limiter RAM et complexite Lua. -- Permet plus tard async, polling, logs, quotas ou allowlist sans changer le programme Lua. - -## 4. Partie A - `cc-agent-proxy` - -Creer un dossier `cc-agent-proxy/` dans le repo. - -Stack : - -- Node 20+. -- TypeScript strict. -- Fastify. -- Vitest. -- Zod. -- Config via env validee, avec `.env.example`. - -Arborescence : - -```text -cc-agent-proxy/ - package.json - tsconfig.json - .env.example - src/ - config.ts - opencode.ts - auth.ts - server.ts - index.ts - test/ - config.test.ts - opencode.test.ts - auth.test.ts - server.test.ts -``` - -Config : - -| Variable | Defaut | Role | -|---|---|---| -| `PROXY_HOST` | `0.0.0.0` | interface d'ecoute | -| `PROXY_PORT` | `7070` | port du proxy | -| `PROXY_TOKEN` | requis | secret turtle <-> proxy | -| `OPENCODE_BASE_URL` | `http://127.0.0.1:4096` | URL OpenCode | -| `OPENCODE_USERNAME` | `opencode` | user Basic Auth | -| `OPENCODE_PASSWORD` | requis | password Basic Auth | -| `OPENCODE_MODEL` | optionnel | modele a passer a OpenCode | - -Routes proxy : - -| Methode | Route | Auth | Usage | -|---|---|---|---| -| `GET` | `/health` | non | etat proxy + `opencode.health()` | -| `POST` | `/ask` | oui | cree/reutilise session, envoie prompt, retourne reponse | -| `POST` | `/session` | oui, optionnel | cree une session explicite | - -Client OpenCode : - -- `health()` appelle `GET /global/health`. -- `createSession(title?)` appelle `POST /session`. -- `sendMessage(sessionId, text)` appelle `POST /session/:id/message`. -- `Authorization: Basic base64(username:password)`. -- Body message : `{ parts: [{ type: "text", text }], model?: ... }`. -- Extraire les `parts` texte de la reponse. -- A valider au premier run reel : format exact des `parts`. - -Auth proxy : - -- Accepter `Authorization: Bearer `. -- Accepter aussi `X-Proxy-Token` pour simplicite CC si besoin. -- Comparaison constant-time avec `crypto.timingSafeEqual`. -- Reponse `401` si absent ou invalide. -- Ne jamais logger le token. - -Tests Node en scope du PoC : - -- `config.test.ts` : valeurs par defaut, erreurs si secrets manquants. -- `opencode.test.ts` : `fetch` mocke pour health/session/message/parsing. -- `auth.test.ts` : token valide, invalide, absent. -- `server.test.ts` : `app.inject()` pour `/health`, `/ask`, `401` sans token. - -## 5. Partie B - programme Lua `opencc` - -Creer : - -```text -apis/libopencc.lua -programs/opencc.lua -tests/opencc.lua -``` - -Programme : - -- `opencc --help`. -- `opencc --version`. -- `opencc "prompt"` ou prompt interactif via `read()`. -- Appelle `http://:/ask`. -- Header `Content-Type: application/json`. -- Header `Authorization: Bearer `. -- Body `textutils.serialiseJSON({ prompt = prompt, sessionId = sessionId })`. -- Parse la reponse JSON. -- Affiche `reply`. -- Memorise `sessionId` pour reutilisation si on choisit une session persistante. - -Config Lua : - -- Utiliser `settings` ComputerCraft en priorite : -- `opencc.proxy_url`. -- `opencc.proxy_token`. -- `opencc.session_id`. -- `opencc.timeout`, si applicable. -- Autoriser un fallback hardcode uniquement pour PoC local, mais ne pas committer de secret reel. -- Stockage session possible dans un petit fichier local, par exemple `/.opencc-session`, si plus simple que `settings`. - -Tests Lua : - -- En scope si simple : tester `libopencc` sans vraie requete HTTP via injection d'un faux client `http`. -- Tester construction URL, headers, body JSON, parsing succes, parsing erreur. -- Hors scope PoC si trop couteux : test end-to-end avec vrai proxy Node et vrai OpenCode. -- Le test reel HTTP se fera manuellement via CraftOS-PC puis en jeu. - -Note repo : - -- Le reseau existant `apis/net.lua` est oriente modem/rednet/router, pas HTTP sortant. -- Pour ce PoC, garder `http.post` direct cote Lua est le bon choix. -- Le packaging existant du repo est `ccpm` / `packages/*/ccpm.json`, pas `cube`. -- Ne pas integrer au packaging tant que le PoC n'est pas valide ; ajouter ensuite un package dedie type `tos-agent` si utile. - -## 6. Pre-requis CC:Tweaked / ATM10 - -- API `http` activee. -- Hote/IP du proxy autorise dans les `http.rules` cote serveur ATM10. -- Pas de CORS, car ce n'est pas un navigateur. -- Prompts courts pour le PoC, car `POST /session/:id/message` peut etre long. - -Evolution hors PoC : - -- Proxy utilise `prompt_async`. -- Turtle appelle `POST /ask` puis poll `GET /ask/:id/status`. -- Le proxy ecoute eventuellement `/event` SSE cote OpenCode. - -## 7. Ordre d'execution - -1. Bootstrap `cc-agent-proxy`. -2. Implementer `config.ts` + tests. -3. Implementer `opencode.ts` + tests avec `fetch` mocke. -4. Implementer `auth.ts`, `server.ts`, `/health`, `/ask` + tests `app.inject()`. -5. Valider manuellement avec `opencode serve` + `curl`. -6. Implementer `apis/libopencc.lua` + `programs/opencc.lua`. -7. Ajouter `tests/opencc.lua` si l'injection HTTP reste simple. -8. Tester via CraftOS-PC. -9. Tester en jeu sur ATM10 apres configuration `http.rules`. -10. Documenter les commandes de lancement et les pre-requis. - -## 8. Validation manuelle - -```bash -OPENCODE_SERVER_PASSWORD=xxx opencode serve \ - --hostname 127.0.0.1 \ - --port 4096 -``` - -```bash -curl -s http://localhost:7070/health -``` - -```bash -curl -s -X POST http://localhost:7070/ask \ - -H "Authorization: Bearer $PROXY_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"prompt":"dis bonjour"}' -``` - -## 9. Points de vigilance - -- Format reel des `parts` OpenCode a valider au premier run. -- OpenCode doit avoir un provider authentifie. -- Timeouts longs cote agent vs limites HTTP CC:Tweaked. -- `http.rules` cote serveur ATM10. -- `PROXY_TOKEN` jamais committe. -- `OPENCODE_PASSWORD` jamais committe. -- Le proxy doit rester minimal tant que le PoC n'a pas prouve la boucle complete. diff --git a/apis/libaihelloworld.lua b/apis/libaihelloworld.lua index 26ece99..fa88ee7 100644 --- a/apis/libaihelloworld.lua +++ b/apis/libaihelloworld.lua @@ -1,9 +1,30 @@ -local _VERSION = '0.1.0'; +local _VERSION = '0.2.1'; local DEFAULT_PROMPT = 'reply with exactly: pong'; -local function trimTrailingSlash(value) - return (value:gsub('/+$', '')); +local B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +local function base64encode(s) + local pad = (3 - #s % 3) % 3; + s = s .. string.rep('\0', pad); + local r = {}; + for i = 1, #s, 3 do + local a, b, c = s:byte(i), s:byte(i + 1), s:byte(i + 2); + local n = a * 65536 + b * 256 + c; + r[#r + 1] = B64:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1) + .. B64:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1) + .. B64:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1) + .. B64:sub(n % 64 + 1, n % 64 + 1); + end + local result = table.concat(r); + if pad > 0 then + result = result:sub(1, #result - pad) .. string.rep('=', pad); + end + return result; +end + +local function trimTrailingSlash(s) + return (s:gsub('/+$', '')); end local function readAllAndClose(response) @@ -19,13 +40,14 @@ local function statusCode(response) return nil; end -local function mapStatusError(code) - if code == 401 then return 'token invalide'; end - if code == 413 then return 'prompt trop long'; end - if code == 502 then return 'agent indisponible'; end - if code == 504 then return 'agent trop lent'; end - if code then return 'erreur proxy: HTTP ' .. tostring(code); end - return 'erreur proxy inconnue'; +local function extractTextParts(parts) + local texts = {}; + for _, part in ipairs(parts) do + if part.type == 'text' and type(part.text) == 'string' then + texts[#texts + 1] = part.text; + end + end + return table.concat(texts, ''); end local function createAiHelloWorld(opts) @@ -41,71 +63,125 @@ local function createAiHelloWorld(opts) return _VERSION; end + local function resolveConfig(options) + local url = options.serverUrl or settingsLib.get('opencc.server_url'); + if not url or url == '' then + return nil, 'missing opencc.server_url; run: settings set opencc.server_url '; + end + local username = options.username or settingsLib.get('opencc.username') or 'opencode'; + local password = options.password or settingsLib.get('opencc.password') or ''; + return { url = trimTrailingSlash(url), username = username, password = password }; + end + + local function buildHeaders(cfg) + local headers = { + ['Content-Type'] = 'application/json', + ['Accept'] = 'application/json', + }; + if cfg.password and cfg.password ~= '' then + headers['Authorization'] = 'Basic ' .. base64encode(cfg.username .. ':' .. cfg.password); + end + return headers; + end + + local function doGet(cfg, path) + local response, _, errorResponse = httpLib.get(cfg.url .. path, buildHeaders(cfg)); + response = response or errorResponse; + if not response then + return nil, 'serveur injoignable'; + end + local code = statusCode(response); + local body = readAllAndClose(response); + return body, code; + end + + local function doPost(cfg, path, payload) + local response, _, errorResponse = httpLib.post( + cfg.url .. path, + textutils.serializeJSON(payload), + buildHeaders(cfg) + ); + response = response or errorResponse; + if not response then + return nil, 'serveur injoignable'; + end + local code = statusCode(response); + local body = readAllAndClose(response); + return body, code; + end + function api.clearSession() settingsLib.unset('opencc.session_id'); if settingsLib.save then settingsLib.save(); end end + function api.listSessions(options) + options = options or {}; + local cfg, err = resolveConfig(options); + if not cfg then return false, err; end + + local body, code = doGet(cfg, '/session'); + if not body then return false, code; end + if code and code ~= 200 then + return false, 'erreur serveur: HTTP ' .. tostring(code); + end + + local decoded = textutils.unserializeJSON(body); + if type(decoded) ~= 'table' then + return false, 'reponse invalide'; + end + return true, decoded; + end + function api.askHello(options) options = options or {}; - - local proxyUrl = options.proxyUrl or settingsLib.get('opencc.proxy_url'); - if not proxyUrl or proxyUrl == '' then - return false, 'missing opencc.proxy_url; run: settings set opencc.proxy_url '; - end - - local token = options.proxyToken or settingsLib.get('opencc.proxy_token'); - if not token or token == '' then - return false, 'missing opencc.proxy_token; run: settings set opencc.proxy_token '; - end + local cfg, err = resolveConfig(options); + if not cfg then return false, err; end local sessionId = options.sessionId; if sessionId == nil then sessionId = settingsLib.get('opencc.session_id'); end - local body = { prompt = prompt }; - if sessionId and sessionId ~= '' then - body.sessionId = sessionId; - end - - local response = httpLib.post( - trimTrailingSlash(proxyUrl) .. '/ask', - textutils.serializeJSON(body), - { - ['Authorization'] = 'Bearer ' .. token, - ['Content-Type'] = 'application/json', - } - ); - - if not response then - return false, 'proxy injoignable. Verifie opencc.proxy_url et http.rules'; - end - - local code = statusCode(response); - local responseBody = readAllAndClose(response); - if code and code ~= 200 then - return false, mapStatusError(code); - end - - local decoded = textutils.unserializeJSON(responseBody or ''); - if type(decoded) ~= 'table' then - return false, 'reponse proxy invalide'; - end - if type(decoded.reply) ~= 'string' then - return false, 'reponse proxy sans reply'; - end - - if type(decoded.sessionId) == 'string' and decoded.sessionId ~= '' then - settingsLib.set('opencc.session_id', decoded.sessionId); + if not sessionId or sessionId == '' then + local body, code = doPost(cfg, '/session', { title = 'cc-helloworld' }); + if not body then return false, code; end + if code and code ~= 200 then + return false, 'impossible de creer une session: HTTP ' .. tostring(code); + end + local decoded = textutils.unserializeJSON(body); + if type(decoded) ~= 'table' or type(decoded.id) ~= 'string' then + return false, 'reponse session invalide'; + end + sessionId = decoded.id; + settingsLib.set('opencc.session_id', sessionId); if settingsLib.save then settingsLib.save(); end end - return true, { - reply = decoded.reply, - sessionId = decoded.sessionId, - truncated = decoded.truncated == true, - }; + local body, code = doPost(cfg, '/session/' .. sessionId .. '/message', { + parts = { { type = 'text', text = prompt } }, + }); + if not body then return false, code; end + if code == 404 then + settingsLib.unset('opencc.session_id'); + if settingsLib.save then settingsLib.save(); end + return false, 'session introuvable; lance: ai-helloworld --new'; + end + if code and code ~= 200 then + return false, 'erreur message: HTTP ' .. tostring(code); + end + + local decoded = textutils.unserializeJSON(body); + if type(decoded) ~= 'table' or type(decoded.parts) ~= 'table' then + return false, 'reponse message invalide'; + end + + local reply = extractTextParts(decoded.parts); + if reply == '' then + return false, 'reponse vide'; + end + + return true, { reply = reply, sessionId = sessionId }; end return api; diff --git a/docs/opencode_api.md b/docs/opencode_api.md new file mode 100644 index 0000000..ea9ab0d --- /dev/null +++ b/docs/opencode_api.md @@ -0,0 +1,148 @@ +# opencode serve — HTTP API reference + +Minimal reference for the endpoints used by `libaihelloworld.lua`. Full spec served at `GET /doc` when the server is running. + +## Running the server + +```bash +opencode serve \ + --hostname 127.0.0.1 \ + --port 4096 +``` + +With Basic Auth (recommended): + +```bash +OPENCODE_SERVER_PASSWORD=secret opencode serve \ + --hostname 127.0.0.1 \ + --port 4096 +``` + +Default username is `opencode`. Override with `OPENCODE_SERVER_USERNAME`. + +## Authentication + +All requests must include `Authorization: Basic ` when the server was started with a password. No auth header is needed if no password is set. + +``` +Authorization: Basic base64("opencode:secret") +``` + +## Endpoints + +### `GET /global/health` + +Health check. Returns `200` when the server is up. + +--- + +### `GET /session` + +List all sessions for the current project. + +**Response** `200`: +```json +[ + { "id": "ses_abc123", "title": "my session", "time": { "created": 1234567890, "updated": 1234567890 } } +] +``` + +--- + +### `POST /session` + +Create a new session. + +**Request body:** +```json +{ "title": "optional title", "parentID": "optional" } +``` + +**Response** `200`: +```json +{ "id": "ses_abc123", "title": "my session" } +``` + +--- + +### `POST /session/:id/message` + +Send a message and wait for the AI reply (blocking). Returns when the assistant has finished responding. + +**Request body:** +```json +{ + "parts": [ + { "type": "text", "text": "your prompt here" } + ], + "model": { "providerID": "anthropic", "modelID": "claude-opus-4-7" } +} +``` + +`model` is optional — omit to use the server's configured default. + +**Response** `200`: +```json +{ + "info": { "id": "msg_xyz", "sessionID": "ses_abc123", "role": "assistant" }, + "parts": [ + { "type": "text", "text": "the reply" } + ] +} +``` + +Parts can include non-text types (`tool-call`, `step-start`, etc.) — collect all `type == "text"` entries to reconstruct the reply. + +**Errors:** +- `401` — wrong credentials +- `404` — session not found (may have been deleted or server restarted) +- `504` — AI took too long + +--- + +### `DELETE /session/:id` + +Delete a session. + +--- + +### `POST /session/:id/abort` + +Abort a running generation. + +--- + +### `POST /session/:id/prompt_async` + +Fire-and-forget variant. Returns `204` immediately; result arrives over the SSE stream. + +--- + +### `GET /global/event` (SSE) + +Server-Sent Events stream. Delivers all server bus events in real time. Useful for async workflows or watching a session from outside the TUI. + +--- + +## opencode attach + +`opencode attach` opens the TUI and connects it to an already-running `opencode serve` instance. Both the TUI and any HTTP clients operate on the same session state. + +```bash +opencode attach http://127.0.0.1:4096 +opencode attach http://127.0.0.1:4096 --session ses_abc123 +``` + +To send messages to the session currently open in the TUI, set `opencc.session_id` in CC to the session ID shown in the TUI, then run `ai-helloworld`. + +## TUI control (bonus) + +When a TUI is attached, these endpoints drive it programmatically: + +| Method | Path | Effect | +|---|---|---| +| `POST` | `/tui/append-prompt` | Append text to the input field | +| `POST` | `/tui/submit-prompt` | Submit the current input | +| `POST` | `/tui/show-toast` | Show a notification | + +Body for append/submit: `{ "text": "..." }`. diff --git a/docs/opencode_server_guide.md b/docs/opencode_server_guide.md new file mode 100644 index 0000000..68089f1 --- /dev/null +++ b/docs/opencode_server_guide.md @@ -0,0 +1,96 @@ +# opencode server guide + +How to run `opencode serve` and test `ai-helloworld` from ComputerCraft directly — no proxy. + +See `docs/opencode_api.md` for the full API reference. + +## Architecture + +``` +CC Turtle + └─ ai-helloworld.lua (libaihelloworld.lua) + └─ POST /session/:id/message → opencode serve +``` + +## 1. Start `opencode serve` + +```bash +opencode serve --hostname 0.0.0.0 --port 4096 +``` + +With Basic Auth (recommended for LAN exposure): + +```bash +OPENCODE_SERVER_PASSWORD=secret opencode serve \ + --hostname 0.0.0.0 \ + --port 4096 +``` + +Default username is `opencode`. Override with `OPENCODE_SERVER_USERNAME=myuser`. + +Check it's alive: +```bash +curl http://localhost:4096/global/health +``` + +## 2. (Optional) Attach the TUI + +Open the interactive TUI connected to the running server. CC clients and the TUI share the same session state. + +```bash +opencode attach http://127.0.0.1:4096 +``` + +To target a specific session from CC, grab the session ID shown in the TUI and run: +``` +settings set opencc.session_id ses_abc123 +``` + +## 3. Configure CC settings + +Run in the ComputerCraft console or CraftOS-PC terminal: + +```lua +settings set opencc.server_url http://:4096 +settings save +``` + +With auth: +```lua +settings set opencc.password secret +settings save +``` + +- **CraftOS-PC (localhost):** `http://127.0.0.1:4096` +- **In-game ATM10:** use your LAN IP (e.g. `192.168.x.x`) — add it to `http.rules` in `config/computercraft-server.toml` + +## 4. Run `ai-helloworld` + +``` +ai-helloworld -- ping, reuses existing session +ai-helloworld --new -- forget current session, start fresh +ai-helloworld --sessions -- list all server sessions with their IDs +ai-helloworld --help +ai-helloworld --version +``` + +Expected output on a working setup: `pong` (default prompt is `reply with exactly: pong`). + +## 5. CraftOS-PC (no Minecraft) + +```bash +just craftos --headless -- /programs/ai-helloworld.lua +``` + +Set settings inside the harness before running, or inject them via the test API. + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `missing opencc.server_url` | Setting not set | `settings set opencc.server_url http://...` | +| `serveur injoignable` | Server not running or wrong URL | Start `opencode serve`, check URL/port | +| `HTTP 401` | Wrong password | Check `opencc.password` matches `OPENCODE_SERVER_PASSWORD` | +| `session introuvable; lance: ai-helloworld --new` | Session was deleted or server restarted | Run `ai-helloworld --new` | +| `HTTP 504` | AI took too long | Retry; consider a faster model | +| `reponse vide` | Reply had no text parts | Check opencode logs | diff --git a/programs/ai-helloworld.lua b/programs/ai-helloworld.lua index ddeff23..963bc4c 100644 --- a/programs/ai-helloworld.lua +++ b/programs/ai-helloworld.lua @@ -1,4 +1,4 @@ -local _VERSION = '0.1.0'; +local _VERSION = '0.2.0'; local createAiHelloWorld = require('/apis/libaihelloworld'); @@ -10,12 +10,17 @@ local function printUsage() print(); print(' ai-helloworld'); print(' ai-helloworld --new'); + print(' ai-helloworld --sessions'); print(' ai-helloworld --version'); print(' ai-helloworld --help'); print(); print('settings required:'); - print(' opencc.proxy_url'); - print(' opencc.proxy_token'); + print(' opencc.server_url'); + print(); + print('settings optional:'); + print(' opencc.username (default: opencode)'); + print(' opencc.password (Basic Auth password)'); + print(' opencc.session_id (auto-managed)'); end if command == '--version' or command == '-version' or command == 'version' then @@ -32,6 +37,20 @@ local ai = createAiHelloWorld(); if command == '--new' then ai.clearSession(); +elseif command == '--sessions' then + local ok, result = ai.listSessions(); + if not ok then + print(result); + return; + end + if #result == 0 then + print('no sessions'); + else + for _, s in ipairs(result) do + print((s.id or '?') .. ' ' .. (s.title or '(untitled)')); + end + end + return; elseif command ~= nil and command ~= '' then printUsage(); return; diff --git a/tests/ai-helloworld.lua b/tests/ai-helloworld.lua index 3aac4bf..261a6d1 100644 --- a/tests/ai-helloworld.lua +++ b/tests/ai-helloworld.lua @@ -6,158 +6,359 @@ local testlib = createLibTest({ ... }); local function fakeSettings(initial) local values = initial or {}; local saveCount = 0; - return { - get = function(key) - return values[key]; - end, - set = function(key, value) - values[key] = value; - end, - unset = function(key) - values[key] = nil; - end, - save = function() - saveCount = saveCount + 1; - end, + get = function(key) return values[key]; end, + set = function(key, value) values[key] = value; end, + unset = function(key) values[key] = nil; end, + save = function() saveCount = saveCount + 1; end, values = values, - saveCount = function() - return saveCount; - end, + saveCount = function() return saveCount; end, }; end local function response(code, body) return { - getResponseCode = function() - return code; - end, - readAll = function() - return body; - end, + getResponseCode = function() return code; end, + readAll = function() return body; end, close = function() end, }; end -local function fakeHttp(result) - local calls = {}; +-- postResults: list of responses returned in order for each POST call. +-- getResults: list of responses returned in order for each GET call. +local function fakeHttp(postResults, getResults) + postResults = postResults or {}; + getResults = getResults or {}; + local postCalls = {}; + local getCalls = {}; + local postIdx = 0; + local getIdx = 0; return { post = function(url, body, headers) - calls[#calls + 1] = { url = url, body = body, headers = headers }; - if type(result) == 'function' then - return result(url, body, headers); - end - return result; + postCalls[#postCalls + 1] = { url = url, body = body, headers = headers }; + postIdx = postIdx + 1; + local r = postResults[postIdx]; + if type(r) == 'function' then return r(url, body, headers); end + return r; end, - calls = calls, + get = function(url, headers) + getCalls[#getCalls + 1] = { url = url, headers = headers }; + getIdx = getIdx + 1; + local r = getResults[getIdx]; + if type(r) == 'function' then return r(url, headers); end + return r; + end, + postCalls = postCalls, + getCalls = getCalls, }; end -testlib.test('askHello posts ping prompt and saves session id', function() - local settingsStub = fakeSettings({ - ['opencc.proxy_url'] = 'https://proxy.example/', - ['opencc.proxy_token'] = 'secret', - }); - local httpStub = fakeHttp(response(200, textutils.serializeJSON({ - sessionId = 'ses_123', - reply = 'pong', - truncated = false, - }))); +local function httpError(code, body) + return function() + return nil, 'HTTP response code ' .. tostring(code), response(code, body); + end; +end + +local function sessionResp(id) + return response(200, textutils.serializeJSON({ id = id, title = 'cc-helloworld' })); +end + +local function messageResp(reply) + return response(200, textutils.serializeJSON({ + info = {}, + parts = { { type = 'text', text = reply } }, + })); +end + +-- base64 -- + +testlib.test('base64encode encodes simple ascii', function() + -- "Man" -> "TWFu" is the canonical base64 test vector; tested indirectly via Authorization header + local httpStub = fakeHttp( + { sessionResp('ses_1'), messageResp('pong') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + settingsStub.values['opencc.password'] = 'pass'; + settingsStub.values['opencc.username'] = 'user'; + createAiHelloWorld({ http = httpStub, settings = settingsStub }).askHello(); + local auth = httpStub.postCalls[1].headers['Authorization']; + -- base64('user:pass') = 'dXNlcjpwYXNz' + testlib.assertEquals(auth, 'Basic dXNlcjpwYXNz'); +end); + +testlib.test('base64encode handles padding with one remainder byte', function() + local httpStub = fakeHttp( + { sessionResp('ses_1'), messageResp('pong') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + settingsStub.values['opencc.password'] = 'x'; + settingsStub.values['opencc.username'] = 'a'; + createAiHelloWorld({ http = httpStub, settings = settingsStub }).askHello(); + -- base64('a:x') = 'YTp4' + testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], 'Basic YTp4'); +end); + +-- listSessions -- + +testlib.test('listSessions returns parsed session list', function() + local sessions = { { id = 'ses_1', title = 'hello' }, { id = 'ses_2', title = 'world' } }; + local httpStub = fakeHttp({}, { response(200, textutils.serializeJSON(sessions)) }); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, result = ai.listSessions(); + + testlib.assertTrue(ok, tostring(result)); + testlib.assertEquals(#result, 2); + testlib.assertEquals(result[1].id, 'ses_1'); +end); + +testlib.test('listSessions fails when server_url missing', function() + local httpStub = fakeHttp({}, {}); + local ai = createAiHelloWorld({ http = httpStub, settings = fakeSettings() }); + + local ok, err = ai.listSessions(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'opencc.server_url', 1, true) ~= nil); + testlib.assertEquals(#httpStub.getCalls, 0); +end); + +testlib.test('listSessions fails when server unreachable', function() + local httpStub = fakeHttp({}, { nil }); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.listSessions(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'injoignable', 1, true) ~= nil); +end); + +testlib.test('listSessions maps HTTP error response codes', function() + local httpStub = fakeHttp({}, { httpError(401, '{}') }); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.listSessions(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'HTTP 401', 1, true) ~= nil); +end); + +-- askHello -- + +testlib.test('askHello creates session then sends message when no session_id', function() + local httpStub = fakeHttp( + { sessionResp('ses_new'), messageResp('pong') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host:4096' }); local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); local ok, result = ai.askHello(); testlib.assertTrue(ok, tostring(result)); testlib.assertEquals(result.reply, 'pong'); - testlib.assertEquals(result.sessionId, 'ses_123'); - testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_123'); - testlib.assertEquals(settingsStub.saveCount(), 1); - - testlib.assertEquals(#httpStub.calls, 1); - local call = httpStub.calls[1]; - testlib.assertEquals(call.url, 'https://proxy.example/ask'); - testlib.assertEquals(call.headers['Authorization'], 'Bearer secret'); - testlib.assertEquals(call.headers['Content-Type'], 'application/json'); - - local request = textutils.unserializeJSON(call.body); - testlib.assertEquals(request.prompt, 'reply with exactly: pong'); - testlib.assertEquals(request.sessionId, nil); + testlib.assertEquals(result.sessionId, 'ses_new'); + testlib.assertEquals(#httpStub.postCalls, 2); + testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session', 1, true) ~= nil); + testlib.assertTrue(string.find(httpStub.postCalls[2].url, '/session/ses_new/message', 1, true) ~= nil); end); -testlib.test('askHello includes existing session id', function() +testlib.test('askHello saves new session_id to settings', function() + local httpStub = fakeHttp( + { sessionResp('ses_abc'), messageResp('pong') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + ai.askHello(); + + testlib.assertEquals(settingsStub.values['opencc.session_id'], 'ses_abc'); + testlib.assertEquals(settingsStub.saveCount(), 1); +end); + +testlib.test('askHello reuses existing session_id without creating a new session', function() + local httpStub = fakeHttp( + { messageResp('pong') }, + {} + ); local settingsStub = fakeSettings({ - ['opencc.proxy_url'] = 'https://proxy.example', - ['opencc.proxy_token'] = 'secret', - ['opencc.session_id'] = 'ses_old', + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_existing', }); - local httpStub = fakeHttp(response(200, textutils.serializeJSON({ - sessionId = 'ses_old', - reply = 'pong', - }))); local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); local ok = ai.askHello(); testlib.assertTrue(ok); - local request = textutils.unserializeJSON(httpStub.calls[1].body); - testlib.assertEquals(request.sessionId, 'ses_old'); + testlib.assertEquals(#httpStub.postCalls, 1); + testlib.assertTrue(string.find(httpStub.postCalls[1].url, '/session/ses_existing/message', 1, true) ~= nil); end); -testlib.test('askHello rejects missing proxy url', function() - local settingsStub = fakeSettings({ ['opencc.proxy_token'] = 'secret' }); - local httpStub = fakeHttp(response(200, '{}')); - local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); - - local ok, err = ai.askHello(); - - testlib.assertTrue(not ok); - testlib.assertTrue(string.find(err, 'opencc.proxy_url', 1, true)); - testlib.assertEquals(#httpStub.calls, 0); -end); - -testlib.test('askHello rejects missing proxy token', function() - local settingsStub = fakeSettings({ ['opencc.proxy_url'] = 'https://proxy.example' }); - local httpStub = fakeHttp(response(200, '{}')); - local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); - - local ok, err = ai.askHello(); - - testlib.assertTrue(not ok); - testlib.assertTrue(string.find(err, 'opencc.proxy_token', 1, true)); - testlib.assertEquals(#httpStub.calls, 0); -end); - -testlib.test('askHello maps proxy unreachable', function() +testlib.test('askHello sends correct message body', function() + local httpStub = fakeHttp( + { messageResp('pong') }, + {} + ); local settingsStub = fakeSettings({ - ['opencc.proxy_url'] = 'https://proxy.example', - ['opencc.proxy_token'] = 'secret', + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_1', }); - local httpStub = fakeHttp(nil); - local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub, prompt = 'my prompt' }); - local ok, err = ai.askHello(); + ai.askHello(); - testlib.assertTrue(not ok); - testlib.assertTrue(string.find(err, 'proxy injoignable', 1, true)); + local body = textutils.unserializeJSON(httpStub.postCalls[1].body); + testlib.assertEquals(#body.parts, 1); + testlib.assertEquals(body.parts[1].type, 'text'); + testlib.assertEquals(body.parts[1].text, 'my prompt'); end); -testlib.test('askHello maps 401 response', function() +testlib.test('askHello concatenates multiple text parts', function() + local httpStub = fakeHttp( + { response(200, textutils.serializeJSON({ + info = {}, + parts = { + { type = 'step-start' }, + { type = 'text', text = 'hello ' }, + { type = 'tool-call' }, + { type = 'text', text = 'world' }, + }, + })) }, + {} + ); local settingsStub = fakeSettings({ - ['opencc.proxy_url'] = 'https://proxy.example', - ['opencc.proxy_token'] = 'secret', + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_1', }); - local httpStub = fakeHttp(response(401, textutils.serializeJSON({ error = 'unauthorized' }))); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, result = ai.askHello(); + + testlib.assertTrue(ok, tostring(result)); + testlib.assertEquals(result.reply, 'hello world'); +end); + +testlib.test('askHello fails with missing server_url', function() + local httpStub = fakeHttp({}, {}); + local ai = createAiHelloWorld({ http = httpStub, settings = fakeSettings() }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'opencc.server_url', 1, true) ~= nil); + testlib.assertEquals(#httpStub.postCalls, 0); +end); + +testlib.test('askHello fails when server unreachable on session create', function() + local httpStub = fakeHttp({ nil }, {}); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); local ok, err = ai.askHello(); testlib.assertTrue(not ok); - testlib.assertEquals(err, 'token invalide'); + testlib.assertTrue(string.find(err, 'injoignable', 1, true) ~= nil); end); +testlib.test('askHello fails when server unreachable on message send', function() + local httpStub = fakeHttp({ sessionResp('ses_1'), nil }, {}); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'injoignable', 1, true) ~= nil); +end); + +testlib.test('askHello maps 401 on message send', function() + local httpStub = fakeHttp( + { sessionResp('ses_1'), response(401, '{}') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'HTTP 401', 1, true) ~= nil); +end); + +testlib.test('askHello maps HTTP error response on message send', function() + local httpStub = fakeHttp( + { sessionResp('ses_1'), httpError(401, '{}') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, 'HTTP 401', 1, true) ~= nil); +end); + +testlib.test('askHello on 404 clears session_id and suggests --new', function() + local httpStub = fakeHttp( + { response(404, '{}') }, + {} + ); + local settingsStub = fakeSettings({ + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_stale', + }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, '--new', 1, true) ~= nil); + testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); +end); + +testlib.test('askHello on HTTP error 404 clears session_id', function() + local httpStub = fakeHttp( + { httpError(404, '{}') }, + {} + ); + local settingsStub = fakeSettings({ + ['opencc.server_url'] = 'http://host', + ['opencc.session_id'] = 'ses_stale', + }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + local ok, err = ai.askHello(); + + testlib.assertTrue(not ok); + testlib.assertTrue(string.find(err, '--new', 1, true) ~= nil); + testlib.assertEquals(settingsStub.values['opencc.session_id'], nil); +end); + +testlib.test('askHello omits Authorization header when no password', function() + local httpStub = fakeHttp( + { sessionResp('ses_1'), messageResp('pong') }, + {} + ); + local settingsStub = fakeSettings({ ['opencc.server_url'] = 'http://host' }); + local ai = createAiHelloWorld({ http = httpStub, settings = settingsStub }); + + ai.askHello(); + + testlib.assertEquals(httpStub.postCalls[1].headers['Authorization'], nil); +end); + +-- clearSession -- + testlib.test('clearSession unsets persisted session id', function() local settingsStub = fakeSettings({ ['opencc.session_id'] = 'ses_old' }); - local ai = createAiHelloWorld({ http = fakeHttp(nil), settings = settingsStub }); + local ai = createAiHelloWorld({ http = fakeHttp({}, {}), settings = settingsStub }); ai.clearSession();