| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- package agi
- import (
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "github.com/robertkrimen/otto"
- "imuslab.com/arozos/mod/agi/static"
- user "imuslab.com/arozos/mod/user"
- )
- /*
- Backend script tests for the AI Chat demo app (web/AIChat/backend/*.agi).
- These execute the real .agi scripts inside an otto VM with the real llm
- library injected (pointed at a mock OpenAI-compatible server), so the demo
- app's backend logic is verified without a running arozos server or a real
- model endpoint.
- */
- // runAIChatBackend loads a backend script, injects the llm lib + stubs for
- // requirelib/sendJSONResp, sets the given POST params and returns whatever the
- // script passed to sendJSONResp.
- func runAIChatBackend(t *testing.T, g *Gateway, scriptRelPath string, params map[string]string) string {
- t.Helper()
- vm := otto.New()
- g.injectLLMFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
- //requirelib is a no-op here: the lib is already injected above.
- vm.Set("requirelib", func(call otto.FunctionCall) otto.Value {
- v, _ := vm.ToValue(true)
- return v
- })
- var captured string
- vm.Set("sendJSONResp", func(call otto.FunctionCall) otto.Value {
- captured, _ = call.Argument(0).ToString()
- return otto.UndefinedValue()
- })
- for k, v := range params {
- vm.Set(k, v)
- }
- scriptPath := filepath.Join("..", "..", "web", scriptRelPath)
- content, err := os.ReadFile(scriptPath)
- if err != nil {
- t.Fatalf("cannot read backend script %s: %v", scriptPath, err)
- }
- if _, err := vm.Run(string(content)); err != nil {
- t.Fatalf("backend script %s errored: %v", scriptRelPath, err)
- }
- return captured
- }
- func TestAIChatBackend_Chat(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- //The system prompt set via options must reach the endpoint.
- if !strings.Contains(string(body), "be a pirate") {
- t.Errorf("system prompt was not forwarded; body=%s", string(body))
- }
- w.Header().Set("Content-Type", "application/json")
- io.WriteString(w, `{"model":"test-model",
- "choices":[{"message":{"role":"assistant","content":"Arr, hello!"}}],
- "usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16}}`)
- }))
- defer srv.Close()
- g := dbGateway(t)
- sysdb := g.Option.UserHandler.GetDatabase()
- sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "test-model", Currency: "USD"})
- out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
- "messages": `[{"role":"user","content":"hi"}]`,
- "options": `{"model":"test-model","system":"be a pirate"}`,
- })
- if !strings.Contains(out, `"ok":true`) {
- t.Fatalf("expected ok:true, got: %s", out)
- }
- if !strings.Contains(out, "Arr, hello!") {
- t.Errorf("assistant content missing from response: %s", out)
- }
- if !strings.Contains(out, `"total_tokens":16`) {
- t.Errorf("usage missing from response: %s", out)
- }
- }
- func TestAIChatBackend_ChatSurfacesReasoning(t *testing.T) {
- //A reasoning model returns its chain-of-thought in reasoning_content; the
- //backend must forward it to the frontend as the "reasoning" field so the
- //UI can show it in a collapsible thinking section.
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- io.WriteString(w, `{"model":"reasoner",
- "choices":[{"message":{"role":"assistant","content":"4","reasoning_content":"2 plus 2 is 4."}}],
- "usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`)
- }))
- defer srv.Close()
- g := dbGateway(t)
- sysdb := g.Option.UserHandler.GetDatabase()
- sysdb.Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "reasoner", Currency: "USD"})
- out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
- "messages": `[{"role":"user","content":"2+2?"}]`,
- "options": `{"model":"reasoner"}`,
- })
- if !strings.Contains(out, `"ok":true`) {
- t.Fatalf("expected ok:true, got: %s", out)
- }
- if !strings.Contains(out, `"reasoning":"2 plus 2 is 4."`) {
- t.Errorf("reasoning was not surfaced in the response: %s", out)
- }
- }
- func TestAIChatBackend_ChatNoEndpointReturnsError(t *testing.T) {
- g := dbGateway(t) //no config written -> endpoint unset
- out := runAIChatBackend(t, g, "AIChat/backend/chat.agi", map[string]string{
- "messages": `[{"role":"user","content":"hi"}]`,
- "options": `{}`,
- })
- if !strings.Contains(out, `"ok":false`) {
- t.Fatalf("expected ok:false when endpoint missing, got: %s", out)
- }
- if !strings.Contains(strings.ToLower(out), "endpoint") {
- t.Errorf("expected an endpoint-related error message, got: %s", out)
- }
- }
- // runAIChatStreamBackend runs the streaming backend script with the llm lib
- // injected and a stubbed websocket object: the first read() yields reqJSON,
- // send() captures every outgoing frame, and exit()/close() end the script.
- // Returns the ordered list of JSON frames the script sent to the "client".
- func runAIChatStreamBackend(t *testing.T, g *Gateway, reqJSON string) []string {
- t.Helper()
- vm := otto.New()
- g.injectLLMFunctions(&static.AgiLibInjectionPayload{VM: vm, User: &user.User{Username: "tester"}})
- vm.Set("requirelib", func(call otto.FunctionCall) otto.Value {
- v, _ := vm.ToValue(true)
- return v
- })
- vm.Set("sendResp", func(call otto.FunctionCall) otto.Value { return otto.UndefinedValue() })
- vm.Set("exit", func(call otto.FunctionCall) otto.Value {
- panic(vm.MakeCustomError("AGIExit", "exit"))
- })
- var frames []string
- readCount := 0
- vm.Set("_ws_upgrade", func(call otto.FunctionCall) otto.Value { return otto.TrueValue() })
- vm.Set("_ws_read", func(call otto.FunctionCall) otto.Value {
- readCount++
- if readCount == 1 {
- v, _ := vm.ToValue(reqJSON)
- return v
- }
- return otto.FalseValue()
- })
- vm.Set("_ws_send", func(call otto.FunctionCall) otto.Value {
- s, _ := call.Argument(0).ToString()
- frames = append(frames, s)
- return otto.TrueValue()
- })
- vm.Set("_ws_close", func(call otto.FunctionCall) otto.Value { return otto.TrueValue() })
- vm.Run(`var websocket = { upgrade:_ws_upgrade, read:_ws_read, send:_ws_send, close:_ws_close, isClosed:function(){return false;} };`)
- scriptPath := filepath.Join("..", "..", "web", "AIChat/backend/chat_stream.agi")
- content, err := os.ReadFile(scriptPath)
- if err != nil {
- t.Fatalf("cannot read chat_stream.agi: %v", err)
- }
- if _, err := vm.Run(string(content)); err != nil && !strings.Contains(err.Error(), "exit") {
- t.Fatalf("chat_stream.agi errored: %v", err)
- }
- return frames
- }
- func TestAIChatBackend_Stream(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- body, _ := io.ReadAll(r.Body)
- if !strings.Contains(string(body), `"stream":true`) {
- t.Errorf("streaming backend did not request a stream; body=%s", string(body))
- }
- w.Header().Set("Content-Type", "text/event-stream")
- io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"reasoning_content\":\"pondering\"}}]}\n\n")
- io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"Hi \"}}]}\n\n")
- io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"there\"},\"finish_reason\":\"stop\"}]}\n\n")
- io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":3,\"completion_tokens\":2,\"total_tokens\":5}}\n\n")
- io.WriteString(w, "data: [DONE]\n\n")
- }))
- defer srv.Close()
- g := dbGateway(t)
- g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", Currency: "USD"})
- frames := runAIChatStreamBackend(t, g, `{"messages":[{"role":"user","content":"hi"}],"options":{"model":"m"}}`)
- joined := strings.Join(frames, "\n")
- if !strings.Contains(joined, `"type":"start"`) {
- t.Errorf("missing start frame; frames=%v", frames)
- }
- if !strings.Contains(joined, `"type":"reasoning"`) || !strings.Contains(joined, "pondering") {
- t.Errorf("reasoning was not streamed; frames=%v", frames)
- }
- if !strings.Contains(joined, `"type":"delta"`) || !strings.Contains(joined, "there") {
- t.Errorf("answer deltas were not streamed; frames=%v", frames)
- }
- //The terminal "done" frame carries the fully assembled reply + usage.
- last := frames[len(frames)-1]
- if !strings.Contains(last, `"type":"done"`) {
- t.Fatalf("last frame should be done, got: %s", last)
- }
- if !strings.Contains(last, `"content":"Hi there"`) {
- t.Errorf("done frame missing assembled content: %s", last)
- }
- if !strings.Contains(last, `"total_tokens":5`) {
- t.Errorf("done frame missing usage: %s", last)
- }
- }
- func TestAIChatBackend_StreamRecordsUsage(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- io.WriteString(w, "data: {\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n")
- io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":4,\"total_tokens\":12}}\n\n")
- io.WriteString(w, "data: [DONE]\n\n")
- }))
- defer srv.Close()
- g := dbGateway(t)
- g.Option.UserHandler.GetDatabase().Write(llmDBTable, "config", LLMConfig{Endpoint: srv.URL, DefaultModel: "m", Currency: "USD"})
- runAIChatStreamBackend(t, g, `{"messages":[{"role":"user","content":"hi"}],"options":{"model":"m"}}`)
- //Streaming must feed the same metrics board as the blocking path.
- m := g.getLLMMetrics()
- if m.TotalRequests != 1 || m.TotalTokens != 12 {
- t.Errorf("streaming usage not recorded in metrics: %+v", m)
- }
- }
- func TestAIChatBackend_Models(t *testing.T) {
- g := dbGateway(t)
- sysdb := g.Option.UserHandler.GetDatabase()
- sysdb.Write(llmDBTable, "config", LLMConfig{DefaultModel: "test-model", Currency: "USD"})
- sysdb.Write(llmDBTable, "pricing", map[string]LLMPricing{
- "test-model": {InputPrice: 1, OutputPrice: 2},
- "other": {InputPrice: 3, OutputPrice: 4},
- })
- out := runAIChatBackend(t, g, "AIChat/backend/models.agi", map[string]string{})
- if !strings.Contains(out, `"default":"test-model"`) {
- t.Errorf("default model missing: %s", out)
- }
- if !strings.Contains(out, "test-model") || !strings.Contains(out, "other") {
- t.Errorf("configured models missing: %s", out)
- }
- }
|