feat(ai): call opencode serve directly

This commit is contained in:
Guillaume ARM 2026-06-08 22:57:18 +02:00
parent 15b7c5482e
commit 51194b9866
7 changed files with 703 additions and 1273 deletions

View File

@ -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<n>`.
> - 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 <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 <token>` 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.

View File

@ -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 <token>`.
- 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://<host>:<port>/ask`.
- Header `Content-Type: application/json`.
- Header `Authorization: Bearer <token>`.
- 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.

View File

@ -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 <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 <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 <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;

148
docs/opencode_api.md Normal file
View File

@ -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 <base64(username:password)>` 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": "..." }`.

View File

@ -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://<host-ip>: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 |

View File

@ -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;

View File

@ -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();