| 1 | //go:build live |
| 2 | |
| 3 | package agent_test |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "sync/atomic" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/internal/agent" |
| 17 | "reasonix/internal/event" |
| 18 | "reasonix/internal/provider" |
| 19 | "reasonix/internal/provider/anthropic" |
| 20 | "reasonix/internal/tool" |
| 21 | ) |
| 22 | |
| 23 | func TestRealDeepSeekAgentInterruptedToolResume(t *testing.T) { |
| 24 | key := os.Getenv("DEEPSEEK_API_KEY") |
| 25 | if key == "" { |
| 26 | t.Skip("DEEPSEEK_API_KEY not set — skipping live probe") |
| 27 | } |
| 28 | p := newLiveDeepSeekAgentProvider(t, key, false) |
| 29 | marker := &liveMarkerTool{} |
| 30 | reg := tool.NewRegistry() |
| 31 | reg.Add(marker) |
| 32 | sess := agent.NewSession("You are a concise tool-using assistant. Call the requested tool before answering.") |
| 33 | |
| 34 | ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) |
| 35 | defer cancel() |
| 36 | sink := event.FuncSink(func(e event.Event) { |
| 37 | if e.Kind == event.ToolResult && e.Tool.Name == marker.Name() { |
| 38 | cancel() |
| 39 | } |
| 40 | }) |
| 41 | a := agent.New(p, reg, sess, agent.Options{MaxSteps: 4, MaxOutputTokens: 512}, sink) |
| 42 | err := a.Run(ctx, "Call get_marker and report its result.") |
| 43 | if !errors.Is(err, context.Canceled) { |
| 44 | t.Fatalf("interrupted run error = %v, want context cancellation", err) |
| 45 | } |
| 46 | if marker.executions.Load() != 1 { |
| 47 | t.Fatalf("tool executions before restart = %d, want 1", marker.executions.Load()) |
| 48 | } |
| 49 | |
| 50 | path := filepath.Join(t.TempDir(), "session.jsonl") |
| 51 | if err := sess.Save(path); err != nil { |
| 52 | t.Fatalf("Save: %v", err) |
| 53 | } |
| 54 | reloaded, err := agent.LoadSession(path) |
| 55 | if err != nil { |
| 56 | t.Fatalf("LoadSession: %v", err) |
| 57 | } |
| 58 | reopened := agent.New(p, reg, reloaded, agent.Options{MaxSteps: 4, MaxOutputTokens: 512}, event.Discard) |
| 59 | resumeCtx, resumeCancel := context.WithTimeout(context.Background(), 90*time.Second) |
| 60 | defer resumeCancel() |
| 61 | if err := reopened.Run(resumeCtx, "Continue from the completed tool result without calling the tool again. Reply with the marker."); err != nil { |
| 62 | t.Fatalf("resume after restart: %v", err) |
| 63 | } |
| 64 | if marker.executions.Load() != 1 { |
| 65 | t.Fatalf("old tool was executed again after restart: executions=%d", marker.executions.Load()) |
| 66 | } |
| 67 | t.Logf("interrupted tool turn resumed after save/load with executions=%d messages=%d", marker.executions.Load(), len(reloaded.Snapshot())) |
| 68 | } |
| 69 | |
| 70 | func TestRealDeepSeekAgentWebSearchContinuation(t *testing.T) { |
| 71 | key := os.Getenv("DEEPSEEK_API_KEY") |
| 72 | if key == "" { |
| 73 | t.Skip("DEEPSEEK_API_KEY not set — skipping live probe") |
| 74 | } |
| 75 | p := newLiveDeepSeekAgentProvider(t, key, true) |
| 76 | sess := agent.NewSession("You are a concise assistant. Use server-side web search when explicitly requested.") |
| 77 | a := agent.New(p, tool.NewRegistry(), sess, agent.Options{MaxSteps: 3, MaxOutputTokens: 512}, event.Discard) |
| 78 | ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) |
| 79 | defer cancel() |
| 80 | if err := a.Run(ctx, "Search the web for the latest DeepSeek API documentation update and reply with one source URL."); err != nil { |
| 81 | t.Fatalf("web search turn: %v", err) |
| 82 | } |
| 83 | var found bool |
| 84 | for _, m := range sess.Snapshot() { |
| 85 | if m.Role == provider.RoleAssistant && len(m.ServerSearch) > 0 { |
| 86 | found = true |
| 87 | if strings.TrimSpace(m.ReasoningContent) == "" { |
| 88 | t.Fatal("stored web-search turn lost provider reasoning") |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | if !found { |
| 93 | t.Fatal("agent session contains no server-search assistant turn") |
| 94 | } |
| 95 | if err := a.Run(ctx, "Without searching again, reply with the hostname of that source."); err != nil { |
| 96 | t.Fatalf("web search continuation: %v", err) |
| 97 | } |
| 98 | t.Logf("agent web-search continuation completed with messages=%d", len(sess.Snapshot())) |
| 99 | } |
| 100 | |
| 101 | func newLiveDeepSeekAgentProvider(t *testing.T, key string, webSearch bool) provider.Provider { |
| 102 | t.Helper() |
| 103 | p, err := anthropic.New(provider.Config{ |
| 104 | Name: "deepseek-anthropic", BaseURL: "https://api.deepseek.com/anthropic", Model: "deepseek-v4-flash", APIKey: key, |
| 105 | Extra: map[string]any{"api_key_env": "DEEPSEEK_API_KEY", "thinking": "enabled", "effort": "high", "web_search": webSearch}, |
| 106 | }) |
| 107 | if err != nil { |
| 108 | t.Fatalf("New: %v", err) |
| 109 | } |
| 110 | if closer, ok := p.(interface{ CloseIdleConnections() }); ok { |
| 111 | t.Cleanup(closer.CloseIdleConnections) |
| 112 | } |
| 113 | return p |
| 114 | } |
| 115 | |
| 116 | type liveMarkerTool struct{ executions atomic.Int32 } |
| 117 | |
| 118 | func (*liveMarkerTool) Name() string { return "get_marker" } |
| 119 | func (*liveMarkerTool) Description() string { return "Return a fixed integration-test marker." } |
| 120 | func (*liveMarkerTool) Schema() json.RawMessage { |
| 121 | return json.RawMessage(`{"type":"object","properties":{},"additionalProperties":false}`) |
| 122 | } |
| 123 | func (m *liveMarkerTool) Execute(context.Context, json.RawMessage) (string, error) { |
| 124 | m.executions.Add(1) |
| 125 | return "protocol-round-trip-ok", nil |
| 126 | } |
| 127 | func (*liveMarkerTool) ReadOnly() bool { return true } |
| 128 |