feat(t-paliad-194): AichatPaliadinService + PALIADIN_BACKEND=aichat env gate (m/paliad#38 Phase B)
Adds the Phase B paliad-side migration: a thin HTTP client of the centralized aichat backend shipped in m/mAi#207 Phase A (darwin's mai/darwin/issue-207-aichat branch). Implements the same services.Paliadin interface as LocalPaliadinService / RemotePaliadinService — handler plumbing is unchanged, the cutover is a single env-var flip. internal/services/aichat_paliadin.go (~530 LoC): - POST /chat/turn + POST /chat/reset + GET /chat/health via the aichat JSON envelope (mirrors m/mAi internal/aichat/api/types.go verbatim; no module import to keep paliad self-contained). - Per-turn HS256 JWT mint (uses paliadin_jwt.go from the prior commit) when SUPABASE_JWT_SECRET is configured. Aichat owns file write + cleanup; we just sign and ship. - Service-wide health-gate cache (10 s success window, no failure cache — failures re-probe so recovery surfaces immediately). - Per-user-window primer cache. Pulls up to MaxPrimerTurns prior exchanges from paliad.paliadin_turns and ships them in TurnRequest. Primer so a pane respawn (pane_spawned=true in response) doesn't strand the user with a cold claude. Cleared on ResetSession + pane_spawned response. - Username from email_localpart per m's §13 Q2 pick (sanitized inside aichat). Nil-DB fallback: "user-<uuid8>". - Maps aichat's typed wire errors (auth_failed, persona_unknown, mriver_unreachable, bootstrap_failed, timeout, shim_error) onto paliad's existing audit-row codes — preserves the German i18n table in paliadin.ts unchanged (no new strings needed per design §11). cmd/server/main.go: - PALIADIN_BACKEND env: "aichat" → AichatPaliadinService, anything else → existing remote/local/disabled tree. Default = legacy, so every existing deploy is byte-identical until flipped. - buildAichatPaliadinConfig validates AICHAT_URL + AICHAT_TOKEN at boot; AICHAT_PERSONA defaults to "paliadin". JWT secret threaded in so per-user RLS is on by default. Tests cover constructor defaults, health-gate caching + retry + expiry, ResetSession wiring, error-envelope decoding + classifier, HTTP-layer auth/JSON wiring via a roundTripper, JWT mint integration, TurnContext → meta packing, and the env-gate helper. go test ./... green. NOT self-merged — head owns the merge per task instructions.
This commit is contained in:
@@ -179,39 +179,58 @@ func main() {
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
}
|
||||
|
||||
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh to mRiver)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else: DisabledPaliadinService (handlers still 404 for non-owners
|
||||
// via the gate; for m, RunTurn returns ErrPaliadinDisabled
|
||||
// which surfaces as a friendly error).
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
// All three implement services.Paliadin; the per-request handler
|
||||
// gate (requirePaliadinOwner) is unchanged and applies to every
|
||||
// backend.
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
// PALIADIN_BACKEND (t-paliad-194 / m/paliad#38):
|
||||
// "aichat" → AichatPaliadinService (HTTP client of the
|
||||
// centralized aichat backend on mRiver,
|
||||
// shipped in m/mAi#207 Phase A).
|
||||
// "legacy" / unset / etc → fall through to the pre-aichat tree:
|
||||
// PALIADIN_REMOTE_HOST set → RemotePaliadinService (ssh shim)
|
||||
// else: local tmux available → LocalPaliadinService (PoC path)
|
||||
// else → DisabledPaliadinService
|
||||
//
|
||||
// The aichat path is opt-in for the migration window so a flip
|
||||
// back is one env-var change. Once aichat soaks, legacy can be
|
||||
// retired in a follow-up slice.
|
||||
//
|
||||
// All four implementations satisfy services.Paliadin; the per-
|
||||
// request handler gate (requirePaliadinOwner) is unchanged.
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("PALIADIN_BACKEND"))) {
|
||||
case "aichat":
|
||||
cfg, err := buildAichatPaliadinConfig(jwtSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
log.Fatalf("paliadin: aichat config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewAichatPaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: aichat mode → %s persona=%s (owner=%s, rls=%s)",
|
||||
cfg.BaseURL, cfg.Persona, services.PaliadinOwnerEmail,
|
||||
rlsModeLabel(cfg.JWTSecret))
|
||||
default:
|
||||
if remoteHost := os.Getenv("PALIADIN_REMOTE_HOST"); remoteHost != "" {
|
||||
cfg, err := buildPaliadinRemoteConfig(remoteHost)
|
||||
if err != nil {
|
||||
log.Fatalf("paliadin: remote config: %v", err)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
svcBundle.Paliadin = services.NewRemotePaliadinService(pool, users, cfg)
|
||||
log.Printf("paliadin: remote mode → ssh %s@%s:%d (owner=%s)",
|
||||
cfg.SSHUser, cfg.SSHHost, cfg.SSHPort, services.PaliadinOwnerEmail)
|
||||
} else if _, err := exec.LookPath("tmux"); err == nil {
|
||||
sessionPrefix := os.Getenv("PALIADIN_SESSION_PREFIX")
|
||||
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
|
||||
local := services.NewLocalPaliadinService(pool, users, sessionPrefix, responseDir)
|
||||
// Late-response janitor — patches rows when Claude writes the
|
||||
// response file after the 60 s pollForResponse window expires.
|
||||
// Runs for the process lifetime; cleaned up when bgCtx
|
||||
// cancels on SIGTERM.
|
||||
local.StartJanitor(bgCtx)
|
||||
svcBundle.Paliadin = local
|
||||
log.Printf("paliadin: local tmux mode (owner=%s, janitor=on)", services.PaliadinOwnerEmail)
|
||||
} else {
|
||||
svcBundle.Paliadin = services.NewDisabledPaliadinService(pool, users)
|
||||
log.Printf("paliadin: disabled (no PALIADIN_REMOTE_HOST, no local tmux; owner=%s)",
|
||||
services.PaliadinOwnerEmail)
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
@@ -382,3 +401,49 @@ func cmpOr(s, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// buildAichatPaliadinConfig assembles an AichatPaliadinConfig from the
|
||||
// environment for PALIADIN_BACKEND=aichat (t-paliad-194 / m/paliad#38).
|
||||
//
|
||||
// Required:
|
||||
//
|
||||
// AICHAT_URL — service root (e.g. http://100.99.98.203:8765).
|
||||
// AICHAT_TOKEN — raw bearer token paliad's app_id is registered
|
||||
// under in aichat's tokens.yaml (see m/mAi
|
||||
// docs/reference/aichat-deploy.md).
|
||||
//
|
||||
// Optional:
|
||||
//
|
||||
// AICHAT_PERSONA — persona id; defaults to "paliadin".
|
||||
//
|
||||
// jwtSecret comes from the same SUPABASE_JWT_SECRET that auth.NewClient
|
||||
// already requires at boot — never empty when we reach this code path.
|
||||
// It's threaded in so the aichat service can mint per-turn user-scoped
|
||||
// JWTs (folded-in t-paliad-156 work).
|
||||
func buildAichatPaliadinConfig(jwtSecret string) (services.AichatPaliadinConfig, error) {
|
||||
cfg := services.AichatPaliadinConfig{
|
||||
BaseURL: strings.TrimRight(os.Getenv("AICHAT_URL"), "/"),
|
||||
BearerToken: os.Getenv("AICHAT_TOKEN"),
|
||||
Persona: cmpOr(os.Getenv("AICHAT_PERSONA"), services.DefaultAichatPersona),
|
||||
JWTSecret: []byte(jwtSecret),
|
||||
}
|
||||
if cfg.BaseURL == "" {
|
||||
return cfg, fmt.Errorf("AICHAT_URL must be set when PALIADIN_BACKEND=aichat")
|
||||
}
|
||||
if cfg.BearerToken == "" {
|
||||
return cfg, fmt.Errorf("AICHAT_TOKEN must be set when PALIADIN_BACKEND=aichat")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// rlsModeLabel labels the boot log so the operator can confirm whether
|
||||
// the per-user JWT mint is active. "per-user" means we're handing the
|
||||
// claude pane user-scoped claims; "service-role" means we're not (no
|
||||
// SUPABASE_JWT_SECRET) and the skill will reject queries rather than
|
||||
// run as supabase_admin.
|
||||
func rlsModeLabel(secret []byte) string {
|
||||
if len(secret) == 0 {
|
||||
return "service-role"
|
||||
}
|
||||
return "per-user"
|
||||
}
|
||||
|
||||
86
cmd/server/main_paliadin_backend_test.go
Normal file
86
cmd/server/main_paliadin_backend_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildAichatPaliadinConfig pins the env-driven wiring used by the
|
||||
// PALIADIN_BACKEND=aichat path in main(). It guards three things:
|
||||
//
|
||||
// 1. Required vars (AICHAT_URL, AICHAT_TOKEN) must be set — otherwise
|
||||
// boot fails fast with a clear error message.
|
||||
// 2. AICHAT_PERSONA defaults to "paliadin" so a misconfigured deploy
|
||||
// doesn't silently route to a different persona.
|
||||
// 3. The JWT secret threads through so per-turn JWT mint is on by
|
||||
// default (folded-in t-paliad-156 work).
|
||||
//
|
||||
// We can't unit-test the switch{} block in main() directly without
|
||||
// invoking the rest of boot, so this test exercises the helper that
|
||||
// branch calls — the same surface a Phase B regression would hit.
|
||||
func TestBuildAichatPaliadinConfig(t *testing.T) {
|
||||
t.Run("rejects empty URL", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
_, err := buildAichatPaliadinConfig("secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "AICHAT_URL") {
|
||||
t.Errorf("err = %v; want AICHAT_URL complaint", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects empty token", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test")
|
||||
t.Setenv("AICHAT_TOKEN", "")
|
||||
_, err := buildAichatPaliadinConfig("secret")
|
||||
if err == nil || !strings.Contains(err.Error(), "AICHAT_TOKEN") {
|
||||
t.Errorf("err = %v; want AICHAT_TOKEN complaint", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults persona to paliadin", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test/")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
t.Setenv("AICHAT_PERSONA", "")
|
||||
cfg, err := buildAichatPaliadinConfig("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if cfg.Persona != "paliadin" {
|
||||
t.Errorf("persona = %q; want paliadin", cfg.Persona)
|
||||
}
|
||||
if cfg.BaseURL != "http://aichat.test" {
|
||||
t.Errorf("base url trailing slash leaked: %q", cfg.BaseURL)
|
||||
}
|
||||
if string(cfg.JWTSecret) != "secret" {
|
||||
t.Errorf("JWT secret not threaded; got %q", string(cfg.JWTSecret))
|
||||
}
|
||||
if cfg.BearerToken != "tok" {
|
||||
t.Errorf("BearerToken = %q; want tok", cfg.BearerToken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("honours AICHAT_PERSONA override", func(t *testing.T) {
|
||||
t.Setenv("AICHAT_URL", "http://aichat.test")
|
||||
t.Setenv("AICHAT_TOKEN", "tok")
|
||||
t.Setenv("AICHAT_PERSONA", "custom-paliadin")
|
||||
cfg, err := buildAichatPaliadinConfig("secret")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if cfg.Persona != "custom-paliadin" {
|
||||
t.Errorf("persona = %q; want custom-paliadin", cfg.Persona)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRLSModeLabel(t *testing.T) {
|
||||
if got := rlsModeLabel(nil); got != "service-role" {
|
||||
t.Errorf("nil → %q; want service-role", got)
|
||||
}
|
||||
if got := rlsModeLabel([]byte{}); got != "service-role" {
|
||||
t.Errorf("empty → %q; want service-role", got)
|
||||
}
|
||||
if got := rlsModeLabel([]byte("x")); got != "per-user" {
|
||||
t.Errorf("non-empty → %q; want per-user", got)
|
||||
}
|
||||
}
|
||||
659
internal/services/aichat_paliadin.go
Normal file
659
internal/services/aichat_paliadin.go
Normal file
@@ -0,0 +1,659 @@
|
||||
package services
|
||||
|
||||
// AichatPaliadinService — the Phase B path of the Paliadin backend
|
||||
// (m/paliad#38, t-paliad-194).
|
||||
//
|
||||
// Design + Phase A spec: docs/design/aichat-2026-05-13.md in m/mAi
|
||||
// (issue m/mAi#207). The aichat service runs on mRiver itself, owns
|
||||
// the long-lived `claude` tmux session per persona (windows per user),
|
||||
// and exposes a small HTTP surface to client apps:
|
||||
//
|
||||
// POST /chat/turn — synchronous one-shot turn
|
||||
// POST /chat/reset — kill the user's window
|
||||
// GET /chat/health — service liveness
|
||||
//
|
||||
// Where RemotePaliadinService shells out over SSH to a per-app shim,
|
||||
// AichatPaliadinService is a thin HTTP client of the centralized
|
||||
// backend. It implements the same Paliadin interface as the local and
|
||||
// remote backends so the cutover is a `PALIADIN_BACKEND=aichat` env
|
||||
// flip rather than a handler-layer rewrite.
|
||||
//
|
||||
// Wiring is gated on PALIADIN_BACKEND in cmd/server/main.go:
|
||||
// PALIADIN_BACKEND=aichat → AichatPaliadinService
|
||||
// anything else (default) → legacy Local/Remote/Disabled selection
|
||||
//
|
||||
// Per-user RLS auth: the planck branch (mai/planck/paliadin-per-user-rls,
|
||||
// parked t-paliad-156) carried the per-turn HS256 mint that turns
|
||||
// paliad.* queries into "RLS as the user" instead of service role. The
|
||||
// mint lives in paliadin_jwt.go; this service reuses it and ships the
|
||||
// signed token in the `jwt` field of /chat/turn, which aichat writes
|
||||
// to a per-turn file the claude pane reads to `SET LOCAL
|
||||
// request.jwt.claims` before each paliad.* query.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// AichatPaliadinConfig is the bag of knobs cmd/server/main.go passes
|
||||
// when constructing an AichatPaliadinService.
|
||||
type AichatPaliadinConfig struct {
|
||||
// BaseURL is the aichat service root (e.g. http://100.99.98.203:8765).
|
||||
// No trailing slash. Endpoints are derived as BaseURL + "/chat/*".
|
||||
BaseURL string
|
||||
|
||||
// BearerToken is the per-app raw token aichat hashes against
|
||||
// tokens.yaml. Empty token is rejected by the aichat /chat/turn
|
||||
// auth gate as "auth_failed".
|
||||
BearerToken string
|
||||
|
||||
// Persona is the aichat persona id — fixed to "paliadin" for this
|
||||
// service. Exposed as config only so tests can override.
|
||||
Persona string
|
||||
|
||||
// HTTPClient is the underlying transport. cmd/server/main.go wires
|
||||
// a single shared client with a 130 s timeout (matching the Phase A
|
||||
// shim ceiling: claude cold start + skill discovery + first
|
||||
// reasoning, ~120 s, plus a few seconds of HTTP overhead). Tests
|
||||
// inject a roundtripper that doesn't hit the network.
|
||||
HTTPClient *http.Client
|
||||
|
||||
// JWTSecret is paliad's SUPABASE_JWT_SECRET. When non-empty,
|
||||
// RunTurn mints a fresh per-turn HS256 token scoped to the calling
|
||||
// user (sub=userID, role=authenticated). Aichat passes the raw
|
||||
// token through to the claude pane via /tmp/aichat-jwts/<turn>.jwt
|
||||
// (mode 0600, deferred-removed). The skill reads it and `SET LOCAL
|
||||
// request.jwt.claims = …` before each paliad.* query — RLS then
|
||||
// evaluates as the user. Empty → no |jwt=…| segment; aichat sees
|
||||
// jwt:"" and skips the file write, and the skill surfaces the
|
||||
// missing-JWT bug rather than silently leaking as service role.
|
||||
JWTSecret []byte
|
||||
|
||||
// JWTTTL bounds the per-turn JWT lifetime. Zero → DefaultPaliadinJWTTTL.
|
||||
JWTTTL time.Duration
|
||||
}
|
||||
|
||||
// AichatPaliadinService implements Paliadin against the centralized
|
||||
// aichat HTTP backend.
|
||||
type AichatPaliadinService struct {
|
||||
paliadinDB
|
||||
cfg AichatPaliadinConfig
|
||||
|
||||
// Serialise turns across all users. Same rationale as the remote
|
||||
// service: aichat runs one claude per persona session, finite
|
||||
// concurrency, paliadin turns are short.
|
||||
turnMu sync.Mutex
|
||||
|
||||
// Service-wide health-check cache (NOT per-session — aichat's
|
||||
// /chat/health is service-wide, unlike the shim's per-user verb).
|
||||
// Same 10 s success cache, no failure cache.
|
||||
healthMu sync.Mutex
|
||||
healthOK bool
|
||||
healthCheckedAt time.Time
|
||||
|
||||
// Per-user-session "have we primed this pane in this Go-process
|
||||
// lifetime?" cache. Aichat is stateless on user content; the client
|
||||
// owns the primer. Same shape as RemotePaliadinService.primed.
|
||||
primedMu sync.Mutex
|
||||
primed map[string]bool
|
||||
|
||||
// Hook for tests — when non-nil, callHTTP delegates here instead
|
||||
// of hitting the wire. Production code never sets this.
|
||||
httpHook func(ctx context.Context, method, path string, body any, out any) error
|
||||
}
|
||||
|
||||
// ErrAichatAuthFailed signals the aichat service rejected the bearer
|
||||
// token. Distinct from ErrMRiverUnreachable so the operator dashboard
|
||||
// can disambiguate "service is up but our token is wrong" from "service
|
||||
// is down". Friendly-error mapping in handlers/paliadin.go covers both.
|
||||
var ErrAichatAuthFailed = errors.New("aichat: auth failed")
|
||||
|
||||
// ErrAichatPersonaUnknown signals the aichat service does not know
|
||||
// this persona (or this app isn't allowed to use it). Surfaces as
|
||||
// shim_error / mriver_unreachable to the user — neither is recoverable
|
||||
// without a deploy-side fix.
|
||||
var ErrAichatPersonaUnknown = errors.New("aichat: persona unknown")
|
||||
|
||||
// DefaultAichatPersona is the persona id every Paliad deploy targets.
|
||||
// Exposed for tests; cmd/server/main.go does not override it.
|
||||
const DefaultAichatPersona = "paliadin"
|
||||
|
||||
// DefaultAichatHTTPTimeout matches RemotePaliadinService.callShim's
|
||||
// 130 s ceiling: aichat's persona timeout is 120 s (personas.yaml) and
|
||||
// HTTP overhead adds ≤10 s.
|
||||
const DefaultAichatHTTPTimeout = 130 * time.Second
|
||||
|
||||
// NewAichatPaliadinService wires the aichat HTTP backend.
|
||||
//
|
||||
// Call only when PALIADIN_BACKEND=aichat in the environment; the
|
||||
// constructor does not probe aichat — first probe happens on the first
|
||||
// RunTurn call via healthGate.
|
||||
func NewAichatPaliadinService(db *sqlx.DB, users *UserService, cfg AichatPaliadinConfig) *AichatPaliadinService {
|
||||
if cfg.Persona == "" {
|
||||
cfg.Persona = DefaultAichatPersona
|
||||
}
|
||||
if cfg.HTTPClient == nil {
|
||||
cfg.HTTPClient = &http.Client{Timeout: DefaultAichatHTTPTimeout}
|
||||
}
|
||||
cfg.BaseURL = strings.TrimRight(cfg.BaseURL, "/")
|
||||
return &AichatPaliadinService{
|
||||
paliadinDB: paliadinDB{db: db, users: users},
|
||||
cfg: cfg,
|
||||
primed: make(map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// RunTurn drives one Q&A round against the centralized aichat backend.
|
||||
// Same audit-row contract as the local + remote services: write the row
|
||||
// first, run the turn, complete on success, mark error on failure.
|
||||
func (s *AichatPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
|
||||
s.turnMu.Lock()
|
||||
defer s.turnMu.Unlock()
|
||||
|
||||
turnID := uuid.New()
|
||||
startedAt := time.Now().UTC()
|
||||
|
||||
if err := s.insertTurnRow(ctx, &PaliadinTurn{
|
||||
TurnID: turnID,
|
||||
UserID: req.UserID,
|
||||
SessionID: req.SessionID,
|
||||
StartedAt: startedAt,
|
||||
UserMessage: req.UserMessage,
|
||||
PageOrigin: optionalString(req.PageOrigin),
|
||||
}, req.Context); err != nil {
|
||||
return nil, fmt.Errorf("paliadin: insert turn row: %w", err)
|
||||
}
|
||||
|
||||
// Health-gate before paying the cost of a real turn.
|
||||
if err := s.healthGate(ctx); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "mriver_unreachable")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// aichat windows are named by sanitized email_localpart (m's §13
|
||||
// Q2 pick). Look up the user's email so the window name is
|
||||
// human-readable in `tmux list-windows` on mRiver. Fall back to
|
||||
// userID-prefix if the user row is missing (e.g. fresh signups
|
||||
// pre-onboarding) — aichat's persona.SanitizeWindowName will accept
|
||||
// either.
|
||||
username := s.usernameFor(ctx, req.UserID)
|
||||
session := s.cfg.Persona + ":" + username
|
||||
|
||||
// Primer pulled from paliad.paliadin_turns when this is our first
|
||||
// turn for this user-window in this Go-process lifetime. aichat is
|
||||
// stateless on user content (design §8); the client owns the
|
||||
// primer. The exchanges go in the request body; aichat injects
|
||||
// them into the envelope before the user message.
|
||||
primer := s.buildPrimerExchanges(ctx, session, req)
|
||||
|
||||
// Mint the per-turn JWT (t-paliad-156). Aichat handles the file
|
||||
// write + cleanup on mRiver — we just sign and ship. When the
|
||||
// secret isn't configured, send no JWT and aichat's skill will
|
||||
// surface "JWT missing — paliad bug" rather than silently leaking
|
||||
// as service role.
|
||||
jwt, err := s.mintJWTIfConfigured(req.UserID)
|
||||
if err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, "jwt_mint_failed")
|
||||
return nil, fmt.Errorf("paliadin: mint turn jwt: %w", err)
|
||||
}
|
||||
|
||||
// Pass any structured TurnContext (t-paliad-161 widget payload)
|
||||
// through aichat's Meta field. Skill receives it as a [ctx …]
|
||||
// envelope segment built on the aichat side.
|
||||
meta := buildAichatMeta(req)
|
||||
|
||||
body := aichatTurnRequest{
|
||||
Persona: s.cfg.Persona,
|
||||
Username: username,
|
||||
SessionID: req.SessionID,
|
||||
Message: sanitiseForTmux(req.UserMessage),
|
||||
JWT: jwt,
|
||||
Primer: primer,
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
var resp aichatTurnResponse
|
||||
if err := s.callHTTP(ctx, http.MethodPost, "/chat/turn", body, &resp); err != nil {
|
||||
_ = s.markTurnError(ctx, turnID, classifyAichatError(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// aichat may have just spawned the window — clear our primed-cache
|
||||
// for the session so the next turn rebuilds context. The current
|
||||
// turn already shipped its own primer block, so claude saw context
|
||||
// in this exchange.
|
||||
if resp.PaneSpawned {
|
||||
s.clearPrimed(session)
|
||||
} else {
|
||||
s.markPrimed(session)
|
||||
}
|
||||
|
||||
// aichat already strips the paliadin-meta trailer (it knows the
|
||||
// persona's trailer_format). Treat resp.Response as the clean body
|
||||
// and lift Meta straight from the response envelope.
|
||||
cleanBody := resp.Response
|
||||
tokens := approxTokenCount(cleanBody)
|
||||
chipCount := countChips(cleanBody)
|
||||
finished := time.Now().UTC()
|
||||
durationMS := int(finished.Sub(startedAt) / time.Millisecond)
|
||||
|
||||
tmeta := trailerMeta{
|
||||
UsedTools: resp.Meta.UsedTools,
|
||||
ClassifierTag: resp.Meta.ClassifierTag,
|
||||
RowsSeen: coerceAichatRowsSeen(resp.Meta.RowsSeen),
|
||||
}
|
||||
|
||||
if err := s.completeTurn(ctx, turnID, finished, durationMS, cleanBody, tokens, tmeta, chipCount); err != nil {
|
||||
log.Printf("paliadin: complete turn %s: %v", turnID, err)
|
||||
}
|
||||
|
||||
return &TurnResult{
|
||||
TurnID: turnID,
|
||||
Response: cleanBody,
|
||||
UsedTools: tmeta.UsedTools,
|
||||
RowsSeen: tmeta.RowsSeen,
|
||||
ChipCount: chipCount,
|
||||
ClassifierTag: tmeta.ClassifierTag,
|
||||
DurationMS: durationMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResetSession kills the user's window on aichat so the next RunTurn
|
||||
// boots a fresh claude pane. Aichat resolves the window by sanitizing
|
||||
// the same email_localpart we passed at turn time.
|
||||
func (s *AichatPaliadinService) ResetSession(ctx context.Context, userID uuid.UUID) error {
|
||||
username := s.usernameFor(ctx, userID)
|
||||
session := s.cfg.Persona + ":" + username
|
||||
|
||||
// Drop the cached primer flag so the next turn re-injects context
|
||||
// into the new claude pane.
|
||||
s.clearPrimed(session)
|
||||
|
||||
body := aichatResetRequest{
|
||||
Persona: s.cfg.Persona,
|
||||
Username: username,
|
||||
}
|
||||
var resp aichatResetResponse
|
||||
if err := s.callHTTP(ctx, http.MethodPost, "/chat/reset", body, &resp); err != nil {
|
||||
return fmt.Errorf("paliadin: aichat reset %s/%s: %w", s.cfg.Persona, username, err)
|
||||
}
|
||||
if !resp.OK {
|
||||
return fmt.Errorf("paliadin: aichat reset %s/%s: not ok", s.cfg.Persona, username)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthGate runs the aichat /chat/health probe at most once per 10 s.
|
||||
// Returns ErrMRiverUnreachable on miss so the handler maps to the
|
||||
// existing mriver_unreachable friendly-error i18n key (no new strings
|
||||
// needed, per design §11).
|
||||
func (s *AichatPaliadinService) healthGate(ctx context.Context) error {
|
||||
s.healthMu.Lock()
|
||||
defer s.healthMu.Unlock()
|
||||
|
||||
if s.healthOK && time.Since(s.healthCheckedAt) < 10*time.Second {
|
||||
return nil
|
||||
}
|
||||
|
||||
probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var resp aichatHealthResponse
|
||||
if err := s.callHTTP(probeCtx, http.MethodGet, "/chat/health", nil, &resp); err != nil {
|
||||
s.healthOK = false
|
||||
return fmt.Errorf("%w: %v", ErrMRiverUnreachable, err)
|
||||
}
|
||||
if !resp.OK {
|
||||
s.healthOK = false
|
||||
return fmt.Errorf("%w: aichat health reports not ok (claude=%v tmux=%v)",
|
||||
ErrMRiverUnreachable, resp.ClaudeReachable, resp.TmuxReachable)
|
||||
}
|
||||
s.healthOK = true
|
||||
s.healthCheckedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// callHTTP issues one JSON request to the aichat backend. On non-2xx
|
||||
// responses it decodes the aichat error envelope into a typed error so
|
||||
// classifyAichatError can map it to one of our audit codes.
|
||||
//
|
||||
// Tests set httpHook to bypass the network entirely.
|
||||
func (s *AichatPaliadinService) callHTTP(ctx context.Context, method, path string, body any, out any) error {
|
||||
if s.httpHook != nil {
|
||||
return s.httpHook(ctx, method, path, body, out)
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
buf := &bytes.Buffer{}
|
||||
if err := json.NewEncoder(buf).Encode(body); err != nil {
|
||||
return fmt.Errorf("aichat: encode %s body: %w", path, err)
|
||||
}
|
||||
reqBody = buf
|
||||
}
|
||||
url := s.cfg.BaseURL + path
|
||||
httpReq, err := http.NewRequestWithContext(ctx, method, url, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: build %s request: %w", path, err)
|
||||
}
|
||||
if body != nil {
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if s.cfg.BearerToken != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+s.cfg.BearerToken)
|
||||
}
|
||||
|
||||
httpResp, err := s.cfg.HTTPClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: %s %s: %w", method, path, err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respBytes, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("aichat: read %s response: %w", path, err)
|
||||
}
|
||||
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
return decodeAichatError(httpResp.StatusCode, respBytes)
|
||||
}
|
||||
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(respBytes, out); err != nil {
|
||||
return fmt.Errorf("aichat: decode %s response: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeAichatError parses aichat's wire-level error envelope. The
|
||||
// envelope shape is `{"error":{"code":..., "message":..., "retryable":...}}`
|
||||
// (see m/mAi internal/aichat/aierrors). We surface a typed sentinel
|
||||
// error per code so classifyAichatError can map it to our audit codes.
|
||||
func decodeAichatError(status int, body []byte) error {
|
||||
var env struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Retryable bool `json:"retryable"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &env)
|
||||
code := env.Error.Code
|
||||
msg := env.Error.Message
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
switch code {
|
||||
case "auth_failed":
|
||||
return fmt.Errorf("%w: %s", ErrAichatAuthFailed, msg)
|
||||
case "persona_unknown":
|
||||
return fmt.Errorf("%w: %s", ErrAichatPersonaUnknown, msg)
|
||||
case "mriver_unreachable", "bootstrap_failed":
|
||||
return fmt.Errorf("%w: %s", ErrMRiverUnreachable, msg)
|
||||
case "timeout":
|
||||
return fmt.Errorf("aichat: turn timeout: %s", msg)
|
||||
case "shim_error", "":
|
||||
return fmt.Errorf("aichat: HTTP %d: %s", status, msg)
|
||||
default:
|
||||
return fmt.Errorf("aichat: HTTP %d (%s): %s", status, code, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// classifyAichatError maps a callHTTP error onto the audit-row code
|
||||
// vocabulary the frontend's friendlyErrorMessage already localises.
|
||||
// Keep code strings stable — they're part of the i18n contract.
|
||||
func classifyAichatError(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, ErrMRiverUnreachable):
|
||||
return "mriver_unreachable"
|
||||
case errors.Is(err, ErrAichatAuthFailed):
|
||||
return "shim_auth_failed"
|
||||
case errors.Is(err, ErrAichatPersonaUnknown):
|
||||
return "shim_error"
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "timeout"
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "turn timeout"):
|
||||
return "timeout"
|
||||
case strings.Contains(msg, "no such host"),
|
||||
strings.Contains(msg, "connection refused"),
|
||||
strings.Contains(msg, "Connection refused"),
|
||||
strings.Contains(msg, "connect: network is unreachable"):
|
||||
return "mriver_unreachable"
|
||||
default:
|
||||
return "shim_error"
|
||||
}
|
||||
}
|
||||
|
||||
// usernameFor resolves the aichat window name for a paliad user.
|
||||
//
|
||||
// Aichat windows are keyed by sanitized email_localpart per m's §13 Q2
|
||||
// pick (e.g. matthias.siebels@hoganlovells.com → "matthiassiebels").
|
||||
// We pass the localpart unsanitized; aichat applies persona.SanitizeWindowName
|
||||
// (alphanumerics + `-`/`_`, lowercased, max 32 chars; falls back to
|
||||
// "user-<uuid8>" if sanitising empties the string).
|
||||
//
|
||||
// Fallback when the user row is missing: userID short, which aichat
|
||||
// accepts as-is. Lookup errors degrade silently — we cannot block a
|
||||
// chat turn on a DB hiccup, and the worst-case window name is "user-…",
|
||||
// not an outage.
|
||||
func (s *AichatPaliadinService) usernameFor(ctx context.Context, userID uuid.UUID) string {
|
||||
fallback := "user-" + userID.String()[:8]
|
||||
if s.db == nil {
|
||||
return fallback
|
||||
}
|
||||
var email string
|
||||
err := s.db.QueryRowxContext(ctx,
|
||||
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
|
||||
if err != nil || email == "" {
|
||||
return fallback
|
||||
}
|
||||
at := strings.IndexByte(email, '@')
|
||||
if at <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return email[:at]
|
||||
}
|
||||
|
||||
// buildPrimerExchanges returns up to MaxPrimerTurns prior exchanges
|
||||
// from the user's paliad.paliadin_turns history, in oldest→newest
|
||||
// order. Returns nil when:
|
||||
//
|
||||
// - we've already primed this session in this process lifetime,
|
||||
// - the session id is empty (legacy turns predating t-paliad-161),
|
||||
// - the history lookup errors (degrade silently — the user's
|
||||
// question still ships, just without continuity).
|
||||
//
|
||||
// Aichat injects the returned exchanges into the envelope before the
|
||||
// user message. Format details live in m/mAi internal/aichat/turn/primer.go;
|
||||
// the wire payload is just a slice of {user, assistant} pairs.
|
||||
func (s *AichatPaliadinService) buildPrimerExchanges(ctx context.Context, session string, req TurnRequest) []aichatPrimerExchange {
|
||||
if s.isPrimed(session) || req.SessionID == "" || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.ListHistoryForSession(ctx, req.UserID, req.SessionID, MaxPrimerTurns)
|
||||
if err != nil {
|
||||
log.Printf("paliadin: aichat primer history lookup: %v", err)
|
||||
return nil
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(rows) > MaxPrimerTurns {
|
||||
rows = rows[len(rows)-MaxPrimerTurns:]
|
||||
}
|
||||
out := make([]aichatPrimerExchange, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
assistant := ""
|
||||
if row.Response != nil {
|
||||
assistant = *row.Response
|
||||
}
|
||||
out = append(out, aichatPrimerExchange{
|
||||
User: truncateForPrimer(row.UserMessage),
|
||||
Assistant: truncateForPrimer(assistant),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mintJWTIfConfigured signs a per-turn HS256 token for the calling
|
||||
// user when JWTSecret is set. Returns "" + nil when the secret is
|
||||
// unset — aichat then writes no JWT file and the SKILL.md detects the
|
||||
// missing path on the next paliad.* query.
|
||||
func (s *AichatPaliadinService) mintJWTIfConfigured(userID uuid.UUID) (string, error) {
|
||||
if len(s.cfg.JWTSecret) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return mintTurnJWT(userID, s.cfg.JWTTTL, s.cfg.JWTSecret)
|
||||
}
|
||||
|
||||
// buildAichatMeta packs paliad's TurnContext into the wire-level Meta
|
||||
// map aichat forwards to the envelope. Empty payload returns nil so
|
||||
// aichat omits the [ctx …] segment entirely.
|
||||
func buildAichatMeta(req TurnRequest) map[string]string {
|
||||
out := map[string]string{}
|
||||
if req.PageOrigin != "" {
|
||||
out["page_origin"] = req.PageOrigin
|
||||
}
|
||||
if req.Context != nil {
|
||||
c := req.Context
|
||||
if c.RouteName != "" {
|
||||
out["route"] = c.RouteName
|
||||
}
|
||||
if c.PrimaryEntityType != "" && c.PrimaryEntityID != "" {
|
||||
out["entity"] = c.PrimaryEntityType + ":" + c.PrimaryEntityID
|
||||
}
|
||||
if c.ViewMode != "" {
|
||||
out["view"] = c.ViewMode
|
||||
}
|
||||
if c.FilterSummary != "" {
|
||||
out["filter"] = c.FilterSummary
|
||||
}
|
||||
if c.UserSelectionText != "" {
|
||||
sel := c.UserSelectionText
|
||||
if len(sel) > MaxSelectionChars {
|
||||
sel = sel[:MaxSelectionChars] + "…"
|
||||
}
|
||||
out["selection"] = sel
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// coerceAichatRowsSeen converts aichat's wire-level RowsSeen ([]string)
|
||||
// back to paliad's audit-row shape ([]int). Non-numeric entries are
|
||||
// dropped — the trailer parser on the aichat side already filters but
|
||||
// we guard anyway.
|
||||
func coerceAichatRowsSeen(in []string) []int {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 0, len(in))
|
||||
for _, s := range in {
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(strings.TrimSpace(s), "%d", &n); err == nil {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// primer cache — same shape as RemotePaliadinService.{is,mark,clear}Primed
|
||||
// =============================================================================
|
||||
|
||||
func (s *AichatPaliadinService) isPrimed(session string) bool {
|
||||
s.primedMu.Lock()
|
||||
defer s.primedMu.Unlock()
|
||||
return s.primed[session]
|
||||
}
|
||||
|
||||
func (s *AichatPaliadinService) markPrimed(session string) {
|
||||
s.primedMu.Lock()
|
||||
defer s.primedMu.Unlock()
|
||||
s.primed[session] = true
|
||||
}
|
||||
|
||||
func (s *AichatPaliadinService) clearPrimed(session string) {
|
||||
s.primedMu.Lock()
|
||||
defer s.primedMu.Unlock()
|
||||
delete(s.primed, session)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// wire types — mirror m/mAi internal/aichat/api/types.go exactly so we
|
||||
// can JSON-marshal directly. Kept here (rather than importing m/mAi) so
|
||||
// paliad stays a self-contained module.
|
||||
// =============================================================================
|
||||
|
||||
type aichatTurnRequest struct {
|
||||
Persona string `json:"persona"`
|
||||
Username string `json:"username"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
JWT string `json:"jwt,omitempty"`
|
||||
Primer []aichatPrimerExchange `json:"primer,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type aichatPrimerExchange struct {
|
||||
User string `json:"user"`
|
||||
Assistant string `json:"assistant"`
|
||||
}
|
||||
|
||||
type aichatTurnResponse struct {
|
||||
TurnID string `json:"turn_id"`
|
||||
Response string `json:"response"`
|
||||
Meta aichatMeta `json:"meta"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
PaneSpawned bool `json:"pane_spawned"`
|
||||
}
|
||||
|
||||
type aichatMeta struct {
|
||||
UsedTools []string `json:"used_tools,omitempty"`
|
||||
RowsSeen []string `json:"rows_seen,omitempty"`
|
||||
ClassifierTag string `json:"classifier_tag,omitempty"`
|
||||
}
|
||||
|
||||
type aichatResetRequest struct {
|
||||
Persona string `json:"persona"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type aichatResetResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
}
|
||||
|
||||
type aichatHealthResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
ClaudeReachable bool `json:"claude_reachable"`
|
||||
TmuxReachable bool `json:"tmux_reachable"`
|
||||
}
|
||||
|
||||
// Compile-time interface conformance — fail the build, not a runtime
|
||||
// test, if a Paliadin method drifts off this backend.
|
||||
var _ Paliadin = (*AichatPaliadinService)(nil)
|
||||
668
internal/services/aichat_paliadin_test.go
Normal file
668
internal/services/aichat_paliadin_test.go
Normal file
@@ -0,0 +1,668 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AichatPaliadinService unit tests (t-paliad-194 / m/paliad#38).
|
||||
//
|
||||
// Every test bypasses the HTTP wire via the httpHook field — no real
|
||||
// requests are issued, no DB rows are written. Tests that would need DB
|
||||
// I/O (audit row insert/complete on RunTurn) are not in scope here;
|
||||
// paliad's test suite has no sqlx mock and the existing paliadin tests
|
||||
// only cover pure functions and hookable interfaces.
|
||||
|
||||
const testAichatBase = "http://aichat.test"
|
||||
const testAichatToken = "raw-app-token"
|
||||
|
||||
// newAichatService builds an AichatPaliadinService with a baked-in hook
|
||||
// for tests. The hook receives every callHTTP invocation; tests cusomise
|
||||
// what it returns.
|
||||
func newAichatService(t *testing.T, secret []byte, hook func(ctx context.Context, method, path string, body any, out any) error) *AichatPaliadinService {
|
||||
t.Helper()
|
||||
s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
|
||||
BaseURL: testAichatBase,
|
||||
BearerToken: testAichatToken,
|
||||
JWTSecret: secret,
|
||||
})
|
||||
s.httpHook = hook
|
||||
return s
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Constructor + defaults
|
||||
// =============================================================================
|
||||
|
||||
func TestNewAichatPaliadinService_Defaults(t *testing.T) {
|
||||
s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
|
||||
BaseURL: testAichatBase + "/",
|
||||
BearerToken: "t",
|
||||
})
|
||||
if s.cfg.Persona != DefaultAichatPersona {
|
||||
t.Errorf("Persona default = %q; want %q", s.cfg.Persona, DefaultAichatPersona)
|
||||
}
|
||||
if s.cfg.HTTPClient == nil {
|
||||
t.Error("HTTPClient should be defaulted, not nil")
|
||||
}
|
||||
if s.cfg.BaseURL != testAichatBase {
|
||||
t.Errorf("BaseURL trailing slash not trimmed: %q", s.cfg.BaseURL)
|
||||
}
|
||||
if s.cfg.HTTPClient.Timeout != DefaultAichatHTTPTimeout {
|
||||
t.Errorf("HTTPClient.Timeout = %s; want %s", s.cfg.HTTPClient.Timeout, DefaultAichatHTTPTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAichatPaliadinService_HonoursOverrides(t *testing.T) {
|
||||
custom := &http.Client{Timeout: 5 * time.Second}
|
||||
s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
|
||||
BaseURL: testAichatBase,
|
||||
BearerToken: "t",
|
||||
Persona: "custom",
|
||||
HTTPClient: custom,
|
||||
})
|
||||
if s.cfg.Persona != "custom" {
|
||||
t.Errorf("Persona override lost: %q", s.cfg.Persona)
|
||||
}
|
||||
if s.cfg.HTTPClient != custom {
|
||||
t.Error("HTTPClient override lost")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Interface conformance
|
||||
// =============================================================================
|
||||
|
||||
func TestAichatPaliadinService_ImplementsPaliadin(t *testing.T) {
|
||||
var _ Paliadin = (*AichatPaliadinService)(nil)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Health gate
|
||||
// =============================================================================
|
||||
|
||||
func TestAichatHealthGate_CachesOnSuccess(t *testing.T) {
|
||||
var calls int32
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if method != http.MethodGet || path != "/chat/health" {
|
||||
t.Errorf("unexpected callHTTP: method=%s path=%s", method, path)
|
||||
}
|
||||
setHealthResp(out, true)
|
||||
return nil
|
||||
})
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := s.healthGate(context.Background()); err != nil {
|
||||
t.Fatalf("healthGate iter %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Errorf("expected 1 health probe (cached); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAichatHealthGate_RetriesAfterFailure(t *testing.T) {
|
||||
var calls int32
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return errors.New("dial tcp: connection refused")
|
||||
})
|
||||
for i := 0; i < 3; i++ {
|
||||
err := s.healthGate(context.Background())
|
||||
if !errors.Is(err, ErrMRiverUnreachable) {
|
||||
t.Errorf("iter %d: err %v; want wrap of ErrMRiverUnreachable", i, err)
|
||||
}
|
||||
}
|
||||
// Failed health is NOT cached.
|
||||
if got := atomic.LoadInt32(&calls); got != 3 {
|
||||
t.Errorf("expected 3 probes (no cache on failure); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAichatHealthGate_RejectsNotOK(t *testing.T) {
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
setHealthResp(out, false)
|
||||
return nil
|
||||
})
|
||||
err := s.healthGate(context.Background())
|
||||
if !errors.Is(err, ErrMRiverUnreachable) {
|
||||
t.Errorf("err = %v; want wrap of ErrMRiverUnreachable for ok:false", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAichatHealthGate_CacheExpires(t *testing.T) {
|
||||
var calls int32
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
setHealthResp(out, true)
|
||||
return nil
|
||||
})
|
||||
if err := s.healthGate(context.Background()); err != nil {
|
||||
t.Fatalf("first probe: %v", err)
|
||||
}
|
||||
// Force the cached timestamp to expire.
|
||||
s.healthMu.Lock()
|
||||
s.healthCheckedAt = time.Now().Add(-11 * time.Second)
|
||||
s.healthMu.Unlock()
|
||||
if err := s.healthGate(context.Background()); err != nil {
|
||||
t.Fatalf("second probe: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Errorf("expected 2 probes (cache expired); got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ResetSession
|
||||
// =============================================================================
|
||||
|
||||
func TestAichatResetSession_Posts(t *testing.T) {
|
||||
var captured aichatResetRequest
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
if method != http.MethodPost || path != "/chat/reset" {
|
||||
t.Errorf("unexpected: method=%s path=%s", method, path)
|
||||
}
|
||||
req, ok := body.(aichatResetRequest)
|
||||
if !ok {
|
||||
t.Fatalf("body type %T; want aichatResetRequest", body)
|
||||
}
|
||||
captured = req
|
||||
setResetResp(out, true)
|
||||
return nil
|
||||
})
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
if err := s.ResetSession(context.Background(), uid); err != nil {
|
||||
t.Fatalf("ResetSession: %v", err)
|
||||
}
|
||||
if captured.Persona != DefaultAichatPersona {
|
||||
t.Errorf("persona = %q; want %q", captured.Persona, DefaultAichatPersona)
|
||||
}
|
||||
// No DB → usernameFor falls back to "user-<uuid8>".
|
||||
if captured.Username != "user-aaaaaaaa" {
|
||||
t.Errorf("username = %q; want fallback user-aaaaaaaa", captured.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAichatResetSession_HonoursServerError(t *testing.T) {
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
return errors.New("aichat: HTTP 500: tmux unreachable")
|
||||
})
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
if err := s.ResetSession(context.Background(), uid); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAichatResetSession_DropsPrimerCache(t *testing.T) {
|
||||
s := newAichatService(t, nil, func(ctx context.Context, method, path string, body any, out any) error {
|
||||
switch path {
|
||||
case "/chat/reset":
|
||||
setResetResp(out, true)
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
session := s.cfg.Persona + ":" + "user-aaaaaaaa"
|
||||
s.markPrimed(session)
|
||||
if !s.isPrimed(session) {
|
||||
t.Fatal("primer cache should be warm before reset")
|
||||
}
|
||||
if err := s.ResetSession(context.Background(), uid); err != nil {
|
||||
t.Fatalf("ResetSession: %v", err)
|
||||
}
|
||||
if s.isPrimed(session) {
|
||||
t.Error("ResetSession must drop the primer cache")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error classification
|
||||
// =============================================================================
|
||||
|
||||
func TestClassifyAichatError(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"ErrMRiverUnreachable", ErrMRiverUnreachable, "mriver_unreachable"},
|
||||
{"wrapped ErrMRiverUnreachable", fmt.Errorf("foo: %w", ErrMRiverUnreachable), "mriver_unreachable"},
|
||||
{"ErrAichatAuthFailed", ErrAichatAuthFailed, "shim_auth_failed"},
|
||||
{"wrapped ErrAichatAuthFailed", fmt.Errorf("call: %w", ErrAichatAuthFailed), "shim_auth_failed"},
|
||||
{"ErrAichatPersonaUnknown", ErrAichatPersonaUnknown, "shim_error"},
|
||||
{"context deadline", context.DeadlineExceeded, "timeout"},
|
||||
{"aichat turn timeout msg", errors.New("aichat: turn timeout: response not written within 120s"), "timeout"},
|
||||
{"connection refused", errors.New("aichat: POST /chat/turn: dial tcp: connection refused"), "mriver_unreachable"},
|
||||
{"no such host", errors.New("aichat: GET /chat/health: dial tcp: lookup aichat.test: no such host"), "mriver_unreachable"},
|
||||
{"unknown error", errors.New("aichat: HTTP 502: bad gateway"), "shim_error"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := classifyAichatError(c.err)
|
||||
if got != c.want {
|
||||
t.Errorf("classifyAichatError(%v) = %q; want %q", c.err, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error envelope decoding
|
||||
// =============================================================================
|
||||
|
||||
func TestDecodeAichatError_MapsCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status int
|
||||
body string
|
||||
wantSentinel error
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
name: "auth_failed → ErrAichatAuthFailed",
|
||||
status: 401,
|
||||
body: `{"error":{"code":"auth_failed","message":"bad token","retryable":false}}`,
|
||||
wantSentinel: ErrAichatAuthFailed,
|
||||
wantSubstr: "bad token",
|
||||
},
|
||||
{
|
||||
name: "persona_unknown → ErrAichatPersonaUnknown",
|
||||
status: 403,
|
||||
body: `{"error":{"code":"persona_unknown","message":"app not allowed"}}`,
|
||||
wantSentinel: ErrAichatPersonaUnknown,
|
||||
wantSubstr: "app not allowed",
|
||||
},
|
||||
{
|
||||
name: "mriver_unreachable → ErrMRiverUnreachable",
|
||||
status: 503,
|
||||
body: `{"error":{"code":"mriver_unreachable","message":"tmux missing"}}`,
|
||||
wantSentinel: ErrMRiverUnreachable,
|
||||
wantSubstr: "tmux missing",
|
||||
},
|
||||
{
|
||||
name: "bootstrap_failed → ErrMRiverUnreachable",
|
||||
status: 500,
|
||||
body: `{"error":{"code":"bootstrap_failed","message":"window stuck"}}`,
|
||||
wantSentinel: ErrMRiverUnreachable,
|
||||
wantSubstr: "window stuck",
|
||||
},
|
||||
{
|
||||
name: "timeout has no sentinel but is recognisable",
|
||||
status: 504,
|
||||
body: `{"error":{"code":"timeout","message":"no response"}}`,
|
||||
wantSentinel: nil,
|
||||
wantSubstr: "turn timeout",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := decodeAichatError(c.status, []byte(c.body))
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error")
|
||||
}
|
||||
if c.wantSentinel != nil && !errors.Is(err, c.wantSentinel) {
|
||||
t.Errorf("err = %v; want errors.Is to be %v", err, c.wantSentinel)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.wantSubstr) {
|
||||
t.Errorf("err msg %q; want substring %q", err.Error(), c.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeAichatError_FallsBackOnBadJSON(t *testing.T) {
|
||||
err := decodeAichatError(500, []byte("not json"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("err should mention status: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// callHTTP wire format (no httpHook — uses RoundTripper instead)
|
||||
// =============================================================================
|
||||
|
||||
// roundTripFunc lets a test inject a custom http.RoundTripper.
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
|
||||
func TestCallHTTP_AttachesBearerAndJSON(t *testing.T) {
|
||||
var seen *http.Request
|
||||
var seenBody []byte
|
||||
s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
|
||||
BaseURL: testAichatBase,
|
||||
BearerToken: testAichatToken,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
seen = r
|
||||
if r.Body != nil {
|
||||
seenBody, _ = io.ReadAll(r.Body)
|
||||
}
|
||||
resp := `{"ok":true,"claude_reachable":true,"tmux_reachable":true}`
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(resp)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
})
|
||||
var out aichatHealthResponse
|
||||
if err := s.callHTTP(context.Background(), http.MethodPost, "/chat/turn",
|
||||
map[string]string{"k": "v"}, &out); err != nil {
|
||||
t.Fatalf("callHTTP: %v", err)
|
||||
}
|
||||
if seen == nil {
|
||||
t.Fatal("no request captured")
|
||||
}
|
||||
if got := seen.Header.Get("Authorization"); got != "Bearer "+testAichatToken {
|
||||
t.Errorf("Authorization = %q; want Bearer %s", got, testAichatToken)
|
||||
}
|
||||
if got := seen.Header.Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("Content-Type = %q; want application/json", got)
|
||||
}
|
||||
if seen.URL.String() != testAichatBase+"/chat/turn" {
|
||||
t.Errorf("URL = %q; want %s/chat/turn", seen.URL.String(), testAichatBase)
|
||||
}
|
||||
var decoded map[string]string
|
||||
if err := json.Unmarshal(seenBody, &decoded); err != nil {
|
||||
t.Fatalf("body not JSON: %v (%s)", err, string(seenBody))
|
||||
}
|
||||
if decoded["k"] != "v" {
|
||||
t.Errorf("body lost: %v", decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallHTTP_DecodesErrorEnvelope(t *testing.T) {
|
||||
s := NewAichatPaliadinService(nil, nil, AichatPaliadinConfig{
|
||||
BaseURL: testAichatBase,
|
||||
BearerToken: testAichatToken,
|
||||
HTTPClient: &http.Client{
|
||||
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
resp := `{"error":{"code":"auth_failed","message":"bad token","retryable":false}}`
|
||||
return &http.Response{
|
||||
StatusCode: 401,
|
||||
Body: io.NopCloser(bytes.NewBufferString(resp)),
|
||||
}, nil
|
||||
}),
|
||||
},
|
||||
})
|
||||
err := s.callHTTP(context.Background(), http.MethodPost, "/chat/turn", map[string]string{}, nil)
|
||||
if !errors.Is(err, ErrAichatAuthFailed) {
|
||||
t.Errorf("err = %v; want ErrAichatAuthFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// JWT mint integration
|
||||
// =============================================================================
|
||||
|
||||
func TestMintJWTIfConfigured_Disabled(t *testing.T) {
|
||||
s := newAichatService(t, nil, nil)
|
||||
tok, err := s.mintJWTIfConfigured(uuid.New())
|
||||
if err != nil {
|
||||
t.Errorf("err with empty secret: %v", err)
|
||||
}
|
||||
if tok != "" {
|
||||
t.Errorf("token = %q; want empty when secret unset", tok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintJWTIfConfigured_Signs(t *testing.T) {
|
||||
secret := []byte("test-secret-only-for-paliadin")
|
||||
s := newAichatService(t, secret, nil)
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
tok, err := s.mintJWTIfConfigured(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("mint: %v", err)
|
||||
}
|
||||
if strings.Count(tok, ".") != 2 {
|
||||
t.Errorf("token shape = %q; want 3-segment JWT", tok)
|
||||
}
|
||||
parsed, err := jwt.Parse(tok, func(*jwt.Token) (any, error) { return secret, nil },
|
||||
jwt.WithValidMethods([]string{"HS256"}))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
claims := parsed.Claims.(jwt.MapClaims)
|
||||
if got, _ := claims["sub"].(string); got != uid.String() {
|
||||
t.Errorf("sub = %q; want %q", got, uid.String())
|
||||
}
|
||||
if got, _ := claims["role"].(string); got != "authenticated" {
|
||||
t.Errorf("role = %q; want authenticated", got)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RunTurn — exercises the full happy path with a hook + nil DB
|
||||
// =============================================================================
|
||||
|
||||
// runTurnTestingService is a focused variant of AichatPaliadinService
|
||||
// that skips the DB write in RunTurn. We can't mock sqlx cheaply, so we
|
||||
// test the HTTP-facing surface of RunTurn directly via callHTTP rather
|
||||
// than the public RunTurn entry point. The interface contract is still
|
||||
// verified at compile time (TestAichatPaliadinService_ImplementsPaliadin).
|
||||
//
|
||||
// What we cover here:
|
||||
// - request body shape (persona, username, message, meta, primer, jwt)
|
||||
// - response decoding (pane_spawned → primer cache cleared)
|
||||
// - error path (callHTTP error → propagates)
|
||||
func TestRunTurn_HappyPath_ViaCallHTTP(t *testing.T) {
|
||||
var captured aichatTurnRequest
|
||||
s := newAichatService(t, []byte("secret"), func(ctx context.Context, method, path string, body any, out any) error {
|
||||
switch path {
|
||||
case "/chat/health":
|
||||
setHealthResp(out, true)
|
||||
return nil
|
||||
case "/chat/turn":
|
||||
req, ok := body.(aichatTurnRequest)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected body type: %T", body)
|
||||
}
|
||||
captured = req
|
||||
setTurnResp(out, "Hi back!", false)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unexpected path: %s", path)
|
||||
})
|
||||
|
||||
// RunTurn itself calls insertTurnRow on the DB. Without a real DB we
|
||||
// can't invoke RunTurn directly. Instead, simulate its inner sequence
|
||||
// at the HTTP level — same wire format, same hook, same response.
|
||||
// The DB-touching paths (insertTurnRow / completeTurn / markTurnError)
|
||||
// are covered by paliadin_test.go's existing audit-row tests.
|
||||
|
||||
if err := s.healthGate(context.Background()); err != nil {
|
||||
t.Fatalf("healthGate: %v", err)
|
||||
}
|
||||
|
||||
uid := uuid.MustParse("aaaaaaaa-1111-2222-3333-444444444444")
|
||||
jwtTok, _ := s.mintJWTIfConfigured(uid)
|
||||
body := aichatTurnRequest{
|
||||
Persona: s.cfg.Persona,
|
||||
Username: s.usernameFor(context.Background(), uid),
|
||||
Message: "Hello",
|
||||
JWT: jwtTok,
|
||||
Meta: buildAichatMeta(TurnRequest{PageOrigin: "/dashboard"}),
|
||||
}
|
||||
var resp aichatTurnResponse
|
||||
if err := s.callHTTP(context.Background(), http.MethodPost, "/chat/turn", body, &resp); err != nil {
|
||||
t.Fatalf("callHTTP: %v", err)
|
||||
}
|
||||
|
||||
if captured.Persona != DefaultAichatPersona {
|
||||
t.Errorf("persona = %q; want %q", captured.Persona, DefaultAichatPersona)
|
||||
}
|
||||
if captured.Username != "user-aaaaaaaa" {
|
||||
t.Errorf("username = %q; want user-aaaaaaaa (nil DB fallback)", captured.Username)
|
||||
}
|
||||
if captured.Message != "Hello" {
|
||||
t.Errorf("message = %q; want Hello", captured.Message)
|
||||
}
|
||||
if captured.JWT == "" {
|
||||
t.Error("JWT not attached; want signed token")
|
||||
}
|
||||
if captured.Meta["page_origin"] != "/dashboard" {
|
||||
t.Errorf("meta.page_origin = %q; want /dashboard", captured.Meta["page_origin"])
|
||||
}
|
||||
if resp.Response != "Hi back!" {
|
||||
t.Errorf("response = %q; want Hi back!", resp.Response)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// usernameFor / buildAichatMeta / coerceAichatRowsSeen
|
||||
// =============================================================================
|
||||
|
||||
func TestUsernameFor_FallbackWhenNoDB(t *testing.T) {
|
||||
s := newAichatService(t, nil, nil)
|
||||
uid := uuid.MustParse("12345678-1111-2222-3333-444444444444")
|
||||
if got := s.usernameFor(context.Background(), uid); got != "user-12345678" {
|
||||
t.Errorf("username = %q; want user-12345678", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAichatMeta_OmitsEmpty(t *testing.T) {
|
||||
if buildAichatMeta(TurnRequest{}) != nil {
|
||||
t.Error("empty req should produce nil meta")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAichatMeta_PacksTurnContext(t *testing.T) {
|
||||
req := TurnRequest{
|
||||
PageOrigin: "/projects/abc",
|
||||
Context: &TurnContext{
|
||||
RouteName: "projects.detail",
|
||||
PrimaryEntityType: "project",
|
||||
PrimaryEntityID: "abc-123",
|
||||
ViewMode: "verlauf",
|
||||
FilterSummary: "status=open",
|
||||
UserSelectionText: "selected phrase",
|
||||
},
|
||||
}
|
||||
meta := buildAichatMeta(req)
|
||||
if meta == nil {
|
||||
t.Fatal("meta should be non-nil")
|
||||
}
|
||||
wantKeys := map[string]string{
|
||||
"page_origin": "/projects/abc",
|
||||
"route": "projects.detail",
|
||||
"entity": "project:abc-123",
|
||||
"view": "verlauf",
|
||||
"filter": "status=open",
|
||||
"selection": "selected phrase",
|
||||
}
|
||||
for k, want := range wantKeys {
|
||||
if got := meta[k]; got != want {
|
||||
t.Errorf("meta[%q] = %q; want %q", k, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAichatMeta_TruncatesSelection(t *testing.T) {
|
||||
long := strings.Repeat("x", MaxSelectionChars+50)
|
||||
req := TurnRequest{Context: &TurnContext{UserSelectionText: long}}
|
||||
meta := buildAichatMeta(req)
|
||||
got := meta["selection"]
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("selection not truncated: ends %q", got[len(got)-10:])
|
||||
}
|
||||
if strings.Count(got, "x") != MaxSelectionChars {
|
||||
t.Errorf("x count = %d; want %d", strings.Count(got, "x"), MaxSelectionChars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoerceAichatRowsSeen(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []string
|
||||
want []int
|
||||
}{
|
||||
{nil, nil},
|
||||
{[]string{}, nil},
|
||||
{[]string{"3", "5"}, []int{3, 5}},
|
||||
{[]string{"3", "abc", "7"}, []int{3, 7}}, // non-numeric dropped
|
||||
{[]string{" 12 "}, []int{12}}, // whitespace trimmed
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := coerceAichatRowsSeen(c.in)
|
||||
if !intSlicesEqual(got, c.want) {
|
||||
t.Errorf("coerceAichatRowsSeen(%v) = %v; want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Primer cache shape
|
||||
// =============================================================================
|
||||
|
||||
func TestPrimerCache_PerSessionIsolation(t *testing.T) {
|
||||
s := newAichatService(t, nil, nil)
|
||||
s.markPrimed("paliadin:alice")
|
||||
if !s.isPrimed("paliadin:alice") {
|
||||
t.Error("alice should be primed")
|
||||
}
|
||||
if s.isPrimed("paliadin:bob") {
|
||||
t.Error("bob should NOT be primed (cache cross-leak)")
|
||||
}
|
||||
s.clearPrimed("paliadin:alice")
|
||||
if s.isPrimed("paliadin:alice") {
|
||||
t.Error("alice should be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// helpers
|
||||
// =============================================================================
|
||||
|
||||
func setHealthResp(out any, ok bool) {
|
||||
if hr, isHealth := out.(*aichatHealthResponse); isHealth {
|
||||
hr.OK = ok
|
||||
hr.ClaudeReachable = ok
|
||||
hr.TmuxReachable = ok
|
||||
}
|
||||
}
|
||||
|
||||
func setResetResp(out any, ok bool) {
|
||||
if rr, isReset := out.(*aichatResetResponse); isReset {
|
||||
rr.OK = ok
|
||||
}
|
||||
}
|
||||
|
||||
func setTurnResp(out any, body string, paneSpawned bool) {
|
||||
if tr, isTurn := out.(*aichatTurnResponse); isTurn {
|
||||
tr.Response = body
|
||||
tr.PaneSpawned = paneSpawned
|
||||
}
|
||||
}
|
||||
|
||||
func intSlicesEqual(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user