| 1 | //go:build live |
| 2 | |
| 3 | // Live network end-to-end test, excluded from the normal suite by the `live` |
| 4 | // build tag. Run it against a real model with: |
| 5 | // |
| 6 | // set -a; . /path/to/.env; set +a |
| 7 | // go test -tags live -run Live ./internal/acp/ -v |
| 8 | // |
| 9 | // It drives the full ACP stack — acp.Serve → control.Controller → agent.Agent → |
| 10 | // the real OpenAI-compatible provider — over a tiny prompt, proving the live |
| 11 | // model path the hermetic tests stub out. |
| 12 | package acp |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "encoding/json" |
| 17 | "os" |
| 18 | "testing" |
| 19 | "time" |
| 20 | |
| 21 | "reasonix/internal/agent" |
| 22 | "reasonix/internal/control" |
| 23 | "reasonix/internal/provider" |
| 24 | _ "reasonix/internal/provider/openai" // registers the "openai" provider kind |
| 25 | "reasonix/internal/tool" |
| 26 | ) |
| 27 | |
| 28 | type liveFactory struct{ prov provider.Provider } |
| 29 | |
| 30 | func (f *liveFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) { |
| 31 | executor := agent.New(f.prov, tool.NewRegistry(), |
| 32 | agent.NewSession("You are a terse assistant. Answer in as few words as possible."), |
| 33 | agent.Options{MaxSteps: 3}, p.Sink) |
| 34 | return control.New(control.Options{Runner: executor, Executor: executor, Sink: p.Sink, Label: "deepseek"}), nil |
| 35 | } |
| 36 | |
| 37 | func TestLiveDeepSeekPrompt(t *testing.T) { |
| 38 | key := os.Getenv("DEEPSEEK_API_KEY") |
| 39 | if key == "" { |
| 40 | t.Skip("DEEPSEEK_API_KEY not set") |
| 41 | } |
| 42 | prov, err := provider.New("openai", provider.Config{ |
| 43 | Name: "deepseek", |
| 44 | BaseURL: "https://api.deepseek.com", |
| 45 | Model: "deepseek-v4-flash", |
| 46 | APIKey: key, |
| 47 | }) |
| 48 | if err != nil { |
| 49 | t.Fatalf("provider.New: %v", err) |
| 50 | } |
| 51 | |
| 52 | client, stop := startServer(t, &liveFactory{prov: prov}) |
| 53 | defer stop() |
| 54 | |
| 55 | client.call(t, "initialize", InitializeParams{ProtocolVersion: 1}) |
| 56 | resp := client.call(t, "session/new", SessionNewParams{}) |
| 57 | var nr SessionNewResult |
| 58 | if err := json.Unmarshal(resp.Result, &nr); err != nil { |
| 59 | t.Fatalf("session/new: %v", err) |
| 60 | } |
| 61 | |
| 62 | promptCh := client.callAsync("session/prompt", SessionPromptParams{ |
| 63 | SessionID: nr.SessionID, |
| 64 | Prompt: []ContentBlock{{Type: "text", Text: "Reply with exactly one word: hi"}}, |
| 65 | }) |
| 66 | |
| 67 | // Collect updates until the prompt response arrives (network: allow up to 60s). |
| 68 | var notifs []frame |
| 69 | var pResp frame |
| 70 | deadline := time.After(60 * time.Second) |
| 71 | collect: |
| 72 | for { |
| 73 | select { |
| 74 | case f := <-client.notifs: |
| 75 | notifs = append(notifs, f) |
| 76 | case pResp = <-promptCh: |
| 77 | break collect |
| 78 | case <-deadline: |
| 79 | t.Fatal("live prompt timed out after 60s") |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | var text string |
| 84 | for _, n := range notifs { |
| 85 | if updateKind(t, n) != "agent_message_chunk" { |
| 86 | continue |
| 87 | } |
| 88 | var p struct { |
| 89 | Update struct { |
| 90 | Content struct { |
| 91 | Text string `json:"text"` |
| 92 | } `json:"content"` |
| 93 | } `json:"update"` |
| 94 | } |
| 95 | json.Unmarshal(n.Params, &p) |
| 96 | text += p.Update.Content.Text |
| 97 | } |
| 98 | if text == "" { |
| 99 | t.Fatal("no agent_message_chunk text received from the live model") |
| 100 | } |
| 101 | |
| 102 | var pr SessionPromptResult |
| 103 | if err := json.Unmarshal(pResp.Result, &pr); err != nil { |
| 104 | t.Fatalf("prompt result: %v", err) |
| 105 | } |
| 106 | if pr.StopReason != StopEndTurn { |
| 107 | t.Errorf("stopReason = %q, want end_turn", pr.StopReason) |
| 108 | } |
| 109 | t.Logf("live model replied: %q (stopReason=%s)", text, pr.StopReason) |
| 110 | } |
| 111 |