diff --git a/README.md b/README.md index 193f8b80..99b91080 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,8 @@ Features adopted from open-source agent projects. All are off by default unless | Relevance pruning | `internal/relevanceprune` | Token-budgeted context pruning preserving recent turns/tool calls/errors. Wired into compaction as a `relevance` strategy | | Tool-result clearing | `internal/engine` (`ClearOldToolResults`) | Two-tier context management: at 80% of the context window, stale tool-result content is replaced with `[output cleared]` placeholders (tool_use kept intact) before compacting — a gentler tier below compaction | | Approval pause timing | `internal/permissions` | Approval requests record decision timestamp + human deliberation duration (`DecisionAt`/`PauseDuration`) for approval-latency observability | +| Graceful exhaustion | `internal/engine` (`SynthesisForExhaustion`) | When turn/token/time limits hit, one final tools-disabled LLM call synthesizes a coherent completion (accomplished/remaining/next steps) instead of a bare stop line. Opt-in via `HAWK_GRACEFUL_EXHAUSTION=1` | +| Deterministic replay cache | `internal/replaycache` (`HAWK_REPLAY_CACHE_DIR`) | Disk-persisted SHA-256-keyed cache of completions; identical requests replay stored responses for reproducible regression runs | ## Usage diff --git a/internal/engine/chat_replay.go b/internal/engine/chat_replay.go new file mode 100644 index 00000000..c0331a6c --- /dev/null +++ b/internal/engine/chat_replay.go @@ -0,0 +1,45 @@ +package engine + +import ( + "context" + "os" + + "github.com/GrayCodeAI/hawk/internal/replaycache" + "github.com/GrayCodeAI/hawk/internal/types" +) + +// replayCacheDirEnv opts a run into the disk-persisted replay cache: when set, +// every non-streaming completion is looked up by its canonicalized request and +// replayed from disk on a hit, giving deterministic, offline regression runs. +// Unset (the default) leaves the chat path untouched. +const replayCacheDirEnv = "HAWK_REPLAY_CACHE_DIR" + +// replayFingerprintEnv optionally folds an extra string (e.g. a fixture +// version) into replay cache keys so whole suites can be invalidated at once. +const replayFingerprintEnv = "HAWK_REPLAY_FINGERPRINT" + +// replayKey builds the cache key for one completion request. +func replayKey(opts types.ChatOptions, messages []types.EyrieMessage) string { + return replaycache.Key(replaycache.Fingerprint(os.Getenv(replayFingerprintEnv)), + opts.Provider, opts.Model, messages, opts.MaxTokens) +} + +// chatWithReplay wraps client.Chat with the replay cache when +// HAWK_REPLAY_CACHE_DIR is set; otherwise it calls straight through. +func chatWithReplay(ctx context.Context, client ChatClient, messages []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + dir := os.Getenv(replayCacheDirEnv) + if dir == "" { + return client.Chat(ctx, messages, opts) + } + cache := replaycache.New(dir) + key := replayKey(opts, messages) + if resp, ok := cache.Get(key); ok { + return resp, nil + } + resp, err := client.Chat(ctx, messages, opts) + if err != nil { + return resp, err + } + _ = cache.Put(key, resp) // best-effort: a failed write must not fail the turn + return resp, nil +} diff --git a/internal/engine/chat_replay_test.go b/internal/engine/chat_replay_test.go new file mode 100644 index 00000000..308e6d36 --- /dev/null +++ b/internal/engine/chat_replay_test.go @@ -0,0 +1,118 @@ +package engine + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestChatWithReplayDisabledPassesThrough(t *testing.T) { + t.Setenv(replayCacheDirEnv, "") + calls := 0 + client := &countingClient{n: &calls, resp: "live"} + resp, err := chatWithReplay(context.Background(), client, + []types.EyrieMessage{{Role: "user", Content: "hi"}}, types.ChatOptions{Model: "m"}) + if err != nil || resp.Content != "live" { + t.Fatalf("resp=%v err=%v", resp, err) + } + if calls != 1 { + t.Fatalf("calls = %d, want 1", calls) + } +} + +func TestChatWithReplayCachesAndHits(t *testing.T) { + dir := t.TempDir() + t.Setenv(replayCacheDirEnv, dir) + + calls := 0 + client := &countingClient{n: &calls, resp: "live"} + msgs := []types.EyrieMessage{{Role: "user", Content: "deterministic"}} + opts := types.ChatOptions{Provider: "p", Model: "m", MaxTokens: 10} + + first, err := chatWithReplay(context.Background(), client, msgs, opts) + if err != nil || first.Content != "live" { + t.Fatalf("first call: resp=%v err=%v", first, err) + } + if calls != 1 { + t.Fatalf("calls after first = %d", calls) + } + + // Second identical request must replay from disk without calling the client. + second, err := chatWithReplay(context.Background(), client, msgs, opts) + if err != nil || second.Content != "live" { + t.Fatalf("replayed call: resp=%v err=%v", second, err) + } + if calls != 1 { + t.Fatalf("client called %d times, want 1 (second served from cache)", calls) + } +} + +func TestChatWithReplayFingerprintInvalidates(t *testing.T) { + dir := t.TempDir() + t.Setenv(replayCacheDirEnv, dir) + t.Setenv(replayFingerprintEnv, "v1") + + calls := 0 + client := &countingClient{n: &calls, resp: "live"} + msgs := []types.EyrieMessage{{Role: "user", Content: "x"}} + opts := types.ChatOptions{Provider: "p", Model: "m"} + + if _, err := chatWithReplay(context.Background(), client, msgs, opts); err != nil { + t.Fatal(err) + } + t.Setenv(replayFingerprintEnv, "v2") + if _, err := chatWithReplay(context.Background(), client, msgs, opts); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("calls = %d, want 2 after fingerprint bump", calls) + } +} + +func TestChatWithReplayDoesNotCacheErrors(t *testing.T) { + dir := t.TempDir() + t.Setenv(replayCacheDirEnv, dir) + + calls := 0 + client := &countingClient{n: &calls, err: errors.New("boom")} + for i := 0; i < 2; i++ { + if _, err := chatWithReplay(context.Background(), client, + []types.EyrieMessage{{Role: "user", Content: "e"}}, types.ChatOptions{}); err == nil { + t.Fatal("expected error to pass through") + } + } + if calls != 2 { + t.Fatalf("errors must not be cached; calls = %d", calls) + } + entries, _ := filepath.Glob(filepath.Join(dir, "resp", "*", "*.json")) + if len(entries) != 0 { + t.Fatalf("no entries expected after failed calls, got %v", entries) + } +} + +type countingClient struct { + n *int + resp string + err error +} + +func (c *countingClient) Chat(context.Context, []types.EyrieMessage, types.ChatOptions) (*types.EyrieResponse, error) { + *c.n++ + if c.err != nil { + return nil, c.err + } + return &types.EyrieResponse{Content: c.resp}, nil +} + +func (c *countingClient) StreamChatContinue(context.Context, []types.EyrieMessage, types.ChatOptions, types.ContinuationConfig) (*types.StreamResult, error) { + *c.n++ + if c.err != nil { + return nil, c.err + } + ch := make(chan types.EyrieStreamEvent, 1) + ch <- types.EyrieStreamEvent{Type: "done"} + return &types.StreamResult{Events: ch}, nil +} diff --git a/internal/engine/chat_service.go b/internal/engine/chat_service.go index b0d385c3..25392cee 100644 --- a/internal/engine/chat_service.go +++ b/internal/engine/chat_service.go @@ -309,7 +309,7 @@ func (c *ChatService) Chat(ctx context.Context, messages []types.EyrieMessage, o if client == nil { return nil, errors.New("chat service: no client configured") } - return client.Chat(ctx, messages, opts) + return chatWithReplay(ctx, client, messages, opts) } // isContextOverflow reports whether err looks like a "context too long" diff --git a/internal/engine/stream_exhaustion.go b/internal/engine/stream_exhaustion.go new file mode 100644 index 00000000..b88e4e47 --- /dev/null +++ b/internal/engine/stream_exhaustion.go @@ -0,0 +1,60 @@ +package engine + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +// synthesisTailMessages is how many recent messages feed the exhaustion prompt. +const synthesisTailMessages = 8 + +// SynthesisForExhaustion produces a coherent final completion when the agent +// loop exhausts its budget (turn/token/time limits), using ONE final +// tools-disabled LLM call — the "graceful exhaustion" idea from herm. Instead +// of stopping with a bare "limit reached" line, the model synthesizes a concise +// summary of what was done, what remains, and next steps. Returns "" when the +// session has no LLM, no conversation, the call fails, or the context is +// cancelled — callers fall back to their static stop message. +func (s *Session) SynthesisForExhaustion(ctx context.Context, reason string) string { + if s == nil || s.ChatLLM() == nil { + return "" + } + raw := s.Persistence().RawMessages() + if len(raw) == 0 { + return "" + } + if err := ctx.Err(); err != nil { + return "" + } + + var b strings.Builder + b.WriteString("You are completing a coding-agent session that must stop now because its execution budget is exhausted.\n") + fmt.Fprintf(&b, "Reason: %s\n\n", reason) + b.WriteString("Do not call any tools. Write a concise final message covering: what was accomplished, what is left to do, and suggested next steps.\n\nRecent conversation (most recent first):\n") + start := 0 + if len(raw) > synthesisTailMessages { + start = len(raw) - synthesisTailMessages + } + for i := len(raw) - 1; i >= start; i-- { + m := raw[i] + fmt.Fprintf(&b, "[%s] %s\n", m.Role, truncateRunes(m.Content, 300)) + } + + callCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + resp, err := s.ChatLLM().Chat(callCtx, []types.EyrieMessage{ + {Role: "user", Content: b.String()}, + }, types.ChatOptions{ + Provider: s.ChatLLM().Provider(), + Model: s.ChatLLM().Model(), + MaxTokens: 800, + }) + if err != nil { + return "" + } + return strings.TrimSpace(resp.Content) +} diff --git a/internal/engine/stream_exhaustion_test.go b/internal/engine/stream_exhaustion_test.go new file mode 100644 index 00000000..1c0f5302 --- /dev/null +++ b/internal/engine/stream_exhaustion_test.go @@ -0,0 +1,76 @@ +package engine + +import ( + "context" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestSynthesisForExhaustion(t *testing.T) { + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + sess.Persistence().SetRawMessages([]types.EyrieMessage{ + {Role: "user", Content: "fix the bug"}, + {Role: "assistant", Content: "I found it"}, + }) + out := sess.SynthesisForExhaustion(context.Background(), "turn limit reached") + if strings.TrimSpace(out) != "mock test response" { + t.Fatalf("got %q, want mock test response", out) + } +} + +func TestSynthesisForExhaustionNoSession(t *testing.T) { + var nilSess *Session + if out := nilSess.SynthesisForExhaustion(context.Background(), "x"); out != "" { + t.Fatalf("nil session should return empty, got %q", out) + } +} + +func TestSynthesisForExhaustionNoMessages(t *testing.T) { + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + if out := sess.SynthesisForExhaustion(context.Background(), "x"); out != "" { + t.Fatalf("empty conversation should return empty, got %q", out) + } +} + +func TestSynthesisForExhaustionCancelled(t *testing.T) { + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + sess.Persistence().SetRawMessages([]types.EyrieMessage{{Role: "user", Content: "hi"}}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if out := sess.SynthesisForExhaustion(ctx, "x"); out != "" { + t.Fatalf("cancelled ctx should return empty, got %q", out) + } +} + +func TestEmitExhaustionFallsBack(t *testing.T) { + // Default (opt-in off): static fallback message even with a live LLM. + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + sess.Persistence().SetRawMessages([]types.EyrieMessage{{Role: "user", Content: "hi"}}) + ch := make(chan StreamEvent, 2) + sess.emitExhaustion(context.Background(), ch, "reason") + ev := <-ch + if ev.Type != "content" || !strings.Contains(ev.Content, "Limit reached") { + t.Fatalf("expected fallback content by default, got %+v", ev) + } + ev2 := <-ch + if ev2.Type != "done" { + t.Fatalf("expected done event, got %+v", ev2) + } +} + +func TestEmitExhaustionSynthesizesWhenOptedIn(t *testing.T) { + t.Setenv(gracefulExhaustionEnv, "1") + sess := NewSessionWithClient(NewMockClientForTest(), "test", "test-model", "", nil, false) + sess.Persistence().SetRawMessages([]types.EyrieMessage{{Role: "user", Content: "hi"}}) + ch := make(chan StreamEvent, 2) + sess.emitExhaustion(context.Background(), ch, "turn limit reached") + ev := <-ch + if ev.Type != "content" || !strings.Contains(ev.Content, "mock test response") { + t.Fatalf("expected synthesized content when opted in, got %+v", ev) + } + if ev2 := <-ch; ev2.Type != "done" { + t.Fatalf("expected done event, got %+v", ev2) + } +} diff --git a/internal/engine/stream_guards.go b/internal/engine/stream_guards.go index 990a4258..4dabecaa 100644 --- a/internal/engine/stream_guards.go +++ b/internal/engine/stream_guards.go @@ -4,10 +4,16 @@ import ( "context" "errors" "fmt" + "os" "github.com/GrayCodeAI/hawk/internal/engine/branching" ) +// gracefulExhaustionEnv opts the guard path into exhaustion synthesis. Off by +// default: synthesis is a blocking provider call and the default loop must +// stop immediately when limits are hit. +const gracefulExhaustionEnv = "HAWK_GRACEFUL_EXHAUSTION" + // checkGuardConditions runs all pre-turn guard checks. // Returns false when the loop should stop (abort conditions met). // On first-stage loop detection, injects a break-loop message and continues. @@ -41,22 +47,49 @@ func (s *Session) checkGuardConditions(ctx context.Context, ch chan<- StreamEven if s.LifecycleSvc().Limits() != nil { if exceeded, reason := s.LifecycleSvc().Limits().IsExceeded(); exceeded { - ch <- StreamEvent{Type: "content", Content: fmt.Sprintf("\n\nLimit reached: %s", reason)} - ch <- StreamEvent{Type: "done"} + s.emitExhaustion(ctx, ch, reason) return false } } if allowed, reason := s.tokUsageCanProceed(); !allowed { - ch <- StreamEvent{Type: "content", Content: fmt.Sprintf("\n\nLimit reached: %s", reason)} - ch <- StreamEvent{Type: "done"} + s.emitExhaustion(ctx, ch, reason) return false } if s.LifecycleSvc() != nil && s.LifecycleSvc().Limits().MaxTurns() > 0 && turnCount >= s.LifecycleSvc().Limits().MaxTurns() { - ch <- StreamEvent{Type: "content", Content: "Turn limit reached — stopping."} - ch <- StreamEvent{Type: "done"} + s.emitExhaustion(ctx, ch, "turn limit reached") return false } return true } + +// emitExhaustion stops the loop with a graceful completion: one final +// tools-disabled LLM call synthesizes a summary of the work (herm's graceful +// exhaustion). Falls back to a static "limit reached" message when synthesis is +// unavailable or fails. +// +// Synthesis performs a blocking provider call, so it is opt-in via +// HAWK_GRACEFUL_EXHAUSTION=1: the guard path must stay fast and non-blocking +// by default (a stalled guard delays every terminal event downstream). +func (s *Session) emitExhaustion(ctx context.Context, ch chan<- StreamEvent, reason string) { + if gracefulExhaustionEnabled() { + if synth := s.SynthesisForExhaustion(ctx, reason); synth != "" { + ch <- StreamEvent{Type: "content", Content: "\n\n" + synth} + ch <- StreamEvent{Type: "done"} + return + } + } + ch <- StreamEvent{Type: "content", Content: fmt.Sprintf("\n\nLimit reached: %s", reason)} + ch <- StreamEvent{Type: "done"} +} + +// gracefulExhaustionEnabled reports whether opt-in exhaustion synthesis is on. +func gracefulExhaustionEnabled() bool { + switch os.Getenv(gracefulExhaustionEnv) { + case "1", "true", "TRUE", "True": + return true + default: + return false + } +} diff --git a/internal/replaycache/replaycache.go b/internal/replaycache/replaycache.go new file mode 100644 index 00000000..5560b18d --- /dev/null +++ b/internal/replaycache/replaycache.go @@ -0,0 +1,180 @@ +// Package replaycache provides a deterministic, disk-persisted replay cache +// for LLM requests. Successful responses (complete and streamed) are stored +// under a SHA-256 key computed from a canonicalized request plus a config +// fingerprint; a later identical request replays the stored bytes instead of +// calling the provider. Built for reproducible agent runs and offline +// regression tests. Adopted from herm's request_cache. +// +// Secrets are never part of the key material directly: API keys are folded +// into the fingerprint via SHA-256 so the stored filenames cannot leak them, +// and responses are written 0600. +package replaycache + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +// Cache is a directory-backed replay cache. Safe for concurrent use within one +// process; cross-process safety relies on atomic renames of whole files. +type Cache struct { + dir string +} + +// New returns a cache rooted at dir (created lazily on first write). +func New(dir string) *Cache { + return &Cache{dir: dir} +} + +// Dir returns the cache root. +func (c *Cache) Dir() string { return c.dir } + +// Fingerprint folds configuration (including secrets) into a non-reversible +// digest so changing credentials invalidates entries without leaking them. +func Fingerprint(secrets ...string) string { + h := sha256.Sum256([]byte(strings.Join(secrets, "\x00"))) + return hex.EncodeToString(h[:])[:16] +} + +// Key computes the cache key for a chat request. Messages are canonicalized +// (role/content/tool fields, sorted map keys) so semantically identical +// requests produce identical keys regardless of struct field order. +func Key(fingerprint, provider, model string, messages []types.EyrieMessage, maxTokens int) string { + canonical := canonicalMessages(messages) + payload := fmt.Sprintf("%s|%s|%s|%s|%d", fingerprint, provider, model, canonical, maxTokens) + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) +} + +func canonicalMessages(messages []types.EyrieMessage) string { + var b strings.Builder + for _, m := range messages { + fmt.Fprintf(&b, "<%s>%s", m.Role, m.Content) + if m.Thinking != "" { + fmt.Fprintf(&b, "%s", m.Thinking) + } + for _, tc := range m.ToolUse { + args, _ := json.Marshal(tc.Arguments) // map keys marshal in sorted order + fmt.Fprintf(&b, "%s%s", tc.Name, args) + } + for _, tr := range m.ToolResults { + fmt.Fprintf(&b, "%s:%d", tr.Content, b2i(tr.IsError)) + } + b.WriteString("") + } + return b.String() +} + +func b2i(v bool) int { + if v { + return 1 + } + return 0 +} + +// Get returns the cached complete response for key, or ok=false. +func (c *Cache) Get(key string) (*types.EyrieResponse, bool) { + data, err := os.ReadFile(c.path("resp", key)) + if err != nil { + return nil, false + } + var resp types.EyrieResponse + if err := json.Unmarshal(data, &resp); err != nil { + return nil, false + } + return &resp, true +} + +// Put stores a complete response under key. +func (c *Cache) Put(key string, resp *types.EyrieResponse) error { + if resp == nil { + return fmt.Errorf("replaycache: nil response") + } + data, err := json.Marshal(resp) + if err != nil { + return err + } + return c.writeAtomic(c.path("resp", key), data) +} + +// GetStream returns the cached stream events for key, or ok=false. +func (c *Cache) GetStream(key string) ([]types.EyrieStreamEvent, bool) { + data, err := os.ReadFile(c.path("stream", key)) + if err != nil { + return nil, false + } + var events []types.EyrieStreamEvent + if err := json.Unmarshal(data, &events); err != nil { + return nil, false + } + return events, true +} + +// PutStream stores stream events under key so a later identical request can +// replay the exact same sequence. +func (c *Cache) PutStream(key string, events []types.EyrieStreamEvent) error { + if len(events) == 0 { + return fmt.Errorf("replaycache: no events") + } + data, err := json.Marshal(events) + if err != nil { + return err + } + return c.writeAtomic(c.path("stream", key), data) +} + +func (c *Cache) path(kind, key string) string { + // Shard by the first two hex chars to keep directories small. + return filepath.Join(c.dir, kind, key[:2], key+".json") +} + +func (c *Cache) writeAtomic(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".replay-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpPath, path) +} + +// Entries returns the number of cached entries across both kinds. +func (c *Cache) Entries() int { + total := 0 + for _, kind := range []string{"resp", "stream"} { + root := filepath.Join(c.dir, kind) + subdirs, err := os.ReadDir(root) + if err != nil { + continue + } + for _, sd := range subdirs { + files, err := os.ReadDir(filepath.Join(root, sd.Name())) + if err != nil { + continue + } + total += len(files) + } + } + return total +} diff --git a/internal/replaycache/replaycache_test.go b/internal/replaycache/replaycache_test.go new file mode 100644 index 00000000..89bf9174 --- /dev/null +++ b/internal/replaycache/replaycache_test.go @@ -0,0 +1,141 @@ +package replaycache + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestKeyDeterministic(t *testing.T) { + msgs := []types.EyrieMessage{{Role: "user", Content: "hello"}} + k1 := Key("fp", "anthropic", "claude", msgs, 100) + k2 := Key("fp", "anthropic", "claude", msgs, 100) + if k1 != k2 { + t.Fatal("identical requests must produce identical keys") + } + if Key("fp2", "anthropic", "claude", msgs, 100) == k1 { + t.Fatal("different fingerprint should change the key") + } + if Key("fp", "openai", "claude", msgs, 100) == k1 { + t.Fatal("different provider should change the key") + } +} + +func TestFingerprintHidesSecrets(t *testing.T) { + fp := Fingerprint("sk-secret-key") + if strings.Contains(fp, "sk-secret") { + t.Fatal("fingerprint leaked the secret") + } + if len(fp) != 16 { + t.Fatalf("fingerprint length = %d, want 16", len(fp)) + } + if Fingerprint("a") == Fingerprint("b") { + t.Fatal("different secrets should produce different fingerprints") + } +} + +func TestPutGetResponseRoundTrip(t *testing.T) { + c := New(t.TempDir()) + key := Key("fp", "p", "m", []types.EyrieMessage{{Role: "user", Content: "hi"}}, 10) + want := &types.EyrieResponse{Content: "cached answer", FinishReason: "end_turn"} + if err := c.Put(key, want); err != nil { + t.Fatal(err) + } + got, ok := c.Get(key) + if !ok { + t.Fatal("cache miss after Put") + } + if got.Content != "cached answer" || got.FinishReason != "end_turn" { + t.Fatalf("got %+v", got) + } + if _, ok := c.Get("missing"); ok { + t.Fatal("expected miss for unknown key") + } +} + +func TestStreamRoundTrip(t *testing.T) { + c := New(t.TempDir()) + key := Key("fp", "p", "m", []types.EyrieMessage{{Role: "user", Content: "s"}}, 10) + events := []types.EyrieStreamEvent{ + {Type: "content", Content: "hel"}, + {Type: "content", Content: "lo"}, + {Type: "done", StopReason: "end_turn"}, + } + if err := c.PutStream(key, events); err != nil { + t.Fatal(err) + } + got, ok := c.GetStream(key) + if !ok { + t.Fatal("stream cache miss after PutStream") + } + if len(got) != 3 || got[0].Content != "hel" || got[2].StopReason != "end_turn" { + t.Fatalf("replayed events = %+v", got) + } +} + +func TestFilesAreNotWorldReadable(t *testing.T) { + dir := t.TempDir() + c := New(dir) + key := Key("fp", "p", "m", nil, 0) + if err := c.Put(key, &types.EyrieResponse{Content: "x"}); err != nil { + t.Fatal(err) + } + matches, _ := filepath.Glob(filepath.Join(dir, "resp", "*", "*.json")) + if len(matches) != 1 { + t.Fatalf("expected one cached file, got %v", matches) + } + info, err := os.Stat(matches[0]) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("file mode = %o, want 600", perm) + } +} + +func TestPutNilResponseErrors(t *testing.T) { + if err := New(t.TempDir()).Put(Key("f", "p", "m", nil, 0), nil); err == nil { + t.Fatal("expected error for nil response") + } +} + +func TestPutStreamEmptyErrors(t *testing.T) { + if err := New(t.TempDir()).PutStream(Key("f", "p", "m", nil, 0), nil); err == nil { + t.Fatal("expected error for empty events") + } +} + +func TestEntriesCountsBothKinds(t *testing.T) { + c := New(t.TempDir()) + k := Key("fp", "p", "m", []types.EyrieMessage{{Role: "user", Content: "e"}}, 5) + if err := c.Put(k, &types.EyrieResponse{Content: "r"}); err != nil { + t.Fatal(err) + } + if err := c.PutStream(k, []types.EyrieStreamEvent{{Type: "done"}}); err != nil { + t.Fatal(err) + } + if got := c.Entries(); got != 2 { + t.Fatalf("entries = %d, want 2", got) + } +} + +func TestJSONStableAcrossMarshalOrder(t *testing.T) { + // The canonical key must not depend on Go struct marshaling order. + msgs := []types.EyrieMessage{ + {Role: "user", Content: "q"}, + {Role: "assistant", Content: "", ToolUse: []types.ToolCall{{Name: "Bash", Arguments: map[string]interface{}{"command": "ls"}}}, ToolResults: []types.ToolResult{{Content: "out", IsError: true}}}, + } + a := Key("fp", "p", "m", msgs, 7) + b := Key("fp", "p", "m", msgs, 7) + if a != b { + t.Fatal("key unstable for identical requests") + } + // A changed tool result must change the key. + msgs[1].ToolResults[0].IsError = false + if Key("fp", "p", "m", msgs, 7) == a { + t.Fatal("changed tool-result error flag should change the key") + } +}