package react_test

import (
	"context"
	"errors"
	"fmt"
	"github.com/kardolus/chatgpt-cli/agent/core"
	"github.com/kardolus/chatgpt-cli/agent/react"
	"github.com/kardolus/chatgpt-cli/agent/types"
	"testing"
	"time"

	"github.com/golang/mock/gomock"
	. "github.com/onsi/gomega"
	"github.com/sclevine/spec"
	"github.com/sclevine/spec/report"
)

//go:generate mockgen -destination=runnermocks_test.go -package=react_test github.com/kardolus/chatgpt-cli/agent/core Runner
//go:generate mockgen -destination=clockmocks_test.go -package=react_test github.com/kardolus/chatgpt-cli/agent/core Clock
//go:generate mockgen -destination=llmmocks_test.go -package=react_test github.com/kardolus/chatgpt-cli/agent/tools LLM
//go:generate mockgen -destination=budgetmocks_test.go -package=react_test github.com/kardolus/chatgpt-cli/agent/core Budget

func TestUnitReAct(t *testing.T) {
	spec.Run(t, "Testing ReActAgent", testReActAgent, spec.Report(report.Terminal{}))
}

func testReActAgent(t *testing.T, when spec.G, it spec.S) {
	var (
		ctrl   *gomock.Controller
		llm    *MockLLM
		runner *MockRunner
		budget *MockBudget
		clock  *MockClock

		reactAgent *react.ReActAgent
		ctx        context.Context
		now        time.Time
	)

	it.Before(func() {
		RegisterTestingT(t)

		ctrl = gomock.NewController(t)
		llm = NewMockLLM(ctrl)
		runner = NewMockRunner(ctrl)
		budget = NewMockBudget(ctrl)
		clock = NewMockClock(ctrl)

		reactAgent = react.NewReActAgent(llm, runner, budget, clock)
		ctx = context.Background()
		now = time.Date(2026, 1, 15, 10, 0, 0, 0, time.UTC)

		clock.EXPECT().Now().Return(now).AnyTimes()
	})

	it.After(func() {
		ctrl.Finish()
	})

	when("LLM returns final answer immediately", func() {
		it("returns the answer without tool calls", func() {

			budget.EXPECT().AllowIteration(now).Return(nil)             // NEW
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{}) // NEW (no token limit)
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
					"thought": "The answer is simple",
					"action_type": "answer",
					"final_answer": "42"
				}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			_, err := reactAgent.RunAgentGoal(ctx, "What is the answer?")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("LLM uses a shell tool then answers", func() {
		it("executes the tool and returns the final answer", func() {

			// Iteration 1: tool
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
					"thought": "I need to list files",
					"action_type": "tool",
					"tool": "shell",
					"command": "ls",
					"args": ["-la"]
				}`, 15, nil)

			budget.EXPECT().ChargeLLMTokens(15, now)

			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Type).To(Equal(types.ToolShell))
					Expect(step.Command).To(Equal("ls"))
					Expect(step.Args).To(Equal([]string{"-la"}))
					return types.StepResult{
						Outcome:  types.OutcomeOK,
						Output:   "file1.txt\nfile2.txt",
						Duration: 100 * time.Millisecond,
					}, nil
				})

			// Iteration 2: answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("OBSERVATION: file1.txt"))
					return `{
						"thought": "I have the file list",
						"action_type": "answer",
						"final_answer": "There are 2 files"
					}`, 12, nil
				})

			budget.EXPECT().ChargeLLMTokens(12, now)

			_, err := reactAgent.RunAgentGoal(ctx, "How many files?")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("Budget is exceeded", func() {
		it("returns Budget error", func() {

			budget.EXPECT().AllowIteration(now).Return(nil) // allow iteration
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(core.BudgetExceededError{
				Kind:    core.BudgetKindLLM,
				Limit:   5,
				Used:    5,
				Message: "LLM call Budget exceeded",
			})

			_, err := reactAgent.RunAgentGoal(ctx, "Do something")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("LLM call Budget exceeded"))
		})
	})

	when("LLM returns invalid JSON", func() {
		it("recovers in-band and returns a final answer", func() {

			// Iteration 1: invalid output
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return("not valid json", 5, nil)

			budget.EXPECT().ChargeLLMTokens(5, now)

			// Iteration 2: recovery prompt, model now returns valid answer JSON
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("ACTION_TAKEN: tool=LLM details=INVALID_RESPONSE"))
					Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR: Your last response violated the ReAct protocol"))
					Expect(prompt).To(ContainSubstring("failed to locate JSON object"))
					Expect(prompt).To(ContainSubstring(`Raw response (truncated): "not valid json"`))
					return `{
					"thought": "ok",
					"action_type": "answer",
					"final_answer": "recovered"
				}`, 7, nil
				})

			budget.EXPECT().ChargeLLMTokens(7, now)

			res, err := reactAgent.RunAgentGoal(ctx, "Do something")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("recovered"))
		})

		it("recovers in-band, then hard-fails after max parse recoveries", func() {

			// We expect 4 attempts total:
			// 1..3 => recovery continues
			// 4    => parseRecoveries becomes 4 (>3) => returns error
			for i := 0; i < 4; i++ {
				budget.EXPECT().AllowIteration(now).Return(nil)
				budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
				budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

				if i == 0 {
					llm.EXPECT().
						Complete(gomock.Any(), gomock.Any()).
						Return("not valid json", 5, nil)
				} else {
					// From attempt 2 onward, prompt should include the recovery observations.
					llm.EXPECT().
						Complete(gomock.Any(), gomock.Any()).
						DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
							Expect(prompt).To(ContainSubstring("ACTION_TAKEN: tool=LLM details=INVALID_RESPONSE"))
							Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR: Your last response violated the ReAct protocol"))
							Expect(prompt).To(ContainSubstring(`"action_type"`))
							return "not valid json", 5, nil
						})
				}

				budget.EXPECT().ChargeLLMTokens(5, now)
			}

			_, err := reactAgent.RunAgentGoal(ctx, "Do something")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("agent failed to produce valid JSON after 3 attempts"))
			Expect(err.Error()).To(ContainSubstring("failed to locate JSON"))
		})
	})

	when("LLM returns JSON with missing action_type", func() {
		it("recovers in-band and returns a final answer", func() {

			// Iteration 1: parse succeeds, validation fails (missing action_type) -> triggers recovery path
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{"thought":"thinking"}`, 5, nil)

			budget.EXPECT().ChargeLLMTokens(5, now)

			// Iteration 2: recovery prompt, model returns valid answer JSON
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("ACTION_TAKEN: tool=LLM details=INVALID_RESPONSE"))
					Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR: Your last response violated the ReAct protocol"))
					Expect(prompt).To(ContainSubstring("missing action_type"))
					// raw is JSON, so the snippet should include it
					Expect(prompt).To(ContainSubstring(`Raw response (truncated): "{\"thought\":\"thinking\"}"`))
					return `{
					"thought": "ok",
					"action_type": "answer",
					"final_answer": "recovered"
				}`, 7, nil
				})

			budget.EXPECT().ChargeLLMTokens(7, now)

			res, err := reactAgent.RunAgentGoal(ctx, "Do something")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("recovered"))
		})

		it("recovers in-band, then hard-fails after max parse recoveries", func() {

			for i := 0; i < 4; i++ {
				budget.EXPECT().AllowIteration(now).Return(nil)
				budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
				budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

				if i == 0 {
					llm.EXPECT().
						Complete(gomock.Any(), gomock.Any()).
						Return(`{"thought":"thinking"}`, 5, nil)
				} else {
					llm.EXPECT().
						Complete(gomock.Any(), gomock.Any()).
						DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
							Expect(prompt).To(ContainSubstring("ACTION_TAKEN: tool=LLM details=INVALID_RESPONSE"))
							Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR: Your last response violated the ReAct protocol"))
							Expect(prompt).To(ContainSubstring("missing action_type"))
							return `{"thought":"thinking"}`, 5, nil
						})
				}

				budget.EXPECT().ChargeLLMTokens(5, now)
			}

			_, err := reactAgent.RunAgentGoal(ctx, "Do something")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("agent failed to produce valid JSON after 3 attempts"))
			Expect(err.Error()).To(ContainSubstring("missing action_type"))
		})
	})

	when("tool execution fails", func() {
		it("returns the execution error", func() {

			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
					"thought": "running command",
					"action_type": "tool",
					"tool": "shell",
					"command": "false"
				}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				Return(types.StepResult{
					Outcome:    types.OutcomeError,
					Transcript: "command failed",
				}, errors.New("exit 1"))

			_, err := reactAgent.RunAgentGoal(ctx, "Run false")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("exit 1"))
		})
	})

	when("iteration Budget is exceeded", func() {
		it("returns iteration Budget exceeded error", func() {

			// 10 successful iterations, then fail on 11th AllowIteration
			for i := 0; i < 10; i++ {
				budget.EXPECT().AllowIteration(now).Return(nil)
				budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
				budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

				llm.EXPECT().
					Complete(gomock.Any(), gomock.Any()).
					Return(fmt.Sprintf(`{
					"thought": "still working",
					"action_type": "tool",
					"tool": "shell",
					"command": "echo",
					"args": ["test-%d"]
				}`, i), 10, nil)

				budget.EXPECT().ChargeLLMTokens(10, now)
			}

			// We expect 10 tool executions total.
			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				Times(10).
				Return(types.StepResult{
					Outcome:  types.OutcomeOK,
					Output:   "ok",
					Duration: 10 * time.Millisecond,
				}, nil)

			budget.EXPECT().AllowIteration(now).Return(core.BudgetExceededError{
				Kind:    core.BudgetKindIterations,
				Limit:   10,
				Used:    10,
				Message: "iteration Budget exceeded",
			})

			_, err := reactAgent.RunAgentGoal(ctx, "Keep looping")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("iteration Budget exceeded"))
		})
	})

	when("LLM output has markdown code fences", func() {
		it("strips the fences and parses correctly", func() {

			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return("```json\n{\"thought\": \"done\", \"action_type\": \"answer\", \"final_answer\": \"Success\"}\n```", 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Test markdown")
			Expect(err).NotTo(HaveOccurred())
		})

		when("shell tool missing command", func() {
			it("injects error observation and lets LLM recover", func() {

				// Iteration 1: invalid shell tool (missing command)
				budget.EXPECT().AllowIteration(now).Return(nil)
				budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
				budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

				llm.EXPECT().
					Complete(gomock.Any(), gomock.Any()).
					Return(`{
				"thought": "using shell",
				"action_type": "tool",
				"tool": "shell",
				"command": ""
			}`, 10, nil)

				budget.EXPECT().ChargeLLMTokens(10, now)

				// Iteration 2: agent should surface validation error to the model as an observation,
				// then model answers (or chooses a different tool).
				budget.EXPECT().AllowIteration(now).Return(nil)
				budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
				budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

				llm.EXPECT().
					Complete(gomock.Any(), gomock.Any()).
					DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
						Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR"))
						Expect(prompt).To(ContainSubstring("shell tool requires command"))
						return `{
					"thought": "ok",
					"action_type": "answer",
					"final_answer": "cannot run shell without a command"
				}`, 1, nil
					})

				budget.EXPECT().ChargeLLMTokens(1, now)

				res, err := reactAgent.RunAgentGoal(ctx, "Test")
				Expect(err).NotTo(HaveOccurred())
				Expect(res).To(Equal("cannot run shell without a command"))
			})
		})
	})

	when("LLM uses shorthand action_type like file/shell/LLM", func() {
		it("treats action_type=file as a tool call", func() {

			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			// NOTE: action_type is "file" (shorthand). tool is omitted.
			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "read it",
				"action_type": "file",
				"op": "read",
				"path": "AGENTS.md"
			}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Type).To(Equal(types.ToolFiles))
					Expect(step.Op).To(Equal("read"))
					Expect(step.Path).To(Equal("AGENTS.md"))
					return types.StepResult{
						Outcome:  types.OutcomeOK,
						Output:   "ok",
						Duration: 1 * time.Millisecond,
					}, nil
				})

			// Next iteration: answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "done",
				"action_type": "answer",
				"final_answer": "ok"
			}`, 5, nil)

			budget.EXPECT().ChargeLLMTokens(5, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Read AGENTS")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("LLM returns multiple JSON objects back-to-back", func() {
		it("parses only the first JSON object", func() {

			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			// Two JSON objects concatenated. parseReActResponse should take only the first.
			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(
					`{"thought":"one","action_type":"answer","final_answer":"A"}{"thought":"two","action_type":"answer","final_answer":"B"}`,
					10,
					nil,
				)

			budget.EXPECT().ChargeLLMTokens(10, now)

			res, err := reactAgent.RunAgentGoal(ctx, "Test")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("A"))
		})
	})

	when("LLM repeats the same tool call twice in a row", func() {
		it("injects a repetition observation and forces a different next step", func() {

			// Iteration 1: tool
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "do it",
				"action_type": "tool",
				"tool": "shell",
				"command": "ls",
				"args": ["-la"]
			}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			// Only ONE tool execution should happen (iteration 1).
			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				Times(1).
				Return(types.StepResult{
					Outcome:  types.OutcomeOK,
					Output:   "file1\nfile2\n",
					Duration: 1 * time.Millisecond,
				}, nil)

			// Iteration 2: same tool again (should be blocked by repetition guard)
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "try again",
				"action_type": "tool",
				"tool": "shell",
				"command": "ls",
				"args": ["-la"]
			}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			// Iteration 3: must see injected repetition message in prompt, then answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("OBSERVATION: You are repeating the same tool call"))
					return `{
					"thought": "ok, I'll stop repeating",
					"action_type": "answer",
					"final_answer": "done"
				}`, 5, nil
				})

			budget.EXPECT().ChargeLLMTokens(5, now)

			res, err := reactAgent.RunAgentGoal(ctx, "List files")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("done"))
		})
	})

	when("LLM ignores repetition warnings", func() {
		it("hard-stops after too many repeats in the rolling window", func() {

			// We'll do 6 iterations total (1 executes, 2-5 skipped, 6 hard-stops)
			for i := 0; i < 6; i++ {
				budget.EXPECT().AllowIteration(now).Return(nil)
				budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
				budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

				llm.EXPECT().
					Complete(gomock.Any(), gomock.Any()).
					Return(`{
          "thought": "list files again",
          "action_type": "tool",
          "tool": "shell",
          "command": "ls",
          "args": ["-la"]
        }`, 1, nil)

				budget.EXPECT().ChargeLLMTokens(1, now)
			}

			// Only the FIRST iteration should actually execute the tool.
			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				Times(1).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Type).To(Equal(types.ToolShell))
					Expect(step.Command).To(Equal("ls"))
					Expect(step.Args).To(Equal([]string{"-la"}))
					return types.StepResult{
						Outcome:  types.OutcomeOK,
						Output:   "file1\nfile2\n",
						Duration: 10 * time.Millisecond,
					}, nil
				})

			_, err := reactAgent.RunAgentGoal(ctx, "Loop forever")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("agent appears stuck"))
			Expect(err.Error()).To(ContainSubstring("repeated tool call too many times"))
		})
	})

	when("LLM uses shorthand action_type=file (no tool field)", func() {
		it("treats it as a tool call and executes file op", func() {

			// Iteration 1: shorthand file tool
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "read README",
				"action_type": "file",
				"op": "read",
				"path": "README.md"
			}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Type).To(Equal(types.ToolFiles))
					Expect(step.Op).To(Equal("read"))
					Expect(step.Path).To(Equal("README.md"))
					return types.StepResult{
						Outcome:  types.OutcomeOK,
						Output:   "README CONTENT",
						Duration: 5 * time.Millisecond,
					}, nil
				})

			// Iteration 2: answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("OBSERVATION: README CONTENT"))
					return `{
					"thought": "done",
					"action_type": "answer",
					"final_answer": "ok"
				}`, 1, nil
				})

			budget.EXPECT().ChargeLLMTokens(1, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Read README and answer")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("LLM uses shorthand action_type=file AND also sets tool=file", func() {
		it("still treats it as a tool call (compat mode)", func() {

			// Iteration 1: shorthand but with tool field present
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "read AGENTS",
				"action_type": "file",
				"tool": "file",
				"op": "read",
				"path": "AGENTS.md"
			}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			runner.EXPECT().
				RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				Return(types.StepResult{
					Outcome:  types.OutcomeOK,
					Output:   "AGENTS CONTENT",
					Duration: 5 * time.Millisecond,
				}, nil)

			// Iteration 2: answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "done",
				"action_type": "answer",
				"final_answer": "ok"
			}`, 1, nil)

			budget.EXPECT().ChargeLLMTokens(1, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Read AGENTS and answer")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("LLM uses shorthand action_type=file but tool mismatches", func() {
		it("recovers in-band rather than failing the whole run", func() {

			// Iteration 1: invalid action_type/tool combination -> parseReActResponse returns invalid action_type
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
				"thought": "oops",
				"action_type": "file",
				"tool": "shell",
				"command": "ls"
			}`, 10, nil)

			budget.EXPECT().ChargeLLMTokens(10, now)

			// Iteration 2: recovery prompt, model returns a valid final answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("ACTION_TAKEN: tool=LLM details=INVALID_RESPONSE"))
					Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR: Your last response violated the ReAct protocol"))
					Expect(prompt).To(ContainSubstring(`invalid action_type: "file"`))
					Expect(prompt).To(ContainSubstring(`"tool": "shell"`))
					return `{
					"thought": "ack, correct schema",
					"action_type": "answer",
					"final_answer": "recovered"
				}`, 5, nil
				})

			budget.EXPECT().ChargeLLMTokens(5, now)

			res, err := reactAgent.RunAgentGoal(ctx, "Bad shorthand")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("recovered"))
		})

		it("recovers in-band, then hard-fails after max parse recoveries", func() {

			// This is a validation error inside parseReActResponse: invalid action_type: "file"
			bad := `{
				"thought": "oops",
				"action_type": "file",
				"tool": "shell",
				"command": "ls"
			}`

			for i := 0; i < 4; i++ {
				budget.EXPECT().AllowIteration(now).Return(nil)
				budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
				budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

				if i == 0 {
					llm.EXPECT().
						Complete(gomock.Any(), gomock.Any()).
						Return(bad, 10, nil)
				} else {
					llm.EXPECT().
						Complete(gomock.Any(), gomock.Any()).
						DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
							Expect(prompt).To(ContainSubstring("ACTION_TAKEN: tool=LLM details=INVALID_RESPONSE"))
							Expect(prompt).To(ContainSubstring("invalid action_type"))
							Expect(prompt).To(ContainSubstring(`Raw response (truncated)`))
							return bad, 10, nil
						})
				}

				budget.EXPECT().ChargeLLMTokens(10, now)
			}

			_, err := reactAgent.RunAgentGoal(ctx, "Bad shorthand")
			Expect(err).To(HaveOccurred())
			Expect(err.Error()).To(ContainSubstring("agent failed to produce valid JSON after 3 attempts"))
			Expect(err.Error()).To(ContainSubstring(`invalid action_type: "file"`))
		})
	})

	when("LLM uses file patch", func() {
		it("converts to a ToolFiles step and executes it", func() {
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"apply diff",
      "action_type":"tool",
      "tool":"file",
      "op":"patch",
      "path":"a.txt",
      "data":"--- a/a.txt\n+++ b/a.txt\n@@\n+hi\n"
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			runner.EXPECT().RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Type).To(Equal(types.ToolFiles))
					Expect(step.Op).To(Equal("patch"))
					Expect(step.Path).To(Equal("a.txt"))
					Expect(step.Data).To(ContainSubstring("+++ b/a.txt"))
					return types.StepResult{Outcome: types.OutcomeOK, Output: "patched"}, nil
				})

			// next iteration: answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"done",
      "action_type":"answer",
      "final_answer":"ok"
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Patch a.txt")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("LLM uses file replace", func() {
		it("converts to a ToolFiles step with Old/New/N", func() {
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"swap token",
      "action_type":"tool",
      "tool":"file",
      "op":"replace",
      "path":"a.txt",
      "old":"foo",
      "new":"bar",
      "n":2
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			runner.EXPECT().RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Type).To(Equal(types.ToolFiles))
					Expect(step.Op).To(Equal("replace"))
					Expect(step.Path).To(Equal("a.txt"))
					Expect(step.Old).To(Equal("foo"))
					Expect(step.New).To(Equal("bar"))
					Expect(step.N).To(Equal(2))
					return types.StepResult{Outcome: types.OutcomeOK, Output: "replaced"}, nil
				})

			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)
			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"done",
      "action_type":"answer",
      "final_answer":"ok"
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Replace in a.txt")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("LLM uses file patch without data", func() {
		it("injects error observation and lets LLM recover", func() {
			// Iteration 1: invalid patch
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"patch",
      "action_type":"tool",
      "tool":"file",
      "op":"patch",
      "path":"a.txt",
      "data":"   "
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			// Iteration 2: model sees error and answers
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("file patch requires data"))
					Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR"))
					return `{
          "thought":"ok",
          "action_type":"answer",
          "final_answer":"fixed"
        }`, 1, nil
				})
			budget.EXPECT().ChargeLLMTokens(1, now)

			res, err := reactAgent.RunAgentGoal(ctx, "Patch")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("fixed"))
		})
	})

	when("LLM uses file replace without old", func() {
		it("injects error observation and lets LLM recover", func() {
			// Iteration 1: invalid replace
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"replace",
      "action_type":"tool",
      "tool":"file",
      "op":"replace",
      "path":"a.txt",
      "new":""
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			// Iteration 2: recover
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("file replace requires old"))
					Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR"))
					return `{
          "thought":"ok",
          "action_type":"answer",
          "final_answer":"recovered"
        }`, 1, nil
				})
			budget.EXPECT().ChargeLLMTokens(1, now)

			res, err := reactAgent.RunAgentGoal(ctx, "Replace")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("recovered"))
		})
	})

	when("patch fails and agent falls back to full write", func() {
		it("continues after patch failure observation and then writes", func() {
			// Iteration 1: patch
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"try patch first",
      "action_type":"tool",
      "tool":"file",
      "op":"patch",
      "path":"a.txt",
      "data":"--- a/a.txt\n+++ b/a.txt\n@@\n+hi\n"
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			runner.EXPECT().RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Op).To(Equal("patch"))
					// IMPORTANT: err == nil, failure is conveyed in Output/Outcome
					return types.StepResult{
						Outcome:  types.OutcomeError,
						Output:   "patch failed: hunk did not apply",
						Duration: 1 * time.Millisecond,
					}, nil
				})

			// Iteration 2: LLM sees patch failed and chooses full write
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR:"))
					Expect(prompt).To(ContainSubstring("patch failed"))
					return `{
          "thought":"fallback to write full file",
          "action_type":"tool",
          "tool":"file",
          "op":"write",
          "path":"a.txt",
          "data":"FULL NEW CONTENT\n"
        }`, 1, nil
				})
			budget.EXPECT().ChargeLLMTokens(1, now)

			runner.EXPECT().RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Op).To(Equal("write"))
					Expect(step.Path).To(Equal("a.txt"))
					Expect(step.Data).To(Equal("FULL NEW CONTENT\n"))
					return types.StepResult{Outcome: types.OutcomeOK, Output: "wrote"}, nil
				})

			// Iteration 3: answer
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"done",
      "action_type":"answer",
      "final_answer":"ok"
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Modify a.txt")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("a step produces side effects", func() {
		it("includes STATE line with cumulative effects in the next prompt", func() {
			// iter 1
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"write a file",
      "action_type":"tool",
      "tool":"file",
      "op":"write",
      "path":"a.txt",
      "data":"hi"
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			runner.EXPECT().RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				Return(types.StepResult{
					Outcome:  types.OutcomeOK,
					Output:   "wrote",
					Duration: 1 * time.Millisecond,
					Effects: types.Effects{
						{Kind: "file.write", Path: "a.txt"},
					},
				}, nil)

			// iter 2
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					Expect(prompt).To(ContainSubstring("State:"))
					Expect(prompt).To(ContainSubstring("file.write x1"))
					return `{
          "thought":"done",
          "action_type":"answer",
          "final_answer":"ok"
        }`, 1, nil
				})
			budget.EXPECT().ChargeLLMTokens(1, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Write")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("patch fails and agent injects FALLBACK REQUIRED guidance", func() {
		it("includes fallback-required instruction (read+write) in the next prompt", func() {
			// Iteration 1: model tries patch
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).Return(`{
      "thought":"try patch first",
      "action_type":"tool",
      "tool":"file",
      "op":"patch",
      "path":"a.txt",
      "data":"--- a/a.txt\n+++ b/a.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"
    }`, 1, nil)
			budget.EXPECT().ChargeLLMTokens(1, now)

			// Patch fails via OutcomeError (err == nil)
			runner.EXPECT().RunStep(gomock.Any(), gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, _ types.Config, step types.Step) (types.StepResult, error) {
					Expect(step.Type).To(Equal(types.ToolFiles))
					Expect(step.Op).To(Equal("patch"))
					Expect(step.Path).To(Equal("a.txt"))
					return types.StepResult{
						Outcome:  types.OutcomeError,
						Output:   "invalid unified diff: missing hunk header",
						Duration: 1 * time.Millisecond,
					}, nil
				})

			// Iteration 2: model sees fallback-required instruction (this is what we’re testing)
			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					// Normal error observation is still present
					Expect(prompt).To(ContainSubstring("OBSERVATION: ERROR:"))
					Expect(prompt).To(ContainSubstring("invalid unified diff"))

					// New guardrail line(s)
					Expect(prompt).To(ContainSubstring("OBSERVATION: FALLBACK REQUIRED"))
					Expect(prompt).To(ContainSubstring(`Do NOT try op="patch" or op=patch/replace again`))

					// Explicit JSON skeleton that forces read next
					Expect(prompt).To(ContainSubstring(`{"action_type":"tool","tool":"file","op":"read","path":"a.txt"}`))

					// We can stop here with an answer; no need to actually execute the fallback in this unit test.
					return `{
          "thought":"ack",
          "action_type":"answer",
          "final_answer":"ok"
        }`, 1, nil
				})
			budget.EXPECT().ChargeLLMTokens(1, now)

			_, err := reactAgent.RunAgentGoal(ctx, "Modify a.txt")
			Expect(err).NotTo(HaveOccurred())
		})
	})

	when("agent already has transcript + history from a previous run", func() {
		it("resets them before starting a new run", func() {
			// Seed previous run leftovers
			reactAgent.AddTranscript("OLD_TRANSCRIPT_SHOULD_BE_CLEARED")
			reactAgent.AddHistory("OLD_HISTORY_SHOULD_BE_CLEARED")

			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				Return(`{
					"thought": "ok",
					"action_type": "answer",
					"final_answer": "done"
				}`, 3, nil)

			budget.EXPECT().ChargeLLMTokens(3, now)

			res, err := reactAgent.RunAgentGoal(ctx, "New goal")
			Expect(err).NotTo(HaveOccurred())
			Expect(res).To(Equal("done"))

			// Assert old data is gone
			Expect(reactAgent.TranscriptString()).NotTo(ContainSubstring("OLD_TRANSCRIPT_SHOULD_BE_CLEARED"))
			Expect(reactAgent.History()).NotTo(ContainSubstring("OLD_HISTORY_SHOULD_BE_CLEARED"))

			// And the new goal is present (sanity check)
			Expect(reactAgent.TranscriptString()).To(ContainSubstring("[goal]"))
			Expect(reactAgent.TranscriptString()).To(ContainSubstring("New goal"))
			Expect(reactAgent.History()).To(ContainSubstring("USER: New goal"))
		})
	})

	when("prompt logging is enabled and transcript max is small", func() {
		it("caps transcript length (and truncates) even if prompt is large", func() {
			// Create an agent with a tiny transcript buffer so truncation is guaranteed.
			// (We keep using the same mocks.)
			agent := react.NewReActAgent(
				llm, runner, budget, clock,
				core.WithTranscriptMaxBytes(120),
				core.WithPromptHistoryMaxBytes(120),
			)

			budget.EXPECT().AllowIteration(now).Return(nil)
			budget.EXPECT().Snapshot(now).Return(core.BudgetSnapshot{})
			budget.EXPECT().AllowTool(types.ToolLLM, now).Return(nil)

			llm.EXPECT().
				Complete(gomock.Any(), gomock.Any()).
				DoAndReturn(func(_ context.Context, prompt string) (string, int, error) {
					// The prompt should be big (your buildReActPrompt() is huge),
					// which is what makes prompt-logging risky if uncapped.
					Expect(len(prompt)).To(BeNumerically(">", 200))
					return `{
						"thought": "ok",
						"action_type": "answer",
						"final_answer": "done"
					}`, 2, nil
				})

			budget.EXPECT().ChargeLLMTokens(2, now)

			_, err := agent.RunAgentGoal(ctx, "Goal that triggers large prompt")
			Expect(err).NotTo(HaveOccurred())

			ts := agent.TranscriptString()

			// This is the actual regression check:
			// transcript must be capped at <= 120 bytes.
			Expect(len([]byte(ts))).To(BeNumerically("<=", 120))

			// And it should show your truncation banner if it overflowed.
			// (banner text is "\n…(truncated)\n")
			Expect(ts).To(ContainSubstring("…(truncated)"))

			// Optional: if you used the suggested prompt logging tag:
			// Expect(ts).To(ContainSubstring("[iteration 1][prompt]"))
		})
	})
}
