diff --git a/Makefile b/Makefile index c2a683da96..32e34be07e 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # anywhere else — so a stale copy can't shadow a fresh one. BINARY := bin/codeaf -.PHONY: all build build-check build-cross debug demo-home embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \ +.PHONY: all build build-check build-cross debug demo-home clean-run embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \ changelog changelog-new changelog-check changelog-preview # What the shipped binary is allowed to weigh, in bytes, checked in beside the @@ -355,6 +355,12 @@ demo-home: build go build -o $(DEMO_BINARY) ./cmd/codeaf-demo-home @$(DEMO_BINARY) $(if $(DEMO_HOME),--into "$(DEMO_HOME)") $(if $(KEEP),--keep) --launch "$(CURDIR)/$(BINARY)" +# clean-run opens bin/codeaf on a fresh state root holding only your settings +# and keys (scripts/clean-run.sh), so a new build is tried from the same clean +# start every time: no conversations, projects or tasks from ~/.codeaf. +clean-run: build + @scripts/clean-run.sh + vet: go vet ./... diff --git a/PERF.md b/PERF.md index e6b9c9bfb2..eb38a0e67e 100644 --- a/PERF.md +++ b/PERF.md @@ -135,6 +135,34 @@ all four, each with its own furrow artifact staged: The budget is 54,600,000, two percent above darwin/amd64, the same headroom every figure in this section was given, now over a smaller binary. +It was reset a fifth time on 2026-09-23, when senior-dev moved inside the binary +(`internal/seniordev`, the built-in programs wave). Like furrow's, this one is a +decision and not a drift: the owner's direction is that the programs codeaf hands +a whole task to are built into every codeaf build and exist nowhere else, so the +limit rises by what the engine weighs. Measured before (`5cf6a821e`) and after +(`45e505550`) it landed, with the flags `make build` uses, on Go 1.27.0: + +| platform | before | after | what senior-dev cost | +| --- | --- | --- | --- | +| darwin/arm64, furrow staged | 54,018,770 | 56,493,874 | 2,475,104 | +| darwin/amd64 | 58,504,000 | 61,279,360 | 2,775,360 | +| linux/arm64 | 52,560,032 | 54,984,864 | 2,424,832 | +| linux/amd64 | 57,421,984 | 60,133,536 | 2,711,552 | + +Only darwin/arm64 had its furrow artifact on disk, so the other three rows are +weighed without theirs: each difference is exact, and each absolute figure is +short by that platform's artifact, about three megabytes. The budget rises by +the largest difference, to 57,400,000 — this change's bill and nothing else. + +AND THE TABLE SHOWS A BILL THAT WAS ALREADY OWED, which this reset does not +fold in. Before senior-dev, darwin/amd64 and linux/amd64 already weighed more +than 54,600,000 without their furrow artifacts, and linux/arm64 was within about +two megabytes of it before its own was added: the growth since the fourth reset +crossed the cap everywhere but the laptop the budget is usually checked on. The +CI size job reports it and does not block (`ci-full.yml`'s `size`), for the +reason that job gives — which architecture the budget is measured on has to be +agreed first — and that agreement, not a larger number here, is the fix. + ## Adaptive run shutdown grace `Agent.Close` cancels adaptive runs and their name calls, then gives all accepted diff --git a/README.md b/README.md index dee128d01d..c555736d03 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ hand work off, see what is moving across every project, and step in only where your judgment is needed. A factory, on your own machine, and the more you hand it the more it does. +**#1 on DeepSWE** of ten coding harnesses on the same model, ahead of Claude Code, +Codex, OpenCode, Kilo and DeepSeek's own harness, at the lowest cost per solved +issue ([benchmarks](#benchmarks)). + Written in Go as one small binary, with nothing else to install or run. Apache 2.0. By [AgentField AI](https://agentfield.ai?utm_source=github-readme&utm_campaign=codeaf-readme&utm_id=codeaf-readme-byline). @@ -51,6 +55,9 @@ curl -fsSL https://agentfield.ai/get/codeaf | bash codeaf ``` +
+Pin a version, or build from source + The script puts the release binary for your platform in `~/.codeaf/bin`. To pin a version, give it a tag from the [releases page](https://github.com/Agent-Field/codeaf/releases), where the @@ -63,6 +70,8 @@ curl -fsSL https://agentfield.ai/get/codeaf | VERSION= bash To build it yourself: `git clone`, `make build`, `bin/codeaf` ([guide](docs/GUIDE.md#install)). +
+ On first start it connects OpenRouter in your browser, or takes a key. Codex signs in a ChatGPT plan from `/connect` or `codeaf connect codex`; DeepSeek, GLM, Kimi, MiniMax and Qwen take keys; Ollama needs none. @@ -133,19 +142,21 @@ A run is a task like any other, on `home`, with a room and a stop. - **Coming soon, native:** [PR-AF](https://github.com/Agent-Field/pr-af), the #1 open-source code reviewer on Martian Code-Review-Bench. -- **Coming soon, in the benchmark below:** the developer subharness against - general harnesses on the same open model. +- **Native now:** `/senior-dev`, the developer subharness. First of ten + harnesses on DeepSWE, in the [benchmark below](#benchmarks). - **Your own:** "make me a harness for triaging flaky tests" designs one, saves it, and `/subharness` runs it. ## Benchmarks -Coming soon. The run is held-out GitHub issues, several seeds each, through -CodeAF's developer subharness and the general harnesses on the same open model: -pass rate, cost per issue and time per issue, with every failure, timeout and -unpriced call written up in [BENCHMARKS.md](BENCHMARKS.md). The chart and the -table land here when the run completes, and `bench/` runs it on your own -repository. +First on DeepSWE: senior-dev, CodeAF's developer subharness, solved the most tasks (54.9%) at the lowest cost per solved task (1x). Every other harness solved less and paid more per solve: mini-swe-agent 1.9x, codex 2.1x, pi 2.4x, claude-code 3.4x, omp, kilo and opencode about 4.5x, muse-code 11.3x, deepseek-harness 26.6x. + +`/senior-dev`, CodeAF's developer subharness, against nine other coding harnesses +on the same model, DeepSeek V4 Flash: 113 real GitHub issues from DeepSWE, graded +by the official verifiers. It solved the most issues and paid the least for each +one it solved. + +Every number, the method and the limits: [docs/benchmarks/deepswe](docs/benchmarks/deepswe/). ## The right model for each call diff --git a/SIZE-BUDGET b/SIZE-BUDGET index b480208c35..c1453c4181 100644 --- a/SIZE-BUDGET +++ b/SIZE-BUDGET @@ -1 +1 @@ -54600000 +57400000 diff --git a/assets/readme/benchmark-deepswe.webp b/assets/readme/benchmark-deepswe.webp new file mode 100644 index 0000000000..0c6404068e Binary files /dev/null and b/assets/readme/benchmark-deepswe.webp differ diff --git a/cmd/codeaf/carried.go b/cmd/codeaf/carried.go new file mode 100644 index 0000000000..ce5f18cefa --- /dev/null +++ b/cmd/codeaf/carried.go @@ -0,0 +1,937 @@ +package main + +// `codeaf …` for a program this build carries (internal/delegate): the +// verb every one of them answers, from a person's shell and from the chat's +// own run alike. +// +// TWO CALLERS, ONE LINE. The chat's run starts `codeaf senior-dev run --json +// --dir … -- ` as its child, with the model API's address and token in +// the child's environment; a person types the same verb at a shell with +// neither. The environment is how the two are told apart: a child of a host +// runs the program's body here and writes its records on stdout; a shell run +// becomes the host itself — it serves the model API and starts the same child. +// +// ── A SHELL RUN IS THE SAME TWO PROCESSES A CHAT'S RUN IS ─────────────────── +// +// The host reaches models the way every headless verb does — the person's own +// profile, its services and its keys (config.Load) — and serves them to the +// program through a model API of its own (internal/provider/modelapi), exactly +// as the chat's run does: the program is started as a child of this very +// executable with the API's address and token and no key, every call it makes +// is metered, held to the ceiling the person set, written to this machine's +// spending ledger, and kept as one turn of a conversation log in the run's own +// record folder. What the chat draws on a task page, the host prints as lines. + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/home" + lanes "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/reltime" +) + +// carriedStdout is where a carried verb writes what a person reads: its help, +// and a shell run's lines or records. A variable so a test can read it back; +// a child of a host writes its records to the real stdout whatever this says, +// because that pipe is its host's. +var carriedStdout io.Writer = os.Stdout + +// carriedStderr is where a shell run says what it is doing about its own +// ending — the stop, and the wait for a last call's price — beside the lines +// or records on stdout. A variable so a test can read it back. +var carriedStderr io.Writer = os.Stderr + +// carriedGrace overrides the launch's SIGTERM grace for a shell run, for a +// test that must not wait fifteen seconds; zero is delegate.DefaultGrace. +var carriedGrace time.Duration + +// runCarried runs one line of a carried program's verb and leaves on the exit +// ladder (envelope.go). +func runCarried(program delegate.Delegate, args []string) error { + inv, err := delegate.Parse(program, args, carriedStdout) + if errors.Is(err, delegate.ErrHelp) { + return exitDone + } + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return exitCannotRun + } + // SIGTERM IS THE HOST'S STOP (internal/delegate's launch): the body's + // context ends, and the program writes its terminal on the way out. + ctx, stop := carriedSignals() + defer stop() + if _, child := delegate.ModelAPIFromEnv(); child { + return carriedExit(delegate.RunChild(ctx, inv, os.Stdout)) + } + return runCarriedHost(ctx, inv) +} + +// carriedSignals is a shell run's context: it ends on the first ctrl-c, +// SIGTERM or hangup, and that first signal hands ctrl-c and SIGTERM back to the +// terminal. +// +// A SECOND CTRL-C LEAVES AT ONCE. After the first one the run still waits for +// the program's grace, its last calls to finish and the price of a call the +// stop cut short — up to about a minute and a half, said on stderr as it +// happens. Holding the signals for all of that swallowed a second ctrl-c, and +// a person who means "now" is owed a way out that does not wait for money to +// be counted. What leaving costs is said in the manual: the folder is +// finished before that wait ([runCarriedHost]), so it is only a price still +// being waited for that is then not in the run's line. +// +// A HANGUP IS A STOP, AND ONLY THE FIRST ONE IS HEARD. A closed terminal or a +// dropped ssh connection sent SIGHUP, which nothing caught: the host died on +// the spot, its program worked on unstopped, and the folder was left on the +// program's branch with nothing said. It now stops the program and finishes +// the folder the way ctrl-c does, and a second hangup — a shell passing one on +// to its jobs as it exits — is ignored rather than allowed to kill that +// finishing halfway. +func carriedSignals() (context.Context, context.CancelFunc) { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + context.AfterFunc(ctx, func() { + signal.Ignore(syscall.SIGHUP) + stop() + }) + return ctx, stop +} + +// carriedRoad is how one shell run reaches models: the funnel a call on a +// model goes out through, whether this person's services can take a call on a +// model, and the work seat a call nothing here can serve is answered on. +type carriedRoad struct { + completerFor func(model string) modelapi.Completer + serves func(model string) bool + seat string + // signModel is the model the attribution line on the one commit codeaf + // writes when the run ends names (internal/session's ProgramFolder.Finish): + // the work seat when the person's `attribution.model` row is on, and "" + // for the line that names none. The commit is signed either way. + signModel string +} + +// carriedModels resolves a shell run's road. It is the person's own profile, +// read the way `codeaf exec` reads it; a variable so a test can hand a +// scripted road instead of a profile and a key. +var carriedModels = profileRoad + +// profileRoad is the road through the person's profile: config.Load's +// services and keys — so a machine with no key at all is answered with the +// one sentence every command gives it — the crew's work seat, and one adapter +// per model, built the way every client outside internal/config is built +// ([config.Config.ClientConfig]). +func profileRoad() (carriedRoad, error) { + settings, err := config.Load() + if err != nil { + return carriedRoad{}, err + } + useAutoSeats(settings) + seats := config.ResolveSeats(settings.ProfileDir, "", "") + settings.Models = sharedCatalog(settings) + adapters := &carriedAdapters{settings: settings, built: map[string]modelapi.Completer{}} + sources := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL) + return carriedRoad{ + completerFor: adapters.forModel, + serves: func(model string) bool { return session.ServesModel(sources, model) }, + seat: seats.Work.Model, + signModel: signedSeat(settings.ProfileDir, seats.Work.Model), + }, nil +} + +// signedSeat is the model a shell run's commit names: the work seat when the +// person's `attribution.model` row is on, and "" when it is off. +func signedSeat(profileDir, seat string) string { + if !config.AttributionModelAt(profileDir) { + return "" + } + return seat +} + +// carriedAdapters is one adapter per model a shell run's program asks for, +// built once and kept for the run. +type carriedAdapters struct { + settings config.Config + mu sync.Mutex + built map[string]modelapi.Completer +} + +// forModel is the adapter for one model. THE WIRE SLUG IS THE LAST OPTION: a +// program names a model in the person's own spelling — `openrouter/…`, a +// connection's own prefix — and that spelling decides the account; what goes +// on the wire is the service's own id for it, which config resolved beside +// the account, exactly as the conversation's own door appends it +// (internal/session's completeWithNamedModel). +func (a *carriedAdapters) forModel(model string) modelapi.Completer { + a.mu.Lock() + defer a.mu.Unlock() + if built, ok := a.built[model]; ok { + return built + } + configured := a.settings.ClientConfig(model) + client, err := provider.NewClient(configured) + if err != nil { + return refusingCompleter{err: err} + } + built := wireCompleter{client: client, wire: configured.Model} + a.built[model] = built + return built +} + +// wireCompleter is one adapter with the model's wire slug appended to every +// call. +type wireCompleter struct { + client *provider.Client + wire string +} + +func (c wireCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + return c.client.CompleteWithMessages(ctx, messages, append(options, ai.WithModel(c.wire))...) +} + +// refusingCompleter is a model whose adapter could not be built: every call is +// answered with why. +type refusingCompleter struct{ err error } + +func (c refusingCompleter) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) { + return nil, c.err +} + +// runCarriedHost is a person's shell run: this process serves the model API +// with the person's own services and starts the program as its own child. +func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error { + // A RUN WITH NOTHING TO DO IS NOT STARTED. The default command is a whole + // task, and a task with no brief is a program sent off to guess; the line + // that says what it wanted is the answer, and nothing is spent on the way. + if inv.Command.Name == inv.Program.Default && inv.Brief() == "" { + fmt.Fprintf(os.Stderr, "no brief given\n\n%s\n\nrun `codeaf %s --help` for its commands and flags.\n", + strings.Join(foldSynopsis("codeaf "+inv.Program.Name+" "+carriedSynopsis), "\n"), inv.Program.Name) + return exitCannotRun + } + road, err := carriedModels() + if err != nil { + return err + } + // A PERSON TYPED THIS AND IS WATCHING ITS LINES, which is the fact the + // lane layer reads for the calls that ride no context of the door's own + // (exec.go's typedDoorContext says the whole of why). + provider.SetPersonAtTheDoor(true) + record := carriedRecordDir(inv.Program.Name) + view := newCarriedView(carriedStdout, inv, record) + // THE FOLDER IS READIED BEFORE ANYTHING STARTS, the one way a conversation's + // run readies it (internal/session's programfolder.go): the folder itself, + // on a branch of its own in a repository, and a refusal — changes that are + // not committed, another program's run in it — before a cent is spent. A + // plain folder is no longer the program's first-line failure: codeaf says + // so on the program's line. + folder, err := carriedFolder(inv, record, road.signModel) + if err != nil { + fmt.Fprintln(carriedStderr, "error:", err) + return exitCannotRun + } + view.inFolder(folder) + // AND THE FOLDER IS FINISHED ON EVERY ROAD OUT: the run's ending below, or + // a door that failed before the program ever ran, which leaves nothing. + finished := false + finish := func(result string) { + if folder != nil && !finished { + finished = true + view.left(folder.Finish(result).Sentence()) + } + } + defer finish("") + + runCtx, cut := context.WithCancel(ctx) + defer cut() + // A LIMIT THE PERSON SET ENDS THE PROGRAM FROM OUTSIDE, whatever it does + // with the same figure on its own command line: the hours by a clock here, + // the dollars the moment a metered call reaches them. The API refuses the + // next call as well, so a program that ignores its SIGTERM cannot spend on. + var limited atomic.Bool + if wall := inv.Ceilings.Elapsed(); wall > 0 { + clock := time.AfterFunc(wall, func() { + limited.Store(true) + cut() + }) + defer clock.Stop() + } + ledger := session.UsageLedgerPath() + // A SHELL RUN'S ROWS NAME THE RUN. There is no conversation and no task + // behind them, and a row that named nothing was money the spending page + // could not say anything about; the run's own record folder is the one + // name it has, so its rows are filed as one piece of work under it. + subject := filepath.Base(record) + api, err := modelapi.Open(modelapi.Config{ + TaskDir: record, + CompleterFor: road.completerFor, + Serves: road.serves, + Seat: road.seat, + Ceiling: inv.Ceilings.CostUSD, + Bank: func(charge modelapi.Charge) { + // THE MACHINE'S SPENDING LEDGER, one row per call, written here and + // nowhere else: nothing else in this process meters these calls. + line := session.UsageLine{ + Model: charge.Model, Calls: 1, Input: charge.TokensIn, Output: charge.TokensOut, USD: charge.CostUSD, + Reconciled: charge.Late, Workspace: inv.Workspace, Task: subject, + } + session.RecordUsage(ledger, session.TagUsage(line, roles.RoleWorker, session.SeatWorker)) + view.call(charge) + if ceiling := inv.Ceilings.CostUSD; ceiling > 0 && charge.Spent >= ceiling { + limited.Store(true) + cut() + } + }, + Unbilled: func(model string) { + session.RecordUnbilledCall(ledger, session.TagUsage(session.UsageLine{Model: model, Workspace: inv.Workspace, Task: subject}, roles.RoleWorker, session.SeatWorker)) + }, + // THE LAST CALL'S PRICE IS WAITED FOR, AND THE PERSON IS TOLD WHY. A + // run stopped by ctrl-c or its own ceiling is usually in the middle of + // a call, priced by a receipt about twenty seconds later; this process + // used to exit first, and that call never reached the ledger. + Settling: func(owed int) { fmt.Fprintln(carriedStderr, carriedSettlingLine(owed)) }, + Role: lanes.RoleLeafAttached, + Node: inv.Program.Name, + }) + if err != nil { + return err + } + defer func() { _ = api.Close() }() + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("find codeaf's own executable to run %s: %w", inv.Program.Name, err) + } + here, err := os.Getwd() + if err != nil { + return fmt.Errorf("read the folder %s was started in: %w", inv.Program.Name, err) + } + grace := carriedGrace + if grace <= 0 { + grace = delegate.DefaultGrace + } + // CTRL-C IS A STOP, SAID AS ONE. The program is sent SIGTERM and given the + // grace to write how it ended; the person is told that much at once rather + // than left watching a terminal that has gone quiet for fifteen seconds. + untell := context.AfterFunc(ctx, func() { + fmt.Fprintf(carriedStderr, "stopping %s: it has %s to say how it ended\n", inv.Program.Name, grace) + }) + view.begin() + // THE PROGRAM'S OWN CLOCK is written in its record folder, as a chat's + // run writes it in the task's: the instant its process was started and + // the instant it was gone (delegate.ProgramRecord). + started := time.Now() + view.opened(started) + result, runErr := delegate.Run(runCtx, delegate.Launch{ + Name: inv.Program.Name, + Bin: exe, + Args: carriedInFolder(carriedChildLine(inv), inv, folder), + // NO KEY REACHES THE PROGRAM (delegate.ChildEnv): the API's address and + // token are the whole of what it is given. + Env: delegate.ChildEnv(api.API()), + Dir: here, + StderrPath: filepath.Join(record, carriedStderrName), + Grace: grace, + }, view) + // THE INSTANT THE PROCESS WAS GONE, not the instant its stdout drained, for + // both the last line and the record's end: a helper the program left holding + // stdout kept the launch open for up to the grace after the exit, and the + // same program read that much longer here than in a conversation, whose + // worker reads it this way ([delegate.Result.ExitedAt]). + ended := result.ExitedAt(started, time.Now()) + untell() + // THE FOLDER IS FINISHED THE MOMENT THE PROGRAM HAS GONE, before its last + // prices are waited for. That wait is up to seventy seconds, a second + // ctrl-c during it leaves at once, and a folder finished after it was a + // folder left on the program's branch with its leftovers uncommitted and + // nothing said, for the next run to find. Nothing in the finishing needs + // the API: the program's ending is read off its last record, and its + // process — its whole group, on a stop — is already gone. + view.closed(ended) + finish(view.endingWords()) + // The program has exited: its API goes with it, so nothing it left behind + // can spend, and every row it cost is on disk before this process leaves — + // the close waits for the price of a call the stop cut in the middle. + _ = carriedAPIClose(api) + session.CloseUsage() + return view.end(result, runErr, limited.Load(), api.Spent(), ended.Sub(started)) +} + +// carriedAPIClose closes a shell run's model API, which waits for the price +// of a call a stop cut short; a variable so a test can see what is already +// done by the time that wait begins. +var carriedAPIClose = (*modelapi.Server).Close + +// carriedFolder readies the folder a shell run's program works in +// (internal/session's PrepareProgramFolder); nil for a program that edits no +// files, which reads the folder where it is. +func carriedFolder(inv *delegate.Invocation, record string, signModel string) (*session.ProgramFolder, error) { + if !inv.Program.LandsTree() { + return nil, nil + } + return session.PrepareProgramFolder(session.ProgramFolderOrder{ + Program: inv.Program, Dir: inv.Workspace, Brief: inv.Brief(), + Holder: "a run started at a shell", Keep: record, SignModel: signModel, + Instead: "run it in the project's folder, or name that folder with --dir", + }) +} + +// carriedInFolder puts on a shell run's child line what codeaf decided about +// its folder, after --json and before the person's own words: the folder +// itself when it is not the one the line names (a folder inside a repository +// is worked in at the repository's root, and the person's own --dir is taken +// off so it cannot win), and the program's own flags for a folder worked in +// without git ([delegate.Delegate.PlainFolder]). +func carriedInFolder(child []string, inv *delegate.Invocation, folder *session.ProgramFolder) []string { + if folder == nil { + return child + } + at := slices.Index(child, "--json") + if at < 0 { + return child + } + head, rest := append([]string(nil), child[:at+1]...), child[at+1:] + if folder.Dir != inv.Workspace { + flags, words := rest[:len(rest)-len(inv.Args)], rest[len(rest)-len(inv.Args):] + kept := make([]string, 0, len(flags)) + for i := 0; i < len(flags); i++ { + switch flag := flags[i]; { + case flag == "--dir" || flag == "-dir": + i++ + case strings.HasPrefix(flag, "--dir=") || strings.HasPrefix(flag, "-dir="): + default: + kept = append(kept, flag) + } + } + head = append(head, "--dir", folder.Dir) + rest = append(kept, words...) + } + if folder.Plain() { + head = append(head, inv.Program.PlainFolder...) + } + return append(head, rest...) +} + +// carriedSettlingLine is what a shell run says while it waits for the +// receipts still owed on the calls its ending cut short, bounded by the +// provider's own schedule (provider.ReceiptWait). +func carriedSettlingLine(owed int) string { + calls := "1 call that was" + if owed != 1 { + calls = strconv.Itoa(owed) + " calls that were" + } + return "waiting up to " + reltime.Elapsed(provider.ReceiptWait) + " for the price of " + calls + " cut short" +} + +// carriedStderrName is the file a shell run keeps its program's stderr in, +// beside the conversation log — the name the chat's run keeps it under too. +const carriedStderrName = "delegate-stderr.log" + +// carriedRecordDir is a shell run's record folder: the conversation log, the +// action log, the program record and the program's stderr, under this machine's state root +// where a person can open them after the lines have scrolled away. It has no +// task page to live beside, so it has a folder of its own, one per run. +func carriedRecordDir(name string) string { + return filepath.Join(carriedRecordRoot(name), time.Now().Format("20060102-150405.000000")) +} + +// carriedRecordRoot is the folder every shell run of one program keeps its +// record under, one folder per run. +func carriedRecordRoot(name string) string { + return home.Join("v3", "carried", name) +} + +// carriedChildLine is the line a shell run starts its child with: THE +// PERSON'S OWN LINE, with --json added after the command word. It is not +// delegate.ChildArgs, which is the chat's line — the default command and the +// shared flags only — because a person at a shell may name another command or +// give the command a flag of its own, and a line rebuilt from the parsed +// invocation would silently drop both. The child reads this line with the same +// parser the host just read it with, from the same folder ([runCarriedHost] +// starts it where it was started), so it arrives at the same invocation. +func carriedChildLine(inv *delegate.Invocation) []string { + line := append([]string(nil), inv.Line...) + head := []string{inv.Program.Name} + if len(line) > 0 { + if _, named := inv.Program.Command(line[0]); named { + head, line = append(head, line[0]), line[1:] + } + } + head = append(head, "--json") + return append(head, line...) +} + +// carriedExit is an ending on the exit ladder: the work stands, a limit you +// set stopped it, or it ran and did not finish. +func carriedExit(status string) error { + switch status { + case delegate.StatusPass: + return exitDone + case delegate.StatusBudget: + return exitLimit + default: + return exitIncomplete + } +} + +// ── the front page ────────────────────────────────────────────────────────── + +// carriedHeading heads the group `codeaf --help` lists the carried programs +// under, in the table's own register: what the group is, a dash, the one thing +// a reader needs to know about all of it. It names no machinery: a person +// reads the program's own name, never the word the code calls it by. +const carriedHeading = "Hand it a whole task — a program codeaf carries does it on its own" + +// carriedSynopsis is the shape every carried program's line takes: the brief, +// and the folder and the two ceilings codeaf puts on every one of them +// (delegate.Parse). It is ONE LINE ON PURPOSE: the front page was cut to fit a +// screen and a bit, and a program costs it two lines — this and its summary. +// `--json`, the program's own commands and their flags are its `--help`. +const carriedSynopsis = `"" [--dir dir] [--max-cost usd] [--max-hours h]` + +// carriedGroup is the group `codeaf --help` gives the programs a build +// carries: one line per program in the table's shape, its summary under it. +// A build that carries none gets no group at all — not a heading over nothing +// — which is every Windows build. +func carriedGroup(programs []delegate.Delegate) string { + if len(programs) == 0 { + return "" + } + lines := []string{carriedHeading} + for _, program := range programs { + lines = append(lines, foldSynopsis("codeaf "+program.Name+" "+carriedSynopsis)...) + indent := strings.Repeat(" ", helpTextColumn) + for _, line := range wrapAt(program.Summary, helpWidth-helpTextColumn) { + lines = append(lines, indent+line) + } + } + // A program's own commands and flags are its `--help`, which the page's + // last line already names for every command; saying it again here would be + // a line of the capped page spent on a sentence the reader has. + return strings.Join(lines, "\n") +} + +// synopsisFold is the column a folded synopsis continues at: under the verb, +// so the flags stay one column, which is where the table folds every other +// command's (main.go's layout law). +const synopsisFold = 14 + +// foldSynopsis writes one command's synopsis at column 2 and folds it, when it +// must, at [synopsisFold], so no line draws wider than [helpWidth]. +func foldSynopsis(synopsis string) []string { + words := strings.Fields(synopsis) + if len(words) == 0 { + return nil + } + lines := []string{" " + words[0]} + for _, word := range words[1:] { + last := len(lines) - 1 + if len(lines[last])+1+len(word) > helpWidth { + lines = append(lines, strings.Repeat(" ", synopsisFold)+word) + continue + } + lines[last] += " " + word + } + return lines +} + +// frontPage is `codeaf --help` as it is printed: the one table, with the +// programs this build carries listed as a group of their own right after the +// work you hand it — they are work you hand it, the whole of a task. The table +// itself stays one constant ([usageText]) so every per-command page is still a +// reading of it; the group is read from the build's list at the moment of +// printing, because that list is what the build carries. +func frontPage() string { + group := carriedGroup(builtin.All()) + if group == "" { + return usageText + } + const after = "\nLook at what happened" + at := strings.Index(usageText, after) + if at < 0 { + return usageText + "\n\n" + group + } + return usageText[:at] + "\n" + group + "\n" + usageText[at:] +} + +// ── what a person at the shell sees ───────────────────────────────────────── + +// carriedView is a shell run's delegate.Sink and its call line: the stage as +// it changes, each step, each model call, and the ending, as lines a person +// reads — or, with --json, the program's records passed through as records. +// The reader's goroutine, the API's calls and the host itself all write here, +// so every write is taken under one lock. +type carriedView struct { + mu sync.Mutex + out io.Writer + inv *delegate.Invocation + record string + records *delegate.Emitter + stage string + status string + calls int + terminal *delegate.Terminal + // program is the run's program record as it stands, rewritten whole in + // the record folder each time it learns something: its start, its hello, + // its end. + program delegate.ProgramRecord + // folder is the folder the run was readied in, and leftFolder is how the + // run left it (internal/session's ProgramFolderEnd.Sentence); nil and + // empty for a program that edits no files. + folder *session.ProgramFolder + leftFolder string +} + +func newCarriedView(out io.Writer, inv *delegate.Invocation, record string) *carriedView { + view := &carriedView{out: out, inv: inv, record: record} + if inv.JSON { + view.records = delegate.NewEmitter(out) + } + return view +} + +// begin says what is starting, where. +func (v *carriedView) begin() { + if v.records != nil { + return + } + v.say("%s · working in %s", v.inv.Program.Name, v.where()) +} + +// inFolder keeps the folder the run was readied in, for the line that says +// where it works. +func (v *carriedView) inFolder(folder *session.ProgramFolder) { + v.mu.Lock() + defer v.mu.Unlock() + v.folder = folder +} + +// where is the folder the program works in, as the first line says it: on its +// own branch when codeaf cut one. +func (v *carriedView) where() string { + v.mu.Lock() + defer v.mu.Unlock() + if v.folder == nil { + return v.inv.Workspace + } + if v.folder.Plain() { + return v.folder.Dir + } + return v.folder.Dir + ", on its own branch " + v.folder.Branch +} + +// left keeps how the run left its folder, for the lines that end the run. +func (v *carriedView) left(sentence string) { + v.mu.Lock() + defer v.mu.Unlock() + v.leftFolder = sentence +} + +// endingWords is the program's ending in the one sentence a person reads +// ([carriedEnding]), the body of the commit that holds what it left. +func (v *carriedView) endingWords() string { + terminal, _ := v.ending() + if terminal == nil { + return "" + } + return carriedEnding(v.inv.Program.Name, *terminal) +} + +func (v *carriedView) Hello(h delegate.Hello) { + v.remember(func(record *delegate.ProgramRecord) { record.Stages = h.Stages }) + if v.records != nil { + _ = v.records.Hello(v.inv.Program.Name, h.Stages) + } +} + +// opened writes the program record the moment the program's process is +// started: whose run it is, its ceiling, and when it began. +func (v *carriedView) opened(at time.Time) { + v.remember(func(record *delegate.ProgramRecord) { + record.Name, record.CeilingUSD, record.StartedAt = v.inv.Program.Name, v.inv.Ceilings.CostUSD, at + }) +} + +// closed writes the instant the program's process was gone. +func (v *carriedView) closed(at time.Time) { + v.remember(func(record *delegate.ProgramRecord) { record.EndedAt = at }) +} + +// remember changes the program record and writes it whole. It is a record, so +// a disk that refuses it costs the record and never the run. +func (v *carriedView) remember(change func(record *delegate.ProgramRecord)) { + v.mu.Lock() + defer v.mu.Unlock() + change(&v.program) + if v.program.Name == "" { + v.program.Name = v.inv.Program.Name + } + _ = delegate.WriteProgram(v.record, v.program) +} + +// kept writes one received record to the run's action log in its record +// folder, stamped with the moment it arrived — the same log a chat's run keeps +// beside its task (delegate.ActionsFile), so the two roads leave one record. It +// is a record, so a disk that refuses it costs the record and never the run. +func (v *carriedView) kept(action delegate.Action) { + if strings.TrimSpace(v.record) == "" { + return + } + _ = delegate.AppendAction(v.record, action) +} + +func (v *carriedView) Stage(record delegate.StageRecord) { + v.kept(delegate.StageAction(time.Now(), record)) + if v.records != nil { + _ = v.records.Stage(record) + return + } + stage, status := record.Stage, record.Status + if !v.moved(stage, status) { + return + } + if status == "" { + v.say("%s", stage) + return + } + v.say("%s · %s", stage, status) +} + +// moved takes the program's new phase and answers whether it is a change: a +// stage said twice is one line, not two. +func (v *carriedView) moved(stage, status string) bool { + v.mu.Lock() + defer v.mu.Unlock() + changed := stage != v.stage || status != v.status + v.stage, v.status = stage, status + return changed +} + +func (v *carriedView) Step(record delegate.StepRecord) { + v.kept(delegate.StepAction(time.Now(), record)) + if v.records != nil { + _ = v.records.Step(record) + return + } + if head := firstLineOf(record.Observation); head != "" { + v.say(" %s · %s", record.Command, head) + return + } + v.say(" %s", record.Command) +} + +func (v *carriedView) Terminal(t delegate.Terminal) { + v.keep(t) + v.kept(delegate.EndAction(time.Now(), t)) + if v.records == nil { + return + } + // THE RECORD PASSES THROUGH AS THE PROGRAM WROTE IT: its data travels whole, + // every key the program put there, in the one terminal this stdout carries. + extra := make(map[string]any, len(t.Data)) + for key, value := range t.Data { + extra[key] = value + } + _ = v.records.Terminal(delegate.Ending{Status: t.Status, Message: t.Message, Extra: extra}) +} + +// keep holds the program's ending for the run's last lines. +func (v *carriedView) keep(t delegate.Terminal) { + v.mu.Lock() + defer v.mu.Unlock() + v.terminal = &t +} + +// counted counts one metered call. +func (v *carriedView) counted() { + v.mu.Lock() + defer v.mu.Unlock() + v.calls++ +} + +// ending is the program's ending and how many calls it made. +func (v *carriedView) ending() (*delegate.Terminal, int) { + v.mu.Lock() + defer v.mu.Unlock() + return v.terminal, v.calls +} + +// call is one metered model call: `model · N in · N out · $X`, with whatever +// nobody measured left off rather than written as a zero. +func (v *carriedView) call(charge modelapi.Charge) { + v.counted() + if v.records != nil { + return + } + parts := []string{charge.Model} + if charge.Model == "" { + parts[0] = "model" + } + if charge.TokensIn > 0 { + parts = append(parts, strconv.Itoa(charge.TokensIn)+" in") + } + if charge.TokensOut > 0 { + parts = append(parts, strconv.Itoa(charge.TokensOut)+" out") + } + if charge.CostUSD > 0 { + parts = append(parts, carriedDollars(charge.CostUSD)) + } + v.say(" %s", strings.Join(parts, " · ")) +} + +// end says how the run ended and answers its rung on the exit ladder. took is +// how long the program's process ran. +func (v *carriedView) end(result delegate.Result, runErr error, limited bool, spent float64, took time.Duration) error { + terminal, calls := v.ending() + name := v.inv.Program.Name + if terminal == nil && result.ExitCode < 0 && !result.Stopped && runErr != nil && !errors.Is(runErr, delegate.ErrNoTerminal) { + // IT NEVER RAN: the process could not be started at all, which is the + // first rung of the ladder rather than work that did not finish. + fmt.Fprintln(os.Stderr, "error:", runErr) + return exitCannotRun + } + status := delegate.StatusCrashed + if terminal != nil { + status = terminal.Status + if !delegate.KnownStatus(status) { + status = delegate.StatusCrashed + } + } + if limited { + status = delegate.StatusBudget + } + if v.records != nil { + // THE RECORDS ARE THE PROGRAM'S, so where its folder was left goes to + // stderr beside them rather than into them. + if said := v.folderLine(); said != "" { + fmt.Fprintln(carriedStderr, said) + } + return carriedExit(status) + } + switch { + case limited: + line := name + " was stopped at a limit you set" + if terminal != nil && terminal.Message != "" { + line += ": " + terminal.Message + } + v.say("%s", line) + case terminal == nil: + line := fmt.Sprintf("%s exited %d without saying how it ended", name, result.ExitCode) + if result.Reading.LastStage != "" { + line += "; its last stage was " + result.Reading.LastStage + } + v.say("%s", line) + default: + v.say("%s", carriedEnding(name, *terminal)) + if claim := terminal.Claim(); claim != "" { + v.say(" %s's model said: %s", name, claim) + } + if observed := terminal.Observed(); observed != "" { + v.say(" %s observed: %s", name, observed) + } + } + // WHERE THE WORK IS comes after how the run ended: its branch, checked out + // in the folder, and how to go back — the sentence a conversation's run + // says on its page. + if said := v.folderLine(); said != "" { + v.say(" %s", said) + } + // The folder the run's record is in comes before the last line, so that + // line is always what the run came to. + if _, err := os.Stat(v.record); err == nil { + v.say(" the run's record is in %s", v.record) + } + // THE LAST LINE IS WHAT THE RUN CAME TO: its calls, its dollars and how + // long the program ran, each left off rather than written as a zero. It is + // last because the manual says so and a person reading `tail -1` is told + // so; the record folder's line, which every real run has, used to follow it. + var summary []string + if calls > 0 { + word := "calls" + if calls == 1 { + word = "call" + } + summary = append(summary, fmt.Sprintf("%d model %s", calls, word)) + if spent > 0 { + summary = append(summary, carriedDollars(spent)) + } + } + if took >= time.Second { + summary = append(summary, reltime.Elapsed(took)) + } + if len(summary) > 0 { + v.say(" %s", strings.Join(summary, " · ")) + } + return carriedExit(status) +} + +// folderLine is how the run left its folder, "" for a program that edits no +// files. +func (v *carriedView) folderLine() string { + v.mu.Lock() + defer v.mu.Unlock() + return v.leftFolder +} + +// carriedEnding is the ending in one sentence, in the program's own words +// after the one that says which of the four it was. +func carriedEnding(name string, terminal delegate.Terminal) string { + message := strings.TrimSpace(terminal.Message) + var said string + switch terminal.Status { + case delegate.StatusPass: + said = name + " finished" + case delegate.StatusBudget: + said = name + " stopped at its ceiling" + case delegate.StatusFail: + said = name + " did not finish" + default: + said = name + " crashed" + } + if message == "" { + return said + } + return said + ": " + message +} + +func (v *carriedView) say(format string, args ...any) { + v.mu.Lock() + defer v.mu.Unlock() + fmt.Fprintf(v.out, format+"\n", args...) +} + +// carriedDollars writes an amount in cents, and to four places under a cent +// so one cheap call is not written as nothing. +func carriedDollars(amount float64) string { + if amount < 0.01 { + return fmt.Sprintf("$%.4f", amount) + } + return fmt.Sprintf("$%.2f", amount) +} + +// firstLineOf is the first line of a text, trimmed and cut to a row's width. +func firstLineOf(text string) string { + line, _, _ := strings.Cut(strings.TrimSpace(text), "\n") + line = strings.TrimSpace(line) + if runes := []rune(line); len(runes) > 100 { + line = string(runes[:100]) + "…" + } + return line +} diff --git a/cmd/codeaf/carried_child_test.go b/cmd/codeaf/carried_child_test.go new file mode 100644 index 0000000000..990cd85f84 --- /dev/null +++ b/cmd/codeaf/carried_child_test.go @@ -0,0 +1,148 @@ +package main + +// The program this package's tests carry, and the door a shell run's REAL +// child comes in by: carried_host_test.go starts this very test binary as the +// program's process, exactly as a shell run starts codeaf's own executable — +// the program's line after it, the model API's address and token in its +// environment and no key — marked by [carriedChildEnv], and TestMain then runs +// the whole dispatch (`execute`) with the fake program on the build's list. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" +) + +// carriedChildEnv marks a process started as a shell run's child. +const carriedChildEnv = "CODEAF_TEST_CARRIED_CHILD" + +// fakeCarried is the fake program's name. It is a word no verb of codeaf's +// own is spelled with, which carried_test.go's collision law holds it to. +const fakeCarried = "fake-carried" + +// fakeCarriedProgram is a program with a command flag of its own: `--calls` +// questions to the model API, a step for each answer, and a passing ending — +// or, with `--wait`, it waits to be stopped and says it was. +func fakeCarriedProgram() delegate.Delegate { + return delegate.Delegate{ + Name: fakeCarried, Summary: "a program the tests carry, which asks its model a question or two", Default: "run", Page: "delegates", + Guide: "For the tests' questions to a model, with a brief that is the question.", + Commands: []delegate.Command{{ + Name: "run", Usage: "[flags] -- ", Summary: "does the whole task", + Bind: func(fs *flag.FlagSet) delegate.Body { + calls := fs.Int("calls", 1, "how many questions to ask the model") + wait := fs.Bool("wait", false, "wait to be stopped after the questions") + linger := fs.Duration("linger", 0, "leave a helper holding stdout this long after the program exits") + return func(ctx context.Context, host delegate.Host, args []string) error { + host.Hello([]string{"implement", "verify"}) + host.Stage(delegate.StageRecord{Stage: "implement", Status: "running"}) + for call := 1; call <= *calls && ctx.Err() == nil; call++ { + reply, err := askCarried(ctx, host.Models(), fmt.Sprintf("question %d: %s", call, strings.Join(args, " "))) + if err != nil { + host.Step(delegate.StepRecord{Command: "model: ask", Observation: "refused: " + err.Error()}) + continue + } + host.Step(delegate.StepRecord{Command: "model: ask", Observation: reply}) + } + if *wait || ctx.Err() != nil { + <-ctx.Done() + host.Terminal(delegate.Ending{Status: delegate.StatusFail, Message: "stopped before it finished"}) + return nil + } + host.Stage(delegate.StageRecord{Stage: "verify", Status: "pass"}) + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "submitted and verified", Claim: "the test is fixed", Observed: "pass"}) + if *linger > 0 { + // A detached helper that inherited stdout and outlives the + // program, which keeps the launch draining after the exit. + helper := exec.Command("sleep", fmt.Sprintf("%g", linger.Seconds())) + helper.Stdout = os.Stdout + _ = helper.Start() + } + return nil + } + }, + }, { + Name: "check", Usage: "", Summary: "says whether it could run", + Bind: func(*flag.FlagSet) delegate.Body { + return func(ctx context.Context, host delegate.Host, args []string) error { + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "it could run"}) + return nil + } + }, + }}, + } +} + +// askCarried is one question through the model API, as any OpenAI client asks +// one. +func askCarried(ctx context.Context, api delegate.ModelAPI, question string) (string, error) { + body, _ := json.Marshal(map[string]any{ + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "messages": []map[string]string{{"role": "user", "content": question}}, + }) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, modelapi.ChatURL(api.BaseURL), bytes.NewReader(body)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + api.Authorize(request) + response, err := http.DefaultClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + payload, _ := io.ReadAll(response.Body) + var answer struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(payload), &answer); err != nil { + return "", fmt.Errorf("%d: %s", response.StatusCode, payload) + } + if answer.Error != nil { + return "", errors.New(answer.Error.Message) + } + if len(answer.Choices) == 0 { + return "", errors.New("no answer") + } + return answer.Choices[0].Message.Content, nil +} + +// runAsCarriedChild runs the dispatch when this binary was started as a shell +// run's child, and says whether it was: with the fake program carried when +// the mark is "1", the fake that works in its folder when it is "folder" +// (carried_folder_test.go), and with the build's own list — senior-dev itself +// — when it is "real". +func runAsCarriedChild() (int, bool) { + switch os.Getenv(carriedChildEnv) { + case "1": + restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()}) + defer restore() + return execute(), true + case "folder": + restore := builtin.Override([]delegate.Delegate{fakeFolderProgram()}) + defer restore() + return execute(), true + case "real": + return execute(), true + } + return 0, false +} diff --git a/cmd/codeaf/carried_folder_test.go b/cmd/codeaf/carried_folder_test.go new file mode 100644 index 0000000000..caea76f56d --- /dev/null +++ b/cmd/codeaf/carried_folder_test.go @@ -0,0 +1,277 @@ +//go:build !windows + +package main + +// A SHELL RUN WORKS IN ITS FOLDER THE WAY A CONVERSATION'S RUN DOES +// (internal/session's programfolder.go): a plain folder is worked in as it is +// with the program told so on its line — where it used to end at once with +// "workspace is not a git repository" — a repository gets a branch of its own +// that is left checked out with the work committed on it, and a checkout with +// changes that are not committed is refused before anything is spent. + +import ( + "bytes" + "context" + "flag" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/session" +) + +// fakeFolder is the name of the fake that works in its folder. +const fakeFolder = "fake-folder" + +// fakeFolderProgram is a program that edits files the way senior-dev does: +// it says whether it was told it works without git, writes one file of work +// and one of its own notes into the folder it was handed, and passes. +func fakeFolderProgram() delegate.Delegate { + return delegate.Delegate{ + Name: fakeFolder, Summary: "a program the tests carry, which writes a file where it is told to work", Default: "run", Page: "delegates", + Guide: "For the tests' folder work, with a brief that names the file.", + PlainFolder: []string{"--in-place"}, + Notes: ".fake-folder", + Commands: []delegate.Command{{ + Name: "run", Usage: "[flags] -- ", Summary: "does the whole task", + Bind: func(fs *flag.FlagSet) delegate.Body { + inPlace := fs.Bool("in-place", false, "work without git") + return func(ctx context.Context, host delegate.Host, args []string) error { + host.Hello([]string{"implement"}) + mode := "git" + if *inPlace { + mode = "in place" + } + host.Step(delegate.StepRecord{Command: "folder", Observation: mode}) + if err := os.WriteFile(filepath.Join(host.Workspace(), "made.txt"), []byte("made\n"), 0o644); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(host.Workspace(), ".fake-folder"), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(host.Workspace(), ".fake-folder", "spec.md"), []byte(strings.Join(args, " ")+"\n"), 0o644); err != nil { + return err + } + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "made it"}) + return nil + } + }, + }}, + } +} + +// hostWithFolderChild is [hostWithRealChild] for the fake that works in its +// folder, and it answers what the run printed on stdout and on stderr. +func hostWithFolderChild(t *testing.T) (*carriedFunnel, *lockedBuffer, *lockedBuffer) { + t.Helper() + restore := builtin.Override([]delegate.Delegate{fakeFolderProgram()}) + t.Cleanup(restore) + t.Setenv(carriedChildEnv, "folder") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + calling := &carriedFunnel{cost: 0.001} + previousRoad, previousOut, previousErr, previousGrace := carriedModels, carriedStdout, carriedStderr, carriedGrace + carriedModels = func() (carriedRoad, error) { + return carriedRoad{completerFor: calling.completerFor, seat: "seat/model"}, nil + } + printed, said := &lockedBuffer{}, &lockedBuffer{} + carriedStdout, carriedStderr = printed, said + carriedGrace = 5 * time.Second + t.Cleanup(func() { + carriedModels, carriedStdout, carriedStderr, carriedGrace = previousRoad, previousOut, previousErr, previousGrace + }) + return calling, printed, said +} + +// shellRepo is a repository with one commit on `main`, the way a person's +// project stands. +func shellRepo(t *testing.T) string { + t.Helper() + repo := t.TempDir() + for _, args := range [][]string{ + {"init", "-q", "-b", "main"}, + {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "first"}, + } { + shellGit(t, repo, args...) + } + return repo +} + +// shellGit runs one git command in dir and answers what it printed. +func shellGit(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + out, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)) +} + +// A SHELL RUN IN A PLAIN FOLDER NO LONGER FAILS: the program is told on its +// line that it works without git, its work is left in the folder, its notes +// are moved into the run's record folder, and the last lines say so. +func TestAShellRunInAPlainFolderIsToldSoAndDoesNotFail(t *testing.T) { + _, printed, _ := hostWithFolderChild(t) + folder := t.TempDir() + err := runCarried(fakeFolderProgram(), []string{"--dir", folder, "make a file"}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed) + } + out := printed.String() + if !strings.Contains(out, " folder · in place") { + t.Fatalf("the program was not told it works without git:\n%s", out) + } + if _, err := os.Stat(filepath.Join(folder, "made.txt")); err != nil { + t.Fatalf("the work is not in the folder: %v", err) + } + if _, err := os.Stat(filepath.Join(folder, ".git")); !os.IsNotExist(err) { + t.Fatalf("a plain folder was made a repository: %v", err) + } + record := newestRecordOf(t, fakeFolder) + if _, err := os.Stat(filepath.Join(record, fakeFolder, "spec.md")); err != nil { + t.Fatalf("the program's notes are not in the run's record folder: %v", err) + } + if _, err := os.Stat(filepath.Join(folder, ".fake-folder")); !os.IsNotExist(err) { + t.Fatalf("the program's notes were left in the folder: %v", err) + } + want := " its work is in " + folder + ", which has no git history, so nothing was committed; its notes (.fake-folder/) are kept in " + filepath.Join(record, fakeFolder) + if !strings.Contains(out, want) { + t.Fatalf("the last lines do not say where the work is:\n%s\nwant %q", out, want) + } +} + +// A SHELL RUN IN A REPOSITORY WORKS ON A BRANCH OF ITS OWN, and the person's +// branch never moves: the program's work is committed on its branch, which is +// left checked out, and the last lines say how to go back. +func TestAShellRunInARepositoryWorksOnABranchOfItsOwn(t *testing.T) { + _, printed, _ := hostWithFolderChild(t) + repo := shellRepo(t) + base := shellGit(t, repo, "rev-parse", "main") + err := runCarried(fakeFolderProgram(), []string{"--dir", repo, "make a file"}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed) + } + out := printed.String() + branch := shellGit(t, repo, "branch", "--show-current") + if !strings.HasPrefix(branch, "task/make-a-file-") { + t.Fatalf("the checkout is on %q, want the run's own branch left checked out", branch) + } + if !strings.Contains(out, fakeFolder+" · working in "+repo+", on its own branch "+branch) || !strings.Contains(out, " folder · git") { + t.Fatalf("the run did not say it works on its own branch with git:\n%s", out) + } + if tip := shellGit(t, repo, "rev-parse", "main"); tip != base { + t.Fatalf("the person's branch moved from %s to %s", base, tip) + } + if files := shellGit(t, repo, "ls-tree", "--name-only", branch); files != "made.txt" { + t.Fatalf("the run's branch holds %q, want its work and none of its notes", files) + } + if subject := shellGit(t, repo, "log", "-1", "--format=%s", branch); subject != "make a file" { + t.Fatalf("the commit of its work is %q, want the brief's words", subject) + } + if status := shellGit(t, repo, "status", "--porcelain"); status != "" { + t.Fatalf("the run left changes that are not committed:\n%s", status) + } + if !strings.Contains(out, " its work is on the branch "+branch+" in "+repo+", 1 file, and that branch is checked out there; your branch main is as it was") { + t.Fatalf("the last lines do not say where the work is:\n%s", out) + } +} + +// A SHELL RUN ON A CHECKOUT WITH CHANGES THAT ARE NOT COMMITTED IS REFUSED +// before anything is started or spent, with the paths named. +func TestAShellRunIsRefusedACheckoutWithChangesThatAreNotCommitted(t *testing.T) { + calling, printed, said := hostWithFolderChild(t) + repo := shellRepo(t) + if err := os.WriteFile(filepath.Join(repo, "draft.md"), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + err := runCarried(fakeFolderProgram(), []string{"--dir", repo, "make a file"}) + if code := exitCodeOf(err); code != int(exitCannotRun) { + t.Fatalf("left with %d, want the rung for a run that could not start", code) + } + if want := "error: " + repo + " has changes that are not committed (draft.md); commit or stash them, then ask again"; !strings.Contains(said.String(), want) { + t.Fatalf("the refusal = %q, want %q", said.String(), want) + } + if len(calling.seen()) != 0 || printed.String() != "" { + t.Fatalf("a refused run did something: %d calls, printed %q", len(calling.seen()), printed.String()) + } + if branch := shellGit(t, repo, "branch", "--show-current"); branch != "main" { + t.Fatalf("a refused checkout was switched to %q", branch) + } +} + +// A SHELL RUN'S CHILD IS TOLD WHAT CODEAF DECIDED ABOUT ITS FOLDER: the +// program's own flags for a folder without git, and the repository's root in +// place of the person's --dir when that named a folder inside it. +func TestAShellRunsChildLineCarriesItsFolder(t *testing.T) { + program := fakeFolderProgram() + inv, err := delegate.Parse(program, []string{"--dir", "/r/repo/sub", "fix", "it"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + child := carriedInFolder(carriedChildLine(inv), inv, &session.ProgramFolder{Dir: "/r/repo", Branch: "task/fix-it-abc123"}) + if got, want := strings.Join(child, " "), fakeFolder+" --json --dir /r/repo fix it"; got != want { + t.Fatalf("the child line = %q, want %q", got, want) + } + plain, err := delegate.Parse(program, []string{"--dir", "/r/plain", "fix", "it"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + child = carriedInFolder(carriedChildLine(plain), plain, &session.ProgramFolder{Dir: "/r/plain"}) + if got, want := strings.Join(child, " "), fakeFolder+" --json --in-place --dir /r/plain fix it"; got != want { + t.Fatalf("the child line = %q, want %q", got, want) + } + again, err := delegate.Parse(program, child[1:], &bytes.Buffer{}) + if err != nil || again.Workspace != "/r/plain" || again.Brief() != "fix it" { + t.Fatalf("the child reads %+v (%v)", again, err) + } +} + +// newestRecordOf is the most recent shell run's record folder for a program. +func newestRecordOf(t *testing.T, name string) string { + t.Helper() + matches, _ := filepath.Glob(filepath.Join(carriedRecordRoot(name), "*")) + if len(matches) == 0 { + t.Fatal("the shell run kept no record folder") + } + newest := matches[0] + for _, match := range matches[1:] { + if match > newest { + newest = match + } + } + return newest +} + +// A SHELL RUN FINISHES ITS FOLDER BEFORE IT WAITS FOR ITS LAST PRICES. That +// wait is up to seventy seconds, a second ctrl-c during it leaves at once, +// and the folder used to be finished only after it: the repository was left +// on the program's branch with its work uncommitted and nothing said. By the +// time the model API starts closing, the work is committed and the run's +// record says when its program ended. +func TestAShellRunFinishesItsFolderBeforeWaitingForPrices(t *testing.T) { + _, printed, _ := hostWithFolderChild(t) + repo := shellRepo(t) + var atClose struct{ status, files string } + previous := carriedAPIClose + carriedAPIClose = func(api *modelapi.Server) error { + atClose.status = shellGit(t, repo, "status", "--porcelain") + atClose.files = shellGit(t, repo, "ls-tree", "--name-only", "HEAD") + return previous(api) + } + t.Cleanup(func() { carriedAPIClose = previous }) + err := runCarried(fakeFolderProgram(), []string{"--dir", repo, "make a file"}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed) + } + if atClose.status != "" || atClose.files != "made.txt" { + t.Fatalf("when the API began to close the folder held %q uncommitted and %q committed, want its work committed", atClose.status, atClose.files) + } +} diff --git a/cmd/codeaf/carried_host_test.go b/cmd/codeaf/carried_host_test.go new file mode 100644 index 0000000000..f2e78b2913 --- /dev/null +++ b/cmd/codeaf/carried_host_test.go @@ -0,0 +1,259 @@ +//go:build !windows + +package main + +import ( + "bufio" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/session" +) + +// carriedFunnel is the person's model road as a test writes it: every call +// answered with words and billed at cost, the way the provider's decode bills +// one. +type carriedFunnel struct { + mu sync.Mutex + cost float64 + models []string +} + +func (f *carriedFunnel) completerFor(string) modelapi.Completer { return f } + +func (f *carriedFunnel) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + _ = option(&request) + } + f.mu.Lock() + f.models = append(f.models, request.Model) + f.mu.Unlock() + if sink := provider.BillingSinkFrom(ctx); sink != nil { + sink(provider.Billed{Model: request.Model, PromptTokens: 100, CompletionTokens: 10, Cost: f.cost}) + } + return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "answered " + messages[len(messages)-1].Content[0].Text}}}, + FinishReason: "stop", + }}}, nil +} + +func (f *carriedFunnel) seen() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.models...) +} + +// hostWithRealChild carries the fake program, marks the environment so the +// child this test binary starts runs it, and replaces the person's profile +// with a funnel costing cost a call. It answers the funnel and what the shell +// run prints, in a buffer that can be read while the run is still writing it +// (chatv3_host_duty_test.go's lockedBuffer). +func hostWithRealChild(t *testing.T, cost float64) (*carriedFunnel, *lockedBuffer) { + t.Helper() + restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()}) + t.Cleanup(restore) + t.Setenv(carriedChildEnv, "1") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + calling := &carriedFunnel{cost: cost} + previousRoad, previousOut, previousGrace := carriedModels, carriedStdout, carriedGrace + carriedModels = func() (carriedRoad, error) { + return carriedRoad{completerFor: calling.completerFor, seat: "seat/model"}, nil + } + printed := &lockedBuffer{} + carriedStdout = printed + carriedGrace = 5 * time.Second + t.Cleanup(func() { carriedModels, carriedStdout, carriedGrace = previousRoad, previousOut, previousGrace }) + return calling, printed +} + +// ledgerRowsFor is this machine's spending ledger's rows for one workspace, +// once the writers have drained. Other tests in this binary share the ledger, +// and a workspace of this test's own is what tells its rows apart. +func ledgerRowsFor(t *testing.T, workspace string) []session.UsageLine { + t.Helper() + session.FlushUsage() + file, err := os.Open(session.UsageLedgerPath()) + if err != nil { + t.Fatalf("the spending ledger was never written: %v", err) + } + defer file.Close() + var rows []session.UsageLine + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var row session.UsageLine + if json.Unmarshal(scanner.Bytes(), &row) == nil && row.Workspace == workspace { + rows = append(rows, row) + } + } + return rows +} + +// newestRecord is the most recent shell run's record folder for the fake. +func newestRecord(t *testing.T) string { + t.Helper() + matches, _ := filepath.Glob(filepath.Join(carriedRecordRoot(fakeCarried), "*")) + if len(matches) == 0 { + t.Fatal("the shell run kept no record folder") + } + newest := matches[0] + for _, match := range matches[1:] { + if match > newest { + newest = match + } + } + return newest +} + +// A PERSON'S SHELL RUN IS THE SAME TWO PROCESSES A CHAT'S RUN IS: this process +// serves the model API, the program runs as a real child of this executable +// with its own command's flag carried through, every call it makes is +// metered onto this machine's spending ledger once, the conversation is kept +// in the run's record folder, and a person reads the stage, each step, each +// call and the ending as lines. +func TestAShellRunHostsTheModelAPIForARealChildAndPrintsItsWork(t *testing.T) { + calling, printed := hostWithRealChild(t, 0.004) + workspace := t.TempDir() + err := runCarried(fakeCarriedProgram(), []string{"--calls", "2", "--dir", workspace, "fix", "the", "flaky", "test"}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("the shell run left with %d (%v):\n%s", code, err, printed) + } + out := printed.String() + for _, line := range []string{ + fakeCarried + " · working in " + workspace, + "implement · running", + " model: ask · answered question 1: fix the flaky test", + " openrouter/deepseek/deepseek-v4-flash-0731 · 100 in · 10 out · $0.0040", + "verify · pass", + fakeCarried + " finished: submitted and verified", + " " + fakeCarried + "'s model said: the test is fixed", + " 2 model calls · $0.0080", + " the run's record is in ", + } { + if !strings.Contains(out, line) { + t.Fatalf("the shell run never printed %q:\n%s", line, out) + } + } + // THE PERSON'S OWN FLAG REACHED THE PROGRAM: two questions, not one, and + // the ask's `openrouter/` spelling reached the funnel as the ask. + if models := calling.seen(); len(models) != 2 || models[0] != "openrouter/deepseek/deepseek-v4-flash-0731" { + t.Fatalf("the funnel was asked for %q", models) + } + rows := ledgerRowsFor(t, workspace) + if len(rows) != 2 || rows[0].USD != 0.004 || rows[0].Model != "openrouter/deepseek/deepseek-v4-flash-0731" || rows[0].Seat != session.SeatWorker { + t.Fatalf("ledger rows = %+v, want one per call", rows) + } + record := newestRecord(t) + turns, err := delegate.ReadTurns(record, 0) + if err != nil || len(turns) != 2 || turns[1].Reply != "answered question 2: fix the flaky test" || turns[1].CostUSD != 0.004 { + t.Fatalf("the kept conversation = %+v (%v)", turns, err) + } + // AND THE ACTIONS ARE KEPT BESIDE IT, the log a chat's run keeps: every + // stage, step and ending as it arrived. + actions, err := delegate.ReadActions(record, 0) + var kinds []string + for _, action := range actions { + kinds = append(kinds, action.Kind) + } + if err != nil || strings.Join(kinds, ",") != "stage,step,step,stage,end" || actions[4].Message != "submitted and verified" { + t.Fatalf("the kept actions = %v %+v (%v)", kinds, actions, err) + } +} + +// WITH --json THE RECORDS PASS THROUGH AS RECORDS, and nothing a person reads +// is mixed into them: one reader of the protocol reads the host's stdout the +// way it reads a program's. +func TestAShellRunWithJSONPassesTheRecordsThrough(t *testing.T) { + _, printed := hostWithRealChild(t, 0.001) + err := runCarried(fakeCarriedProgram(), []string{"--json", "--dir", t.TempDir(), "fix it"}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("left with %d:\n%s", code, printed) + } + reading, readErr := delegate.Read(strings.NewReader(printed.String()), nil) + if readErr != nil { + t.Fatal(readErr) + } + if reading.Ignored != 0 || reading.Hello == nil || reading.Steps != 1 || reading.Terminal == nil || reading.Terminal.Status != delegate.StatusPass { + t.Fatalf("reading = %+v, want the program's records and nothing else:\n%s", reading, printed) + } + if reading.Terminal.Claim() != "the test is fixed" { + t.Fatalf("the terminal's data did not pass through: %+v", reading.Terminal) + } +} + +// A LIMIT THE PERSON SET STOPS THE PROGRAM FROM OUTSIDE: the dollar ceiling is +// reached by the second metered call, the program is stopped, a third call is +// refused before it is made, and the run leaves on the limit rung. +func TestAShellRunStopsItsProgramAtTheDollarCeiling(t *testing.T) { + calling, printed := hostWithRealChild(t, 0.004) + workspace := t.TempDir() + err := runCarried(fakeCarriedProgram(), []string{"--max-cost", "0.005", "--calls", "4", "--wait", "--dir", workspace, "fix it"}) + if code := exitCodeOf(err); code != int(exitLimit) { + t.Fatalf("left with %d, want the limit rung:\n%s", code, printed) + } + if !strings.Contains(printed.String(), fakeCarried+" was stopped at a limit you set") { + t.Fatalf("the ending does not name the limit:\n%s", printed) + } + if models := calling.seen(); len(models) != 2 { + t.Fatalf("the funnel was asked %d times, want the two calls that reached the ceiling", len(models)) + } + if rows := ledgerRowsFor(t, workspace); len(rows) != 2 { + t.Fatalf("ledger rows = %+v", rows) + } +} + +// A RUN WITH NO BRIEF IS NOT STARTED: nothing is spent, no child is started, +// and the run leaves on the first rung. +func TestAShellRunWithNoBriefStartsNothing(t *testing.T) { + calling, printed := hostWithRealChild(t, 0.001) + err := runCarried(fakeCarriedProgram(), []string{"--dir", t.TempDir()}) + if code := exitCodeOf(err); code != int(exitCannotRun) { + t.Fatalf("left with %d, want the rung for a run that could not start", code) + } + if len(calling.seen()) != 0 || printed.String() != "" { + t.Fatalf("a run with no brief did something: %d calls, printed %q", len(calling.seen()), printed.String()) + } +} + +// CTRL-C IS A STOP: the program is sent SIGTERM, writes how it ended inside +// its grace, and the run leaves as work that did not finish. +func TestAShellRunIsStoppedCleanlyWhenItsContextEnds(t *testing.T) { + _, printed := hostWithRealChild(t, 0.001) + inv, err := delegate.Parse(fakeCarriedProgram(), []string{"--calls", "1", "--wait", "--dir", t.TempDir(), "fix it"}, printed) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + go func() { + // Once the program has asked its one question it is waiting to be + // stopped; that is when a person reaches for ctrl-c. + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) && !strings.Contains(printed.String(), "model: ask") { + time.Sleep(20 * time.Millisecond) + } + cancel() + }() + started := time.Now() + err = runCarriedHost(ctx, inv) + if code := exitCodeOf(err); code != int(exitIncomplete) { + t.Fatalf("left with %d, want the rung for work that did not finish:\n%s", code, printed) + } + if !strings.Contains(printed.String(), fakeCarried+" did not finish: stopped before it finished") { + t.Fatalf("the program's own ending did not arrive inside its grace:\n%s", printed) + } + if time.Since(started) > 8*time.Second { + t.Fatalf("the stop took %s; the program was not stopped by SIGTERM", time.Since(started)) + } +} diff --git a/cmd/codeaf/carried_money_test.go b/cmd/codeaf/carried_money_test.go new file mode 100644 index 0000000000..bab3f15326 --- /dev/null +++ b/cmd/codeaf/carried_money_test.go @@ -0,0 +1,137 @@ +//go:build !windows + +package main + +import ( + "context" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" +) + +// carriedOwingFunnel answers at once with no usage block and owes the call's +// receipt, which lands after a delay — the provider's own order: owed before +// the fetch, answered after the sink has the money. +type carriedOwingFunnel struct{ late time.Duration } + +func (f carriedOwingFunnel) completerFor(string) modelapi.Completer { return f } + +func (f carriedOwingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + _ = option(&request) + } + done := provider.ReceiptPendingFrom(ctx)() + sink := provider.ReconcileSinkFrom(ctx) + go func() { + defer done() + time.Sleep(f.late) + sink(provider.Reconciled{Billed: provider.Billed{Model: request.Model, PromptTokens: 52139, CompletionTokens: 4895, Cost: 0.058188488}, Found: true}) + }() + return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "cut short"}}}, + FinishReason: "stop", + }}}, nil +} + +// A SHELL RUN WAITS FOR THE PRICE OF ITS LAST CALL, SAYS SO, AND FILES IT. The +// call's receipt lands after the program has exited; the run waits for it +// before its last lines and before this process's ledger closes, tells the +// person why on stderr, files the row under the run, and keeps the program's +// own clock in its record folder. +func TestAShellRunWaitsForItsLastCallsPriceAndKeepsItsClock(t *testing.T) { + _, printed := hostWithRealChild(t, 0) + carriedModels = func() (carriedRoad, error) { + return carriedRoad{completerFor: carriedOwingFunnel{late: 300 * time.Millisecond}.completerFor, seat: "seat/model"}, nil + } + told := &lockedBuffer{} + previous := carriedStderr + carriedStderr = told + t.Cleanup(func() { carriedStderr = previous }) + workspace := t.TempDir() + before := time.Now() + err := runCarried(fakeCarriedProgram(), []string{"--calls", "1", "--dir", workspace, "fix it"}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("left with %d (%v):\n%s", code, err, printed) + } + if !strings.Contains(told.String(), "waiting up to 1m 10s for the price of 1 call that was cut short") { + t.Fatalf("stderr = %q, want the wait said", told.String()) + } + if !strings.Contains(printed.String(), " 1 model call · $0.06") { + t.Fatalf("the last line does not hold the late call:\n%s", printed) + } + record := newestRecord(t) + rows := ledgerRowsFor(t, workspace) + if len(rows) != 1 || !rows[0].Reconciled || rows[0].USD != 0.058188488 || rows[0].Task != filepath.Base(record) { + t.Fatalf("ledger rows = %+v, want the late receipt filed under the run %q", rows, filepath.Base(record)) + } + program, ok := delegate.ReadProgram(record) + if !ok || program.Name != fakeCarried || program.StartedAt.Before(before) || !program.EndedAt.After(program.StartedAt) || + len(program.Stages) == 0 { + t.Fatalf("program record = %+v (%v), want its name, stages and the program's own start and end", program, ok) + } +} + +// THE LAST LINE SAYS HOW LONG THE PROGRAM RAN, the way a person says it, and +// leaves off a figure nobody measured rather than writing a zero. IT IS THE LAST +// LINE EVEN THOUGH THE RUN KEPT A RECORD: every real run has a record folder by +// its end, and the line naming it used to follow the summary, so the manual's +// "last line" was the folder's path. +func TestAShellRunsLastLineSaysHowLongItRan(t *testing.T) { + for _, row := range []struct { + calls int + spent float64 + took time.Duration + want string + }{ + {calls: 277, spent: 2.295385, took: 22*time.Minute + 51*time.Second, want: " 277 model calls · $2.30 · 22m 51s\n"}, + {took: 2*time.Hour + 5*time.Minute, want: " 2h 5m\n"}, + {calls: 1, took: 400 * time.Millisecond, want: " 1 model call\n"}, + } { + printed := &lockedBuffer{} + inv := &delegate.Invocation{Program: fakeCarriedProgram(), Workspace: t.TempDir()} + record := t.TempDir() + view := newCarriedView(printed, inv, record) + view.calls = row.calls + view.Terminal(delegate.Terminal{Status: delegate.StatusPass, Message: "done"}) + _ = view.end(delegate.Result{}, nil, false, row.spent, row.took) + if !strings.HasSuffix(printed.String(), row.want) { + t.Fatalf("printed %q, want it to end %q", printed.String(), row.want) + } + if !strings.Contains(printed.String(), " the run's record is in "+record+"\n") { + t.Fatalf("printed %q, want the record folder named before the last line", printed.String()) + } + } +} + +// A SHELL RUN'S TIME IS THE PROGRAM'S, NOT THE DRAIN'S. The launch returns only +// once the program's stdout is drained, and a helper the program left holding +// stdout keeps that open for up to the grace after the program itself exited. +// The shell took its end after the launch returned, so the same program read up +// to fifteen seconds longer from a shell than from a conversation, whose worker +// already ends the clock at the process's own exit. +func TestAShellRunsTimeEndsWhenTheProgramExitedAndNotWhenItsOutputDrained(t *testing.T) { + _, printed := hostWithRealChild(t, 0.01) + const linger = 2 * time.Second + before := time.Now() + err := runCarried(fakeCarriedProgram(), []string{"--calls", "1", "--linger", linger.String(), "--dir", t.TempDir(), "fix it"}) + if code := exitCodeOf(err); code != 0 { + t.Fatalf("left with %d (%v):\n%s", code, err, printed) + } + if waited := time.Since(before); waited < linger { + t.Fatalf("the run returned after %v, before the helper let go of stdout at %v", waited, linger) + } + program, ok := delegate.ReadProgram(newestRecord(t)) + if !ok || program.EndedAt.Before(program.StartedAt) { + t.Fatalf("program record = %+v (%v), want the program's own start and end", program, ok) + } + if ran := program.EndedAt.Sub(program.StartedAt); ran >= linger { + t.Fatalf("the record says the program ran %v, which is the drain's %v and not the process's", ran, linger) + } +} diff --git a/cmd/codeaf/carried_seniordev_test.go b/cmd/codeaf/carried_seniordev_test.go new file mode 100644 index 0000000000..1f054c1727 --- /dev/null +++ b/cmd/codeaf/carried_seniordev_test.go @@ -0,0 +1,207 @@ +//go:build !windows + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" +) + +// seniorDevModel is senior-dev's side of one scripted conversation, played by +// codeaf's funnel instead of a model: write the feature, write the checklist, +// submit, and say it is done — the conversation internal/seniordev's own test +// plays against a server that imitates the model API. Here the API is the +// real one, and every call through it is billed. +type seniorDevModel struct { + mu sync.Mutex + calls int + keys []string +} + +func (m *seniorDevModel) completerFor(string) modelapi.Completer { return m } + +func (m *seniorDevModel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + _ = option(&request) + } + m.mu.Lock() + m.calls++ + call := m.calls + m.keys = append(m.keys, provider.CacheKeyFrom(ctx)) + m.mu.Unlock() + if sink := provider.BillingSinkFrom(ctx); sink != nil { + sink(provider.Billed{Model: request.Model, PromptTokens: 300, CompletionTokens: 20, Cost: 0.002}) + } + tool := func(name string, arguments map[string]any) (*ai.Response, error) { + encoded, _ := json.Marshal(arguments) + return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ + // ONE ID PER CALL, as a model gives them: senior-dev reports a + // finished call once per id, so two writes under one id read as + // one step. + ID: fmt.Sprintf("call-%s-%d", name, call), Type: "function", Function: ai.ToolCallFunction{Name: name, Arguments: string(encoded)}, + }}}, + FinishReason: "tool_calls", + }}}, nil + } + switch call { + case 1: + return tool("write", map[string]any{"filePath": "feature.txt", "content": "implemented\n"}) + case 2: + return tool("write", map[string]any{"filePath": ".senior-dev/checklist.md", "content": "- [x] the feature is implemented\n"}) + case 3: + return tool("submit", map[string]any{ + "reason": "feature.txt now holds the feature", "evidence": "make test exits 0", "checklist_satisfied": true, + }) + } + return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "Done."}}}, FinishReason: "stop", + }}}, nil +} + +// SENIOR-DEV ITSELF, THROUGH THE WHOLE ROAD: a person's shell run of the +// program this build carries serves it the real model API, starts it as a real +// child of this executable, and senior-dev — speaking its own OpenRouter +// dialect over a real socket, streaming — works a scripted task in a real +// repository to a passing ending. Every call is metered onto this machine's +// ledger once, the conversation is kept, and the work is in the tree. +func TestSeniorDevWorksATaskThroughTheShellHostsModelAPI(t *testing.T) { + if testing.Short() { + t.Skip("drives the real senior-dev engine") + } + program, carried := builtin.Find("senior-dev") + if !carried { + t.Skip("this build carries no senior-dev") + } + workspace := seniorDevWorkspace(t) + t.Setenv(carriedChildEnv, "real") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + model := &seniorDevModel{} + previousRoad, previousOut, previousGrace := carriedModels, carriedStdout, carriedGrace + carriedModels = func() (carriedRoad, error) { return carriedRoad{completerFor: model.completerFor}, nil } + printed := &lockedBuffer{} + carriedStdout = printed + carriedGrace = 5 * time.Second + t.Cleanup(func() { carriedModels, carriedStdout, carriedGrace = previousRoad, previousOut, previousGrace }) + + err := runCarried(program, []string{"--high", "openrouter/fixture/vendor-model", "--dir", workspace, "--", "Add", "the", "feature."}) + out := printed.String() + if code := exitCodeOf(err); code != 0 { + stderr := "" + if matches, _ := filepath.Glob(filepath.Join(newestSeniorDevRecord(t), carriedStderrName)); len(matches) > 0 { + data, _ := os.ReadFile(matches[0]) + stderr = string(data) + } + t.Fatalf("senior-dev's shell run left with %d:\n%s\nits stderr:\n%s", code, out, stderr) + } + for _, want := range []string{"senior-dev · working in " + workspace, "senior-dev finished", "senior-dev's model said: feature.txt now holds the feature", " · 300 in · 20 out · $0.0020"} { + if !strings.Contains(out, want) { + t.Fatalf("the shell run never printed %q:\n%s", want, out) + } + } + if content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")); err != nil || string(content) != "implemented\n" { + t.Fatalf("the work is not in the tree: %q %v", content, err) + } + model.mu.Lock() + calls, keys := model.calls, append([]string(nil), model.keys...) + model.mu.Unlock() + if calls < 4 { + t.Fatalf("senior-dev made %d calls through the API, want the scripted four", calls) + } + for _, key := range keys { + if key == "" { + t.Fatalf("a call lost senior-dev's own prompt_cache_key: %q", keys) + } + } + if rows := ledgerRowsFor(t, workspace); len(rows) != calls { + t.Fatalf("%d ledger rows for %d calls, want exactly one each", len(rows), calls) + } + turns, err := delegate.ReadTurns(newestSeniorDevRecord(t), 0) + if err != nil || len(turns) != calls { + t.Fatalf("the kept conversation holds %d turns (%v), want one per call", len(turns), err) + } + var submitted bool + for _, turn := range turns { + for _, use := range turn.Calls { + submitted = submitted || use.Name == "submit" + } + } + if !submitted || turns[0].Thread == delegate.MainThread { + t.Fatalf("the conversation lacks the submit or senior-dev's own thread: %+v", turns) + } +} + +// seniorDevWorkspace is the hermetic world senior-dev's own test runs in +// (internal/seniordev's hermeticRun): nothing of the machine's configuration, +// its model catalog on disk and no fetch, and a git repository whose build and +// tests pass. +func seniorDevWorkspace(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) + t.Setenv("SENIOR_DEV_CONFIG_DIR", t.TempDir()) + t.Setenv("SENIOR_DEV_CONFIG", "") + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "") + t.Setenv("SENIOR_DEV_PERMISSION", "") + t.Setenv("SENIOR_DEV_NET", "allow") + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + t.Setenv("SENIOR_DEV_DISABLE_MODELS_FETCH", "1") + catalog, err := filepath.Abs(filepath.Join("..", "..", "internal", "seniordev", "modelsdev", "testdata", "catalog.json")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(catalog); err != nil { + t.Skipf("senior-dev's fixture catalog is not where its test keeps it: %v", err) + } + t.Setenv("SENIOR_DEV_MODELS_PATH", catalog) + workspace := t.TempDir() + for name, content := range map[string]string{"README.md": "base\n", "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n"} { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + for _, args := range [][]string{ + {"init", "-q", "-b", "main"}, + {"add", "README.md", "Makefile"}, + {"-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "base"}, + } { + command := exec.Command("git", args...) + command.Dir = workspace + if out, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return workspace +} + +// newestSeniorDevRecord is the most recent shell run's record folder for +// senior-dev. +func newestSeniorDevRecord(t *testing.T) string { + t.Helper() + matches, _ := filepath.Glob(filepath.Join(carriedRecordRoot("senior-dev"), "*")) + newest := "" + for _, match := range matches { + if match > newest { + newest = match + } + } + return newest +} diff --git a/cmd/codeaf/carried_seniordev_worker_test.go b/cmd/codeaf/carried_seniordev_worker_test.go new file mode 100644 index 0000000000..4639ee19cb --- /dev/null +++ b/cmd/codeaf/carried_seniordev_worker_test.go @@ -0,0 +1,302 @@ +//go:build !windows + +package main + +import ( + "context" + "encoding/json" + "math" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/plandb" + runengine "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/seniordev/app" + "github.com/Agent-Field/codeaf/internal/session" +) + +// SENIOR-DEV ITSELF, THE CHAT'S WAY: the road `/senior-dev ` takes once +// the conversation has opened its run — the run's worker serves the program the +// real model API, starts it as a real child of this executable with the line +// the chat hands it and an environment with no key in it, and senior-dev works +// a scripted task in a real repository to a passing ending. Every call it makes +// is metered once: one spend row on the task, one ledger row, one turn of the +// conversation the task page draws, and the program record the page names it +// by, ceiling included. +// +// The shell's road is TestSeniorDevWorksATaskThroughTheShellHostsModelAPI; +// this one is the worker's, which is where the chat's money, its page and its +// ceiling are kept. +func TestSeniorDevWorksATaskAsTheChatsRunWorker(t *testing.T) { + if testing.Short() { + t.Skip("drives the real senior-dev engine") + } + program, carried := builtin.Find("senior-dev") + if !carried { + t.Skip("this build carries no senior-dev") + } + workspace := seniorDevWorkspace(t) + seniorDevCatalogWithItsOwnPool(t) + t.Setenv(carriedChildEnv, "real") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + // The key a program must never see, planted where a careless launch would + // hand it on. + t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-the-chat-run-must-not-hand-this-on") + + store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.json"), "senior-dev-run", "root", "Add the feature", "Add the feature.") + if err != nil { + t.Fatalf("open plan store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + storeDir := filepath.Dir(store.Path()) + root := store.RootID() + + model := &seniorDevModel{} + ledger := filepath.Join(t.TempDir(), "usage.jsonl") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + const ceiling = 1.0 + worker := runengine.NewDelegateWorker(store, workspace, program, runengine.DelegateSetup{ + Exe: self, + Grace: 5 * time.Second, + CompleterFor: func(string) session.Completer { return model }, + Ledger: ledger, + }, ceiling, 0) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + report, err := worker.Run(ctx, *store.Task(root)) + taskDir := plandb.TaskDir(storeDir, root) + if err != nil { + stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log")) + t.Fatalf("the chat's run of senior-dev failed: %v\nits stderr:\n%s", err, stderr) + } + if !strings.Contains(report.Result, "senior-dev's model said: feature.txt now holds the feature") { + t.Fatalf("the run's result = %q, want senior-dev's own ending with its claim", report.Result) + } + if content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")); err != nil || string(content) != "implemented\n" { + t.Fatalf("the work is not in the copy: %q %v", content, err) + } + + model.mu.Lock() + calls := model.calls + model.mu.Unlock() + if calls < 4 { + t.Fatalf("senior-dev made %d calls through the API, want the scripted four", calls) + } + // ONE ROW PER CALL, AND NOTHING AT THE END: the page and the rail sum + // these, so an end-of-run row would count the money twice. + spent := store.SpendSummary().ByModel["delegate/senior-dev"] + if spent.Calls != calls || math.Abs(spent.USD-0.002*float64(calls)) > 1e-9 { + t.Fatalf("the task holds %d spend rows for $%.4f, want one per call (%d) at $0.002 each", spent.Calls, spent.USD, calls) + } + if math.Abs(report.USD-spent.USD) > 1e-9 { + t.Fatalf("the run reported $%.4f and the task holds $%.4f; they must be one figure", report.USD, spent.USD) + } + session.FlushUsage() + if data, err := os.ReadFile(ledger); err != nil || strings.Count(strings.TrimSpace(string(data)), "\n")+1 != calls { + t.Fatalf("the ledger holds %q (%v), want one row per call", data, err) + } + turns, err := delegate.ReadTurns(taskDir, 0) + if err != nil || len(turns) != calls { + t.Fatalf("the task's conversation holds %d turns (%v), want one per call", len(turns), err) + } + record, ok := delegate.ReadProgram(taskDir) + if !ok || record.Name != "senior-dev" || len(record.Stages) == 0 || record.CeilingUSD != ceiling { + t.Fatalf("the program record = %+v %v, want senior-dev, its stages and the run's ceiling", record, ok) + } + // THE TASK KEEPS senior-dev's ACTIONS, and senior-dev's own words read them + // under the steps of its process: the brief written down as its spec, the + // work, the hand-in, each command of its own check and its ending. + actions, err := delegate.ReadActions(taskDir, 0) + if err != nil || len(actions) == 0 || actions[len(actions)-1].Kind != delegate.ActionEnd { + t.Fatalf("the task's action log = %+v (%v), want every record and the ending last", actions, err) + } + read, steps := program.Reader(), map[string]bool{} + for _, action := range actions { + if shown, ok := read(action); ok && shown.Step != "" { + steps[shown.Step] = true + } + } + for _, want := range []string{"setup", "spec", "checklist", "implement", "submit", "verify", "finish"} { + if !steps[want] { + t.Errorf("no action was read under %q: %v", want, steps) + } + } + // AND NO KEY WAS HANDED ON: the child's stderr is the program's own words, + // and the planted key is nowhere in them. + if stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log")); strings.Contains(string(stderr), "the-chat-run-must-not-hand-this-on") { + t.Fatal("the planted key reached senior-dev's process") + } +} + +// A FOLDER WITH NO GIT HISTORY IS WORKED IN WHERE IT IS. The chat reads the +// folder before it starts the program and, finding no history to copy from, +// hands senior-dev its own flag for that (seniordev.Program's PlainFolder) on +// the line the run's worker builds. senior-dev then works the same scripted +// task to the same passing ending, and leaves the folder as plain as it found +// it: no repository is made in somebody's folder behind their back. +func TestSeniorDevWorksAPlainFolderAsTheChatsRunWorker(t *testing.T) { + if testing.Short() { + t.Skip("drives the real senior-dev engine") + } + program, carried := builtin.Find("senior-dev") + if !carried { + t.Skip("this build carries no senior-dev") + } + workspace := seniorDevWorkspace(t) + if err := os.RemoveAll(filepath.Join(workspace, ".git")); err != nil { + t.Fatal(err) + } + seniorDevCatalogWithItsOwnPool(t) + t.Setenv(carriedChildEnv, "real") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + + store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.json"), "senior-dev-run", "root", "Add the feature", "Add the feature.") + if err != nil { + t.Fatalf("open plan store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + model := &seniorDevModel{} + worker := runengine.NewDelegateWorker(store, workspace, program, runengine.DelegateSetup{ + Exe: self, + Grace: 5 * time.Second, + CompleterFor: func(string) session.Completer { return model }, + Ledger: filepath.Join(t.TempDir(), "usage.jsonl"), + PlainFolder: true, + }, 1.0, 0) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + report, err := worker.Run(ctx, *store.Task(store.RootID())) + if err != nil { + stderr, _ := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "delegate-stderr.log")) + t.Fatalf("senior-dev on a plain folder failed: %v\nits stderr:\n%s", err, stderr) + } + if !strings.Contains(report.Result, "feature.txt now holds the feature") { + t.Fatalf("the run's result = %q, want senior-dev's own passing ending", report.Result) + } + if content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")); err != nil || string(content) != "implemented\n" { + t.Fatalf("the work is not in the folder: %q %v", content, err) + } + if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) { + t.Fatalf("the plain folder was made into a repository: %v", err) + } +} + +// THE CREW'S MODEL IS THE ONE ASKED FOR. The worker hands senior-dev the +// conversation's crew; senior-dev routes on the crew's working seat, and every +// call the model API serves names it — none of senior-dev's own list, which +// this catalog does not even carry, so a call on it would fail the run. +func TestSeniorDevWorksOnTheConversationsCrew(t *testing.T) { + if testing.Short() { + t.Skip("drives the real senior-dev engine") + } + program, carried := builtin.Find("senior-dev") + if !carried { + t.Skip("this build carries no senior-dev") + } + workspace := seniorDevWorkspace(t) + t.Setenv(carriedChildEnv, "real") + t.Setenv("DO_NOT_TRACK", "1") + t.Setenv("CODEAF_NO_UPDATE_CHECK", "1") + + store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.json"), "senior-dev-run", "root", "Add the feature", "Add the feature.") + if err != nil { + t.Fatalf("open plan store: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + model := &seniorDevModel{} + var asked sync.Map + worker := runengine.NewDelegateWorker(store, workspace, program, runengine.DelegateSetup{ + Exe: self, + Grace: 5 * time.Second, + CompleterFor: func(name string) session.Completer { + asked.Store(name, true) + return model + }, + Ledger: filepath.Join(t.TempDir(), "usage.jsonl"), + Crew: delegate.Crew{Hands: "fixture/vendor-model", Brain: "fixture/vendor-model"}, + }, 1.0, 0) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + report, err := worker.Run(ctx, *store.Task(store.RootID())) + if err != nil { + stderr, _ := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "delegate-stderr.log")) + t.Fatalf("senior-dev on the crew's model failed: %v\nits stderr:\n%s", err, stderr) + } + if !strings.Contains(report.Result, "feature.txt now holds the feature") { + t.Fatalf("the run's result = %q, want senior-dev's own passing ending", report.Result) + } + var names []string + asked.Range(func(key, _ any) bool { names = append(names, key.(string)); return true }) + if len(names) == 0 { + t.Fatal("no call reached the model API") + } + for _, name := range names { + if !strings.Contains(name, "fixture/vendor-model") { + t.Fatalf("a call asked for %q; want every call on the crew's model, asked %q", name, names) + } + } +} + +// seniorDevCatalogWithItsOwnPool points senior-dev at a model catalog that +// carries its OWN default pool. The chat hands the program no `--high` — its +// line is the default command and the shared flags only — so senior-dev asks +// for the models it ships with, and it sizes its calls from the catalog's +// entry for each. The fixture's one model is copied under every pool id, so +// the run is hermetic and still the one a person's `/senior-dev` starts. +func seniorDevCatalogWithItsOwnPool(t *testing.T) { + t.Helper() + data, err := os.ReadFile(os.Getenv("SENIOR_DEV_MODELS_PATH")) + if err != nil { + t.Fatalf("read senior-dev's fixture catalog: %v", err) + } + var catalog map[string]map[string]any + if err := json.Unmarshal(data, &catalog); err != nil { + t.Fatal(err) + } + service := catalog["openrouter"] + models, _ := service["models"].(map[string]any) + template, ok := models["fixture/vendor-model"].(map[string]any) + if !ok { + t.Fatal("the fixture catalog lost its vendor model") + } + for _, id := range strings.Split(app.DefaultHighModels, ",") { + id = strings.TrimPrefix(strings.TrimSpace(id), "openrouter/") + entry := map[string]any{} + for key, value := range template { + entry[key] = value + } + entry["id"] = id + models[id] = entry + } + encoded, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "catalog.json") + if err := os.WriteFile(path, encoded, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SENIOR_DEV_MODELS_PATH", path) +} diff --git a/cmd/codeaf/carried_signal_test.go b/cmd/codeaf/carried_signal_test.go new file mode 100644 index 0000000000..750cfeaef1 --- /dev/null +++ b/cmd/codeaf/carried_signal_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package main + +import ( + "errors" + "os" + "os/exec" + "syscall" + "testing" + "time" +) + +// carriedSecondSignalEnv marks the process this file's test starts as the one +// that is signalled. +const carriedSecondSignalEnv = "CODEAF_TEST_CARRIED_SECOND_SIGNAL" + +// A SECOND CTRL-C LEAVES A SHELL RUN AT ONCE. The first one stops the run, and +// the run then waits for the program's grace, its last calls and the price of +// a call the stop cut short — up to about a minute and a half. The signals were +// held for all of it, so a person who pressed ctrl-c again was ignored. The +// first signal now gives the terminal its ordinary ctrl-c back. +// +// It is proved in a process of its own, because the proof is that process +// dying of the second signal. +func TestASecondInterruptLeavesAShellRunAtOnce(t *testing.T) { + if os.Getenv(carriedSecondSignalEnv) == "1" { + ctx, stop := carriedSignals() + defer stop() + _ = syscall.Kill(os.Getpid(), syscall.SIGINT) + select { + case <-ctx.Done(): + case <-time.After(5 * time.Second): + os.Exit(3) + } + // The release runs beside the cancellation; give it a moment. + time.Sleep(200 * time.Millisecond) + _ = syscall.Kill(os.Getpid(), syscall.SIGINT) + time.Sleep(5 * time.Second) + // Still here: the second ctrl-c was swallowed. + os.Exit(0) + } + command := exec.Command(os.Args[0], "-test.run=^TestASecondInterruptLeavesAShellRunAtOnce$") + command.Env = append(os.Environ(), carriedSecondSignalEnv+"=1") + began := time.Now() + err := command.Run() + var exit *exec.ExitError + if !errors.As(err, &exit) { + t.Fatalf("the signalled process ended with %v after %v, want it killed by the second ctrl-c", err, time.Since(began)) + } + status, ok := exit.Sys().(syscall.WaitStatus) + if !ok || !status.Signaled() || status.Signal() != syscall.SIGINT { + t.Fatalf("the signalled process ended %v after %v, want it killed by the second ctrl-c", exit, time.Since(began)) + } +} + +// carriedHangupEnv marks the process the hangup test starts as the one that +// is hung up on. +const carriedHangupEnv = "CODEAF_TEST_CARRIED_HANGUP" + +// A HANGUP STOPS A SHELL RUN THE WAY CTRL-C DOES. A closed terminal or a +// dropped ssh connection sent SIGHUP, which nothing caught: the host died on +// the spot with its program still working and its folder left unfinished. +// Now the first hangup ends the run's context — the program is stopped and the +// folder finished — and a second, which a shell passes on to its jobs as it +// exits, is not allowed to kill that finishing halfway. +// +// It is proved in a process of its own, because the failure is that process +// dying of the signal. +func TestAHangupStopsAShellRunLikeCtrlC(t *testing.T) { + if os.Getenv(carriedHangupEnv) == "1" { + ctx, stop := carriedSignals() + defer stop() + _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) + select { + case <-ctx.Done(): + case <-time.After(5 * time.Second): + os.Exit(3) + } + time.Sleep(200 * time.Millisecond) + _ = syscall.Kill(os.Getpid(), syscall.SIGHUP) + time.Sleep(500 * time.Millisecond) + os.Exit(0) + } + command := exec.Command(os.Args[0], "-test.run=^TestAHangupStopsAShellRunLikeCtrlC$") + command.Env = append(os.Environ(), carriedHangupEnv+"=1") + if err := command.Run(); err != nil { + t.Fatalf("the hung-up process ended with %v, want it to hear the hangup as a stop and live through a second", err) + } +} diff --git a/cmd/codeaf/carried_test.go b/cmd/codeaf/carried_test.go new file mode 100644 index 0000000000..f225a55608 --- /dev/null +++ b/cmd/codeaf/carried_test.go @@ -0,0 +1,268 @@ +package main + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "os" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" + "github.com/Agent-Field/codeaf/internal/manual" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" +) + +// carryFake puts the fake program on the build's list for one test. +func carryFake(t *testing.T) { + t.Helper() + restore := builtin.Override([]delegate.Delegate{fakeCarriedProgram()}) + t.Cleanup(restore) +} + +// EVERY CARRIED PROGRAM IS ON THE FRONT PAGE, inside the page's two laws: its +// own group after the work you hand codeaf, its line and its summary, eighty +// cells at most, and the whole page still inside its line cap. +func TestTheFrontPageListsEveryCarriedProgramInsideTheLaws(t *testing.T) { + carryFake(t) + out, errs := captureUsage(t) + if err := usage(nil); err != nil { + t.Fatal(err) + } + printed := out.String() + for _, want := range []string{carriedHeading, " codeaf " + fakeCarried + ` ""`, "a program the tests carry"} { + if !strings.Contains(printed, want) { + t.Fatalf("`codeaf --help` does not carry %q:\n%s", want, printed) + } + } + group, work, look := strings.Index(printed, carriedHeading), strings.Index(printed, "Hand it work"), strings.Index(printed, "Look at what happened") + if !(work < group && group < look) { + t.Fatalf("the carried group is at %d, want it between the work group (%d) and what happened (%d)", group, work, look) + } + pageFits(t, printed, 1) + if errs.Len() != 0 { + t.Fatalf("`codeaf --help` wrote to stderr:\n%s", errs.String()) + } + // A BUILD THAT CARRIES NOTHING DRAWS NO HEADING OVER NOTHING. + restore := builtin.Override(nil) + defer restore() + if page := frontPage(); strings.Contains(page, carriedHeading) || page != usageText { + t.Fatal("a build that carries no program still draws the carried group") + } +} + +// AND THE PAGE THIS BUILD REALLY PRINTS keeps the same laws with the programs +// it really carries — which is where a real program's long summary would show. +func TestTheFrontPageFitsWithTheProgramsThisBuildCarries(t *testing.T) { + out, _ := captureUsage(t) + if err := usage(nil); err != nil { + t.Fatal(err) + } + pageFits(t, out.String(), len(builtin.All())) + for _, program := range builtin.All() { + if !strings.Contains(out.String(), "codeaf "+program.Name+" ") { + t.Errorf("`codeaf --help` never names `codeaf %s`, a program this build carries", program.Name) + } + } +} + +// carriedPageLines is what the carried group may cost the front page on top +// of [helpLineCap]: its heading and the blank line under it, and TWO LINES FOR +// EACH PROGRAM — its synopsis and a summary that fits one line. The table +// itself sits at its cap, and the cap moves by exactly what a feature adds +// (helpLineCap's own rule); this is that move, fixed per program, so a program +// whose summary needs a second line fails here, and the answer is a shorter +// summary rather than a longer page. +func carriedPageLines(programs int) int { + if programs == 0 { + return 0 + } + return 2 + 2*programs +} + +// pageFits is the front page's two laws: eighty cells a line, and the cap with +// the carried programs' own fixed allowance. +func pageFits(t *testing.T, printed string, programs int) { + t.Helper() + lines := strings.Split(strings.TrimRight(printed, "\n"), "\n") + for at, line := range lines { + if drawn := ansi.StringWidth(line); drawn > helpWidth { + t.Errorf("`codeaf --help` line %d draws %d cells: %q", at+1, drawn, line) + } + } + if cap := helpLineCap + carriedPageLines(programs); len(lines) > cap { + t.Errorf("`codeaf --help` is %d lines with %d carried programs on it, past the %d-line cap", len(lines), programs, cap) + } +} + +// ASKING A PROGRAM FOR HELP IS NOT A FAILURE, through the one dispatch a +// person's line takes: its help on stdout, exit zero, nothing on stderr. +func TestACarriedProgramsHelpIsNotAFailure(t *testing.T) { + carryFake(t) + for _, line := range [][]string{{"-h"}, {"--help"}, {"help"}, {"run", "--help"}} { + printed := &bytes.Buffer{} + previous := carriedStdout + carriedStdout = printed + saved := os.Args + os.Args = append([]string{"codeaf", fakeCarried}, line...) + err := run() + os.Args, carriedStdout = saved, previous + if code := exitCodeOf(err); code != 0 { + t.Fatalf("`codeaf %s %s` left with %d", fakeCarried, strings.Join(line, " "), code) + } + if !strings.Contains(printed.String(), "codeaf "+fakeCarried) { + t.Fatalf("`codeaf %s %s` printed no help:\n%s", fakeCarried, strings.Join(line, " "), printed) + } + } +} + +// A TYPO OF A PROGRAM'S NAME IS ANSWERED WITH THE PROGRAM, like a typo of any +// verb of codeaf's own. +func TestAMisspelledProgramNameIsAnsweredWithIt(t *testing.T) { + carryFake(t) + if said := unknownCommand("fake-carrid").Error(); !strings.Contains(said, "codeaf "+fakeCarried) { + t.Fatalf("a typo of a carried program was answered %q", said) + } +} + +// NO PROGRAM MAY SHADOW A WORD OF CODEAF'S OWN. The dispatch asks the build's +// list last, after every verb, alias and hidden door it answers itself, so a +// program named after one of them would be a verb nobody could ever reach — +// and it fails the build here instead. +func TestNoCarriedProgramShadowsAWordOfCodeafsOwn(t *testing.T) { + own := codeafsOwnWords(t) + for _, word := range []string{"do", "doctor", "help", "--version", "engine", "plandb"} { + if !own[word] { + t.Fatalf("%q was not read as a word of codeaf's own; the reader of main.go has stopped working", word) + } + } + for _, program := range append(builtin.All(), fakeCarriedProgram()) { + if own[program.Name] { + t.Errorf("the program %q shadows `codeaf %s`, a word codeaf answers itself; rename the program", program.Name, program.Name) + } + } +} + +// codeafsOwnWords is every word the dispatch answers before it asks the +// build's list: every case of run()'s switch — the hidden doors and the flag +// spellings included — and every word the typo suggester offers. +func codeafsOwnWords(t *testing.T) map[string]bool { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0) + if err != nil { + t.Fatalf("parse main.go: %v", err) + } + words := map[string]bool{} + for _, decl := range file.Decls { + function, ok := decl.(*ast.FuncDecl) + if !ok || function.Name.Name != "run" || function.Recv != nil { + continue + } + ast.Inspect(function, func(node ast.Node) bool { + clause, ok := node.(*ast.CaseClause) + if !ok { + return true + } + for _, expression := range clause.List { + if literal, ok := expression.(*ast.BasicLit); ok && literal.Kind == token.STRING { + if word, err := strconv.Unquote(literal.Value); err == nil { + words[word] = true + } + } + } + return true + }) + } + if len(words) < 20 { + t.Fatalf("only %d words were read out of run()'s dispatch", len(words)) + } + for _, word := range knownCommands { + words[word] = true + } + return words +} + +// A SHELL RUN'S CHILD IS HANDED THE PERSON'S OWN LINE: the command they named, +// its own flags and the brief as they typed it, with --json added — and the +// child's parser reads that line back to the same invocation. +func TestAShellRunHandsItsChildThePersonsOwnLine(t *testing.T) { + program := fakeCarriedProgram() + for _, row := range []struct { + line []string + child []string + }{ + {[]string{"--calls", "2", "fix", "it"}, []string{fakeCarried, "--json", "--calls", "2", "fix", "it"}}, + {[]string{"run", "--wait", "--", "--not-a-flag"}, []string{fakeCarried, "run", "--json", "--wait", "--", "--not-a-flag"}}, + {[]string{"check"}, []string{fakeCarried, "check", "--json"}}, + } { + inv, err := delegate.Parse(program, row.line, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + child := carriedChildLine(inv) + if strings.Join(child, " ") != strings.Join(row.child, " ") { + t.Fatalf("%q became the child line %q, want %q", row.line, child, row.child) + } + again, err := delegate.Parse(program, child[1:], &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if again.Command.Name != inv.Command.Name || again.Brief() != inv.Brief() || again.Workspace != inv.Workspace || !again.JSON { + t.Fatalf("the child reads %+v where the host read %+v", again, inv) + } + } +} + +// A PREFIXED ID IS AN ACCOUNT, NOT PART OF THE MODEL: a shell run's adapter for +// `openrouter/deepseek/…` puts the router's own id on the wire, and a +// connection's own prefix is that connection's. +func TestAShellRunsAdapterPutsTheServicesOwnIDOnTheWire(t *testing.T) { + router := modelsource.DefaultSource(config.DefaultBaseURL) + proxy := modelsource.Source{ID: modelsource.CustomID, Written: "mybox", Name: "mybox", Address: "http://127.0.0.1:9000/v1"} + settings := config.Config{ + APIKey: "sk-or-v1-routerkey0000000000", BaseURL: config.DefaultBaseURL, + Sources: modelsource.NewSet( + modelsource.Connected{Source: router, Key: "sk-or-v1-routerkey0000000000", Address: config.DefaultBaseURL}, + modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address}, + ), + } + adapters := &carriedAdapters{settings: settings, built: map[string]modelapi.Completer{}} + for model, wire := range map[string]string{ + "openrouter/deepseek/deepseek-v4-flash-0731": "deepseek/deepseek-v4-flash-0731", + "deepseek/deepseek-v4-flash-0731": "deepseek/deepseek-v4-flash-0731", + "mybox/qwen3-coder": "qwen3-coder", + } { + built, ok := adapters.forModel(model).(wireCompleter) + if !ok || built.wire != wire { + t.Fatalf("the adapter for %q puts %q on the wire, want %q", model, built.wire, wire) + } + } + if first, again := adapters.forModel("mybox/qwen3-coder"), adapters.forModel("mybox/qwen3-coder"); first != again { + t.Fatal("an adapter was built twice for one model") + } +} + +// A PROGRAM THIS BUILD CARRIES HAS ITS PAGE IN THE CHAT'S MANUAL, and the page +// names both of its doors. The chat can say only what a page says, and a verb +// the manual does not know is one the chat will improvise about or deny. +func TestEveryCarriedProgramHasItsPageInTheChatManual(t *testing.T) { + for _, program := range builtin.All() { + page, ok := manual.Chat().Page(program.Page) + if !ok { + t.Errorf("%s names the manual page %q and the chat's manual has no such page", program.Name, program.Page) + continue + } + shell := regexp.MustCompile(`\bcodeaf ` + regexp.QuoteMeta(program.Name) + `\b`) + if !shell.MatchString(page) || !strings.Contains(page, "/"+program.Name) { + t.Errorf("%s's page %q does not name `codeaf %s` and `/%s`", program.Name, program.Page, program.Name, program.Name) + } + } +} diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index ff77b5ba5f..19a1acd559 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1070,6 +1070,9 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { SubharnessMemory: subharnesses.Memory, SubharnessLastRun: subharnesses.LastRun, SubharnessRecordRun: subharnesses.Record, + // AND THE PROGRAMS THIS BUILD CARRIES that a task can be handed to + // whole (chatv3_delegate.go). Empty is none, on the terms above. + Delegates: v3Delegates(), // The hand that paints, and the model it asks (internal/session's // tools_image.go). The pair is CONDITIONAL on the other side — a nil // client leaves generate_image off the belt entirely — so this is diff --git a/cmd/codeaf/chatv3_delegate.go b/cmd/codeaf/chatv3_delegate.go new file mode 100644 index 0000000000..f61981aa0e --- /dev/null +++ b/cmd/codeaf/chatv3_delegate.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" +) + +// THE PROGRAMS ONE CONVERSATION CAN HAND A WHOLE TASK TO: the ones this build +// carries (internal/delegate/builtin). The list is read here, at the door, and +// handed to internal/session, so the session package never imports a +// program's whole engine and a test of it never carries one. +func v3Delegates() []delegate.Delegate { + return builtin.All() +} diff --git a/cmd/codeaf/chatv3_host_test.go b/cmd/codeaf/chatv3_host_test.go index f89d924060..152a66626a 100644 --- a/cmd/codeaf/chatv3_host_test.go +++ b/cmd/codeaf/chatv3_host_test.go @@ -138,7 +138,15 @@ func TestH9HostMissingCommandNamesTheCurrentInstallation(t *testing.T) { } func TestSSHSpawnCarriesTheLowLatencyPolicy(t *testing.T) { - t.Setenv("CODEAF_HOME", filepath.Join(os.TempDir(), "acp")) + // A SHORT HOME, because the control socket must fit enginehost.SocketLimit: + // under macOS's own $TMPDIR the path came to 104 bytes, one over, and the + // multiplexing options were rightly left out. + short, err := os.MkdirTemp("/tmp", "acp") + if err != nil { + t.Skipf("no short folder for the control socket: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(short) }) + t.Setenv("CODEAF_HOME", short) t.Setenv("CODEAF_PROFILE_DIR", t.TempDir()) args := strings.Join(sshTransportArgs("devbox", "codeaf engine"), " ") for _, want := range []string{ diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index 404a97b892..3f152882b9 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -322,6 +322,13 @@ func openChatV3Local(launch localLaunch) error { // road and not a use of this client's connection, and this is the door that // knows the road (chatv3_taskowner.go says what makes it safe). options.OpenTaskOwner = localTaskOwnerDoor(welcome.Workspace) + // AND IT CAN SEE THEM AT ALL. The rows that door sits behind are minted from + // the other conversations' presence files, which the agent this surface + // holds — a connection — cannot read; the engine is on this machine, so the + // files are on this disk beside the transcript the surface was handed, and + // the reading is taken straight off it. Without this the door above was + // never reached from a real window. + options.Elsewhere = session.ElsewhereOf // AND HOME CAN TELL AN ENGINE FROM A WINDOW. It is bound on THIS road and no // other, which is the absence law rather than an oversight: --host has its // holder on this laptop and its journal on the far machine, and the in-process diff --git a/cmd/codeaf/chatv3_taskowner.go b/cmd/codeaf/chatv3_taskowner.go index 4210acf2af..7969ad7392 100644 --- a/cmd/codeaf/chatv3_taskowner.go +++ b/cmd/codeaf/chatv3_taskowner.go @@ -108,7 +108,14 @@ func openTaskOwnerView(workspace string, ask tui3.TaskOwnerAsk) (tui3.TaskOwnerV // not — the page draws the question and the window that owns the work // answers it (internal/remote's driver.go). Questions: agent.WatchQuestions, - Close: client.Close, + // AND ONE TASK'S STORED PAGE, which is the whole of what a program's task + // has to read: senior-dev writes no worker journal, and its actions are on + // its page in the owner's store. It is a read in the same sense — + // [remote.MethodPlanTaskPage] is on the watcher's allow-list and none of + // the page's verbs are — and it keeps the engine's refusal, which is how a + // program's page learns that the conversation under it was replaced. + TaskPage: agent.ReadPlanTaskPage, + Close: client.Close, }, nil } diff --git a/cmd/codeaf/logs_test.go b/cmd/codeaf/logs_test.go index e1b1e4835b..39dcc7f08d 100644 --- a/cmd/codeaf/logs_test.go +++ b/cmd/codeaf/logs_test.go @@ -30,6 +30,13 @@ import ( // they stopped at a live provider instead (testenv_test.go carries the whole // case). func TestMain(m *testing.M) { + // A SHELL RUN'S CHILD COMES IN HERE: carried_test.go starts this very test + // binary as the program's process, marked in its environment, and the + // binary then runs the dispatch the way `codeaf ` would. Its + // environment is the parent's, already isolated below. + if code, child := runAsCarriedChild(); child { + os.Exit(code) + } if _, pinned := os.LookupEnv(calllog.EnvVar); !pinned { os.Setenv(calllog.EnvVar, calllog.OffValue) } diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index cb7154e89d..de1c8800a2 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -28,6 +28,7 @@ import ( "github.com/Agent-Field/codeaf/internal/calllog" "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" lanes "github.com/Agent-Field/codeaf/internal/lane" @@ -393,6 +394,12 @@ func run() error { case "-h", "--help", "help": return usage(os.Args[2:]) default: + // A PROGRAM THIS BUILD CARRIES IS A VERB OF ITS OWN: `codeaf senior-dev + // ` (carried.go). It is asked last, after every verb above, so + // no program's name can shadow one of codeaf's own words. + if program, ok := builtin.Find(os.Args[1]); ok { + return runCarried(program, os.Args[2:]) + } return unknownCommand(os.Args[1]) } } @@ -705,7 +712,10 @@ func usage(args []string) error { fmt.Fprintln(usageOut, environmentText) return nil } - fmt.Fprintln(usageOut, usageText) + // The table with the programs this build carries in it (carried.go): a + // verb nobody can find on the page that lists the verbs is a verb nobody + // types. + fmt.Fprintln(usageOut, frontPage()) return nil } diff --git a/cmd/codeaf/usage.go b/cmd/codeaf/usage.go index f368cbabb2..3178d73033 100644 --- a/cmd/codeaf/usage.go +++ b/cmd/codeaf/usage.go @@ -9,6 +9,8 @@ import ( "strings" "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/delegate/builtin" ) // This file is the ONE SEAM every subcommand's flags are built and parsed at. @@ -474,7 +476,7 @@ func nearestCommand(typed string) string { // not what anybody meant: `quux` is three edits from `run`, and answering // with it would send somebody confidently to the wrong command. best, distance := "", 3 - for _, candidate := range knownCommands { + for _, candidate := range commandWords() { if measured := editDistance(typed, candidate); measured < distance { best, distance = candidate, measured } @@ -482,9 +484,28 @@ func nearestCommand(typed string) string { return best } +// commandWords is every word the dispatch answers to: codeaf's own +// ([knownCommands]) and then the name of every program this build carries, +// which is a verb of its own (carried.go) and a typo of which deserves the same +// answer as a typo of `logs`. +// +// THE PROGRAMS ARE READ FROM THE BUILD'S LIST AT THE MOMENT OF ASKING, NOT +// WRITTEN INTO THE LITERAL BELOW. The literal is codeaf's own vocabulary and is +// read as source by internal/manual's terminal-verb gate; a program is on the +// list only in a build that carries it — none on Windows — so its name belongs +// to the list, and a literal naming it would be a verb this build may not have. +func commandWords() []string { + words := append([]string(nil), knownCommands...) + for _, program := range builtin.All() { + words = append(words, program.Name) + } + return words +} + // knownCommands is every word the dispatch answers to, in the order the table // introduces them. `engine` and `tick` are deliberately absent for the same -// reason they are absent from the usage text: nothing types them. +// reason they are absent from the usage text: nothing types them. The programs +// this build carries are joined to it where it is read ([commandWords]). var knownCommands = []string{ "chat", "resume", "serve", "devices", "do", "plan", "revise", "run", "exec", "show", "models", "pool", "notebook", "collections", "competence", "services", "wake", "patch", diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index a0d1e1a665..17bc05bd17 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -17,15 +17,8 @@ codeaf sends anonymous usage counts to AgentField. Turn off: CODEAF_TELEMETRY=off ``` -The installer prints a three-line form of the same notice, to stderr, after the -`installed codeaf …` receipt and before the `export PATH` line. The full notice -above still arrives at the first session: - -``` -codeaf shares anonymous performance data with AgentField -codeaf does NOT share your prompts, code, files, or any private information -see what is shared: codeaf telemetry info · turn off: CODEAF_TELEMETRY=off -``` +The installer prints nothing about telemetry; the notice above arrives with the +first session, before anything is sent. ## What is sent diff --git a/docs/benchmarks/deepswe/README.md b/docs/benchmarks/deepswe/README.md new file mode 100644 index 0000000000..9e5fed668c --- /dev/null +++ b/docs/benchmarks/deepswe/README.md @@ -0,0 +1,61 @@ +# DeepSWE harness comparison + +Ten coding harnesses, one model, the same 113 tasks, one attempt each. + +| harness | solved | cost per task | cost per solved issue | mean time | +| --- | --- | --- | --- | --- | +| **senior-dev** | **62 of 113, 54.9%** | **22¢** | **1x** | 54 min | +| mini-swe-agent | 56, 49.6% | 38¢ | 1.9x | 44 min | +| codex | 51, 45.1% | 37¢ | 2.1x | 46 min | +| pi | 42, 37.2% | 35¢ | 2.4x | 52 min | +| omp | 31, 27.4% | 50¢ | 4.5x | 49 min | +| opencode | 30, 26.6% | 50¢ | 4.8x | 48 min | +| kilo | 30, 26.6% | 48¢ | 4.6x | 54 min | +| claude-code | 16, 14.2% | 19¢ | 3.4x | 32 min | +| deepseek-harness | 16, 14.2% | 150¢ | 26.6x | 94 min | +| muse-code | 3, 2.7% | 12¢ | 11.3x | 16 min | + +senior-dev solved the most issues and paid the least for each one it solved: +nearly 4x the issues claude-code solved, at about half the cost per solve of the +next best harness. + +Since then, on the same 113 tasks: 88 solved (77.9%, exact 95% CI 69.1% to 85.1%) +with DeepSeek V4.1 Flash, and 78 (69.0%, 59.6% to 77.4%) with Kimi K3. Those runs +are senior-dev alone, not a comparison. + +## Setup + +| | | +| --- | --- | +| Benchmark | full DeepSWE set, 113 tasks, one seed per harness | +| Model | `deepseek/deepseek-v4-flash-0731` through OpenRouter | +| Verifiers | official DeepSWE at `0b9fabb` | +| Budget | 3 h per task | +| Isolation | four shards per harness, a dedicated OpenRouter key per harness | +| Ran | nine harnesses on 2026-09-11; senior-dev on 2026-09-12 | + +[`arms.csv`](arms.csv) has one row per harness: solved, reward rate, valid +grades, invalid outcomes, mean F2P and P2P, OpenRouter spend, cost per task, +mean agent seconds and model. + +## How the columns in the README are derived + +- **solved**: tasks the verifier passed, out of 113. +- **cost per task**: billed OpenRouter spend divided by 113, so tasks without a + verifier result stay in the denominator. +- **cost per solved issue**: spend divided by tasks solved, shown as a multiple + of senior-dev's (39.9¢). +- **mean time**: mean agent wall time per task. + +## Limits + +- One seed per harness. senior-dev's 62 against mini-swe-agent's 56 is not a + statistically resolved difference. +- senior-dev departs from the sampling contract: the other nine sent temperature + 1.0 and top-p 0.95, senior-dev sent neither, so provider defaults applied. +- Five tasks produced no verifier outcome: codex 1, pi 1, omp 2, opencode 1. + They count as unsolved. +- omp, opencode, kilo, deepseek-harness, claude-code and muse-code did not record + per-attempt cost; their spend is known at harness level only. +- senior-dev ran under an earlier name for the binary; values are rewritten to + `senior-dev`. diff --git a/docs/benchmarks/deepswe/arms.csv b/docs/benchmarks/deepswe/arms.csv new file mode 100644 index 0000000000..ba8c10e980 --- /dev/null +++ b/docs/benchmarks/deepswe/arms.csv @@ -0,0 +1,11 @@ +harness,solved,tasks,reward_rate,valid_grades,invalid,mean_f2p,mean_p2p,openrouter_spend_usd,cost_per_task_usd,mean_agent_seconds,model +senior-dev,62,113,0.5487,113,0,0.8864,0.9942,24.73,0.2188,3258,openrouter/deepseek/deepseek-v4-flash-0731 +mini-swe-agent,56,113,0.4956,113,0,0.8817,0.9966,43.09,0.3813,2651,openrouter/deepseek/deepseek-v4-flash-0731 +codex,51,113,0.4513,112,1,0.8681,0.9876,42.23,0.3737,2746,deepseek/deepseek-v4-flash-0731 +pi,42,113,0.3717,112,1,0.7163,0.8909,39.61,0.3505,3101,openrouter/deepseek/deepseek-v4-flash-0731 +omp,31,113,0.2743,111,2,0.7191,0.8526,55.98,0.4954,2920,openrouter/deepseek/deepseek-v4-flash-0731 +opencode,30,113,0.2655,112,1,0.7113,0.8679,56.96,0.5041,2871,openrouter/deepseek/deepseek-v4-flash-0731 +kilo,30,113,0.2655,113,0,0.6903,0.9051,54.65,0.4836,3215,openrouter/deepseek/deepseek-v4-flash-0731 +deepseek-harness,16,113,0.1416,113,0,0.5339,0.9145,169.47,1.4997,5613,openrouter/deepseek/deepseek-v4-flash-0731 +claude-code,16,113,0.1416,113,0,0.3972,0.916,21.72,0.1922,1909,deepseek/deepseek-v4-flash-0731 +muse-code,3,113,0.0265,113,0,0.0514,0.9901,13.54,0.1198,952,deepseek/deepseek-v4-flash-0731 diff --git a/docs/changes/unreleased/1488-installer-no-telemetry-notice.md b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md new file mode 100644 index 0000000000..af4d6d5336 --- /dev/null +++ b/docs/changes/unreleased/1488-installer-no-telemetry-notice.md @@ -0,0 +1,10 @@ +--- +kind: removed +title: the installer says nothing about telemetry +pr: 1488 +surface: [build, docs] +invalidates: + - "The installer printed a three-line telemetry notice after the `installed codeaf` receipt. It prints none now; the binary's full notice still arrives before the first session's events are sent." + - "When the install marker could not be written, the installer printed a line naming its telemetry folder. The marker is still written when it can be, and a failure is now silent." + - "docs/TELEMETRY.md carried a second fenced block, the installer's three-line form, and test/installer-telemetry.sh compared the installer against it. That block is gone and the test asserts the installer prints no notice." +--- diff --git a/docs/changes/unreleased/1488-senior-dev.md b/docs/changes/unreleased/1488-senior-dev.md new file mode 100644 index 0000000000..0f6e829f52 --- /dev/null +++ b/docs/changes/unreleased/1488-senior-dev.md @@ -0,0 +1,38 @@ +--- +kind: added +title: senior-dev, a coding agent built into codeaf, takes a whole task in the folder itself +pr: 1488 +surface: [chat, engine, docs] +invalidates: + - "There was no way to hand one large task to an agent of its own; the nearest thing was `bash` with `background: true`, which gave a job log and none of a task's limits, rail row or ending. senior-dev is now built into codeaf: `/senior-dev ` starts a run it does alone in the folder itself — on a branch of its own when the folder is a git repository, in place when it is not. `propose_task` takes `via: \"senior-dev\"`, and the model is told each program in the program's own words (its guide) plus codeaf's folder rule: hand it the folder the work belongs in as `ground`, clone a repository this machine lacks into a new folder first, and never brief it to work anywhere else. Nothing is installed. On Windows it is absent." + - "senior-dev was a separate program (swe-pro-go; called swe-pro until 2026-09-22) that read `OPENROUTER_API_KEY` itself. It is now part of codeaf, copied from swe-pro-go at 6103488 (local tag `codeaf-absorb`), and it runs only through codeaf: every model call it makes goes to a model API codeaf serves that one run. No key reaches it or any command its model runs. Each call is priced once into the conversation, the task and the spending ledger, and its ledger row names the conversation and the task, so `/cost`'s `tasks` line and the spend place show what a run cost. A call cut short by a stop or the ceiling is priced by its receipt, and the run is not over until that receipt is in (at most 70 seconds from when it was owed). The dollar ceiling refuses the call that would cross it (`the run's dollar ceiling of $… is reached ($… spent), so codeaf made no call`), and a run handed an already-spent ceiling makes no call at all. On a service that reports no prices (a local proxy, a sign-in) the dollar ceiling cannot hold; the manual says so and names `--max-hours` as the bound there. A model the person's services cannot serve is answered on the run's own work model, and the page names the model that answered." + - "A program's task page was a step list with no dollars until the run landed, and its stage was drawn nowhere. senior-dev's task opens inside the conversation's own tab, as any task does, from its row, its card, a task link, the task strip, the home panel or the sessions place; `esc`, the conversation's tab and the `home` tab leave it, and the program has no tab of its own. It shows the actions senior-dev takes, each under the step of its own process it served — `BRIEF`, `SETUP`, `SPEC`, `EXPLORE`, `PIN`, `CHECKLIST`, `IMPLEMENT`, `SUBMIT`, `VERIFY`, `FINISH` — with how each came out (`passes`, `fails · exit 1`, `4 files · 5 of 5 ticked`), the build and test commands it runs itself after the hand-in, `compacted its memory`, `switched to ` with the router's reason, its nudges and last turn drawn quieter, and `◐ thinking · 12s` while a model call is out; model names appear nowhere else. `ctrl+y` turns the page to the raw calls to its model and back. A line pinned over it says the step, the spend against the ceiling, the calls and the time, and the rail row says the step (`explore`, `verify`) and the spend. senior-dev reports this through optional fields on its protocol records (a step's `tool`, `step` and `exit`; a stage's `data`; stages `compaction`, `model-switch` and `verification · running`), and codeaf keeps them in the task's `delegate-actions.jsonl`; its algorithm is unchanged. The box sends nothing: `senior-dev reads no messages — say it to main`." + - "A senior-dev run's time was read off different clocks on different surfaces and was recorded nowhere: the page counted from before the copy was made to whenever the store happened to end, the rail from when the window first saw the run. It is now one span everywhere — from the hand-off to the moment senior-dev's own process ended, rounded to the second — on the page, the rail, the room, the landed card, the note the chat is handed (`done · ran 22m 51s · …`), the chat's `tasks` tool and the project's task list. The instants are kept in the task's `delegate-program.json`, and a reopened conversation still shows them." + - "The chat's `tasks` tool could not see a senior-dev run (`No task \"3\" in this project`), and nothing outside its own conversation could. It now reads the run, says how long it took, and every run takes a row in the project's task list, so the `@` list, other conversations and other windows see it. Another window that opens the run's row on its tasks place (`enter read it as it runs`) gets the same actions page, read-only, with `ctrl+y` for the raw calls and no stop, where it used to get the `[senior-dev]` badge over an empty page; a window reading another conversation may now read that task's stored page (and none of its verbs) on its connection. And that row was never drawn on an ordinary launch: the tasks place read the other conversations' presence only from an in-process agent, so a window on the engine (bare `codeaf`) showed another conversation's running work as `enter go inside it` and could never reach `enter read it as it runs` for any task. The engine launch now reads that presence off this machine's disk, so the row says `another window` and opens the reading page." + - "A second hand-off in a conversation whose earlier run had been left open ran inside that run's records: the second senior-dev was handed the first one's brief, and its calls, ceiling, stop and end time were written into the first task's page. Every hand-off now has its own store, record folder and brief. A run codeaf closed or crashed under used to read `running` for ever with its clock climbing; it now reads `incomplete` with `codeaf closed while senior-dev was running`, its time stopped where it was last seen working, and nothing waiting on the person. Closing a hosted conversation's window only detaches, as before." + - "Reopening a conversation after codeaf had closed under a run could set its whole task list aside as corrupt (`run row … is in state \"interrupted\"`), losing every task in it. Such a row is now written as the moving row it was, and a list an earlier build wrote that way is read." + - "The card that summarises a run and a question asked of a run on its page were model calls that no book counted; they are now in the conversation's spend, `/cost` and the ledger, and a program's run buys no summary at all. The home card and project facts counted a run's dollars twice; they now count them once." + - "senior-dev's file tools wrote anywhere the model named, outside the folder it was given. Its `write`, `edit` and `apply_patch` now refuse any path outside that folder, links resolved; reads stay open, and the shell is not fenced." + - "A program was placed by the same ladder as codeaf's own tasks, and its `in place` rung answered with the conversation's folder before the proposal's `ground` was read: a chat opened in the home folder that made ~/Desktop/pong, named it as ground and said `in place` handed senior-dev the whole home folder, which it began to snapshot and died on 60 ms in (`open ~/.Trash: operation not permitted`). A program's folder is now its proposal's `ground`, or the conversation's folder when it names none (the repository's root inside one), and nothing else is read for it; a `ground` that does not exist yet is made when its parent does; the card says `where: , on a branch of its own` (or the folder), and the receipt the model is handed names it. A program is never handed the home folder or one above it: the hand-off is refused with `senior-dev works in one project's folder, and is your home folder; say which folder the work is in, as ground`, and `/senior-dev` typed there is refused the same way. On a folder with no git history, a folder or file senior-dev may not read is skipped instead of ending the run; it needs no Full Disk Access." + - "senior-dev handed a folder with no git history ended at once with `workspace is not a git repository`, from the chat and from a shell. senior-dev now uses git only if it is there: a plain folder, a repository with no commit, a `.git` with no `HEAD` or a machine with no git runs on its snapshot recorder, as `--in-place` would, with or without the flag, so the chat never meets an ending it has no flag to fix. codeaf still starts it with `--in-place` under a repository rooted at the home folder or above, where git is there and must not be written to: it works in the folder itself and commits nothing. Wherever it works, its notes (`.senior-dev/`, the whole model conversation among them) are moved into the task's record folder when it ends, unless they were there before it started." + - "senior-dev ran in a copy of the person's repository and its work was left as one squashed `task:` commit on a branch nothing merged. It now works in the person's folder itself: in a repository codeaf records the branch they are on, switches the checkout to a new branch `task/-<id>` (the person's own branch never moves), and senior-dev works there; when the run ends — done, not finished, or stopped — what it left uncommitted is committed onto that branch and the branch is left checked out, so the work is in the person's folder (`its work is on the branch <branch> in <folder>, N files, and that branch is checked out there; your branch main is as it was: `git -C '<folder>' switch main` goes back to it, and `git -C '<folder>' merge <branch>` from there brings the work in`). A run that changed nothing switches back and deletes its branch (`it changed nothing, so <folder> is back on your branch <b> and its branch <task> was deleted`). A checkout with changes not committed, or in the middle of a merge or rebase, is refused before its card: `<folder> has changes that are not committed (a.go, b.go and 2 more); commit or stash them, then ask again`. One run works in a folder at a time, from any conversation, window or shell, and a folder inside or around a busy one is busy too. While it works, nothing else of codeaf's writes there: the chat's file tools refuse a path inside the folder, and a task grounded on it is refused (`<folder> is busy: senior-dev, task 1 (<title>), is working in it, and nothing else of codeaf's works there until that run has ended; wait for it, or stop it, then ask again`); the shell is not fenced, and the person's own edits there join its work. A run whose process went away (codeaf closed, a crash, a killed shell run) is settled without touching git: its work stays on its branch as it left it, and the ending says how many files are not committed. codeaf reads the person's branch again before it says it is as it was, and switches branches with the repository's hooks off. The copy, the rewriting of folder paths in the brief, the squash and the landing are gone." + - "A program that ended without finishing drew `a fault: ran and did not finish`. The row now carries the program's own sentence (`senior-dev did not finish: submitted a change that the project's own build or tests do not pass`), is not drawn as a fault (new ending `program`), keeps that ending and its branch across a reopen, and the conversation is told the program's account; a program crash is still a fault." + - "senior-dev routed on a built-in list of six open models whatever the person's crew said. A run a conversation starts now hands it the crew's worker (`--high`) and low (`--low`) models; it skips a crew model its catalog cannot size and uses its own list only when none is left (`--crew`). The mastermind is not passed, because senior-dev has no call that would use it." + - "A model the person asked for was shown on the card and dropped: senior-dev was handed the crew's working seat whatever the proposal named. The models a proposal names (one, or several separated by commas) are now senior-dev's working pool (`--asked --high …`), named on the card and in the receipt; a model none of the connected services can serve is refused before the card, by name, where it used to be answered on the crew's seat call after call; a model spelled with a connected service's prefix (`mybox/qwen3`) is taken as written; and one senior-dev's catalog cannot size ends the run before its first call, naming it, where a crew seat it cannot size is dropped for its own list. A proposal naming no model is handed the crew, as before." + - "codeaf reached for senior-dev only when its model read a paragraph calling it for \"one large code change worth an hour\". The paragraph now says work a program is for goes to it whole, rather than to the conversation or its own worker, and senior-dev's guide claims complex, multi-part coding work in a real project: an issue in a mature codebase whose cause spans files, a feature with its tests, a rewrite across a package, a migration. A proposal that leaves out a program the person named (by name or as `/name`, in the message or a correction typed into the same turn) is turned back once — `the person named senior-dev: if they want it to do this work, propose this again with `via: \"senior-dev\"`; if they asked for it not to be used, or did not mean the program, propose it again unchanged` — every such proposal of that reply is turned back, and one the model makes after reading it passes. An ask for a program lifts the one-command floor (`fix this file with senior-dev` goes to senior-dev); a passing mention does not; a commit, undo or revert stays in the conversation whatever `via` says. The approval card and its countdown are unchanged." + - "Nothing distinguished a program's task from codeaf's own: the rail row, the card and the page drew them alike, and the card did not say where the work was going. A program's tasks now wear its name as a badge — `[senior-dev]`, bold in the accent colour, after the title — on the side list (`[sd]` on the narrow one, the `#id` going first and the title cut last), the card (`wants to start a [senior-dev] task: <title>`), the task's page, the strip, the `@` list, the tasks place and home; the chat's `tasks` tool says `via senior-dev`. The badge is made from the program's name, so a program added later wears its own. An ordinary task wears none. The card's `from your folder as it stands — unsaved edits included` line is not on a program's card." + - "senior-dev's ending went nowhere codeaf acted on: it was a sentence on a row, read by the chat only if the person asked something next. Now every run's ending wakes the conversation with how it came out (passed its own check, unchecked, fails, stopped on a limit, broke) and what to do about it, under a playbook page of its own: the chat checks the work, fixes a small gap on the branch, or hands the work back to senior-dev with a sharper brief — at most twice on its own (a third is refused: `senior-dev has been sent back to this work 2 times already, the most codeaf does on its own: tell the person where the work stands and let them decide`), and never after a dollar or time limit without the person's word. The landed card says `ended` and `senior-dev's ending went to the chat` instead of the program's status." + - "senior-dev's page named its task three times — the trail's crumb (the conversation's name, which a conversation named after its work spells the same), the bold title, and `Reading: <title>` over the box — and opened on a clipped brief. The head is now the title row alone, with its badge, a `▸ brief` dropdown and the pinned facts; the dropdown (or `ctrl+o`) draws the whole brief in grey between the head's rules. Every action with more to show opens to its whole step — the command or file it was called with and what came back — on a click, and folds on another. The page's notes keep `senior-dev's ending went to the chat` and where the work is, not the program's status." + - "senior-dev's page said `wrote a.go` and `edited b.go` with nothing about how much each changed. Every change to the work now wears git's `+N,-M` at its right edge, the added lines in the diff's green and the removed in its red: counted by the file tools themselves (`write` now counts its lines as `edit` and `apply_patch` already did), carried on the protocol's `step` record as `added` and `removed`, and never on senior-dev's own spec, pinned check or checklist." + - "A second senior-dev run in a folder the last one left on its branch cut a new branch from it and called the first run's branch `your branch`; a second run that changed nothing deleted its own branch and switched back to the first's. It now carries on on the same branch (`carrying on on its branch task/x, where the last run left it; your branch main does not move`), its ending names the person's real branch, and nothing an earlier run committed is ever deleted." + - "A senior-dev run cannot wait and is never carried on, and nothing offered otherwise. A proposal handed to it whose `depends_on` names work that has not landed is refused before its card (`depends_on names task 3, which has not finished, and senior-dev starts the moment it is approved — it cannot wait. …`), where it used to start at once with its `depends_on` dropped; any task may name a senior-dev run that ended done, and one still going is refused in its own sentence. Its card offers no retry, the `@` block offers the stop and no steer (`senior-dev reads no messages; stop it with tasks id 7 stop`), and its row says `senior-dev's run is never carried on: its work is left where it ended, and a new hand-off starts a new run`. Its landed card, opened, and the chat's `tasks` tool say what the run cost." + - "Opening a task's room froze its side-list clock at the moment of the click, and the row kept drawing that stopped age (senior-dev's row read `2s` for over a minute beside a page reading `1m 21s`). The row now leaves its clock out while the room is open and reads the whole true age again when the person leaves." + - "`codeaf senior-dev [flags] <brief>` runs it from a shell in the current folder (or `--dir`) by the same rules as the chat — its own branch in a repository, in place in a plain folder — with `--max-cost`, `--max-hours`, `--json` and its own `--variant`, `--high`, `--in-place`; `codeaf --help` lists it. Its last line says what the run came to (`277 model calls · $2.30 · 22m 51s`), it waits for a cut call's price before it prints it, and a second ctrl-c leaves at once. A shell run keeps its record under `~/.codeaf/v3/carried/senior-dev/`. There is no `codeaf delegate` and no `/delegate`: \"delegate\" names the idea in code only." + - "`SIZE-BUDGET` was 54,600,000. It is 57,400,000: the old figure plus what the engine weighs on the heaviest platform, tabled in PERF.md, which also names that darwin/amd64 and linux/amd64 were already over the old figure before this change." + - "The chat's prompt-size caps (internal/session's prefixbudget_test.go) were 56,146 bytes for the full prefix and 48,814 for the lean one on dev, and neither weighed the programs paragraph. Both are fixed, and the caps are 57,124 and 49,590: raised by exactly what the paragraph and the preference for a program cost (978 and 776 bytes), on the owner's calls of 2026-09-23 and 2026-09-24 (\"raise the cap only as much as necessary\")." + - "A draft that installed programs from manifests in `~/.codeaf/delegates` was built and never shipped; it is kept on the tag `delegate-manifest-v1` for when programs from outside the binary return." +--- + +`docs/design/delegate/PROTOCOL.md` is the internal protocol (version 2); `internal/delegate` +is its specification in Go. senior-dev needs its model catalog (models.dev, fetched once and +cached); an offline machine with no cache refuses the run. diff --git a/docs/design/delegate/DESIGN.md b/docs/design/delegate/DESIGN.md new file mode 100644 index 0000000000..d647a4e91b --- /dev/null +++ b/docs/design/delegate/DESIGN.md @@ -0,0 +1,358 @@ +# Delegates — handing a task to an outside program — DESIGN (draft) + +> **Superseded in part on 2026-09-23.** The owner moved the first release to +> programs BUILT INTO codeaf: no manifests, no `~/.codeaf/delegates`, no install, +> no `/delegate`; senior-dev copied into `internal/seniordev` from swe-pro-go at +> the tag `codeaf-absorb` (`6103488`); its CLI is `codeaf senior-dev`; every +> model call goes through a per-run model API codeaf serves; and the task page +> shows the program's conversation with codeaf (since 2026-09-24, the actions it +> took, step by step, with the conversation one key away). The protocol is now internal, +> version 2: [PROTOCOL.md](PROTOCOL.md). What follows is the v1 design as it was +> built; the manifest road is kept on the tag `delegate-manifest-v1`. The run +> road, the answer folded in for text, the stop and the reader below all carry +> over. +> +> **Superseded again on 2026-09-24: a program works in the folder itself.** The +> owner asked why it was so hard to have senior-dev just work on the problem, +> and the copy per run, the brief's paths rewritten to name it, the squash and +> the HEAD-restoring landing were all deleted. A program that edits files now +> works in the folder the task names, on a branch codeaf cuts for it there when +> the folder is a git repository, and when it ends codeaf commits what it left +> onto that branch and leaves it checked out; the person's branch never moves. +> The contract is the header of `internal/session/programfolder.go`, and +> [PROTOCOL.md](PROTOCOL.md) §2 says it. Every "working copy", "squash" and +> "merge home" below is the design as it was built before that day. + + +*2026-09-21, revised 2026-09-23. Written against `dev @ 17ae56d34` and +`swe-pro-go @ 6103488` (branch `zeropoint95/improvements`, PR #30). Waves 1 to +4 are built on this branch; every senior-dev change this asked for has landed.* + +*The first delegate was called `swe-pro` when this was written. It was renamed +`senior-dev` in its own repository on 2026-09-22 (`b43daaf`): the binary, +`cmd/senior-dev`, the `.senior-dev/` run folder, `refs/senior-dev/*` and every +`SENIOR_DEV_*` variable. The repository and Go module keep the name +`swe-pro-go`. The commit pins below predate the rename and are still in its +history.* + +## In one paragraph + +A **delegate** is an outside program that does a whole coding task on its own. +You start one by typing its name as a command: + +``` +/senior-dev rewrite the auth middleware to use the new session store +``` + +That starts an ordinary **task**. It runs in the folder itself, on a branch of +its own in a repository, under your dollar and time limits, shows on the rail, +can be stopped, and leaves its branch checked out when it ends. The chat is not +blocked while it runs, but nothing else of codeaf's writes in that folder until +it ends (internal/session's programhold.go). Inside codeaf, a +delegate is one more **worker kind** behind the existing run supervisor. It is +not a second engine. + +`senior-dev` is the first delegate. Others are added later, one manifest each, +at the person's discretion. + +## Decisions already taken + +| decision | answer | date | +| --- | --- | --- | +| Name | **delegate**, not sub-harness (that word is taken, see below) | 2026-09-21 | +| Command | `/<name> <brief>`, one word per installed delegate | 2026-09-21 | +| What it starts | a task through the existing `/task` door, never a blocking turn | 2026-09-21 | +| Questions from the delegate | none. The brief must be self-sufficient | 2026-09-21 | +| senior-dev's `wip(edit)` commits | squashed into one commit at landing (2026-09-21); kept on the program's own branch, under one commit of what it left uncommitted, from 2026-09-24 | 2026-09-24 | +| senior-dev control plane | optional. Landed in senior-dev `f3b9716` | 2026-09-21 | +| Live cost from senior-dev | a top-level `spend` record. Landed in senior-dev `5793499` | 2026-09-22 | +| Steps from senior-dev | a `step` record per finished tool call. Landed in senior-dev `5793499` | 2026-09-22 | +| Command rows and the manual law | rows are generated at launch; each delegate ships its own manual page; the law is checked at load | 2026-09-21 | +| Readers | **one generic reader**, compiled in, over a small stdout protocol. No per-program reader | 2026-09-21 | +| Delegates that produce no tree | allowed. The manifest says `"lands": "text"` and the terminal record's text is the deliverable | 2026-09-21 | +| Stage records on the task page | stages feed the live step only; `step` records are the trajectory, so the step count is what the program said it did | 2026-09-22 | +| The task page draws actions, not a dialogue | **superseded the row above, 2026-09-24.** Every stage, step and ending is also written to the task's action log (`delegate-actions.jsonl`) as it arrives, stamped with codeaf's clock; the page draws the program's actions under the steps of its own process through the program's own vocabulary (`Delegate.Present`), and the raw calls are one key away (`ctrl+y`). The live step names the step the program is in. Steps still feed the trajectory | 2026-09-24 | +| Review round on a delegated run | none. A check seat is a bash-belt worker the belt switch may have left off; the program's own checking is in its result | 2026-09-22 | +| The run road and the belt switch | a delegated run takes the run road whatever `CODEAF_TASK_BELT` says; only the worker kind differs | 2026-09-22 | +| A delegate runs alone | nothing joins a delegated run and no delegate joins a run underway; both are refused naming the busy folder | 2026-09-22 | + +## Why not "sub-harness" + +The word already means something else in this repository: + +- `/harness` lists **saved shapes of work**: small programs built from this + binary's own node kinds (agent loop, tool call, verify, human gate). A + designer model builds one in conversation and saves it under + `~/.codeaf/harnesses/<name>/vN.json`. +- `/subharness` lists those plus bundles on disk and built-ins, and opens an + intake card for one. + +Both run **inside this process**. A delegate is an **outside binary** codeaf +cannot see into. One word for both would confuse the manual, and the manual +is what the chat answers from. + +| command | what it starts | who wrote it | where it runs | +| --- | --- | --- | --- | +| `/harness` | a saved shape of work | codeaf, at your request | in this process | +| `/subharness` | the same, through an intake card | codeaf or a bundle author | in this process | +| `/senior-dev` | an outside program | someone else | a child process in a working copy | + +## The command + +`/senior-dev <brief>` is `/task <brief>` with the worker already chosen. + +1. The same card appears. You answer it before money moves. +2. The turn ends. You are not held for the hour. +3. A run starts in the tasks store, in its own working copy. +4. It shows on the rail with a live step. `stop` works. +5. When it ends, the landing wakes a turn, as every task does today. That + turn reads senior-dev's terminal record and the landing note, and answers. + +The model never watches the stream. You watch the rail. + +**Rows are generated.** A manifest at `~/.codeaf/delegates/<name>.json` whose +binary is on PATH adds one row `/<name> <brief>` to the live command list, so +`/help` and the picker show it beside `/task`. No senior-dev on the machine means +no `/senior-dev` row. A name that collides with a built-in command or alias is +refused, naming the row. + +**The model can propose one too.** `propose_task` gets an optional `via` +field. The system prompt names installed delegates the same conditional way +it names everything else (`HANDOFF_FACTS` in `beltfacts.go`), and says when +to pick one: a change big enough to want its own agent for an hour, specified +well enough that nobody will be asked anything. + +**`/delegate`** (bare) lists the delegates on this machine: the binary each +resolved to, and the last run in two words. It answers "which do I have here". + +## Where it plugs in + +The run supervisor already gives every worker what a delegate needs. The +worker contract is one method: + +```go +// internal/run/worker.go +type Worker interface { + Run(ctx context.Context, task plandb.Task) (Report, error) +} +type Report struct { Result string; Steps int; USD float64; Waiting bool } +``` + +| already exists | where | +| --- | --- | +| cost and time ceilings handed to the worker | `run.Limits` | +| a spend bank the worker reports rising dollars into | `run.WithSpendBank` | +| the live step the rail draws | `plandb.Store.SetLive` / `ClearLive` | +| the trajectory the task page opens | `trajectory.jsonl` | +| a working copy cut per run | `task_run_copy.go` | +| landing and merge home | `run.Land`, `landBeltRun` | +| stop, proven reachable for every running row | `session.Cancel`, `stoplaw_test.go` | +| the run's dollars folded into the conversation total | `driveBeltRun`'s `foldSpend`, #1280 | + +**A delegate is a second `run.Worker`.** `CrewFactory` picks a worker per task +by role today. It gains one branch: a task whose row names a delegate gets a +`delegate.Worker` instead of a `BashWorker`. Nothing above the factory changes. + +**The floor.** With no change at all, the model can run `senior-dev run …` +through the `bash` tool in the background. That gives a job log and an exit +notice, and none of the rows in the table above. That gap is what this design +pays for. + +## The contract a program must meet + +| the program must | senior-dev today | +| --- | --- | +| **launch** from argv with brief, directory, dollar ceiling, wall ceiling | `senior-dev run --dir D --max-cost X --max-hours H -- "goal"` | +| **stream** progress as one JSON object per line on stdout, nothing else | yes, EVENTS-CONTRACT.md | +| **end** with exactly one terminal record: status, reason, `cost_usd` | yes, `{"type":"terminal",…}` | +| **stop** cleanly on SIGTERM, still writing the terminal record | yes. Only SIGKILL loses it | +| **leave its work in the tree** it was given, and nothing else | yes. `.senior-dev/` is git-excluded | + +Two things codeaf does **not** ask, and the manual page says so: + +- **No questions.** senior-dev auto-rejects its own `question` tool and has no + stdin road. Write the brief so nobody needs to be asked. +- **No step cap.** senior-dev has cost and hours only. The step count on the + task page is whatever the reader can count off the stream. + +### The manifest + +```jsonc +// ~/.codeaf/delegates/senior-dev.json +{ + "name": "senior-dev", // also the command: /senior-dev <brief> + "description": "an autonomous coding agent for one large, well-specified change", + "bin": "senior-dev", // resolved on PATH; a path is allowed + "argv": ["run", "--dir", "{{workspace}}", + "--max-cost", "{{cost_usd}}", "--max-hours", "{{hours}}", + "--", "{{brief}}"], + "env": { "OPENROUTER_API_KEY": "{{key:openrouter}}" }, + "lands": "tree", // "tree": squash and merge the copy; "text": the terminal's text is the answer + "limits": { "cost": true, "elapsed": true, "steps": false, "questions": false } +} +``` + +- `{{key:openrouter}}` resolves through `config.APIKeyAt`, the same door every + lane uses, so a key pasted at first-run setup reaches the delegate (#576). +- A `bin` not on PATH means the delegate is absent, not broken. `/delegate` + draws one dim line naming the binary it looked for. +- A `manual.md` ships beside the manifest. See *The manual law* below. + +### The protocol, and the one reader + +There is **one reader**, compiled in. It reads a small protocol on the +program's stdout: one JSON object per line, four record types, everything +else ignored. Ignoring the rest is what makes it generic: senior-dev's bus +payloads pass straight through it. + +| record | required fields | the reader makes it | +| --- | --- | --- | +| `{"type":"stage","stage":S,"status":T}` | `stage`, `status` | the live step, `S · T`, and one trajectory line | +| `{"type":"spend","cost_usd":C}` | `cost_usd`, cumulative, non-decreasing | banked spend | +| `{"type":"step","command":X,"observation":Y}` | `command`; `observation` optional | one trajectory step. `Steps` counts these. Optional: a program with no steps is drawn by its stages | +| `{"type":"terminal","status":U,"message":M,"data":{"cost_usd":C,…}}` | `status`, `message`, `data.cost_usd` | the `Report` and the outcome. Exactly one, last | + +`terminal.status` is a closed set, and it is senior-dev's: + +| `status` | run outcome | rail word | +| --- | --- | --- | +| `pass` | done | done | +| `fail` | ran and did not finish | incomplete | +| `budget-exhausted` | a limit you set stopped it | stopped, naming the limit (#1279) | +| `crashed` | ran and did not finish | incomplete | + +Process exit with no terminal seen is `ran and did not finish`, naming the +last stage seen. Optional `data` keys the landing note reads when present: +`reason`, `claim` (what the program's model said), `observed` (what the +program itself saw), `deliverable` (the answer text, for `"lands": "text"`). + +**What this costs each program:** + +- **senior-dev** emits all four in exactly this shape as of `5793499`. Its + `step` is one per tool call reaching `completed` or `error`, never twice + for a republished part; `command` is `tool: argument`, the argument capped + at 200 bytes; `observation` is the output or the error string, capped at + 2048 bytes on a rune boundary. Nothing to adapt. +- **pr-af** needs a one-shot mode that prints these four records and exits: + `stage` per review phase, `spend` per model call, `terminal` with the + findings as `data.deliverable`, and `"lands": "text"` in its manifest. + +**Never sum `cost` off senior-dev's `message.updated`.** An assistant message is +written more than once, so a naive sum double-counts. The `spend` record +exists for exactly this reason. + +senior-dev keeps the model's claim and its own observation as separate fields. +The landing note keeps them separate too: *senior-dev says it submitted; its +verification failed 2 of 5 commands* is two sentences. + +### Two kinds of landing + +| `lands` | working copy | when the program ends | +| --- | --- | --- | +| `tree` (senior-dev) | cut per run, passed as `{{workspace}}` | squash, merge home, landing card | +| `text` (pr-af) | none; `{{workspace}}` is the person's folder, read-only by contract | `data.deliverable` is folded into the conversation the way a quick task's answer is, and the woken turn reads it | + +### Money + +1. senior-dev spends the person's key outside codeaf's provider ledger. +2. The reader hands every rising `spend` figure to the supervisor's bank. +3. At the ceiling the supervisor cancels the context, which sends SIGTERM, + which lets senior-dev write its terminal record. +4. The conversation total, `/cost` and the status line move through + `foldSpend`, as for any run. +5. The on-disk usage ledger does **not** get senior-dev's calls, because they + did not go through a codeaf lane. The spending page says `via senior-dev`. + +The ceiling passed on the command line is what is left of the smaller of the +conversation's limits (`runCostLeft`, #1281), so senior-dev cuts itself first. + +### Stopping + +`stop` on the row is `session.Cancel` with a new kind, `delegate`, listed in +`stoplaw_test.go` with its proving test. The worker terminates the process +group, waits the job grace, then kills. A terminal record inside the grace is +read and folded. Without one the row reads `stopped` with the last stage seen. + +### Landing a `tree` delegate + +*As built on 2026-09-21 and deleted on 2026-09-24: since then senior-dev works in +the person's folder on a branch of its own, its `wip(edit)` commits stay on that +branch, what it left uncommitted is committed there when it ends, and the branch +is left checked out rather than merged. `refs/senior-dev/*` are written into the +person's repository and overwritten by the next run.* + +1. senior-dev works in the run's own copy, passed as `--dir`. +2. senior-dev commits every edit as it goes: `wip(edit): <path>`, dozens per run. + These stay on inside the copy, because senior-dev's crash recovery and its + restore-after-ship read them. +3. At landing codeaf **squashes** everything past the cut point into one + commit. Subject: the task's title. Body: two sentences from the terminal + record, what the model claimed and what senior-dev observed. +4. That one commit merges home the way every task lands. +5. `.senior-dev/` is git-excluded in the copy and never lands. `refs/senior-dev/*` + die with the copy. + +## The manual law + +Delegates are added at a person's discretion, so `/<name>` rows cannot be a +build-time list, and a page compiled into every binary cannot explain them. +The law stays: every command the chat offers is explained in the corpus. Where +it is enforced moves. + +1. **The static table keeps its static gate.** `internal/tui3`'s `commands` + and its test are unchanged. Delegate rows are appended to the live list at + launch and never enter the Go literal. +2. **Each delegate ships `manual.md`** beside its manifest, following the same + rules as `internal/manual/chat/` pages. At launch the chat's corpus is the + packed corpus plus an **overlay** of installed delegate pages. `manual.Corpus` + gains one constructor that layers pages over another corpus. The `manual` + tool then answers "what does /senior-dev do" from senior-dev's own page. +3. **The check runs at load.** A page that does not mention `/<name>` refuses + the manifest. `/delegate` shows why: `senior-dev: its manual page does not say + /senior-dev — not added`. +4. **One built-in page explains the family.** *Delegates — programs codeaf can + hand a task to* mentions `/delegate` and answers "what is a delegate", "how + do I add one", "why is there no /senior-dev here". It never names a delegate + the build cannot promise exists. + +## What senior-dev changed for this + +Landed 2026-09-21 and 2026-09-22 on `zeropoint95/improvements`, PR #30. + +1. **Control plane optional** (`f3b9716`). Reachable: mirrored as before. + Unreachable: one stderr line, and the run proceeds. The `run-contract` + record carries `"control_plane": {"enabled": false, "url": "<probed url>"}`. + `senior-dev serve` still requires a plane. The manifest sets no `SENIOR_DEV_CP_*` + variable. +2. **Live spend record** (`5793499`). `{"type":"spend","cost_usd":0.0213,"ts":…}`, + top-level, one per completed assistant message, cumulative, compaction + included. Emitted even at zero. Not projected onto the control plane. +3. **Step record** (`5793499`). `{"type":"step","command":"bash: go test ./...","observation":"…","ts":…}`, + one per finished tool call. stdout only, not in the stderr trace. +4. **No question road**, by decision. Auto-reject stays. + +Both stream additions were verified on the senior-dev side to touch only the +event layer: nothing under its engine, session, prompt builders or tool-result +path changed, and a standing test asserts the exact stdout record count. + +Checked by the senior-dev side against its code: the outcome table above holds, +SIGTERM still writes the terminal record, and `--` before the goal parses. + +## Waves + +| # | lands | proof | +| --- | --- | --- | +| **1** ✓ | `internal/delegate`: manifest and loader; `Worker` (spawn under `processgroup`, stream to the reader, SIGTERM then kill, `Report`); the one generic reader and its protocol, already written down in `docs/DELEGATE-PROTOCOL.md` | unit tests against a fake binary emitting scripted protocol lines and honouring SIGTERM; the outcome table pinned; a recorded senior-dev stream replayed through the reader | +| **2** ✓ | the door (`via` rides the run, not a store column: a delegated run is one task); `CrewFactory` branches on it; generated `/<name>` rows and `/delegate`; `propose_task.via`; `HANDOFF_FACTS`; the `delegate` cancel kind; squash-then-merge landing for `tree`, text fold for `text`; `via` on the spend row | focused `internal/session` and `internal/tui3` tests | +| **3** ✓ | the manual: the built-in *Delegates* page; the corpus overlay; the load-time page check; senior-dev's own `manual.md` | `internal/manual/chat_test.go` probes: "can you hand this to senior-dev", "what does /senior-dev do", "why can't the delegate ask me", "difference between /harness and /senior-dev" | +| **4** ✓ | hosted: the door crosses the wire (`Delegate.List`, `Delegate.Start`, wire version 18), so a `--host` surface generates its rows from the far machine's registry and a delegate runs there | `internal/remote` surface-door law; `internal/tui3` delegate tests | +| later | `codeaf do` speaking the protocol so codeaf on another machine is a delegate; pr-af's one-shot mode; delegates chosen by crew seat; answering a delegate's question | — | + +Wave 1 has no door and spends no money. Wave 2 is the first thing a person +can type. + +## Open questions + +1. **Who picks senior-dev's models.** Today its own `--high` default. The manifest + could pass codeaf's work seat, but senior-dev speaks OpenRouter slugs and the + seat may be on another lane. First cut: the manifest's argv, no seat. diff --git a/docs/design/delegate/PROTOCOL.md b/docs/design/delegate/PROTOCOL.md new file mode 100644 index 0000000000..f9af6ca270 --- /dev/null +++ b/docs/design/delegate/PROTOCOL.md @@ -0,0 +1,235 @@ +# The protocol, version 2 (internal) + +*2026-09-23. What a program codeaf carries does, and what codeaf does for it. It +replaces the public, manifest-based v1 (`docs/DELEGATE-PROTOCOL.md`, kept on the +tag `delegate-manifest-v1`). The owner's plan is the "Built-in delegates plan" +doc; the Go types in `internal/delegate` are the specification, and this page +says what they mean. "Delegate" is a working title: a person only ever reads the +program's own name.* + +## 1. What a program is + +A value in the build's list, `internal/delegate/builtin`, of type +`delegate.Delegate`: a name (the chat command `/<name>` and the shell verb +`codeaf <name>`), a one-line summary, a guide, what it lands (`tree` or `text`), +its commands with their own flags, its default command, the flags that command +takes to work in a folder with no git history (`PlainFolder`), and the name of +its page in the chat's manual. There is nothing to install. A program not in the list does +not exist anywhere; on Windows the list is empty. + +**The guide is the program describing itself to the model that hands it work:** +one paragraph of at most 400 bytes (`delegate.GuideMax`) saying what it is for, +and what its brief must hold. The conversation prints +it under the program's name, beside `propose_task`'s `via`, and says nothing about +the program of its own. It rides every request of every turn, which is why it is +short and why the manual page carries the rest. + +**The program owns what is true of it; codeaf owns what is true of every +program.** That a program that edits files works in the task's folder itself, on +a branch of its own in a repository, and so the rule that it must be handed the +repository the work belongs in (cloned first when the machine lacks it, and never +briefed to work anywhere else) are codeaf's to say, once, beside the list; the +rule is printed only when a program that lands a tree is carried. That nobody can be asked +anything is `propose_task`'s own. A guide repeats none of it. + +A program cannot run on its own. Its entry point is a `Command` whose body takes +a `delegate.Host`, and only codeaf makes one. + +## 2. How it runs + +Always as a child process of codeaf's own executable: + +``` +codeaf <name> <command> --json --dir <workspace> [--max-cost USD] [--max-hours H] [plain-folder flags] -- <brief> +``` + +**The folder is codeaf's to read, and the program is told what it found.** A +program that edits files works in the folder itself, never a copy +(`internal/session`'s `programfolder.go`, whose header is the contract): the +folder the proposal names, or the conversation's, or the shell's, snapped to its +repository's root. In a repository with a commit whose root is below the home +folder, codeaf writes the person's branch down, refuses a checkout with changes +that are not committed or a merge half done, and cuts the program's own branch +with `git switch -c task/<title>-<id>`; the program works there in its own git +mode. Anything else — no history, no commit, or a repository at the home folder — +is worked in as it is, and codeaf puts the program's own `PlainFolder` flags on +its line (senior-dev's is `--in-place`), because the program's own reading climbs +to any repository around the folder. senior-dev reads its folder too, and uses +git only where there is a work tree with a commit, so the flag is needed only +where git IS there and must not be used (the home folder's repository) — a +plain folder never ends it, whatever its line says. codeaf never learns a program's flag by +name, and a flag the default command does not take fails `Validate`, so the +build's own test catches it. One folder takes one program run at a time, held by +a file lock that dies with its process. + +**The brief is handed over as written.** There is no copy for a path in it to be +rewritten into. + +**A tree program's work stays on its branch, checked out.** When the run ends, +however it ends, codeaf commits what the program left uncommitted onto its branch +(the task's title, the ending as the body), moves the program's notes out of the +folder, and leaves the branch checked out; the person's branch never moves and +nothing is merged into it. The page says `its work is on the branch <branch> in +<folder>, N files, and that branch is checked out there; your branch <yours> is as +it was: …` with the commands that go back and bring the work in. A run that +changed nothing switches back and deletes its empty branch; a HEAD the program's +shell moved off its branch is left where it is and said. + +**The crew rides the line.** A run a conversation starts carries its crew +(`delegate.Crew`: brain, hands, light — the mastermind, worker and low tiers, +effort taken off) in the program's own flags (`Delegate.CrewFlags`); a program +with none picks its own models. senior-dev's are `--crew --high <hands> +--frontier <brain> --low <light>`, and `--crew` makes it drop a model its +catalog cannot size, and route on its own list if the working seat is left +empty, rather than fail the run. `Validate` parses the flags with the default +command, as it does the plain-folder ones. + +**A program's own ending names the row.** A terminal that is not `pass` +reaches the session typed (`run.ProgramEndedError` → `session.ProgramEnding`): +`fail` and `budget` end the row on `TaskEndingProgram`, whose reason is the +program's sentence (`senior-dev did not finish: …`) and which is not a fault; +`crashed` is `TaskEndingError`, the fault it is. + +- **From the chat,** the engine's run (`internal/run`'s `DelegateWorker`) starts + that line in the folder the proposal names (`propose_task`'s `ground`) or else + the conversation's own. +- **From a shell,** `codeaf <name> <brief>` becomes the host: it serves the model + API itself, readies its folder the same way, and starts the same child. + +The two are told apart by the environment. A child of a host has +`CODEAF_MODEL_API` and `CODEAF_MODEL_TOKEN`; a person's shell has neither. + +The child's environment is the parent's with every provider key and model +redirection codeaf knows of removed (`delegate.ChildEnv`). The program passes its +environment on to every command its model runs, so a key left there would be one +any model-written shell line could print. + +## 3. The model API — the only road to a model + +For each run codeaf serves an OpenAI-style chat-completions API at +`CODEAF_MODEL_API` (a base URL), opened by the bearer token in +`CODEAF_MODEL_TOKEN` and by nothing else. It lives in `internal/provider`, the +one package codeaf's funnel law lets spell a model route. Every call: + +1. is refused before it is made when the run's dollar ceiling is reached, with + HTTP 402 (a status senior-dev does not retry). A run a refusal ended is + reported as `<name> reached the run's dollar ceiling of $X: …`, whatever + status the program itself wrote, and ends on the run's cost limit; +2. goes through codeaf's own model funnel, with its router, retries, caching and + billing, on the model the program asked for when one of the person's + services can serve it, and otherwise on the run's work seat, which the turn + names in `Served` (`modelapi.Resolve`; a call is never refused only because + the machine does not know the id); +3. is answered in the OpenRouter shape, `usage.cost` included, streamed with + keepalives while a long call is thinking, or as one body when it was not + streamed (`response_format` carried); +4. is banked to the task's spend and the spending ledger, and written to the + run's conversation log. + +The token dies with the run, so a grandchild that outlives its parent can no +longer spend. A call's thread is its `prompt_cache_key`, or its +`x-session-affinity` header when the body carries no key; reasoning effort rides +codeaf's own effort ladder. + +A shell run (`codeaf <name> …`) has no task folder, so its record — the +conversation log, the program record and the program's stderr — goes to +`~/.codeaf/v3/carried/<name>/<when>/`, one folder per run. Its child is started +with the person's own line plus `--json`, so a command other than the default +and the command's own flags survive. + +## 4. The records — stdout, one JSON object per line + +| record | when | fields | +| --- | --- | --- | +| `hello` | first | `protocol` (2), `delegate`, `stages` (the whole list, in order) | +| `stage` | on every phase change | `stage`, `status`, and optionally `data`: a JSON object of at most 1024 bytes (`delegate.StageDataCap`) | +| `step` | once per finished action | `command` (one line, 200 bytes at most), `observation` (2048 bytes at most), and optionally `tool` (the tool's name), `step` (the program's own id for the part of its process the action served), `exit` (a command's exit code, only for an action that ran one), and `added` and `removed` (the lines an action that changed a file added and removed, only when the program counted them) | +| `terminal` | last, exactly once, on every path | `status` (`pass`, `fail`, `budget-exhausted`, `crashed`), `message`, `data`: `reason`, `claim`, `observed`, `deliverable`, and anything else | + +Any other line is ignored. There is no `spend` record: the model API meters +every call as it is made, so money has one source of truth and it is not the +program's word. + +**The optional fields are additive, and they are version 2.** The version moves +only when a record changes meaning (`delegate.ProtocolVersion`); a field a +reader does not know is ignored like any other, so a reader that predates +`data`, `tool`, `step` and `exit` reads the same records without them, and a +program that sends none of them is read exactly as before. They are read +forgivingly: a `tool`, `step`, `exit`, `added` or `removed` of another JSON shape is left off and +the step kept, and `data` that is not an object, or is past the cap, is left +off and the stage kept. + +A stage's `data` is a small, curated copy of what the program already knows +about the phase — senior-dev's is an attempt, a retry count, its checklist's +counts, the hand-in's size, what its own check found, the model it moved to — +for a page to say in words; the program's whole account stays on its stderr. +senior-dev's step ids are `brief`, `explore`, `pin`, `checklist`, `implement`, +`submit` and `verify` (`internal/seniordev/app`'s `Steps`); the last is the +project's own build and tests, which senior-dev runs itself with no model after +the hand-in and when it checks the tree mid-run, each command one step. + +A `hello` carrying another protocol number means the engine outlived a rebuild +and started the new binary as its child. The run is stopped before it spends, +with the reason `codeaf was rebuilt while this conversation was open …; restart +codeaf to run <name>`. + +## 5. Stop + +SIGTERM to the process group, a 15-second grace, then SIGKILL. On SIGTERM the +program stops starting new work, writes its terminal, and exits. A body that +returns without writing a terminal gets one written for it (`delegate.RunChild`). + +A host that dies without a word — killed, or taken by a closed terminal's +hangup, which never reaches a child in a process group of its own — sends no +SIGTERM. The child looks for its parent once a second and, when the process that +started it is no longer its parent, stops exactly as a SIGTERM would stop it, and +is ended outright if it is still at work when the grace has passed +(`delegate.RunChild`'s `watchHost`). A shell run's host itself treats SIGHUP as +its first ctrl-c. + +## 6. The conversation log and the action log + +`delegate-conversation.jsonl` in the task's record folder, one `delegate.Turn` +per model call: the thread, the model asked for and the one that answered, what +the program sent that the thread's previous call had not, the reply and the tool +calls, tokens and cost, and codeaf's refusal or the model's failure. A call is +written when it starts and again when it ends, and a reader keeps the later +record, so the task page shows the call in flight. + +`delegate-actions.jsonl` beside it, one `delegate.Action` per record the program +wrote — `stage`, `step`, and its ending (`end`: the terminal's status and +message) — each stamped `at` with the moment codeaf received it, because a +program's own clock is not trusted and the page merges this log with the +conversation log, whose times are codeaf's too. The run's worker writes it and +so does a shell run, into its own record folder; it is capped as the turns are. + +**The page draws actions, not the dialogue.** A program's own vocabulary +(`Delegate.Present`, a reader told every line of the log in order) turns each +line into what a person reads under the step of the program's process it +served (`delegate.Shown`); the page merges those with what only the calls know +— a compaction, a change of the model answering, a refused or failed call — by +time, and keeps the dialogue of the raw calls one key away. The live step names +the step the program is in: the word the program's reader gives the latest +record that named one, and its stage's word before any has. + +## 7. What a program may not do + +- Ask a person anything. Nobody is at its keyboard. (Later: a tool codeaf runs + inside the model API.) +- Read stdin. +- Reach a model any way but the model API. +- Write anything on stdout that is not a record on its own line. +- For `tree`: touch files outside its workspace, or leave anything in it that is + not its work (its own state git-excluded). senior-dev enforces the first for its + file tools: `write`, `edit` and `apply_patch` refuse a path outside the + workspace, links resolved (`tool.RegistryOptions.ConfineWrites`), while reads + stay open. Its shell is not fenced; its prompt says that nothing a shell + command changes outside the workspace comes back. + +## 8. Built in now for later programs + +pr-af and sec-af, looked at on 2026-09-23, would need: plain structured calls +with `response_format`, many conversations at once (kept apart by thread), +grandchildren inheriting the API's address and token, quiet stretches of up to +30 minutes, and text landings with attachments. The first four are in v2 from +the start; attachments come with the first text program. diff --git a/internal/config/config.go b/internal/config/config.go index 9ce82f04aa..bc1484afae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -360,6 +360,11 @@ func LoadKeyless() (Config, error) { return load(false) } // though NewSettings(...).Rows() does not list them. var nonSettingProfileFields = []string{ KeySetupSeen, + // The talk lane's borrow row sits beside its lane row and is read by + // [LaneBorrowAt], but it is set from the lane page and not from a settings + // row of its own. Missing here, every profile the lane page had written + // was told at launch that a key codeaf reads was unread. + LaneBorrowKey(LaneSlotTalk), KeySplitPct, KeyStandingBackground, KeyResponseAttempts, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index efc2d2661b..9ea3e63474 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -670,6 +670,18 @@ func TestProfileKeyLedgerLaw(t *testing.T) { } } +// A profile the lane page wrote carries the talk lane's borrow row, which a +// reader consumes, so it is never reported unread. +func TestTheLaneBorrowRowIsNotReportedUnread(t *testing.T) { + values := map[string]json.RawMessage{ + LaneSettingKey(LaneSlotTalk): json.RawMessage(`"openrouter"`), + LaneBorrowKey(LaneSlotTalk): json.RawMessage(`false`), + } + if unread := warnUnreadProfileKeys(t.TempDir(), values); len(unread) != 0 { + t.Fatalf("the lane rows were reported unread: %v", unread) + } +} + // profileKeysWrittenByRows drives every settings row through its own writer, one // row to one empty profile, and answers every top-level key that landed in the // file with the row that wrote it. diff --git a/internal/config/settings.go b/internal/config/settings.go index ffe531bd45..2114c8fe70 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1128,6 +1128,12 @@ var OperatorEnvPins = []string{ // would be promising an override that does nothing, which is worse than // saying nothing at all. "CODEAF_PROFILE_DIR", + // The model API codeaf serves one program's run, and the token for it + // (internal/delegate's ChildEnv). codeaf sets them on the child it starts + // and nobody else does; they are an address and a credential, so plumbing, + // and the footer names them and never shows a value. + "CODEAF_MODEL_API", + "CODEAF_MODEL_TOKEN", // The release check's one-launch opt-out and its two mirror addresses // (internal/update). They are plumbing rather than settings rows: the first // is a shell's decision not to make a launch request, while the other two diff --git a/internal/delegate/actions.go b/internal/delegate/actions.go new file mode 100644 index 0000000000..9f95e93e63 --- /dev/null +++ b/internal/delegate/actions.go @@ -0,0 +1,254 @@ +package delegate + +// The action log: every stage, step and ending a program reported, one line +// each, stamped with the moment codeaf received it, kept in the task's own +// record folder beside the conversation log. The run's worker (internal/run) +// and the shell verb (cmd/codeaf) write it as the records arrive; the task page +// reads it (internal/session) and draws the program's work as the actions it +// took, each under the step of the program's own process it served. +// +// THE TIME IS CODEAF'S. A program's records carry no clock of their own that +// codeaf trusts, and the page merges this log with the conversation log, whose +// every time is codeaf's too; stamping on receipt is what makes the two one +// timeline. +// +// THE WORDS ARE THE PROGRAM'S. A line keeps the record as the program wrote it +// — its stage and status, its step id, its data — and the program's own +// vocabulary ([Delegate.Present]) turns a line into what a person reads, at the +// moment the page is read. So a program that learns to say a thing better says +// it better about every run it has made. + +import ( + "bufio" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// ActionsFile is the log's name inside a task's record folder. +const ActionsFile = "delegate-actions.jsonl" + +// The kinds of line the log holds: a stage record, a step record, and the +// terminal record's status and message. +const ( + ActionStage = "stage" + ActionStep = "step" + ActionEnd = "end" +) + +// Action is one line of the log. +type Action struct { + // At is when codeaf received the record. + At time.Time `json:"at"` + Kind string `json:"kind"` + // Stage, Status and Data are a stage's; Status is the ending's too. + Stage string `json:"stage,omitempty"` + Status string `json:"status,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + // Tool, Step, Command, Observation and Exit are a step's. + Tool string `json:"tool,omitempty"` + Step string `json:"step,omitempty"` + Command string `json:"command,omitempty"` + Observation string `json:"observation,omitempty"` + Exit *int `json:"exit,omitempty"` + // Added and Removed are a step's lines added and removed, when the program + // counted them. + Added *int `json:"added,omitempty"` + Removed *int `json:"removed,omitempty"` + // Message is the ending's one sentence. + Message string `json:"message,omitempty"` +} + +// StageAction is a stage record as a line of the log, received at at. +func StageAction(at time.Time, record StageRecord) Action { + return Action{At: at, Kind: ActionStage, Stage: record.Stage, Status: record.Status, Data: record.Data} +} + +// StepAction is a step record as a line of the log, received at at. +func StepAction(at time.Time, record StepRecord) Action { + return Action{ + At: at, Kind: ActionStep, Tool: record.Tool, Step: record.Step, + Command: record.Command, Observation: record.Observation, Exit: record.Exit, + Added: record.Added, Removed: record.Removed, + } +} + +// EndAction is the terminal record as the log's last line: its status and its +// sentence. The rest of the record is the result's, which the run keeps whole. +func EndAction(at time.Time, t Terminal) Action { + return Action{At: at, Kind: ActionEnd, Status: t.Status, Message: t.Message} +} + +// capped is the line as it is written: every text held to the reader's own +// caps, the observation and the message to a turn's, and data that is not an +// object under [StageDataCap] left off. +func (a Action) capped() Action { + a.Command = cut(oneLine(a.Command), commandCap) + a.Observation = cut(a.Observation, turnTextCap) + a.Message = cut(a.Message, turnTextCap) + a.Tool, a.Step = label(a.Tool), label(a.Step) + a.Stage, a.Status = label(a.Stage), label(a.Status) + a.Data = stageData(a.Data) + return a +} + +// AppendAction writes one line to the log in dir, capped, in one write, making +// the folder when it is not there. +func AppendAction(dir string, action Action) error { + line, err := json.Marshal(action.capped()) + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + file, err := os.OpenFile(filepath.Join(dir, ActionsFile), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := file.Write(append(line, '\n')); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +// ReadActions reads the log in dir in the order it was written, and the last n +// lines of it (n <= 0 for all). A log that is not there is no actions and no +// error — a run from before the log existed, or one whose program has said +// nothing yet — and a line that does not parse is skipped, because a log cut +// mid-write is still a log. +func ReadActions(dir string, n int) ([]Action, error) { + file, err := os.Open(filepath.Join(dir, ActionsFile)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer file.Close() + var actions []Action + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64<<10), maxLineBytes) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var action Action + if json.Unmarshal([]byte(line), &action) != nil || action.Kind == "" { + continue + } + actions = append(actions, action) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if n > 0 && len(actions) > n { + actions = actions[len(actions)-n:] + } + return actions, nil +} + +// Shown is one action as a person reads it on its program's page: the step it +// belongs under, the words, and how it came out. A program's own vocabulary +// makes it ([Delegate.Present]); a program with none is read plainly +// ([Delegate.Reader]). +type Shown struct { + At time.Time `json:"at"` + // Step is the word for the part of the program's process the action + // served, printed once at the head of each run of actions in it. Empty is + // an action inside whatever step is under way. + Step string `json:"step,omitempty"` + // Text is the action in words: `read internal/auth/middleware.go`. + Text string `json:"text"` + // Outcome is how it came out, in a word or two: `passes`, `fails · exit 2`, + // `4 files`. Empty when there is nothing to say. + Outcome string `json:"outcome,omitempty"` + // Detail is the whole of the step as the log kept it — the command or + // argument, and what came back — which the page opens under the action's + // one line when it is clicked. Empty for a line with nothing more to show. + Detail string `json:"detail,omitempty"` + // Lines says the action changed a file and counted how: Added and Removed + // are its lines added and removed, drawn as `+N,-M` in the diff's own + // colours beside the action. False for every other action. + Lines bool `json:"lines,omitempty"` + Added int `json:"added,omitempty"` + Removed int `json:"removed,omitempty"` + // Steer marks the program steering its own model — a nudge, a last turn, a + // retry after a dropped call, a correction — rather than working through it. + Steer bool `json:"steer,omitempty"` + // Memory marks the action that says the program compacted its memory, and + // Model names the model an action says it moved to, with Reason why. The + // page reads both beside the conversation log, which says the same two + // things from the model's side, so one compaction or one switch is drawn + // once. + Memory bool `json:"memory,omitempty"` + Model string `json:"model,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// ActionReader reads a program's action log for its page: each line, in the +// order the log holds them, as the words a person reads ([Shown]), and false +// for a line the page leaves out. A reader may remember the lines before — a +// program can only tell its model's second nudge from a retry of the first by +// what came earlier — so one reader reads one log, from its first line. +type ActionReader func(Action) (Shown, bool) + +// Reader is a fresh reader of this program's action log: through the program's +// own vocabulary when it has one ([Delegate.Present]), and plainly otherwise — +// a stage as its name and status, a step as its command with its step id as +// the step's word and its exit as the outcome, the ending as its sentence. A +// line with no words is left out, and every line shown keeps the moment codeaf +// received it. +func (d Delegate) Reader() ActionReader { + read := ActionReader(plainShown) + if d.Present != nil { + if own := d.Present(); own != nil { + read = own + } + } + return func(action Action) (Shown, bool) { + shown, ok := read(action) + if !ok || strings.TrimSpace(shown.Text) == "" { + return Shown{}, false + } + shown.At = action.At + return shown, true + } +} + +// plainShown is a program's action read with no vocabulary of its own. +func plainShown(action Action) (Shown, bool) { + switch action.Kind { + case ActionStage: + text := action.Stage + if action.Status != "" { + text += " · " + action.Status + } + return Shown{Text: text}, true + case ActionStep: + return Shown{Step: action.Step, Text: action.Command, Outcome: ExitWord(action.Exit)}, true + case ActionEnd: + return Shown{Text: action.Message}, true + } + return Shown{}, false +} + +// ExitWord is how a command came out, in the words every program's page uses: +// `passes` for an exit of 0, `fails · exit N` for any other, and nothing for an +// action that ran no command or learned no exit. +func ExitWord(exit *int) string { + if exit == nil { + return "" + } + if *exit == 0 { + return "passes" + } + return "fails · exit " + strconv.Itoa(*exit) +} diff --git a/internal/delegate/actions_test.go b/internal/delegate/actions_test.go new file mode 100644 index 0000000000..44d985cab2 --- /dev/null +++ b/internal/delegate/actions_test.go @@ -0,0 +1,94 @@ +package delegate + +import ( + "strings" + "testing" + "time" +) + +// THE ACTION LOG KEEPS EVERY RECORD AS IT WAS RECEIVED, stamped with codeaf's +// own clock, in the order it arrived: a stage with its data, a step with its +// tool, step and exit, and the ending's status and sentence. +func TestTheActionLogKeepsEachRecordInTheOrderItArrived(t *testing.T) { + dir := t.TempDir() + at := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC) + exit := 1 + for _, action := range []Action{ + StageAction(at, StageRecord{Stage: "submit", Status: "frozen", Data: []byte(`{"patch_files":4}`)}), + StepAction(at.Add(time.Second), StepRecord{Command: "bash: go test ./...", Observation: "FAIL", Tool: "bash", Step: "verify", Exit: &exit}), + EndAction(at.Add(2*time.Second), Terminal{Status: StatusPass, Message: "submitted a change"}), + } { + if err := AppendAction(dir, action); err != nil { + t.Fatal(err) + } + } + actions, err := ReadActions(dir, 0) + if err != nil || len(actions) != 3 { + t.Fatalf("actions %+v, err %v", actions, err) + } + if a := actions[0]; a.Kind != ActionStage || a.Stage != "submit" || string(a.Data) != `{"patch_files":4}` || !a.At.Equal(at) { + t.Fatalf("the stage read back as %+v", a) + } + if a := actions[1]; a.Kind != ActionStep || a.Tool != "bash" || a.Step != "verify" || a.Exit == nil || *a.Exit != 1 { + t.Fatalf("the step read back as %+v", a) + } + if a := actions[2]; a.Kind != ActionEnd || a.Status != StatusPass || a.Message != "submitted a change" { + t.Fatalf("the ending read back as %+v", a) + } + if last, _ := ReadActions(dir, 1); len(last) != 1 || last[0].Kind != ActionEnd { + t.Fatalf("the last line = %+v", last) + } +} + +// A LINE IS WRITTEN CAPPED, and a log that is not there — a run from before the +// log existed — is no actions and no error. +func TestAnActionIsWrittenCappedAndAMissingLogIsEmpty(t *testing.T) { + dir := t.TempDir() + if actions, err := ReadActions(dir, 0); err != nil || len(actions) != 0 { + t.Fatalf("a missing log read %v, %v", actions, err) + } + err := AppendAction(dir, Action{Kind: ActionStep, Command: "bash:\n" + strings.Repeat("x", 500), + Observation: strings.Repeat("é", 3000), Data: []byte(`[1]`)}) + if err != nil { + t.Fatal(err) + } + actions, _ := ReadActions(dir, 0) + a := actions[0] + if len(a.Command) > commandCap || strings.Contains(a.Command, "\n") || len(a.Observation) > turnTextCap || a.Data != nil { + t.Fatalf("the line was written %d/%d bytes, data %s", len(a.Command), len(a.Observation), a.Data) + } +} + +// A PROGRAM WITH NO VOCABULARY IS READ PLAINLY: a stage as its name and +// status, a step as its command under its own step id with its exit said, and +// the ending as its sentence. +func TestAProgramWithNoVocabularyIsReadPlainly(t *testing.T) { + plain := Delegate{Name: "fake"} + at := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC) + exit := 2 + for _, tc := range []struct { + action Action + want Shown + }{ + {StageAction(at, StageRecord{Stage: "implement", Status: "running"}), Shown{At: at, Text: "implement · running"}}, + {StepAction(at, StepRecord{Command: "bash: go test", Step: "verify", Exit: &exit}), Shown{At: at, Step: "verify", Text: "bash: go test", Outcome: "fails · exit 2"}}, + {EndAction(at, Terminal{Status: StatusFail, Message: "it did not finish"}), Shown{At: at, Text: "it did not finish"}}, + } { + got, ok := plain.Reader()(tc.action) + if !ok || got != tc.want { + t.Errorf("Show(%+v) = %+v, %v; want %+v", tc.action, got, ok, tc.want) + } + } + zero := 0 + if ExitWord(&zero) != "passes" || ExitWord(nil) != "" { + t.Fatalf("exit words = %q, %q", ExitWord(&zero), ExitWord(nil)) + } + // A PROGRAM'S OWN VOCABULARY IS WHAT IT SAYS, and nothing with no words is + // drawn. + own := Delegate{Name: "fake", Present: func() ActionReader { + return func(Action) (Shown, bool) { return Shown{Text: ""}, true } + }} + if _, ok := own.Reader()(StageAction(at, StageRecord{Stage: "x"})); ok { + t.Fatal("an action with no words was shown") + } +} diff --git a/internal/delegate/builtin/builtin.go b/internal/delegate/builtin/builtin.go new file mode 100644 index 0000000000..7f79641923 --- /dev/null +++ b/internal/delegate/builtin/builtin.go @@ -0,0 +1,54 @@ +// Package builtin is the list of programs this build carries — the one place a +// program becomes part of codeaf (internal/delegate). The chat's rows, the +// command line's verbs, the prompt's hand-off paragraph and the manual all read +// this list, so a program is added by one package and one line here, and a +// program not on it does not exist anywhere. +// +// IT IS A LIST IN CODE, NOT A FOLDER ON THE MACHINE. Nothing is installed, and +// no program can differ from the codeaf it ships in. Programs from outside the +// binary are a later road; the first draft of it, manifests read from disk, +// is kept on the tag delegate-manifest-v1. +// +// THIS PACKAGE IS WHERE THE WEIGHT IS. It imports every program it carries, so +// only the doors that must hand a program to something — the command line and +// the chat's launch, both in cmd/codeaf — import it. internal/session and +// internal/run are handed the list and never import it, or a test binary of +// either would carry every program's engine. +package builtin + +import ( + "sort" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// list is what this build carries: [carried] for this platform, or what a +// test put in its place ([Override]). +var list = carried + +// All is every program this build carries, sorted by name, which is the order +// lists draw them. +func All() []delegate.Delegate { + out := append([]delegate.Delegate(nil), list...) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Find answers the program with this name. +func Find(name string) (delegate.Delegate, bool) { + for _, program := range list { + if program.Name == name { + return program, true + } + } + return delegate.Delegate{}, false +} + +// Override puts programs in the list's place and answers the restore. It is +// for tests of the doors that read the list, which need a program to exist +// that is not senior-dev's whole engine; nothing in the product calls it. +func Override(programs []delegate.Delegate) (restore func()) { + previous := list + list = programs + return func() { list = previous } +} diff --git a/internal/delegate/builtin/builtin_test.go b/internal/delegate/builtin/builtin_test.go new file mode 100644 index 0000000000..67df900724 --- /dev/null +++ b/internal/delegate/builtin/builtin_test.go @@ -0,0 +1,48 @@ +package builtin + +import ( + "bytes" + "strings" + "testing" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// Every program this build carries is one that can run: its definition +// validates, and no two share a name. +func TestEveryCarriedProgramIsWellDefined(t *testing.T) { + seen := map[string]bool{} + for _, program := range All() { + if err := program.Validate(); err != nil { + t.Errorf("%v", err) + } + if seen[program.Name] { + t.Errorf("two programs are called %s", program.Name) + } + seen[program.Name] = true + } +} + +// EVERY HELP PAGE A CARRIED PROGRAM PRINTS FITS EIGHTY CELLS: its own page and +// each command's, codeaf's shared flags included, the width every page of +// `codeaf --help` is held to. +func TestEveryCarriedProgramsHelpFitsEightyColumns(t *testing.T) { + for _, program := range All() { + lines := [][]string{{"--help"}} + for _, command := range program.Commands { + lines = append(lines, []string{command.Name, "--help"}) + } + for _, line := range lines { + var out bytes.Buffer + if _, err := delegate.Parse(program, line, &out); err != delegate.ErrHelp { + t.Fatalf("%s %v: err = %v, want the help", program.Name, line, err) + } + for at, printed := range strings.Split(strings.TrimRight(out.String(), "\n"), "\n") { + if width := utf8.RuneCountInString(printed); width > 80 { + t.Errorf("codeaf %s %s line %d draws %d cells: %q", program.Name, strings.Join(line, " "), at+1, width, printed) + } + } + } + } +} diff --git a/internal/delegate/builtin/carried_unix.go b/internal/delegate/builtin/carried_unix.go new file mode 100644 index 0000000000..52e54d097a --- /dev/null +++ b/internal/delegate/builtin/carried_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package builtin + +import ( + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev" +) + +// carried is every program this build carries on a unix: senior-dev, whose +// engine lives in internal/seniordev. +var carried = []delegate.Delegate{seniordev.Program} diff --git a/internal/delegate/builtin/carried_windows.go b/internal/delegate/builtin/carried_windows.go new file mode 100644 index 0000000000..e2cf947657 --- /dev/null +++ b/internal/delegate/builtin/carried_windows.go @@ -0,0 +1,11 @@ +//go:build windows + +package builtin + +import "github.com/Agent-Field/codeaf/internal/delegate" + +// carried is empty on Windows. senior-dev's engine uses process groups, file +// locks and a bash shell, none of which it has ever had a Windows form of, so +// on Windows it is ABSENT — no row, no verb, no paragraph in the prompt — +// rather than present and failing every time it is asked. +var carried []delegate.Delegate diff --git a/internal/delegate/cli.go b/internal/delegate/cli.go new file mode 100644 index 0000000000..d93b55f910 --- /dev/null +++ b/internal/delegate/cli.go @@ -0,0 +1,309 @@ +package delegate + +// The command line every program answers: `codeaf <name> [command] [flags] +// [--] <brief>`. codeaf owns the verb, the dispatch and the four flags every +// program shares; the program owns its commands and their flags. The same +// line is what a person types at a shell and what a chat's run starts its +// child with ([ChildArgs]), so there is one parser for both. + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// ErrHelp is Parse's answer when the line asked for help and got it. +var ErrHelp = flag.ErrHelp + +// Invocation is one `codeaf <name> …` line, parsed. +type Invocation struct { + Program Delegate + Command Command + // Workspace is --dir, absolute; the current folder when it was not given. + Workspace string + // Ceilings are --max-cost and --max-hours. + Ceilings Ceilings + // JSON is --json: the records on stdout instead of readable lines. A child + // of a host always writes records, so for it the flag only says so aloud. + JSON bool + // Args is what the flags left: the brief's words. + Args []string + // Line is the arguments exactly as given after the name, so a host can hand + // its child the same line it was handed. + Line []string + body Body +} + +// Brief is the brief's words, joined. +func (inv *Invocation) Brief() string { return strings.TrimSpace(strings.Join(inv.Args, " ")) } + +// Parse reads the arguments after `codeaf <name>`. The first word picks a +// command when it names one; otherwise the program's default command runs on +// the whole line, so `codeaf senior-dev fix the flaky test` is its `run`. Help +// (`-h`, `--help`, or `help` as the first word) is written to out and answered +// as ErrHelp. +func Parse(program Delegate, line []string, out io.Writer) (*Invocation, error) { + rest := line + if len(rest) > 0 { + switch rest[0] { + case "help", "-h", "-help", "--help": + // THE PROGRAM'S OWN PAGE FOR A BARE ASK. `codeaf <name> --help` is + // asked before any command is named, so it answers with what the + // program is and every command it has; a command's own flags are + // one `codeaf <name> <command> --help` away, as the page ends by + // saying. + Help(program, out) + return nil, ErrHelp + } + } + command, named := program.Command(program.Default) + if len(rest) > 0 { + if c, ok := program.Command(rest[0]); ok { + command, named, rest = c, true, rest[1:] + } + } + if !named || command.Bind == nil { + return nil, fmt.Errorf("%s has no command %q", program.Name, program.Default) + } + fs := flag.NewFlagSet(program.Name+" "+command.Name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + dir := fs.String("dir", "", "the folder to work in (default: the current folder)") + cost := fs.Float64("max-cost", 0, "a dollar ceiling; codeaf refuses the call that would cross it") + hours := fs.Float64("max-hours", 0, "a ceiling in hours of wall-clock time") + asJSON := fs.Bool("json", false, "write the records on stdout instead of readable lines") + body := command.Bind(fs) + if body == nil { + return nil, fmt.Errorf("%s %s: %w", program.Name, command.Name, errNoBody) + } + if err := fs.Parse(rest); err != nil { + if errors.Is(err, flag.ErrHelp) { + commandHelp(program, command, fs, out) + return nil, ErrHelp + } + return nil, fmt.Errorf("%s %s: %w", program.Name, command.Name, err) + } + if *cost < 0 || *hours < 0 { + return nil, fmt.Errorf("%s %s: a ceiling cannot be negative", program.Name, command.Name) + } + workspace := *dir + if strings.TrimSpace(workspace) == "" { + workspace = "." + } + abs, err := filepath.Abs(workspace) + if err != nil { + return nil, fmt.Errorf("%s %s: --dir: %w", program.Name, command.Name, err) + } + return &Invocation{ + Program: program, Command: command, + Workspace: abs, + Ceilings: Ceilings{CostUSD: *cost, Hours: *hours}, + JSON: *asJSON, + Args: fs.Args(), + Line: append([]string(nil), line...), + body: body, + }, nil +} + +// ChildArgs is the line a host starts a program's process with, after +// codeaf's own executable: the name, the default command, --json, the folder, +// the ceilings that are set, and the brief after `--`, so no word of it can be +// read as a flag. [Parse] reads it back to the same invocation. +// +// AN UNSET CEILING IS NOT ON THE LINE. A program handed `--max-cost 0` might +// read it as a ceiling of nothing; one handed no flag reads no ceiling. +// +// The facts codeaf read about the run put the program's own flags on the line +// after codeaf's: [Delegate.PlainFolder] for a folder with no git history, and +// [Delegate.CrewFlags] for the conversation's crew. +func ChildArgs(program Delegate, workspace, brief string, ceilings Ceilings, facts RunFacts) []string { + args := []string{program.Name, program.Default, "--json", "--dir", workspace} + if ceilings.CostUSD > 0 { + args = append(args, "--max-cost", strconv.FormatFloat(ceilings.CostUSD, 'f', -1, 64)) + } + if ceilings.Hours > 0 { + args = append(args, "--max-hours", strconv.FormatFloat(ceilings.Hours, 'f', -1, 64)) + } + if facts.Plain { + args = append(args, program.PlainFolder...) + } + if program.CrewFlags != nil && !facts.Crew.IsZero() { + args = append(args, program.CrewFlags(facts.Crew)...) + } + return append(args, "--", brief) +} + +// RunFacts is what codeaf read about a run before it started the program, each +// of which puts the program's own flags for it on the line ([ChildArgs]). +type RunFacts struct { + // Plain says the folder has no git history. + Plain bool + // Crew is the conversation's crew; zero for a run no conversation started. + Crew Crew +} + +// Help writes a program's help: what it is, its commands, and the flags every +// command takes. +// +// EVERY LINE FITS EIGHTY CELLS, the width codeaf's own help pages are held to +// (cmd/codeaf's helpwidth law); the build's list holds every carried program's +// pages to it (internal/delegate/builtin). +func Help(program Delegate, out io.Writer) { + fmt.Fprintf(out, "codeaf %s: %s\n\n", program.Name, program.Summary) + fmt.Fprintf(out, "usage:\n codeaf %s [flags] <brief> the same as %s\n", program.Name, program.Default) + for _, c := range program.Commands { + fmt.Fprintf(out, " codeaf %s %s %s\n %s\n", program.Name, c.Name, c.Usage, c.Summary) + } + fmt.Fprintf(out, "\nflags every command takes:\n") + fmt.Fprintf(out, " --dir DIR the folder to work in (default: the current folder)\n") + fmt.Fprintf(out, " --max-cost USD a dollar ceiling; codeaf refuses the call that would cross it\n") + fmt.Fprintf(out, " --max-hours H a ceiling in hours of wall-clock time\n") + fmt.Fprintf(out, " --json write the records on stdout instead of readable lines\n") + fmt.Fprintf(out, "\n`codeaf %s <command> --help` lists a command's own flags.\n", program.Name) +} + +// commandHelp is one command's help, with its own flags. +func commandHelp(program Delegate, command Command, fs *flag.FlagSet, out io.Writer) { + fmt.Fprintf(out, "codeaf %s %s %s\n %s\n\nflags:\n", program.Name, command.Name, command.Usage, command.Summary) + fs.VisitAll(func(f *flag.Flag) { + fmt.Fprintf(out, " --%-14s %s\n", f.Name, f.Usage) + }) +} + +// RunChild runs a parsed invocation as the child of a host: its records go to +// stdout as JSON lines and its models come from the environment. It answers +// the status of the ending it wrote, and the caller turns that into the exit +// code. +// +// EXACTLY ONE TERMINAL, ON EVERY PATH. A body that returns without writing one +// gets one written for it here — the context's end, the error it returned, or +// the plain fact that it said nothing — because a host reads a missing +// terminal as work that did not finish and says only that, and the reason the +// body knew would be lost. +// +// AND IT ENDS WHEN ITS HOST DOES, however the host went ([watchHost]). +func RunChild(ctx context.Context, inv *Invocation, stdout io.Writer) string { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + finished := make(chan struct{}) + defer close(finished) + go watchHost(ctx, cancel, finished) + api, _ := ModelAPIFromEnv() + emitter := NewEmitter(stdout) + host := &childHost{inv: inv, emitter: emitter, api: api, ending: StatusFail} + var err error + if !api.Ready() { + err = errors.New("this run has no model API: codeaf starts " + inv.Program.Name + " with one, and a shell run hosts its own") + } else { + err = inv.body(ctx, host, inv.Args) + } + if !emitter.Ended() { + switch { + case ctx.Err() != nil: + host.Terminal(Ending{Status: StatusFail, Message: "stopped before it finished"}) + case err != nil: + host.Terminal(Ending{Status: StatusCrashed, Message: firstLineOf(err.Error())}) + default: + host.Terminal(Ending{Status: StatusFail, Message: "it ended without saying how"}) + } + } + return host.ending +} + +// hostPID reads the process a child's host is; a variable so a test can play +// a host that goes away. +var hostPID = os.Getppid + +// hostWatch is how often a child looks for its host. +var hostWatch = time.Second + +// hostGrace is how long a child whose host has gone is given to end on its +// own, and hostGoneExit what ends it after that; variables so a test can +// play a program that ignores its stop without ending the test binary. +var ( + hostGrace = DefaultGrace + hostGoneExit = func() { os.Exit(1) } +) + +// watchHost ends a child's context when the process that started it is gone, +// and returns when the context ends, or the child has finished, either way. +// +// A HOST KILLED OUTRIGHT SENDS NOTHING. A child runs in a process group of its +// own ([Run]), so a closed terminal's hangup never reaches it, and a host that +// was killed, or died of that hangup, never sends the SIGTERM a stop is: the +// child worked on in the person's folder, released by nobody, while the next +// run took the folder the dead host's lock had let go. The child learns it +// here instead — the parent it was started by is no longer its parent — and +// stops exactly as a stop would have stopped it, its terminal written on the +// way out; a record written to the dead host's pipe after that ends it anyway. +// +// AND THE LADDER A HOST'S OWN STOP KEEPS IS KEPT ([Run]): the stop, the grace, +// then the end. A program still at work when the grace has passed — a restore, +// a last test run — is ended outright, because nobody is left to do it and the +// folder it is working in is free for the next run. One that finished within +// the grace (finished closed) is left to leave on its own. +func watchHost(ctx context.Context, cancel context.CancelFunc, finished <-chan struct{}) { + host := hostPID() + tick := time.NewTicker(hostWatch) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + if hostPID() == host { + continue + } + cancel() + grace := time.NewTimer(hostGrace) + defer grace.Stop() + select { + case <-finished: + case <-grace.C: + hostGoneExit() + } + return + } + } +} + +// childHost is the Host of a program running as a child: records to stdout, +// models from the environment. +type childHost struct { + inv *Invocation + emitter *Emitter + api ModelAPI + ending string +} + +func (h *childHost) Workspace() string { return h.inv.Workspace } +func (h *childHost) Ceilings() Ceilings { return h.inv.Ceilings } +func (h *childHost) Models() ModelAPI { return h.api } +func (h *childHost) Hello(stages []string) { + _ = h.emitter.Hello(h.inv.Program.Name, stages) +} +func (h *childHost) Stage(stage StageRecord) { _ = h.emitter.Stage(stage) } +func (h *childHost) Step(step StepRecord) { _ = h.emitter.Step(step) } +func (h *childHost) Terminal(end Ending) { + if h.emitter.Ended() { + return + } + if !KnownStatus(end.Status) { + end.Status = StatusCrashed + } + h.ending = end.Status + _ = h.emitter.Terminal(end) +} + +// firstLineOf is an error's first line, because an ending's message is one +// sentence. +func firstLineOf(s string) string { + line, _, _ := strings.Cut(strings.TrimSpace(s), "\n") + return line +} diff --git a/internal/delegate/cli_test.go b/internal/delegate/cli_test.go new file mode 100644 index 0000000000..1b66c30f7b --- /dev/null +++ b/internal/delegate/cli_test.go @@ -0,0 +1,364 @@ +package delegate + +import ( + "bytes" + "context" + "errors" + "flag" + "strings" + "sync" + "testing" + "time" +) + +// testProgram is a program with two commands, the default one taking a flag +// of its own, whose body reports what it was handed through the host. +func testProgram(body Body) Delegate { + if body == nil { + body = func(context.Context, Host, []string) error { return nil } + } + return Delegate{ + Name: "fake", Summary: "a fake program for the tests", Default: "run", Page: "fake", + Guide: "For the tests' fake work, with a brief that names what it touches.", + Commands: []Command{{ + Name: "run", Usage: "[flags] -- <brief>", Summary: "does the whole task", + Bind: func(fs *flag.FlagSet) Body { + variant := fs.String("variant", "", "how hard the model thinks") + return func(ctx context.Context, host Host, args []string) error { + if *variant != "" { + host.Stage(StageRecord{Stage: "variant", Status: *variant}) + } + return body(ctx, host, args) + } + }, + }, { + Name: "check", Usage: "", Summary: "says whether it could run", + Bind: func(fs *flag.FlagSet) Body { return body }, + }}, + } +} + +func TestParseRunsTheDefaultCommandOnABareBrief(t *testing.T) { + inv, err := Parse(testProgram(nil), []string{"--max-cost", "5", "fix", "the", "flaky", "test"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if inv.Command.Name != "run" || inv.Brief() != "fix the flaky test" || inv.Ceilings.CostUSD != 5 { + t.Fatalf("invocation = %+v", inv) + } + if inv.Workspace == "" || inv.Workspace[0] != '/' { + t.Fatalf("workspace = %q, want the current folder, absolute", inv.Workspace) + } +} + +func TestParseTakesANamedCommandAndItsOwnFlags(t *testing.T) { + inv, err := Parse(testProgram(nil), []string{"run", "--variant", "high", "--dir", "/tmp", "--", "--not-a-flag"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if inv.Command.Name != "run" || inv.Workspace != "/tmp" || inv.Brief() != "--not-a-flag" { + t.Fatalf("invocation = %+v", inv) + } + if other, err := Parse(testProgram(nil), []string{"check"}, &bytes.Buffer{}); err != nil || other.Command.Name != "check" { + t.Fatalf("check = %+v %v", other, err) + } +} + +// The line a host starts its child with is the line Parse reads back. +func TestChildArgsParseBackToTheSameInvocation(t *testing.T) { + program := testProgram(nil) + line := ChildArgs(program, "/work", "add a --flag to the parser", Ceilings{CostUSD: 2.5, Hours: 1}, RunFacts{}) + if line[0] != "fake" { + t.Fatalf("line = %q, want the program's name first", line) + } + inv, err := Parse(program, line[1:], &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if inv.Command.Name != "run" || !inv.JSON || inv.Workspace != "/work" || inv.Brief() != "add a --flag to the parser" || + inv.Ceilings != (Ceilings{CostUSD: 2.5, Hours: 1}) { + t.Fatalf("invocation = %+v", inv) + } +} + +// A plain folder puts the program's own flags for one on the line, before the +// brief and where its command parses them, and a folder with history puts +// nothing there. +func TestChildArgsCarryThePlainFolderFlagsOnlyForAPlainFolder(t *testing.T) { + program := testProgram(nil) + program.PlainFolder = []string{"--variant", "plain"} + if err := program.Validate(); err != nil { + t.Fatalf("a program whose plain-folder flags its command takes is refused: %v", err) + } + if line := ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{}); strings.Contains(strings.Join(line, " "), "--variant") { + t.Fatalf("a folder with history carried the plain-folder flags: %q", line) + } + line := ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{Plain: true}) + if got := strings.Join(line, " "); !strings.HasSuffix(got, "--variant plain -- the brief") { + t.Fatalf("line = %q, want the plain-folder flags just before the brief", got) + } + // Parse refuses a flag its command does not declare, so reading the line + // back is the command taking them. + inv, err := Parse(program, line[1:], &bytes.Buffer{}) + if err != nil || inv.Brief() != "the brief" { + t.Fatalf("the line read back as %+v, %v", inv, err) + } +} + +// The conversation's crew reaches the program in its own flags, before the +// brief, and a run with no crew carries none. +func TestChildArgsCarryTheCrewInTheProgramsOwnFlags(t *testing.T) { + program := testProgram(nil) + program.CrewFlags = func(crew Crew) []string { return []string{"--variant", crew.Hands} } + if err := program.Validate(); err != nil { + t.Fatalf("a program whose crew flags its command takes is refused: %v", err) + } + if line := strings.Join(ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{}), " "); strings.Contains(line, "--variant") { + t.Fatalf("a run with no crew carried crew flags: %q", line) + } + line := ChildArgs(program, "/work", "the brief", Ceilings{}, RunFacts{Crew: Crew{Hands: "vendor/hands"}}) + if got := strings.Join(line, " "); !strings.HasSuffix(got, "--variant vendor/hands -- the brief") { + t.Fatalf("line = %q, want the crew's flags just before the brief", got) + } + program.CrewFlags = func(Crew) []string { return []string{"--models", "x"} } + if err := program.Validate(); err == nil || !strings.Contains(err.Error(), "crew flags") { + t.Fatalf("Validate = %v, want crew flags its command does not take refused", err) + } +} + +// A plain-folder flag the default command does not declare would end every +// run on a plain folder at its first line, so the definition is refused. +func TestValidateRefusesPlainFolderFlagsTheCommandDoesNotTake(t *testing.T) { + program := testProgram(nil) + program.PlainFolder = []string{"--in-place"} + if err := program.Validate(); err == nil || !strings.Contains(err.Error(), "plain folder flags") { + t.Fatalf("Validate = %v, want the plain folder flags refused", err) + } +} + +func TestParseWritesHelpAndSaysSo(t *testing.T) { + for _, line := range [][]string{{"--help"}, {"help"}, {"run", "-h"}} { + var out bytes.Buffer + if _, err := Parse(testProgram(nil), line, &out); !errors.Is(err, ErrHelp) { + t.Fatalf("%q: err = %v, want ErrHelp", line, err) + } + if !strings.Contains(out.String(), "codeaf fake") { + t.Fatalf("%q: help = %q", line, out.String()) + } + } + var out bytes.Buffer + _, _ = Parse(testProgram(nil), []string{"run", "--help"}, &out) + if !strings.Contains(out.String(), "--variant") { + t.Fatalf("a command's help lacks its own flag:\n%s", out.String()) + } + // A bare ask is the program's own page, with every command on it. + out.Reset() + _, _ = Parse(testProgram(nil), []string{"--help"}, &out) + if !strings.Contains(out.String(), "flags every command takes") || !strings.Contains(out.String(), "codeaf fake check") { + t.Fatalf("a bare --help is not the program's own page:\n%s", out.String()) + } +} + +// EXACTLY ONE TERMINAL, ON EVERY PATH: a body that ends without one gets one, +// and a body's own is the only one written. +func TestRunChildWritesExactlyOneTerminal(t *testing.T) { + t.Setenv(EnvModelAPI, "http://127.0.0.1:9/v1") + t.Setenv(EnvModelToken, "token") + cases := []struct { + name string + body Body + status string + }{ + {"its own", func(ctx context.Context, host Host, args []string) error { + host.Hello([]string{"work"}) + host.Terminal(Ending{Status: StatusPass, Message: "done", Claim: "it works"}) + host.Terminal(Ending{Status: StatusFail, Message: "a second"}) + return nil + }, StatusPass}, + {"an error", func(ctx context.Context, host Host, args []string) error { + return errors.New("the engine broke\nwith a trace") + }, StatusCrashed}, + {"nothing said", func(ctx context.Context, host Host, args []string) error { return nil }, StatusFail}, + } + for _, tc := range cases { + inv, err := Parse(testProgram(tc.body), []string{"b"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + status := RunChild(context.Background(), inv, &stdout) + reading, err := Read(&stdout, nil) + if err != nil { + t.Fatal(err) + } + if status != tc.status || reading.Terminal == nil || reading.Terminal.Status != tc.status { + t.Fatalf("%s: status %q terminal %+v, want %q", tc.name, status, reading.Terminal, tc.status) + } + if strings.Count(stdout.String(), `"terminal"`) > 1 { + t.Fatalf("%s: more than one terminal:\n%s", tc.name, stdout.String()) + } + } +} + +func TestRunChildRefusesToRunWithoutAModelAPI(t *testing.T) { + t.Setenv(EnvModelAPI, "") + ran := false + inv, err := Parse(testProgram(func(ctx context.Context, host Host, args []string) error { ran = true; return nil }), []string{"b"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + if status := RunChild(context.Background(), inv, &stdout); status != StatusCrashed || ran { + t.Fatalf("status %q ran %v, want crashed before the body", status, ran) + } +} + +func TestValidateRefusesADefinitionThatCouldNotRun(t *testing.T) { + good := testProgram(func(context.Context, Host, []string) error { return nil }) + if err := good.Validate(); err != nil { + t.Fatalf("a good definition refused: %v", err) + } + bad := good + bad.Default = "missing" + if err := bad.Validate(); err == nil || !strings.Contains(err.Error(), "default command") { + t.Fatalf("err = %v", err) + } + shadow := good + shadow.Commands = []Command{{Name: "run", Bind: func(fs *flag.FlagSet) Body { + fs.String("dir", "", "") + return func(context.Context, Host, []string) error { return nil } + }}} + if err := shadow.Validate(); err == nil || !strings.Contains(err.Error(), "--dir") { + t.Fatalf("err = %v, want the shared flag named", err) + } +} + +// A PROGRAM DESCRIBES ITSELF TO THE MODEL THAT HANDS IT WORK, in one paragraph +// the conversation's fixed prefix can afford: a program with no guide would be +// listed by its name alone, one with line breaks would break the list it is an +// item of, and one past GuideMax would be paid for on every request of every +// turn of every conversation that carries it. +func TestValidateHoldsTheGuideToOneAffordableParagraph(t *testing.T) { + good := testProgram(nil) + for _, c := range []struct { + name, guide, want string + }{ + {"empty", " ", "the guide is empty"}, + {"two paragraphs", "For one thing.\n\nAnd another.", "no line breaks"}, + {"too long", strings.Repeat("x", GuideMax+1), "held to"}, + } { + program := good + program.Guide = c.guide + if err := program.Validate(); err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("%s: err = %v, want it to say %q", c.name, err, c.want) + } + } + program := good + program.Guide = strings.Repeat("x", GuideMax) + if err := program.Validate(); err != nil { + t.Fatalf("a guide of exactly GuideMax bytes refused: %v", err) + } +} + +// A CHILD WHOSE HOST HAS GONE STOPS. Its host can die without a word — killed, +// or taken by a closed terminal's hangup, which never reaches a child in a +// process group of its own — and the child used to work on in the person's +// folder after the folder's hold had gone with the host. Now the child sees +// its parent change and ends as a stop would end it, its terminal written. +func TestAChildStopsWhenItsHostIsGone(t *testing.T) { + t.Setenv(EnvModelAPI, "http://127.0.0.1:9/v1") + t.Setenv(EnvModelToken, "token") + previousPID, previousWatch, previousGrace, previousExit := hostPID, hostWatch, hostGrace, hostGoneExit + t.Cleanup(func() { + hostPID, hostWatch, hostGrace, hostGoneExit = previousPID, previousWatch, previousGrace, previousExit + }) + hostGoneExit = func() { t.Error("a child that stopped within its grace was ended outright") } + hostGrace = time.Second + var mu sync.Mutex + host := 4242 + hostPID = func() int { + mu.Lock() + defer mu.Unlock() + return host + } + hostWatch = 5 * time.Millisecond + started := make(chan struct{}) + inv, err := Parse(testProgram(func(ctx context.Context, host Host, args []string) error { + close(started) + <-ctx.Done() + return ctx.Err() + }), []string{"b"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + done := make(chan string, 1) + go func() { done <- RunChild(context.Background(), inv, &stdout) }() + <-started + select { + case <-done: + t.Fatal("the child stopped while its host was still there") + case <-time.After(50 * time.Millisecond): + } + mu.Lock() + host = 1 + mu.Unlock() + select { + case status := <-done: + reading, err := Read(&stdout, nil) + if err != nil { + t.Fatal(err) + } + if status != StatusFail || reading.Terminal == nil || reading.Terminal.Message != "stopped before it finished" { + t.Fatalf("the child ended %q with %+v, want it stopped", status, reading.Terminal) + } + case <-time.After(5 * time.Second): + t.Fatal("the child worked on after its host was gone") + } +} + +// AND ONE THAT IGNORES THE STOP IS ENDED WHEN ITS GRACE HAS PASSED, the ladder +// a host's own stop keeps: nobody is left to send the SIGKILL, and the folder +// it is working in is free for the next run. +func TestAChildThatIgnoresItsGoneHostIsEndedAfterTheGrace(t *testing.T) { + t.Setenv(EnvModelAPI, "http://127.0.0.1:9/v1") + t.Setenv(EnvModelToken, "token") + previousPID, previousWatch, previousGrace, previousExit := hostPID, hostWatch, hostGrace, hostGoneExit + t.Cleanup(func() { + hostPID, hostWatch, hostGrace, hostGoneExit = previousPID, previousWatch, previousGrace, previousExit + }) + var mu sync.Mutex + calls := 0 + hostPID = func() int { + mu.Lock() + defer mu.Unlock() + calls++ + if calls == 1 { + return 4242 + } + return 1 + } + hostWatch, hostGrace = 5*time.Millisecond, 20*time.Millisecond + ended := make(chan struct{}) + hostGoneExit = func() { close(ended) } + release := make(chan struct{}) + inv, err := Parse(testProgram(func(ctx context.Context, host Host, args []string) error { + <-release + return nil + }), []string{"b"}, &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { + RunChild(context.Background(), inv, &bytes.Buffer{}) + close(done) + }() + select { + case <-ended: + case <-time.After(5 * time.Second): + t.Fatal("a child that ignored its gone host was never ended") + } + close(release) + <-done +} diff --git a/internal/delegate/conversation.go b/internal/delegate/conversation.go new file mode 100644 index 0000000000..c34d1fc57d --- /dev/null +++ b/internal/delegate/conversation.go @@ -0,0 +1,247 @@ +package delegate + +// The conversation log: one record per model call a program makes through its +// model API, kept in the task's own record folder beside the trajectory. The +// API writes it (internal/provider) and the task page reads it +// (internal/session), and neither may import the other, so the record and the +// one door each side uses live here. +// +// THIS IS WHAT MAKES A PROGRAM'S WORK VISIBLE. To the program the API is an +// ordinary model backend; to codeaf the program is a very particular person +// asking it things. Every exchange is therefore a turn — what the program sent +// that it had not sent before, and what the model answered — and the page +// draws the turns as the conversation they are. + +import ( + "bufio" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "time" +) + +// ConversationFile is the log's name inside a task's record folder. +const ConversationFile = "delegate-conversation.jsonl" + +// ProgramFile names, inside a task's record folder, which program the run +// handed its task to and the stages it said it would move through (its +// `hello`). The worker writes it when the hello arrives and again, whole, when +// the program's process is gone — then whether or not a hello ever came, so a +// program that died early still has its clock; the task page reads it to say +// whose conversation it is drawing, after the run as well as during. +const ProgramFile = "delegate-program.json" + +// ProgramRecord is ProgramFile's content. +type ProgramRecord struct { + Name string `json:"name"` + Stages []string `json:"stages,omitempty"` + // CeilingUSD is the dollar ceiling the run handed the program, zero for + // none. It is written here because the run works it out when it starts and + // keeps it nowhere a page could read it afterwards, and a page that shows + // the spend without the ceiling beside it leaves out half the reading. + CeilingUSD float64 `json:"ceiling_usd,omitempty"` + // StartedAt and EndedAt are the program's own clock: the instant codeaf + // started its process and the instant that process was gone, written by + // whoever ran it (the run's worker, or the shell verb). They are the ONE + // record of how long the program itself ran, because every other pair of + // times near a run brackets something else — the store is seeded before + // the copy is cut, and the row settles after the landing. EndedAt is zero + // while the program runs, and both are zero in a record written before + // they existed, which a reader draws as no time rather than a wrong one. + StartedAt time.Time `json:"started_at,omitzero"` + EndedAt time.Time `json:"ended_at,omitzero"` +} + +// WriteProgram writes the record, whole, making the folder when it is not +// there. +func WriteProgram(dir string, record ProgramRecord) error { + data, err := json.Marshal(record) + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + temp := filepath.Join(dir, ProgramFile+".tmp") + if err := os.WriteFile(temp, append(data, '\n'), 0o600); err != nil { + return err + } + return os.Rename(temp, filepath.Join(dir, ProgramFile)) +} + +// ReadProgram reads the record; ok is false for a run that handed its task to +// no program, or whose program has neither said hello nor ended yet. +func ReadProgram(dir string) (ProgramRecord, bool) { + data, err := os.ReadFile(filepath.Join(dir, ProgramFile)) + if err != nil { + return ProgramRecord{}, false + } + var record ProgramRecord + if json.Unmarshal(data, &record) != nil || record.Name == "" { + return ProgramRecord{}, false + } + return record, true +} + +// MainThread is the thread a call belongs to when the program gave it no +// other: its one long conversation. +const MainThread = "main" + +// Turn is one model call a program made through its model API. A call is +// written twice under one Seq — when it starts, with no Ended, and when it +// ends — and a reader keeps the later, which is how the page shows a call in +// flight without a second file. +type Turn struct { + Seq int `json:"seq"` + // Thread tells conversations apart when a program holds more than one at + // once (a summary of its own history, a helper agent): the call's cache key + // or its own id, MainThread when it gave none. + Thread string `json:"thread,omitempty"` + Started time.Time `json:"started"` + Ended time.Time `json:"ended,omitempty"` + // Model is the model the program asked for; Served is the one that + // answered, when codeaf's router answered with another. + Model string `json:"model"` + Served string `json:"served,omitempty"` + // Sent is what the program sent that the thread's previous call did not: + // its brief first, then its tools' results and its own words. Restarted is + // true when the program rewrote its history instead of adding to it (a + // compaction), so Sent is then everything it sent. + Sent []Said `json:"sent,omitempty"` + Restarted bool `json:"restarted,omitempty"` + // Reply is the model's text, and Calls the tools it asked the program to run. + Reply string `json:"reply,omitempty"` + Calls []ToolUse `json:"calls,omitempty"` + // The call's size and price, as the funnel metered them. + TokensIn int `json:"tokens_in,omitempty"` + TokensOut int `json:"tokens_out,omitempty"` + Cached int `json:"cached,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` + // Refused is codeaf's own refusal — the ceiling, a run that has ended — set + // when the call never reached a model. Failed is the model's side failing. + Refused string `json:"refused,omitempty"` + Failed string `json:"failed,omitempty"` +} + +// InFlight answers whether the call has not come back yet. +func (t Turn) InFlight() bool { return t.Ended.IsZero() && t.Refused == "" && t.Failed == "" } + +// Said is one message a program sent: whose it is and its words. A tool's +// result carries the tool it answers. +type Said struct { + // Role is "system", "user" or "tool", as the program sent it. + Role string `json:"role"` + Tool string `json:"tool,omitempty"` + Text string `json:"text"` +} + +// ToolUse is one tool a model asked the program to run, with its arguments on +// one line. +type ToolUse struct { + Name string `json:"name"` + Args string `json:"args,omitempty"` +} + +// The caps a turn is written with. A page draws the first lines of these; the +// whole of a message is the program's own record, never codeaf's. +const ( + turnTextCap = 2048 + turnSaidMax = 12 + turnCallsMax = 16 + turnArgsCap = 200 +) + +// capped is the turn as it is written: every text cut on a rune boundary, the +// newest messages kept when there are too many, the tool calls bounded. +func (t Turn) capped() Turn { + t.Reply = cut(t.Reply, turnTextCap) + if len(t.Sent) > turnSaidMax { + t.Sent = t.Sent[len(t.Sent)-turnSaidMax:] + } + sent := make([]Said, len(t.Sent)) + for i, said := range t.Sent { + said.Text = cut(said.Text, turnTextCap) + sent[i] = said + } + t.Sent = sent + if len(t.Calls) > turnCallsMax { + t.Calls = t.Calls[:turnCallsMax] + } + calls := make([]ToolUse, len(t.Calls)) + for i, call := range t.Calls { + call.Args = cut(oneLine(call.Args), turnArgsCap) + calls[i] = call + } + t.Calls = calls + if t.Thread == "" { + t.Thread = MainThread + } + return t +} + +// AppendTurn writes one turn to the log in dir, capped, in one write, making +// the folder when it is not there. +func AppendTurn(dir string, turn Turn) error { + line, err := json.Marshal(turn.capped()) + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + file, err := os.OpenFile(filepath.Join(dir, ConversationFile), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if _, err := file.Write(append(line, '\n')); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +// ReadTurns reads the log in dir: every call once, in the order they started, +// each as its latest record says, and the last n of them (n <= 0 for all). A +// log that is not there is no turns and no error, because a run that has not +// called a model yet has said nothing; a line that does not parse is skipped, +// because a log cut mid-write is still a log. +func ReadTurns(dir string, n int) ([]Turn, error) { + file, err := os.Open(filepath.Join(dir, ConversationFile)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + defer file.Close() + latest := map[int]int{} + var turns []Turn + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 64<<10), maxLineBytes) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var turn Turn + if json.Unmarshal([]byte(line), &turn) != nil { + continue + } + if at, seen := latest[turn.Seq]; seen { + turns[at] = turn + continue + } + latest[turn.Seq] = len(turns) + turns = append(turns, turn) + } + if err := scanner.Err(); err != nil { + return nil, err + } + if n > 0 && len(turns) > n { + turns = turns[len(turns)-n:] + } + return turns, nil +} diff --git a/internal/delegate/conversation_test.go b/internal/delegate/conversation_test.go new file mode 100644 index 0000000000..6b1e43f8a2 --- /dev/null +++ b/internal/delegate/conversation_test.go @@ -0,0 +1,77 @@ +package delegate + +import ( + "strings" + "testing" + "time" +) + +// A call is written when it starts and again when it ends; the reader keeps +// the later record in the earlier one's place, so a call in flight is seen and +// then replaced by its answer. +func TestReadTurnsKeepsEachCallsLatestRecordInStartOrder(t *testing.T) { + dir := t.TempDir() + start := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC) + for _, turn := range []Turn{ + {Seq: 1, Started: start, Model: "m", Sent: []Said{{Role: "user", Text: "the brief"}}}, + {Seq: 1, Started: start, Ended: start.Add(time.Second), Model: "m", Reply: "reading the tests", Calls: []ToolUse{{Name: "bash", Args: "go test ./..."}}}, + {Seq: 2, Started: start.Add(2 * time.Second), Model: "m"}, + } { + if err := AppendTurn(dir, turn); err != nil { + t.Fatal(err) + } + } + turns, err := ReadTurns(dir, 0) + if err != nil { + t.Fatal(err) + } + if len(turns) != 2 || turns[0].Reply != "reading the tests" || turns[0].InFlight() || !turns[1].InFlight() { + t.Fatalf("turns = %+v", turns) + } + if turns[0].Thread != MainThread { + t.Fatalf("thread = %q, want the main one for a call that named none", turns[0].Thread) + } + if last, _ := ReadTurns(dir, 1); len(last) != 1 || last[0].Seq != 2 { + t.Fatalf("last = %+v", last) + } +} + +func TestATurnIsWrittenCapped(t *testing.T) { + dir := t.TempDir() + sent := make([]Said, 20) + for i := range sent { + sent[i] = Said{Role: "tool", Tool: "bash", Text: strings.Repeat("é", 3000)} + } + if err := AppendTurn(dir, Turn{Seq: 1, Sent: sent, Reply: strings.Repeat("x", 5000), Calls: []ToolUse{{Name: "bash", Args: "a\nb " + strings.Repeat("y", 500)}}}); err != nil { + t.Fatal(err) + } + turns, _ := ReadTurns(dir, 0) + turn := turns[0] + if len(turn.Sent) != turnSaidMax || len(turn.Sent[0].Text) > turnTextCap || len(turn.Reply) != turnTextCap { + t.Fatalf("sent %d, first %d bytes, reply %d bytes", len(turn.Sent), len(turn.Sent[0].Text), len(turn.Reply)) + } + if args := turn.Calls[0].Args; len(args) > turnArgsCap || strings.Contains(args, "\n") { + t.Fatalf("args = %q, want one line, capped", args) + } +} + +func TestReadTurnsOfARunThatCalledNothingIsEmpty(t *testing.T) { + turns, err := ReadTurns(t.TempDir(), 0) + if err != nil || len(turns) != 0 { + t.Fatalf("turns %v err %v", turns, err) + } +} + +func TestTheProgramRecordReadsBackAndIsAbsentBeforeTheHello(t *testing.T) { + dir := t.TempDir() + if _, ok := ReadProgram(dir); ok { + t.Fatal("a run with no hello read a program") + } + if err := WriteProgram(dir, ProgramRecord{Name: "senior-dev", Stages: []string{"implement", "submit"}}); err != nil { + t.Fatal(err) + } + record, ok := ReadProgram(dir) + if !ok || record.Name != "senior-dev" || len(record.Stages) != 2 { + t.Fatalf("record = %+v %v", record, ok) + } +} diff --git a/internal/delegate/delegate.go b/internal/delegate/delegate.go new file mode 100644 index 0000000000..d01ff0f80f --- /dev/null +++ b/internal/delegate/delegate.go @@ -0,0 +1,319 @@ +// Package delegate is what codeaf needs of the programs it carries and can hand +// a whole task to — senior-dev first (docs/design/delegate/PROTOCOL.md): what +// one IS (a Go value in the build's list, internal/delegate/builtin), the +// command line every one of them answers (`codeaf <name> …`), the records a +// running one writes on its stdout and the one reader over them, the model API +// that is its only road to a model, the log of the conversation it holds over +// that road, and the launch of it as a child process that streams, stops on +// SIGTERM and ends with one terminal record. +// +// "DELEGATE" IS A WORKING TITLE. Everything a person reads names the program +// itself — `/senior-dev`, `codeaf senior-dev`, its own manual page — and only +// code says delegate, where a later rename is one package move. +// +// A PROGRAM CODEAF CARRIES IS STILL A PROGRAM APART. It is compiled into this +// binary, but it runs as a child process of it (`codeaf <name> run --json …`), +// so a crash in its engine cannot take the chat down, and it reaches a model +// only through the API codeaf serves it for that one run, so it never holds a +// key. What runs one AS A WORKER of a run — the live step, the trajectory, the +// spend bank — is internal/run's; nothing here knows what a task is. +// +// THIS PACKAGE IS A LEAF ON PURPOSE. The session door lists the programs and +// checks a name; the run engine seats one; the command line runs one; none of +// them may import the others, so what they share lives here and imports none +// of them. +package delegate + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "regexp" + "strings" +) + +// The two things a program can leave behind. +const ( + // LandsTree is a program that edits files in the folder it is given and + // leaves its changes there: in a git repository on a branch codeaf cut for + // the run and left checked out, with what it left uncommitted committed + // onto that branch when it ends (internal/session's programfolder.go). + LandsTree = "tree" + // LandsText is a program that changes nothing in the folder and puts its + // answer in the terminal record's deliverable: codeaf folds the text into + // the conversation the way a quick task's answer arrives. + LandsText = "text" +) + +// Delegate is one program this build carries. It is a value in the build's +// list (internal/delegate/builtin), never a file on the machine: there is +// nothing to install, and no version of it that differs from the codeaf it +// ships in. +type Delegate struct { + // Name is one lowercase word with single hyphens: the chat command + // (`/<name> <brief>`), the command line's verb (`codeaf <name>`) and the + // word every row says out loud. + Name string + // Summary is one sentence saying what it does, in a person's words: the + // command row's tail and its line in `codeaf --help`. + Summary string + // Guide is the program describing itself to the model that hands it work: + // what it is for, what its brief must hold, and what it needs of its + // folder. The conversation prints it under the program's name, where the + // model reads which programs it can name in `via`, and says nothing about + // the program of its own. + // + // THE PROGRAM OWNS WHAT IS TRUE OF IT, AND CODEAF OWNS WHAT IS TRUE OF + // EVERY PROGRAM. The folder a program works in, where its work is left and + // the fact that nobody can be asked anything are codeaf's mechanics, stated + // once beside the list; a guide that restated them would be one more copy + // to drift. A second program brings its own guide, and the conversation's + // page never has to learn its name. + // + // IT RIDES EVERY REQUEST OF EVERY TURN, because the paragraph is part of + // the conversation's fixed prefix (internal/session's prefixbudget_test.go + // weighs it), so it is one paragraph of at most [GuideMax] bytes. + Guide string + // Lands is LandsTree or LandsText. Empty reads as LandsTree, because a + // program that edits a tree is the one this was built for. + Lands string + // PlainFolder is the flags the default command takes to work in a folder + // with no git history, which codeaf puts on the line itself when the folder + // it hands a tree program is one ([ChildArgs]). Empty is a program that + // needs no flag for it, or cannot work there and says so in its ending. + // + // CODEAF DECIDES, BECAUSE CODEAF KNOWS. The folder is the one the task was + // proposed on, and whether the program works there on a branch of its own + // is read by codeaf before the program starts: a folder with no history, or + // in a repository at the home folder, is worked in without git, and the + // program is told so on its line. The program says only how it is told, so + // codeaf never has to learn its flag's name. + PlainFolder []string + // Notes is the folder, relative to the folder it works in, where the + // program keeps its own records while it works: its copy of the brief, its + // checklist, its session's database and its whole conversation with its + // model. Empty is a program that keeps nothing there. + // + // THE NOTES ARE MOVED OUT OF THE FOLDER. A program works in the person's + // folder itself, so its records were left there when it ended — 46 files + // for one senior-dev run, a database and the full conversation among them — + // where a `git add -A` would commit them and the next run would read them + // as its own. codeaf moves the folder this names into the run's own record + // folder when the run ends, unless it was there before the run began. + Notes string + // CrewFlags is the flags the default command takes to use the models of + // the conversation's crew ([Crew]), which codeaf puts on the line of every + // run it starts from a conversation. Nil is a program that picks its own + // models whatever the crew says. + // + // THE PERSON'S CREW IS THE DEFAULT, AND THE PROGRAM SAYS HOW IT HEARS IT. + // A person who set which models do the thinking and the typing expects a + // program they hand work to to use them too, rather than a list of its + // own they never chose; codeaf knows the crew and nothing of the program's + // flags, so the program turns the one into the other. + CrewFlags func(Crew) []string + // StageWords is the word a person reads for each stage the program reports + // (its `stage` record), keyed by the stage's own name. The task's row and + // the line over its conversation show the word, never the name: a program's + // stages are its machinery — senior-dev's say `agent-runtime` and + // `router-cancellation` — and this house draws no machinery vocabulary. + // A stage with no word leaves the word shown before it standing, so a + // program's inner phases need not each be named. Nil shows every stage by + // its own name, for a program that has not said. + StageWords map[string]string + // Present is the program's own vocabulary for its task's page: it makes a + // reader ([ActionReader]) that turns each line of its action log + // ([Action]) — a stage, a step or its ending — into the words a person + // reads, under the word for the step of its process it served ([Shown]), + // and answers false for a line the page leaves out. Nil reads every line + // plainly ([Delegate.Reader]). + // + // THE PROGRAM KNOWS WHAT ITS RECORDS MEAN, AND CODEAF KNOWS HOW A PAGE IS + // DRAWN. A stage named `submit` with `patch_files` in its data is + // senior-dev's machinery; that it reads `handed in its work · 4 files` is + // senior-dev's to say, once, beside the words it gives its stages. The page + // draws whatever a program says here, and the same step word leads the + // task's row while the program is in that step. + Present func() ActionReader + // Default is the command a bare brief runs: `/<name> <brief>` in the chat + // and `codeaf <name> <brief>` in a shell. It names one of Commands. + Default string + // Commands is the program's own verbs, each with its own flags. codeaf owns + // the dispatch and the flags every program shares; the program owns these. + Commands []Command + // Page is the name of its page in the chat's manual (internal/manual/chat): + // what it does, how to ask it, what a run costs, where the work lands. It is + // compiled in with the rest of the manual, so the manual law's own gates + // hold it to that. + Page string +} + +// Command is one verb a program answers to: +// `codeaf <name> <command> [flags] -- <brief>`. +type Command struct { + Name string + // Usage is the shape of the line after the command's name, for its help: + // `[flags] -- <brief>`. + Usage string + Summary string + // Bind declares the command's own flags on fs and answers its body, which + // reads them once the line has been parsed. It is called once per + // invocation, so the values live in the closure and never in package + // state. codeaf's shared flags (--dir, --max-cost, --max-hours, --json) are + // already on fs; a command may not declare them again. + Bind func(fs *flag.FlagSet) Body +} + +// Body is a command's work. It runs to its ending and reports through the host +// — the ending included, as one [Host.Terminal] — and answers an error only +// for a failure it could not put into that record itself. args is what the +// flags left on the line: the brief's words. +type Body func(ctx context.Context, host Host, args []string) error + +// nameShape is the one shape a name may have: lowercase letters, digits and +// single hyphens, starting with a letter. It is a command word twice over — a +// slash command and a shell verb — so it has to be something a person can +// type without quoting. +var nameShape = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`) + +// Crew is the models a conversation's crew seats, by what each is for, as ids +// on the service codeaf's model API speaks for (`vendor/model`), with no +// effort suffix. An empty field is a seat the crew leaves unset. +type Crew struct { + // Brain is the planning seat: the model the crew thinks hardest with. + Brain string + // Hands is the working seat: the model the crew does the work with. + Hands string + // Light is the cheap seat: summaries, and whatever needs no depth. + Light string + // Asked is the models the person asked this run to work with, in their + // words' order, already resolved to ids. When it is set it is the working + // seat in place of Hands, and a program may not swap any of it for another: + // one it cannot use is a refusal, said before anything is spent. + Asked []string +} + +// IsZero says the crew names no model at all, so no flag is owed for it. +func (c Crew) IsZero() bool { + return c.Brain == "" && c.Hands == "" && c.Light == "" && len(c.Asked) == 0 +} + +// GuideMax is the most bytes a program's [Delegate.Guide] may take. It is a +// paragraph a model reads on every turn of every conversation that carries the +// program, so it is held to what a model needs to choose the program and brief +// it, and the program's manual page carries the rest. +const GuideMax = 400 + +// sharedFlags are the flags codeaf puts on every command's line. A command +// declaring one of them again would panic inside the flag package at parse +// time, so Validate refuses it by name first. +var sharedFlags = []string{"dir", "max-cost", "max-hours", "json"} + +// Validate names the first thing wrong with a program's definition in a +// sentence the person who wrote it can act on. The build's own test runs it on +// every program the list carries (internal/delegate/builtin), so a definition +// that could not run never reaches a person. +func (d Delegate) Validate() error { + if !nameShape.MatchString(d.Name) { + return fmt.Errorf("%q is not a program name: one lowercase word, letters, digits and single hyphens", d.Name) + } + if strings.TrimSpace(d.Summary) == "" { + return fmt.Errorf("%s: the summary is empty, and it is what the command row says", d.Name) + } + switch guide := strings.TrimSpace(d.Guide); { + case guide == "": + return fmt.Errorf("%s: the guide is empty, so the model that hands it work is told nothing but its name", d.Name) + case strings.Contains(guide, "\n"): + return fmt.Errorf("%s: the guide is one paragraph and has no line breaks, because it is printed as one item of a list", d.Name) + case len(guide) > GuideMax: + return fmt.Errorf("%s: the guide is %d bytes; it rides every request of every turn, so it is held to %d", d.Name, len(guide), GuideMax) + } + switch d.Lands { + case "", LandsTree, LandsText: + default: + return fmt.Errorf("%s: lands is %q; it is %q or %q", d.Name, d.Lands, LandsTree, LandsText) + } + if strings.TrimSpace(d.Page) == "" { + return fmt.Errorf("%s: it names no manual page, and the chat can only say what a page says", d.Name) + } + if len(d.Commands) == 0 { + return fmt.Errorf("%s: it has no commands, so there is nothing to run", d.Name) + } + seen := map[string]bool{} + for _, c := range d.Commands { + if !nameShape.MatchString(c.Name) { + return fmt.Errorf("%s: %q is not a command name", d.Name, c.Name) + } + if seen[c.Name] { + return fmt.Errorf("%s: the command %q is defined twice", d.Name, c.Name) + } + seen[c.Name] = true + if c.Bind == nil { + return fmt.Errorf("%s %s: the command has no body", d.Name, c.Name) + } + fs := flag.NewFlagSet(d.Name+" "+c.Name, flag.ContinueOnError) + if c.Bind(fs) == nil { + return fmt.Errorf("%s %s: binding the command answered no body", d.Name, c.Name) + } + for _, shared := range sharedFlags { + if fs.Lookup(shared) != nil { + return fmt.Errorf("%s %s: --%s is codeaf's own flag and may not be declared again", d.Name, c.Name, shared) + } + } + } + if !seen[d.Default] { + return fmt.Errorf("%s: the default command %q is not one of its commands", d.Name, d.Default) + } + if err := d.validateLineFlags(); err != nil { + return err + } + return nil +} + +// validateLineFlags holds the flags codeaf puts on the program's line for it — +// for a plain folder, and for the conversation's crew — to its default command: +// a flag the command does not take would end every such run at its first line, +// so it fails here, in the build's own test. +func (d Delegate) validateLineFlags() error { + command, _ := d.Command(d.Default) + parses := func(flags []string) bool { + fs := flag.NewFlagSet(d.Name+" "+command.Name, flag.ContinueOnError) + fs.SetOutput(io.Discard) + command.Bind(fs) + return fs.Parse(flags) == nil && fs.NArg() == 0 + } + if d.CrewFlags != nil { + for _, sample := range []Crew{ + {Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"}, + {Hands: "vendor/hands", Light: "vendor/light", Asked: []string{"vendor/one", "vendor/two"}}, + } { + if flags := d.CrewFlags(sample); !parses(flags) { + return fmt.Errorf("%s: the crew flags %q are not flags its %s command takes", d.Name, strings.Join(flags, " "), command.Name) + } + } + } + if len(d.PlainFolder) > 0 && !parses(d.PlainFolder) { + return fmt.Errorf("%s: the plain folder flags %q are not flags its %s command takes", d.Name, strings.Join(d.PlainFolder, " "), command.Name) + } + return nil +} + +// LandsTree answers whether this program's work is a tree to land, which is +// the reading of an empty Lands too. +func (d Delegate) LandsTree() bool { return d.Lands == "" || d.Lands == LandsTree } + +// Command finds one of the program's commands by name. +func (d Delegate) Command(name string) (Command, bool) { + for _, c := range d.Commands { + if c.Name == name { + return c, true + } + } + return Command{}, false +} + +// errNoBody is what binding a command without a body answers, so a definition +// Validate never saw still fails in words rather than with a nil call. +var errNoBody = errors.New("the command has no body") diff --git a/internal/delegate/emit.go b/internal/delegate/emit.go new file mode 100644 index 0000000000..1480158e3c --- /dev/null +++ b/internal/delegate/emit.go @@ -0,0 +1,161 @@ +package delegate + +// The writing half of the records, for the program's side of the pipe. The +// reader (protocol.go) is the parent's; this is what a program running as +// codeaf's child calls through its [Host], so the two halves are one package +// and cannot disagree about a field's spelling. + +import ( + "encoding/json" + "io" + "sync" +) + +// Ending is a program's result as it writes it: the terminal record, in +// fields rather than a map, so a program cannot misspell the one record the +// whole protocol exists for. +type Ending struct { + // Status is StatusPass, StatusFail, StatusBudget or StatusCrashed. + Status string + // Message is one sentence saying why. + Message string + // CostUSD is the program's own reading of what it spent, zero for none. + // codeaf's model API meters every call itself; this figure is kept for the + // record and never trusted over that one. + CostUSD float64 + // Reason is the longer reason, when there is one. + Reason string + // Claim is what the program's model said it did, and Observed is what the + // program itself verified. They are two witnesses and stay two fields. + Claim string + Observed string + // Deliverable is the answer text of a program that lands text. + Deliverable string + // Extra is any other data the program wants on the record. It never + // overrides a field above. + Extra map[string]any +} + +// record is the Ending on the wire. +func (e Ending) record() map[string]any { + data := map[string]any{} + for key, value := range e.Extra { + data[key] = value + } + set := func(key, value string) { + if value != "" { + data[key] = value + } + } + if e.CostUSD > 0 { + data["cost_usd"] = e.CostUSD + } + set("reason", e.Reason) + set("claim", e.Claim) + set("observed", e.Observed) + set("deliverable", e.Deliverable) + return map[string]any{"type": RecordTerminal, "status": e.Status, "message": e.Message, "data": data} +} + +// Emitter writes a program's records on its stdout: one JSON object per line, +// each written whole under one lock, so two goroutines of the program can +// never interleave half a line of each. +// +// THE TERMINAL IS WRITTEN AT MOST ONCE. A second is dropped here rather than +// sent for the reader to drop, so a program's own "and one more for luck" on +// its way out cannot become the record a person reads. +type Emitter struct { + mu sync.Mutex + w io.Writer + ended bool + err error +} + +// NewEmitter writes to w, which for a running program is its stdout. +func NewEmitter(w io.Writer) *Emitter { return &Emitter{w: w} } + +// Hello writes the first record. +func (e *Emitter) Hello(name string, stages []string) error { + return e.write(map[string]any{"type": RecordHello, "protocol": ProtocolVersion, "delegate": name, "stages": stages}) +} + +// Stage writes a phase change, with its data when it is an object the reader +// will keep ([StageDataCap]) and without it otherwise, so the record a program +// writes is the record that arrives. +func (e *Emitter) Stage(stage StageRecord) error { + record := map[string]any{"type": RecordStage, "stage": stage.Stage, "status": stage.Status} + if data := stageData(stage.Data); data != nil { + record["data"] = data + } + return e.write(record) +} + +// Step writes one finished action, capped the way the reader caps it, so what +// the program meant to say is what arrives. The optional fields are written +// only when they say something. +func (e *Emitter) Step(step StepRecord) error { + record := map[string]any{"type": RecordStep, "command": cut(oneLine(step.Command), commandCap)} + if step.Observation != "" { + record["observation"] = cut(step.Observation, observationCap) + } + if tool := label(step.Tool); tool != "" { + record["tool"] = tool + } + if id := label(step.Step); id != "" { + record["step"] = id + } + if step.Exit != nil { + record["exit"] = *step.Exit + } + if step.Added != nil { + record["added"] = *step.Added + } + if step.Removed != nil { + record["removed"] = *step.Removed + } + return e.write(record) +} + +// Terminal writes the result, once. +func (e *Emitter) Terminal(end Ending) error { + e.mu.Lock() + if e.ended { + e.mu.Unlock() + return nil + } + e.ended = true + e.mu.Unlock() + return e.write(end.record()) +} + +// Ended answers whether the terminal has been written. +func (e *Emitter) Ended() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.ended +} + +// Err is the first write that failed, if any. A program whose stdout is gone +// has nobody left to tell; the error is kept so its ending can say so. +func (e *Emitter) Err() error { + e.mu.Lock() + defer e.mu.Unlock() + return e.err +} + +func (e *Emitter) write(record map[string]any) error { + line, err := json.Marshal(record) + if err != nil { + return err + } + line = append(line, '\n') + e.mu.Lock() + defer e.mu.Unlock() + if _, err := e.w.Write(line); err != nil { + if e.err == nil { + e.err = err + } + return err + } + return nil +} diff --git a/internal/delegate/emit_test.go b/internal/delegate/emit_test.go new file mode 100644 index 0000000000..a74804f4cf --- /dev/null +++ b/internal/delegate/emit_test.go @@ -0,0 +1,50 @@ +package delegate + +import ( + "bytes" + "strings" + "testing" +) + +// What the emitter writes is what the reader reads: one package, one spelling. +func TestTheEmitterWritesWhatTheReaderReads(t *testing.T) { + var stdout bytes.Buffer + emitter := NewEmitter(&stdout) + _ = emitter.Hello("senior-dev", []string{"implement", "submit"}) + exit := 2 + _ = emitter.Stage(StageRecord{Stage: "implement", Status: "running", Data: []byte(`{"attempt":1}`)}) + _ = emitter.Stage(StageRecord{Stage: "ship", Status: "unchanged", Data: []byte(`"not an object"`)}) + _ = emitter.Step(StepRecord{Command: "bash: go test\n./...", Observation: "ok", Tool: "bash", Step: "explore", Exit: &exit}) + _ = emitter.Terminal(Ending{Status: StatusPass, Message: "submitted", CostUSD: 0.42, Claim: "tests pass", Observed: "3 of 3 commands passed", Extra: map[string]any{"claim": "overridden?", "commits": 4}}) + _ = emitter.Terminal(Ending{Status: StatusFail, Message: "never written"}) + sink := &recorder{} + reading, err := Read(&stdout, sink) + if err != nil { + t.Fatal(err) + } + if reading.Hello == nil || reading.Hello.Protocol != ProtocolVersion || reading.LastStage != "ship" || reading.Steps != 1 { + t.Fatalf("reading = %+v", reading) + } + if sink.steps[0] != "bash: go test ./...→ok" { + t.Fatalf("step = %q", sink.steps[0]) + } + if step := sink.stepRecords[0]; step.Tool != "bash" || step.Step != "explore" || step.Exit == nil || *step.Exit != 2 { + t.Fatalf("step record = %+v, want its tool, step and exit through the wire", step) + } + if string(sink.stageRecords[0].Data) != `{"attempt":1}` || sink.stageRecords[1].Data != nil { + t.Fatalf("stage data = %s and %s, want the object and nothing for the string", sink.stageRecords[0].Data, sink.stageRecords[1].Data) + } + if strings.Contains(stdout.String(), "not an object") { + t.Fatalf("the emitter wrote data the reader would drop:\n%s", stdout.String()) + } + end := reading.Terminal + if end == nil || end.Status != StatusPass || end.Claim() != "tests pass" || end.Observed() != "3 of 3 commands passed" { + t.Fatalf("terminal = %+v", end) + } + if cost, _ := end.CostUSD(); cost != 0.42 { + t.Fatalf("cost = %v", cost) + } + if reading.Ignored != 0 || strings.Contains(stdout.String(), "never written") { + t.Fatalf("a second terminal was written") + } +} diff --git a/internal/delegate/host.go b/internal/delegate/host.go new file mode 100644 index 0000000000..15091e1e45 --- /dev/null +++ b/internal/delegate/host.go @@ -0,0 +1,106 @@ +package delegate + +// The host: everything a running program may ask of codeaf, and the +// environment its process starts in. + +import ( + "net/http" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/env" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// The model API's two names in a program's environment: the OpenAI-style base +// URL codeaf serves this one run, and the token that opens it and nothing else. +// They are the ONLY road to a model a program has. +const ( + EnvModelAPI = "CODEAF_MODEL_API" + EnvModelToken = "CODEAF_MODEL_TOKEN" +) + +// Host is what a running program asks codeaf for. Its body is handed one and +// reports through it: the records go to codeaf, and the models come from it. +type Host interface { + // Workspace is the folder the program works in, absolute. + Workspace() string + // Ceilings are the limits codeaf set for this run. The program keeps them + // itself so it can end cleanly, and codeaf enforces them whatever it does. + Ceilings() Ceilings + // Hello, Stage, Step and Terminal are the records (protocol.go). Hello + // comes first and Terminal last, once. + Hello(stages []string) + Stage(stage StageRecord) + Step(step StepRecord) + Terminal(end Ending) + // Models is this run's model API. + Models() ModelAPI +} + +// Ceilings are a run's limits. Zero is none. +type Ceilings struct { + CostUSD float64 + Hours float64 +} + +// Elapsed is the hours as a duration, zero for none. +func (c Ceilings) Elapsed() time.Duration { + return time.Duration(c.Hours * float64(time.Hour)) +} + +// ModelAPI is the model API codeaf serves one run: an OpenAI-style base URL and +// the bearer token that opens it. A program in codeaf's own tree builds its +// route through internal/provider, the one package codeaf's funnel law lets +// spell a model route; a program outside it appends the route the way every +// OpenAI client does. +type ModelAPI struct { + BaseURL string + Token string +} + +// Ready answers whether there is an API to call. +func (m ModelAPI) Ready() bool { + return strings.TrimSpace(m.BaseURL) != "" && strings.TrimSpace(m.Token) != "" +} + +// Authorize puts the token on a request the program sends to the API. +func (m ModelAPI) Authorize(req *http.Request) { req.Header.Set("Authorization", "Bearer "+m.Token) } + +// ModelAPIFromEnv reads the API from this process's environment; ok is false +// outside a run, which is how `codeaf <name>` tells a child of a host from a +// person at a shell. +func ModelAPIFromEnv() (ModelAPI, bool) { + api := ModelAPI{BaseURL: strings.TrimSpace(env.Get(EnvModelAPI)), Token: strings.TrimSpace(env.Get(EnvModelToken))} + return api, api.BaseURL != "" +} + +// ChildEnv is the environment a program's process starts in: this process's, +// with every provider key and model redirection codeaf knows of taken out, and +// the model API's two names set. +// +// NO KEY REACHES A PROGRAM. Taking the keys out is not tidiness: a program +// hands its environment on to every command its model runs, so a key left +// here is a key any model-written shell line can print — senior-dev passed its +// whole environment to its shell tool before it was absorbed. And a +// redirection left here would let a program reach a model some other way than +// the API, which is the one road codeaf can meter, refuse at the ceiling and +// show a person. +func ChildEnv(api ModelAPI) []string { + strip := []string{EnvModelAPI, EnvModelToken, envBaseURL, "OPENAI_API_KEY", modelsource.DefaultSource("").KeyEnv} + for _, source := range modelsource.Vendored() { + if source.KeyEnv != "" { + strip = append(strip, source.KeyEnv) + } + } + environ := env.EnvironWithout(strip...) + if api.BaseURL != "" { + environ = append(environ, EnvModelAPI+"="+api.BaseURL, EnvModelToken+"="+api.Token) + } + return environ +} + +// envBaseURL is codeaf's own redirection of its default model service +// (internal/config). A program must not inherit it: its only address is the +// model API's. +const envBaseURL = "CODEAF_BASE_URL" diff --git a/internal/delegate/launch.go b/internal/delegate/launch.go new file mode 100644 index 0000000000..559b173344 --- /dev/null +++ b/internal/delegate/launch.go @@ -0,0 +1,216 @@ +package delegate + +// The launch: one program as a child process, in its own process group, its +// stdout read as the records and its stderr kept in a file for a person, ended +// by SIGTERM with a grace and then SIGKILL when the caller's context ends +// (docs/design/delegate/PROTOCOL.md). The process is codeaf's own executable +// running the program's verb ([ChildArgs]); what it is started with is the +// caller's to say, so a test can start a script that speaks the records. + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// DefaultGrace is how long a SIGTERM has to work before SIGKILL follows. It is +// the job registry's own two seconds plus what a program that has to write a +// terminal record and close a database needs: senior-dev ships its frozen tree on +// the way out, and a grace that cut that short would lose the one record the +// whole protocol exists for. +const DefaultGrace = 15 * time.Second + +// Launch is one run of one program. +type Launch struct { + // Name is the program's name, for the errors this launch writes. + Name string + // Bin and Args are the process: codeaf's own executable and the program's + // line ([ChildArgs]). + Bin string + Args []string + // Env is the child's whole environment ([ChildEnv]). Nil inherits this + // process's, which only a test wants: it would hand a program every key. + Env []string + // Dir is the folder the process starts in. + Dir string + // StderrPath is the file the program's stderr is appended to. Empty + // discards it, which no real caller wants: stderr is where a program says + // why it could not start. + StderrPath string + // Grace overrides DefaultGrace, for a test that must not wait fifteen + // seconds for a process that ignores SIGTERM. + Grace time.Duration +} + +// Result is what one launch came to. +type Result struct { + Reading Reading + // ExitCode is the process's own, -1 when it was ended by a signal or never + // ran. The verdict is NOT read from it (§3): a program that failed its task + // exits zero with a terminal saying `fail`. + ExitCode int + // Stopped is true when the caller's context ended the program: SIGTERM, + // and SIGKILL when the grace passed. The reading may still hold a terminal + // the program wrote inside the grace. + Stopped bool + // Killed is true when SIGKILL was needed. + Killed bool + // Elapsed is the process's wall time. + Elapsed time.Duration +} + +// ExitedAt is the instant the program's process was gone: the launch's own +// measure of the process's life laid on the instant the caller started it, and +// never later than returned, the instant the launch gave its answer back. +// +// THE PROGRAM'S WALL TIME IS ITS PROCESS'S, NOT THE DRAIN'S. A launch returns +// only once stdout is drained, and a helper the program left holding stdout can +// keep that drain open for the whole grace after the program itself exited. A +// conversation's run and a shell run both end the program's clock here, so the +// same program reads the same time on every surface. +func (r Result) ExitedAt(started, returned time.Time) time.Time { + if r.Elapsed > 0 { + if exited := started.Add(r.Elapsed); exited.Before(returned) { + return exited + } + } + return returned +} + +// ErrNoTerminal is the error a launch answers when the program exited without +// a terminal record and was not stopped by the caller: the run did not finish +// in the protocol's terms, whatever the exit code said. +var ErrNoTerminal = errors.New("the program exited without a terminal record") + +// Run starts the program and reads it to its end. It returns when the process +// has exited and stdout is drained, so nothing of the child outlives the call. +// +// A CONTEXT THAT ENDS ENDS THE PROGRAM, in the order the protocol promises: +// SIGTERM to the group, the grace, SIGKILL. The stdout reader keeps reading +// through the grace, so a terminal written on the way out is the reading's +// terminal. The error answered is the context's own, so a run supervisor that +// reads `context.Canceled` off a worker knows its own ending cut the task. +func Run(ctx context.Context, launch Launch, sink Sink) (Result, error) { + cmd := exec.Command(launch.Bin, launch.Args...) + cmd.Env = launch.Env + cmd.Dir = launch.Dir + cmd.Stdin = nil + processgroup.Configure(cmd) + stderr, err := openStderr(launch.StderrPath) + if err != nil { + return Result{ExitCode: -1}, err + } + defer stderr.Close() + cmd.Stderr = stderr + // STDOUT IS A PIPE THIS LAUNCH OWNS, not cmd.StdoutPipe: Wait closes that + // one the moment the process exits, and bytes still in the kernel's buffer + // — a terminal record written a millisecond before exit — would be gone + // with it. Here the write end is the child's alone once started, the reader + // reads to EOF, and EOF comes when every holder of the write end is gone. + stdoutRead, stdoutWrite, err := os.Pipe() + if err != nil { + return Result{ExitCode: -1}, err + } + cmd.Stdout = stdoutWrite + started := time.Now() + if err := cmd.Start(); err != nil { + _ = stdoutRead.Close() + _ = stdoutWrite.Close() + return Result{ExitCode: -1}, fmt.Errorf("start %s: %w", launch.Name, err) + } + _ = stdoutWrite.Close() + group := processgroup.CaptureGroup(cmd.Process.Pid) + + type read struct { + reading Reading + err error + } + readDone := make(chan read, 1) + go func() { + reading, err := Read(stdoutRead, sink) + readDone <- read{reading, err} + }() + + waitDone := make(chan error, 1) + go func() { waitDone <- cmd.Wait() }() + + result := Result{ExitCode: -1} + grace := launch.Grace + if grace <= 0 { + grace = DefaultGrace + } + var waitErr error + select { + case waitErr = <-waitDone: + case <-ctx.Done(): + result.Stopped = true + _ = group.Terminate() + select { + case waitErr = <-waitDone: + case <-time.After(grace): + result.Killed = true + _ = group.Kill() + waitErr = <-waitDone + } + } + result.Elapsed = time.Since(started) + if waitErr == nil { + result.ExitCode = 0 + } else { + var exit *exec.ExitError + if errors.As(waitErr, &exit) { + if status, ok := exit.Sys().(syscall.WaitStatus); ok && status.Exited() { + result.ExitCode = status.ExitStatus() + } + } + } + // THE READER IS GIVEN THE GRACE TO REACH EOF, then the pipe is closed under + // it. EOF ordinarily arrives with the exit, but a grandchild the program + // left holding stdout — a detached helper — would hold this launch open for + // as long as it lived, and a launch that never returns is a run that never + // lands. + var r read + select { + case r = <-readDone: + case <-time.After(grace): + _ = stdoutRead.Close() + r = <-readDone + } + _ = stdoutRead.Close() + result.Reading = r.reading + if result.Stopped { + return result, ctx.Err() + } + if r.err != nil { + return result, fmt.Errorf("read %s's stdout: %w", launch.Name, r.err) + } + if result.Reading.Terminal == nil { + return result, ErrNoTerminal + } + return result, nil +} + +// openStderr opens the stderr file for append, creating it, or a sink when +// no path was given. +func openStderr(path string) (io.WriteCloser, error) { + if strings.TrimSpace(path) == "" { + return nopCloser{io.Discard}, nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + return os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) +} + +type nopCloser struct{ io.Writer } + +func (nopCloser) Close() error { return nil } diff --git a/internal/delegate/launch_test.go b/internal/delegate/launch_test.go new file mode 100644 index 0000000000..b06e026a8f --- /dev/null +++ b/internal/delegate/launch_test.go @@ -0,0 +1,241 @@ +//go:build !windows + +package delegate + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeProgram is a shell script that stands in for codeaf running a program: +// it writes its argv to the file FAKE_ARGS names, emits a hello, a stage, a +// v1 spend line (which the reader no longer knows, and ignores) and a step, +// then runs the body it was given. +func fakeProgram(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + script := filepath.Join(dir, "fake.sh") + writeProgram(t, script, strings.Join([]string{ + `if [ -n "$FAKE_ARGS" ]; then printf '%s\n' "$@" > "$FAKE_ARGS"; fi`, + `if [ -n "$FAKE_ENV" ]; then env > "$FAKE_ENV"; fi`, + `echo '{"type":"hello","protocol":2,"delegate":"fake","stages":["implement"]}'`, + `echo '{"type":"stage","stage":"implement","status":"running"}'`, + `echo '{"type":"spend","cost_usd":0.01}'`, + `echo '{"type":"step","command":"bash: true","observation":"ok"}'`, + `echo 'a note for a person' >&2`, + body, + }, "\n")) + return script +} + +// fakeLaunch is the launch of the fake program the way a worker builds one: +// the program's line after the executable, and the child's environment. +func fakeLaunch(t *testing.T, script, workspace, brief string, ceilings Ceilings, api ModelAPI) Launch { + t.Helper() + program := Delegate{Name: "fake", Default: "run"} + return Launch{ + Name: "fake", + Bin: script, + Args: ChildArgs(program, workspace, brief, ceilings, RunFacts{}), + Env: ChildEnv(api), + Dir: workspace, + } +} + +func terminalLine(status, message string) string { + return `echo '{"type":"terminal","status":"` + status + `","message":"` + message + `","data":{"cost_usd":0.02}}'` +} + +func TestRunStartsTheProgramsLineAndReadsTheTerminal(t *testing.T) { + script := fakeProgram(t, terminalLine("pass", "done")) + args := filepath.Join(t.TempDir(), "args") + t.Setenv("FAKE_ARGS", args) + workspace := t.TempDir() + stderr := filepath.Join(t.TempDir(), "stderr.log") + sink := &recorder{} + launch := fakeLaunch(t, script, workspace, "rewrite the thing", Ceilings{CostUSD: 1.5, Hours: 0.25}, ModelAPI{}) + launch.StderrPath = stderr + result, err := Run(context.Background(), launch, sink) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 || result.Stopped || result.Reading.Terminal == nil || result.Reading.Terminal.Status != StatusPass { + t.Fatalf("result = %+v", result) + } + got, _ := os.ReadFile(args) + want := "fake\nrun\n--json\n--dir\n" + workspace + "\n--max-cost\n1.5\n--max-hours\n0.25\n--\nrewrite the thing\n" + if string(got) != want { + t.Fatalf("argv =\n%s\nwant\n%s", got, want) + } + if log, _ := os.ReadFile(stderr); !strings.Contains(string(log), "a note for a person") { + t.Fatalf("stderr file = %q, want the program's note kept", log) + } + if sink.hello == nil || sink.hello.Delegate != "fake" || sink.steps[0] != "bash: true→ok" { + t.Fatalf("sink = %+v", sink) + } + // The program's own word about money is not a record any more: the spend + // line is the one line the reader dropped. + if result.Reading.Ignored != 1 { + t.Fatalf("ignored = %d, want the v1 spend line and nothing else", result.Reading.Ignored) + } +} + +func TestRunLeavesAnUnsetCeilingOffTheLine(t *testing.T) { + script := fakeProgram(t, terminalLine("pass", "done")) + args := filepath.Join(t.TempDir(), "args") + t.Setenv("FAKE_ARGS", args) + workspace := t.TempDir() + if _, err := Run(context.Background(), fakeLaunch(t, script, workspace, "b", Ceilings{}, ModelAPI{}), nil); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(args) + if string(got) != "fake\nrun\n--json\n--dir\n"+workspace+"\n--\nb\n" { + t.Fatalf("argv =\n%s\nwant no --max-cost and no --max-hours at all", got) + } +} + +// THE BRIEF IS THE PERSON'S WORDS AND REACHES THE PROGRAM AS WRITTEN: after +// `--`, one element, whatever it spells — a flag, a placeholder, a key's name. +func TestRunHandsTheBriefOverVerbatim(t *testing.T) { + script := fakeProgram(t, terminalLine("pass", "done")) + args := filepath.Join(t.TempDir(), "args") + t.Setenv("FAKE_ARGS", args) + for _, brief := range []string{ + "https://github.com/o/r/pull/1 check {{ .Name }} escaping, and {{key}} in {{workspace}}", + "--dir /etc --max-cost 999 read these as words", + } { + if _, err := Run(context.Background(), fakeLaunch(t, script, t.TempDir(), brief, Ceilings{}, ModelAPI{}), nil); err != nil { + t.Fatalf("the launch refused a brief: %v", err) + } + got, _ := os.ReadFile(args) + lines := strings.Split(strings.TrimRight(string(got), "\n"), "\n") + if last := lines[len(lines)-1]; last != brief || lines[len(lines)-2] != "--" { + t.Fatalf("the brief reached the program as\n%q\nwant it verbatim after --", lines) + } + } +} + +// NO KEY REACHES A PROGRAM. The child's environment is this process's with +// every provider key and model redirection taken out and the model API's two +// names put in. +func TestTheChildsEnvironmentCarriesTheAPIAndNoKey(t *testing.T) { + script := fakeProgram(t, terminalLine("pass", "done")) + envFile := filepath.Join(t.TempDir(), "env") + t.Setenv("FAKE_ENV", envFile) + t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-parent") + t.Setenv("OPENAI_API_KEY", "sk-parent") + t.Setenv("DEEPSEEK_API_KEY", "sk-deepseek") + t.Setenv("CODEAF_BASE_URL", "https://elsewhere.example/v1") + t.Setenv("SOMETHING_ELSE", "kept") + api := ModelAPI{BaseURL: "http://127.0.0.1:9/v1", Token: "run-token"} + if _, err := Run(context.Background(), fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, api), nil); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(envFile) + environ := string(data) + for _, gone := range []string{"sk-or-v1-parent", "sk-parent", "sk-deepseek", "elsewhere.example"} { + if strings.Contains(environ, gone) { + t.Fatalf("the child inherited %q:\n%s", gone, environ) + } + } + for _, kept := range []string{"SOMETHING_ELSE=kept", EnvModelAPI + "=http://127.0.0.1:9/v1", EnvModelToken + "=run-token"} { + if !strings.Contains(environ, kept) { + t.Fatalf("the child's environment lacks %q:\n%s", kept, environ) + } + } +} + +func TestRunAnswersNoTerminalWhenTheProgramExitsWithoutOne(t *testing.T) { + script := fakeProgram(t, "exit 3") + result, err := Run(context.Background(), fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, ModelAPI{}), nil) + if !errors.Is(err, ErrNoTerminal) { + t.Fatalf("err = %v, want ErrNoTerminal", err) + } + if result.ExitCode != 3 || result.Reading.LastStage != "implement" { + t.Fatalf("result = %+v, want the exit code and the last stage seen kept", result) + } +} + +func TestRunTerminatesOnCancelAndKeepsATerminalWrittenInTheGrace(t *testing.T) { + // The program traps TERM, writes its terminal and exits; the sleep is what + // the signal interrupts. + script := fakeProgram(t, strings.Join([]string{ + `trap '` + strings.ReplaceAll(terminalLine("budget-exhausted", "stopped by the parent"), "'", `'"'"'`) + `; exit 0' TERM`, + `sleep 30 &`, + `wait $!`, + }, "\n")) + ctx, cancel := context.WithCancel(context.Background()) + sink := newRecorder() + go func() { + // Cancel once the program has said its first word, so the trap is armed. + select { + case <-sink.spoke: + case <-time.After(5 * time.Second): + } + time.Sleep(50 * time.Millisecond) + cancel() + }() + launch := fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, ModelAPI{}) + launch.Grace = 5 * time.Second + result, err := Run(ctx, launch, sink) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want the context's own", err) + } + if !result.Stopped || result.Killed { + t.Fatalf("result = %+v, want stopped by SIGTERM and not killed", result) + } + if result.Reading.Terminal == nil || result.Reading.Terminal.Status != StatusBudget { + t.Fatalf("terminal = %+v, want the one the program wrote on its way out", result.Reading.Terminal) + } +} + +func TestRunKillsAProgramThatIgnoresTerm(t *testing.T) { + script := fakeProgram(t, strings.Join([]string{ + `trap '' TERM`, + `sleep 30`, + }, "\n")) + // THE PROGRAM MUST HAVE ITS TRAP BEFORE THE STOP ARRIVES. Three hundred + // milliseconds was the whole of its life here, and on a loaded box the + // shell had not yet run `trap` when TERM came, so it died of the TERM and + // the test read a program that honours TERM as one the launch failed to + // kill. It is given a second and a half to get there; the grace that + // follows is what the test is about. + ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) + defer cancel() + started := time.Now() + launch := fakeLaunch(t, script, t.TempDir(), "b", Ceilings{}, ModelAPI{}) + launch.Grace = 200 * time.Millisecond + result, err := Run(ctx, launch, nil) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v", err) + } + if !result.Stopped || !result.Killed || result.Reading.Terminal != nil { + t.Fatalf("result = %+v, want stopped, killed, no terminal", result) + } + if time.Since(started) > 5*time.Second { + t.Fatalf("the launch took %s to give up on a program that ignores TERM", time.Since(started)) + } +} + +func TestRunRefusesAProgramThatIsNotThere(t *testing.T) { + _, err := Run(context.Background(), Launch{Name: "gone", Bin: filepath.Join(t.TempDir(), "gone"), Dir: t.TempDir()}, nil) + if err == nil || !strings.Contains(err.Error(), "start gone") { + t.Fatalf("err = %v", err) + } +} + +// writeProgram writes an executable shell script. +func writeProgram(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/internal/delegate/protocol.go b/internal/delegate/protocol.go new file mode 100644 index 0000000000..c6f0566d5f --- /dev/null +++ b/internal/delegate/protocol.go @@ -0,0 +1,404 @@ +package delegate + +// The records: one JSON object per line on the program's stdout, the types +// below read, everything else ignored (docs/design/delegate/PROTOCOL.md). +// Ignoring the rest is what makes the reader generic — a program's own records +// pass straight through — and it is also why a line that is not JSON at all is +// dropped and counted rather than failing the run: a program that printed one +// stray line has not stopped being one codeaf can run. +// +// THERE IS NO SPEND RECORD. Version 1 read a cumulative `spend` the program +// reported about itself; the model API (internal/provider/modelapi) meters +// every call the program makes as it is made, so money has one source of truth +// and it is not the program's word. A `spend` line a program still writes is +// one more line this reader does not know, ignored and counted like any other. +// +// VERSION 2 IS INTERNAL. Both ends are compiled from this package into one +// binary, so the Go types here are the specification and the number in `hello` +// guards the one case where the two ends can still differ: an engine that +// outlived a rebuild starting the NEW binary as its child. + +import ( + "bufio" + "encoding/json" + "io" + "strconv" + "strings" + "unicode/utf8" +) + +// The record types. +const ( + // RecordHello is the first line a program writes: the protocol it speaks, + // its name, and the stages it will move through, in order. + RecordHello = "hello" + RecordStage = "stage" + RecordStep = "step" + RecordTerminal = "terminal" +) + +// ProtocolVersion is the version `hello` carries. Both ends are this package, +// so it moves only when a record changes meaning, and a mismatch means the two +// processes are two builds. +// +// AN OPTIONAL FIELD ADDED TO A RECORD IS NOT A NEW MEANING. A stage's `data` +// and a step's `tool`, `step` and `exit` arrived inside version 2: a reader +// that predates them ignores them as it ignores every field it does not know, +// and a program that does not send them is read exactly as before. +const ProtocolVersion = 2 + +// Hello is the first record: who is speaking, in which protocol, and the +// stages it will move through, which is what lets a page draw the whole track +// before the program has reached the end of it. +type Hello struct { + Protocol int `json:"protocol"` + Delegate string `json:"delegate"` + Stages []string `json:"stages,omitempty"` +} + +// The terminal statuses. The set is closed and it is senior-dev's, because +// senior-dev's projection of an ending onto four words was already the right one: +// the work stands, it does not, a ceiling stopped it, or the program itself +// broke. +const ( + StatusPass = "pass" + StatusFail = "fail" + StatusBudget = "budget-exhausted" + StatusCrashed = "crashed" +) + +// Caps the reader applies so a record can never carry more than the page +// draws. A program that sends more is cut here, on a rune boundary, rather +// than trusted to have capped itself. +const ( + commandCap = 200 + observationCap = 2048 + // labelCap bounds a step's tool name and its step id: each is one word a + // page prints, never a payload. + labelCap = 64 +) + +// StageDataCap is the most bytes a stage record's data may take, in JSON. It +// is a curated copy of what the program already knows about the phase — an +// attempt number, a count, a verdict of its own checks — for a page to say in +// words, and never the program's whole account of itself, which stays on its +// stderr. A reader drops data past it rather than cut it, because half an +// object is not an object; the program is expected to have curated to it, and +// senior-dev does (internal/seniordev/app's stage_data.go). +const StageDataCap = 1024 + +// StageRecord is one `stage` record: the phase the program moved to, how it +// stands in it, and the small copy of what it knows about it. +type StageRecord struct { + Stage string `json:"stage"` + Status string `json:"status"` + // Data is a JSON object of at most [StageDataCap] bytes, or nothing. It is + // OPTIONAL AND ADDITIVE: a reader of version 2 that predates it reads the + // record without it. + Data json.RawMessage `json:"data,omitempty"` +} + +// StepRecord is one `step` record: one finished action, what was run and the +// head of what came back, and — each optional, each absent from a program that +// does not say it — the tool that ran it, the step of the program's own +// process it served, and a command's exit code. +type StepRecord struct { + // Command is the action on one line, `<tool>: <what it was about>`. + Command string `json:"command"` + // Observation is the head of what came back. + Observation string `json:"observation,omitempty"` + // Tool is the tool's own name. + Tool string `json:"tool,omitempty"` + // Step is the program's own id for the part of its process the action + // served (senior-dev's are app.Steps). It is the program's word, drawn + // through the program's own vocabulary ([Delegate.Present]). + Step string `json:"step,omitempty"` + // Exit is a command's exit code, present only for an action that ran a + // command and learned how it exited — which is why it is a pointer: a + // command that exited 0 and an action that ran none are two facts. + Exit *int `json:"exit,omitempty"` + // Added and Removed are the lines an action that changed a file added and + // removed, present only when the program counted them. + Added *int `json:"added,omitempty"` + Removed *int `json:"removed,omitempty"` +} + +// stageData is a record's data as a reader keeps it: a JSON object of at most +// [StageDataCap] bytes, and nothing for anything else. +func stageData(raw json.RawMessage) json.RawMessage { + trimmed := strings.TrimSpace(string(raw)) + if len(trimmed) > StageDataCap || !strings.HasPrefix(trimmed, "{") || !json.Valid([]byte(trimmed)) { + return nil + } + return json.RawMessage(trimmed) +} + +// label is a step's tool name or step id as a reader keeps it: one line, cut. +func label(s string) string { return cut(oneLine(s), labelCap) } + +// maxLineBytes bounds one stdout line. A program that writes a megabyte on one +// line is mirroring something it should not, and a reader without a bound is +// a way for a child to take the parent's memory. +const maxLineBytes = 4 << 20 + +// Terminal is the one record that is the result. Data is kept whole so the +// landing note can read the optional keys, in the protocol's spelling and in +// senior-dev's own, through the accessors below rather than by every caller +// knowing both. +type Terminal struct { + Status string `json:"status"` + Message string `json:"message"` + Data map[string]json.RawMessage `json:"data"` +} + +// CostUSD is the final total, and false when the record did not carry one. +func (t Terminal) CostUSD() (float64, bool) { return t.number("cost_usd") } + +// Reason is the longer reason when there is one. +func (t Terminal) Reason() string { return t.text("reason") } + +// Claim is what the program's model said it did: `claim` in the protocol, +// `submission_reason` in senior-dev's record. +func (t Terminal) Claim() string { return first(t.text("claim"), t.text("submission_reason")) } + +// Observed is what the program itself verified: `observed` in the protocol. +// senior-dev spells its observation as its own inner status and a count of +// failing verification commands, which read here as one sentence so the +// landing note can keep the claim and the observation apart. +func (t Terminal) Observed() string { + if observed := t.text("observed"); observed != "" { + return observed + } + inner := t.text("status") + if inner == "" { + return "" + } + if failing, ok := t.number("verification_failing"); ok && failing > 0 { + commands, _ := t.number("verification_commands") + return inner + ", verification failed " + strconv.Itoa(int(failing)) + " of " + strconv.Itoa(int(commands)) + " commands" + } + return inner +} + +// Verdict is the program's own word for how its work stood when it ended — +// senior-dev's inner status (`pass`, `pass-unverified`, `fail`) — beside the +// protocol's status word, and "" when the record carried none. +func (t Terminal) Verdict() string { return t.text("status") } + +// Deliverable is the answer text of a delegate that lands text. +func (t Terminal) Deliverable() string { return t.text("deliverable") } + +func (t Terminal) text(key string) string { + raw, ok := t.Data[key] + if !ok { + return "" + } + var s string + if json.Unmarshal(raw, &s) != nil { + return "" + } + return strings.TrimSpace(s) +} + +func (t Terminal) number(key string) (float64, bool) { + raw, ok := t.Data[key] + if !ok { + return 0, false + } + var n float64 + if json.Unmarshal(raw, &n) != nil { + return 0, false + } + return n, true +} + +func first(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +// KnownStatus answers whether a terminal status is one of the four. +func KnownStatus(status string) bool { + switch status { + case StatusPass, StatusFail, StatusBudget, StatusCrashed: + return true + } + return false +} + +// Sink is what a reader tells as the stream arrives. Every method is called on +// the reader's goroutine, in stream order, and none may block on the program: +// a sink that waits on the child is a deadlock with a pipe in the middle. +type Sink interface { + // Hello is the program's first record, told once. + Hello(h Hello) + // Stage is a phase change: the live step, and one line of the program's + // action log. Its data is already held to [StageDataCap]. + Stage(record StageRecord) + // Step is one finished action: command and the observation head, both + // already capped, and the tool, step and exit the program said. + Step(record StepRecord) + // Terminal is the result. It is told at most once; a second terminal on + // the stream is ignored, because the contract says exactly one and the + // first is the one the program wrote on purpose. + Terminal(t Terminal) +} + +// Reading is what a reader saw, for the record the launch keeps: the last +// stage, how many steps, whether a terminal arrived, and how many lines were +// not the protocol's (dropped, not failed). What the run spent is not here: +// the model API metered it call by call, and a reading of the program's +// stdout is not where money is learned. +type Reading struct { + Hello *Hello + LastStage string + LastStatus string + Steps int + Terminal *Terminal + Ignored int +} + +// Read consumes r to its end, telling sink each record, and answers what it +// saw. It returns when the stream closes, which for a pipe is when the program +// exits or closes stdout; an error is only a read failure on the stream itself. +func Read(r io.Reader, sink Sink) (Reading, error) { + var reading Reading + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 64<<10), maxLineBytes) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var head struct { + Type string `json:"type"` + } + if !strings.HasPrefix(line, "{") || json.Unmarshal([]byte(line), &head) != nil { + reading.Ignored++ + continue + } + switch head.Type { + case RecordHello: + // ONE HELLO. A second is ignored for the reason a second terminal + // is: the first is the one the program wrote on purpose. + if reading.Hello != nil { + reading.Ignored++ + continue + } + var rec Hello + if json.Unmarshal([]byte(line), &rec) != nil { + reading.Ignored++ + continue + } + reading.Hello = &rec + if sink != nil { + sink.Hello(rec) + } + case RecordStage: + // THE OPTIONAL FIELDS ARE READ FORGIVINGLY. A stage whose data is + // not an object, or is past the cap, is still the stage: the data + // is left off, never the record. + var rec struct { + Stage string `json:"stage"` + Status string `json:"status"` + Data json.RawMessage `json:"data"` + } + if json.Unmarshal([]byte(line), &rec) != nil || rec.Stage == "" { + reading.Ignored++ + continue + } + reading.LastStage, reading.LastStatus = rec.Stage, rec.Status + if sink != nil { + sink.Stage(StageRecord{Stage: rec.Stage, Status: rec.Status, Data: stageData(rec.Data)}) + } + case RecordStep: + // And so are a step's: a tool, a step id or an exit of another + // shape than this reader's is left off, because a program that + // spelled an optional field its own way has still finished the + // action it is reporting. + var rec struct { + Command string `json:"command"` + Observation string `json:"observation"` + Tool json.RawMessage `json:"tool"` + Step json.RawMessage `json:"step"` + Exit json.RawMessage `json:"exit"` + Added json.RawMessage `json:"added"` + Removed json.RawMessage `json:"removed"` + } + if json.Unmarshal([]byte(line), &rec) != nil || strings.TrimSpace(rec.Command) == "" { + reading.Ignored++ + continue + } + reading.Steps++ + if sink != nil { + sink.Step(StepRecord{ + Command: cut(oneLine(rec.Command), commandCap), + Observation: cut(rec.Observation, observationCap), + Tool: label(rawText(rec.Tool)), + Step: label(rawText(rec.Step)), + Exit: rawWhole(rec.Exit), + Added: rawWhole(rec.Added), + Removed: rawWhole(rec.Removed), + }) + } + case RecordTerminal: + if reading.Terminal != nil { + reading.Ignored++ + continue + } + var rec Terminal + if json.Unmarshal([]byte(line), &rec) != nil || rec.Status == "" { + reading.Ignored++ + continue + } + reading.Terminal = &rec + if sink != nil { + sink.Terminal(rec) + } + default: + reading.Ignored++ + } + } + return reading, scanner.Err() +} + +// rawText is an optional field read as a string, and nothing when it is +// absent or of another shape. +func rawText(raw json.RawMessage) string { + var s string + if len(raw) == 0 || json.Unmarshal(raw, &s) != nil { + return "" + } + return s +} + +// rawWhole is an optional field read as a whole number, and nil when it is +// absent or of another shape. +func rawWhole(raw json.RawMessage) *int { + var n int + if len(raw) == 0 || json.Unmarshal(raw, &n) != nil { + return nil + } + return &n +} + +// oneLine folds a command onto one line, because it is drawn in a row. +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +// cut caps text at n bytes on a rune boundary, so a record never opens a +// character it does not close. +func cut(s string, n int) string { + if len(s) <= n { + return s + } + for n > 0 && !utf8.RuneStart(s[n]) { + n-- + } + return s[:n] +} diff --git a/internal/delegate/protocol_test.go b/internal/delegate/protocol_test.go new file mode 100644 index 0000000000..385c114899 --- /dev/null +++ b/internal/delegate/protocol_test.go @@ -0,0 +1,279 @@ +package delegate + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// recorder is a Sink that keeps what it was told, in order. It is read after +// the reader is done, except for spoke, which a launch test waits on to know +// the program has said its first word. +type recorder struct { + mu sync.Mutex + once sync.Once + spoke chan struct{} + hello *Hello + stages []string + steps []string + // stageRecords and stepRecords are the records whole, for the tests of the + // optional fields. + stageRecords []StageRecord + stepRecords []StepRecord + terminal *Terminal +} + +func newRecorder() *recorder { return &recorder{spoke: make(chan struct{})} } + +func (r *recorder) Hello(h Hello) { + r.mu.Lock() + defer r.mu.Unlock() + r.hello = &h +} + +func (r *recorder) Stage(stage StageRecord) { + r.mu.Lock() + defer r.mu.Unlock() + r.stages = append(r.stages, stage.Stage+"·"+stage.Status) + r.stageRecords = append(r.stageRecords, stage) + if r.spoke != nil { + r.once.Do(func() { close(r.spoke) }) + } +} +func (r *recorder) Step(step StepRecord) { + r.mu.Lock() + defer r.mu.Unlock() + r.steps = append(r.steps, step.Command+"→"+step.Observation) + r.stepRecords = append(r.stepRecords, step) +} +func (r *recorder) Terminal(t Terminal) { + r.mu.Lock() + defer r.mu.Unlock() + r.terminal = &t +} + +// A recorded senior-dev stream, taken from EVENTS-CONTRACT.md's shapes, read +// through the one generic reader: the stages reach the live step, the steps +// reach the page, the terminal is the result, and every bus payload passes +// through untouched. The stream was recorded while the program still reported +// its own `spend`; those lines are read now as what they are — lines this +// reader does not know — because the model API meters money itself. +func TestTheReaderReplaysASeniorDevStream(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "senior-dev-stream.ndjson")) + if err != nil { + t.Fatal(err) + } + sink := &recorder{} + reading, err := Read(strings.NewReader(string(data)), sink) + if err != nil { + t.Fatal(err) + } + if reading.Terminal == nil || reading.Terminal.Status != StatusPass { + t.Fatalf("terminal = %+v, want the pass senior-dev wrote last", reading.Terminal) + } + if reading.LastStage != "agent-summary" { + t.Fatalf("last stage = %q, want agent-summary, the stage before the terminal", reading.LastStage) + } + if reading.Steps != 2 { + t.Fatalf("steps %d, want 2", reading.Steps) + } + // Three bus payloads and three v1 spend lines are on the stream; all six + // are ignored, not failed. + if reading.Ignored != 6 { + t.Fatalf("ignored = %d, want the three bus payloads and the three spend lines", reading.Ignored) + } + if got := strings.Join(sink.stages, " "); !strings.Contains(got, "implement·running") || !strings.Contains(got, "verification·pass") { + t.Fatalf("stages = %q", got) + } + if sink.steps[0] != "bash: go test ./...→ok \tpkg\t0.3s" || sink.steps[1] != "edit: internal/auth/middleware.go→" { + t.Fatalf("steps told = %q", sink.steps) + } + // The terminal's optional keys read in senior-dev's spelling. Its cost is + // the program's own reading, kept on the record and never banked. + cost, ok := sink.terminal.CostUSD() + if !ok || cost != 0.0213 { + t.Fatalf("terminal cost = %v %v", cost, ok) + } + if sink.terminal.Claim() != "tests pass" { + t.Fatalf("claim = %q, want senior-dev's submission_reason", sink.terminal.Claim()) + } + if sink.terminal.Observed() != "pass" { + t.Fatalf("observed = %q, want senior-dev's own inner status", sink.terminal.Observed()) + } +} + +// ONE TERMINAL, AND NO WORD OF THE PROGRAM'S ABOUT MONEY. A second terminal +// is dropped, and a v1 `spend` record is a line this reader does not know: the +// model API is where a run's money is metered, so nothing the program says +// about its own spending reaches a sink. +func TestTheReaderTakesOneTerminalAndNoSpendRecord(t *testing.T) { + stream := strings.Join([]string{ + `{"type":"spend","cost_usd":0.5}`, + `{"type":"spend","cost_usd":0.2}`, + `{"type":"terminal","status":"fail","message":"first"}`, + `{"type":"terminal","status":"pass","message":"second"}`, + `not json at all`, + `{"type":"something-else"}`, + ``, + }, "\n") + sink := &recorder{} + reading, err := Read(strings.NewReader(stream), sink) + if err != nil { + t.Fatal(err) + } + if sink.terminal == nil || sink.terminal.Message != "first" { + t.Fatalf("terminal = %+v, want the first one only", sink.terminal) + } + // The two spend lines, the second terminal, the stray line and the unknown + // type are the five ignored lines; the empty line is nothing. + if reading.Ignored != 5 { + t.Fatalf("ignored = %d, want the two spend lines, the second terminal, the stray line and the unknown type", reading.Ignored) + } +} + +func TestTheReaderCapsAStepOnARuneBoundary(t *testing.T) { + long := strings.Repeat("é", 2000) + stream := `{"type":"step","command":" bash: two words ","observation":"` + long + `"}` + "\n" + sink := &recorder{} + if _, err := Read(strings.NewReader(stream), sink); err != nil { + t.Fatal(err) + } + got := sink.steps[0] + command, observation, _ := strings.Cut(got, "→") + if command != "bash: two words" { + t.Fatalf("command = %q, want it folded onto one line", command) + } + if len(observation) > observationCap || !strings.HasSuffix(observation, "é") { + t.Fatalf("observation is %d bytes ending %q, want ≤ %d on a rune boundary", len(observation), observation[len(observation)-2:], observationCap) + } +} + +func TestObservedReadsSeniorDevsVerificationCount(t *testing.T) { + sink := &recorder{} + stream := `{"type":"terminal","status":"fail","message":"x","data":{"status":"fail","verification_failing":2,"verification_commands":5}}` + if _, err := Read(strings.NewReader(stream), sink); err != nil { + t.Fatal(err) + } + if got := sink.terminal.Observed(); got != "fail, verification failed 2 of 5 commands" { + t.Fatalf("observed = %q", got) + } +} + +// THE FIRST HELLO IS THE ONE READ: it carries the protocol, the name and the +// stages, and a second is ignored for the reason a second terminal is. +func TestTheReaderTakesOneHelloWithItsStages(t *testing.T) { + stream := strings.Join([]string{ + `{"type":"hello","protocol":2,"delegate":"senior-dev","stages":["bootstrap","implement","submit"]}`, + `{"type":"hello","protocol":9,"delegate":"other"}`, + `{"type":"stage","stage":"implement","status":"running"}`, + }, "\n") + sink := &recorder{} + reading, err := Read(strings.NewReader(stream), sink) + if err != nil { + t.Fatal(err) + } + if reading.Hello == nil || reading.Hello.Protocol != ProtocolVersion || reading.Hello.Delegate != "senior-dev" { + t.Fatalf("hello = %+v, want the first one", reading.Hello) + } + if sink.hello == nil || strings.Join(sink.hello.Stages, ",") != "bootstrap,implement,submit" { + t.Fatalf("hello told = %+v", sink.hello) + } + if reading.Ignored != 1 { + t.Fatalf("ignored = %d, want the second hello", reading.Ignored) + } +} + +// A STEP SAYS ITS TOOL, ITS STEP AND A COMMAND'S EXIT, AND A STAGE ITS DATA — +// each optional, each read forgivingly. A field of another shape than this +// reader's is left off and the record kept; data that is not an object, or is +// past the cap, is left off the stage and the stage kept; and a record that +// carries none of them reads exactly as it did before they existed. +func TestTheReaderCarriesTheOptionalFieldsAndForgivesTheirShape(t *testing.T) { + big := `{"text":"` + strings.Repeat("x", StageDataCap) + `"}` + stream := strings.Join([]string{ + `{"type":"step","command":"bash: go test ./...","observation":"FAIL","tool":"bash","step":"explore","exit":1}`, + `{"type":"step","command":"bash: go build ./...","tool":"bash","step":"verify","exit":0}`, + `{"type":"step","command":"read: a.go","tool":7,"step":{"id":"x"},"exit":"one"}`, + `{"type":"step","command":"edit: a.go"}`, + `{"type":"stage","stage":"submit","status":"frozen","data":{"patch_files":4,"checklist_items":5}}`, + `{"type":"stage","stage":"verification","status":"pass","data":[1,2]}`, + `{"type":"stage","stage":"verification","status":"pass","data":` + big + `}`, + `{"type":"stage","stage":"bootstrap","status":"ready"}`, + }, "\n") + sink := &recorder{} + reading, err := Read(strings.NewReader(stream), sink) + if err != nil { + t.Fatal(err) + } + if reading.Steps != 4 || len(sink.stageRecords) != 4 || reading.Ignored != 0 { + t.Fatalf("steps %d, stages %d, ignored %d; want every record kept", reading.Steps, len(sink.stageRecords), reading.Ignored) + } + first := sink.stepRecords[0] + if first.Tool != "bash" || first.Step != "explore" || first.Exit == nil || *first.Exit != 1 { + t.Fatalf("first step = %+v, want its tool, its step and its exit", first) + } + // AN EXIT OF 0 IS A FACT, NOT AN ABSENCE. + if second := sink.stepRecords[1]; second.Exit == nil || *second.Exit != 0 { + t.Fatalf("second step = %+v, want exit 0 kept", second) + } + if odd := sink.stepRecords[2]; odd.Tool != "" || odd.Step != "" || odd.Exit != nil || odd.Command != "read: a.go" { + t.Fatalf("a step with odd-shaped optional fields = %+v, want them left off and the step kept", odd) + } + if plain := sink.stepRecords[3]; plain.Tool != "" || plain.Step != "" || plain.Exit != nil { + t.Fatalf("a step with no optional fields = %+v", plain) + } + if got := string(sink.stageRecords[0].Data); got != `{"patch_files":4,"checklist_items":5}` { + t.Fatalf("stage data = %s, want the object as written", got) + } + for i := 1; i <= 3; i++ { + if data := sink.stageRecords[i].Data; data != nil { + t.Fatalf("stage %d data = %s, want none: not an object, past the cap, or never sent", i, data) + } + } +} + +// A STEP THAT CHANGED A FILE CARRIES ITS LINES, added and removed, and a zero +// is a count like any other; an odd shape is left off and the step kept. +func TestTheReaderCarriesAStepsLinesAddedAndRemoved(t *testing.T) { + stream := strings.Join([]string{ + `{"type":"step","command":"edit: a.go","tool":"edit","added":12,"removed":0}`, + `{"type":"step","command":"write: b.go","tool":"write","added":"many"}`, + }, "\n") + sink := &recorder{} + if _, err := Read(strings.NewReader(stream), sink); err != nil { + t.Fatal(err) + } + first := sink.stepRecords[0] + if first.Added == nil || *first.Added != 12 || first.Removed == nil || *first.Removed != 0 { + t.Fatalf("an edit's lines = %+v, want +12 and a kept zero", first) + } + action := StepAction(time.Time{}, first) + if action.Added == nil || *action.Added != 12 || action.Removed == nil || *action.Removed != 0 { + t.Fatalf("the action log's line = %+v, want the step's lines", action) + } + if odd := sink.stepRecords[1]; odd.Added != nil || odd.Removed != nil { + t.Fatalf("an odd-shaped count = %+v, want it left off", odd) + } +} + +// AND THE LINES CROSS THE WIRE: what a program's emitter writes for a step's +// lines is what codeaf's reader takes back, a zero included. The emitter wrote +// a fixed list of a step's fields, and a count it did not name never left the +// program. +func TestAStepsLinesSurviveTheEmitterAndTheReader(t *testing.T) { + var wire strings.Builder + added, removed := 7, 0 + if err := NewEmitter(&wire).Step(StepRecord{Command: "write: a.go", Tool: "write", Added: &added, Removed: &removed}); err != nil { + t.Fatal(err) + } + sink := &recorder{} + if _, err := Read(strings.NewReader(wire.String()), sink); err != nil { + t.Fatal(err) + } + if got := sink.stepRecords[0]; got.Added == nil || *got.Added != 7 || got.Removed == nil || *got.Removed != 0 { + t.Fatalf("the step read back = %+v from %q, want +7,-0", got, wire.String()) + } +} diff --git a/internal/delegate/testdata/senior-dev-stream.ndjson b/internal/delegate/testdata/senior-dev-stream.ndjson new file mode 100644 index 0000000000..0511542170 --- /dev/null +++ b/internal/delegate/testdata/senior-dev-stream.ndjson @@ -0,0 +1,19 @@ +{"type":"stage","stage":"bootstrap","status":"ready","data":{"workspace":"/tmp/copy"},"ts":1725000000000,"trace_id":"ses_1","step":1,"occurrence":1,"title":"Bootstrap: Ready","elapsed_ms":3} +{"type":"stage","stage":"run-contract","status":"ready","data":{"base_sha":"abc","high_models":["openrouter/deepseek/deepseek-v4-flash-0731"],"entry_agent":"coder","control_plane":{"enabled":false,"url":"http://localhost:8080"}},"ts":1725000000010} +{"id":"evt_1","type":"session.created","properties":{"sessionID":"ses_1","info":{"id":"ses_1","title":"rewrite the auth middleware"}}} +{"type":"stage","stage":"intake","status":"captured","data":{"spec_path":".senior-dev/spec.md","spec_bytes":42},"ts":1725000000020} +{"type":"stage","stage":"agent-runtime","status":"configured","data":{"agent":"coder","session_id":"ses_1","model_id":"deepseek-v4-flash-0731"},"ts":1725000000030} +{"type":"stage","stage":"implement","status":"running","data":{"attempt":0},"ts":1725000000040} +{"id":"evt_2","type":"message.updated","properties":{"sessionID":"ses_1","info":{"role":"assistant","id":"msg_1","cost":0.0101,"tokens":{"input":100,"output":20}}}} +{"type":"spend","cost_usd":0.0101,"ts":1725000000100} +{"id":"evt_3","type":"message.part.updated","properties":{"sessionID":"ses_1","part":{"type":"tool","tool":"bash","state":{"status":"completed","input":{"command":"go test ./..."},"output":"ok \tpkg\t0.3s"}},"time":1725000000110}} +{"type":"step","command":"bash: go test ./...","observation":"ok \tpkg\t0.3s","ts":1725000000110} +{"type":"step","command":"edit: internal/auth/middleware.go","observation":"","ts":1725000000120} +{"type":"spend","cost_usd":0.0213,"ts":1725000000200} +{"type":"spend","cost_usd":0.0213,"ts":1725000000201} +{"type":"stage","stage":"submit","status":"frozen","data":{"reason":"tests pass","checklist_satisfied":true,"patch_bytes":812,"patch_files":2,"tree_sha":"t1","commit_sha":"c1"},"ts":1725000000300} +{"type":"stage","stage":"implement","status":"submitted","data":{"attempt":0,"reason":"tests pass","checklist_satisfied":true},"ts":1725000000301} +{"type":"stage","stage":"verification","status":"pass","data":{"commands":[{"cmd":"go test ./...","exit":0}],"vacuous":false},"ts":1725000000400} +{"type":"stage","stage":"patch-summary","status":"completed","data":{"base_sha":"abc","files":2,"additions":30,"deletions":4,"patch_bytes":812},"ts":1725000000410} +{"type":"stage","stage":"agent-summary","status":"completed","data":{"agents":{"coder":{"calls":3,"cost_usd":0.0213}}},"ts":1725000000420} +{"type":"terminal","status":"pass","message":"submitted and verified: tests pass","session_id":"ses_1","data":{"cost_usd":0.0213,"status":"pass","reason":"submitted and verified","submitted":true,"nudges":0,"submission_reason":"tests pass","submission_evidence":"go test ./... is green","checklist_satisfied":true,"patch_bytes":812,"patch_files":2,"frozen_tree":"t1","frozen_commit":"c1","verification_failing":0,"verification_commands":1},"ts":1725000000500} diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index d69bb477df..1e716e0f1b 100644 --- a/internal/enginehost/enginehost.go +++ b/internal/enginehost/enginehost.go @@ -65,13 +65,19 @@ const ( // SocketLimit is the most bytes a unix socket path may weigh. // -// It is 104 rather than Linux's own 108 because THE SMALLEST LIMIT IS THE ONE -// THAT TRAVELS: macOS stops at 104, the same codeaf home can be shared over a -// network mount, and a host that worked on one machine and refused on another -// for a reason nobody could see would be worse than one honest refusal -// everywhere. Exceeding it is not a fault — CODEAF_HOME can be anywhere — so it -// is answered as "no host today" and the caller falls back to the pipe. -const SocketLimit = 104 +// It is macOS's rather than Linux's because THE SMALLEST LIMIT IS THE ONE THAT +// TRAVELS: the same codeaf home can be shared over a network mount, and a host +// that worked on one machine and refused on another for a reason nobody could +// see would be worse than one honest refusal everywhere. Exceeding it is not a +// fault — CODEAF_HOME can be anywhere — so it is answered as "no host today" +// and the caller falls back to the pipe. +// +// IT IS 103, NOT 104. macOS's sun_path is 104 bytes and the NUL that ends the +// name takes one of them. At 104 this answered "fits" for a path bind() then +// refused with "invalid argument": a state root in $TMPDIR whose socket path +// came to exactly 104 bytes started no host, took no fallback, and codeaf did +// not open at all. +const SocketLimit = 103 // ErrSocketPathTooLong is a state root deeper than a unix socket may be named // in, and it is the one failure on this road that is settled BEFORE anything diff --git a/internal/enginehost/enginehost_test.go b/internal/enginehost/enginehost_test.go index 838a3ed377..f7cccc02cb 100644 --- a/internal/enginehost/enginehost_test.go +++ b/internal/enginehost/enginehost_test.go @@ -675,6 +675,19 @@ func TestHostedAgentReadsSeededPlanTasksEndToEnd(t *testing.T) { t.Fatalf("PlanTasks over host = %+v, want seeded real-store row", rows) } +// A path of exactly 104 bytes does not fit: macOS's socket name holds 104 +// bytes and its terminating NUL is one of them, so bind refuses it with +// "invalid argument". Answering "fits" there left codeaf with no host and no +// fallback, and it did not open. +func TestASocketPathFitsOnlyWithRoomForItsEnd(t *testing.T) { + if !SocketPathFits(strings.Repeat("a", 103)) { + t.Fatal("a 103-byte path was refused; it binds on every platform") + } + if SocketPathFits(strings.Repeat("a", 104)) { + t.Fatal("a 104-byte path was said to fit; macOS refuses to bind it") + } +} + // THE TASK PAGE'S MODEL IS THE LEDGER'S, and it has to cross the host: the // room head reads it off the page the engine served, and a page that arrives // without it draws the price and the tokens and never the model. diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index c7188f5ace..5b16e3aa61 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -178,6 +178,7 @@ Canonical word, the other words it answers to, its argument form, and what it do | `/harness` | `/harnesses` | — | lists the saved shapes of work and what they did | | `/subharness` | `/sub` | — | lists the programs you can run; type to filter, enter opens that one's card | | `/subharness` | `/sub` | `<name>` | opens that subharness's intake card straight away | +| `/<program>` | — | `<brief>` | one row per program this build carries: starts a task that program does on its own | | `/skill` | `/skills` | — | opens the skill shelf under the message box; enter toggles a skill, and its chip stays attached across messages | | `/memory` | — | — | opens the memory panel | | `/memory` | `/memories` | `<query>` | prints matching memories into the conversation | @@ -1262,6 +1263,17 @@ launch on this machine and `--no-host` both wire this machine's registry and ope panel. The second is drawn as the panel's only row, and it is also what a registry that cannot be read at all shows, rather than an error. +## /<program> — a program codeaf carries, handed a whole task + +Every program your build carries is a command of its own: `/<name> <brief>` hands the brief +to that program and starts a task at once, exactly as `/task <brief>` does with codeaf's own +worker. The rows come from the build itself, so there is nothing to install and a build that +carries no program has no such row. With no brief it says its usage: +`usage: /<name> <brief> · hands the whole task to that program`. + +Over `--host` the rows are the far machine's build's, and a row you run starts the work there. +The *Programs codeaf carries* page says what one is, what it cannot do, and where its work goes. + ## /subharness — the command's two forms, bare and with a name after it `/subharness` (or `/sub`) opens a filtering list of the programs this conversation can diff --git a/internal/manual/chat/delegates.md b/internal/manual/chat/delegates.md new file mode 100644 index 0000000000..f2ba7bdc3f --- /dev/null +++ b/internal/manual/chat/delegates.md @@ -0,0 +1,225 @@ +# Programs codeaf carries + +## What a program codeaf carries is — a delegate, another coding agent, an agent of its own for a whole task + +codeaf carries programs of its own that take one whole coding task and do it alone, for as +long as an hour or more. People call them delegates. You hand one a task the way codeaf +hands a task to its own worker: it works in your folder itself, under this +conversation's dollar and time limits, shows on the rail while it runs, can be stopped, +and in a git repository leaves its work on a branch of its own, checked out, when it +ends. + +Each one is **built into codeaf**. There is nothing to install and nothing to set up, and +none of them runs on its own outside codeaf. Each is a command in the chat, `/<name> +<brief>`, and a verb at a shell, `codeaf <name> <brief>`. The verbs are listed in +`codeaf --help`. + +**It reaches a model only through codeaf.** codeaf serves each run its own model API. +Your key stays in codeaf and never reaches the program or any command it runs. Every +call the program makes goes through codeaf's own model road, so it is priced into your +spending, held to the run's dollar ceiling, and kept as one turn of its conversation with +its model. The run's task page, which opens inside the conversation's own tab like any +task's, shows the actions the program took, each under the step of its own process, and +`ctrl+y` turns it to those raw calls. When your services cannot serve the model the +program asks for, the run's own work model answers, and the raw calls name the model that +did. + +**Every program's tasks wear its name as a badge**: `[<name>]` after the task's title on +the side list, the card, the task's page, the `@` list, the tasks place and home, and its +initials (`[sd]` for senior-dev) where a list is narrow. A task codeaf's own worker does +wears none, and a program added to codeaf later gets its own badge from its name. + +This is different from a harness or a subharness, which are built out of codeaf's own +parts. A program codeaf carries has an engine of its own. + +## How do I hand work to it — /<name> <brief>, codeaf <name> in a shell, via, delegate this to another agent + +Type its name as a command, then the brief: + +``` +/<name> rewrite the auth middleware to use the new session store +``` + +That is `/task` with the worker chosen. A run starts at once in your folder, the turn goes +on, and the row appears on the rail. + +The model can choose one as well, and reaches for one by itself (see *When codeaf hands +work to a program by itself*). `propose_task` takes `via` naming the program, and the +card you answer says which program the work is going to: it asks `wants to start a +[<name>] task: <title>`, and its top line wears the program's badge. The model is told +the programs your build carries, each in the program's own words: what it is for, what its +brief must say, and what it needs of its folder. + +At a shell, `codeaf <name> <brief>` runs the same program in the folder you are in, or the +one `--dir` names. `--max-cost` and `--max-hours` set its ceilings, and `--json` prints its +records instead of readable lines. `codeaf <name> --help` lists its own commands and flags. +Its last line says what the run came to, such as `277 model calls · $2.30 · 22m 51s`: the +calls, the dollars and how long the program ran. + +## When codeaf hands work to a program by itself — will it use one without being asked, naming one is enough, it did the work itself instead + +The chat's model is told to hand the work a program is for to that program, whole, rather +than doing it itself or giving it to codeaf's own worker, even when it is one long job +with nothing to run beside it. Each program's own line says what it is for: senior-dev's +claims complex, multi-part coding work, such as fixing an issue in a mature codebase +whose cause spans files, a feature with its tests, a rewrite across a package, or a +migration. The model proposes that work with `via` naming the program, and its card goes +up like any proposal's. + +**Naming the program is enough.** Say it in your message, by name or as its command +("fix issue 412 with senior-dev", "give this to /senior-dev", "senior dev should do +this"), and the model is told to use it. If it proposes the work without the program +anyway, codeaf turns that proposal back once, along with every other proposal without +it in the same reply: +``the person named senior-dev: if they want it to do this work, propose this again with `via: "senior-dev"`; if they asked for it not to be used, or did not mean the program, propose it again unchanged``. +A proposal the model makes after reading that passes as it is, so "don't use senior-dev +for this" is kept too. The next section says what else counts. + +**What it does not do.** A reply codeaf moves to a task on its own, because it ran long or +looked like work, goes to codeaf's own worker and never to a program. A task never hands +its work to a program. `/<name> <brief>` starts the program at once, with no card. + +## Naming a program in a small ask or a correction — fix this file with senior-dev, revert what senior-dev did, I typed a correction and it forgot senior-dev + +**An ask for a program is never too small.** A one-file fix or a single read otherwise +stays in the conversation, but "fix this file with senior-dev" goes to senior-dev. For +that, your words have to ask for the program: its command (`/senior-dev`), its name +first in the message, or its name right after with, via, using, use, give, hand, to, +have, let, ask, get or want. A name in passing asks nothing: "fix senior-dev's typo in +this file" or "fix the line senior-dev changed in this file" stays here. + +**A commit, an undo or a revert stays here, whatever it names.** "revert senior-dev's +commit", "commit senior-dev's changes" or "revert this commit with senior-dev" is done in +the conversation, and a proposal for it is refused: a program works on a branch of its +own and never moves yours, so it could not do it. `/senior-dev <brief>` still starts it. + +**A correction does not undo the name.** Every message you type into one turn is read for +the program's name, not only the newest. Name senior-dev, then type "the failing test is +TestRetryUnderLoad" while it reads the code, and the name still holds for the rest of that +turn and for a turn a finished task or job wakes to answer it: a proposal without the +program is turned back as above, and "fix this file only" typed after the name still goes +to senior-dev. A correction that does not name the program earns no second turn-back. Your +next message that starts a turn of its own is read on its own. + +## Which folder a program works in — a repository I have not cloned, it edited files outside its folder, a folder with no git + +A program that edits code works in one folder itself, never a copy: the one the task +names as its `ground`, or this conversation's own folder when it names none (a typed +`/<name>` names none). Nothing else moves it — not `where`, not a path in the brief, not +where the conversation has been working — and the task's receipt names the folder. +Inside a git repository it is the repository's root. A `ground` that is not there yet is +made, empty, when the run starts, as long as the folder it would be made in is there. +Only what it changes there is part of the task. While it runs the folder is the +program's: codeaf's own file tools and tasks keep out of it (senior-dev's page says how). + +**It is never handed your home folder**, or a folder above it: that is not a project. A +conversation opened in your home folder names the project's folder (making one first when +the work is new), and a hand-off that names none is refused with `<name> works in one +project's folder, and <folder> is your home folder; say which folder the work is in, as +ground`. `/<name>` typed there is refused the same way, and says to open codeaf in the +project's folder or to ask in the chat and say which folder. + +So when the work belongs in a repository that is not on this machine (a benchmark task +that names a repository and a commit), the model clones it first, into a new folder, at +the commit the work names, and hands the program that folder, never a brief that sends +it to work in another folder. + +A folder with no git history (a plain folder, a repository with no commit yet, or a +folder in a repository whose root is your home folder) is worked in as it is, and codeaf +tells the program so on the line it starts it with (senior-dev is given `--in-place`), +from the chat and at a shell. Nothing is committed: its changes are already in the +folder when it ends. senior-dev also reads this itself: it uses git only if git is +there, so it never ends for want of a repository. + +At a shell, clone the repository yourself, then run `codeaf <name>` inside it, or name the +folder with `--dir`. + +## What it cannot do — why it did not ask me, no questions, no step cap, no review round + +**It cannot ask you anything.** Nobody is at its keyboard. Write the brief so that +everything it would stop and ask is already settled. The model is told the same thing when +it proposes one. + +**It has no step cap.** It is held to this conversation's dollar and time limits. It is +given them when it starts, and codeaf enforces them from outside as well: once the run's +spend has reached the dollar ceiling, every further model call is refused before it is +made, and the task then says `<name> reached the run's dollar ceiling of $…`. The call +that crossed the ceiling was already paid for, so a run can end a little over it. On a +service that reports no prices (a local proxy, a vendor's own API, a plan you signed in +to) no call has a price to add up, so the dollar ceiling cannot hold: a time limit +(`--max-hours`) is the bound there. + +**It has no review round.** codeaf's checker does not read its work afterwards. What the +program itself checked is reported in its result, kept apart from what its model claimed. + +**It ends with the engine holding the conversation.** Leaving a hosted conversation's +window only detaches it. If that engine stops or crashes, the conversation is closed, or a +`--no-host` codeaf quits, the run ends with `codeaf closed while <name> was running` where +it was last seen working, or `<name> had ended; codeaf closed before it could say where its +work is` at the program's exit; the next codeaf to find the run says where its work is, +as it was left, and commits nothing. Nothing carries it on; the next hand-off starts a +run of its own. + +## Why was the delegate refused — uncommitted changes, the folder is busy, it runs alone, no such program + +**It needs a clean checkout.** In a repository with changes that are not committed +(modified, staged or untracked files), or a merge, rebase or cherry-pick half done, it is +refused before anything starts, and nothing is switched or spent: `<folder> has changes +that are not committed (<files>); commit or stash them, then ask again`, or `<folder> is +in the middle of a merge; finish it or abort it, then ask again`. The model reads this +before you are shown a card. + +**One folder takes one program run at a time**, from any conversation, any window or a +shell: `<folder> is busy: <name>, task 4 (…), is working in it, and one folder takes one +program run at a time; ask again when that run has ended`. So do the folders inside it +and around it: `… is working in <held folder>, which holds it, …` (or `which is inside +it`). + +**It runs alone.** While one is running, no other task can join it, and it cannot be +started under another run of this conversation: `work is already underway in <folder>; +<name> runs alone, so propose it again when that work has ended` (`in a copy of +<folder>` when the work underway is a task of codeaf's own). codeaf's own tasks run as the +conversation's run too, so a `/task` typed in a conversation while senior-dev is working +there is refused the same way, as a task that did not start; another conversation can +run one, on a different folder. + +A name your build does not carry is refused with the ones it does: +`this codeaf carries no program called <name>; it carries …`. + +## Where a delegate's work goes — its own branch, checked out in my folder, not merged into mine, not squashed, the wip commits, what it costs + +In a git repository codeaf cuts the program a branch of its own (`task/<title>-<id>`) in +your folder and checks it out, and the program works there; its own commits (senior-dev's +`wip(edit): …`) stay on that branch, and nothing squashes them. When it ends, codeaf commits what +it left uncommitted onto that branch — the task's title, with the program's own account +of the ending as the body — and **leaves the branch checked out**, so the work is in your +folder. **Your own branch never moves**, and nothing is merged into it; if anything else +moved it during the run, the page says so instead of `as it was`. The task's page +and the conversation say ``its work is on the branch <branch> in <folder>, N files, and +that branch is checked out there; your branch <yours> is as it was: `git -C '<folder>' +switch <yours>` goes back to it, and `git -C '<folder>' merge <branch>` from there brings +the work in``. Ask the chat to merge it, or run that yourself, when you are ready. + +A run you stop keeps its work the same way. A run that changed nothing leaves nothing: +your branch is checked out again and the empty branch is deleted (`it changed nothing, so +<folder> is back on your branch <yours> and its branch <branch> was deleted`). If the +program's own shell left the folder on another branch, codeaf commits and switches +nothing and says where it was left. Its own notes (senior-dev's `.senior-dev/`) are moved +out of the folder into the task's record folder, in any kind of folder. + +A program that only answers works in your folder in place and changes nothing. Its answer +arrives in the conversation the way a task's landing does. + +What it spent is in the conversation's total, in `/cost` and on the status line. Every +model call it made went through codeaf and is priced like one of codeaf's own. A run +stopped in the middle of a call is not over until that call's price has come in, for at +most 70 seconds, so the call it was cut in is in those figures too. + +## Why is there no command for it — missing, not in this build, Windows, a hosted conversation + +A program's command exists only in a build that carries it. On Windows codeaf carries +none: their engines need a Unix shell, process groups and file locks, so the commands are +absent there rather than failing every time. + +Over `--host`, the programs are the far machine's build's. The rows come from that build, +and a run you start happens there, in that machine's folder, on a branch of its own. diff --git a/internal/manual/chat/home.md b/internal/manual/chat/home.md index ceab09d4b6..4c07ea7b5e 100644 --- a/internal/manual/chat/home.md +++ b/internal/manual/chat/home.md @@ -2255,14 +2255,17 @@ to the cent. zero or unknown files, spend and tokens are omitted, and a line with no true fact at all is not drawn. The resting panels do not carry it — `spend` is the whole machine's day. -**It counts the talking and the work the talking started, in one figure.** The turns — -your messages, the answers, and the small calls beside them — are added up by the session -itself and written to the session folder at the end of every turn, so home can read them -without opening the transcript. Every task and every unattended run this conversation -commissioned is added from the project's task index. `spent $1.25` is those two halves -together. - -`tokens` is input plus output as one sum, over the same two halves. `touched 12 files` is how +**It counts the talking and the work the talking started, in one figure, and each dollar +once.** The session keeps one set of books: your messages, the answers, the small calls +beside them, and every task and senior-dev run this conversation started, folded in as each +one finishes. It writes that total to the session folder at the end of every turn and again +when a task or run finishes, so home can read it without opening the transcript. The +project's task index also carries each task's and run's own bill. `spent $1.25` is the larger +of those two figures, never their sum: the books already hold every finished task and run, so +adding the index on top would count them twice. While work is still running the index can be +ahead, and then its figure is the one shown. + +`tokens` is input plus output as one sum, read the same way. `touched 12 files` is how many files this conversation's work wrote, summed over its tasks. `/cost` and `/status` inside the conversation still answer for the live session. A whole @@ -2409,9 +2412,13 @@ keeps its status bullet, with the most recently active running conversation anim A task awaiting your decision has its own question indicator; its parent conversation does not repeat that indicator unless it has a separate question. -## Does a run create another conversation in Sessions or the chats menu +## Does a run create another conversation in Sessions or the chats menu — a run's tab, senior-dev's task has no tab -A run’s tab is a view inside its parent conversation. Home’s Sessions list and the chats menu keep one row for that conversation, using its conversation title. The run’s own tab remains available beside it. +No. Home’s Sessions list and the chats menu keep one row for the conversation, using its conversation title. + +A run the task-belt switch (`CODEAF_TASK_BELT=bash`) drives also has a tab of its own on the strip beside its conversation’s, named after the task the run is working on, for as long as the run works. It is a view inside that conversation: while it is open it is the one tab drawn selected, and a press on the conversation’s tab, a press on the `home` tab, or `esc` leaves it. + +A task handed to a program such as senior-dev has no tab. It opens inside the conversation’s own tab, as any task does, and `esc`, the conversation’s tab and the `home` tab leave it. ## Why does a closed conversation say another window diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 66eeeb9083..21104f6006 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -388,7 +388,8 @@ clear by hand. **Two tasks cannot both run in place in one directory.** Whichever started first has it; the second is refused its writes and told which task to wait for. When the first lands, the -second gets the directory. +second gets the directory. A program's run (senior-dev) holds its folder the same way, and +a task is refused that folder before it starts: senior-dev's page has the words. ## A task that has written a file holds that file — I cannot edit a file while a task runs, chat edit blocked, single writer @@ -456,7 +457,7 @@ line of its report — `files: site/index.html, site/app.css` — and only names exist in its checkout are believed. A task that says nothing about them has left them behind, and that is the difference between a deliverable and a dropping. -## Why my task's branch was kept — I committed, amended, rebased or reset my branch while it ran, it did not merge, my checkout is on main or dev, tasks do not merge into a protected branch automatically, how do I take the work, why did the work not land in my checkout, why didn't my task merge, which branches does codeaf refuse to write +## Why my task's branch was kept — I committed, amended, rebased or reset my branch while it ran, it did not merge, has codeaf committed to dev or main, my checkout is on main or dev, tasks do not merge into a protected branch automatically, how do I take the work, why did the work not land in my checkout, why didn't my task merge, which branches does codeaf refuse to write A tag with the same name as a branch does not change which branch is protected or which commit the landing compares. Git signature-display settings also do @@ -3163,6 +3164,9 @@ and counted as the section above on work that is your call says. Ids can only po and only ids `propose_task` itself returned count: a job or adaptive-run number is a different kind of work, and naming one — or a task that already failed — refuses the proposal on the spot instead of queueing work that could never start. +A task handed to a program such as senior-dev cannot wait at all, because it starts the +moment it is approved: its `depends_on` may name only work that has already landed. A +program's run that ended done may be named by any task; one still going may not. **`model`** — which model this task runs on. Set only when you asked for a particular model or class of model for this work. Left out, the task runs on `task.model` if set, otherwise diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index 3061164f4f..f71a43f9a0 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -2503,6 +2503,12 @@ and scroll the page one row only when there is no history to walk. `left` is deliberately **not** taken here — it falls through to the message box's back-navigation. +**Inside a program's room** — a task handed to senior-dev — `ctrl+y` turns the page +between the actions it took and its raw calls to its model, `ctrl+o` folds its brief, and +the box sends nothing. While it works the keys row under the box reads +`/stop · x with empty input · esc main · ctrl+y calls`, ending `ctrl+y actions` while the +calls are showing; once it has ended the row is the `ctrl+y` clause alone. + **`up` and `down` in a room mean what they mean in the message box**, in the same order: inside a multi-line message they move the caret; on the first line — or over an empty box — they walk your own history, newest first; and only with nothing to walk do they scroll @@ -2568,6 +2574,18 @@ card, and the card is answered below. The tasks pages describe what rooms and the roster are for. +## See a program's raw calls — ctrl+y on senior-dev's page, the model calls behind its actions + +A program's task page — senior-dev's — opens on the actions it took, each under the step +of its process. **`ctrl+y` turns it to the raw calls** it made to its model: what it sent, +what the model answered, which model it was, and the call in flight. `ctrl+y` again turns +it back. It works in the program's room, whichever door opened it — its row, its card, or +the tasks place — and the key row names it: `ctrl+y calls` over the actions, `ctrl+y actions` +over the calls. Every page opens on the actions. + +It is a chord, so it never costs a character: the room's box keeps what you typed. It is +not bound on any other task's page. + ## Stopping work with `x` — the confirmation card, why the stop card needs enter as well as the number `x` raises one card above the message box: @@ -3137,7 +3155,8 @@ answer: | `ctrl+r` | Bound. In the message box it is **spell it out** — see "Make my prompt better" above — in the `/files` list it opens the folder a file is in, and in the `/model` picker it fetches the newest model list. Nowhere else | | `alt+e` | **Bound**, on three surfaces: it moves how hard the thing you are standing on thinks — this conversation from the message box, a task, or a standing item on home. The machine's own default is the `thinking` row of `/settings` and is not on this chord. See "The thinking chip above the message box" and "alt+e — how hard the thing you are looking at thinks". Anywhere else it does nothing. On macOS it is shown as `opt+e`; the terminal must send Option as Alt/Meta, as for the other Option shortcuts | | `ctrl+x` | Bound in three places: it drops a harness design from inside its room; on home it stops a standing item for good; and on a `tasks` row of home that this window holds it asks to stop that task (`ctrl+x stop it` on the `alt+.` map; the foot under a field row is the resting sentence and does not name it). Not bound anywhere else | -| `ctrl+y`, `ctrl+z` | Not bound | +| `ctrl+y` | **Bound in two places**: on a program's task page — senior-dev's room, however it was opened — it turns the page between the actions the program took and its raw calls to its model; in the `/files` list it copies the file under the cursor. Nowhere else | +| `ctrl+z` | **Bound**: undo in every box, with `ctrl+shift+z` to redo — see "Undo what I typed" | | `ctrl+<digit>` | **Bound as a second spelling of the place keys, on the terminals that report they can send it.** `ctrl` and a digit has no encoding in the scheme most terminals speak, which is why `alt+1` … `alt+8` (`opt+1` … `opt+8` on a Mac) are the first spelling and always will be, but a terminal running the kitty keyboard protocol sends it and says so, and where that report arrives `ctrl+1` … `ctrl+8` reach the same seven places. The map's line says `alt+1…8 or ctrl+1…8 go to a place` exactly when the alias is live. Where the terminal has said nothing, the chord does nothing and is never drawn | | `ctrl+.` | Two meanings, on two screens that cannot both be up. In a conversation it is every task this project has run (`/history`); while a place is standing it draws the key map, on the terminals that can send `ctrl+<digit>` | | `alt+<letter>` | Bound **only where a place says so, and only on that place**. `alt+s` changes the shelf on the memory place; `alt+b` and `alt+f` are the word jumps inside every box and are never taken by a place. Every other `alt+<letter>` does nothing | diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index a98f535129..15b1b656cd 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1497,8 +1497,13 @@ one late line to the machine's usage ledger. That line is marked `reconciled`, m figures came from the receipt rather than the cut stream. A losing rescue arm is recorded as hedged waste from its own receipt too; it is real provider money, but it is not added twice. +codeaf asks for the receipt at once, then again about 1, 5, 20 and 40 seconds after the call +ended. The receipt for a call cut in the middle usually takes the router about twenty seconds +to price. A call senior-dev made that arrived whole but with no usage block is asked about +the same way. + When no generation id arrived, the base has no receipt route, or the receipt still cannot be -had after the short retry schedule, codeaf writes an `unbilled` marker with no invented +had after that schedule, codeaf writes an `unbilled` marker with no invented price or token count. The marker survives a restart. `/cost` counts missing prices for this conversation and its tasks; `/spend` counts the markers in its selected time window. Both say, for example, `2 calls the provider charged for and could not be priced`. At zero they @@ -2099,7 +2104,7 @@ which is the whole machine's ledger rather than this conversation's — it was a |---|---| | `spend` | the money, printed only when it is above zero — this conversation **and every task it started** | | `conversation` | what the conversation's own calls cost | -| `tasks` | what the work it started has cost, running or finished — tasks and the nodes of an adaptive run | +| `tasks` | what the work it started has cost, running or finished — tasks, the nodes of an adaptive run, and a task handed to a program such as senior-dev | | `tokens` | `48.1k in · 3.2k out`, or one half alone, or the combined figure | | `cache` | `31.2k read · saved $0.0180` — the money half only when a price pair was published | | `model calls` | **requests to the provider**, deliberately not "turns" | @@ -2131,6 +2136,12 @@ is the same figure `/cost` leads with. started: its money is on the row while it is still working, under `tasks` when you ask `/cost` for the halves. +**So is a program's run.** Every model call senior-dev (or another program codeaf carries) +makes for a task this conversation handed it names this conversation and that task on the +ledger, and it is on the row as it is spent, under `tasks` in `/cost`, and under the task +on the spend place. Its tokens and its calls reach this conversation's `tokens` and +`model calls` lines as well. + It used to be the conversation's own half alone. A task's money only reaches the conversation's books when the task **closes**, so a family working for two hours left the row saying `$2.53` while $51.05 was being spent under it, and the true figure could only be @@ -2178,8 +2189,9 @@ than the number of times you have spoken. It counts every request that is written down, not only the ones in your turns: naming the session, a judge deciding where something should be routed, looking at a picture, every -request a task's own agent made on its own lane, and every request a harness run made -while it walked its program. That is deliberate, because the `spend` line above it is the +request a task's own agent made on its own lane, every request a harness run made +while it walked its program, and every request a program such as senior-dev made for a +task this conversation handed it. That is deliberate, because the `spend` line above it is the sum over exactly those requests — a smaller count beside it would be a bill divided by the wrong number. diff --git a/internal/manual/chat/places.md b/internal/manual/chat/places.md index 1dba209b64..a7e3f034a8 100644 --- a/internal/manual/chat/places.md +++ b/internal/manual/chat/places.md @@ -258,7 +258,7 @@ the one you opened it for. With nothing pinned the rung and the gate are what a would run at: the `thinking` and `ask before running` rows in `/settings`, or `--yolo` if this process was started with it. -**The `here ~/codeaf` chip is gone**, and so are the rules that the other places used to +**The `here ~/codeaf` chip that sat next to the box is gone**, and so are the rules that the other places used to draw over their boxes. The arrow and `new conversation in` lead are gone from home too; the model starts the seam, and the project sits at the far right of the keys row under the box. A place with something to say about its page — `nothing matches` on tasks when a filter emptied it, a receipt on memory, the "this session is on another diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index 47ed4f7dc9..974f4bcb01 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -41,6 +41,15 @@ Nothing here is thrown away — what is folded is one keypress from open. `started 14:02` only when the record carries the instant the work began. Reopening the conversation does not replace that instant with the time you sat down. +**How long it ran is the record's too.** The settled page's header and the completion card +show how long the work took, rounded to the second — one figure, spelled `29m 8s` in the +header and `29m08s` on the card. It is the time the work itself took, as the record gives +it: the time you took to answer a task that landed needing your look is not added, so a task +that worked five minutes and that you accepted an hour later still reads `5m`. A record +that gives no such figure but carries both instants shows the landing less the start. While +the work runs, the side list counts from the record's start, even in a window opened after +it began. + An older record may carry a duration but no start or landing instant. When that duration is at least one second, the settled task page shows it in the header — for example `12m` — while the completion card omits the entire `started 14:02` segment. A shorter or absent diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md new file mode 100644 index 0000000000..9f8eba106d --- /dev/null +++ b/internal/manual/chat/senior-dev.md @@ -0,0 +1,676 @@ +# senior-dev + +## What /senior-dev does — hand one large change to senior-dev, an autonomous coding agent + +`/senior-dev <brief>` hands the whole brief to **senior-dev**, an autonomous coding agent +codeaf carries. At a shell the same program is `codeaf senior-dev <brief>`. It is built +into codeaf and runs only through it: there is nothing to install and no senior-dev of +its own to start. + +It works alone in your folder itself, on a branch of its own when the folder is a git +repository, and your own branch never moves. It writes your brief down word for word, reads +the repository, keeps a checklist of what the brief asks for, pins a command that shows +the work passes, and edits until it believes the change is done. Then it **submits**: +the tree is frozen at that moment, so nothing it does afterwards can change what it hands +back. It then runs the project's own build and tests on the frozen tree, and if anything +moved after it submitted, the tree is put back to what it submitted. + +It is for complex, multi-part coding work: fixing an issue in a mature codebase whose +cause spans files, a feature with its tests, a rewrite across a package, a migration. +codeaf hands work like that to it by itself, and uses it whenever you name it (the next +section). Its brief has to settle everything, because nobody will be asked anything. + +## Will codeaf use senior-dev by itself — when does codeaf hand work to senior-dev, how do I make codeaf use senior-dev, stop it using senior-dev + +**Yes, for the work it is for.** The chat's model is told to hand complex, multi-part +coding work to senior-dev, whole, rather than doing it in the conversation or giving it +to codeaf's own worker: fixing an issue in a mature codebase whose cause spans files, a +feature with its tests, a rewrite across a package, a migration. It proposes the task +with `via` naming senior-dev, and the card goes up like any proposal's, with its +countdown. A change you would make in a few steps it still makes itself. + +**Naming it is enough.** Say senior-dev in your message, in any spelling: "fix issue 412 +with senior-dev", "/senior-dev should take this", "senior dev". The model is told to use +it, and if it proposes the work without senior-dev anyway, codeaf turns that proposal +back once, with any others in the same reply, and tells it you named senior-dev. That +holds even for a one-file fix you ask senior-dev for, which otherwise stays in the +conversation, and through a correction you type while it works that does not name +senior-dev again. A commit, an undo or a revert stays in the conversation even when it +names senior-dev ("revert senior-dev's commit"): senior-dev works on a branch of its own +and never moves yours. + +**Saying not to is kept too.** "don't use senior-dev for this" names it, so the first +proposal is turned back the same way; the model reads that and proposes it again as it +was, and that proposal passes. + +**Typing `/senior-dev <brief>`** starts it at once, with your brief word for word and no +card. Work codeaf moves to a task on its own, because a reply ran long or looked like +work, goes to codeaf's own worker, never to senior-dev. + +## Watching senior-dev work — open its task, what it is doing step by step, how long it has run, stop it + +A senior-dev run is a task of the conversation that started it. Its row is on the side +list wearing `[senior-dev]` after its title, with the step it is in and what it has spent +so far under it, and a card in the conversation lands when it ends. Click the row or the +card, or follow a task link to it, and its task opens **inside the conversation's own +tab**: the tab strip stays on top, with the conversation's tab selected and the `home` tab beside +it. senior-dev gets no tab of its own. + +The task shows **what senior-dev is doing**, action by action, each under the step of its +process it served — the workspace it set up, what it read and ran and changed, its +hand-in, the build and tests it ran itself, and how it finished — with the call to its +model in flight as the last line, `◐ thinking` and its seconds. The next section says what +each step means. **Click an action to see the whole step** — the command or file it was +called with and what came back — and click it again to fold it. **Every change to your +files wears git's `+N,-M`** at the right of its line — the lines it added in green and the +lines it removed in red — so you can see how much each step moved the work; senior-dev's +own spec, pinned check and checklist wear none. + +The one line over it is the task's title with its `[senior-dev]` badge, a `▸ brief` +dropdown, and the step, the spend of the run's ceiling, the number of model calls and how +long the run has been going — the same time the side list and the landed card show, +counted from the moment codeaf handed the work over. **The brief is behind the dropdown**: +click `▸ brief`, or press `ctrl+o`, and the whole brief senior-dev was handed is drawn in +grey under the title; the same again hides it. While the +task is open, its row on the side list leaves its clock out rather than show a time that +stopped when you clicked; the true time is back on the row the moment you leave. + +**The raw calls are one key away.** `ctrl+y` turns the page to senior-dev's calls to its +model — what it sent, what the model answered, and which model it was — and `ctrl+y` +turns it back; the key row says `ctrl+y calls` or `ctrl+y actions`. + +`esc`, a press on the conversation's tab, or a press on the `home` tab leaves it, and the run goes +on. `x` over an empty box, `/stop`, or `Stop` on that line asks `Stop this task?` first. +Nothing typed there reaches senior-dev: the box says `senior-dev reads no messages — say +it to main`, and `enter` says the same line and keeps your words in the box. + +## What is senior-dev doing — the steps on senior-dev's page, what spec, explore, pin, checklist, implement, submit, verify mean + +The word down the left of senior-dev's page, and on its row while it runs, is the step of +its own process an action served. senior-dev has no planner, reviewer or helper agent: +one model works through the middle steps in the order it chooses, so a step's word comes +back whenever it returns to that step. + +- `setup` — it set up the folder it works in: `git`, or `no git history` for a plain + folder, whose checkpoints it keeps outside it. +- `spec` — it wrote your brief down word for word as its spec, and read it back. +- `explore` — it read, searched and ran commands before changing any file. +- `pin` — it wrote down the one command that shows the work passes. +- `checklist` — it listed what the brief asks for, and ticked it off. +- `implement` — it changed files, and everything it read or ran after its first change. +- `submit` — it handed in its work: `handed in its work · 4 files · 5 of 5 ticked`, or + `its hand-in was refused` and why. The work is frozen at that moment. +- `verify` — with no model, it ran the project's own build and tests itself, one line per + command with `passes` or `fails · exit N`, then what they came to. It also checks the + tree this way when its model stops without handing in. +- `finish` — what it did to the tree it leaves, the size of its change, and its ending. + +Lines with no word of their own are senior-dev steering its model in the step already +under way, drawn quieter: `told its model what it found, and to finish and hand in (nudge +1)`, `time is short: gave its model one last turn to finish`, a dropped call retried, a +tool call written as text corrected — and `compacted its memory` and `switched to <model>` +with its reason. + +## How do I tell a senior-dev task from a normal task — the [senior-dev] badge, [sd], what the brackets on a task mean + +A task handed to senior-dev wears its name as a badge wherever a task is named: +`[senior-dev]`, bold in the accent colour, after the task's title. A normal task — one +`/task` starts, or one the chat hands to codeaf's own worker — wears no badge. + +- **The side list** wears it after the title. When the list is too narrow for + everything, the task's number (`#7`) goes first; then the badge shortens to its + initials, `[sd]` — the narrower list a frame under 120 columns draws reads + `⠋ rewrite the… [sd] #7` — and the title is cut last. Widened with `w`, the list has + room for the whole badge and the number. +- **The card you answer** asks `wants to start a [senior-dev] task: <title>`, and the + card's top line wears the badge beside the task's name. +- **The task's own page** wears it beside the title — a page onto another + conversation's task too, with that task's own badge and never the one a task of the + same number in this conversation wears. +- **The task strip**, the row of chips that stands in for the side list under 100 + columns, wears `[sd]`. +- **The `@` list, the tasks place and home** — its list of work, a landing under + `needs you` and a line under `since you left` — wear `[senior-dev]`, or `[sd]` where + the row is short of room. The title is cut before the badge, and a `since you left` + line cuts what the work came to first: `rewrite the auth middleware [senior-dev] · it…`. +- **The chat's `tasks` tool** says `via senior-dev` on the row, so the chat can tell too. + +The brackets are always drawn, so a terminal with no colour, the row of the task you +have open, and a screen reader all still show the badge. It is not a button: a press +anywhere on the row opens the task. + +## How do I ask senior-dev for a change — writing the brief, what to put in it + +The brief is everything senior-dev knows about what you want. It is saved as +`.senior-dev/spec.md` in the folder it works in exactly as you wrote it, and it is read +back from there whenever senior-dev summarises its own history, so the words you chose +are never paraphrased away. + +Write it the way you would hand work to someone who cannot reach you: + +- the files, packages or commands involved, by name; +- what done means, and how to check it (the test to run, the output to see); +- the constraints: what must not change, and the wrong answer to avoid. + +In the chat, `/senior-dev` followed by the brief starts it as a task. At a shell, flags go +before the brief, and `--` ends them: `codeaf senior-dev run --variant high -- rename the +config loader`. Everything from the first word that is not a flag onwards is the brief, +so a flag written after the brief becomes part of it. + +## Running senior-dev on a repository you have not cloned — a benchmark task, another project + +senior-dev works in the folder it is handed and nowhere else, so it has to be handed the +repository the work belongs in. + +In the chat, ask for the work and name the repository, and the commit if the work names +one. The model clones it first, into a new folder, at that commit, and hands senior-dev +that folder. A benchmark task works this way: senior-dev works in the project's own +repository and not in the benchmark's, so the benchmark's files, its reference solution +among them, are not in its folder. + +At a shell, clone the repository yourself, then run `codeaf senior-dev` inside it or pass +the folder with `--dir`. + +A brief that names the folder it works in is fine: senior-dev reads it as written. A +brief that tells senior-dev to make a checkout of its own somewhere else does not work: +its file tools refuse to write outside its folder, and what a shell command changes out +there is not part of the task. + +## What senior-dev cannot do — it cannot ask you anything, wait on another task, be retried or carried on, no step cap, no Windows + +**It cannot ask you anything.** Nobody is at its keyboard: a question its model tries to +ask is turned down inside the program, and after three it is told questions are not +available. Put everything it would stop and ask into the brief. + +**It cannot wait on another task.** A task handed to senior-dev starts the moment it is +approved, so a proposal whose `depends_on` names work that has not finished is refused +before its card: `depends_on names task 3, which has not finished, and senior-dev starts +the moment it is approved — it cannot wait. Propose it again once task 3 has landed, or +with depends_on left out if nothing must finish first.` The chat is told when that task +lands and can propose it again then. The other way round, a task may name a senior-dev +run that ended done in its `depends_on` (the run's work is on its branch, in its folder), +but not one still going: `depends_on names task 5, a program's run that has not ended, +and a task cannot wait on one.` + +**A run is never resumed, but codeaf may send the work back.** A run that ended is not +started again: `senior-dev's run is never carried on: its work is left where it ended, and +a new hand-off starts a new run`. Its card offers no retry, and the `@` list offers no +steer on a running one, because it reads no messages. What codeaf does instead is the next +section. + +## What codeaf does when senior-dev ends — its ending, checked, sent back, retry, at most twice, ask before spending more + +**senior-dev's ending goes to the chat, not to you.** The moment a run ends, the +conversation wakes on its own with how it came out — passed its own check of the project, +nothing finished checking it, handed in work that does not pass, stopped on a limit, or +broke — and acts on it: + +- **passed**: the chat looks at what changed against what was asked, then tells you where + the work is and offers to merge its branch; +- **nothing checked it**: the chat runs the project's checks on its branch itself, then + acts on what they show; +- **does not pass, or did not finish**: the chat fixes a small gap on its branch itself, or + hands the work back to senior-dev with a brief sharpened by what failed; +- **stopped on a dollar or time limit**: the chat never sends it back on its own, because + another run spends more of your money: it says what is done and what is left, and asks; +- **broke**: the chat hands it back once if the cause looks passing (a network or model + service failure), and otherwise tells you what broke. + +**codeaf sends senior-dev back at most twice on its own** for one piece of work. A third +hand-off it tries, or one after a limit, is refused +(`senior-dev has been sent back to this work 2 times already, the most codeaf does on its +own: tell the person where the work stands and let them decide`), and you decide. A +hand-off you ask for yourself is yours, and starts the count again. Each hand-off still +shows its card, with the same countdown as any other, so you can stop one. + +**Every run on the same work stays on one branch.** A run handed a folder that the last +senior-dev run left on its branch carries on on that branch rather than cutting another: +`it works alone in <folder> itself, carrying on on its branch <branch>, where the last run +left it; your branch main does not move`. Its ending names your own branch, and a run that +adds nothing never deletes what an earlier one committed. Switch the folder to another +branch first and the next run cuts its own. + +**The card stays quiet.** senior-dev's landed card says the run `ended` (never a red +cross) and `senior-dev's ending went to the chat`; the chat's own reply is where you read +what came of the work. `ctrl+o` on the card still shows senior-dev's own words. + +**It has no step cap.** It is held to the conversation's dollar and time ceilings instead, +and codeaf enforces both from outside whatever it does. On a service that reports no +prices the dollar ceiling cannot hold, and a time limit is the only bound (see the section +on services that report no prices). + +**It reaches a model only through codeaf.** It holds no key and reads none; a +`senior-dev.json` in your folder that sets `apiKey`, `baseURL` or `providerRouting` is +refused by name, because codeaf decides which model service serves each call. + +**It writes only inside its folder.** Its file tools (`write`, `edit`, `apply_patch`) +refuse any path outside the folder it was handed, including one reached through a link, +and say so to its model; it can still read files elsewhere. Its shell is not fenced the +same way, and nothing a shell command changes outside the folder is part of the task. + +**It keeps its record in git if git is there**, on its own branch. Where there is no git +history it keeps its checkpoints outside the folder instead and commits nothing — it +reads that itself, and never ends for want of git (see the section on folders that are +not a git repository). + +**On Windows it is absent**: there is no `/senior-dev` and no `codeaf senior-dev`. Its +engine needs a Unix shell, process groups and file locks, so Windows builds leave it out +rather than carry something that fails every time. + +## Can I run senior-dev in a folder that is not a git repo — a plain folder, no git, --in-place, operation not permitted, .Trash + +Yes. **senior-dev uses git only if it is there.** A folder with no git history — a plain +folder, a repository with no first commit yet, a broken `.git`, a machine with no git — is +worked in as it is: senior-dev reads that itself when it starts, keeps its checkpoints +outside the folder and makes no commits. A folder inside a git repository whose root is +your home folder (a dotfiles repository) is worked in the same way, because codeaf starts +senior-dev with `--in-place` there: no branch is ever cut in your dotfiles. + +When it ends its changes are already in the folder. The task's page says `its work is in +<folder>, which has no git history, so nothing was committed` (or, under a repository at +your home folder, `its work is in <folder>; the git repository around it is at <repo>, +which holds your home folder, so codeaf cut no branch there and committed nothing`). + +It works in your folder itself, so leave that folder alone while it runs: once it has +submitted, anything changed there is put back to what it submitted, and a file added +there is removed. codeaf's own file tools and tasks keep out of it until then. + +**A folder or file in it that senior-dev may not read is skipped**, not a reason to stop: +it is in none of its checkpoints, and nothing of it is changed or removed. senior-dev +needs no Full Disk Access; a folder macOS keeps to itself (`operation not permitted`) is +skipped like any other. It is never started on your home folder or a folder above it +(see the programs page): to check what it changed, it reads every file in the folder, +and your home folder is not one project. + +senior-dev used to stop at once there with `workspace is not a git repository: +<folder>; run with --in-place to work in a plain folder`, which the chat could not act on. +It no longer does, whatever flags it is started with. + +## Why can't codeaf edit files while senior-dev is working — the folder is senior-dev's while it runs, a write or a task refused, bash, your own editor + +senior-dev works in your folder itself, so **the folder is senior-dev's until the run +ends**: once it has submitted, anything changed there is put back to what it submitted +and a file added there is removed, and what is left over is committed as its work. So +nothing else of codeaf's writes there meanwhile, from any conversation, window or shell: + +- the chat's `write` and `edit`, `edit_video`, and a picture, music, video or speech + saved at a path there are refused: `<file> is in <folder>, where senior-dev, task 4 + (Fix the parser), is working, so nothing was written; wait for that run to end, or stop + it, then write there`. Reading stays open. +- a task on that folder, inside it or around it — proposed, typed with `/task`, a quick + task, or one whose turn to start comes — is refused before it starts: `<folder> is + busy: senior-dev, task 4 (Fix the parser), is working in it, and nothing else of + codeaf's works there until that run has ended; wait for it, or stop it, then ask again`. +- a task already running when it started lands beside it: `its branch <branch> was kept: + senior-dev, task 4 (…), is working in it — bring it in when that run has ended`. A + `/land` of the chat's changes there is refused the same way, and waits. + +**`bash` is not fenced**: codeaf cannot know what a command writes. **Neither is your +own editor**: what you save there while it runs joins its work, or is put back. + +## Its notes — .senior-dev, its checklist, its session database, moved out when it ends + +senior-dev keeps its own records in `.senior-dev/` in the folder it works in: the brief, +its checklist, the command it pinned, its session database and its whole conversation +with its model. **They are moved out of your folder when the run ends or you stop it**, +into the task's record folder beside `delegate-conversation.jsonl` (a shell run's record +folder at a shell), and the page adds `its notes (.senior-dev/) are kept in <path>`. So +they never end up on a branch, and the next run in that folder never reads the last +one's checklist as its own. A `.senior-dev/` already in the folder when the run began is +left where it is, and never ends up on a branch either. + +## Where does senior-dev put its work — its own branch, checked out in your folder, not merged, not squashed + +In a git repository, codeaf cuts a branch of its own for the run (`task/<title>-<id>`) +in your folder and checks it out there, and senior-dev works on it. senior-dev commits +every file it writes (`wip(write): <path>`, `wip(edit): <path>`) on that branch, which is +how it keeps a record to restore from; they stay there, and nothing squashes them. + +When the run ends — finished or not, stopped, or crashed — codeaf commits whatever it +left uncommitted onto that branch, in one commit whose subject is the task's title and +whose body is senior-dev's own ending (unless codeaf itself closed first: then nothing +is committed), and **leaves the branch checked out**, so the work is in your folder +when you look. Nothing is merged into your own branch. The task's page +and the conversation both say ``its work is on the branch <branch> in <folder>, N files, +and that branch is checked out there; your branch <yours> is as it was: `git -C '<folder>' +switch <yours>` goes back to it, and `git -C '<folder>' merge <branch>` from there brings +the work in``. Merge it when you are ready, or ask the chat to. + +The ending keeps two witnesses apart: what senior-dev's model said it did +(`senior-dev's model said: …`) and what senior-dev saw when it ran the project's build +and tests (`senior-dev observed: …`). Read the second for "did it work". + +**A run you stop keeps its work the same way**: the stop says `its work so far stays on +its branch <branch>, checked out in <folder>` at once, and the page then says where it is +in the words above. A merge senior-dev's shell left half done is never committed: the +page says what it left `could not be committed (<folder> is in the middle of a merge)`. + +**A run that changed nothing leaves nothing**: your own branch is checked out again, its +empty branch is deleted, and the page says `it changed nothing, so <folder> is back on +your branch <yours> and its branch <branch> was deleted`. + +## Does senior-dev change my branch — your branch never moves, going back, a HEAD it moved, my branch moved during the run + +No. Your branch (or, when your checkout was on no branch, the commit it was on) is +written down before senior-dev starts, and codeaf never writes to it, resets it or +merges into it. After the run your folder is on senior-dev's branch; `git -C '<folder>' +switch <yours>` goes back, and the page names the exact command. From no branch it +names `git -C '<folder>' switch --detach <commit>`. codeaf's own switches run with your +repository's hooks turned off: both go between two names for one commit, so a hook has +nothing to do there. + +senior-dev's shell can still run `git checkout`, and a brief that says "work on a new +branch" makes that likely. **So a brief need not ask for a branch: the work already has +one.** If HEAD is not on its branch when the run ends, nothing is touched, and the page +says where HEAD is: `senior-dev left <folder> on the branch <other> instead of its own +branch <branch>, so codeaf changed nothing there: nothing was committed and nothing was +switched; <branch> holds N files` (or `on no branch, at <commit>`). + +**codeaf reads your branch again before it says it is as it was.** If something moved it +during the run, the page says `your branch <yours> moved during the run, from <commit> +to <commit>, and codeaf did not move it: look at it before you push or merge it`, and a +run that changed nothing is not switched back onto it: `it changed nothing, but your +branch <yours> moved during the run, from <commit> to <commit>, so codeaf did not switch +back to it: its empty branch <branch> is still checked out in <folder>`. A branch deleted +meanwhile reads `your branch <yours> is gone: it was at <commit> when the run began, and +codeaf did not make it again`. + +## senior-dev refused: changes that are not committed — a dirty checkout, uncommitted changes, a merge in progress + +senior-dev works in your checkout itself, so it starts only on a clean one. **A repository +with changes that are not committed — modified, staged or untracked files — is refused +before anything starts**, nothing is switched and nothing is spent: `<folder> has changes +that are not committed (a.go, b.go, c.go and 2 more); commit or stash them, then ask +again`. senior-dev's own `.senior-dev/` does not count. A checkout in the middle of a +merge, a rebase, a cherry-pick or a revert is refused the same way: `<folder> is in the +middle of a merge; finish it or abort it, then ask again`. + +In the chat the model is told this before you are shown a card, and can commit or stash +the changes itself if you ask it to; at a shell the run prints `error:` and the sentence, +and leaves. + +## senior-dev refused: the folder is busy — one run per folder, a folder inside it, another window, a shell run + +One folder takes one senior-dev run at a time, from any conversation, any window or a +shell. A second is refused, naming the one working there: `<folder> is busy: senior-dev, +task 4 (Fix the parser), is working in it, and one folder takes one program run at a +time; ask again when that run has ended` (or `senior-dev, a run started at a shell`). + +**So are the folders inside it, and a folder around it.** A run on a folder of projects +puts back whatever changed anywhere under it once it has submitted, so a run in one of +those projects is refused too, naming the folder held: `<folder> is busy: senior-dev, +task 4 (…), is working in <held folder>, which holds it, and one folder takes one program +run at a time; ask again when that run has ended` (`which is inside it` the other way +round). Two runs in two folders side by side both go. + +The hold goes with the codeaf holding it, however it ends, so a crash never leaves a +folder refused. + +## What a senior-dev run costs — model calls, the dollar ceiling, which models + +Every model call senior-dev makes goes through codeaf, which serves each run its own +model API. So every call is priced like one of codeaf's own, shows in the conversation's +total, its tokens and its call count, under `tasks` in `/cost`, and under the task on the +spend place. What the whole run came to is on its row, its landed card once opened +and the chat's `tasks` tool (`#3 · … · done · ran 22m 51s · $2.30 · via senior-dev`). +Every call is held to the run's dollar ceiling: **once the run's spend has +reached it, codeaf refuses every further call** before it is made, with +`the run's dollar ceiling of $5.00 is reached ($5.04 spent), so codeaf made no call`. +The call that crossed the ceiling was already made and paid for, so a run can end a little +over it. A refused call ends senior-dev's turn; it runs the project's build and tests on +the tree it has, and ends there, and the task says +`senior-dev reached the run's dollar ceiling of $5.00: …` with senior-dev's own words +after it. A run handed off after the conversation's dollar limit is already spent starts +nothing and makes no call: its row ends at once with `a dollar limit you set stopped it`. + +The time ceiling is kept by senior-dev as well as by codeaf. It holds back the last part +of its time to land: two fifteenths of the run, at least 45 seconds, at most 12 minutes, +and never more than a quarter of it. When that window opens it gets one last turn to +submit. + +**When none of your model services can serve the model it asks for**, codeaf answers the +call on the run's own work model — the one a task's own worker would use — and the raw +calls on the task page (`ctrl+y`) name the model that answered. When nothing here can serve +that model either, the conversation's own model may answer instead, and the page names +whichever model did. A dated build or a variant of the model it asked for, such as +`deepseek/deepseek-v4-pro-0731` or `qwen/qwen3.6-plus:free`, is that model and is not named +again; a sibling such as `openai/gpt-5.5-mini` answering for `openai/gpt-5.5` is a different +model and is named. Which models it asks for is the next section. + +## senior-dev on a service that reports no prices — a local proxy, a Codex sign-in, the dollar ceiling does not hold, set a time limit + +Some model services answer without saying what a call cost: most of the services you +connect in `/connect` besides the default router, such as a local proxy or runner, a +vendor's own API, or a plan you signed in to such as Codex. codeaf never guesses a price, +so each call +senior-dev makes through one is counted with its tokens and no dollars. The task page, +the rail and the spend place show no money for those calls, never `$0.00`, and a missing +price does not mean the service charged nothing. + +**So the dollar ceiling cannot hold there.** A run whose calls report no price never +reaches its dollar ceiling, whatever it is set to, and senior-dev's own `--max-cost` adds +up the same missing figures. codeaf does not refuse such a run or estimate its cost. + +**On such a service the bound that holds is a time limit.** Start codeaf with +`--max-hours`, or give a shell run `--max-hours`, before you hand the work off. With no +time limit, the run ends only when senior-dev finishes or you stop it. + +## Why a stopped senior-dev run takes a moment to end — the price of the call it was in the middle of + +When you stop a run, or codeaf ends it at its dollar ceiling, senior-dev is usually in +the middle of a model call. That call is still paid for, and the router prices a call cut +off like that by a receipt codeaf fetches afterwards, usually about twenty seconds later. +**The run is not over until that receipt is in**, for at most 70 seconds, so the task's +spend, the run's total and the conversation's `/cost` all include that call. A shell run +waits the same way before it prints its last line. + +A receipt that never comes is kept as a call nobody could price, never as a free one +(the section `Was I charged for a reply that got cut off` says where those are counted). +A senior-dev call answered whole whose answer carried no usage block at all is asked +about the same way, including one codeaf then set aside because it was not usable text: +priced by its receipt, or kept as a call nobody could price. codeaf never guesses a figure +for either. + +## Which models does senior-dev use — your crew, a model you ask for, its own list, --high + +**Ask for a model and it works with that one.** Say which in the chat — "use senior-dev +with kimi-k2.6", or several: "with kimi-k2.6 and deepseek-v4-pro" — and senior-dev works +with exactly those, routing among them call by call when there are several; the card and +the task's first line name them. A name that fits more than one model is put to you to +settle. A model none of your connected services can serve is refused before the card, by +name, rather than swapped for another. A model senior-dev's model catalog does not know how to size cannot be used: the +run ends before its first call with `senior-dev cannot work with <model>: …`, and nothing +is spent. The models are fixed when the run starts; changing the crew later does not move +a run already working. `/senior-dev` typed with a brief uses your crew. + +**Otherwise, from the chat it uses your crew.** codeaf hands senior-dev two of the conversation's +crew: the worker (hands) model is the one it works with, and the low model its history +summaries. Change the crew and the next run follows. The mastermind (brain) model is not +used: every call senior-dev makes is either its work or a history summary. +A crew model senior-dev's model catalog cannot size is left out, and its log says so; +if that leaves no working model, it uses its own list instead. + +**Its own list** is six open models it routes among call by call, avoiding one for a +while after it fails: deepseek-v4-flash, deepseek-v4-pro, qwen3.6-plus, kimi-k2.6, +glm-5.1 and minimax-m2.7. A run with no crew set uses it, and so does a shell run. + +**At a shell you choose**: `--high` replaces the list, `--low` sets the summaries' models, +and `--variant` sets the reasoning effort every call asks for. + +## What a shell run prints at the end — how long senior-dev ran, what it cost, waiting for the last price, a closed terminal + +At a shell, `codeaf senior-dev` first says where it works (`senior-dev · working in +<folder>, on its own branch <branch>` in a repository), then prints each stage, step and +model call as it happens, then how the run ended, then where its work is (the sentence a +task's page says), then `the run's record is in` and the run's record folder, and last one +line with what it came to: + +``` + 277 model calls · $2.30 · 22m 51s +``` + +That is the calls, the dollars, and how long senior-dev's own process ran. A figure nobody +measured is left off, never written as a zero. + +When ctrl-c or `--max-cost` stops the run in the middle of a model call, that call is still +paid for, and its price arrives by a receipt about twenty seconds later. The run waits for +it before those last lines, and says so on stderr: +`waiting up to 1m 10s for the price of 1 call that was cut short`. **A second ctrl-c leaves +at once** instead of waiting; its folder is already finished by then, and only a price +still owed is missing from the run's line and from this machine's spending ledger. + +**A closed terminal or a dropped ssh connection stops the run the way ctrl-c does**: +senior-dev is stopped and its folder finished. If the codeaf running it is killed +outright, senior-dev sees within a second that it is gone and stops; its folder is then +settled by the next run started there, without a commit (see `If codeaf quits while +senior-dev works`). + +Every call is written to this machine's spending ledger, filed as one piece of work named +after the run's record folder (such as `20260924-150405.000000`). That folder also keeps +`delegate-program.json`, with the instant senior-dev's process started and the instant it +ended. + +## senior-dev's flags — run, --variant, --in-place, --high, --max-cost + +`codeaf senior-dev <brief>` is `codeaf senior-dev run -- <brief>`. codeaf gives every +program it carries four flags: + +- `--dir DIR` — the folder to work in (the current one by default; inside a git + repository, the repository's root); +- `--max-cost USD` and `--max-hours H` — the ceilings; +- `--json` — the program's records on stdout instead of readable lines. + +senior-dev's own flags on `run`: + +- `--variant NAME` — reasoning effort sent with every call: `low`, `medium`, `high`, + `xhigh`; unset leaves the model's own default; +- `--in-place` — work without git even inside a repository: no commits, and its + checkpoints kept outside the folder. A folder with no git history is worked that way + without it; codeaf passes it itself under a repository at your home folder; +- `--high`, `--low` — comma-separated models it routes among; `--low` (its history + summaries) falls back to `--high`; +- `--frontier` — accepted, and changes nothing: no call senior-dev makes uses that tier; +- `--crew` — the models came from a conversation's crew: one its catalog cannot size is + left out instead of failing the run. codeaf passes it with the crew's models. + +`codeaf senior-dev help` describes it and its one command, `run`; +`codeaf senior-dev run --help` prints all of them, codeaf's four included. + +## How long did senior-dev take — a run's time, the clock on its page, wall time + +A senior-dev run is timed from the moment you handed it off — when its row first reads +`running`, after its folder is ready and its branch cut — to the moment senior-dev's own +process ended. Readying the folder before it, and committing what it left after it, are +not counted. A run whose senior-dev never started is timed to the moment the run ended. + +Everything that shows the run's time shows that one span: the line under its page's title +(counting up from the hand-off while it runs, and stopped at senior-dev's exit once it has +ended, even before its last changes are committed), its row and card once it has ended, the note the +conversation is handed when it lands (`done · ran 22m 51s · …`), and the chat's `tasks` +tool (`#3 · <title> · done · ran 22m 51s · via senior-dev`, or `running for 3m` while it +goes) — so you can ask the chat how long it took. Each spells it the way the page does — `42s`, `22m 51s`, +`1h 7m` — except the landed card, which spells it `22m51s`. + +The instants senior-dev's process started and ended are also kept in `delegate-program.json` +in the task's record folder, beside `delegate-stderr.log`. + +**After a reopen.** A conversation closed and opened again still shows each run's time, how +it ended in senior-dev's own words (a `senior-dev did not finish: …` stays that sentence and +is not turned into a fault), which limit stopped it when one did, `stopped` when you stopped +it, and the branch its work is on. + +**A run nothing is running any more.** If codeaf closed or crashed while senior-dev was +working, nothing is driving that run: its page reads `incomplete` rather than `running`, +its time stops at the last thing it did, and it offers no stop. + +## Does another conversation or window see my senior-dev run — the @ list, other windows, the project's task list, watching it from another window + +Yes. A senior-dev run takes a row in the project's task list the moment it starts, saying +running, and a second row closes it when it ends, with its time, how it ended, the branch +its work was kept on and what it cost. So the `@` list, another conversation's `tasks` +tool, the conversation list's task counts and every other codeaf window on the project +see it, and a window that has the run's conversation open says it is being worked on. The +conversation that started the run lists it once, by the number its rail shows. + +**Another window can watch it, read-only.** On that window's tasks place the run's row +stands under `running` with `another window` beside it, and `enter read it as it runs` +opens the page the conversation that started it shows: senior-dev's actions under their +steps, the line over them with the step, the spend, the calls and the time, and `ctrl+y` +for its raw calls. The trail reads `reading in <that conversation>` and the box says +`Reading this task… (esc: main)`; `enter` over words answers `this window is reading this +task — go to the conversation that owns it to steer or stop it`. It offers no stop: only +the conversation that started the run can stop it. This works where the engine is local, +as every page read from another window does. + +If codeaf went away while the run was working, its row is closed the next time that +conversation is opened, with the time the run had when it was last seen: it reads `codeaf +closed while senior-dev was running`, or the run's own ending when it had one. A run +senior-dev had finished but that codeaf closed under before the run was over reads +`incomplete — codeaf closed while this was still running`. + +## Why did senior-dev stop — how a run ends, its log, crashed or stopped + +A run ends in one of these ways, and the task's ending says which: + +- `finished: …` — it submitted, and the words after say what the project's build and + tests did on the frozen tree; +- `senior-dev did not finish: …` — it ended without submitting, or what it submitted fails + the project's own build or tests. It is not drawn as a fault, what it made is still on its + branch, and the chat acts on it (see what codeaf does when senior-dev ends); +- `senior-dev reached the run's dollar ceiling of $5.00: …` — codeaf refused a model call + at the dollar ceiling; the words after are senior-dev's own ending; +- `senior-dev stopped on its own ceiling: …` — it stopped itself at the time ceiling; +- `senior-dev crashed: …` — the program itself broke, or could not start (no brief, a + refused `senior-dev.json`); +- `stopped by the run: …` — you, or the run it belonged to, stopped it; what follows is + what senior-dev said on its way out, usually `stopped before it finished`; +- `codeaf closed while senior-dev was running` — the codeaf holding its conversation + stopped or crashed while it worked (see the next section); +- `senior-dev had ended; codeaf closed before it could say where its work is` — + senior-dev had already exited, and codeaf stopped before it had finished its folder + (see the next section). + +When it ends without submitting, it still checks the tree it leaves. If the project's +tests cannot even start there, the tree is put back to the last state whose build and +tests could run, or to where it began. + +## If codeaf quits while senior-dev works — closed, crashed, engine stopped, restarted mid-run, where is its work + +senior-dev ends with the engine holding its conversation. Leaving a hosted conversation's +window only detaches: senior-dev keeps working. When that engine is stopped or crashes, +the conversation is closed, or a `--no-host` codeaf quits, the run is over: its page and +side-list row read `incomplete` with `codeaf closed while senior-dev was running`, no +stage, nothing waiting on you, and no fault; or `senior-dev had ended; codeaf closed +before it could say where its work is` if it had exited. A run senior-dev had finished +reads done, with its result. + +**Its folder is settled by the next codeaf that finds the run, and nothing is +committed**: the one that opens that conversation, hands work off in it, or starts a run +in that folder, a shell run included. codeaf cannot tell senior-dev's last edits from yours +made there since, so it commits neither and switches nothing. Its branch stays checked +out as it was left, its notes are moved out, and the page adds `its work so far is on its +branch <branch> in <folder>, which is checked out there, as it left it, with N files not +committed; commit or stash them there before you go back to your branch <yours>`. A run +started in that folder then is refused over those changes, and adds `they may be an +earlier senior-dev run's, which codeaf could not finish: its branch <branch> is checked +out there`. + +**The run ends where it was last seen working**: senior-dev's exit, or else the end of its +last model call, its last charge, or its store's last change, whichever is latest. So its +time and spend do not count the hours codeaf was closed. An orderly close writes the ending +before senior-dev is stopped; after a crash the next codeaf that opens that conversation, +or hands work off in it, writes it. + +**Nothing carries it on.** The next `/senior-dev` starts a run of its own, with its own +task and page; the old page stays as the record of what it did. + +## senior-dev's log — delegate-stderr.log, agent-summary, a shell run's record folder + +Everything senior-dev said while it worked (each stage and what it knew at the time) +is kept in `delegate-stderr.log` in the task's record folder, and every stage, step and +ending it reported — what its page draws — in `delegate-actions.jsonl` beside it. Its `agent-summary` there +adds up each of its agents' calls, time and cost; the cost is the price codeaf's model +API told it for each call, not a catalog estimate, and a call nobody priced adds nothing. A run started at a shell has +no task, so its record — that log, its conversation with codeaf, its actions, its stages +and when it started and ended — is kept in a folder of its own under +`~/.codeaf/v3/carried/senior-dev/`, one per run. diff --git a/internal/manual/chat/staying-on-that-machine.md b/internal/manual/chat/staying-on-that-machine.md index 7fb61956ae..a007213fef 100644 --- a/internal/manual/chat/staying-on-that-machine.md +++ b/internal/manual/chat/staying-on-that-machine.md @@ -53,7 +53,7 @@ refused visibly rather than lost. A recent ssh connection is kept reusable for 300 seconds, so a new channel can avoid a full handshake when the underlying ssh connection is still healthy. Its control socket lives under this machine's codeaf state directory at `~/.codeaf/v3/ssh/` (moved by -`CODEAF_HOME`). The same **104-byte** socket-path limit applies there: a state path too +`CODEAF_HOME`). The same **103-byte** socket-path limit applies there: a state path too long disables reuse only; the ordinary ssh connection still opens. These network-dependent defaults are editable on `/settings`' **Workspace** tab as `ssh @@ -575,9 +575,10 @@ the one a person really does type; it has its own section above. None of them ap ## Why does codeaf take ten seconds to start, or say the conversation ends with this terminal — a state folder too long for a socket The thing that holds a conversation after you close the terminal is reached on a unix -socket under codeaf's own state folder, and a socket path may weigh at most **104 -bytes**. It is 104 rather than Linux's own 108 because the smallest limit is the one that -travels: macOS stops at 104, and the same folder can be shared over a network mount. +socket under codeaf's own state folder, and a socket path may weigh at most **103 +bytes**. That is macOS's limit (104 bytes, one of them the end of the name) rather than +Linux's larger one, because the smallest limit is the one that travels: the same folder +can be shared over a network mount. If `CODEAF_HOME` puts that folder deep enough to push the path past the limit, there is nowhere for a session host to answer, and the launch opens the conversation in this @@ -586,7 +587,7 @@ under `v3/hosts`. Everything else about the conversation works exactly as it alw It simply ends when this terminal does. The entry notice says so: ``` -this conversation opened in this terminal instead, and ends with it: codeaf's state folder is a longer path than the 104 bytes a socket may be named in — CODEAF_HOME moves it somewhere shorter +this conversation opened in this terminal instead, and ends with it: codeaf's state folder is a longer path than the 103 bytes a socket may be named in — CODEAF_HOME moves it somewhere shorter ``` **It used to cost ten seconds.** The launch started a host into a path it could never @@ -598,7 +599,7 @@ The way out is to point `CODEAF_HOME` at a shorter path — that is the whole of the next launch holds its conversation in the background again. `codeaf chat --no-host` is the same floor asked for on purpose, on any machine. -The same 104 bytes govern the reusable ssh control socket under **How quickly a dead ssh +The same 103 bytes govern the reusable ssh control socket under **How quickly a dead ssh link is noticed and retried**: a path past it turns ssh reuse off and nothing else. ## Background replies while another reply finishes diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index a3e89d4a84..053483edf0 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -543,7 +543,9 @@ block in the conversation shows: - one dim sentence under it — the first sentence of the summary, capped at 90 cells, and left out entirely when it would only repeat the name; - the facts about the work: which other window is already in these files, `where:` it will - run, and `from your folder as it stands — unsaved edits included`; + run, and `from your folder as it stands — unsaved edits included` (not on a program's + card: senior-dev works in the folder itself, and its `where:` names the folder and says + `on a branch of its own` in a repository); - a dim meta line reading `model <full id> · ctrl+e for the brief`. The model id leads because it is the one fact nothing else on screen will say again; on a narrow frame the hint is dropped and the model kept. @@ -1201,7 +1203,11 @@ gets dropped. The floor is the words you typed, not how much the reply has alrea **What still becomes a task.** Several independent pieces in one message, a sweep across many files, a rewrite you would sit and watch: those can still be handed over, proposed, or started with `/task`. Typing `/task commit everything` still starts a task, because you -asked for one. +asked for one. Asking for a program codeaf carries lifts the floor the same way: for "fix +this one line with senior-dev", a proposal that hands it to senior-dev is not refused, +because you asked for senior-dev (the programs page). A name in passing ("fix +senior-dev's typo in this file") lifts nothing, and nothing lifts it for a commit, an undo +or a revert: "revert senior-dev's commit" is done here. ## An answer that stops before your question is finished is carried on — my reply stopped halfway, it said it would do the rest and then stopped, codeaf kept going without me @@ -2725,7 +2731,9 @@ switch, and `tab` comes back. **`enter read it as it runs`** — the work belongs to a conversation the engine is running that this window can join. Pressing it opens **that task's own transcript**, live, updating as the work goes. The trail at the top reads `reading in <that conversation>` so nothing on -the page can be mistaken for this conversation's own work. `esc` returns. +the page can be mistaken for this conversation's own work. `esc` returns. A task handed to +senior-dev has no transcript, so its page is senior-dev's actions under their steps instead, +exactly as the conversation that started it shows them, with `ctrl+y` for its raw calls. This page is **read-only**. The keyboard for that task belongs to the window that owns it, so the message box says `Reading this task… (esc: main)` and sending anything answers @@ -5171,8 +5179,10 @@ number its card and the rail show, `#2`; a part the run made for itself is read place under that task, `#2.1`, `#2.2`, in an order that does not move. A listing shows each one's name, title, state and the first line of what came back; reading one task shows what it was asked, what came back in full, what the run's checks found, and its last steps. A -store's own id is never shown. A finished task is asked about this way and is never redone -or rechecked by hand. +task handed to senior-dev also says how long it has taken — `#3 · <title> · done · ran 22m 51s`, +or `running for 3m` while it goes — and it is read this way whatever the task belt is set +to. A store's own id is never shown. A finished task is asked about this way and is never +redone or rechecked by hand. Tasks from earlier sittings and from other windows are still listed after the run's, and a number the run does not hold is answered the way it always was. @@ -5549,7 +5559,10 @@ and breadcrumbs remain available. ## will the chat do it itself or start a task? One read, one edit or one command the chat does itself. Anything with parts goes -out as tasks. There is **one way** the chat puts work out, a task: +out as tasks. Complex coding work a program codeaf carries is for, such as fixing an +issue in a mature codebase, goes to that program (senior-dev), and so does work you +name a program for; the programs page says when. There is **one way** the chat puts work +out, a task: - **hand off:** the chat proposes a task; approving the card, or letting its countdown run out, starts it as a run in the conversation's plan. @@ -5585,3 +5598,11 @@ the run has moved and somebody is looking, rather than every time you look, and once when the run lands. The **now** sentence also appears under the run's dot row in the rail, dim and two lines at most. Without a model key the lines are absent; the task facts remain available on their own. + +Each refresh is one model call, and it is counted like any other: it is in the +conversation's spend on the status line, in `/cost` and in the spending ledger, +even when its answer could not be used. Two looks at the same moment buy one +refresh, not two. A run with no rows yet buys none, and neither does a task +handed to a program such as senior-dev: its page is the actions it took, each +under the step of its process, and its row already says the step it is in, so it has +no four lines. diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index e903ddccb9..869e89cbe2 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -27,17 +27,23 @@ says `It joined the work already underway and shares its copy.` A proposed task ANOTHER folder is refused while that run is underway, with both folders named and `tasks that run together share one copy of one folder. Propose it again when that work has ended`. A task handed off after the run has ended starts a run of its own, in a new -copy cut from your folder as the first run left it. A task handed off in the few seconds -while a run is finishing (its work landing, its summary being written) waits until that -run is over and then starts its own: it never joins a run on its way out. +copy cut from your folder as the first run left it. So does one handed off after a run +that nothing is driving any more (a limit you set ended it, or codeaf closed under it): +the old run's store is kept beside the new one as its record — an ordinary run's ended as +`interrupted`, and a program's run nothing had ended first ended where it was last seen, +in its own words — and new work never runs inside it. A task handed off in the few +seconds while a run is finishing (its work landing, its summary being written) waits +until that run is over and then starts its own: it never joins a run on its way out. ## When does a task run's work come home, including commits its workers made When a `/task` run on the worker harness ends, its copy's uncommitted work is committed and merged into the folder it was cut from, the copy is given back, and the run's page carries `its work is in <folder> on <branch>`. The conversation is woken with the same -note a landed task sends: the outcome word, the result the root reported, and where the -work went (`landed on <branch>: N files`, or the sentence saying why it did not). Work +note a landed task sends: the outcome word, how long the run took (`ran 4m 12s`, from the +hand-off to the moment its work ended, and left out under a second), the result the root +reported, and where the work went (`landed on <branch>: N files`, or the sentence saying +why it did not). Work that will not go in is never forced: the branch is kept in your repository and the note names it, for example `its branch <branch> was kept`, when your checkout moved on after the copy was cut. A run whose workers committed everything still names its @@ -48,7 +54,9 @@ landing card says `merged` when the work is in your folder and `branch kept` onl branch that is waiting. A hand-off that joined the run ends with it: its row settles `done` or `incomplete` when the run's does. The row the run was published under settles `done` when the run finished whole and -`incomplete` on any other ending. +`incomplete` on any other ending. Each of these rows is in the project's task list (the +`@` list, other conversations' `tasks` tool, other windows) from the moment it starts, and +is closed there with its time when it settles. **With the switch unset, this is the road `/task` takes.** Set `CODEAF_TASK_BELT=node` to use the older session tree road instead. See *How @@ -110,7 +118,7 @@ The only difference is where it reads from: a run's task is read from the run's A page the engine will not answer for — a task this conversation did not spawn, or one whose store has gone — is not opened; the list stays where it was. -## Open a run's task from the side list — click its row, or one of its parts +## Open a run's task from the side list — click its row, or one of its parts, and leave it with esc With the switch on, a run is drawn in the conversation's side list as its own row, `#N`, with its parts and their checks hanging under it. **Every one of those rows @@ -124,7 +132,9 @@ row each, the finished ones included. Every one of those rows is a door: click the run's row, or select it and press `enter`, and its room opens over the conversation; click a part's row or a check's row and THAT task's room opens. It is the room the tasks place opens and the run's tab opens: what the task was asked, its steps, -its notes, and the box that leaves a note. `esc` goes back to the conversation exactly as +its notes, and the box that leaves a note. A task handed to a program such as senior-dev +opens its own program room instead (see *A program's task page is a conversation, not +steps*). `esc` goes back to the conversation exactly as you left it, with whatever you had typed still in the box. The room can take a moment to arrive. From the press on, what you type belongs to the @@ -138,9 +148,10 @@ every three seconds, so a new step shows within that, and it stops reading when has settled. A room on a task that has ended is read once, to open it. A step whose command is many lines long is drawn as its first line and `…`; what ran is unchanged. -A row the store has no page for opens what it always opened, its room. That is every -task when the switch is off. A task of an earlier run keeps its page after a later run -has started. +A row the store has no page for opens what it always opened, its room: with the switch +off that is every task, except one handed to a program, which opens the program's room +with the switch on or off. A task of an earlier run keeps its page after a later run has +started. ## Can I still read a task from an earlier run? @@ -258,6 +269,85 @@ room's head says the task is working, how long it has run and what it has cost, task room's head does. When the command ends the store clears the live step and the next read draws it as an ordinary call, with what came back behind it. +## A program's task page is the actions it took, not steps — a delegate's page: open it, leave it, no tab of its own, no note box, what the box says + +A task handed to a program codeaf carries (`/<name> <brief>`, such as `/senior-dev`) +opens **inside the conversation's own tab**, as any task does: from its row on the side +list, its card in the conversation, a task link, the task strip or the home panel. The +tab strip stays on top with the conversation's tab the one selected and the `home` tab beside +it, and the program gets no tab of its own. + +``` + the run ▸ rewrite the auth middleware esc/← main +─ implement · $1.24 of $5.00 · 3 calls · 14m 3s ─────────────────── Stop ─ + BRIEF rewrite the auth middleware to use the new session store + SETUP set up its workspace git + SPEC wrote your brief down as its spec + EXPLORE read internal/auth/middleware.go + ran go test ./internal/auth/... fails · exit 1 + IMPLEMENT edited internal/auth/middleware.go + ◐ thinking · 12s +``` + +`esc`, a press on the conversation's tab and a press on the `home` tab leave it; none of them +stops the run. `ctrl+o` opens and folds a long brief. `ctrl+y` turns the page to the +program's raw calls and back. `x` over an empty box, `/stop`, or `Stop` at the end of the +line over the page asks `Stop this task?` and ends the whole run. + +**The box sends nothing.** A program reads no message. The box says `<program> reads no +messages — say it to main` (`senior-dev reads no messages — say it to main`), and `enter` +over a sentence says the same line on the page and leaves your words in the box. Once the +run has ended its foot and its box say `this task has finished — say it to main`. + +In the tasks place, `enter` on the program's row opens the same page as a page of that +place, with no box at all. + +## Reading a program's actions — the step words down the side, how each came out, the call in flight, how long it has run + +The page shows what the program did, as the program itself says it: every stage, step and +ending it reported, kept as codeaf received them, each read in the program's own words. +The word down the left is the step of the program's own process the action served +(senior-dev's page has its own section on its steps). It is printed on the first action +of each run of actions in one step and left blank for the rest, so a word comes back when +the program comes back to that step. How an action came out is at the right edge, dim: +`passes`, `fails · exit 2`, `4 files`. Under about 28 cells of room the step's word +stands on its own line and its actions hang under it. + +The page opens on the brief, under `BRIEF`. What only the program's model calls know is +put in where it happened, each one plain line: `compacted its memory` when the program +rewrote its history as a summary, `switched to <model>` when another model started +answering its work (with the program's reason after it when it gave one), `codeaf +refused a call · <why>` and `a call to its model failed · <why>`. A model is named nowhere +else. While a call is out the last line is `◐ thinking` and its seconds. A long run shows +its newest actions under a line such as `…142 earlier actions`. A run from before codeaf +kept a program's actions is drawn from its model calls, each tool asked for as one action +with no step word, and a long one shows its newest under `…142 earlier calls`. + +The line over the page stays put while you scroll: the step the program is in (before it +names one, its stage in the word it gives a person; the task's own word, such as +`running` or `done`, when there is neither), what the run has spent (`of` its ceiling when +the page knows it), how many model calls it has made, and how long it has been going. A +figure with nothing behind it is left out, and a narrow window drops the time first. The +time counts from the moment codeaf handed the work over and stops when the program's own +process ends. The page reads the store again every three seconds while the run works, and +once more after its work has landed, so the note on where the work went is on the page. + +## A program's raw calls — ctrl+y, the dialogue with its model, what it sent and what the model answered + +`ctrl+y` on a program's page — in its room or in the tasks place — turns it to the raw +calls the program made, and `ctrl+y` again turns it back to the actions; the key row says +which: `ctrl+y calls` or `ctrl+y actions`. A page opens on the actions. + +The calls are the conversation between the program and the model that answered it, for +seeing exactly what it was sent. Each call is the program's side — a tool's result as +`<tool>: <first line>`, its own words, or `summarized its history so far` — and the +model's, named by its short name: the first line of its answer, and one dim row per tool +it asked for behind that tool's mark. A call codeaf refused is one line from `codeaf`, +`refused · <why>`; a failed one is `the call failed · <why>`. The call in flight is the +last line, `◐`, the model and its seconds. Only the first line of each message is drawn, +and a long run shows its newest calls under a line such as `…142 earlier calls`; the +task's own record keeps more of every call. + ## Why is a step missing, the step numbers skip, the cd at the front of a command is gone **The steps a run's task shows are the work, cut from the commands as they ran.** Two @@ -502,7 +592,9 @@ what you actually asked for would list every row of the run and never learn it. Press `x` over an empty box while the run's row is the one task row on the side list, or open the run's task room and press `x` there over an empty box. Both raise the same -card, `Stop this task?`, with `stop it` and `keep going`. A digit moves the choice, `enter` takes it, and `esc` is +card, `Stop this task?`, with `stop it` and `keep going`. On a task handed to a program, +`x` over an empty box, `/stop`, or `Stop` at the end of the line over its conversation +raises the same card. A digit moves the choice, `enter` takes it, and `esc` is `keep going`. Nothing ends on one keystroke. Telling the chat "stop task 1" ends a run the same way and asks nothing, because your sentence is the decision. diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 67c9cc39e2..d9309e5153 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -952,6 +952,78 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I stop a run writing outside one folder", "adaptive-runs"}, {"it broke a rule I set", "adaptive-runs"}, {"what is a harness", "saved-shapes-of-work"}, + {"what is a delegate", "delegates"}, + {"can you hand this whole task to another coding agent", "delegates"}, + {"what does the command for a delegate do", "delegates"}, + {"delegate this to another coding agent", "delegates"}, + {"why can't the delegate ask me anything", "delegates"}, + {"why is there no command for my delegate", "delegates"}, + {"where does a delegate's work go, does it squash the commits", "delegates"}, + // senior-dev, the program codeaf carries, asked the ways somebody meets + // it: what the command does, whether it will stop to ask, where its + // commits went, what it cost, its flags, and why a Windows build has none. + {"what does /senior-dev do", "senior-dev"}, + {"will senior-dev stop and ask me questions while it works", "senior-dev"}, + {"where did senior-dev's commits go", "senior-dev"}, + {"how much does a senior-dev run cost", "senior-dev"}, + {"what flags does codeaf senior-dev take", "senior-dev"}, + {"why is there no /senior-dev on windows", "senior-dev"}, + {"run senior-dev on a benchmark task from a repository I have not cloned", "senior-dev"}, + {"can senior-dev work in a folder that is not a git repository", "senior-dev"}, + {"senior-dev says workspace is not a git repository", "senior-dev"}, + {"senior-dev finished but its work is not in my folder", "senior-dev"}, + {"how do I merge senior-dev's branch", "senior-dev"}, + {"which models does senior-dev use", "senior-dev"}, + {"make senior-dev use my crew models", "senior-dev"}, + {"how long did the senior-dev run take", "senior-dev"}, + {"can a senior-dev task wait for another task to finish first", "senior-dev"}, + {"retry a senior-dev task that failed", "senior-dev"}, + {"senior-dev's page still says running after codeaf crashed", "senior-dev"}, + {"codeaf closed while senior-dev was running where is its work", "senior-dev"}, + {"my ssh connection dropped during codeaf senior-dev", "senior-dev"}, + {"can my other window see the senior-dev run", "senior-dev"}, + {"watch a senior-dev run from another window", "senior-dev"}, + // Its page is the actions it took, each under the step of its process, + // asked the ways somebody watching it would ask. + {"what is senior-dev doing", "senior-dev"}, + {"what do the steps on senior-dev's page mean", "senior-dev"}, + {"how do I see senior-dev's raw calls to its model", "senior-dev"}, + // And its badge, asked by somebody who has just seen a bracketed word on a + // task and does not know what it is, and by somebody looking for one. + {"how do I tell a senior-dev task from a normal task", "senior-dev"}, + {"what does [senior-dev] mean on a task", "senior-dev"}, + {"what is the [sd] next to a task on the side list", "senior-dev"}, + {"which of my tasks are senior-dev's", "senior-dev"}, + {"does every delegate get its own badge", "delegates"}, + // And whether codeaf reaches for it on its own, asked the ways somebody + // who has just watched it do the work itself, or wants it to, puts it. + {"will codeaf use senior-dev by itself", "senior-dev"}, + {"when does codeaf hand work to senior-dev", "senior-dev"}, + {"how do I make codeaf use senior-dev", "senior-dev"}, + {"I asked for senior-dev and it did the work itself", "senior-dev"}, + {"how do I stop it using senior-dev for this", "senior-dev"}, + {"will codeaf hand work to a program without being asked", "delegates"}, + {"is naming a delegate enough to make codeaf use it", "delegates"}, + {"I typed a correction and it forgot I named the delegate", "delegates"}, + {"does a correction undo naming a program", "delegates"}, + {"why did it revert the delegate's commit itself instead of using the delegate", "delegates"}, + {"which folder does a delegate work in", "delegates"}, + // A program works in the folder itself, on a branch of its own in a + // repository (internal/session's programfolder.go), asked the ways + // somebody meets it: where the work went, whether their branch moved, + // how to get back, and the refusals that stop a run before it starts. + {"where does senior-dev put its work", "senior-dev"}, + {"does senior-dev change my branch", "senior-dev"}, + {"how do I go back to my own branch after senior-dev", "senior-dev"}, + {"senior-dev says my branch moved during the run", "senior-dev"}, + {"senior-dev refused: changes that are not committed", "senior-dev"}, + {"senior-dev says my folder is busy", "senior-dev"}, + {"why can't I run senior-dev in a folder inside the one another run is working in", "senior-dev"}, + {"can I run senior-dev in a folder that is not a git repo", "senior-dev"}, + {"where do senior-dev's notes go", "senior-dev"}, + {"why can't codeaf edit files while senior-dev is working", "senior-dev"}, + {"my task was refused because senior-dev is working in the folder", "senior-dev"}, + {"the delegate was refused because of uncommitted changes", "delegates"}, {"the harness I just had built is not in /subharness", "subharnesses"}, {"how do I run a harness I had designed", "subharnesses"}, // The card codeaf raises by itself, asked the three ways somebody meets @@ -2844,6 +2916,14 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { // watching something the plan does not name is the shape the note was // written for, and these are the words of a person holding it. {"my task is waiting on a build outside the plan", "worker-harness"}, + // A program's task page (internal/tui3's taskconversation.go), asked + // the way somebody meets it: a page that is not the list of steps every + // other task opens on, and the exchange they are watching on it. + {"what is the program saying to the model on its task page", "worker-harness"}, + {"what does the delegate's task page show", "worker-harness"}, + {"can I leave a note for the delegate", "worker-harness"}, + {"what are the words down the side of a program's task page", "worker-harness"}, + {"what does ctrl+y do on a program's page", "keys"}, // Notes as a channel rather than a log (internal/run's note channel and // the chat's own `tasks` listing): whether the note was read, when, and // where anyone else can see it. The first four are the question a person diff --git a/internal/namelaw/namelaw_test.go b/internal/namelaw/namelaw_test.go index 88c2659d57..733c6ec4d6 100644 --- a/internal/namelaw/namelaw_test.go +++ b/internal/namelaw/namelaw_test.go @@ -226,7 +226,7 @@ func TestW7ThePromptNamesCodeafOnceAndNamesNoRetiredProduct(t *testing.T) { if err != nil { t.Fatal(err) } - wantFiles := []string{"bashrules.md", "bashtask.md", "bashworker.md", "discipline.md", "divide.md", "fanout.md", "landing-answer.md", "quick.md", "revise.md", "runask.md", "runsummary.md", "shape.md", "system.md", "worker.md"} + wantFiles := []string{"bashrules.md", "bashtask.md", "bashworker.md", "discipline.md", "divide.md", "fanout.md", "landing-answer.md", "program-outcome.md", "quick.md", "revise.md", "runask.md", "runsummary.md", "shape.md", "system.md", "worker.md"} var gotFiles []string var corpus []byte for _, entry := range entries { diff --git a/internal/plandb/stoproot_test.go b/internal/plandb/stoproot_test.go index 21420796d4..41ecea7bd5 100644 --- a/internal/plandb/stoproot_test.go +++ b/internal/plandb/stoproot_test.go @@ -3,6 +3,7 @@ package plandb import ( "path/filepath" "testing" + "time" ) // A PERSON'S STOP ENDS THE WHOLE RUN IN THE STORE, AND KEEPS WHAT HAD LANDED. @@ -50,6 +51,102 @@ func TestStopRootEndsTheRunAndEverythingStillOpenUnderIt(t *testing.T) { } } +// A RUN WHOSE OWN TASK FAILED IS OVER IN THE STORE. Nothing wrote its ending, +// so it read as running for ever and the next hand-off would have adopted it. +// The runtime's verb fails the run's task with the reason, cancels what is +// still open, writes no result, and leaves what had ended as it ended. +func TestFailRootEndsTheRunWithoutAResult(t *testing.T) { + store := planOpen(t, filepath.Join(t.TempDir(), "plan.json")) + planAdd(t, store, planSpec("landed", "Landed"), planSpec("waiting", "Waiting")) + planFinish(t, store, "landed", "worker", "landed delivered") + + if err := store.FailRoot("senior-dev did not finish: its tests fail"); err != nil { + t.Fatalf("fail root: %v", err) + } + root := store.Task("root") + if root.Status != StatusFailed || root.Error != "senior-dev did not finish: its tests fail" || root.Result != "" || root.CompletedAt.IsZero() { + t.Fatalf("the run's own task after it failed = %s, %q, result %q, ended %v", root.Status, root.Error, root.Result, root.CompletedAt) + } + if task := store.Task("waiting"); task.Status != StatusCancelled { + t.Fatalf("open work under a failed run = %s, want cancelled", task.Status) + } + if task := store.Task("landed"); task.Status != StatusDone || task.Result != "landed delivered" { + t.Fatalf("work that had already landed was rewritten: %s, %q", task.Status, task.Result) + } + if err := store.FailRoot("again"); err != nil || store.Task("root").Error != "senior-dev did not finish: its tests fail" { + t.Fatalf("a second ending rewrote the first: %v, %q", err, store.Task("root").Error) + } +} + +// A RUN WHOSE PROCESS WENT AWAY IS ENDED WHEN IT WAS LAST SEEN. The next +// process to find its store open ends it at the instant it names, so the run's +// page does not count the hours nobody was driving it; an instant before the +// run began or after now is held inside what can be true of the run. +func TestFailRootAtEndsTheRunAtTheInstantItNames(t *testing.T) { + store := planOpen(t, filepath.Join(t.TempDir(), "plan.json")) + // The run's clock starts where its own task was made, so every instant + // below is one the run could have lived through. + clock := store.Task("root").CreatedAt + store.now = func() time.Time { return clock } + planAdd(t, store, planSpec("waiting", "Waiting")) + + lastSeen := clock + clock = clock.Add(11 * time.Hour) + if err := store.FailRootAt("codeaf closed while senior-dev was running", lastSeen.Add(29*time.Minute)); err != nil { + t.Fatalf("fail root at: %v", err) + } + ended := store.Task("root") + if ended.Status != StatusFailed || ended.Error != "codeaf closed while senior-dev was running" { + t.Fatalf("the run's own task = %s (%q), want failed with the sentence", ended.Status, ended.Error) + } + if want := lastSeen.Add(29 * time.Minute); !ended.CompletedAt.Equal(want) || !ended.UpdatedAt.Equal(want) { + t.Fatalf("the run ended at %v (updated %v), want the instant it was last seen, %v", ended.CompletedAt, ended.UpdatedAt, want) + } + if task := store.Task("waiting"); task.Status != StatusCancelled || !task.CompletedAt.Equal(lastSeen.Add(29*time.Minute)) { + t.Fatalf("open work under the run = %s ended %v, want cancelled with the run", task.Status, task.CompletedAt) + } + + // Held inside the run's own life: never before it began, never after now. + early := planOpen(t, filepath.Join(t.TempDir(), "plan.json")) + early.now = func() time.Time { return clock } + began := early.Task("root").CreatedAt + if err := early.FailRootAt("gone", began.Add(-time.Hour)); err != nil { + t.Fatal(err) + } + if got := early.Task("root").CompletedAt; !got.Equal(began) { + t.Fatalf("an ending before the run began was written at %v, want its start %v", got, began) + } + late := planOpen(t, filepath.Join(t.TempDir(), "plan.json")) + late.now = func() time.Time { return clock } + if err := late.FailRootAt("gone", clock.Add(time.Hour)); err != nil { + t.Fatal(err) + } + if got := late.Task("root").CompletedAt; !got.Equal(clock) { + t.Fatalf("an ending in the future was written at %v, want now %v", got, clock) + } +} + +// The ledger's latest charge is the last moment a run was certainly spending, +// and a ledger with none answers nothing rather than a zero-cost instant. +func TestLastSpendAtIsTheLedgersLatestCharge(t *testing.T) { + clock := time.Date(2026, time.September, 24, 1, 14, 6, 5e8, time.UTC) + store := planOpen(t, filepath.Join(t.TempDir(), "plan.json")) + store.now = func() time.Time { return clock } + if got := store.LastSpendAt(); !got.IsZero() { + t.Fatalf("a ledger with no charge answered %v", got) + } + for _, step := range []time.Duration{0, 9 * time.Second, 3 * time.Second} { + clock = clock.Add(step) + if err := store.AddSpend("root", "delegate/senior-dev", "work", 0.01, 10, 2); err != nil { + t.Fatal(err) + } + } + want := time.Date(2026, time.September, 24, 1, 14, 18, 5e8, time.UTC) + if got := store.LastSpendAt(); !got.Equal(want) { + t.Fatalf("the latest charge = %v, want %v", got, want) + } +} + // A RUN THAT ENDS ON ITS OWN LIMIT OR ITS OWN WORKER IS ENDED IN THE STORE, // AND NOT AS A PERSON'S STOP. The run's own task is failed with the reason, // what was still open is cancelled under the same reason, what had landed keeps diff --git a/internal/plandb/store.go b/internal/plandb/store.go index 6342c81d14..b54b2167b9 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -1530,11 +1530,12 @@ func (s *Store) CompleteRoot(result string) error { // a cascade that follows cancelled parents stops at a parent that ended earlier // and would leave the open work under it to be offered to the next worker. // -// A RUN LEFT OPEN IS A RUN THE NEXT HAND-OFF ADOPTS, which is why a stop has to -// be written here and cannot only be a context somebody cut: a store whose run -// task is still open is picked up again by the next run over it, stopped work -// included. Two presses are one stop, and a run that ended by itself is left as -// it ended. +// A RUN LEFT OPEN READS AS RUNNING, which is why a stop has to be written here +// and cannot only be a context somebody cut: a store whose run task is still +// open is drawn as work going, and a door that adopts open stores (the +// headless errand's, the carry-on door) picks it up again, stopped work +// included. Two presses are one stop, and a run that ended by itself is left +// as it ended. func (s *Store) StopRoot(reason string) error { return s.closeRoot(StatusCancelled, reason) } @@ -1587,6 +1588,73 @@ func (s *Store) closeRoot(rootStatus Status, reason string) error { }) } +// FailRoot ends the run because the run's own task failed: its worker came +// home with an error and nothing of the run is still working. Only the runtime +// calls it, the way only the runtime calls [Store.CompleteRoot] and +// [Store.StopRoot]. The run's task is failed with the reason, and every other +// task still open is cancelled with it, in one transaction; a task that had +// already ended keeps its ending. No result is written: a result is what a +// finished run delivers, and a failed worker's account is not one. +// +// A FAILED RUN WAS LEFT OPEN, AND AN OPEN RUN READS AS RUNNING. Nothing wrote +// the ending of a run whose own worker failed, so its store said `running` for +// ever: the task's page drew `running` and offered `stop it` over a program +// that had ended forty minutes earlier, and a door that adopts open stores +// would have taken the dead run up as live work ([Store.StopRoot]). A run that +// already ended is left as it ended. +func (s *Store) FailRoot(reason string) error { + return s.FailRootAt(reason, time.Time{}) +} + +// FailRootAt is [Store.FailRoot] with the instant the run ended named rather +// than read off the clock: the zero time is now, which is FailRoot itself. +// +// A RUN WHOSE PROCESS WENT AWAY ENDED WHEN IT WAS LAST SEEN, NOT WHEN SOMEBODY +// NOTICED. A program's run that codeaf was closed under is ended by the next +// process that finds its store open, which can be hours later; written at that +// moment, the run's page counted every hour the machine sat idle as time the +// program had worked. The caller names the run's last evidence of life instead +// (its last model call, its last charge, the store's own last write), and the +// ending is written there. +// +// The instant is held inside what can be true of the run: never before its own +// task was made, because a run cannot end before it began, and never after +// now, because an ending in the future would read as a run still going. +func (s *Store) FailRootAt(reason string, at time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.transact(func(next *state, now time.Time) error { + root := next.Tasks[next.RootID] + if root == nil || terminal(root.Status) { + return errNoChange + } + ended := now + if !at.IsZero() && at.Before(now) { + ended = at.UTC() + } + if ended.Before(root.CreatedAt) { + ended = root.CreatedAt + } + reason = strings.TrimSpace(reason) + for _, task := range next.Tasks { + if terminal(task.Status) || task.ID == root.ID { + continue + } + task.Status, task.Error, task.ClaimedBy = StatusCancelled, reason, "" + task.Owner, task.SeenAt = "", time.Time{} + task.UpdatedAt, task.CompletedAt = ended, ended + if ended.Before(task.CreatedAt) { + task.UpdatedAt, task.CompletedAt = task.CreatedAt, task.CreatedAt + } + } + root.Status, root.Error, root.ClaimedBy = StatusFailed, reason, "" + root.Owner, root.SeenAt = "", time.Time{} + root.UpdatedAt, root.CompletedAt = ended, ended + promote(next, ended) + return nil + }) +} + // Archive moves whole finished subtrees out of the live plan and into the // archive: a task and every task under it, when each one has been terminal — // done, cancelled or failed — for longer than the window. The moved tasks @@ -2969,6 +3037,43 @@ func (s *Store) SpendBy(axis string, since time.Time) []SpendLine { return lines } +// LastSpendAt answers when the ledger's latest charge was written, and the +// zero time for a ledger with none or a store that is closed. It is one of the +// three readings a run's last evidence of life is taken from, beside its last +// model call and the store's own last write ([Store.FailRootAt] says why that +// instant matters): a charge is written the moment a call was paid for, so it +// is the latest moment the run was certainly still spending. +// +// THE LATEST IS FOUND IN GO, not with MAX() in the query, for the reason +// SpendBy gives: `at` is RFC3339Nano text, whose fractional digits vary, so a +// text comparison would misorder a whole second against its own fraction. +func (s *Store) LastSpendAt() time.Time { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return time.Time{} + } + rows, err := s.rdb.Query(`SELECT at FROM spend`) + if err != nil { + return time.Time{} + } + defer rows.Close() + var latest time.Time + for rows.Next() { + var at string + if err := rows.Scan(&at); err != nil { + return time.Time{} + } + if moment, err := parseTime(at); err == nil && moment.After(latest) { + latest = moment + } + } + if rows.Err() != nil { + return time.Time{} + } + return latest +} + func cloneTask(task *Task) *Task { if task == nil { return nil diff --git a/internal/provider/billing.go b/internal/provider/billing.go index 01277d3305..feee7921e6 100644 --- a/internal/provider/billing.go +++ b/internal/provider/billing.go @@ -94,8 +94,25 @@ type Reconciled struct { // finish without their usage blocks at the same instant. type ReconcileSink func(Reconciled) +// ReceiptPending is told the moment a receipt is queued for a call whose +// stream ended without its usage block, and answers the function to call once +// that receipt's one answer has reached the [ReconcileSink]. The answer is +// called exactly once, found or not, so a count kept with it always comes back +// to zero. +// +// IT EXISTS FOR WORK WHOSE BOOKS CLOSE. A receipt is fetched in the background +// on a schedule that runs for seconds after the call returned, and a caller +// that reads its total and closes its books the moment its last call ends +// reads a total without that money — the stopped senior-dev runs of +// 2026-09-23 lost their in-flight call exactly so, about twenty seconds before +// its receipt arrived. With this armed, such a caller can wait (bounded by +// [ReceiptWait]) for what it is still owed before it reads the total. +type ReceiptPending func() (done func()) + type billingContextKey struct{} type reconcileContextKey struct{} +type receiptPendingContextKey struct{} +type unmeteredReceiptsContextKey struct{} // WithBilling arms one piece of work's banking. Like the transcript sink it // belongs to the work rather than to the client, because one client serves @@ -117,6 +134,51 @@ func WithReconcile(ctx context.Context, sink ReconcileSink) context.Context { return context.WithValue(ctx, reconcileContextKey{}, sink) } +// WithReceiptPending arms one piece of work to be told about every receipt +// queued on its behalf and when each was answered ([ReceiptPending]). It +// changes nothing about how a receipt is fetched or banked: the money still +// reaches the work through [WithReconcile] alone. +func WithReceiptPending(ctx context.Context, pending ReceiptPending) context.Context { + if pending == nil { + return ctx + } + return context.WithValue(ctx, receiptPendingContextKey{}, pending) +} + +// WithUnmeteredReceipts arms one piece of work to have an answer that arrived +// whole but carried no usage block settled the way a cut one is ([Client.settle]): +// its receipt is asked for by generation id, or it is told as a call nobody +// could price. Without it such an answer is billed nowhere and said nowhere, +// which is every other caller's behaviour, left alone on purpose. +// +// IT IS OPT-IN BECAUSE IT IS NEW MONEY ON AN OLD ROAD. A program's model API +// arms it (internal/provider/modelapi): its runs are held to a dollar ceiling +// and read as one account, and true-myth's call 7483768e of 2026-09-23 — a 200 +// on kimi-k2.6 after nearly eight seconds with no usage block — was in no book +// at all. A direct service is untouched either way, because settle stops at +// one: its missing usage block is a subscription's silence, not a charge. +func WithUnmeteredReceipts(ctx context.Context) context.Context { + return context.WithValue(ctx, unmeteredReceiptsContextKey{}, true) +} + +// unmeteredReceiptsFrom reports whether [WithUnmeteredReceipts] armed ctx. +func unmeteredReceiptsFrom(ctx context.Context) bool { + if ctx == nil { + return false + } + armed, _ := ctx.Value(unmeteredReceiptsContextKey{}).(bool) + return armed +} + +// receiptPendingFrom reads back what [WithReceiptPending] armed, or nil. +func receiptPendingFrom(ctx context.Context) ReceiptPending { + if ctx == nil { + return nil + } + pending, _ := ctx.Value(receiptPendingContextKey{}).(ReceiptPending) + return pending +} + // billingFrom reads back the sink WithBilling armed, or nil. func billingFrom(ctx context.Context) BillingSink { if ctx == nil { @@ -170,6 +232,17 @@ func (c *Client) bill(ctx context.Context, model string, response *ai.Response) sink(billed) } +// billAnswered bills an answer that arrived whole. One with no usage block is +// settled like a cut one when the work asked for that ([WithUnmeteredReceipts]), +// and billed the ordinary way — which banks nothing for it — otherwise. +func (c *Client) billAnswered(ctx context.Context, model string, response *ai.Response, answerBytes int) { + if response != nil && response.Usage == nil && unmeteredReceiptsFrom(ctx) { + c.settle(ctx, model, response, receiptUnmeteredReason, answerBytes) + return + } + c.bill(ctx, model, response) +} + // BillingSinkFrom and CallNodeFrom read back what a leaf's context was armed // with. They exist for the surfaces that arm it and the tests that check they // did: arming billing is one line at three call sites, and a call site that @@ -179,5 +252,12 @@ func BillingSinkFrom(ctx context.Context) BillingSink { return billingFrom(ctx) // ReconcileSinkFrom reads back the receipt sink [WithReconcile] armed, or nil. func ReconcileSinkFrom(ctx context.Context) ReconcileSink { return reconcileFrom(ctx) } +// UnmeteredReceiptsFrom reports whether [WithUnmeteredReceipts] armed ctx. +func UnmeteredReceiptsFrom(ctx context.Context) bool { return unmeteredReceiptsFrom(ctx) } + +// ReceiptPendingFrom reads back what [WithReceiptPending] armed, or nil — for a +// scripted funnel that owes a receipt the way the provider's own does. +func ReceiptPendingFrom(ctx context.Context) ReceiptPending { return receiptPendingFrom(ctx) } + // CallNodeFrom is the node WithCallNode named, empty when nothing did. func CallNodeFrom(ctx context.Context) string { return callNode(ctx) } diff --git a/internal/provider/client.go b/internal/provider/client.go index a97ea9cdc1..75df588dfc 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -1101,7 +1101,12 @@ func (c *Client) completionInOnePiece( began: logBegan, status: status, served: served, err: cut, responseBody: payload, }) - c.bill(ctx, c.modelFor(request), &response) + // THROUGH THE ANSWERED DOOR, not the bare one: the provider charged for + // this 200 whether or not its text was language, and on work that asked + // for it an answer with no usage block is priced by its receipt + // ([Client.billAnswered]). The bare door banks nothing without usage, so + // that charge reached no book at all. + c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response))) return nil, false, cut } reasonWord, servedWell := answerOutcome(&response) @@ -1124,7 +1129,7 @@ func (c *Client) completionInOnePiece( }) // The money, banked at the same instant the log row is written and for the // same reason: this is where the fact is known. See billing.go. - c.bill(ctx, c.modelFor(request), &response) + c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response))) return &response, len(relearned) > 0, nil } @@ -2165,7 +2170,8 @@ func (c *Client) completeWithMessagesStreaming( response: response, reasoningTokens: reasoningTokens, ttft: firstTokenAfter(began, firstToken), }) - c.bill(ctx, c.modelFor(request), response) + // Through the answered door, for the reason the whole-body twin gives. + c.billAnswered(ctx, c.modelFor(request), response, content.Len()) return nil, false, cut } // A RESCUE IS NOT THE TURN UNTIL IT READS AS LANGUAGE. The hedge used @@ -2179,7 +2185,9 @@ func (c *Client) completeWithMessagesStreaming( response: response, reasoningTokens: reasoningTokens, ttft: firstTokenAfter(began, firstToken), }) - c.bill(ctx, c.modelFor(request), response) + // A rescue that is not language was still paid for, so it goes through + // the answered door too. + c.billAnswered(ctx, c.modelFor(request), response, content.Len()) return nil, false, cut } // PAST EVERY GUARD, SO THIS LANE SERVED — the recovery half of the quality @@ -2209,7 +2217,7 @@ func (c *Client) completeWithMessagesStreaming( // Both paths or neither, exactly as the learning above: a streamed answer // is billed by the provider the same way a whole-body one is, and a ledger // blind to one of the two transports is a ledger nobody can reconcile. - c.bill(ctx, c.modelFor(request), response) + c.billAnswered(ctx, c.modelFor(request), response, content.Len()) finished = true observer(StreamEvent{Kind: StreamFinished, Session: session}) return response, relearned, nil diff --git a/internal/provider/modelapi/export_test.go b/internal/provider/modelapi/export_test.go new file mode 100644 index 0000000000..5a74963268 --- /dev/null +++ b/internal/provider/modelapi/export_test.go @@ -0,0 +1,11 @@ +package modelapi + +import "time" + +// ShortenReceiptWait lets a test outside the package see Close give up on a +// receipt that never comes without waiting the provider's whole schedule. +func ShortenReceiptWait(bound time.Duration) (restore func()) { + was := receiptWait + receiptWait = bound + return func() { receiptWait = was } +} diff --git a/internal/provider/modelapi/resolve.go b/internal/provider/modelapi/resolve.go new file mode 100644 index 0000000000..d1e0194b1f --- /dev/null +++ b/internal/provider/modelapi/resolve.go @@ -0,0 +1,98 @@ +package modelapi + +// Which model answers a program's call. +// +// A program asks for the models of its own pool — senior-dev names DeepSeek, +// Qwen, Kimi, GLM and MiniMax ids, sometimes behind an `openrouter/` prefix — +// and it was written for a machine that has an OpenRouter account. The person +// running it may not have one: their profile can reach models only through a +// service of their own (a local proxy, a vendor's key), which knows none of +// those ids. A program is not a person who can be asked to pick again, so the +// answer is decided here, once per call, by one rule. +// +// THE RULE: HONOUR THE ASK, OR ANSWER WITH THE RUN'S OWN SEAT, AND SAY WHICH. +// The model the program asked for is used whenever one of this person's +// services can take a call on it. When none can, the call is answered on the +// run's work seat — the model a task's own worker would sit on in this run — +// and the turn the conversation log keeps says so in its Served field. A call +// is NEVER refused only because this machine does not know the id it named: +// failing a whole task over a spelling the person never chose is the wrong +// trade, and the seat is a model they did choose. +// +// WHETHER A SERVICE CAN TAKE A CALL IS NOT DECIDED HERE. It is the account +// pool's own question (internal/session's ServesModel, the same test the +// conversation's client pool asks before it seats a model), handed in by the +// caller as a function, so the model API and the conversation cannot come to +// two answers about one machine. + +import ( + "errors" + "strings" + + "github.com/Agent-Field/codeaf/internal/provider" +) + +// Resolve is THE ONE PLACE a program's model becomes the model that answers +// it. asked is the id as the program wrote it, serves answers whether one of +// this person's services can take a call on a model (nil answers yes for +// every model), and seats are the models a call falls to, in order — the run's +// work seat first. +// +// model is what the call goes out as; served is set exactly when it is not +// the model that was asked for, and it is what [delegate.Turn.Served] carries. +// A seat that cannot be served either is passed over for the next; when +// nothing can be served the call goes out as asked, so the funnel's own road +// answers it and says why, rather than this function inventing a refusal. +func Resolve(asked string, serves func(model string) bool, seats ...string) (model, served string) { + asked = strings.TrimSpace(asked) + can := func(candidate string) bool { return serves == nil || serves(candidate) } + if asked != "" && can(asked) { + return asked, "" + } + for _, seat := range seats { + seat = strings.TrimSpace(seat) + if seat == "" || seat == asked { + continue + } + if can(seat) { + return seat, seat + } + } + if asked == "" { + // A call that named no model at all is answered on the first seat + // there is, whatever can be said about it: there is nothing else to + // send, and the funnel says why if it cannot. + for _, seat := range seats { + if seat = strings.TrimSpace(seat); seat != "" { + return seat, seat + } + } + } + return asked, "" +} + +// unknownHere reports that a call failed because this machine could not serve +// the model it went out as — no key for the service the id resolves to, or a +// router that carries no such model — which is the one failure the seat can +// cure. It reads the funnel's own typed facts, never its sentence. +func unknownHere(err error) bool { + if err == nil { + return false + } + if errors.Is(err, provider.ErrNoAPIKey) { + return true + } + refusal, ok := provider.RefusalFrom(err) + return ok && refusal.Withdrawn +} + +// without is serves with one model struck out: the funnel has just said it +// cannot serve it, whatever the account pool believed a moment ago. +func without(serves func(string) bool, gone string) func(string) bool { + return func(model string) bool { + if strings.TrimSpace(model) == strings.TrimSpace(gone) { + return false + } + return serves == nil || serves(model) + } +} diff --git a/internal/provider/modelapi/resolve_test.go b/internal/provider/modelapi/resolve_test.go new file mode 100644 index 0000000000..12d9ce39a3 --- /dev/null +++ b/internal/provider/modelapi/resolve_test.go @@ -0,0 +1,76 @@ +package modelapi_test + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/session" +) + +// routerAccount is a machine with an OpenRouter key and nothing else. +func routerAccount() modelsource.Set { + source := modelsource.DefaultSource("https://openrouter.ai/api/v1") + return modelsource.NewSet(modelsource.Connected{Source: source, Key: "sk-or-v1-routerkey0000000000", Address: source.Address}) +} + +// proxyOnly is the owner's machine: no OpenRouter key, and one service of +// their own — an OpenAI-compatible proxy on this machine — that carries every +// conversation. +func proxyOnly() modelsource.Set { + router := modelsource.DefaultSource("https://openrouter.ai/api/v1") + proxy := modelsource.Source{ID: modelsource.CustomID, Written: "mybox", Name: "mybox", Address: "http://127.0.0.1:9000/v1"} + return modelsource.NewSet( + modelsource.Connected{Source: router, Address: router.Address}, + modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address}, + ) +} + +// servedBy is the account pool's own test over one machine's services — the +// door the model API is handed in production (session.ServesModel), never a +// second copy of it. +func servedBy(sources modelsource.Set) func(string) bool { + return func(model string) bool { return session.ServesModel(sources, model) } +} + +// THE RULE, ON THE MACHINES IT IS FOR: a model the person's services carry is +// honoured as asked; one they cannot reach is answered on the run's seat and +// the answer names the seat; an `openrouter/` prefix is read as the service it +// names, never as part of the model; and nothing is refused here only because +// this machine does not know the id. +func TestResolveHonoursACarriedModelAndSeatsTheRest(t *testing.T) { + const seat = "mybox/qwen3-coder" + for _, row := range []struct { + name string + sources modelsource.Set + asked string + seats []string + model, served string + }{ + {"a model the router carries", routerAccount(), "deepseek/deepseek-v4-flash-0731", []string{seat}, + "deepseek/deepseek-v4-flash-0731", ""}, + {"a prefixed id on the router's own key", routerAccount(), "openrouter/deepseek/deepseek-v4-pro", []string{seat}, + "openrouter/deepseek/deepseek-v4-pro", ""}, + {"a model no service here can reach", proxyOnly(), "moonshotai/kimi-k2.6", []string{seat}, + seat, seat}, + {"a prefixed id whose service has no key", proxyOnly(), "openrouter/z-ai/glm-5.1", []string{seat}, + seat, seat}, + {"a model the person's own service carries", proxyOnly(), "mybox/deepseek-v4-flash", []string{seat}, + "mybox/deepseek-v4-flash", ""}, + {"a seat nothing can reach is passed over for the next", proxyOnly(), "qwen/qwen3.6-plus", []string{"deepseek/deepseek-v4-pro", seat}, + seat, seat}, + {"nothing here can serve anything named", proxyOnly(), "minimax/minimax-m2.7", []string{"z-ai/glm-5.1"}, + "minimax/minimax-m2.7", ""}, + {"a call that named no model", routerAccount(), "", []string{"deepseek/deepseek-v4-flash-0731"}, + "deepseek/deepseek-v4-flash-0731", "deepseek/deepseek-v4-flash-0731"}, + } { + model, served := modelapi.Resolve(row.asked, servedBy(row.sources), row.seats...) + if model != row.model || served != row.served { + t.Errorf("%s: Resolve(%q) = %q served %q, want %q served %q", row.name, row.asked, model, served, row.model, row.served) + } + } + // With nobody to ask, every model is taken as written. + if model, served := modelapi.Resolve("anything/at-all", nil, seat); model != "anything/at-all" || served != "" { + t.Fatalf("Resolve with no door = %q %q", model, served) + } +} diff --git a/internal/provider/modelapi/route.go b/internal/provider/modelapi/route.go new file mode 100644 index 0000000000..a123e0eca1 --- /dev/null +++ b/internal/provider/modelapi/route.go @@ -0,0 +1,23 @@ +// Package modelapi is the model API codeaf serves each run of a program it +// carries (internal/delegate): an OpenAI-style chat-completions endpoint on +// this machine, opened by one token, whose every call goes through codeaf's own +// model funnel — refused at the run's ceiling, priced, logged, and written down +// as one turn of the program's conversation with codeaf. +// +// IT LIVES UNDER internal/provider BECAUSE THAT IS THE ONLY PLACE A MODEL +// ROUTE MAY BE SPELLED (funnel_law_test.go). A program in codeaf's own tree +// builds its request URL with [ChatURL] rather than appending the route +// itself, so the route is written once, here, and the law holds for the +// program's code as for everything else. +package modelapi + +import "strings" + +// chatRoute is the one route a program calls, relative to the API's base URL. +const chatRoute = "/chat/completions" + +// ChatURL is the chat-completions endpoint of an API whose base URL is base, +// the way every OpenAI client joins them: one slash between. +func ChatURL(base string) string { + return strings.TrimRight(strings.TrimSpace(base), "/") + chatRoute +} diff --git a/internal/provider/modelapi/route_test.go b/internal/provider/modelapi/route_test.go new file mode 100644 index 0000000000..7c73aae033 --- /dev/null +++ b/internal/provider/modelapi/route_test.go @@ -0,0 +1,11 @@ +package modelapi + +import "testing" + +func TestChatURLJoinsTheBaseAndTheRouteWithOneSlash(t *testing.T) { + for _, base := range []string{"http://127.0.0.1:9/v1", "http://127.0.0.1:9/v1/", " http://127.0.0.1:9/v1 "} { + if got := ChatURL(base); got != "http://127.0.0.1:9/v1/chat/completions" { + t.Fatalf("ChatURL(%q) = %q", base, got) + } + } +} diff --git a/internal/provider/modelapi/server.go b/internal/provider/modelapi/server.go new file mode 100644 index 0000000000..028658d71f --- /dev/null +++ b/internal/provider/modelapi/server.go @@ -0,0 +1,1112 @@ +package modelapi + +// The server: one per run of a program, on this machine's loopback, opened by +// one token, closed when the run ends. +// +// ── EVERY CALL IS A TURN OF A CONVERSATION A PERSON CAN READ ──────────────── +// +// To the program this is a model backend like any other. To codeaf the program +// is a very particular person asking it things, so every call is written down +// as one turn of that conversation (delegate.Turn, in the task's own record +// folder): once when it starts, so the task page can show a call in flight, and +// once when it ends, under the same number. +// +// ── MONEY IS METERED HERE, CALL BY CALL, AND NOWHERE ELSE ─────────────────── +// +// The funnel tells whoever armed the call what each answer cost the moment it +// decodes it (provider.WithBilling), and a receipt fetched later for a stream +// that was cut before its usage block (provider.WithReconcile). Both reach the +// run through [Config.Bank] as they happen, so the run's ceiling, the task's +// spend rows and the machine's spending ledger all see a call's dollars before +// the program does. The program's own account of what it spent is never read. +// +// ── THE RUN'S BOOKS CLOSE ONLY WHEN ITS RECEIPTS ARE IN ───────────────────── +// +// A call cut in the middle — the one in flight when a person stops the run, or +// when the supervisor ends it at the ceiling — is still paid for, and its price +// arrives by the provider's receipt about twenty seconds later. [Server.Close] +// waits for every receipt still owed on this run's calls (bounded by +// provider.ReceiptWait) before it returns, so the run's total read after it, +// the task's spend rows and the conversation's books all hold that money. +// +// ── THE CEILING IS A REFUSAL BEFORE THE CALL ──────────────────────────────── +// +// A call made once the run's metered spend has reached its dollar ceiling is +// never made: it is answered 402 in the router's own shape and written down as +// a refused turn. A call already in flight when the ceiling is crossed is not +// cut here — the run's supervisor ends the program for that, the way it ends +// any worker whose run has spent its allowance. + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/guard" + lanes "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// Completer is the funnel one call goes out through. It is provider.Client's +// own method, and internal/session's Completer is the same one method, so a +// run hands this the conversation's completer as it is and a test hands it a +// script. +type Completer interface { + CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) +} + +// Charge is one priced answer, as the funnel billed it. +type Charge struct { + // Model is the model that answered, in the funnel's own spelling. + Model string + TokensIn int + TokensOut int + Cached int + CostUSD float64 + // Spent is the run's metered total with this charge in it. It only rises, + // and charges are told one at a time in the order they were metered, so a + // bank that keeps the latest figure is always right. + Spent float64 + // Late says the charge is a receipt the provider fetched after its call had + // already returned — a stream cut before its usage block. + Late bool +} + +// Config is one run's API. +type Config struct { + // TaskDir is the task's record folder, where the conversation log is kept + // (delegate.ConversationFile). Empty keeps no log. + TaskDir string + // CompleterFor answers the funnel a call on model goes out through. Nil is + // a run with no model road: every call is answered with the sentence that + // says so, and none is made. + CompleterFor func(model string) Completer + // Serves answers whether one of this person's services can take a call on + // model — the account pool's own test, handed in (internal/session's + // ServesModel). Nil answers yes for every model. + Serves func(model string) bool + // Seat is the run's own work seat: the model a call falls to when the one + // the program asked for cannot be served on this machine ([Resolve]). + Seat string + // Ceiling is the run's dollar ceiling, zero for none. + Ceiling float64 + // Bank is told every charge as it is metered. It is called one charge at a + // time and must not block on the program. + Bank func(Charge) + // Unbilled is told a call the provider charged for and could put no figure + // on — a cut stream whose receipt never came. + Unbilled func(model string) + // Settling is told, once, when [Server.Close] begins to wait for the + // receipts still owed on this run's cut calls, with how many there are, so + // a person watching the run can be told why its end takes a moment. Nil + // says nothing; nothing is told when nothing is owed. + Settling func(owed int) + // Role is the lane role the calls ride: an unattended leaf when nobody is + // reading, which is a run's worker, and an attended one for a shell run a + // person is watching. Empty is unattended. + Role lanes.Role + // Node names the work the calls belong to in the model-call log — the + // program's name — so `codeaf logs --node <name>` reads one program's calls. + // Their tag is `task`, the word every call made inside a piece of work + // carries (internal/session's purposeTask). + Node string + // Keepalive overrides [DefaultKeepalive], for a test that must not wait + // fifteen seconds to see one. + Keepalive time.Duration +} + +// DefaultKeepalive is how often a waiting answer says it is still coming. It +// is well inside the two-minute idle timeout a program's HTTP client keeps, so +// a model thinking for half an hour never looks like a dead connection. +const DefaultKeepalive = 15 * time.Second + +// basePath is the version segment every OpenAI-style base URL ends in, and the +// route is appended to it exactly as a client appends it ([ChatURL]). +const basePath = "/v1" + +// closeWait bounds how long [Server.Close] waits for calls already in flight +// to write their last record. Their contexts are ended first, so an honest +// funnel returns at once; the bound is for one that does not. +const closeWait = 10 * time.Second + +// Server is one run's model API. +type Server struct { + config Config + listener net.Listener + server *http.Server + base string + // ctx ends every call in flight when the run's API closes. + ctx context.Context + cancel context.CancelFunc + calls sync.WaitGroup + + // mu guards the token, the ending, the meter, the turn numbers and the + // threads' memory — everything a call reads and writes that another call + // may be reading at the same moment. + mu sync.Mutex + token string + closed bool + spent float64 + seq int + threads threads + // refused counts the calls answered 402 at the ceiling. + refused int + + // bankMu keeps charges in the order they were metered, one at a time, and + // logMu keeps two turns from sharing one write of the log. + bankMu sync.Mutex + logMu sync.Mutex + + // owed counts the receipts the funnel has queued for this run's calls and + // not yet answered (provider.WithReceiptPending), which [Server.Close] + // waits for. + owed receiptsOwed +} + +// Open starts one run's API on an OS-chosen 127.0.0.1 port and mints its +// token. +// +// 127.0.0.1 AND NEVER 0.0.0.0, for the file door's reason: the token is the +// only thing between a caller and the person's model account, and a listener +// on every interface hands that account to anybody on the same network who +// can guess a port. +func Open(config Config) (*Server, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("modelapi: %w", err) + } + token, err := mint(32) + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("modelapi: mint the run's token: %w", err) + } + ctx, cancel := context.WithCancel(context.Background()) + s := &Server{config: config, listener: listener, token: token, ctx: ctx, cancel: cancel, + base: "http://" + listener.Addr().String() + basePath} + mux := http.NewServeMux() + mux.HandleFunc(basePath+chatRoute, s.serveChat) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "the model API answers "+basePath+chatRoute+" and nothing else") + }) + s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + guard.Go("modelapi/serve", func() { _ = s.server.Serve(listener) }) + return s, nil +} + +// API is the address and the token a program is started with +// (delegate.ChildEnv). After [Server.Close] the token is empty: a closed API +// has nothing to hand out. +func (s *Server) API() delegate.ModelAPI { + s.mu.Lock() + defer s.mu.Unlock() + return delegate.ModelAPI{BaseURL: s.base, Token: s.token} +} + +// Spent is the run's metered total so far. +func (s *Server) Spent() float64 { + s.mu.Lock() + defer s.mu.Unlock() + return s.spent +} + +// RefusedAtCeiling is how many calls were refused because the run's dollar +// ceiling had been reached. +// +// IT IS WHAT TELLS THE CEILING FROM A CRASH. A program that budgets by its own +// sum of each answer's cost can be refused before that sum reaches the +// ceiling it was given — codeaf's meter counts every answer the funnel was +// charged for, retries included — and senior-dev then ends its run as +// `crashed`. The run was stopped by the limit a person set, and the worker +// reads this to say so (internal/run's DelegateWorker). +func (s *Server) RefusedAtCeiling() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.refused +} + +// refuse counts one call refused at the ceiling. +func (s *Server) refuse() { + s.mu.Lock() + defer s.mu.Unlock() + s.refused++ +} + +// Close ends the API: THE TOKEN DIES WITH THE RUN. The token is forgotten, +// every call in flight is ended, the listener and every connection are closed, +// and the calls that were running are given [closeWait] to write their last +// record. A grandchild the program left behind can no longer spend. +// +// AND THE RUN'S BOOKS ARE CLOSED WITH EVERY RECEIPT IN THEM. A call cut in +// the middle — ended just now by this very close, or by the program's own stop +// a moment before — is priced by a receipt the provider fetches in the +// background about twenty seconds later. Close waits for every receipt still +// owed, for at most [receiptWait], so the [Server.Spent] a caller reads after +// it is the run's whole total and every charge has reached [Config.Bank] while +// the caller's books are still open. It measured: each of the three stopped +// runs of 2026-09-23 lost exactly that call from its task, its run total and +// its conversation's books, and only the machine's ledger heard of it. +func (s *Server) Close() error { + if !s.end() { + return nil + } + s.cancel() + err := s.server.Close() + drained := make(chan struct{}) + guard.Go("modelapi/close", func() { + s.calls.Wait() + close(drained) + }) + select { + case <-drained: + case <-time.After(closeWait): + } + if owed := s.owed.count(); owed > 0 && s.config.Settling != nil { + s.config.Settling(owed) + } + s.owed.wait(receiptWait) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + return err +} + +// receiptWait bounds how long [Server.Close] waits for the receipts owed on a +// run's cut calls: the provider's own ceiling for one receipt, so a receipt +// the provider is still asking for is never abandoned early, and one that will +// never come costs the run's ending no more than that. The provider counts that +// ceiling from the instant a receipt was queued, however many were ahead of it +// for a worker, and every receipt owed by a call that had ended by the time +// this wait began was queued before it, so one bound covers them all. A +// variable only so a test can shorten it. +var receiptWait = provider.ReceiptWait + +// receiptsOwed counts receipts queued and not yet answered. Its idle channel +// is closed whenever the count is zero and made anew when it leaves zero, so a +// waiter can wait on it with a bound and look again when it closes. +type receiptsOwed struct { + mu sync.Mutex + n int + idle chan struct{} +} + +// owe counts one receipt in and answers the function that counts it out, +// which does so once however often it is called. +func (o *receiptsOwed) owe() func() { + o.add() + var once sync.Once + return func() { once.Do(o.settle) } +} + +// add counts one receipt in, making the idle channel anew when the count +// leaves zero. +func (o *receiptsOwed) add() { + o.mu.Lock() + defer o.mu.Unlock() + if o.n == 0 { + o.idle = make(chan struct{}) + } + o.n++ +} + +// settle counts one receipt out, closing the idle channel when none is left. +func (o *receiptsOwed) settle() { + o.mu.Lock() + defer o.mu.Unlock() + o.n-- + if o.n == 0 { + close(o.idle) + } +} + +// count is how many receipts are owed now. +func (o *receiptsOwed) count() int { + o.mu.Lock() + defer o.mu.Unlock() + return o.n +} + +// owing answers the channel that closes when nothing is owed, or nil when +// nothing is owed now. +func (o *receiptsOwed) owing() chan struct{} { + o.mu.Lock() + defer o.mu.Unlock() + if o.n == 0 { + return nil + } + return o.idle +} + +// wait returns when nothing is owed, or when bound has passed; it answers +// whether everything owed came in. +func (o *receiptsOwed) wait(bound time.Duration) bool { + deadline := time.Now().Add(bound) + for { + idle := o.owing() + if idle == nil { + return true + } + left := time.Until(deadline) + if left <= 0 { + return false + } + timer := time.NewTimer(left) + select { + case <-idle: + timer.Stop() + case <-timer.C: + return false + } + } +} + +// end marks the API closed and forgets its token, and answers whether this was +// the call that closed it. +func (s *Server) end() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return false + } + s.closed, s.token = true, "" + return true +} + +// enter counts one call in, unless the API has closed. It is taken under the +// same lock Close sets the ending under, so no call is counted after Close has +// begun to wait. +func (s *Server) enter() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return false + } + s.calls.Add(1) + return true +} + +// authorized reports whether a request carries this run's token. The compare +// takes the same time whatever the guess, so a token cannot be read off how +// long a refusal takes. +func (s *Server) authorized(r *http.Request) bool { + token := s.liveToken() + if token == "" { + return false + } + given, ok := strings.CutPrefix(strings.TrimSpace(r.Header.Get("Authorization")), "Bearer ") + if !ok { + return false + } + return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(given)), []byte(token)) == 1 +} + +// liveToken is the run's token, empty once the API has closed. +func (s *Server) liveToken() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return "" + } + return s.token +} + +// serveChat is the one route. +func (s *Server) serveChat(w http.ResponseWriter, r *http.Request) { + if !s.authorized(r) { + writeError(w, http.StatusUnauthorized, "that token does not open this run's model API") + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + writeError(w, http.StatusMethodNotAllowed, "the model API answers POST") + return + } + if !s.enter() { + writeError(w, http.StatusServiceUnavailable, "this run has ended") + return + } + defer s.calls.Done() + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBytes)) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("the request is larger than the %d bytes one call may carry", maxRequestBytes)) + return + } + writeError(w, http.StatusBadRequest, "the request body could not be read") + return + } + request, err := decodeRequest(body) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + // A PROGRAM MAY NAME ITS LINEAGE IN A HEADER ONLY. The router's + // session-affinity header is the same fact as the body's prompt_cache_key — + // which warm instance this conversation belongs on — and codeaf's adapter + // sends its own header from the key it is handed, so a header with no key + // beside it is read as the key. + if request.cacheKey == "" { + if affinity := strings.TrimSpace(r.Header.Get(affinityHeader)); affinity != "" { + request.cacheKey, request.thread = affinity, affinity + } + } + s.serve(w, r, request) +} + +// affinityHeader is the router's session-affinity header, which a program +// written for OpenRouter sends beside its prompt_cache_key. +const affinityHeader = "X-Session-Affinity" + +// record is one call's turn as it stands, shared with the receipt that can +// arrive after the call has ended. +type record struct { + mu sync.Mutex + turn delegate.Turn + ended bool +} + +// serveOn says the call went out on the seat instead of the ask. +func (r *record) serveOn(seat string) { + r.mu.Lock() + defer r.mu.Unlock() + r.turn.Served = seat +} + +// close writes the call's ending onto its turn and answers the turn as it now +// stands, for the log. +func (r *record) close(fill func(turn *delegate.Turn)) delegate.Turn { + r.mu.Lock() + defer r.mu.Unlock() + r.turn.Ended = time.Now() + fill(&r.turn) + r.ended = true + return r.turn +} + +// open numbers one call, opens its turn with what the thread had not said +// before, names the working the program handed back by the field its thread's +// working last arrived on, and answers the run's spend at the moment the call +// arrived — the figure its ceiling is asked against. +func (s *Server) open(request *call, thread, served string) (*record, float64) { + s.mu.Lock() + defer s.mu.Unlock() + s.seq++ + entry := &record{turn: delegate.Turn{Seq: s.seq, Thread: thread, Started: time.Now(), Model: request.asked, Served: served}} + entry.turn.Sent, entry.turn.Restarted = s.threads.delta(thread, request.messages) + request.reasoning = s.threads.name(thread, request.reasoning) + return entry, s.spent +} + +// arrived remembers the field a thread's working came in on. +func (s *Server) arrived(thread, field string) { + s.mu.Lock() + defer s.mu.Unlock() + s.threads.arrived(thread, field) +} + +// serve answers one decoded call: the model decided, the turn opened, the +// ceiling asked, the funnel called with keepalives while it thinks, the model +// fallen back to the seat when the machine could not serve the ask, and the +// answer written in the shape the call asked for. +func (s *Server) serve(w http.ResponseWriter, r *http.Request, request *call) { + model, served := Resolve(request.asked, s.config.Serves, s.config.Seat) + thread := request.thread + if thread == "" { + thread = delegate.MainThread + } + entry, spent := s.open(request, thread, served) + + if ceiling := s.config.Ceiling; ceiling > 0 && ceilingReached(ceiling, spent) { + // 402 AND NOTHING THAT READS AS PASSING: a program's client retries a + // 408, a 409, a 429 and a 5xx as the weather, and a ceiling is not + // weather — asked again it answers the same. + s.refuse() + refused := ceilingSentence(ceiling, spent) + s.log(entry.close(func(turn *delegate.Turn) { turn.Refused = refused })) + writeError(w, http.StatusPaymentRequired, refused) + return + } + s.log(entry.turn) + + ctx, stop := s.callContext(r.Context()) + defer stop() + out := &reply{w: w, stream: request.stream, id: "gen-" + mustMint(12), created: time.Now().Unix()} + bill := &tally{} + catch := &catcher{} + slot := &provider.ServedEndpoint{} + response, err := s.complete(ctx, out, request, model, bill, catch, slot, entry) + // THE ONE FAILURE THE SEAT CAN CURE: the machine could not serve the model + // the program asked for — no key for its service, or a router that carries + // no such model — though the account pool believed it could. The call goes + // out once more, on the seat, and the turn says so. + if err != nil && served == "" && ctx.Err() == nil && unknownHere(err) { + if fallback, seat := Resolve(request.asked, without(s.config.Serves, model), s.config.Seat); seat != "" { + model = fallback + entry.serveOn(seat) + catch = &catcher{} + response, err = s.complete(ctx, out, request, model, bill, catch, slot, entry) + } + } + + var said answer + status, sentence := 0, "" + if err != nil { + status, sentence = s.failure(err, r.Context(), model) + } else { + said = answerOf(response, model, bill, catch, slot, out) + s.arrived(thread, said.reasoning.field) + } + s.log(entry.close(func(turn *delegate.Turn) { + turn.TokensIn, turn.TokensOut, turn.Cached, turn.CostUSD = bill.figures() + turn.Failed = sentence + if err == nil { + turn.Reply, turn.Calls = said.text, toolUses(said.calls) + } + turn.Served = answeredBy(*turn, bill.model(), response) + })) + + if r.Context().Err() != nil { + // The program stopped waiting; there is nobody to write the answer to. + return + } + if err != nil { + out.fail(status, sentence, model) + return + } + out.answer(said) +} + +// complete makes one call through the funnel and waits for it, saying the +// answer is still coming every [Config.Keepalive] while it does. +func (s *Server) complete(ctx context.Context, out *reply, request *call, model string, bill *tally, catch *catcher, slot *provider.ServedEndpoint, entry *record) (*ai.Response, error) { + var completer Completer + if s.config.CompleterFor != nil { + completer = s.config.CompleterFor(model) + } + if completer == nil { + return nil, errNoRoad + } + options := append(append([]ai.Option(nil), request.options...), ai.WithModel(model)) + ctx = s.settings(ctx, request, bill, catch, slot, entry) + type outcome struct { + response *ai.Response + err error + } + done := make(chan outcome, 1) + guard.Go("modelapi/call", func() { + // THE ANSWER IS SENT ON EVERY PATH, a fault included: the handler is + // waiting on this channel, and a funnel that panicked must come back as + // a failed call rather than a handler that waits for ever. + result := outcome{err: errFault} + defer func() { done <- result }() + result.response, result.err = completer.CompleteWithMessages(ctx, request.messages, options...) + }) + interval := s.config.Keepalive + if interval <= 0 { + interval = DefaultKeepalive + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case result := <-done: + if result.err == nil && result.response == nil { + return nil, errEmpty + } + return result.response, result.err + case <-ticker.C: + out.keepalive() + } + } +} + +// settings are the per-call facts the funnel reads off the call's context: +// who the call is for, what it is called in the log, the program's own cache +// lineage and reasoning depth, the working it handed back, the sinks that +// meter it, catch its working and name its server, the count of the receipts +// it is owed, and the ask that an answer with no usage block be priced too. +func (s *Server) settings(ctx context.Context, request *call, bill *tally, catch *catcher, slot *provider.ServedEndpoint, entry *record) context.Context { + role := s.config.Role + if role == "" { + role = lanes.RoleLeafUnattended + } + ctx = provider.WithRole(ctx, role) + ctx = provider.WithCallTag(ctx, "task") + ctx = provider.WithCallNode(ctx, s.config.Node) + ctx = provider.WithCacheKey(ctx, request.cacheKey) + // THE PROGRAM ASKED FOR ITS DEPTH IN SO MANY WORDS, which is what a person's + // configured level is: sent even to a model the catalog cannot vouch for, + // and dropped by the adapter's own repair if the model refuses it. A rung + // rides the ladder's one translation (xhigh and max as a thinking budget); + // the two words that are not rungs ride the adapter's own. + switch { + case request.depth.rung != effort.None: + ctx = provider.WithConfiguredEffortRung(ctx, request.depth.rung) + case request.depth.word != provider.EffortNone: + ctx = provider.WithConfiguredReasoningEffort(ctx, request.depth.word) + } + ctx = provider.WithMessageReasoning(ctx, request.reasoning) + ctx = provider.WithBilling(ctx, func(billed provider.Billed) { s.charge(bill, billed, false) }) + ctx = provider.WithReconcile(ctx, func(receipt provider.Reconciled) { s.receipt(bill, entry, receipt) }) + ctx = provider.WithReceiptPending(ctx, s.owed.owe) + // AN ANSWER WITH NO USAGE BLOCK IS NOT A FREE ONE. The funnel asks for a + // receipt only when an answer was cut; one that arrived whole and simply + // carried no usage block was billed nowhere and said so nowhere — + // true-myth's call 7483768e on 2026-09-23, a 200 on kimi-k2.6 after nearly + // eight seconds with no figure in any book. A run's calls are settled like + // cut ones instead: priced by their receipt, or told as calls nobody could + // price, never guessed. + ctx = provider.WithUnmeteredReceipts(ctx) + ctx = provider.WithStreamObserver(ctx, catch.observe) + return provider.WithServedEndpoint(ctx, slot) +} + +// callContext is the call's own context: the request's, which ends when the +// program stops waiting, ended as well when the run's API closes. +func (s *Server) callContext(parent context.Context) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + unhook := context.AfterFunc(s.ctx, cancel) + return ctx, func() { + unhook() + cancel() + } +} + +// charge meters one billed answer: onto the call's own tally, onto the run's +// total, and to the bank, one charge at a time. +func (s *Server) charge(bill *tally, billed provider.Billed, late bool) { + if billed.Empty() { + return + } + bill.add(billed) + s.bankMu.Lock() + defer s.bankMu.Unlock() + spent := s.meter(billed.Cost) + if s.config.Bank != nil { + s.config.Bank(Charge{ + Model: strings.TrimSpace(billed.Model), TokensIn: billed.PromptTokens, TokensOut: billed.CompletionTokens, + Cached: billed.CachedTokens, CostUSD: billed.Cost, Spent: spent, Late: late, + }) + } +} + +// meter adds one charge to the run's total and answers the total. +func (s *Server) meter(cost float64) float64 { + s.mu.Lock() + defer s.mu.Unlock() + s.spent += cost + return s.spent +} + +// receipt is a cut stream's late answer. A found receipt is the same real +// money and is metered like any charge, and the call's turn is written again +// with it, so the page's figure for that call is the true one; a receipt that +// never came is told as a call nobody could price. +func (s *Server) receipt(bill *tally, entry *record, receipt provider.Reconciled) { + if !receipt.Found || receipt.Billed.Empty() { + if s.config.Unbilled != nil { + s.config.Unbilled(strings.TrimSpace(receipt.Model)) + } + return + } + s.charge(bill, receipt.Billed, true) + entry.mu.Lock() + defer entry.mu.Unlock() + if !entry.ended { + // The call has not written its ending yet; the tally it reads carries + // this receipt already. + return + } + entry.turn.TokensIn, entry.turn.TokensOut, entry.turn.Cached, entry.turn.CostUSD = bill.figures() + entry.turn.Served = answeredBy(entry.turn, bill.model(), nil) + s.log(entry.turn) +} + +// answeredBy is the model a turn names as the one that answered: the one it +// already names — the run's seat, when [Resolve] or the funnel put the call +// there — or else the model the funnel billed, or the answer's own model when +// nothing was billed, whenever that is another model than the one asked for. +// +// THE PAGE MUST NOT NAME A MODEL THAT NEVER ANSWERED. When nothing this +// machine serves can take the ask or the seat, the call goes out as asked, and +// the conversation's own pool may put it on the model in its own seat — which +// this API never hears of. Run 3d6d on 2026-09-24 asked qwen, deepseek and +// kimi 27 times, and gpt-5.6-sol answered every one; the page named the three +// that never did. The funnel's bill names who answered, so it is the witness. +func answeredBy(turn delegate.Turn, billed string, response *ai.Response) string { + if served := strings.TrimSpace(turn.Served); served != "" { + return served + } + answered := strings.TrimSpace(billed) + if answered == "" && response != nil { + answered = strings.TrimSpace(response.Model) + } + if answered == "" || sameModel(turn.Model, answered) { + return "" + } + return answered +} + +// sameModel reports whether two ids name one model, read the way the task page +// names a speaker: by the part after the last vendor, case aside, so +// `openrouter/deepseek/deepseek-v4-pro` and `deepseek/deepseek-v4-pro` are one +// model, and a dated build of it (`deepseek-v4-pro-0731`) or a variant of it +// (`qwen3.6-plus:free`) is still it ([buildOf] says which suffixes count). An +// ask that named no model is never the same as the model that answered it. +func sameModel(asked, answered string) bool { + word := func(id string) string { + id = strings.ToLower(strings.TrimSpace(id)) + if at := strings.LastIndexByte(id, '/'); at >= 0 { + id = id[at+1:] + } + return id + } + a, b := word(asked), word(answered) + if a == "" || b == "" { + return a == b + } + return a == b || buildOf(a, b) || buildOf(b, a) +} + +// buildOf reports whether long is short with a build stamp or a variant on it: +// `-0731` or `-20260731` (four to eight digits, which is how makers date a +// build), a `:free` variant, or a dated build of a variant. +// +// A HYPHEN FOLLOWED BY A WORD IS ANOTHER MODEL. `gpt-5.5-mini`, +// `deepseek-v4-flash` and `kimi-k2-thinking` are siblings of the model they +// extend, sold and priced as models of their own, and reading any hyphenated +// suffix as a build named the ask as the speaker when its sibling answered — +// the misnaming [answeredBy] exists to stop. +func buildOf(long, short string) bool { + rest, ok := strings.CutPrefix(long, short) + if !ok { + return false + } + if variant, ok := strings.CutPrefix(rest, ":"); ok { + return variant != "" + } + stamp, ok := strings.CutPrefix(rest, "-") + if !ok { + return false + } + stamp, variant, tagged := strings.Cut(stamp, ":") + if tagged && variant == "" { + return false + } + return len(stamp) >= 4 && len(stamp) <= 8 && strings.Trim(stamp, "0123456789") == "" +} + +// log writes one turn. A log that cannot be written costs the record and never +// the call: the program is owed its answer whatever the disk does. +func (s *Server) log(turn delegate.Turn) { + if strings.TrimSpace(s.config.TaskDir) == "" { + return + } + s.logMu.Lock() + defer s.logMu.Unlock() + _ = delegate.AppendTurn(s.config.TaskDir, turn) +} + +// ── the call's own figures ────────────────────────────────────────────────── + +// tally is what one call cost, across every answer the funnel was charged for +// on its way to the one it returned. +type tally struct { + mu sync.Mutex + in, out int + cached int + cost float64 + billed bool + lastModel string +} + +func (t *tally) add(billed provider.Billed) { + t.mu.Lock() + defer t.mu.Unlock() + t.in += billed.PromptTokens + t.out += billed.CompletionTokens + t.cached += billed.CachedTokens + t.cost += billed.Cost + t.billed = true + if model := strings.TrimSpace(billed.Model); model != "" { + t.lastModel = model + } +} + +// model is the model the funnel last billed for this call, empty when it +// billed nothing. +func (t *tally) model() string { + t.mu.Lock() + defer t.mu.Unlock() + return t.lastModel +} + +func (t *tally) figures() (in, out, cached int, cost float64) { + t.mu.Lock() + defer t.mu.Unlock() + return t.in, t.out, t.cached, t.cost +} + +// usage is the call's usage block: the metered figures when the funnel billed +// anything, and the answer's own usage block otherwise. +func (t *tally) usage(response *ai.Response) usageBlock { + block, billed := t.metered() + if !billed && response != nil && response.Usage != nil { + block = usageBlock{ + PromptTokens: response.Usage.PromptTokens, CompletionTokens: response.Usage.CompletionTokens, + PromptTokensDetails: promptDetail{CachedTokens: response.Usage.CacheReadTokens()}, + } + if response.Usage.Cost != nil { + block.Cost = *response.Usage.Cost + } + } + block.TotalTokens = block.PromptTokens + block.CompletionTokens + return block +} + +// metered is the tally as a usage block, and whether the funnel billed +// anything at all. +func (t *tally) metered() (usageBlock, bool) { + t.mu.Lock() + defer t.mu.Unlock() + return usageBlock{PromptTokens: t.in, CompletionTokens: t.out, Cost: t.cost, PromptTokensDetails: promptDetail{CachedTokens: t.cached}}, t.billed +} + +// answerOf is the funnel's response as the program is handed it. +func answerOf(response *ai.Response, model string, bill *tally, catch *catcher, slot *provider.ServedEndpoint, out *reply) answer { + said := answer{ + id: out.id, provider: slot.Name(), model: model, created: out.created, + text: response.Text(), calls: response.ToolCalls(), finish: finishOf(response), + reasoning: catch.caught(), usage: bill.usage(response), + } + if answered := strings.TrimSpace(response.Model); answered != "" { + said.model = answered + } + return said +} + +// toolUses is the answer's tool calls as the log writes them. +func toolUses(calls []ai.ToolCall) []delegate.ToolUse { + var uses []delegate.ToolUse + for _, call := range calls { + uses = append(uses, delegate.ToolUse{Name: call.Function.Name, Args: call.Function.Arguments}) + } + return uses +} + +// ── failures ──────────────────────────────────────────────────────────────── + +var ( + // errNoRoad is a run started with no funnel at all. + errNoRoad = errors.New("this run was started with no road to a model") + // errFault is a funnel that panicked; the fault itself is in the log guard + // writes. + errFault = errors.New("the model road failed inside codeaf") + // errEmpty is a funnel that answered nothing and said nothing. + errEmpty = errors.New("the model road answered nothing") +) + +// failure is one failed call's status and sentence, in the words the program +// is answered with and the turn is written with. +// +// AN ACCOUNT REFUSED UPSTREAM IS NOT THE PROGRAM'S TOKEN BEING WRONG. A 401 or +// 403 from the model's service is codeaf's own account being refused, and on +// this API those two statuses mean the run's token; the program is told 502, +// a gateway whose far side said no, with the far side's sentence. +func (s *Server) failure(err error, request context.Context, model string) (int, string) { + switch { + case s.ctx.Err() != nil: + return http.StatusServiceUnavailable, "the run ended before the answer came back" + case request.Err() != nil: + return 499, "the program stopped waiting for the answer" + case errors.Is(err, errNoRoad): + return http.StatusServiceUnavailable, err.Error() + case errors.Is(err, provider.ErrNoAPIKey): + return http.StatusServiceUnavailable, "no service on this machine can answer " + quoted(model) + ": it has no key for the service that model is on" + case errors.Is(err, context.DeadlineExceeded): + return http.StatusGatewayTimeout, firstLine(err.Error()) + } + if refusal, ok := provider.RefusalFrom(err); ok { + status := refusal.Status + if status == http.StatusUnauthorized || status == http.StatusForbidden || status < 400 || status > 599 { + status = http.StatusBadGateway + } + return status, firstLine(refusal.Error()) + } + return http.StatusBadGateway, firstLine(err.Error()) +} + +// ceilingSentence is the refusal a call made past the run's ceiling gets. +func ceilingSentence(ceiling, spent float64) string { + return "the run's dollar ceiling of " + dollars(ceiling) + " is reached (" + dollars(spent) + " spent), so codeaf made no call" +} + +// ceilingDust is the most a ceiling may still have left and be reached: a +// billionth of a dollar, far below any call's price and far above the float +// rounding in a sum of prices. +const ceilingDust = 1e-9 + +// ceilingReached reports whether a run's spend has reached its ceiling. +// +// A CEILING WITH NOTHING LEFT IS REACHED BEFORE THE FIRST CALL. A run whose +// person's limit was already spent is handed the smallest positive figure, +// because zero means no ceiling at all (internal/session's runCostLeft); read +// as `spent >= ceiling`, nothing spent was still under it, and the run's first +// call was made and paid for. Nothing left is nothing left. +func ceilingReached(ceiling, spent float64) bool { + return ceiling-spent <= ceilingDust +} + +// dollars writes an amount the way a person reads one: cents, and four places +// under a cent so a small run is not written as nothing. An amount that would +// still read as nothing at four places — a ceiling with nothing left — is +// written as the nothing it is. +func dollars(amount float64) string { + if amount >= 0.00005 && amount < 0.01 { + return fmt.Sprintf("$%.4f", amount) + } + return fmt.Sprintf("$%.2f", amount) +} + +func quoted(model string) string { + if strings.TrimSpace(model) == "" { + return "the default model" + } + return model +} + +// firstLine is an error's first line, because a refusal is one sentence. +func firstLine(text string) string { + line, _, _ := strings.Cut(strings.TrimSpace(text), "\n") + return line +} + +// writeError answers a call that has not begun its reply, in the router's own +// error envelope. +func writeError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorBody{Error: errorDetail{Message: message, Code: status}}) +} + +// ── the reply ─────────────────────────────────────────────────────────────── + +// reply is one call's side of the response: nothing is written until the +// answer is ready or the first keepalive is due, so a call that fails fast is +// answered with its real status; after that the status is 200 and a failure +// travels in the body, the way the router sends one. +type reply struct { + w http.ResponseWriter + stream bool + id string + created int64 + committed bool +} + +func (r *reply) commit() { + if r.committed { + return + } + header := r.w.Header() + if r.stream { + header.Set("Content-Type", "text/event-stream") + header.Set("Cache-Control", "no-cache") + } else { + header.Set("Content-Type", "application/json") + } + r.w.WriteHeader(http.StatusOK) + r.committed = true +} + +// keepalive says the answer is still coming: an event-stream comment on a +// stream, and on a whole body the whitespace JSON allows before its value, so +// a client's idle timer is fed either way. +func (r *reply) keepalive() { + r.commit() + if r.stream { + _, _ = io.WriteString(r.w, ": keepalive\n\n") + } else { + _, _ = io.WriteString(r.w, "\n") + } + r.flush() +} + +// answer writes the finished answer in the shape the call asked for. +func (r *reply) answer(said answer) { + r.commit() + if !r.stream { + _ = json.NewEncoder(r.w).Encode(said.whole()) + r.flush() + return + } + for _, event := range said.chunks() { + r.event(event) + } + _, _ = io.WriteString(r.w, "data: [DONE]\n\n") + r.flush() +} + +// fail writes a failure: its own status when nothing has been written yet, +// and in the body when a keepalive already sent the 200. +func (r *reply) fail(status int, message, model string) { + if !r.committed { + writeError(r.w, status, message) + return + } + if !r.stream { + _ = json.NewEncoder(r.w).Encode(errorBody{Error: errorDetail{Message: message, Code: status}}) + r.flush() + return + } + r.event(failedChunk(r.id, model, r.created, status, message)) + _, _ = io.WriteString(r.w, "data: [DONE]\n\n") + r.flush() +} + +func (r *reply) event(event chunk) { + encoded, err := json.Marshal(event) + if err != nil { + return + } + _, _ = io.WriteString(r.w, "data: ") + _, _ = r.w.Write(encoded) + _, _ = io.WriteString(r.w, "\n\n") + r.flush() +} + +func (r *reply) flush() { + if flusher, ok := r.w.(http.Flusher); ok { + flusher.Flush() + } +} + +// mint is n random bytes as hex. +func mint(n int) (string, error) { + raw := make([]byte, n) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +// mustMint is an id that only has to be unlikely to repeat; a machine whose +// random source failed gets a clock reading instead. +func mustMint(n int) string { + if id, err := mint(n); err == nil { + return id + } + return fmt.Sprintf("%x", time.Now().UnixNano()) +} diff --git a/internal/provider/modelapi/server_test.go b/internal/provider/modelapi/server_test.go new file mode 100644 index 0000000000..5276bb3ad9 --- /dev/null +++ b/internal/provider/modelapi/server_test.go @@ -0,0 +1,866 @@ +package modelapi_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + lanes "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" +) + +// seenCall is one call as the funnel was handed it: the model, the messages, +// the SDK request the options build, and the per-call facts on the context. +type seenCall struct { + completerModel string + messages []ai.Message + request ai.Request + cacheKey string + effort provider.Effort + reasoning []provider.MessageReasoning + role lanes.Role +} + +// script is a funnel a test writes: it records every call and answers with +// whatever reply says. +type script struct { + mu sync.Mutex + seen []seenCall + reply func(ctx context.Context, model string, messages []ai.Message, request ai.Request) (*ai.Response, error) +} + +func (s *script) completerFor(model string) modelapi.Completer { + return scriptCall{script: s, model: model} +} + +func (s *script) calls() []seenCall { + s.mu.Lock() + defer s.mu.Unlock() + return append([]seenCall(nil), s.seen...) +} + +type scriptCall struct { + script *script + model string +} + +func (c scriptCall) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + if err := option(&request); err != nil { + return nil, err + } + } + c.script.mu.Lock() + c.script.seen = append(c.script.seen, seenCall{ + completerModel: c.model, messages: messages, request: request, + cacheKey: provider.CacheKeyFrom(ctx), effort: provider.ReasoningEffortFrom(ctx), + reasoning: provider.MessageReasoningFrom(ctx), role: provider.RoleFrom(ctx), + }) + c.script.mu.Unlock() + return c.script.reply(ctx, request.Model, messages, request) +} + +// bill is the funnel telling whoever armed the call what an answer cost, the +// way the provider's decode does. +func bill(ctx context.Context, model string, in, out, cached int, cost float64) { + if sink := provider.BillingSinkFrom(ctx); sink != nil { + sink(provider.Billed{Model: model, PromptTokens: in, CompletionTokens: out, CachedTokens: cached, Cost: cost}) + } +} + +// saying is an answer of words. +func saying(model, text string) *ai.Response { + return &ai.Response{ID: "upstream-1", Model: model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: text}}}, FinishReason: "stop", + }}} +} + +// words answers every call with its text, billed at cost. +func words(text string, cost float64) func(context.Context, string, []ai.Message, ai.Request) (*ai.Response, error) { + return func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + bill(ctx, model, 100, 20, 30, cost) + return saying(model, text), nil + } +} + +// open starts an API for one test and closes it after. +func open(t *testing.T, config modelapi.Config) (*modelapi.Server, delegate.ModelAPI) { + t.Helper() + server, err := modelapi.Open(config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Close() }) + return server, server.API() +} + +// post sends one body to the API with the token given. +func post(t *testing.T, api delegate.ModelAPI, token, body string) (int, []byte) { + t.Helper() + request, err := http.NewRequest(http.MethodPost, modelapi.ChatURL(api.BaseURL), strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, payload +} + +// errorOf reads the router's error envelope. +func errorOf(t *testing.T, payload []byte) (string, int) { + t.Helper() + var body struct { + Error struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatalf("not an error envelope: %s", payload) + } + return body.Error.Message, body.Error.Code +} + +// whole reads a whole completion. +type whole struct { + ID string `json:"id"` + Object string `json:"object"` + Model string `json:"model"` + Choices []struct { + Message struct { + Role string `json:"role"` + Content *string `json:"content"` + Reasoning string `json:"reasoning"` + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Cost *float64 `json:"cost"` + PromptTokensDetails struct { + CachedTokens int `json:"cached_tokens"` + } `json:"prompt_tokens_details"` + } `json:"usage"` +} + +// events splits an event stream into its data payloads and counts its +// comment lines. +func events(body []byte) (data []string, comments int) { + for _, block := range strings.Split(string(body), "\n\n") { + block = strings.TrimSpace(block) + switch { + case block == "": + case strings.HasPrefix(block, ":"): + comments++ + case strings.HasPrefix(block, "data: "): + data = append(data, strings.TrimPrefix(block, "data: ")) + } + } + return data, comments +} + +// rawTurns is every line the conversation log holds, in the order written. +func rawTurns(t *testing.T, dir string) []delegate.Turn { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, delegate.ConversationFile)) + if err != nil { + t.Fatal(err) + } + var turns []delegate.Turn + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + var turn delegate.Turn + if err := json.Unmarshal([]byte(line), &turn); err != nil { + t.Fatalf("a log line does not parse: %s", line) + } + turns = append(turns, turn) + } + return turns +} + +const hello = `{"model":"deepseek/deepseek-v4-flash-0731","messages":[{"role":"user","content":"hi"}]}` + +// THE TOKEN IS THE ONLY WAY IN, AND IT DIES WITH THE RUN: no token and a wrong +// one are both refused in the router's own shape, the right one is answered, +// and after Close the API hands out no token and the port answers nobody. +func TestTheAPIOpensToItsTokenAloneAndClosesWithTheRun(t *testing.T) { + calls := &script{reply: words("hello", 0.01)} + server, api := open(t, modelapi.Config{CompleterFor: calls.completerFor}) + if !strings.HasPrefix(api.BaseURL, "http://127.0.0.1:") || !strings.HasSuffix(api.BaseURL, "/v1") || len(api.Token) < 32 { + t.Fatalf("api = %+v, want a loopback /v1 base and a real token", api) + } + for _, token := range []string{"", "not-the-token"} { + status, payload := post(t, api, token, hello) + if message, code := errorOf(t, payload); status != http.StatusUnauthorized || code != 401 || message == "" { + t.Fatalf("token %q: status %d body %s, want 401 in the router's shape", token, status, payload) + } + } + if len(calls.calls()) != 0 { + t.Fatal("a refused token reached the funnel") + } + if status, payload := post(t, api, api.Token, hello); status != http.StatusOK { + t.Fatalf("the right token was answered %d: %s", status, payload) + } + if err := server.Close(); err != nil { + t.Fatal(err) + } + if after := server.API(); after.Token != "" || after.Ready() { + t.Fatalf("a closed API still hands out %+v", after) + } + request, _ := http.NewRequest(http.MethodPost, modelapi.ChatURL(api.BaseURL), strings.NewReader(hello)) + request.Header.Set("Authorization", "Bearer "+api.Token) + if response, err := http.DefaultClient.Do(request); err == nil { + response.Body.Close() + t.Fatalf("the old token still opens a closed API: %d", response.StatusCode) + } +} + +// A CALL PAST THE CEILING IS NEVER MADE: it is answered 402 in the router's +// shape, the funnel is not asked, the refusal is a turn of the log, and every +// charge before it reached the bank in order with the run's rising total. +func TestACallPastTheCeilingIsRefusedBeforeItIsMade(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: words("ok", 0.06)} + var mu sync.Mutex + var banked []modelapi.Charge + server, api := open(t, modelapi.Config{ + TaskDir: dir, CompleterFor: calls.completerFor, Ceiling: 0.10, + Bank: func(charge modelapi.Charge) { + mu.Lock() + defer mu.Unlock() + banked = append(banked, charge) + }, + }) + for call := 0; call < 2; call++ { + if status, payload := post(t, api, api.Token, hello); status != http.StatusOK { + t.Fatalf("call %d under the ceiling was answered %d: %s", call+1, status, payload) + } + } + status, payload := post(t, api, api.Token, hello) + message, code := errorOf(t, payload) + if status != http.StatusPaymentRequired || code != 402 || !strings.Contains(message, "ceiling of $0.10") { + t.Fatalf("the call past the ceiling was answered %d: %s", status, payload) + } + if got := len(calls.calls()); got != 2 { + t.Fatalf("the funnel was asked %d times, want the two calls under the ceiling and not the third", got) + } + if refused := server.RefusedAtCeiling(); refused != 1 { + t.Fatalf("refused at the ceiling = %d, want the one call", refused) + } + mu.Lock() + defer mu.Unlock() + if len(banked) != 2 || banked[0].Spent != 0.06 || banked[1].Spent != 0.12 || banked[1].CostUSD != 0.06 || + banked[0].TokensIn != 100 || banked[0].Cached != 30 || banked[0].Model != "deepseek/deepseek-v4-flash-0731" { + t.Fatalf("banked = %+v", banked) + } + turns, err := delegate.ReadTurns(dir, 0) + if err != nil { + t.Fatal(err) + } + if len(turns) != 3 || turns[2].Refused == "" || turns[2].Ended.IsZero() || turns[2].CostUSD != 0 || turns[2].InFlight() { + t.Fatalf("turns = %+v, want the third written as a refusal that cost nothing", turns) + } +} + +// THE WHOLE BODY REACHES THE FUNNEL AND THE WHOLE ANSWER COMES BACK: tools and +// the program's own tool_choice, a tool call and its result, the reasoning +// depth, the cache key, the response format, the working handed back — and +// the model's tool calls, its words and its working on the way out. +func TestTheCallCrossesIntoTheFunnelWholeAndTheAnswerComesBackWhole(t *testing.T) { + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + provider.EmitReasoning(ctx, "reasoning", "plan first", json.RawMessage(`[{"type":"reasoning.text","text":"plan first"}]`)) + bill(ctx, model, 1200, 400, 1000, 0.0042) + return &ai.Response{ID: "upstream", Model: model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "call_9", Type: "function", Function: ai.ToolCallFunction{Name: "edit", Arguments: `{"path":"a.go"}`}}}}, + }}}, nil + }} + _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor, Role: lanes.RoleLeafAttached}) + body := `{ + "model": "moonshotai/kimi-k2.6", + "messages": [ + {"role": "system", "content": "be brief"}, + {"role": "user", "content": "fix it"}, + {"role": "assistant", "content": "", "reasoning_content": "earlier working", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "bash", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call_1", "content": "FAIL"} + ], + "tools": [{"type": "function", "function": {"name": "edit", "parameters": {"type": "object"}}}], + "tool_choice": "required", + "max_tokens": 4096, + "reasoning_effort": "low", + "prompt_cache_key": "sd-main", + "response_format": {"type": "json_object"} + }` + status, payload := post(t, api, api.Token, body) + if status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + seen := calls.calls() + if len(seen) != 1 { + t.Fatalf("the funnel was asked %d times", len(seen)) + } + call := seen[0] + if call.completerModel != "moonshotai/kimi-k2.6" || call.request.Model != "moonshotai/kimi-k2.6" { + t.Fatalf("model = %q / %q", call.completerModel, call.request.Model) + } + if len(call.messages) != 4 || call.messages[2].ToolCalls[0].ID != "call_1" || call.messages[3].ToolCallID != "call_1" { + t.Fatalf("messages = %+v", call.messages) + } + if len(call.request.Tools) != 1 || call.request.Tools[0].Function.Name != "edit" || call.request.ToolChoice != "required" { + t.Fatalf("tools %+v choice %#v", call.request.Tools, call.request.ToolChoice) + } + if call.request.MaxTokens == nil || *call.request.MaxTokens != 4096 || call.request.ResponseFormat == nil || call.request.ResponseFormat.Type != "json_object" { + t.Fatalf("request = %+v", call.request) + } + if call.cacheKey != "sd-main" || call.effort != provider.EffortLow || call.role != lanes.RoleLeafAttached { + t.Fatalf("cache key %q effort %q role %q", call.cacheKey, call.effort, call.role) + } + if len(call.reasoning) != 4 || call.reasoning[2].Field != "reasoning_content" || call.reasoning[2].Text != "earlier working" { + t.Fatalf("working handed back = %+v", call.reasoning) + } + var answer whole + if err := json.Unmarshal(payload, &answer); err != nil { + t.Fatalf("%v: %s", err, payload) + } + choice := answer.Choices[0] + if answer.Object != "chat.completion" || choice.FinishReason != "tool_calls" || choice.Message.Content != nil || + len(choice.Message.ToolCalls) != 1 || choice.Message.ToolCalls[0].ID != "call_9" || choice.Message.ToolCalls[0].Function.Arguments != `{"path":"a.go"}` { + t.Fatalf("answer = %s", payload) + } + if choice.Message.Reasoning != "plan first" || !strings.Contains(string(payload), `"reasoning_details":[{"type":"reasoning.text"`) { + t.Fatalf("the model's working did not come back: %s", payload) + } + if answer.Usage.Cost == nil || *answer.Usage.Cost != 0.0042 || answer.Usage.PromptTokens != 1200 || answer.Usage.TotalTokens != 1600 || answer.Usage.PromptTokensDetails.CachedTokens != 1000 { + t.Fatalf("usage = %+v", answer.Usage) + } +} + +// A THINKING MODEL'S WORKING MAKES THE ROUND TRIP: an endpoint that writes it +// as reasoning_content has it handed to the program under the router's own +// `reasoning`, and the program handing it back that way has it replayed to the +// endpoint under the field it came in with. +func TestAModelsWorkingGoesOutUnderTheRoutersNameAndComesBackUnderItsOwn(t *testing.T) { + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + provider.EmitReasoning(ctx, "reasoning_content", "run the tests first", nil) + return &ai.Response{Model: model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "c1", Type: "function", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}}}, + }}}, nil + }} + _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor}) + status, payload := post(t, api, api.Token, `{"model":"m","messages":[{"role":"user","content":"fix it"}]}`) + if status != http.StatusOK || !strings.Contains(string(payload), `"reasoning":"run the tests first"`) || strings.Contains(string(payload), "reasoning_content") { + t.Fatalf("status %d, the working did not go out under the router's name: %s", status, payload) + } + back := `{"model":"m","messages":[{"role":"user","content":"fix it"},` + + `{"role":"assistant","content":null,"reasoning":"run the tests first","tool_calls":[{"id":"c1","type":"function","function":{"name":"bash","arguments":"{}"}}]},` + + `{"role":"tool","tool_call_id":"c1","content":"ok"}]}` + if status, payload := post(t, api, api.Token, back); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + seen := calls.calls() + if len(seen) != 2 || len(seen[1].reasoning) != 3 || seen[1].reasoning[1].Field != "reasoning_content" || seen[1].reasoning[1].Text != "run the tests first" { + t.Fatalf("the working handed back reached the funnel as %+v", seen[len(seen)-1].reasoning) + } +} + +// A STREAM IS THE ROUTER'S STREAM: the words as a delta, the finish, then a +// chunk carrying the usage with its cost, then [DONE]. +func TestAStreamedAnswerEndsWithItsCostThenDone(t *testing.T) { + calls := &script{reply: words("all green", 0.0125)} + _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor}) + status, payload := post(t, api, api.Token, `{"model":"z-ai/glm-5.1","stream":true,"messages":[{"role":"user","content":"go"}]}`) + if status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + data, _ := events(payload) + if len(data) < 3 || data[len(data)-1] != "[DONE]" { + t.Fatalf("events = %q, want chunks and then [DONE]", data) + } + var content strings.Builder + var finish string + var cost *float64 + for _, event := range data[:len(data)-1] { + var chunk struct { + Object string `json:"object"` + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + Usage *struct { + Cost *float64 `json:"cost"` + } `json:"usage"` + } + if err := json.Unmarshal([]byte(event), &chunk); err != nil || chunk.Object != "chat.completion.chunk" { + t.Fatalf("event %s: %v", event, err) + } + for _, choice := range chunk.Choices { + content.WriteString(choice.Delta.Content) + if choice.FinishReason != nil { + finish = *choice.FinishReason + } + } + if chunk.Usage != nil { + cost = chunk.Usage.Cost + } + } + if content.String() != "all green" || finish != "stop" || cost == nil || *cost != 0.0125 { + t.Fatalf("content %q finish %q cost %v", content.String(), finish, cost) + } +} + +// A MODEL THAT THINKS FOR A LONG TIME NEVER LOOKS LIKE A DEAD CONNECTION: a +// stream is sent comment lines while it waits, and a whole body is sent the +// whitespace JSON allows before its value, and both still read as what they +// are. +func TestAWaitingAnswerSaysItIsStillComing(t *testing.T) { + calls := &script{reply: func(ctx context.Context, model string, messages []ai.Message, request ai.Request) (*ai.Response, error) { + time.Sleep(150 * time.Millisecond) + return words("slow", 0.001)(ctx, model, messages, request) + }} + _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor, Keepalive: 20 * time.Millisecond}) + _, payload := post(t, api, api.Token, `{"model":"m","stream":true,"messages":[{"role":"user","content":"go"}]}`) + data, comments := events(payload) + if comments < 2 || !strings.HasPrefix(string(payload), ": keepalive\n\n") || data[len(data)-1] != "[DONE]" { + t.Fatalf("%d comments before the answer, want several:\n%s", comments, payload) + } + status, payload := post(t, api, api.Token, `{"model":"m","messages":[{"role":"user","content":"go"}]}`) + if status != http.StatusOK || !strings.HasPrefix(string(payload), "\n") { + t.Fatalf("status %d, a whole body with no whitespace kept alive: %q", status, payload) + } + var answer whole + if err := json.Unmarshal(bytes.TrimSpace(payload), &answer); err != nil || *answer.Choices[0].Message.Content != "slow" { + t.Fatalf("the kept-alive body no longer reads: %v %s", err, payload) + } + if err := json.Unmarshal(payload, &answer); err != nil { + t.Fatalf("a JSON reader refuses the leading whitespace: %v", err) + } +} + +// EVERY CALL IS WRITTEN TWICE UNDER ONE NUMBER — when it starts and when it +// ends — and what a turn says it sent is only what the thread's previous +// request did not carry. +func TestEveryCallIsOneTurnWrittenAtItsStartAndItsEnd(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: words("done", 0.002)} + _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor}) + first := `{"model":"qwen/qwen3.6-plus","messages":[{"role":"system","content":"rules"},{"role":"user","content":"fix the test"}]}` + second := `{"model":"qwen/qwen3.6-plus","messages":[{"role":"system","content":"rules"},{"role":"user","content":"fix the test"},` + + `{"role":"assistant","content":null,"tool_calls":[{"id":"c1","type":"function","function":{"name":"bash","arguments":"{\"cmd\":\"go test\"}"}}]},` + + `{"role":"tool","tool_call_id":"c1","content":"FAIL: TestX"}]}` + for _, body := range []string{first, second} { + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + } + lines := rawTurns(t, dir) + if len(lines) != 4 { + t.Fatalf("%d records, want two per call", len(lines)) + } + for index, line := range lines { + wantSeq, ended := index/2+1, index%2 == 1 + if line.Seq != wantSeq || line.Ended.IsZero() == ended || line.Thread != delegate.MainThread { + t.Fatalf("record %d = %+v, want seq %d ended %v on the main thread", index, line, wantSeq, ended) + } + } + if !lines[0].InFlight() || lines[1].InFlight() { + t.Fatal("the start record does not read as in flight, or the end record still does") + } + turns, err := delegate.ReadTurns(dir, 0) + if err != nil { + t.Fatal(err) + } + if len(turns) != 2 { + t.Fatalf("%d turns, want two", len(turns)) + } + one, two := turns[0], turns[1] + if len(one.Sent) != 2 || one.Sent[0].Role != "system" || one.Sent[1].Text != "fix the test" || one.Restarted { + t.Fatalf("first turn sent %+v", one.Sent) + } + if len(two.Sent) != 1 || two.Sent[0].Role != "tool" || two.Sent[0].Tool != "bash" || two.Sent[0].Text != "FAIL: TestX" || two.Restarted { + t.Fatalf("second turn sent %+v, want only the tool's result", two.Sent) + } + if two.Model != "qwen/qwen3.6-plus" || two.Served != "" || two.Reply != "done" || two.TokensIn != 100 || two.TokensOut != 20 || two.Cached != 30 || two.CostUSD != 0.002 { + t.Fatalf("second turn = %+v", two) + } +} + +// A REWRITTEN HISTORY IS SAID TO BE ONE, AND TWO THREADS ARE TWO +// CONVERSATIONS: each thread's first call sends its whole brief, and a thread +// whose next request is not its last one extended is a restart. +func TestARewrittenHistoryIsARestartAndThreadsAreKeptApart(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: words("ok", 0)} + _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor}) + send := func(key, messages string) { + t.Helper() + body := `{"model":"m","prompt_cache_key":"` + key + `","messages":[` + messages + `]}` + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + } + send("coder", `{"role":"user","content":"write it"},{"role":"assistant","content":"written"},{"role":"user","content":"now test it"}`) + send("summariser", `{"role":"user","content":"summarise the coder"}`) + send("coder", `{"role":"user","content":"summary: written and tested"},{"role":"user","content":"ship it"}`) + turns, err := delegate.ReadTurns(dir, 0) + if err != nil { + t.Fatal(err) + } + if len(turns) != 3 { + t.Fatalf("%d turns", len(turns)) + } + if turns[0].Thread != "coder" || len(turns[0].Sent) != 2 || turns[0].Restarted { + t.Fatalf("coder's first turn = %+v, want its two words of its own and not the model's", turns[0]) + } + if turns[1].Thread != "summariser" || len(turns[1].Sent) != 1 || turns[1].Restarted { + t.Fatalf("the second thread's first turn = %+v, want a first call of its own", turns[1]) + } + if !turns[2].Restarted || len(turns[2].Sent) != 2 || turns[2].Sent[0].Text != "summary: written and tested" { + t.Fatalf("the rewritten coder turn = %+v, want a restart that sends it whole", turns[2]) + } +} + +// A LINEAGE NAMED ONLY IN THE ROUTER'S HEADER IS STILL THE CALL'S LINEAGE: it +// is the cache key the funnel is handed and the thread the log keeps, and a +// body's own key wins over it. +func TestTheSessionAffinityHeaderIsTheLineageWhenTheBodyNamesNone(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: words("ok", 0)} + _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor}) + for _, body := range []string{hello, `{"model":"m","prompt_cache_key":"from-body","messages":[{"role":"user","content":"hi"}]}`} { + request, _ := http.NewRequest(http.MethodPost, modelapi.ChatURL(api.BaseURL), strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer "+api.Token) + request.Header.Set("x-session-affinity", "from-header") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + } + seen := calls.calls() + if len(seen) != 2 || seen[0].cacheKey != "from-header" || seen[1].cacheKey != "from-body" { + t.Fatalf("cache keys = %+v", seen) + } + if turns, _ := delegate.ReadTurns(dir, 0); len(turns) != 2 || turns[0].Thread != "from-header" || turns[1].Thread != "from-body" { + t.Fatalf("threads = %+v", turns) + } +} + +// A MODEL THIS MACHINE CANNOT SERVE IS ANSWERED ON THE RUN'S SEAT, AND THE +// TURN SAYS SO: the program asked for one id, the funnel was handed the seat, +// and Served names what answered. +func TestAModelThisMachineCannotServeIsAnsweredOnTheSeat(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: words("from the seat", 0.003)} + _, api := open(t, modelapi.Config{ + TaskDir: dir, CompleterFor: calls.completerFor, Seat: "mybox/qwen3-coder", + Serves: func(model string) bool { return strings.HasPrefix(model, "mybox/") }, + }) + status, payload := post(t, api, api.Token, `{"model":"openrouter/deepseek/deepseek-v4-pro","messages":[{"role":"user","content":"go"}]}`) + if status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + if seen := calls.calls(); len(seen) != 1 || seen[0].request.Model != "mybox/qwen3-coder" || seen[0].completerModel != "mybox/qwen3-coder" { + t.Fatalf("the funnel was handed %+v, want the seat", seen) + } + turns, _ := delegate.ReadTurns(dir, 0) + if len(turns) != 1 || turns[0].Model != "openrouter/deepseek/deepseek-v4-pro" || turns[0].Served != "mybox/qwen3-coder" { + t.Fatalf("turn = %+v, want the ask kept and the seat named as what answered", turns) + } +} + +// A CALL IS NEVER LOST ONLY BECAUSE THIS MACHINE DOES NOT KNOW THE ID: when the +// funnel itself says it cannot serve the ask — no key for its service — the +// call goes out once more on the seat. +func TestAnAskTheFunnelCannotServeFallsToTheSeat(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: func(ctx context.Context, model string, messages []ai.Message, request ai.Request) (*ai.Response, error) { + if model != "seat/model" { + return nil, provider.ErrNoAPIKey + } + return words("seated", 0.001)(ctx, model, messages, request) + }} + _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor, Seat: "seat/model"}) + status, payload := post(t, api, api.Token, `{"model":"minimax/minimax-m2.7","messages":[{"role":"user","content":"go"}]}`) + if status != http.StatusOK || !strings.Contains(string(payload), "seated") { + t.Fatalf("status %d: %s", status, payload) + } + if seen := calls.calls(); len(seen) != 2 || seen[1].request.Model != "seat/model" { + t.Fatalf("the funnel saw %+v, want the ask and then the seat", seen) + } + if turns, _ := delegate.ReadTurns(dir, 0); len(turns) != 1 || turns[0].Served != "seat/model" || turns[0].Failed != "" { + t.Fatalf("turns = %+v", turns) + } +} + +// A MODEL'S FAILURE IS THE ROUTER'S ERROR WITH A STATUS THAT MEANS THE SAME +// THING — and an account refused upstream is a gateway's refusal, never the +// program's own token being wrong. +func TestAModelFailureIsTheRoutersErrorAndItsTurnSaysSo(t *testing.T) { + dir := t.TempDir() + var refusal error + calls := &script{reply: func(context.Context, string, []ai.Message, ai.Request) (*ai.Response, error) { return nil, refusal }} + _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor}) + for _, row := range []struct { + err error + status int + }{ + {&provider.APIError{Status: 429, Message: "slow down"}, 429}, + {&provider.APIError{Status: 401, Message: "no such account"}, 502}, + {errors.New("connection reset"), 502}, + } { + refusal = row.err + status, payload := post(t, api, api.Token, hello) + message, code := errorOf(t, payload) + if status != row.status || code != row.status || message == "" { + t.Fatalf("%v: status %d body %s, want %d", row.err, status, payload, row.status) + } + } + turns, _ := delegate.ReadTurns(dir, 0) + if len(turns) != 3 || !strings.Contains(turns[0].Failed, "slow down") || turns[0].Ended.IsZero() { + t.Fatalf("turns = %+v, want each failure written with its sentence", turns) + } + // A run started with no road answers with that sentence and makes no call. + _, bare := open(t, modelapi.Config{TaskDir: t.TempDir()}) + status, payload := post(t, bare, bare.Token, hello) + if message, _ := errorOf(t, payload); status != http.StatusServiceUnavailable || !strings.Contains(message, "no road to a model") { + t.Fatalf("a road-less run answered %d: %s", status, payload) + } +} + +// A STREAM CUT BEFORE ITS USAGE BLOCK IS PRICED LATE AND STILL COUNTED ONCE: +// the receipt reaches the bank marked late, and the call's turn is written +// again with the figure; a receipt that never comes is told as unbilled. +func TestALateReceiptIsBankedAndItsTurnRewritten(t *testing.T) { + dir := t.TempDir() + late := make(chan struct{}) + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + // Owed the way the provider owes a receipt: before the fetch, and + // answered after the sink. + done := provider.ReceiptPendingFrom(ctx)() + sink := provider.ReconcileSinkFrom(ctx) + go func() { + defer done() + time.Sleep(50 * time.Millisecond) + sink(provider.Reconciled{Billed: provider.Billed{Model: model, PromptTokens: 50, CompletionTokens: 5, Cost: 0.02}, Found: true}) + sink(provider.Reconciled{Billed: provider.Billed{Model: "other"}, Found: false}) + close(late) + }() + return saying(model, "cut short"), nil + }} + var mu sync.Mutex + var banked []modelapi.Charge + var unbilled []string + _, api := open(t, modelapi.Config{ + TaskDir: dir, CompleterFor: calls.completerFor, + Bank: func(charge modelapi.Charge) { mu.Lock(); banked = append(banked, charge); mu.Unlock() }, + Unbilled: func(model string) { mu.Lock(); unbilled = append(unbilled, model); mu.Unlock() }, + }) + if status, payload := post(t, api, api.Token, hello); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + <-late + mu.Lock() + if len(banked) != 1 || !banked[0].Late || banked[0].CostUSD != 0.02 || banked[0].Spent != 0.02 || len(unbilled) != 1 || unbilled[0] != "other" { + t.Fatalf("banked %+v unbilled %v", banked, unbilled) + } + mu.Unlock() + turns, _ := delegate.ReadTurns(dir, 0) + if len(turns) != 1 || turns[0].CostUSD != 0.02 || turns[0].TokensIn != 50 { + t.Fatalf("turn = %+v, want it rewritten with the late receipt", turns) + } +} + +// THE RUN'S BOOKS CLOSE WITH THE RECEIPT OF THE CALL IT WAS CUT IN: the funnel +// owes a receipt for a call that ended without its usage block, and it arrives +// well after the call has returned — the stopped runs of 2026-09-23 saw it +// twenty seconds later. Close waits for it, so the total read after Close and +// the bank both hold it, and the watcher is told once how many were owed. A +// receipt that never comes costs the ending no more than the bound. +func TestCloseWaitsForTheReceiptOwedOnACutCall(t *testing.T) { + gate := make(chan struct{}) + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + // The provider's own order: owed before the fetch, answered after the + // sink has the money. + done := provider.ReceiptPendingFrom(ctx)() + sink := provider.ReconcileSinkFrom(ctx) + go func() { + defer done() + <-gate + sink(provider.Reconciled{Billed: provider.Billed{Model: model, PromptTokens: 52139, CompletionTokens: 4895, Cost: 0.058188488}, Found: true}) + }() + return saying(model, "cut short"), nil + }} + var mu sync.Mutex + var banked []modelapi.Charge + var settling []int + server, api := open(t, modelapi.Config{ + CompleterFor: calls.completerFor, + Bank: func(charge modelapi.Charge) { mu.Lock(); banked = append(banked, charge); mu.Unlock() }, + Settling: func(owed int) { mu.Lock(); settling = append(settling, owed); mu.Unlock() }, + }) + if status, payload := post(t, api, api.Token, hello); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + if spent := server.Spent(); spent != 0 { + t.Fatalf("spent %v before the receipt came", spent) + } + go func() { + time.Sleep(100 * time.Millisecond) + close(gate) + }() + if err := server.Close(); err != nil { + t.Fatal(err) + } + if spent := server.Spent(); spent != 0.058188488 { + t.Fatalf("spent after Close = %v, want the late receipt's $0.058188488 in it", spent) + } + mu.Lock() + if len(banked) != 1 || !banked[0].Late || banked[0].CostUSD != 0.058188488 || len(settling) != 1 || settling[0] != 1 { + mu.Unlock() + t.Fatalf("banked %+v settling %v, want the one late charge banked before Close returned, told once", banked, settling) + } + mu.Unlock() + + // A receipt that never comes: Close gives up at the bound. + defer modelapi.ShortenReceiptWait(150 * time.Millisecond)() + never := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + provider.ReceiptPendingFrom(ctx)() + return saying(model, "cut short"), nil + }} + stuck, stuckAPI := open(t, modelapi.Config{CompleterFor: never.completerFor}) + if status, payload := post(t, stuckAPI, stuckAPI.Token, hello); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + began := time.Now() + if err := stuck.Close(); err != nil { + t.Fatal(err) + } + if waited := time.Since(began); waited < 100*time.Millisecond || waited > 5*time.Second { + t.Fatalf("Close waited %s for a receipt that never came, want about the bound", waited) + } +} + +// THE PAGE NAMES THE MODEL THAT ANSWERED. When nothing here could take the ask +// or the seat, the conversation's own pool put the call on its seat, which this +// API never hears of — run 3d6d asked qwen and was answered by gpt-5.6-sol, at a +// price its service does not report. The funnel's bill names who answered, and +// the turn says so; an ask answered by the same model under another spelling, +// or by a dated build of it, names nothing more. A SIBLING IS ANOTHER MODEL: +// `gpt-5.5-mini` answering an ask for `gpt-5.5` is named, because a hyphen +// followed by a word is a different model and only a date or build number is +// the same one. +func TestATurnNamesTheModelTheFunnelBilledWhenItIsNotTheAsk(t *testing.T) { + for _, row := range []struct { + name, asked, billed, answer, want string + }{ + {name: "the pool's seat answered", asked: "qwen/qwen3.6-plus", billed: "gpt-5.6-sol", want: "gpt-5.6-sol"}, + {name: "the same model without its service", asked: "openrouter/deepseek/deepseek-v4-pro", billed: "deepseek/deepseek-v4-pro", want: ""}, + {name: "a dated build of the ask", asked: "deepseek/deepseek-v4-pro", billed: "deepseek/deepseek-v4-pro-0731", want: ""}, + {name: "a full date on the build", asked: "openai/gpt-5.5", billed: "openai/gpt-5.5-20260731", want: ""}, + {name: "a variant of the ask", asked: "qwen/qwen3.6-plus", billed: "qwen/qwen3.6-plus:free", want: ""}, + {name: "a smaller sibling answered", asked: "openai/gpt-5.5", billed: "openai/gpt-5.5-mini", want: "openai/gpt-5.5-mini"}, + {name: "a faster sibling answered", asked: "deepseek/deepseek-v4", billed: "deepseek/deepseek-v4-flash", want: "deepseek/deepseek-v4-flash"}, + {name: "a thinking sibling answered", asked: "moonshotai/kimi-k2", billed: "moonshotai/kimi-k2-thinking", want: "moonshotai/kimi-k2-thinking"}, + {name: "the ask is the sibling", asked: "deepseek/deepseek-v4-flash", billed: "deepseek/deepseek-v4", want: "deepseek/deepseek-v4"}, + {name: "nothing billed, the answer names another", asked: "moonshotai/kimi-k2.6", answer: "z-ai/glm-5.1", want: "z-ai/glm-5.1"}, + } { + t.Run(row.name, func(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + answered := model + if row.billed != "" { + // A service that reports no price: tokens, and no dollars. + bill(ctx, row.billed, 58511, 405, 55356, 0) + answered = row.billed + } + if row.answer != "" { + answered = row.answer + } + return saying(answered, "done"), nil + }} + _, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor}) + body := `{"model":"` + row.asked + `","messages":[{"role":"user","content":"go"}]}` + if status, payload := post(t, api, api.Token, body); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + turns, _ := delegate.ReadTurns(dir, 0) + if len(turns) != 1 || turns[0].Model != row.asked || turns[0].Served != row.want { + t.Fatalf("turn = %+v, want the ask %q kept and %q named as what answered", turns, row.asked, row.want) + } + }) + } +} + +// A RUN WITH NOTHING LEFT OF ITS LIMIT MAKES NO CALL AT ALL. The conversation +// hands such a run the smallest positive ceiling, because zero means none; the +// first call used to go through and be paid for, since nothing spent was still +// "under" it. It is refused with the ceiling's own sentence, and the funnel is +// never asked. +func TestARunWhoseCeilingIsAlreadySpentMakesNoCall(t *testing.T) { + dir := t.TempDir() + calls := &script{reply: words("paid for", 0.139463)} + server, api := open(t, modelapi.Config{TaskDir: dir, CompleterFor: calls.completerFor, Ceiling: math.SmallestNonzeroFloat64}) + status, payload := post(t, api, api.Token, hello) + message, code := errorOf(t, payload) + if status != http.StatusPaymentRequired || code != 402 || + message != "the run's dollar ceiling of $0.00 is reached ($0.00 spent), so codeaf made no call" { + t.Fatalf("the first call of a spent run was answered %d: %s", status, payload) + } + if len(calls.calls()) != 0 || server.Spent() != 0 || server.RefusedAtCeiling() != 1 { + t.Fatalf("the funnel was asked %d times, spent %v, refused %d", len(calls.calls()), server.Spent(), server.RefusedAtCeiling()) + } +} + +// AN ANSWER NOTHING PRICED IS NOT LEFT SILENT. Every call a run makes rides a +// context that asks the funnel to settle an answer that arrived whole with no +// usage block the way it settles a cut one: by its receipt, or as a call nobody +// could price (provider.WithUnmeteredReceipts). +func TestARunsCallsAskTheFunnelToSettleAnAnswerWithNoUsage(t *testing.T) { + armed := make(chan bool, 1) + calls := &script{reply: func(ctx context.Context, model string, _ []ai.Message, _ ai.Request) (*ai.Response, error) { + armed <- provider.UnmeteredReceiptsFrom(ctx) + return saying(model, "whole"), nil + }} + _, api := open(t, modelapi.Config{CompleterFor: calls.completerFor}) + if status, payload := post(t, api, api.Token, hello); status != http.StatusOK { + t.Fatalf("status %d: %s", status, payload) + } + if !<-armed { + t.Fatal("the call's context does not ask the funnel to settle an answer with no usage block") + } +} diff --git a/internal/provider/modelapi/threads.go b/internal/provider/modelapi/threads.go new file mode 100644 index 0000000000..58cf5ea218 --- /dev/null +++ b/internal/provider/modelapi/threads.go @@ -0,0 +1,168 @@ +package modelapi + +// What the program said that it had not said before. +// +// A program talks to its model the way every chat client does: each request +// carries the whole conversation so far. Written down whole, one call's record +// would repeat every call before it, and the task page would draw the same +// brief forty times. So each thread's previous request is remembered — as one +// fingerprint per message, never the text — and a turn records only what came +// after it: the brief the first time, then the tools' results and the +// program's own words. +// +// A PROGRAM THAT REWRITES ITS HISTORY IS SAID TO HAVE DONE SO. When a request +// is not the previous one with more added — a compaction, a summary of old +// turns in place of the turns — nothing is a delta of anything, so the turn is +// marked Restarted and records what the program sent, whole (capped where the +// log is written, delegate.AppendTurn). +// +// THE MODEL'S OWN REPLIES ARE NOT SENT WORDS. An assistant message on a request +// is the program handing the model's last answer back to it; the page already +// drew that answer on the turn that produced it, so it is skipped here. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// threads is every thread's previous request, as fingerprints, and the field +// its model's working last arrived on. It is guarded by the server's lock. +type threads struct { + previous map[string][]string + fields map[string]string +} + +// arrived remembers the field a thread's working came in on, so working the +// program hands back under the router's name is replayed under the field the +// endpoint wrote it with. +func (t *threads) arrived(thread, field string) { + if field == "" { + return + } + if t.fields == nil { + t.fields = map[string]string{} + } + t.fields[thread] = field +} + +// name gives every piece of handed-back working with no field of its own the +// field its thread's working last arrived on — the router's `reasoning` when +// the thread has not said — so no working reaches the encoder unnamed. +func (t *threads) name(thread string, working []provider.MessageReasoning) []provider.MessageReasoning { + field := t.fields[thread] + if field == "" { + field = "reasoning" + } + for index := range working { + if working[index].Field == "" && working[index].Text != "" { + working[index].Field = field + } + } + return working +} + +// delta answers what this request adds to the thread's previous one, and +// remembers this request as the thread's previous from now on — whatever the +// call comes to, because "previous" is the previous request, not the previous +// answer. +func (t *threads) delta(thread string, messages []ai.Message) (sent []delegate.Said, restarted bool) { + if t.previous == nil { + t.previous = map[string][]string{} + } + prints := make([]string, len(messages)) + for index, message := range messages { + prints[index] = fingerprint(message) + } + before := t.previous[thread] + start := len(before) + if !extends(prints, before) { + restarted, start = true, 0 + } + t.previous[thread] = prints + names := toolNames(messages) + for _, message := range messages[start:] { + if message.Role == "assistant" { + continue + } + sent = append(sent, said(message, names)) + } + return sent, restarted +} + +// extends reports whether a request is the previous one with messages added. +func extends(now, before []string) bool { + if len(before) > len(now) { + return false + } + for index, print := range before { + if now[index] != print { + return false + } + } + return true +} + +// fingerprint is one message's identity: its role, every part of its content, +// its tool calls and the call it answers. It is a hash so a thread's memory is +// a few dozen bytes a message whatever the message weighed. +func fingerprint(message ai.Message) string { + encoded, _ := json.Marshal(struct { + Role string `json:"r"` + Content []ai.ContentPart `json:"c"` + ToolCalls []ai.ToolCall `json:"t"` + ToolCallID string `json:"i"` + }{message.Role, message.Content, message.ToolCalls, message.ToolCallID}) + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:12]) +} + +// toolNames maps every tool call on the request to the tool it named, so a +// tool's result can say which tool it answers. +func toolNames(messages []ai.Message) map[string]string { + names := map[string]string{} + for _, message := range messages { + for _, call := range message.ToolCalls { + if call.ID != "" { + names[call.ID] = call.Function.Name + } + } + } + return names +} + +// said is one message as the log writes it: whose, which tool it answers, and +// its words, with a word in brackets standing for anything that is not text. +func said(message ai.Message, names map[string]string) delegate.Said { + var words []string + for _, part := range message.Content { + switch part.Type { + case "text": + if part.Text != "" { + words = append(words, part.Text) + } + case "image_url": + words = append(words, "[image]") + case "video_url": + words = append(words, "[video]") + case "input_audio": + words = append(words, "[audio]") + case "file": + words = append(words, "[file]") + default: + if part.Type != "" { + words = append(words, "["+part.Type+"]") + } + } + } + entry := delegate.Said{Role: message.Role, Text: strings.Join(words, "\n")} + if message.Role == "tool" { + entry.Tool = names[message.ToolCallID] + } + return entry +} diff --git a/internal/provider/modelapi/wire.go b/internal/provider/modelapi/wire.go new file mode 100644 index 0000000000..631eee4ee8 --- /dev/null +++ b/internal/provider/modelapi/wire.go @@ -0,0 +1,525 @@ +package modelapi + +// The wire: an OpenAI chat-completions body in, codeaf's funnel types out, and +// the answer back in OpenRouter's own shape. +// +// OPENROUTER'S SHAPE, BECAUSE THAT IS WHAT THE PROGRAMS WERE WRITTEN AGAINST. +// senior-dev reads `usage.cost` off the last chunk of a stream and stops +// budgeting silently when it is not there, so the answer is not "an +// OpenAI-compatible reply" in the loose sense: it is the router's own body — +// the `cost`, the cached-token nesting, the usage chunk after the finish, the +// `: …` comment lines while a call is thinking — so a program moved from a +// router onto codeaf cannot tell the road changed. +// +// WHAT CODEAF DECIDES IS DROPPED, NOT PASSED. A program's `provider` routing +// object, its `models` fallback list, `route`, `transforms` and `plugins` are +// how a caller steers OpenRouter; here codeaf's own router steers, with the +// lane beliefs, pins and ceilings a person set, so those fields are read past. +// `stream_options` and `usage` are read past too, because usage and its cost +// are always sent. + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// chatRequest is the body a program sends, as far as codeaf reads it. +type chatRequest struct { + Model string `json:"model"` + Messages []json.RawMessage `json:"messages"` + Tools []wireTool `json:"tools"` + // ToolChoice is "auto", "none", "required" or an object naming one + // function; it is kept raw and handed on as the program wrote it. + ToolChoice json.RawMessage `json:"tool_choice"` + MaxTokens *int `json:"max_tokens"` + MaxCompletionTokens *int `json:"max_completion_tokens"` + Temperature *float64 `json:"temperature"` + Reasoning *wireReasoning `json:"reasoning"` + ReasoningEffort string `json:"reasoning_effort"` + PromptCacheKey string `json:"prompt_cache_key"` + ResponseFormat json.RawMessage `json:"response_format"` + Stream bool `json:"stream"` +} + +// wireTool is one tool the program offers its model. +type wireTool struct { + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]any `json:"parameters"` + } `json:"function"` +} + +// wireReasoning is OpenRouter's unified reasoning object. +type wireReasoning struct { + Effort string `json:"effort"` + Enabled *bool `json:"enabled"` +} + +// messageExtras are the fields of one message the SDK's type has no home for: +// the model's working a program hands back on an assistant message so a +// thinking model can continue its own tool loop (provider.MessageReasoning). +type messageExtras struct { + Reasoning string `json:"reasoning"` + ReasoningContent string `json:"reasoning_content"` + ReasoningText string `json:"reasoning_text"` + ReasoningDetails json.RawMessage `json:"reasoning_details"` +} + +// call is one decoded request: what the funnel is handed and what the log is +// written from. +type call struct { + asked string + thread string + cacheKey string + stream bool + messages []ai.Message + reasoning []provider.MessageReasoning + options []ai.Option + depth depth +} + +// depth is how hard the program asked its model to think, in codeaf's own +// words: a rung of the ladder (internal/effort) from low to max, or — for the +// two requests that are not rungs, the pass switched off and the lowest word +// the router has — the adapter's own word. At most one of the two is set. +type depth struct { + rung effort.Rung + word provider.Effort +} + +// maxRequestBytes bounds one request body. A transcript with pictures in it is +// megabytes, never this; the bound is the provider's own answer ceiling, so a +// question can be as large as an answer may be and no larger. +const maxRequestBytes = 64 << 20 + +// decodeRequest reads one body into a call, or says in one sentence what is +// wrong with it — which is the whole of a 400's message. +func decodeRequest(body []byte) (*call, error) { + var request chatRequest + if err := json.Unmarshal(body, &request); err != nil { + return nil, fmt.Errorf("the body is not a chat-completions request: %v", err) + } + if len(request.Messages) == 0 { + return nil, errors.New("the request carries no messages") + } + decoded := &call{ + asked: strings.TrimSpace(request.Model), + cacheKey: strings.TrimSpace(request.PromptCacheKey), + stream: request.Stream, + } + decoded.thread = decoded.cacheKey + hasReasoning := false + for index, raw := range request.Messages { + var message ai.Message + if err := json.Unmarshal(raw, &message); err != nil { + return nil, fmt.Errorf("message %d does not parse: %v", index, err) + } + message.Role = strings.ToLower(strings.TrimSpace(message.Role)) + switch message.Role { + case "system", "user", "assistant", "tool": + case "developer": + // OpenAI's newer name for the system turn. Not every model a + // person's service carries knows it, and every one knows system. + message.Role = "system" + default: + return nil, fmt.Errorf("message %d has the role %q; a message is system, user, assistant or tool", index, message.Role) + } + decoded.messages = append(decoded.messages, message) + working := provider.MessageReasoning{} + if message.Role == "assistant" { + var extras messageExtras + _ = json.Unmarshal(raw, &extras) + working = extras.working() + if working.Text != "" || len(working.Details) > 0 { + hasReasoning = true + } + } + decoded.reasoning = append(decoded.reasoning, working) + } + if !hasReasoning { + decoded.reasoning = nil + } + options, err := request.options() + if err != nil { + return nil, err + } + decoded.options = options + decoded.depth = request.depth() + return decoded, nil +} + +// working is the reasoning a program handed back. A field the program named +// outright is the field it travels under; the router's own `reasoning` — the +// name every answer here hands the working out under — is left unnamed, and +// the call names it from what its thread's working last arrived on +// ([threads.name]), because the provider's replay law is that working goes +// back under the field it came in with (provider.ReasoningReplayPolicy). +func (e messageExtras) working() provider.MessageReasoning { + working := provider.MessageReasoning{} + switch { + case e.ReasoningContent != "": + working.Field, working.Text = "reasoning_content", e.ReasoningContent + case e.ReasoningText != "": + working.Field, working.Text = "reasoning_text", e.ReasoningText + case e.Reasoning != "": + working.Text = e.Reasoning + } + if details := strings.TrimSpace(string(e.ReasoningDetails)); strings.HasPrefix(details, "[") && details != "[]" { + working.Details = append(json.RawMessage(nil), e.ReasoningDetails...) + } + return working +} + +// options are the funnel's per-call settings for everything the SDK request +// has a field for: the tools and the choice among them, the output ceiling, +// the temperature and the response format. The model is set by the caller, +// once it is decided ([Resolve]). +func (r chatRequest) options() ([]ai.Option, error) { + var options []ai.Option + if len(r.Tools) > 0 { + tools := make([]ai.ToolDefinition, 0, len(r.Tools)) + for index, tool := range r.Tools { + kind := strings.TrimSpace(tool.Type) + if kind == "" { + kind = "function" + } + if kind != "function" { + return nil, fmt.Errorf("tool %d is a %q tool; the model API carries function tools", index, kind) + } + if strings.TrimSpace(tool.Function.Name) == "" { + return nil, fmt.Errorf("tool %d has no name", index) + } + parameters := tool.Function.Parameters + if parameters == nil { + parameters = map[string]any{"type": "object", "properties": map[string]any{}} + } + tools = append(tools, ai.ToolDefinition{Type: "function", Function: ai.ToolFunction{ + Name: tool.Function.Name, Description: tool.Function.Description, Parameters: parameters, + }}) + } + options = append(options, ai.WithTools(tools)) + // The SDK's WithTools says "auto"; a choice the program made is + // applied after it, so the program's word is the one that travels. + if choice, ok := decodeToolChoice(r.ToolChoice); ok { + options = append(options, withToolChoice(choice)) + } + } + if ceiling := firstCeiling(r.MaxCompletionTokens, r.MaxTokens); ceiling > 0 { + options = append(options, ai.WithMaxTokens(ceiling)) + } + if r.Temperature != nil { + options = append(options, ai.WithTemperature(*r.Temperature)) + } + format, err := decodeResponseFormat(r.ResponseFormat) + if err != nil { + return nil, err + } + if format != nil { + options = append(options, withResponseFormat(format)) + } + return options, nil +} + +// firstCeiling is the output ceiling a request named: max_completion_tokens, +// OpenAI's newer spelling, when it is there, and max_tokens otherwise. The +// provider decides which of the two a given endpoint is sent. +func firstCeiling(ceilings ...*int) int { + for _, ceiling := range ceilings { + if ceiling != nil && *ceiling > 0 { + return *ceiling + } + } + return 0 +} + +// decodeToolChoice reads tool_choice as the program wrote it: one of the +// three words, or an object naming a function. +func decodeToolChoice(raw json.RawMessage) (any, bool) { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return nil, false + } + var word string + if json.Unmarshal(raw, &word) == nil { + word = strings.TrimSpace(word) + return word, word != "" + } + var object map[string]any + if json.Unmarshal(raw, &object) == nil && len(object) > 0 { + return object, true + } + return nil, false +} + +// decodeResponseFormat reads response_format. `text` is the default and is +// sent as nothing; json_object and json_schema travel in the SDK's own shape. +func decodeResponseFormat(raw json.RawMessage) (*ai.ResponseFormat, error) { + text := strings.TrimSpace(string(raw)) + if text == "" || text == "null" { + return nil, nil + } + var format ai.ResponseFormat + if err := json.Unmarshal(raw, &format); err != nil { + return nil, fmt.Errorf("response_format does not parse: %v", err) + } + switch strings.TrimSpace(format.Type) { + case "", "text": + return nil, nil + case "json_object": + return &ai.ResponseFormat{Type: "json_object"}, nil + case "json_schema": + if format.JSONSchema == nil || len(format.JSONSchema.Schema) == 0 { + return nil, errors.New("response_format json_schema carries no schema") + } + return &format, nil + default: + return nil, fmt.Errorf("response_format %q is not one the model API carries", format.Type) + } +} + +// withToolChoice sets the program's own tool_choice on the SDK request. +func withToolChoice(choice any) ai.Option { + return func(request *ai.Request) error { + request.ToolChoice = choice + return nil + } +} + +// withResponseFormat sets a decoded response_format on the SDK request. +func withResponseFormat(format *ai.ResponseFormat) ai.Option { + return func(request *ai.Request) error { + request.ResponseFormat = format + return nil + } +} + +// depth is the reasoning depth the program asked for — OpenRouter's +// `reasoning` object or OpenAI's `reasoning_effort` — on codeaf's own ladder: +// low, medium and high are the words every provider shares, and xhigh and max +// are the two rungs above them, which codeaf says with a thinking budget +// (internal/provider's effortladder.go). `enabled: false` and `none` switch the +// pass off, and `minimal` is the router's own lowest word. A word none of that +// has a place for is not sent, because a knob a model would refuse must never +// reach the wire. +func (r chatRequest) depth() depth { + word := strings.TrimSpace(r.ReasoningEffort) + if r.Reasoning != nil { + if r.Reasoning.Enabled != nil && !*r.Reasoning.Enabled { + return depth{word: provider.EffortOff} + } + if said := strings.TrimSpace(r.Reasoning.Effort); said != "" { + word = said + } + } + switch word = strings.ToLower(word); word { + case "none", "off": + return depth{word: provider.EffortOff} + case "minimal": + return depth{word: provider.EffortMinimal} + } + if rung := effort.Rung(word); rung.Valid() { + return depth{rung: rung} + } + return depth{} +} + +// ── the answer ────────────────────────────────────────────────────────────── + +// completion is one whole answer, the body a request that did not ask for a +// stream is given. +type completion struct { + ID string `json:"id"` + Provider string `json:"provider,omitempty"` + Model string `json:"model"` + Object string `json:"object"` + Created int64 `json:"created"` + Choices []wholeChoice `json:"choices"` + Usage usageBlock `json:"usage"` +} + +type wholeChoice struct { + Index int `json:"index"` + Message map[string]any `json:"message"` + FinishReason string `json:"finish_reason"` + NativeFinishReason string `json:"native_finish_reason"` + Logprobs any `json:"logprobs"` +} + +// chunk is one event of a streamed answer. +type chunk struct { + ID string `json:"id"` + Provider string `json:"provider,omitempty"` + Model string `json:"model"` + Object string `json:"object"` + Created int64 `json:"created"` + Choices []chunkChoice `json:"choices"` + Usage *usageBlock `json:"usage,omitempty"` + Error *errorDetail `json:"error,omitempty"` +} + +type chunkChoice struct { + Index int `json:"index"` + Delta map[string]any `json:"delta"` + FinishReason *string `json:"finish_reason"` + NativeFinishReason *string `json:"native_finish_reason"` + Logprobs any `json:"logprobs"` +} + +// usageBlock is OpenRouter's usage object. COST IS ALWAYS PRESENT, zero +// included: a program that budgets reads it off every answer, and a missing +// field is a budget that silently stops counting. +type usageBlock struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + Cost float64 `json:"cost"` + PromptTokensDetails promptDetail `json:"prompt_tokens_details"` +} + +type promptDetail struct { + CachedTokens int `json:"cached_tokens"` +} + +// errorBody is OpenRouter's error envelope: a sentence and a numeric code. +type errorBody struct { + Error errorDetail `json:"error"` +} + +type errorDetail struct { + Message string `json:"message"` + Code int `json:"code"` +} + +// wireToolCall is one tool call on the answer, with the index a stream's +// delta carries so a client can assemble calls by position. +type wireToolCall struct { + Index *int `json:"index,omitempty"` + ID string `json:"id"` + Type string `json:"type"` + Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` +} + +// answer is everything one call came back with, in the shape both the whole +// body and the stream are written from. +type answer struct { + id string + provider string + model string + created int64 + text string + calls []ai.ToolCall + finish string + reasoning captured + usage usageBlock +} + +// finishOf is the answer's own word for how it ended, "tool_calls" for an +// answer that is a tool call and said nothing, "stop" when nothing was said. +func finishOf(response *ai.Response) string { + finish := strings.TrimSpace(provider.FinishReason(response)) + if finish != "" { + return finish + } + if response != nil && response.HasToolCalls() { + return "tool_calls" + } + return "stop" +} + +// toolCalls is the answer's tool calls in the wire's shape, indexed when the +// shape is a stream's. +func toolCalls(calls []ai.ToolCall, indexed bool) []wireToolCall { + out := make([]wireToolCall, 0, len(calls)) + for position, call := range calls { + wired := wireToolCall{ID: call.ID, Type: "function"} + if strings.TrimSpace(call.Type) != "" { + wired.Type = call.Type + } + wired.Function.Name = call.Function.Name + wired.Function.Arguments = call.Function.Arguments + if indexed { + at := position + wired.Index = &at + } + out = append(out, wired) + } + return out +} + +// message is the whole answer's assistant message, the model's working on it +// under the router's own `reasoning` ([captured.onto] says why). +func (a answer) message() map[string]any { + message := map[string]any{"role": "assistant", "refusal": nil} + if a.text != "" || len(a.calls) == 0 { + message["content"] = a.text + } else { + message["content"] = nil + } + if len(a.calls) > 0 { + message["tool_calls"] = toolCalls(a.calls, false) + } + a.reasoning.onto(message) + return message +} + +// whole is the answer as one completion body. +func (a answer) whole() completion { + return completion{ + ID: a.id, Provider: a.provider, Model: a.model, Object: "chat.completion", Created: a.created, + Choices: []wholeChoice{{Index: 0, Message: a.message(), FinishReason: a.finish, NativeFinishReason: a.finish}}, + Usage: a.usage, + } +} + +// chunks is the answer as the events of a stream, in the order OpenRouter +// sends them: the working, the words, each tool call whole under its index, the +// finish, and then the usage on a chunk of its own — the last thing before +// `[DONE]`, which is where a program that budgets reads its cost. +func (a answer) chunks() []chunk { + head := func(delta map[string]any, finish *string) chunk { + return chunk{ + ID: a.id, Provider: a.provider, Model: a.model, Object: "chat.completion.chunk", Created: a.created, + Choices: []chunkChoice{{Index: 0, Delta: delta, FinishReason: finish, NativeFinishReason: finish}}, + } + } + var out []chunk + if a.reasoning.present() { + delta := map[string]any{"role": "assistant", "content": ""} + a.reasoning.onto(delta) + out = append(out, head(delta, nil)) + } + if a.text != "" || len(a.calls) == 0 { + out = append(out, head(map[string]any{"role": "assistant", "content": a.text}, nil)) + } + for _, call := range toolCalls(a.calls, true) { + out = append(out, head(map[string]any{"role": "assistant", "content": nil, "tool_calls": []wireToolCall{call}}, nil)) + } + finish := a.finish + out = append(out, head(map[string]any{"role": "assistant", "content": ""}, &finish)) + usage := a.usage + last := head(map[string]any{"role": "assistant", "content": ""}, nil) + last.Usage = &usage + return append(out, last) +} + +// failedChunk is a failure after the stream has begun: OpenRouter's in-band +// error event, an `error` beside a choice that finished on "error". +func failedChunk(id, model string, created int64, status int, message string) chunk { + finish := "error" + return chunk{ + ID: id, Model: model, Object: "chat.completion.chunk", Created: created, + Error: &errorDetail{Message: message, Code: status}, + Choices: []chunkChoice{{Index: 0, Delta: map[string]any{"content": ""}, FinishReason: &finish, NativeFinishReason: &finish}}, + } +} diff --git a/internal/provider/modelapi/wire_test.go b/internal/provider/modelapi/wire_test.go new file mode 100644 index 0000000000..fe0b2ee073 --- /dev/null +++ b/internal/provider/modelapi/wire_test.go @@ -0,0 +1,295 @@ +package modelapi + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// applied is the SDK request a call's options build, which is exactly what the +// funnel encodes. +func applied(t *testing.T, decoded *call) ai.Request { + t.Helper() + var request ai.Request + for _, option := range decoded.options { + if err := option(&request); err != nil { + t.Fatal(err) + } + } + return request +} + +// THE BODY A PROGRAM SENDS IS THE REQUEST THE FUNNEL MAKES: messages of every +// role with their tool calls and results, the tools, the program's own +// tool_choice over the SDK's default, the output ceiling in either spelling, +// the temperature, the response format, the reasoning depth, the cache key and +// the working handed back on an assistant message. +func TestTheWireCarriesEveryFieldTheFunnelHasAHomeFor(t *testing.T) { + body := `{ + "model": "openrouter/deepseek/deepseek-v4-flash-0731", + "messages": [ + {"role": "developer", "content": "you are careful"}, + {"role": "user", "content": [{"type": "text", "text": "fix the test"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}]}, + {"role": "assistant", "content": null, "reasoning": "look first", "reasoning_details": [{"type": "reasoning.text", "text": "look first"}], + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "bash", "arguments": "{\"cmd\":\"go test\"}"}}]}, + {"role": "tool", "tool_call_id": "call_1", "content": "ok"} + ], + "tools": [{"type": "function", "function": {"name": "bash", "description": "run a command", "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}}}], + "tool_choice": {"type": "function", "function": {"name": "bash"}}, + "max_tokens": 900, + "max_completion_tokens": 1200, + "temperature": 0.2, + "reasoning": {"effort": "high"}, + "prompt_cache_key": "thread-a", + "response_format": {"type": "json_schema", "json_schema": {"name": "verdict", "strict": true, "schema": {"type": "object"}}}, + "provider": {"order": ["somebody"]}, + "stream": true, + "stream_options": {"include_usage": true}, + "usage": {"include": true} + }` + decoded, err := decodeRequest([]byte(body)) + if err != nil { + t.Fatal(err) + } + if decoded.asked != "openrouter/deepseek/deepseek-v4-flash-0731" || decoded.cacheKey != "thread-a" || decoded.thread != "thread-a" || !decoded.stream { + t.Fatalf("call = %+v", decoded) + } + if len(decoded.messages) != 4 || decoded.messages[0].Role != "system" { + t.Fatalf("messages = %+v, want four with the developer turn read as system", decoded.messages) + } + if parts := decoded.messages[1].Content; len(parts) != 2 || parts[1].ImageURL == nil { + t.Fatalf("the user's picture was lost: %+v", parts) + } + assistant := decoded.messages[2] + if len(assistant.ToolCalls) != 1 || assistant.ToolCalls[0].Function.Name != "bash" || assistant.ToolCalls[0].Function.Arguments != `{"cmd":"go test"}` { + t.Fatalf("assistant tool calls = %+v", assistant.ToolCalls) + } + if tool := decoded.messages[3]; tool.Role != "tool" || tool.ToolCallID != "call_1" || tool.Content[0].Text != "ok" { + t.Fatalf("tool result = %+v", tool) + } + // The router's own `reasoning` is left unnamed here: the call names it + // from its thread (threads.name). + if len(decoded.reasoning) != 4 || decoded.reasoning[2].Field != "" || decoded.reasoning[2].Text != "look first" || + !strings.Contains(string(decoded.reasoning[2].Details), "reasoning.text") || decoded.reasoning[0].Text != "" { + t.Fatalf("working sidecar = %+v, want the assistant's working aligned with its message", decoded.reasoning) + } + if decoded.depth != (depth{rung: effort.High}) { + t.Fatalf("depth = %+v, want the high rung", decoded.depth) + } + request := applied(t, decoded) + if len(request.Tools) != 1 || request.Tools[0].Function.Name != "bash" || request.Tools[0].Function.Parameters["type"] != "object" { + t.Fatalf("tools = %+v", request.Tools) + } + choice, ok := request.ToolChoice.(map[string]any) + if !ok || choice["type"] != "function" { + t.Fatalf("tool_choice = %#v, want the program's own object over the SDK's auto", request.ToolChoice) + } + if request.MaxTokens == nil || *request.MaxTokens != 1200 { + t.Fatalf("max tokens = %v, want max_completion_tokens' 1200", request.MaxTokens) + } + if request.Temperature == nil || *request.Temperature != 0.2 { + t.Fatalf("temperature = %v", request.Temperature) + } + if request.ResponseFormat == nil || request.ResponseFormat.Type != "json_schema" || request.ResponseFormat.JSONSchema.Name != "verdict" || + !request.ResponseFormat.JSONSchema.Strict || string(request.ResponseFormat.JSONSchema.Schema) != `{"type": "object"}` { + t.Fatalf("response_format = %+v", request.ResponseFormat) + } +} + +// EVERY SPELLING OF A DEPTH LANDS ON CODEAF'S OWN LADDER: the three shared +// words and the two rungs above them as rungs — senior-dev's `--variant xhigh` +// included — the pass switched off and the router's lowest word as the +// adapter's own words, and anything else as nothing. +func TestTheWireReadsEveryReasoningSpelling(t *testing.T) { + for _, row := range []struct { + body string + want depth + }{ + {`"reasoning_effort": "low"`, depth{rung: effort.Low}}, + {`"reasoning": {"effort": "medium"}`, depth{rung: effort.Medium}}, + {`"reasoning": {"effort": "xhigh"}`, depth{rung: effort.XHigh}}, + {`"reasoning_effort": "max"`, depth{rung: effort.Max}}, + {`"reasoning": {"effort": "minimal"}`, depth{word: provider.EffortMinimal}}, + {`"reasoning": {"enabled": false}`, depth{word: provider.EffortOff}}, + {`"reasoning_effort": "none"`, depth{word: provider.EffortOff}}, + {`"reasoning": {"enabled": true}`, depth{}}, + // A word nothing in codeaf has a place for is never sent. + {`"reasoning_effort": "ultra"`, depth{}}, + } { + decoded, err := decodeRequest([]byte(`{"model":"m","messages":[{"role":"user","content":"hi"}],` + row.body + `}`)) + if err != nil { + t.Fatal(err) + } + if decoded.depth != row.want { + t.Fatalf("%s: depth %+v, want %+v", row.body, decoded.depth, row.want) + } + } +} + +func TestTheWireRefusesWhatItCannotCarryInOneSentence(t *testing.T) { + for _, row := range []struct{ body, says string }{ + {`not json`, "not a chat-completions request"}, + {`{"model":"m","messages":[]}`, "no messages"}, + {`{"model":"m","messages":[{"role":"wizard","content":"hi"}]}`, `"wizard"`}, + {`{"model":"m","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"retrieval"}]}`, "function tools"}, + {`{"model":"m","messages":[{"role":"user","content":"hi"}],"response_format":{"type":"json_schema"}}`, "no schema"}, + } { + if _, err := decodeRequest([]byte(row.body)); err == nil || !strings.Contains(err.Error(), row.says) { + t.Fatalf("%s: err = %v, want it to say %q", row.body, err, row.says) + } + } + // A response_format of text is the default and travels as nothing; no + // tools means no tool_choice either. + decoded, err := decodeRequest([]byte(`{"messages":[{"role":"user","content":"hi"}],"response_format":{"type":"text"},"tool_choice":"required"}`)) + if err != nil { + t.Fatal(err) + } + if request := applied(t, decoded); request.ResponseFormat != nil || request.ToolChoice != nil || request.Tools != nil { + t.Fatalf("request = %+v", request) + } +} + +// THE ANSWER IS THE ROUTER'S OWN SHAPE, and a stream ends the way the +// router's does: the working, the words, each tool call under its index, the +// finish, a usage chunk carrying the cost, in that order. +func TestAStreamedAnswerIsTheRoutersChunksInTheRoutersOrder(t *testing.T) { + said := answer{ + id: "gen-1", model: "m", created: 7, text: "done", + calls: []ai.ToolCall{{ID: "call_1", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}, {ID: "call_2", Function: ai.ToolCallFunction{Name: "edit", Arguments: `{"a":1}`}}}, + finish: "tool_calls", + reasoning: captured{field: "reasoning_content", text: "thinking", details: json.RawMessage(`[{"type":"reasoning.text","text":"thinking"}]`)}, + usage: usageBlock{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15, Cost: 0.25, PromptTokensDetails: promptDetail{CachedTokens: 4}}, + } + chunks := said.chunks() + if len(chunks) != 6 { + t.Fatalf("%d chunks, want working, words, two calls, finish and usage", len(chunks)) + } + // Working that arrived as a direct endpoint's reasoning_content is handed + // out under the router's own name, the one a program reads. + if working := chunks[0].Choices[0].Delta; working["reasoning"] != "thinking" || working["reasoning_details"] == nil || working["reasoning_content"] != nil { + t.Fatalf("working chunk = %+v, want the working under the router's own name", working) + } + if chunks[1].Choices[0].Delta["content"] != "done" { + t.Fatalf("words chunk = %+v", chunks[1].Choices[0].Delta) + } + second := chunks[3].Choices[0].Delta["tool_calls"].([]wireToolCall)[0] + if second.Index == nil || *second.Index != 1 || second.ID != "call_2" || second.Type != "function" || second.Function.Name != "edit" || second.Function.Arguments != `{"a":1}` { + t.Fatalf("second call = %+v", second) + } + if finish := chunks[4].Choices[0].FinishReason; finish == nil || *finish != "tool_calls" || chunks[4].Usage != nil { + t.Fatalf("finish chunk = %+v", chunks[4]) + } + if last := chunks[5]; last.Usage == nil || last.Usage.Cost != 0.25 || last.Usage.PromptTokensDetails.CachedTokens != 4 || last.Choices[0].FinishReason != nil { + t.Fatalf("usage chunk = %+v", last) + } + encoded, _ := json.Marshal(chunks[5]) + for _, want := range []string{`"object":"chat.completion.chunk"`, `"cost":0.25`, `"prompt_tokens_details":{"cached_tokens":4}`, `"finish_reason":null`} { + if !strings.Contains(string(encoded), want) { + t.Fatalf("usage chunk %s lacks %s", encoded, want) + } + } +} + +func TestAWholeAnswerCarriesItsCallsAndACostOfZeroOutLoud(t *testing.T) { + said := answer{id: "gen-2", model: "m", created: 9, calls: []ai.ToolCall{{ID: "c", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}}, finish: "tool_calls"} + encoded, err := json.Marshal(said.whole()) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{`"object":"chat.completion"`, `"content":null`, `"tool_calls":[{"id":"c","type":"function"`, `"finish_reason":"tool_calls"`, `"cost":0`} { + if !strings.Contains(string(encoded), want) { + t.Fatalf("whole answer %s lacks %s", encoded, want) + } + } + if strings.Contains(string(encoded), `"tool_calls":[{"index"`) { + t.Fatalf("a whole answer's tool calls carry a stream's index: %s", encoded) + } +} + +// A THREAD REMEMBERS ITS PREVIOUS REQUEST AND NOTHING ELSE: what is new is the +// delta, the model's own replies are never sent words, and a request that is +// not the previous one extended is a restart. +func TestAThreadRecordsOnlyWhatItHadNotSaidBefore(t *testing.T) { + system := ai.Message{Role: "system", Content: []ai.ContentPart{{Type: "text", Text: "rules"}}} + user := ai.Message{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "fix it"}}} + reply := ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ID: "c1", Function: ai.ToolCallFunction{Name: "bash", Arguments: "{}"}}}} + result := ai.Message{Role: "tool", ToolCallID: "c1", Content: []ai.ContentPart{{Type: "text", Text: "PASS"}}} + var memory threads + sent, restarted := memory.delta("main", []ai.Message{system, user}) + if restarted || len(sent) != 2 || sent[0].Role != "system" || sent[1].Text != "fix it" { + t.Fatalf("first call sent %+v restarted %v", sent, restarted) + } + sent, restarted = memory.delta("main", []ai.Message{system, user, reply, result}) + if restarted || len(sent) != 1 || sent[0].Role != "tool" || sent[0].Tool != "bash" || sent[0].Text != "PASS" { + t.Fatalf("second call sent %+v restarted %v, want only the tool's result, naming its tool", sent, restarted) + } + // The same request again — a retry — added nothing. + if sent, restarted = memory.delta("main", []ai.Message{system, user, reply, result}); restarted || len(sent) != 0 { + t.Fatalf("a retry sent %+v restarted %v", sent, restarted) + } + summary := ai.Message{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "so far: tests pass"}}} + sent, restarted = memory.delta("main", []ai.Message{system, summary}) + if !restarted || len(sent) != 2 || sent[1].Text != "so far: tests pass" { + t.Fatalf("a rewritten history sent %+v restarted %v, want the whole of it and the restart said", sent, restarted) + } + // Another thread has its own memory. + if sent, restarted = memory.delta("helper", []ai.Message{system, user}); restarted || len(sent) != 2 { + t.Fatalf("a second thread sent %+v restarted %v, want its own first call", sent, restarted) + } +} + +// WORKING HANDED BACK UNDER THE ROUTER'S NAME GOES BACK UNDER THE FIELD IT +// ARRIVED ON: a thread whose working came in as reasoning_content has it +// replayed as reasoning_content; a thread that has not said is the router's; +// a field the program named outright is kept; and the threads do not share. +func TestHandedBackWorkingIsNamedByWhatItsThreadLastArrivedOn(t *testing.T) { + var memory threads + memory.arrived("coder", "reasoning_content") + memory.arrived("coder", "") + working := func() []provider.MessageReasoning { + return []provider.MessageReasoning{{}, {Text: "mine"}, {Field: "reasoning_text", Text: "named"}, {Details: json.RawMessage(`[{}]`)}} + } + named := memory.name("coder", working()) + if named[1].Field != "reasoning_content" || named[2].Field != "reasoning_text" || named[0].Field != "" || named[3].Field != "" { + t.Fatalf("named on the coder's thread = %+v", named) + } + if other := memory.name("helper", working()); other[1].Field != "reasoning" { + t.Fatalf("a thread that has not said named its working %q, want the router's own", other[1].Field) + } +} + +func TestAMessageThatIsNotTextIsNamedInBrackets(t *testing.T) { + message := ai.Message{Role: "user", Content: []ai.ContentPart{ + {Type: "text", Text: "look"}, {Type: "image_url", ImageURL: &ai.ImageURLData{URL: "x"}}, {Type: "file"}, + }} + if got := said(message, nil).Text; got != "look\n[image]\n[file]" { + t.Fatalf("said %q", got) + } +} + +func TestStreamedWorkingIsJoinedAsTheWireSentIt(t *testing.T) { + var held json.RawMessage + held = joinArrays(held, json.RawMessage(`[{"a":1}]`)) + held = joinArrays(held, json.RawMessage(` [] `)) + held = joinArrays(held, json.RawMessage(`not an array`)) + held = joinArrays(held, json.RawMessage(`[{"b":2},{"c":3}]`)) + if string(held) != `[{"a":1},{"b":2},{"c":3}]` { + t.Fatalf("joined %s", held) + } + catch := &catcher{} + catch.observe(provider.StreamEvent{Kind: provider.StreamReasoning, Delta: "one ", ReasoningField: "reasoning"}) + catch.observe(provider.StreamEvent{Kind: provider.StreamReasoning, Delta: "shown only", FromAnswer: true}) + catch.observe(provider.StreamEvent{Kind: provider.StreamReasoning, Delta: "two", ReasoningDetails: json.RawMessage(`[{"t":1}]`)}) + if got := catch.caught(); got.field != "reasoning" || got.text != "one two" || string(got.details) != `[{"t":1}]` { + t.Fatalf("caught %+v", got) + } + // A replaced answer takes its working with it. + catch.observe(provider.StreamEvent{Kind: provider.StreamReplaced, Delta: "retrying"}) + if got := catch.caught(); got.present() { + t.Fatalf("working survived its answer's replacement: %+v", got) + } +} diff --git a/internal/provider/modelapi/working.go b/internal/provider/modelapi/working.go new file mode 100644 index 0000000000..a2f46502b8 --- /dev/null +++ b/internal/provider/modelapi/working.go @@ -0,0 +1,118 @@ +package modelapi + +// The model's working on one answer, caught as the funnel streams it. +// +// The SDK's response has no field for reasoning, so the only place the words a +// thinking model wrote before its answer arrive is the funnel's stream +// observer (provider.StreamReasoning). They are gathered here and handed to the +// program on its answer, because a thinking model in a tool loop is continued +// by being handed its own working back — and a program can only hand back what +// it was given. +// +// NOTHING IS FORWARDED WHILE IT ARRIVES. The funnel can replace an answer it +// has begun (a stalled stream rescued by a second request, provider's +// StreamReplaced), and a program cannot be told to forget bytes it has already +// read; so the working is kept until the answer is final and a replacement +// empties it. + +import ( + "bytes" + "encoding/json" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/provider" +) + +// captured is one answer's working: the field it arrived on, its words, and the +// structured blocks a router sends beside them. +type captured struct { + field string + text string + details json.RawMessage +} + +// present reports whether there is any working to hand over. +func (c captured) present() bool { return c.text != "" || len(c.details) > 0 } + +// onto writes the working onto a message or a delta under THE ROUTER'S OWN +// NAME, `reasoning`, whatever field it arrived on. A program written against +// OpenRouter reads that name and no other, and a direct endpoint's +// `reasoning_content` would be working it never saw and so could never hand +// back; the field it really arrived on is remembered for the thread instead +// ([threads.arrived]), and a hand-back is replayed under it. +func (c captured) onto(target map[string]any) { + if c.text != "" { + target["reasoning"] = c.text + } + if len(c.details) > 0 { + target["reasoning_details"] = c.details + } +} + +// catcher is the stream observer one call installs. The funnel calls it on its +// own read loop, synchronously, so it does nothing but append under a lock. +type catcher struct { + mu sync.Mutex + field string + text strings.Builder + details json.RawMessage +} + +// observe is the provider.StreamObserver. +func (c *catcher) observe(event provider.StreamEvent) { + c.mu.Lock() + defer c.mu.Unlock() + switch event.Kind { + case provider.StreamReplaced: + // Everything gathered belonged to the answer being thrown away. + c.field, c.details = "", nil + c.text.Reset() + case provider.StreamReasoning: + // Working carved out of the answer's own text has no field to be + // handed back under (provider's answer.go), so it is not a + // continuation and is not kept. + if event.FromAnswer { + return + } + if c.field == "" && event.ReasoningField != "" { + c.field = event.ReasoningField + } + c.text.WriteString(event.Delta) + c.details = joinArrays(c.details, event.ReasoningDetails) + } +} + +// caught is what the observer holds now. +func (c *catcher) caught() captured { + c.mu.Lock() + defer c.mu.Unlock() + return captured{field: c.field, text: c.text.String(), details: append(json.RawMessage(nil), c.details...)} +} + +// joinArrays appends one streamed array of reasoning blocks to the blocks +// already held, as the wire sent them: a client written for a router's stream +// assembles them itself, exactly as it would have assembled that router's +// chunks. +func joinArrays(current, next json.RawMessage) json.RawMessage { + next = bytes.TrimSpace(next) + if len(next) < 2 || next[0] != '[' || next[len(next)-1] != ']' { + return current + } + inner := bytes.TrimSpace(next[1 : len(next)-1]) + if len(inner) == 0 { + return current + } + if len(current) == 0 { + return append(json.RawMessage(nil), next...) + } + held := bytes.TrimSpace(current[1 : len(current)-1]) + joined := make(json.RawMessage, 0, len(held)+len(inner)+3) + joined = append(joined, '[') + joined = append(joined, held...) + if len(held) > 0 { + joined = append(joined, ',') + } + joined = append(joined, inner...) + return append(joined, ']') +} diff --git a/internal/provider/receipt.go b/internal/provider/receipt.go index a58f99f070..6557e459d6 100644 --- a/internal/provider/receipt.go +++ b/internal/provider/receipt.go @@ -38,9 +38,18 @@ const ( // The growing pauses give a generation receipt time to appear after its call // ends. It is the only honest source of this money, and this bounded wait is // entirely in the background, so generosity here costs the person nothing. + // + // THE FOURTH PAUSE IS THE MEASURED ONE. The receipts of the three stopped + // senior-dev runs of 2026-09-23 — each for the call in flight when the run + // was cut — landed 20.5, 20.6 and 20.8 seconds after the cut: on the fourth + // and then last request, with nothing to spare. A cancelled generation takes + // the router about that long to price, so a little more lag on its side + // turned a real charge into an unpriced marker. One more request twenty + // seconds later gives that ending a second chance. receiptFirstRetryDelay = time.Second receiptSecondRetryDelay = 4 * time.Second receiptThirdRetryDelay = 15 * time.Second + receiptFourthRetryDelay = 20 * time.Second // receiptRequestAllowance leaves each attempt room to complete in addition // to the pauses. The ceiling is derived from every part of that schedule so // widening one cannot silently leave the background context too short. @@ -48,8 +57,17 @@ const ( // receiptScheduleSlack leaves the derived ceiling comfortably beyond both // the growing pauses and every request's allowance. receiptScheduleSlack = 5 * time.Second - receiptFetchTimeout = receiptFirstRetryDelay + receiptSecondRetryDelay + receiptThirdRetryDelay + + receiptFetchTimeout = receiptFirstRetryDelay + receiptSecondRetryDelay + receiptThirdRetryDelay + receiptFourthRetryDelay + time.Duration(receiptAttempts)*receiptRequestAllowance + receiptScheduleSlack + // ReceiptWait is the longest one receipt can take to be answered once it is + // queued: the whole schedule's ceiling, counted from the queue however long + // the receipt waited there for a worker ([receiptWork.deadline]), so a + // waiter that starts after every receipt it is owed was queued sees each one + // answered within it. It is exported for work that waits + // for the receipts it is owed before it closes its books + // ([WithReceiptPending]), so that wait and this schedule are one figure and + // widening the schedule widens the wait with it. + ReceiptWait = receiptFetchTimeout // receiptRouteTTL is how long a base's answer that it has no generation // route is trusted before the capability may be asked about again. receiptRouteTTL = 5 * time.Minute @@ -57,24 +75,50 @@ const ( // carries and still prevents an upstream body becoming an unbounded read. maxReceiptBytes = 1 << 20 - // These two words name endings that have no [CutReason] of their own. They + // These words name endings that have no [CutReason] of their own. They // live here so every such ending and every receipt row spell them alike. - receiptTornReason = "torn" - receiptRefusalReason = "refusal" + // unmetered is an answer that arrived whole with no usage block + // ([WithUnmeteredReceipts]). + receiptTornReason = "torn" + receiptRefusalReason = "refusal" + receiptUnmeteredReason = "unmetered" ) var receiptRetrySchedule = [...]time.Duration{ receiptFirstRetryDelay, receiptSecondRetryDelay, receiptThirdRetryDelay, + receiptFourthRetryDelay, } // receiptWork is all the worker may retain from a call whose own context is // usually cancelled. The sink and attribution are values; no request context // crosses the hand-off because its cancellation is why this work exists. +// queued is the instant the receipt was owed, which its ceiling is counted from. type receiptWork struct { result Reconciled sink ReconcileSink + queued time.Time +} + +// deadline is the latest a receipt may be answered: [receiptFetchTimeout] after +// it was queued. +// +// IT IS COUNTED FROM THE QUEUE AND NOT FROM THE WORKER, because the queue is +// what a waiter sees. A client drains its receipts with [receiptWorkerCount] +// workers, so a fifth receipt owed behind four slow ones started its whole +// schedule some forty seconds late and was answered about eighty seconds after +// it was queued — past [ReceiptWait], so a run that waited that long for its +// books closed them without that call's price. A receipt that waited in the +// queue loses none of its chance by this: the provider was pricing its +// generation the whole time it waited, and the worker's first request for it +// is made that much later. +func (w receiptWork) deadline() time.Time { + queued := w.queued + if queued.IsZero() { + queued = time.Now() + } + return queued.Add(receiptFetchTimeout) } // receiptRouteMemo remembers only the one definite capability answer: a base @@ -150,11 +194,24 @@ func (c *Client) settle(ctx context.Context, model string, response *ai.Response return } work := receiptWork{result: result, sink: sink} + // THE WORK IS TOLD A RECEIPT IS OWED BEFORE IT IS QUEUED, and told it was + // answered only after the sink has banked it, so a caller waiting for its + // receipts cannot see zero owed while money is between the two + // ([ReceiptPending]). + if pending := receiptPendingFrom(ctx); pending != nil { + done := pending() + work.sink = func(answer Reconciled) { + defer done() + sink(answer) + } + } + // THE RECEIPT'S BOUND STARTS HERE, when it is owed, and not when a worker + // gets to it ([receiptWork.deadline]). + work.queued = time.Now() if !c.queueReceipt(work) { // A full queue reports the missing price without holding up the turn. - sink(result) + work.sink(result) } - } // runReceipts is one member of the small fixed pool draining this client's @@ -203,7 +260,8 @@ func (c *Client) nextReceipt() (receiptWork, bool) { // reconcile follows the fixed growing schedule and delivers exactly one answer. // It starts from a fresh context because the call's own context has commonly -// been cancelled already, then puts one ceiling around the entire schedule. +// been cancelled already, then puts one ceiling around the entire schedule, +// counted from when the receipt was queued ([receiptWork.deadline]). func (c *Client) reconcile(work receiptWork) { result := work.result base := strings.TrimRight(strings.TrimSpace(c.config.BaseURL), "/") @@ -211,7 +269,7 @@ func (c *Client) reconcile(work receiptWork) { work.sink(result) return } - ctx, cancel := context.WithTimeout(context.Background(), receiptFetchTimeout) + ctx, cancel := context.WithDeadline(context.Background(), work.deadline()) defer cancel() for attempt := 0; attempt < receiptAttempts; attempt++ { billed, found, noRoute := c.fetchReceipt(ctx, result.Ref) diff --git a/internal/provider/receipt_test.go b/internal/provider/receipt_test.go index ec3fd255a1..4c1cb7d1a9 100644 --- a/internal/provider/receipt_test.go +++ b/internal/provider/receipt_test.go @@ -676,3 +676,266 @@ func TestReceiptWorkersRetireAfterTheirQueueDrains(t *testing.T) { t.Fatal("a receipt arriving after retirement never restarted its worker") } } + +// TestAQueuedReceiptIsOwedUntilItsSinkHasBankedIt pins the pending door that +// lets a run wait for the price of the call it was cut in the middle of: the +// work is told a receipt is owed before the fetch begins, and told it was +// answered only after the sink has had the money — never the other way round, +// or a caller could read its total in the gap. A call that queues no receipt +// (a usage block, or no id and no text) owes nothing. +func TestAQueuedReceiptIsOwedUntilItsSinkHasBankedIt(t *testing.T) { + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + <-release + fmt.Fprint(w, `{"data":{"total_cost":0.058,"tokens_prompt":52139,"tokens_completion":4895}}`) + })) + t.Cleanup(server.Close) + client := receiptTestClient(t, server) + var mu sync.Mutex + var owed int + var order []string + pending := func() func() { + mu.Lock() + owed++ + mu.Unlock() + return func() { + mu.Lock() + owed-- + order = append(order, "answered") + mu.Unlock() + } + } + results := make(chan Reconciled, 1) + ctx := WithReceiptPending(WithReconcile(t.Context(), func(result Reconciled) { + mu.Lock() + order = append(order, "banked") + mu.Unlock() + results <- result + }), pending) + + // Neither of these queues a receipt, so neither is owed. + cost := 0.01 + client.settle(ctx, "sim/model", &ai.Response{Usage: &ai.Usage{PromptTokens: 1, Cost: &cost}}, "stalled", 0) + client.settle(ctx, "sim/model", &ai.Response{}, "stalled", 0) + mu.Lock() + if owed != 0 { + mu.Unlock() + t.Fatalf("owed = %d after two calls that queued no receipt", owed) + } + mu.Unlock() + + client.settle(ctx, "sim/model", &ai.Response{ID: "cut-in-flight"}, "stopped", 12) + mu.Lock() + if owed != 1 { + mu.Unlock() + t.Fatalf("owed = %d while the receipt is being fetched, want 1", owed) + } + mu.Unlock() + close(release) + if result := receiptResult(t, results); !result.Found || result.Cost != 0.058 { + t.Fatalf("receipt = %+v", result) + } + deadline := time.Now().Add(5 * time.Second) + for { + mu.Lock() + settled := owed == 0 && len(order) == 2 + got := append([]string(nil), order...) + mu.Unlock() + if settled { + if got[0] != "banked" || got[1] != "answered" { + t.Fatalf("order = %v, want the money banked before the receipt is marked answered", got) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("the receipt was never marked answered: order %v", got) + } + time.Sleep(5 * time.Millisecond) + } +} + +// writeWholeWithoutUsage streams a whole, finished answer that names its +// generation and never sends a usage block. +func writeWholeWithoutUsage(w http.ResponseWriter, id string) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `data: {"id":%q,"choices":[{"index":0,"delta":{"content":"a whole answer"}}]}`+"\n\n", id) + fmt.Fprintf(w, `data: {"id":%q,"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`+"\n\n", id) + fmt.Fprint(w, "data: [DONE]\n\n") + w.(http.Flusher).Flush() +} + +// TestAnAnswerWithNoUsageIsSettledOnlyWhereTheWorkAskedForIt pins the opt-in +// door: a whole answer that carried no usage block is settled like a cut one — +// its receipt asked for and banked — only when the work armed +// WithUnmeteredReceipts, and never on a direct service. Every other caller's +// road is exactly what it was: no receipt request and nothing reported. +func TestAnAnswerWithNoUsageIsSettledOnlyWhereTheWorkAskedForIt(t *testing.T) { + for _, row := range []struct { + name string + armed bool + direct bool + want bool + }{ + {name: "armed on the routed service", armed: true, want: true}, + {name: "not armed", armed: false}, + {name: "armed on a direct service", armed: true, direct: true}, + } { + t.Run(row.name, func(t *testing.T) { + var receipts atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/generation" { + receipts.Add(1) + fmt.Fprint(w, `{"data":{"total_cost":0.032296144,"tokens_prompt":116000,"tokens_completion":40}}`) + return + } + writeWholeWithoutUsage(w, "gen-unmetered") + })) + t.Cleanup(server.Close) + client, err := NewClient(Config{ + APIKey: "receipt-key", BaseURL: server.URL, Model: "moonshotai/kimi-k2.6", + Direct: row.direct, HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + client.velocity = newVelocityLedger() + client.wait = func(context.Context, time.Duration) error { return nil } + results := make(chan Reconciled, 1) + ctx := WithReconcile(WithStreamObserver(t.Context(), func(StreamEvent) {}), func(result Reconciled) { results <- result }) + if row.armed { + ctx = WithUnmeteredReceipts(ctx) + } + response, err := client.CompleteWithMessages(ctx, userMessages("hello")) + if err != nil || response == nil || response.Usage != nil { + t.Fatalf("response %+v err %v, want a whole answer with no usage block", response, err) + } + if !row.want { + select { + case result := <-results: + t.Fatalf("an answer this work did not arm was settled: %+v", result) + case <-time.After(200 * time.Millisecond): + } + if receipts.Load() != 0 { + t.Fatalf("a receipt was asked for %d times", receipts.Load()) + } + return + } + result := receiptResult(t, results) + if !result.Found || result.Cost != 0.032296144 || result.Ref != "gen-unmetered" || result.Reason != "unmetered" { + t.Fatalf("settled = %+v, want the receipt's $0.032296144", result) + } + }) + } +} + +// TestACutAnswerWithNoUsageIsStillSettledWhereTheWorkAskedForIt pins the opt-in +// door on the roads that end in a cut. A paid 200 with no usage block that is +// cut after it arrived — the model's own tool grammar written as text, or a +// rescue that is not language — was billed through the ordinary door, which +// banks nothing without a usage block, so on work that armed +// WithUnmeteredReceipts the provider's charge reached no book at all. +func TestACutAnswerWithNoUsageIsStillSettledWhereTheWorkAskedForIt(t *testing.T) { + const leak = `<|DSML|_web_search>{\"query\":\"x\"}<|/DSML|_web_search>` + for _, row := range []struct { + name string + stream bool + rescue bool + body string + }{ + {name: "a whole answer that leaked its grammar", body: `{"id":"gen-cut","model":"sim/model","choices":[{"index":0,` + + `"finish_reason":"stop","message":{"role":"assistant","content":"` + leak + `"}}]}`}, + {name: "a streamed answer that leaked its grammar", stream: true, + body: `data: {"id":"gen-cut","choices":[{"index":0,"delta":{"content":"` + leak + `"},"finish_reason":"stop"}]}` + "\n\n" + "data: [DONE]\n\n"}, + {name: "a rescue that is not language", stream: true, rescue: true, + body: `data: {"id":"gen-cut","choices":[{"index":0,"delta":{"content":"half an answer \ufffd\ufffd"},"finish_reason":"stop"}]}` + "\n\n" + "data: [DONE]\n\n"}, + } { + t.Run(row.name, func(t *testing.T) { + forgetLanes(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/generation" { + fmt.Fprint(w, `{"data":{"total_cost":0.0125,"tokens_prompt":9000,"tokens_completion":40}}`) + return + } + if row.stream { + w.Header().Set("Content-Type", "text/event-stream") + } else { + w.Header().Set("Content-Type", "application/json") + } + fmt.Fprint(w, row.body) + })) + t.Cleanup(server.Close) + client, err := NewClient(Config{ + APIKey: "receipt-key", BaseURL: server.URL, Model: "sim/model", HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + client.velocity = newVelocityLedger() + client.wait = func(context.Context, time.Duration) error { return nil } + results := make(chan Reconciled, 1) + ctx := WithUnmeteredReceipts(WithReconcile(t.Context(), func(result Reconciled) { results <- result })) + if row.stream { + ctx = WithStreamObserver(ctx, func(StreamEvent) {}) + } + if row.rescue { + ctx = withHedgeLane(ctx, "rescue") + } + _, err = client.CompleteWithMessages(ctx, userMessages("look this up"), ai.WithTools(machineryTools("web_search"))) + if _, ok := CutFrom(err); !ok { + t.Fatalf("err = %v, want the answer cut", err) + } + result := receiptResult(t, results) + if !result.Found || result.Cost != 0.0125 || result.Ref != "gen-cut" { + t.Fatalf("settled = %+v, want the receipt's $0.0125", result) + } + }) + } +} + +// ReceiptWait IS COUNTED FROM THE QUEUE, NOT FROM THE WORKER. A client drains +// its receipts with four workers, so a fifth owed receipt waits behind four slow +// ones before any worker asks for it; its whole schedule used to start there, +// and it was answered about eighty seconds after it was queued — past the +// seventy a run's books wait for it ([ReceiptWait]), so the run closed without +// that call's price. A receipt now carries the instant it was queued, and its +// ceiling is that instant plus the schedule's own. +func TestAReceiptIsAnsweredWithinReceiptWaitOfBeingQueued(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // The generation is never priced, so only the ceiling can end the schedule. + fmt.Fprint(w, `{"data":{}}`) + })) + t.Cleanup(server.Close) + client := receiptTestClient(t, server) + + // The queue stamps each receipt as it is admitted. No worker is started + // here, so the queued work can be read back as it was admitted. + client.receiptRunning = receiptWorkerCount + before := time.Now() + client.settle(WithReconcile(t.Context(), func(Reconciled) {}), "sim/model", &ai.Response{ID: "gen-queued"}, "torn", 0) + after := time.Now() + queued := <-client.receipts + if queued.queued.Before(before) || queued.queued.After(after) { + t.Fatalf("the receipt was stamped %v, want the instant it was queued (%v to %v)", queued.queued, before, after) + } + + // A receipt that has already waited in the queue for nearly the whole bound + // is given only what is left of it, however long its pauses would run. + client.wait = func(ctx context.Context, _ time.Duration) error { + <-ctx.Done() + return ctx.Err() + } + results := make(chan Reconciled, 1) + late := receiptWork{ + result: Reconciled{Ref: "gen-late"}, + sink: func(result Reconciled) { results <- result }, + queued: time.Now().Add(-(ReceiptWait - 300*time.Millisecond)), + } + go client.reconcile(late) + if result := receiptResult(t, results); result.Found { + t.Fatalf("a receipt the provider never priced was found: %+v", result) + } + if answered := time.Since(late.queued); answered > ReceiptWait+2*time.Second { + t.Fatalf("the receipt was answered %v after it was queued, past ReceiptWait %v", answered, ReceiptWait) + } +} diff --git a/internal/remote/client.go b/internal/remote/client.go index 1becfd22fc..4d6b39b984 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1559,6 +1559,37 @@ func (a *Agent) StartTask(ctx context.Context, brief string, solo bool) (uint64, return started.ID, started.Title, started.Note, nil } +// Delegates is the programs the engine machine's build carries, as the surface +// draws its rows from them (internal/session's delegate_door.go). A failed read +// is the zero report — no rows — because a list is a reading and never worth a +// refusal at the door. +func (a *Agent) Delegates() session.DelegateReport { + payload, err := a.c.call(context.Background(), MethodDelegateList, nil) + if err != nil { + return session.DelegateReport{} + } + var report session.DelegateReport + if err := json.Unmarshal(payload, &report); err != nil { + return session.DelegateReport{} + } + return report +} + +// StartDelegate hands the brief to the named program on the engine machine +// and returns the same receipt StartTask does. It is an ordinary call with the +// ordinary deadline: the engine admits the run at once. +func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, string, string, error) { + payload, err := a.c.call(ctx, MethodDelegateStart, DelegateStartArgs{Name: name, Brief: brief}) + if err != nil { + return 0, "", "", err + } + var started TaskStarted + if err := json.Unmarshal(payload, &started); err != nil { + return 0, "", "", err + } + return started.ID, started.Title, started.Note, nil +} + // StartPlannerRun opens the adaptive form on the engine machine. func (a *Agent) StartPlannerRun(ctx context.Context, brief, hint string) (string, string, error) { payload, err := a.c.call(ctx, MethodPlannerStart, PlannerStartArgs{Brief: brief, Hint: hint}) @@ -1970,15 +2001,30 @@ func (a *Agent) PlanTasks() []session.PlanTaskRow { // PlanTaskPage reads one complete task page from the engine. func (a *Agent) PlanTaskPage(id string) (session.PlanTaskPage, bool) { - payload, err := a.c.call(nil, MethodPlanTaskPage, PlanTaskPageArgs{ID: id}) + page, found, err := a.ReadPlanTaskPage(id) if err != nil { return session.PlanTaskPage{}, false } + return page, found +} + +// ReadPlanTaskPage is [Agent.PlanTaskPage] with the engine's refusal kept. +// +// A READING WINDOW NEEDS THE REFUSAL. A page opened onto another +// conversation's program task reads nothing but this, and the one way it +// learns the conversation under it was replaced is the engine's own sentence +// ([ErrJoinedGone]) — which the plan capability's (page, found) shape has +// nowhere to put (internal/tui3's [tui3.TaskOwnerView.TaskPage]). +func (a *Agent) ReadPlanTaskPage(id string) (session.PlanTaskPage, bool, error) { + payload, err := a.c.call(nil, MethodPlanTaskPage, PlanTaskPageArgs{ID: id}) + if err != nil { + return session.PlanTaskPage{}, false, err + } var result PlanTaskPageResult - if json.Unmarshal(payload, &result) != nil { - return session.PlanTaskPage{}, false + if err := json.Unmarshal(payload, &result); err != nil { + return session.PlanTaskPage{}, false, err } - return result.Page, result.OK + return result.Page, result.OK, nil } // PlanTaskWork reads the run's working copy over the wire. An engine that diff --git a/internal/remote/client_test.go b/internal/remote/client_test.go index b654869218..878aa464c1 100644 --- a/internal/remote/client_test.go +++ b/internal/remote/client_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/standing" @@ -1000,6 +1001,8 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) { Depth: 2, Waits: []string{"t-a", "t-b"}, Steps: 7, USD: 1.25, Started: started, Ended: ended, Note: "last note", Live: plandb.LiveStep{Step: 8, Command: "go test ./internal/remote", Since: started}, + Program: "senior-dev", + Stage: "implement", TrajectoryPath: "/tmp/trajectory.jsonl", } page := session.PlanTaskPage{ @@ -1009,6 +1012,29 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) { Live: row.Live, Children: []session.PlanTaskRow{row}, WaitRows: []session.PlanTaskRow{row}, + // A PROGRAM'S CONVERSATION CROSSES WITH ITS PAGE, on the page's own call + // and in no call of its own: an answered turn with everything a turn can + // carry, a refused one, and the one still in flight — and so do its + // actions, each with everything an action can carry. + Program: &session.PlanProgram{ + Name: "senior-dev", Stages: []string{"intake", "implement"}, + Turns: []delegate.Turn{ + {Seq: 1, Thread: "main", Started: started, Ended: ended, Model: "deepseek/deepseek-v4-flash", Served: "deepseek/deepseek-v4-flash-0731", + Sent: []delegate.Said{{Role: "user", Text: "rewrite the wire"}, {Role: "tool", Tool: "read", Text: "package remote"}}, + Reply: "I'll read the wire first.", Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"wire.go"}`}}, + TokensIn: 1200, TokensOut: 40, Cached: 800, CostUSD: 0.012}, + {Seq: 2, Thread: "main", Started: started, Model: "deepseek/deepseek-v4-flash", Refused: "the run's dollar ceiling is reached"}, + {Seq: 3, Thread: "main", Started: ended, Model: "deepseek/deepseek-v4-flash", Restarted: true}, + }, + Earlier: 4, Calls: 6, CeilingUSD: 5, + Actions: []delegate.Shown{ + {At: started, Step: "explore", Text: "ran go test ./internal/remote", Outcome: "fails · exit 1"}, + {At: started.Add(time.Second), Text: "compacted its memory", Outcome: "kept its own record", Memory: true}, + {At: ended, Text: "switched to deepseek-v4-flash", Model: "openrouter/deepseek/deepseek-v4-flash", Reason: "the last one was busy"}, + {At: ended, Step: "implement", Text: "told its model to finish (nudge 1)", Steer: true}, + }, + EarlierActions: 12, + }, } e.answers[MethodPlanTasks] = []session.PlanTaskRow{row} e.answers[MethodPlanTaskPage] = PlanTaskPageResult{Page: page, OK: true} @@ -1029,6 +1055,21 @@ func TestPlanTasksAndPlanTaskPageCrossWhole(t *testing.T) { } } +// A READING WINDOW'S PAGE READ KEEPS THE ENGINE'S REFUSAL. The plan +// capability folds every failure into "not found"; a page onto another +// conversation's program task reads nothing else, and the refusal is how it +// learns that conversation was replaced. +func TestReadPlanTaskPageKeepsTheEnginesRefusal(t *testing.T) { + client, e := newEngine(t) + e.fails[MethodPlanTaskPage] = "engine: that conversation is not open here any more" + if _, found, err := client.Agent().ReadPlanTaskPage("7"); found || err == nil || !strings.Contains(err.Error(), "not open here any more") { + t.Fatalf("ReadPlanTaskPage = (found %v, %v), want the engine's own refusal", found, err) + } + if _, found := client.Agent().PlanTaskPage("7"); found { + t.Fatal("PlanTaskPage found a page the engine refused") + } +} + func TestRunSummariesCrossWholeAndDroppedRefreshKeepsNothing(t *testing.T) { client, e := newEngine(t) want := session.RunPlanSummary{ diff --git a/internal/remote/driver.go b/internal/remote/driver.go index c19a58ff91..0eadada68d 100644 --- a/internal/remote/driver.go +++ b/internal/remote/driver.go @@ -280,10 +280,19 @@ const watchingWord = "this window is reading this conversation, not typing into // change (tasklane.go) — and it is what a reading page asks INSTEAD of guessing // from files on the machine it happens to be running on // (internal/tui3's taskowner.go). +// +// AND ONE TASK'S STORED PAGE IS ON IT BECAUSE A PROGRAM'S TASK HAS NO JOURNAL. +// senior-dev writes no worker transcript for [MethodTaskRoom] to read; what it +// did is its conversation with codeaf on the task's page in the plan store, so +// a reading page onto a program's task reads [MethodPlanTaskPage] instead +// (internal/tui3's [app.guestPageRead]). It opens the store's read handles and +// changes nothing. Every verb on that page — a note, a pause, a stop — is NOT +// here and must not be: they act on the work, which is the owner's. var watcherReads = map[string]bool{ MethodTaskRoom: true, MethodTaskWatch: true, MethodQuestionWatch: true, + MethodPlanTaskPage: true, MethodDetach: true, MethodPing: true, } diff --git a/internal/remote/driver_test.go b/internal/remote/driver_test.go index 64477f9abd..a650600529 100644 --- a/internal/remote/driver_test.go +++ b/internal/remote/driver_test.go @@ -446,6 +446,55 @@ func TestAWatchingSurfaceIsRefusedEveryDoorThatChangesAnything(t *testing.T) { } } +// A READING SURFACE MAY READ ONE TASK'S STORED PAGE AND NONE OF ITS VERBS. A +// program's task writes no worker journal, so the page another window opens +// onto it reads the task's page in the owner's store instead +// (internal/tui3's taskowner.go). The page's verbs — a note, a pause, a stop — +// act on the owner's work and stay refused. And the read is bound to the +// conversation the reader joined, as the journal is: once the owner opens +// something else, it is told rather than handed the replacement's task. +func TestAReadingSurfaceReadsAProgramsPageAndNoneOfItsVerbs(t *testing.T) { + first := &fakeAgent{model: "a/b", title: "the one being read"} + second := &fakeAgent{model: "a/b", title: "something else"} + engine := engineOn(first) + engine.Fresh = func() (WrappedAgent, string, error) { return second, "/sessions/two.jsonl", nil } + sess := NewSession(engine, true) + + owner := dialSession(t, sess) + owner.hello(Hello{Version: Version, Surface: "macbook"}) + reader := dialSession(t, sess) + reader.hello(Hello{ + Version: Version, Surface: "reader", + Session: engine.SessionFile, Join: true, Watch: true, + }) + + if frame := reader.call(1, MethodPlanTaskPage, PlanTaskPageArgs{ID: "7"}); frame.Error != "" { + t.Fatalf("the reader was refused a program's page: %v", frame.Error) + } + for id, call := range []struct { + method string + payload any + }{ + {MethodPlanNote, PlanTextArgs{ID: "7", Text: "go faster"}}, + {MethodPlanPause, PlanTaskArgs{ID: "7"}}, + {MethodPlanCancel, PlanTaskArgs{ID: "7"}}, + } { + frame := reader.call(uint64(id+10), call.method, call.payload) + if !strings.Contains(frame.Error, watchingWord) { + t.Fatalf("%s on a reading surface answered %q, want the reader's own refusal", call.method, frame.Error) + } + } + if len(first.planSteers) != 0 { + t.Fatalf("a reading surface acted on the owner's work: %v", first.planSteers) + } + + owner.ok(20, MethodSessionNew, nil) + frame := reader.call(21, MethodPlanTaskPage, PlanTaskPageArgs{ID: "7"}) + if !strings.Contains(frame.Error, "not open here any more") { + t.Fatalf("after the owner opened something else the reader's page read answered %q, want the sentence its page acts on", frame.Error) + } +} + // ── the client half ───────────────────────────────────────────────────────── // The real client against the real engine over an in-memory pipe: what a diff --git a/internal/remote/places_test.go b/internal/remote/places_test.go index 5703d7a8bc..f7acbb3804 100644 --- a/internal/remote/places_test.go +++ b/internal/remote/places_test.go @@ -43,6 +43,7 @@ func farWorld(now time.Time) session.World { ID: "1", Name: "trimming", Label: "trimming the index", Title: "trimming the index", Status: string(session.TaskDone), Cost: 22.54, SessionID: "bbbb000000000002", EndedAt: now, + Program: "senior-dev", }}}, }}, }}, @@ -130,6 +131,11 @@ func TestTheWorldCrossesTheWire(t *testing.T) { if len(tasks) != 1 || tasks[0].Label != "trimming the index" || tasks[0].Cost != 22.54 { t.Fatalf("the work did not cross: %+v", tasks) } + // AND WHICH PROGRAM HAD IT, which is the badge the far machine's tasks place + // draws on the row. + if tasks[0].Program != "senior-dev" { + t.Fatalf("the work's program did not cross: %+v", tasks[0]) + } } // An engine with no world door REFUSES, and the refusal is not an empty world. diff --git a/internal/remote/server.go b/internal/remote/server.go index 09b9815fd4..59c5189609 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2316,6 +2316,28 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, err } return json.Marshal(TaskStarted{ID: id, Title: title, Note: note}) + case MethodDelegateList: + door, ok := agent.(interface{ Delegates() session.DelegateReport }) + if !ok { + return json.Marshal(session.DelegateReport{}) + } + return json.Marshal(door.Delegates()) + case MethodDelegateStart: + door, ok := agent.(interface { + StartDelegate(context.Context, string, string) (uint64, string, string, error) + }) + if !ok { + return nil, errors.New("engine: this session has no delegate door") + } + args, err := arg[DelegateStartArgs](call) + if err != nil { + return nil, err + } + id, title, note, err := door.StartDelegate(context.Background(), args.Name, args.Brief) + if err != nil { + return nil, err + } + return json.Marshal(TaskStarted{ID: id, Title: title, Note: note}) case MethodPlannerStart: door, ok := agent.(interface { StartPlannerRun(context.Context, string, string) (string, string, error) diff --git a/internal/remote/tasklane_test.go b/internal/remote/tasklane_test.go index ff36e9d4af..116da015d7 100644 --- a/internal/remote/tasklane_test.go +++ b/internal/remote/tasklane_test.go @@ -158,6 +158,32 @@ func TestAHandStartedTaskReachesTheHostedRail(t *testing.T) { } } +// A PROGRAM'S ROW KEEPS ITS PROGRAM OVER THE WIRE, so a surface on the near side +// draws the badge a program's work wears (internal/tui3's programbadge.go) from +// the row's first frame, exactly as a window on the far machine would. +func TestAProgramsRowCrossesTheWireNamingItsProgram(t *testing.T) { + far := &railAgent{fakeAgent: &fakeAgent{}} + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { + return &Engine{Agent: far, Workspace: "/srv/app", SessionFile: "/srv/app/j.jsonl"}, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + + lane, stop := loop.Client.Agent().WatchTaskUpdates() + t.Cleanup(stop) + waitFor(t, "the engine opened the surface's task lane", func() bool { return far.opened() == 1 }) + + row := taskEvent(9, "rewrite the auth middleware", session.TaskRunning) + row.Task.Program = "senior-dev" + far.land(row) + event := nextTask(t, lane) + if event.Task == nil || event.Task.ID != 9 || event.Task.Program != "senior-dev" { + t.Fatalf("the program's row crossed as %+v, want it naming senior-dev", event.Task) + } +} + // A FIRING USES THE REAL SESSION LANE OVER THE REAL WIRE. The scripted rail // above proves that a task-shaped event can cross; this is the other producer // of that lane, whose event has no task payload and is raised outside a turn. diff --git a/internal/remote/wire.go b/internal/remote/wire.go index b49bdc2b4a..8d82fae99f 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -344,7 +344,16 @@ import ( // ReplaceQuestion. It also carries whether a caller has no approval resolver. // Older peers must refuse before a question or an unwatched tool can run under // semantics the other side does not understand. -const Version = 17 +// +// VERSION 18 CARRIES THE DELEGATE DOOR — [MethodDelegateList] and +// [MethodDelegateStart] (wire_task.go). The number moves for [MethodTaskStart]'s +// reason: `Delegate.Start` COMMISSIONS WORK on the far machine and spends its +// money, so a version-17 engine answering "no such method" would leave a person +// told their work was under way while nothing had started. The list rides the +// same number because a surface generates its command rows from it before its +// first frame, and a row for a program the engine cannot start is a command +// that lies. +const Version = 18 // AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods // rode version 5's: neither half can be surprised by them. "phase" and "lane" diff --git a/internal/remote/wire_task.go b/internal/remote/wire_task.go index 276b6e547f..38c9a21e03 100644 --- a/internal/remote/wire_task.go +++ b/internal/remote/wire_task.go @@ -6,6 +6,14 @@ import "time" // machine. The surface sends intent; sizing, shaping, admission and spending // remain with the session agent that owns the conversation. const ( + // MethodDelegateList and MethodDelegateStart are the program door + // (internal/session's delegate_door.go): the programs the ENGINE machine's + // build carries, and handing a brief to one. They belong to the engine side + // for the reason the task door does — the program runs on that machine and + // the run spends that machine's money — so a hosted surface lists the far + // build's programs and its `/<name> <brief>` starts work there. + MethodDelegateList = "Delegate.List" + MethodDelegateStart = "Delegate.Start" MethodTaskStart = "Task.Start" MethodPlannerStart = "Task.StartPlanner" MethodTaskRoom = "Task.Room" @@ -157,6 +165,13 @@ type TaskStartArgs struct { Solo bool `json:"solo,omitempty"` } +// DelegateStartArgs carries the program's name and the person's brief, both +// as typed: the name is resolved against the engine machine's build there. +type DelegateStartArgs struct { + Name string `json:"name"` + Brief string `json:"brief"` +} + // PlannerStartArgs also carries the sizing hint used by the adaptive form. type PlannerStartArgs struct { Brief string `json:"brief"` diff --git a/internal/run/crew.go b/internal/run/crew.go index be4cd7ac6e..0936c5a1d6 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -114,32 +114,49 @@ func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, // role, so RoleOf's error needs no reader here. role, _ := store.RoleOf(task.ID) tier := SeatFor(role) - // THE DOOR'S SEAT WINS WHERE IT NAMED ONE. A planner (the run's root or - // a task that has children) rides the plan seat. A check rides the careful - // work seat. A leaf and every task an unknown role falls to the work seat. The probe tier is named by nobody, so it - // keeps the profile's row below. - var model string - switch tier { - case config.ModelTierMastermind: - model = seats.Plan - case config.ModelTierWorker: - model = seats.Work - case config.ModelTierHigh: - model = seats.Check - } - if model == "" { - model = config.TierSeatAt(profileDir, tier).Model - } + model := seatModel(profileDir, tier, seats) if model == "" { - model = config.TierSeatAt(profileDir, config.ModelTierWorker).Model - if model == "" { - return seatlessWorker{tier: tier} - } + return seatlessWorker{tier: tier} } return NewBashWorker(store, workspace, model, completerFor(model)) } } +// seatModel is the model a task riding tier is seated on, and the one answer +// both the crew's workers and a delegated program's model API read. +// +// THE DOOR'S SEAT WINS WHERE IT NAMED ONE. A planner (the run's root or a task +// that has children) rides the plan seat. A check rides the careful work seat. +// A leaf and every task an unknown role falls to the work seat. The probe tier +// is named by nobody, so it keeps the profile's row. A tier with no model +// falls to the worker row, and empty is a seat no model can fill. +func seatModel(profileDir, tier string, seats Seats) string { + var model string + switch tier { + case config.ModelTierMastermind: + model = seats.Plan + case config.ModelTierWorker: + model = seats.Work + case config.ModelTierHigh: + model = seats.Check + } + if model == "" { + model = config.TierSeatAt(profileDir, tier).Model + } + if model == "" { + model = config.TierSeatAt(profileDir, config.ModelTierWorker).Model + } + return model +} + +// WorkSeat is the model this run's own work seat holds: the door's work seat +// where it named one, the profile's worker row otherwise — exactly the seat a +// leaf of the run is built on ([CrewFactory]). A delegated program's model API +// answers on it whatever the program asks for that nothing here can reach. +func WorkSeat(profileDir, work string) string { + return seatModel(profileDir, config.ModelTierWorker, Seats{Work: work}) +} + // seatlessWorker is the seat a task gets when the crew holds no model for its // tier and none on the worker row either. It runs nothing and reports an error, // because a task that cannot be seated must fail with the row that has to be diff --git a/internal/run/cut_root_test.go b/internal/run/cut_root_test.go new file mode 100644 index 0000000000..4be974f6f1 --- /dev/null +++ b/internal/run/cut_root_test.go @@ -0,0 +1,38 @@ +package run + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// A RUN THE CALLER CUT IS LEFT OPEN, WHICHEVER WAY THE SELECT FELL. When the +// caller's context ends, the root worker comes home with the context's own +// error, and that return and the context's end are ready at the loop's select +// together. The select took the return first here, as Go may: the pass that +// follows must answer incomplete and leave the root as the run left it, the +// same ending the caller's wall gives, and never write `context canceled` over +// the run's task as though its work had failed. +func TestARootTheCallerCutIsNotFailedInItsStore(t *testing.T) { + for _, cut := range []error{context.Canceled, fmt.Errorf("the program stopped: %w", context.DeadlineExceeded)} { + store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "cut", "root", "The run", "cut") + if err != nil { + t.Fatal(err) + } + s := NewSupervisor(store, t.TempDir(), 1, Limits{}, nil) + s.dispatchedRoot = true + ctx, cancel := context.WithCancel(context.Background()) + cancel() + s.absorb(workerReturn{task: *store.Task("root"), err: cut}) + if got := s.pass(ctx, "root"); got != OutcomeIncomplete { + t.Fatalf("a cut root's pass answered %q, want %q", got, OutcomeIncomplete) + } + if root := store.Task("root"); root.Status == plandb.StatusFailed || root.Error != "" { + t.Fatalf("the caller's cut was written as the run failing: %s (%q)", root.Status, root.Error) + } + _ = store.Close() + } +} diff --git a/internal/run/delegate_child_test.go b/internal/run/delegate_child_test.go new file mode 100644 index 0000000000..ba06b8154b --- /dev/null +++ b/internal/run/delegate_child_test.go @@ -0,0 +1,155 @@ +package run_test + +// The program a delegated run's REAL child runs: this test binary, started by +// the worker exactly as it starts codeaf's own executable — the program's line +// after it, the model API's address and token in its environment and no key — +// and marked by [delegateChildEnv] so its TestMain runs the program instead of +// the suite. It is how the worker is tested against a process that is really +// another process, speaking the records on a real pipe and calling the real +// model API over a real socket, rather than against a script that only +// pretends to. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" +) + +// delegateChildEnv marks a process started as a delegate's child. +const delegateChildEnv = "RUN_TEST_DELEGATE_CHILD" + +// childProgram is the fake program: it says hello, asks the model API +// FAKE_CALLS questions — a step for each answer — and ends passing, or, with +// FAKE_ENDING=wait, waits to be told to stop and says it stopped. Its terminal +// claims a cost of its own that no bank may believe. +func childProgram() delegate.Delegate { + return delegate.Delegate{ + Name: "fake", Summary: "a fake program", Default: "run", Page: "fake", + Commands: []delegate.Command{{ + Name: "run", Usage: "[flags] -- <brief>", Summary: "does the whole task", + Bind: func(*flag.FlagSet) delegate.Body { return childBody }, + }}, + } +} + +func childBody(ctx context.Context, host delegate.Host, args []string) error { + if path := os.Getenv("FAKE_API_FILE"); path != "" { + api := host.Models() + _ = os.WriteFile(path, []byte(api.BaseURL+"\n"+api.Token+"\n"), 0o600) + } + if path := os.Getenv("FAKE_ENV"); path != "" { + _ = os.WriteFile(path, []byte(strings.Join(os.Environ(), "\n")), 0o600) + } + host.Hello([]string{"implement", "verify"}) + host.Stage(delegate.StageRecord{Stage: "implement", Status: "running"}) + calls, _ := strconv.Atoi(os.Getenv("FAKE_CALLS")) + for call := 1; call <= calls; call++ { + if ctx.Err() != nil { + break + } + reply, err := askModel(ctx, host.Models(), fmt.Sprintf("call %d: %s", call, strings.Join(args, " "))) + if err != nil { + host.Step(delegate.StepRecord{Command: "model: ask", Observation: "refused: " + err.Error()}) + if os.Getenv("FAKE_ENDING") == "crash" { + // senior-dev's own ending after a refusal: its sum of its + // answers' costs never reached its ceiling, so it cannot tell a + // ceiling from a broken road and says it crashed. + host.Terminal(delegate.Ending{Status: delegate.StatusCrashed, Message: "the model road refused a call"}) + return nil + } + continue + } + host.Step(delegate.StepRecord{Command: "model: ask", Observation: reply}) + } + if os.Getenv("FAKE_ENDING") == "wait" || ctx.Err() != nil { + <-ctx.Done() + host.Terminal(delegate.Ending{Status: delegate.StatusBudget, Message: "told to stop", CostUSD: 99}) + return nil + } + host.Stage(delegate.StageRecord{Stage: "verify", Status: "pass"}) + host.Terminal(delegate.Ending{Status: delegate.StatusPass, Message: "submitted and verified", Claim: "all green", Observed: "pass", CostUSD: 99}) + return nil +} + +// askModel is one call through the model API, the way any OpenAI client +// makes one: the route joined to the base, the bearer token, one question, +// the answer's words back — or the API's own refusal as the error. +func askModel(ctx context.Context, api delegate.ModelAPI, question string) (string, error) { + body, _ := json.Marshal(map[string]any{ + "model": "deepseek/deepseek-v4-flash-0731", + "messages": []map[string]string{{"role": "system", "content": "be brief"}, {"role": "user", "content": question}}, + }) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, modelapi.ChatURL(api.BaseURL), bytes.NewReader(body)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + api.Authorize(request) + response, err := http.DefaultClient.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + payload, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + var answer struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(payload), &answer); err != nil { + return "", fmt.Errorf("%d: %s", response.StatusCode, payload) + } + if answer.Error != nil { + return "", fmt.Errorf("%d: %s", answer.Error.Code, answer.Error.Message) + } + if len(answer.Choices) == 0 { + return "", errors.New("no choices") + } + return answer.Choices[0].Message.Content, nil +} + +// runAsDelegateChild runs the fake program when this binary was started as a +// delegate's child, and says whether it was. +func runAsDelegateChild() (int, bool) { + if os.Getenv(delegateChildEnv) != "1" { + return 0, false + } + program := childProgram() + if len(os.Args) < 2 || os.Args[1] != program.Name { + fmt.Fprintf(os.Stderr, "started as a delegate's child with %q\n", os.Args) + return 3, true + } + inv, err := delegate.Parse(program, os.Args[2:], os.Stdout) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return 1, true + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if delegate.RunChild(ctx, inv, os.Stdout) == delegate.StatusPass { + return 0, true + } + return 2, true +} diff --git a/internal/run/delegate_money_test.go b/internal/run/delegate_money_test.go new file mode 100644 index 0000000000..be5b9179e8 --- /dev/null +++ b/internal/run/delegate_money_test.go @@ -0,0 +1,228 @@ +//go:build !windows + +package run_test + +import ( + "context" + "math" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A PROGRAM'S CALLS ARE THE CONVERSATION'S CHILDREN, EACH ONCE. Every ledger +// row a delegated run writes names the conversation as its Root and its +// Session and the task as its Task, so the conversation's receipt counts the +// calls under the work it started — never under its own calls, never twice — +// and the spending page files them under the task. Every call also reaches +// the conversation's fold whole, tokens and model with its dollars. +func TestADelegatedRunsLedgerRowsNameTheConversationAndTheTask(t *testing.T) { + store := runOpenStore(t) + program, setup, _, ledger := realChild(t, 0.05, "2") + const conversation = "3d6ddfd172b2960f" + var mu sync.Mutex + var charges []session.RunCharge + setup.Conversation = conversation + setup.OnCharge = func(charge session.RunCharge) { + mu.Lock() + defer mu.Unlock() + charges = append(charges, charge) + } + worker := run.NewDelegateWorker(store, t.TempDir(), program, setup, 0, 0) + if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil { + t.Fatal(err) + } + rows := ledgerRows(t, ledger) + if len(rows) != 2 { + t.Fatalf("ledger rows = %+v", rows) + } + for _, row := range rows { + if row.Root != conversation || row.Session != conversation || row.Task != store.RootID() { + t.Fatalf("ledger row = %+v, want the conversation as Root and Session and the task as Task", row) + } + } + receipt := session.UsageTree(rows, conversation) + if receipt.Children != 0.1 || receipt.Direct != 0 || receipt.Calls != 2 { + t.Fatalf("the conversation's receipt = %+v, want the two calls once each, under the work it started", receipt) + } + subjects := session.UsageBySubject(rows) + if len(subjects) != 1 || subjects[0].Kind != session.SubjectTask || subjects[0].ID != store.RootID() || + subjects[0].Root != conversation || subjects[0].USD != 0.1 || subjects[0].Calls != 2 { + t.Fatalf("spend by subject = %+v, want one task row holding both calls", subjects) + } + mu.Lock() + defer mu.Unlock() + if len(charges) != 2 || charges[0].USD != 0.05 || charges[0].TokensIn != 100 || charges[0].Cached != 60 || + charges[0].Model != "deepseek/deepseek-v4-flash-0731" { + t.Fatalf("folded charges = %+v, want each call whole", charges) + } +} + +// owingFunnel answers every call at once without a usage block and owes its +// receipt, which it delivers after a delay — the provider's own order, owed +// before the fetch and answered after the sink has the money. +type owingFunnel struct { + late time.Duration + cost float64 +} + +func (f *owingFunnel) completerFor(string) session.Completer { return f } + +func (f *owingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + _ = option(&request) + } + done := provider.ReceiptPendingFrom(ctx)() + sink := provider.ReconcileSinkFrom(ctx) + go func() { + defer done() + time.Sleep(f.late) + sink(provider.Reconciled{Billed: provider.Billed{Model: request.Model, PromptTokens: 52139, CompletionTokens: 4895, Cost: f.cost}, Found: true}) + }() + return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "cut short"}}}, + FinishReason: "stop", + }}}, nil +} + +// THE CALL A RUN WAS CUT IN THE MIDDLE OF IS IN THE RUN'S BOOKS. Its price +// arrives by receipt after the program has exited — twenty seconds late on the +// stopped runs of 2026-09-23 — and the worker waits for it before it reports, +// so the run's total, the task's spend rows and the conversation's fold all +// hold it while the store is still open. Before the wait, the report read $0 +// and the store had no row. +func TestDelegateWorkerBanksTheReceiptThatArrivesAfterTheProgramExited(t *testing.T) { + store := runOpenStore(t) + program, setup, _, ledger := realChild(t, 0, "1") + owing := &owingFunnel{late: 400 * time.Millisecond, cost: 0.058188488} + setup.CompleterFor = owing.completerFor + var mu sync.Mutex + var folded float64 + var liveWhilePriced int + setup.OnCharge = func(charge session.RunCharge) { + mu.Lock() + defer mu.Unlock() + folded += charge.USD + // The receipt lands while the API's close waits for it: the program is + // gone, so its live step must be too. + liveWhilePriced = len(store.LiveSteps()) + } + worker := run.NewDelegateWorker(store, t.TempDir(), program, setup, 0, 0) + report, err := worker.Run(runContext(t), *store.Task(store.RootID())) + if err != nil { + stderr, _ := os.ReadFile(filepath.Join(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()), "delegate-stderr.log")) + t.Fatalf("run: %v\n%s", err, stderr) + } + if report.USD != 0.058188488 { + t.Fatalf("report usd = %v, want the late receipt's $0.058188488", report.USD) + } + if spend := store.SpendSummary().ByModel["delegate/fake"]; spend.USD != 0.058188488 || spend.Calls != 1 { + t.Fatalf("spend rows = %+v, want the late receipt's row", store.SpendSummary().ByModel) + } + mu.Lock() + if folded != 0.058188488 || liveWhilePriced != 0 { + mu.Unlock() + t.Fatalf("folded %v with %d live steps while the receipt was owed, want the late receipt and none", folded, liveWhilePriced) + } + mu.Unlock() + if rows := ledgerRows(t, ledger); len(rows) != 1 || !rows[0].Reconciled || rows[0].USD != 0.058188488 { + t.Fatalf("ledger rows = %+v", rows) + } +} + +// A SPEND ROW THE STORE REFUSES IS SAID, NOT DROPPED. A receipt so late it +// outlived the worker's wait reaches a store that has closed; the ledger has +// it, and the task's record folder says the task's rows do not. +func TestAChargeTheStoreRefusedIsWrittenDownInTheTasksRecord(t *testing.T) { + store := runOpenStore(t) + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()) + program, setup, _, ledger := realChild(t, 0.05, "1") + setup.CompleterFor = (&closingFunnel{store: store}).completerFor + worker := run.NewDelegateWorker(store, t.TempDir(), program, setup, 0, 0) + if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil { + t.Fatal(err) + } + stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log")) + if !strings.Contains(string(stderr), "codeaf: a charge of $0.050000 for a call on deepseek/deepseek-v4-flash-0731 is not in this task's spend rows") { + t.Fatalf("delegate-stderr.log:\n%s", stderr) + } + if rows := ledgerRows(t, ledger); len(rows) != 1 || rows[0].USD != 0.05 { + t.Fatalf("ledger rows = %+v", rows) + } +} + +// closingFunnel closes the run's store before it bills its one call, the way +// a store has closed under a receipt that arrived after the run was over. +type closingFunnel struct{ store *plandb.Store } + +func (f *closingFunnel) completerFor(string) session.Completer { return f } + +func (f *closingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + _ = option(&request) + } + _ = f.store.Close() + if sink := provider.BillingSinkFrom(ctx); sink != nil { + sink(provider.Billed{Model: request.Model, PromptTokens: 100, CompletionTokens: 10, Cost: 0.05}) + } + return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "ok"}}}, + FinishReason: "stop", + }}}, nil +} + +// A RUN HANDED NOTHING OF ITS LIMIT SPENDS NOTHING AND ENDS ON THE LIMIT. The +// conversation hands a run whose person's limit is already spent the smallest +// positive figure; the program's first call is refused before it is made, and +// the run ends on the person's cost limit rather than as work that broke. +func TestARunWhoseLimitIsAlreadySpentMakesNoCallAndEndsOnTheLimit(t *testing.T) { + store := runOpenStore(t) + m, setup, calling, _ := realChild(t, 0.139463, "3") + t.Setenv("FAKE_ENDING", "crash") + spent := run.Limits{CostUSD: math.SmallestNonzeroFloat64} + factory := run.DelegateFactory(store, t.TempDir(), m, setup, spent, nil) + outcome, summary := run.Start(runContext(t), run.Spec{ + Store: store, Workspace: t.TempDir(), Slots: 1, Limits: spent, Factory: factory, + }) + if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost { + t.Fatalf("outcome %q limit %q, want the cost limit", outcome, summary.Limit) + } + if summary.USD != 0 || len(calling.seen()) != 0 { + t.Fatalf("usd %v after %d funnel calls, want nothing made and nothing spent", summary.USD, len(calling.seen())) + } +} + +// AND NO WORKER OF ANY KIND IS SEATED on a run handed nothing of its limit: a +// worker that meters only its own total would otherwise make the one paid call +// that tells the loop the limit is gone. +func TestARunWhoseLimitIsAlreadySpentSeatsNoWorker(t *testing.T) { + store := runOpenStore(t) + var seated int + var mu sync.Mutex + outcome, summary := run.Start(runContext(t), run.Spec{ + Store: store, Workspace: t.TempDir(), Slots: 1, + Limits: run.Limits{CostUSD: math.SmallestNonzeroFloat64}, + Factory: func(plandb.Task) run.Worker { + mu.Lock() + seated++ + mu.Unlock() + return nil + }, + }) + mu.Lock() + defer mu.Unlock() + if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost || seated != 0 { + t.Fatalf("outcome %q limit %q with %d workers seated, want the cost limit and none", outcome, summary.Limit, seated) + } +} diff --git a/internal/run/delegateworker.go b/internal/run/delegateworker.go new file mode 100644 index 0000000000..1a1144d0d3 --- /dev/null +++ b/internal/run/delegateworker.go @@ -0,0 +1,655 @@ +package run + +// A PROGRAM CODEAF CARRIES IS ONE MORE WORKER KIND. A program that does a +// whole task on its own — senior-dev first (internal/delegate, +// docs/design/delegate/PROTOCOL.md) — is seated on a task exactly where the +// bash worker is: it reads the same context for its limits, banks its dollars +// into the same account, publishes the same live step, appends to the same +// trajectory, and comes home with the same Report. Nothing above the factory +// knows which kind ran. +// +// What differs is inside: there is no model turn here. The program runs as a +// child process of codeaf's own executable (`codeaf <name> run --json …`) in +// the run's folder (for a program that edits files, the person's folder itself, +// readied by internal/session's PrepareProgramFolder), its stdout is the +// records, and its terminal record is the ending. Every stage, step and ending +// is written to the task's action log (delegate.ActionsFile) the moment it is +// received, which is what the task page draws the program's work from; its +// `step` records are also what enter the trajectory, so the task page's step +// count is what the program said it did and not how many phases it announced; +// and the live step names the step of the program's process it is in. +// +// ── ITS ONLY ROAD TO A MODEL IS THIS RUN'S MODEL API ──────────────────────── +// +// Before the program starts, the worker opens the run's model API +// (internal/provider/modelapi) on this machine's loopback and hands the child +// its address and token and nothing else (delegate.ChildEnv): no key reaches +// the program. Every call it makes goes through the conversation's own +// completer, is refused at the run's dollar ceiling before it is made, and is +// written to the task's conversation log as one turn. The API is closed the +// moment the program has exited, and the token dies with it. +// +// ── MONEY IS METERED BY THE API, NEVER REPORTED BY THE PROGRAM ────────────── +// +// Each call's price reaches four books as it is metered ([delegateMeter]): +// the conversation's own, which folds the call whole — tokens, model and +// dollars — without writing a ledger row of its own (internal/session's +// beltFold); the run's live bank, which the supervisor holds to the ceiling; +// the task's spend rows, one per call, which the task page draws; and this +// machine's spending ledger, one row per call, exactly once, filed under the +// conversation and the task. +// The program's terminal record may still carry its own reading of what it +// spent; that figure is kept on the record and never banked. + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + lanes "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/session" +) + +// delegateStderrName is the file a delegate's stderr is kept in, in the task's +// own record folder beside the trajectory, because stderr is where a program +// says why it could not start and a person opening the task should find it. +const delegateStderrName = "delegate-stderr.log" + +// DelegateWorker runs one program as the worker of one task. +type DelegateWorker struct { + store *plandb.Store + workspace string + program delegate.Delegate + setup DelegateSetup + // cost and elapsed are the run's ceilings, handed to the program on its + // command line so it cuts itself before the run has to. They are the + // factory's copy of the run's Limits: the supervisor enforces the same two + // from outside whatever the program does with them, and the model API + // refuses a call made past the dollar one. + cost float64 + elapsed time.Duration +} + +// DelegateSetup is how a delegated run starts its program's process and serves +// it models. +type DelegateSetup struct { + // Exe is codeaf's own executable, which the program runs as. Empty is this + // process's own; a test names a script that speaks the records. + Exe string + // Grace overrides the launch's SIGTERM grace, for a test. + Grace time.Duration + // CompleterFor answers the funnel a call on a model goes out through: the + // conversation's own completer (session.RunSpec.CompleterFor), so a + // program's calls take the road the conversation's own do. Nil is a run + // with no model road, whose API answers every call with that sentence. + CompleterFor func(model string) session.Completer + // Serves answers whether this conversation's services can take a call on a + // model (session.RunSpec.Serves); nil answers yes for every model. + Serves func(model string) bool + // Seat is the run's own work seat ([WorkSeat]): the model a call is + // answered on when the one the program asked for cannot be reached here. + Seat string + // Ledger is the spending ledger the calls are written to. Empty is this + // machine's own (session.UsageLedgerPath); a test names a file of its own. + Ledger string + // Keepalive overrides the model API's keepalive interval, for a test. + Keepalive time.Duration + // PlainFolder says the program works in its folder without git + // (session.RunSpec.PlainFolder), so the program's line carries its own + // flags for that (delegate.Delegate.PlainFolder). + PlainFolder bool + // Crew is the conversation's crew (session.RunSpec.Crew), which the + // program's line carries in its own flags (delegate.Delegate.CrewFlags) so + // it works on the models the person chose. Zero leaves it to its own. + Crew delegate.Crew + // Conversation is the id of the conversation the run belongs to + // (session.RunSpec.Conversation), which every ledger row the program's + // calls write names as its Root and its Session, beside the task's id, so + // the conversation's spend and the spending page can say whose money it + // was. Empty leaves the rows naming no conversation. + Conversation string + // OnCharge is told every priced call as it is metered + // (session.RunSpec.OnCharge), for the conversation to fold the call's + // tokens, model and dollars into its own books. Nil tells nobody. + OnCharge func(session.RunCharge) +} + +// NewDelegateWorker builds the worker. cost and elapsed are the run's +// ceilings, zero for none. +func NewDelegateWorker(store *plandb.Store, workspace string, program delegate.Delegate, setup DelegateSetup, cost float64, elapsed time.Duration) *DelegateWorker { + return &DelegateWorker{store: store, workspace: workspace, program: program, setup: setup, cost: cost, elapsed: elapsed} +} + +// DelegateFactory is the run's WorkerFactory for a delegated run: the root task +// is the program's, and every other task the run seats — the review round's +// check, and nothing else, because a delegated run is a run of one task — +// falls to the factory it wraps, which is the crew's. +func DelegateFactory(store *plandb.Store, workspace string, program delegate.Delegate, setup DelegateSetup, limits Limits, rest WorkerFactory) WorkerFactory { + return func(task plandb.Task) Worker { + if task.ID == store.RootID() { + return NewDelegateWorker(store, workspace, program, setup, limits.CostUSD, limits.Elapsed) + } + if rest == nil { + return nil + } + return rest(task) + } +} + +// delegateSink is the delegate.Sink one run of the worker hands the launch: it +// turns the stream into the store's live step, the trajectory's step lines and +// the program record the task page reads. Its methods run on the reader's +// goroutine and none of them waits on anything but the store's own lock. +type delegateSink struct { + worker *DelegateWorker + taskID string + storeDir string + taskDir string + name string + steps int + lastErr error + terminal *delegate.Terminal + // stop ends the program early, and mismatch says why: the child spoke + // another protocol than this build's, which means codeaf was rebuilt while + // this conversation's engine was running and its child is the new build. + stop context.CancelFunc + mismatch string + // record is the program record as this run has written it so far: the + // name, the ceiling and the instant the process was started, and the + // stages once the hello has named them. It is kept here because the record + // is written whole, twice — at the hello and when the process is gone — and + // the second write must carry what the first one said. + record delegate.ProgramRecord + // reader is the program's own reader of its action log + // (delegate.Delegate.Reader), told every record in the order it arrives, so + // the live step can name the step of the program's process the record + // served; stepped is whether any record has named one yet, and step the + // word the live step reads now, which a record naming the same step again + // does not write twice. + reader delegate.ActionReader + stepped bool + step string +} + +// remember writes one received record to the task's action log, stamped with +// the moment it arrived, and moves the live step to the step it served. +// +// THE LOG IS A RECORD, SO A DISK THAT REFUSES IT COSTS THE PAGE AND NEVER THE +// RUN, as the program record's does; and a child of ANOTHER BUILD is not this +// run's program, so nothing it says is written down as the program's. +func (s *delegateSink) remember(action delegate.Action) { + if s.mismatch != "" { + return + } + if strings.TrimSpace(s.taskDir) != "" { + _ = delegate.AppendAction(s.taskDir, action) + } + s.live(action) +} + +// live moves the live step for one received record. +// +// THE LIVE STEP IS THE STEP OF THE PROGRAM'S PROCESS IT IS IN, numbered after +// the last step recorded, so the row reads "senior-dev: explore" while the +// program explores and the count on the row stays the steps'. The step is the +// one the program's own reader of its log names for the record +// (delegate.Delegate.Present) — a stage can name one as well as a step — and a +// record that names none leaves the word standing. +// +// BEFORE ANY RECORD HAS NAMED A STEP, A STAGE IS SHOWN IN THE PROGRAM'S WORDS +// FOR A PERSON, NOT ITS STAGE'S NAME. A program that says what a person should +// read for its stages (delegate.Delegate's StageWords) is shown that word and +// no status beside it — a status is its machinery too — and a stage it gave no +// word keeps the word already shown. Only a program that said nothing is shown +// its own names, as it spelled them. +func (s *delegateSink) live(action delegate.Action) { + if s.reader == nil { + s.reader = s.worker.program.Reader() + } + if shown, ok := s.reader(action); ok && strings.TrimSpace(shown.Step) != "" { + s.stepped = true + if word := strings.TrimSpace(shown.Step); word != s.step { + s.step = word + _ = s.worker.store.SetLive(s.taskID, s.steps+1, s.name+": "+word) + } + return + } + if action.Kind != delegate.ActionStage || s.stepped { + return + } + label := s.name + ": " + action.Stage + if words := s.worker.program.StageWords; words != nil { + word := strings.TrimSpace(words[action.Stage]) + if word == "" { + return + } + label = s.name + ": " + word + } else if action.Status != "" { + label += " · " + action.Status + } + _ = s.worker.store.SetLive(s.taskID, s.steps+1, label) +} + +func (s *delegateSink) Hello(h delegate.Hello) { + if h.Protocol == delegate.ProtocolVersion { + // THE PAGE LEARNS WHOSE CONVERSATION IT IS DRAWING, the stages the + // program will move through and the ceiling its spend is read against, + // the moment the program says hello — and keeps knowing after the run. + // It is a record, so a disk that refuses it costs the page its heading + // and never the run. + s.record.Stages = h.Stages + _ = delegate.WriteProgram(s.taskDir, s.record) + return + } + // TWO BUILDS, ONE RUN. Nothing a newer child writes can be trusted to mean + // what this parent reads it as, so the run is stopped before it spends and + // the person is told the one thing that fixes it. + s.mismatch = fmt.Sprintf("codeaf was rebuilt while this conversation was open (its %s speaks version %d of the records, this one reads %d); restart codeaf to run %s", + s.name, h.Protocol, delegate.ProtocolVersion, s.name) + if s.stop != nil { + s.stop() + } +} + +func (s *delegateSink) Stage(record delegate.StageRecord) { + s.remember(delegate.StageAction(time.Now(), record)) +} + +func (s *delegateSink) Step(record delegate.StepRecord) { + s.steps++ + if err := appendTrajectory(s.storeDir, s.taskID, Step{ + Kind: trajectoryStepKind, + Step: s.steps, + Command: record.Command, + Observation: observationHead(record.Observation), + }); err != nil && s.lastErr == nil { + s.lastErr = err + } + s.remember(delegate.StepAction(time.Now(), record)) +} + +func (s *delegateSink) Terminal(t delegate.Terminal) { + s.terminal = &t + s.remember(delegate.EndAction(time.Now(), t)) +} + +// delegateMeter is where the run's model API tells each charge as it is +// metered: the conversation's books, the run's live bank, the task's spend +// row, and the machine's spending ledger. It is called one charge at a time, +// in order. +type delegateMeter struct { + ctx context.Context + store *plandb.Store + taskID string + taskDir string + role string + name string + workspace string + ledger string + // conversation is the conversation the run belongs to, stamped on every + // ledger row; onCharge folds each call into that conversation's books. + conversation string + onCharge func(session.RunCharge) +} + +// bank books one charge in all four places. +// +// THE CONVERSATION HEARS FIRST, BEFORE THE RUN'S BANK MOVES. The conversation +// folds each call whole — its tokens, its model, its dollars — as it is +// metered, and also folds whatever the run's total says it has not yet heard +// of (internal/session's beltFold); telling it the call before the total that +// holds the call is what keeps one dollar from being folded twice. +// +// THE LEDGER ROW IS WRITTEN HERE AND ONLY HERE. The conversation's fold writes +// no ledger row, exactly as it does for a bash worker whose own session wrote +// the rows — so each of the program's calls is on this machine's spending +// ledger once. The row is the worker seat's, because the program sits where +// the run's worker would, and it names whose work it was the way a task +// node's row does ([session.UsageLine.Root]): the conversation as its Root and +// its Session, the task as its Task. A row that named none of them was money +// the conversation's receipt and the spending page could not place — 94.9% of +// one day's spend on 2026-09-23 was senior-dev calls filed under nobody. +func (m *delegateMeter) bank(charge modelapi.Charge) { + if m.onCharge != nil { + m.onCharge(session.RunCharge{ + Model: charge.Model, TokensIn: charge.TokensIn, TokensOut: charge.TokensOut, + Cached: charge.Cached, USD: charge.CostUSD, + }) + } + bankSpend(m.ctx, charge.Spent) + if err := m.store.AddSpend(m.taskID, m.name, m.role, charge.CostUSD, charge.TokensIn, charge.TokensOut); err != nil { + m.unstored(charge, err) + } + line := m.stamp(session.UsageLine{ + Model: charge.Model, Calls: 1, Input: charge.TokensIn, Output: charge.TokensOut, USD: charge.CostUSD, + Reconciled: charge.Late, + }) + session.RecordUsage(m.ledgerPath(), session.TagUsage(line, roles.RoleWorker, session.SeatWorker)) +} + +// unbilled keeps a call nobody could price on the ledger as the marker it is, +// with no invented money, filed under the same work as every priced row. +func (m *delegateMeter) unbilled(model string) { + session.RecordUnbilledCall(m.ledgerPath(), session.TagUsage(m.stamp(session.UsageLine{Model: model}), roles.RoleWorker, session.SeatWorker)) +} + +// stamp names whose work a ledger row is: the workspace it was spent against, +// the task, and the conversation the task belongs to. +func (m *delegateMeter) stamp(line session.UsageLine) session.UsageLine { + line.Workspace = m.workspace + line.Task = strings.TrimPrefix(strings.TrimSpace(m.taskID), "t-") + if conversation := strings.TrimSpace(m.conversation); conversation != "" { + line.Root, line.Session = conversation, conversation + } + return line +} + +// unstored says, in the task's own record folder, that a charge could not be +// written to the task's spend rows. +// +// A SPEND ROW THE STORE REFUSED IS NOT DROPPED IN SILENCE. The machine's +// ledger and the conversation's books already hold the charge, but the task +// page's figure is read from these rows, so a refusal makes the page read +// short; the line in delegate-stderr.log is where a person asking why finds +// the answer. It is written only after the model API has closed — a receipt +// that outlived even its wait, arriving after the program's process is gone — +// or on a store that failed outright, so it never interleaves with the +// program's own stderr. +func (m *delegateMeter) unstored(charge modelapi.Charge, err error) { + if strings.TrimSpace(m.taskDir) == "" { + return + } + file, openErr := os.OpenFile(filepath.Join(m.taskDir, delegateStderrName), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if openErr != nil { + return + } + defer file.Close() + model := strings.TrimSpace(charge.Model) + if model == "" { + model = "a model" + } + _, _ = fmt.Fprintf(file, "codeaf: a charge of $%.6f for a call on %s is not in this task's spend rows, because the task's record refused it (%v); the machine's spending ledger has it\n", + charge.CostUSD, model, err) +} + +func (m *delegateMeter) ledgerPath() string { + if strings.TrimSpace(m.ledger) != "" { + return m.ledger + } + return session.UsageLedgerPath() +} + +// Run starts the program and reads it to its ending. The Report's Result is +// the ending in words a person reads; Steps is what the program said it did; +// USD is what the model API metered, and nothing the program said about it. +func (w *DelegateWorker) Run(ctx context.Context, task plandb.Task) (Report, error) { + storeDir := filepath.Dir(w.store.Path()) + taskDir := plandb.TaskDir(storeDir, task.ID) + if err := appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryBeginKind, ExitsRecorded: true}); err != nil { + return Report{}, fmt.Errorf("stamp the trajectory opening line: %w", err) + } + // THE PROGRAM'S OWN CLOCK: the instant its process was started and the + // instant it was gone, both zero on every road out of here that never + // started one. The ending line carries them, so the trajectory holds the + // same pair the program record does. + var started, ended time.Time + end := func(steps int, reason, result string) { + _ = appendTrajectory(storeDir, task.ID, Step{ + Kind: trajectoryEndKind, ExitsRecorded: true, Steps: steps, Result: result, Reason: reason, + StartedAt: started, EndedAt: ended, + }) + } + exe := w.setup.Exe + if exe == "" { + self, err := os.Executable() + if err != nil { + reason := fmt.Sprintf("find codeaf's own executable to run %s: %v", w.program.Name, err) + end(0, reason, "") + return Report{}, errors.New(reason) + } + exe = self + } + role, err := w.store.RoleOf(task.ID) + if err != nil { + role = plandb.RoleWork + } + meter := &delegateMeter{ + ctx: ctx, store: w.store, taskID: task.ID, taskDir: taskDir, role: role, + // The spend row's "model" column carries the program's name, because + // that is what spent the money; the ledger row names the model that + // answered. + name: "delegate/" + w.program.Name, + workspace: w.workspace, ledger: w.setup.Ledger, + conversation: w.setup.Conversation, onCharge: w.setup.OnCharge, + } + api, err := modelapi.Open(modelapi.Config{ + TaskDir: taskDir, + CompleterFor: w.completerFor(), + Serves: w.setup.Serves, + Seat: w.setup.Seat, + Ceiling: w.cost, + Bank: meter.bank, + Unbilled: meter.unbilled, + // THE LIVE STEP GOES WITH THE PROCESS, EVEN WHILE ITS LAST PRICE IS + // OWED. The API's close waits for a cut call's receipt after the program + // has exited, and a row reading "implement · running" through that wait + // would claim a present that is over. + Settling: func(int) { _ = w.store.ClearLive(task.ID) }, + // NOBODY IS READING THE PROGRAM'S CALLS AS THEY ARRIVE: it is a task's + // worker, and the person is in their conversation or away from it. + Role: lanes.RoleLeafUnattended, + Node: w.program.Name, + Keepalive: w.setup.Keepalive, + }) + if err != nil { + reason := fmt.Sprintf("open %s's model API: %v", w.program.Name, err) + end(0, reason, "") + return Report{}, errors.New(reason) + } + // THE TOKEN DIES WITH THE RUN, on every path out of this function; the + // ordinary path closes it the moment the program has exited, below. + defer func() { _ = api.Close() }() + + launchCtx, stop := context.WithCancel(ctx) + defer stop() + sink := &delegateSink{worker: w, taskID: task.ID, storeDir: storeDir, taskDir: taskDir, name: w.program.Name, stop: stop, + record: delegate.ProgramRecord{Name: w.program.Name, CeilingUSD: w.cost}} + brief := strings.TrimSpace(task.Description) + if brief == "" { + brief = strings.TrimSpace(task.Title) + } + started = time.Now() + sink.record.StartedAt = started + result, err := delegate.Run(launchCtx, delegate.Launch{ + Name: w.program.Name, + Bin: exe, + Args: delegate.ChildArgs(w.program, w.workspace, brief, delegate.Ceilings{CostUSD: w.cost, Hours: w.elapsed.Hours()}, + delegate.RunFacts{Plain: w.setup.PlainFolder, Crew: w.setup.Crew}), + // NO KEY REACHES THE PROGRAM (delegate.ChildEnv): the API's address and + // token are the whole of what it is given. + Env: delegate.ChildEnv(api.API()), + Dir: w.workspace, + StderrPath: filepath.Join(taskDir, delegateStderrName), + Grace: w.setup.Grace, + }, sink) + // THE INSTANT THE PROCESS WAS GONE, and not the instant its stdout drained + // ([delegate.Result.ExitedAt] says why; a shell run reads it the same way). + ended = result.ExitedAt(started, time.Now()) + // THE RECORD IS WRITTEN AGAIN NOW, WHOLE, AND WHETHER OR NOT A HELLO CAME. A + // program that died before it said hello is still a program this run + // started, and its page and its row need its times as much as a finished + // one's do. A child of ANOTHER BUILD is the one exception: it was never this + // run's program, and it is not written down as one. + if sink.mismatch == "" { + sink.record.EndedAt = ended + _ = delegate.WriteProgram(taskDir, sink.record) + } + // The program has exited: its API goes with it, so nothing it left behind + // can spend, and the calls that were still running write their last turn. + // THE CLOSE WAITS FOR THE RECEIPTS STILL OWED (modelapi's Server.Close): the + // call a stop or the ceiling cut in the middle is priced about twenty + // seconds later, and it has to reach the task's spend rows, the run's total + // read just below and the conversation's books while all three are open. + _ = api.Close() + // THE LIVE STEP GOES WITH THE PROCESS, whatever the ending: a row that still + // read "implement · running" after the program was gone would be a claim + // about a present that is over. + _ = w.store.ClearLive(task.ID) + + report := Report{Steps: sink.steps, USD: api.Spent()} + if sink.lastErr != nil { + end(sink.steps, "the record failed: "+sink.lastErr.Error(), "") + return report, sink.lastErr + } + if sink.mismatch != "" && ctx.Err() == nil { + end(sink.steps, sink.mismatch, "") + return report, errors.New(sink.mismatch) + } + if result.Stopped { + // THE RUN'S OWN ENDING CUT THIS PROGRAM: the context is what ended it, so + // the error is the context's own and the supervisor records the cut. A + // terminal the program wrote inside the grace still names the reason. + reason := "stopped by the run" + if t := result.Reading.Terminal; t != nil && t.Message != "" { + reason += ": " + w.program.Name + " said " + t.Message + } + end(sink.steps, reason, "") + return report, err + } + // THE CEILING, NOT A CRASH. A program the model API refused at the run's + // dollar ceiling ends however it ends — senior-dev, whose own sum of its + // answers' costs never reached the figure it was given, ends as `crashed` — + // but what stopped it was the limit a person set, and the run says so. The + // supervisor's own ledger has reached the same ceiling, so the run ends on + // its cost limit; this is the worker's half, the words the task keeps. + if t := result.Reading.Terminal; api.RefusedAtCeiling() > 0 && (t == nil || t.Status != delegate.StatusPass) { + reason := fmt.Sprintf("%s reached the run's dollar ceiling of $%.2f", w.program.Name, w.cost) + if t != nil { + report.Result = delegateResult(w.program, *t) + if message := strings.TrimSpace(t.Message); message != "" { + reason += ": " + w.program.Name + " said " + message + } + } + end(sink.steps, reason, report.Result) + return report, errors.New(reason) + } + if errors.Is(err, delegate.ErrNoTerminal) { + reason := fmt.Sprintf("%s exited %d without a terminal record", w.program.Name, result.ExitCode) + if result.Reading.LastStage != "" { + reason += "; its last stage was " + result.Reading.LastStage + } + end(sink.steps, reason, "") + return report, errors.New(reason) + } + if err != nil { + end(sink.steps, err.Error(), "") + return report, err + } + t := *result.Reading.Terminal + report.Result = delegateResult(w.program, t) + var reason string + switch t.Status { + case delegate.StatusPass: + report.Verdict = t.Verdict() + end(sink.steps, "finished: "+t.Message, report.Result) + return report, nil + case delegate.StatusBudget: + reason = w.program.Name + " stopped on its own ceiling: " + t.Message + case delegate.StatusCrashed: + reason = w.program.Name + " crashed: " + t.Message + default: + // `fail`, and any word this build does not know, is work that does not + // stand: the run reads it as incomplete. + reason = w.program.Name + " did not finish: " + t.Message + } + end(sink.steps, reason, report.Result) + return report, &ProgramEndedError{Status: t.Status, Reason: reason, Result: report.Result} +} + +// ProgramEndedError is a program's own ending when it did not finish: the +// status word its terminal record carried, the sentence the task keeps, and +// its account in full. The run carries it to the session whole +// ([Summary.Program]), which draws the row from the fact rather than from the +// generic "ran and did not finish" — the row that said only that, over an hour +// of work that had submitted a change and said exactly why it would not +// stand, told a person nothing they could act on. +type ProgramEndedError struct { + // Status is the terminal record's word: fail, budget, crashed, or one + // this build does not know. + Status string + // Reason is the one sentence: `senior-dev did not finish: …`. + Reason string + // Result is the program's account: its message, what its model claimed + // and what it observed ([delegateResult]). + Result string +} + +func (e *ProgramEndedError) Error() string { return e.Reason } + +// completerFor is the setup's completer factory in the model API's own +// words, each completer marked so a call keeps the program's own cache +// lineage (session.WithOwnCacheLineage): a program's conversations are its +// own, and the conversation's key stamped over them would put every one of +// them on the conversation's warm instance. +func (w *DelegateWorker) completerFor() func(model string) modelapi.Completer { + if w.setup.CompleterFor == nil { + return nil + } + return func(model string) modelapi.Completer { + completer := w.setup.CompleterFor(model) + if completer == nil { + return nil + } + return ownLineage{completer} + } +} + +// ownLineage is a completer whose calls keep the cache key already on their +// context. +type ownLineage struct{ inner session.Completer } + +func (c ownLineage) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + return c.inner.CompleteWithMessages(session.WithOwnCacheLineage(ctx), messages, options...) +} + +// delegateResult is the ending in words: the deliverable for a program that +// lands text, and for one that lands a tree the program's message with the +// claim and the observation as two sentences, kept apart because the +// program's model and the program itself are two witnesses. +func delegateResult(m delegate.Delegate, t delegate.Terminal) string { + if !m.LandsTree() { + if deliverable := t.Deliverable(); deliverable != "" { + return deliverable + } + } + parts := []string{strings.TrimSpace(t.Message)} + if claim := t.Claim(); claim != "" { + parts = append(parts, m.Name+"'s model said: "+claim) + } + if observed := t.Observed(); observed != "" { + parts = append(parts, m.Name+" observed: "+observed) + } + if reason := t.Reason(); reason != "" && reason != t.Message { + parts = append(parts, reason) + } + return strings.Join(nonEmpty(parts), ". ") +} + +func nonEmpty(parts []string) []string { + out := parts[:0] + for _, p := range parts { + if strings.TrimSpace(p) != "" { + out = append(out, strings.TrimRight(strings.TrimSpace(p), ".")) + } + } + return out +} diff --git a/internal/run/delegateworker_clock_test.go b/internal/run/delegateworker_clock_test.go new file mode 100644 index 0000000000..a8494f352f --- /dev/null +++ b/internal/run/delegateworker_clock_test.go @@ -0,0 +1,73 @@ +//go:build !windows + +package run_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/run" +) + +// THE PROGRAM'S OWN CLOCK IS WRITTEN DOWN. A run's wall time had nothing to +// stand on: the store is seeded before the copy is cut and the row settles +// after the landing, so every surface reconstructed a span from a different +// pair of instants and none of them was the program's. The worker now stamps +// the instant it started the process and the instant the process was gone on +// the program record — keeping the hello's name, stages and ceiling — and on +// the trajectory's ending line. +func TestDelegateWorkerStampsTheProgramsOwnClock(t *testing.T) { + store := runOpenStore(t) + storeDir := filepath.Dir(store.Path()) + m, setup := fakeDelegate(t, "sleep 0.2\n"+passLine("tests are green")) + worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 2.5, 0) + before := time.Now() + if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err != nil { + t.Fatalf("the delegate's run failed: %v", err) + } + after := time.Now() + + record, ok := delegate.ReadProgram(plandb.TaskDir(storeDir, store.RootID())) + if !ok || record.Name != "fake" || strings.Join(record.Stages, ",") != "implement,verify" || record.CeilingUSD != 2.5 { + t.Fatalf("program record = %+v %v, want the hello's name and stages and the run's ceiling kept", record, ok) + } + if record.StartedAt.IsZero() || record.EndedAt.IsZero() { + t.Fatalf("program record carries no clock: started %v ended %v", record.StartedAt, record.EndedAt) + } + if record.StartedAt.Before(before) || record.EndedAt.After(after) || record.EndedAt.Sub(record.StartedAt) < 200*time.Millisecond { + t.Fatalf("program clock %v → %v is not the process's life inside the run's %v → %v", record.StartedAt, record.EndedAt, before, after) + } + end := endLine(t, rawTrajectory(t, storeDir, store.RootID())) + if !end.StartedAt.Equal(record.StartedAt) || !end.EndedAt.Equal(record.EndedAt) { + t.Fatalf("the ending line's clock %v → %v is not the record's %v → %v", end.StartedAt, end.EndedAt, record.StartedAt, record.EndedAt) + } +} + +// A PROGRAM THAT DIED BEFORE ITS HELLO STILL HAS ITS TIMES. The record used to +// be written at the hello and nowhere else, so a program that fell over on its +// first line left no record at all, and its page and its row had nothing to +// measure it by. +func TestDelegateWorkerRecordsTheClockOfAProgramThatNeverSaidHello(t *testing.T) { + store := runOpenStore(t) + storeDir := filepath.Dir(store.Path()) + script := filepath.Join(t.TempDir(), "dies.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho 'no such flag' >&2\nexit 3\n"), 0o755); err != nil { + t.Fatal(err) + } + worker := run.NewDelegateWorker(store, t.TempDir(), delegate.Delegate{Name: "fake", Default: "run"}, run.DelegateSetup{Exe: script}, 0, 0) + if _, err := worker.Run(runContext(t), *store.Task(store.RootID())); err == nil || !strings.Contains(err.Error(), "fake exited 3 without a terminal record") { + t.Fatalf("err = %v, want the exit named", err) + } + record, ok := delegate.ReadProgram(plandb.TaskDir(storeDir, store.RootID())) + if !ok || record.Name != "fake" || len(record.Stages) != 0 { + t.Fatalf("program record = %+v %v, want the program named with no stages it never said", record, ok) + } + if record.StartedAt.IsZero() || record.EndedAt.IsZero() || record.EndedAt.Before(record.StartedAt) { + t.Fatalf("program clock = %v → %v, want both instants in order", record.StartedAt, record.EndedAt) + } +} diff --git a/internal/run/delegateworker_test.go b/internal/run/delegateworker_test.go new file mode 100644 index 0000000000..875f497e22 --- /dev/null +++ b/internal/run/delegateworker_test.go @@ -0,0 +1,542 @@ +//go:build !windows + +package run_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/run" + "github.com/Agent-Field/codeaf/internal/session" +) + +// fakeDelegate writes a shell program that stands in for codeaf running a +// program — a hello, a stage, a v1 spend line (which nothing reads any more), +// two steps, then body — and answers the program's definition and the setup +// that starts the script in codeaf's place. +func fakeDelegate(t *testing.T, body string) (delegate.Delegate, run.DelegateSetup) { + t.Helper() + script := filepath.Join(t.TempDir(), "fake.sh") + program := "#!/bin/sh\n" + strings.Join([]string{ + `if [ -n "$FAKE_ARGS" ]; then printf '%s\n' "$@" > "$FAKE_ARGS"; fi`, + `echo '{"type":"hello","protocol":2,"delegate":"fake","stages":["implement","verify"]}'`, + `echo '{"type":"stage","stage":"implement","status":"running"}'`, + `echo '{"type":"spend","cost_usd":0.05}'`, + `echo '{"type":"step","command":"bash: go test ./...","observation":"ok"}'`, + `echo '{"type":"step","command":"edit: a.go"}'`, + body, + }, "\n") + "\n" + if err := os.WriteFile(script, []byte(program), 0o755); err != nil { + t.Fatal(err) + } + return delegate.Delegate{Name: "fake", Summary: "a fake program", Default: "run"}, run.DelegateSetup{Exe: script} +} + +func passLine(claim string) string { + return `echo '{"type":"terminal","status":"pass","message":"submitted and verified","data":{"cost_usd":0.12,"submission_reason":"` + claim + `","status":"pass"}}'` +} + +// funnel is the conversation's completer as a test writes it: every call is +// answered with words and billed at cost, the way the provider's decode bills +// one, and every model it was handed is kept. +type funnel struct { + mu sync.Mutex + cost float64 + models []string +} + +func (f *funnel) completerFor(string) session.Completer { return f } + +func (f *funnel) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + var request ai.Request + for _, option := range options { + _ = option(&request) + } + f.mu.Lock() + f.models = append(f.models, request.Model) + f.mu.Unlock() + if sink := provider.BillingSinkFrom(ctx); sink != nil { + sink(provider.Billed{Model: request.Model, PromptTokens: 100, CompletionTokens: 10, CachedTokens: 60, Cost: f.cost}) + } + question := "" + if last := messages[len(messages)-1]; len(last.Content) > 0 { + question = last.Content[0].Text + } + return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ + Message: ai.Message{Role: "assistant", Content: []ai.ContentPart{{Type: "text", Text: "answered " + question}}}, + FinishReason: "stop", + }}}, nil +} + +func (f *funnel) seen() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.models...) +} + +// realChild is the setup that starts THIS test binary as the program's process +// (delegate_child_test.go), with the model API served over a funnel costing +// cost a call and a spending ledger of the test's own. +func realChild(t *testing.T, cost float64, calls string) (delegate.Delegate, run.DelegateSetup, *funnel, string) { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(delegateChildEnv, "1") + t.Setenv("FAKE_CALLS", calls) + ledger := filepath.Join(t.TempDir(), "usage.jsonl") + calling := &funnel{cost: cost} + return childProgram(), run.DelegateSetup{Exe: self, CompleterFor: calling.completerFor, Ledger: ledger, Grace: 5 * time.Second}, calling, ledger +} + +// ledgerRows is the spending ledger's rows, once the writer has drained. +func ledgerRows(t *testing.T, path string) []session.UsageLine { + t.Helper() + session.FlushUsage() + file, err := os.Open(path) + if err != nil { + t.Fatalf("the spending ledger was never written: %v", err) + } + defer file.Close() + var rows []session.UsageLine + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var row session.UsageLine + if err := json.Unmarshal(scanner.Bytes(), &row); err != nil { + t.Fatalf("a ledger row does not parse: %s", scanner.Bytes()) + } + rows = append(rows, row) + } + return rows +} + +// THE WHOLE ROAD, WITH A REAL CHILD: the worker opens the run's model API, +// starts the program as a process of its own with the API's address and token +// and no key, the program asks it two questions over a real socket, and every +// call is metered into all three books as it happens — the run's bank, the +// task's spend rows and the machine's ledger, once each — and written down as +// a turn of the program's conversation. The program's own claim about what it +// spent is never believed, and the token is dead the moment the program is. +func TestDelegateWorkerServesItsChildTheModelAPIAndMetersEveryCall(t *testing.T) { + store := runOpenStore(t) + storeDir := filepath.Dir(store.Path()) + taskDir := plandb.TaskDir(storeDir, store.RootID()) + program, setup, calling, ledger := realChild(t, 0.05, "2") + apiFile := filepath.Join(t.TempDir(), "api") + envFile := filepath.Join(t.TempDir(), "env") + t.Setenv("FAKE_API_FILE", apiFile) + t.Setenv("FAKE_ENV", envFile) + t.Setenv("OPENROUTER_API_KEY", "sk-or-v1-the-parents-own-key") + workspace := t.TempDir() + worker := run.NewDelegateWorker(store, workspace, program, setup, 2.5, 0) + + var mu sync.Mutex + var banked []float64 + ctx := run.WithSpendBank(runContext(t), func(usd float64) { + mu.Lock() + defer mu.Unlock() + banked = append(banked, usd) + }) + report, err := worker.Run(ctx, *store.Task(store.RootID())) + if err != nil { + stderr, _ := os.ReadFile(filepath.Join(taskDir, "delegate-stderr.log")) + t.Fatalf("the delegate's run failed: %v\nstderr:\n%s", err, stderr) + } + if report.Steps != 2 || !strings.Contains(report.Result, "fake's model said: all green") { + t.Fatalf("report = %+v", report) + } + // THE METER'S FIGURE, NOT THE PROGRAM'S 99. + if report.USD != 0.1 { + t.Fatalf("usd = %v, want the two metered calls' 0.10", report.USD) + } + mu.Lock() + if len(banked) != 2 || banked[0] != 0.05 || banked[1] != 0.1 { + t.Fatalf("banked = %v, want the run's total rising call by call", banked) + } + mu.Unlock() + if spend := store.SpendSummary().ByModel["delegate/fake"]; spend.USD != 0.1 || spend.Calls != 2 { + t.Fatalf("spend rows = %+v, want one per call under the program's name", store.SpendSummary().ByModel) + } + rows := ledgerRows(t, ledger) + if len(rows) != 2 { + t.Fatalf("ledger rows = %+v, want exactly one per call", rows) + } + for _, row := range rows { + if row.Model != "deepseek/deepseek-v4-flash-0731" || row.USD != 0.05 || row.Input != 100 || row.Calls != 1 || row.Seat != session.SeatWorker || row.Workspace != workspace { + t.Fatalf("ledger row = %+v", row) + } + } + // The conversation: two turns, what the program said and what came back. + turns, err := delegate.ReadTurns(taskDir, 0) + if err != nil { + t.Fatal(err) + } + if len(turns) != 2 || turns[0].Reply != "answered call 1: drive the plan to the ground" || turns[1].CostUSD != 0.05 || turns[1].Cached != 60 { + t.Fatalf("turns = %+v", turns) + } + if len(turns[0].Sent) != 2 || turns[0].Sent[0].Role != "system" || turns[1].Restarted != true { + // The fake asks each question on a fresh two-message history, which + // is a history rewritten — said so, and sent whole. + t.Fatalf("sent = %+v / restarted %v", turns[0].Sent, turns[1].Restarted) + } + if record, ok := delegate.ReadProgram(taskDir); !ok || record.Name != "fake" || strings.Join(record.Stages, ",") != "implement,verify" || record.CeilingUSD != 2.5 { + t.Fatalf("program record = %+v %v, want the hello's name and stages and the run's ceiling", record, ok) + } + if models := calling.seen(); len(models) != 2 || models[0] != "deepseek/deepseek-v4-flash-0731" { + t.Fatalf("the funnel was asked for %q", models) + } + // NO KEY REACHED THE PROGRAM, and the API it was given is dead now. + environ, _ := os.ReadFile(envFile) + if strings.Contains(string(environ), "sk-or-v1-the-parents-own-key") || !strings.Contains(string(environ), delegate.EnvModelToken+"=") { + t.Fatalf("the child's environment:\n%s", environ) + } + api, _ := os.ReadFile(apiFile) + base, token, _ := strings.Cut(strings.TrimSpace(string(api)), "\n") + if !strings.HasPrefix(base, "http://127.0.0.1:") || token == "" { + t.Fatalf("the child was handed %q", api) + } + request, _ := http.NewRequest(http.MethodPost, base+"/chat/completions", strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`)) + request.Header.Set("Authorization", "Bearer "+token) + if response, err := http.DefaultClient.Do(request); err == nil { + response.Body.Close() + t.Fatalf("the run's token still opens its API after the run: %d", response.StatusCode) + } +} + +// A CHILD THAT CURLS THE API — the way any program outside codeaf's tree +// would — is served by the worker, its call metered and written down, and the +// token it was handed opens nothing once the run has ended. +func TestDelegateWorkerServesAChildThatCurlsTheAPIAndCutsItOffAfter(t *testing.T) { + if _, err := exec.LookPath("curl"); err != nil { + t.Skip("no curl on this machine") + } + store := runOpenStore(t) + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()) + saved := filepath.Join(t.TempDir(), "saved") + reply := filepath.Join(t.TempDir(), "reply") + t.Setenv("FAKE_SAVED", saved) + t.Setenv("FAKE_REPLY", reply) + m, setup := fakeDelegate(t, strings.Join([]string{ + `printf '%s\n%s\n' "$CODEAF_MODEL_API" "$CODEAF_MODEL_TOKEN" > "$FAKE_SAVED"`, + `curl -sS -X POST "$CODEAF_MODEL_API/chat/completions" -H "Authorization: Bearer $CODEAF_MODEL_TOKEN" -H "Content-Type: application/json" ` + + `-d '{"model":"z-ai/glm-5.1","messages":[{"role":"user","content":"is it green"}]}' > "$FAKE_REPLY"`, + passLine("curl was answered"), + }, "\n")) + calling := &funnel{cost: 0.03} + setup.CompleterFor = calling.completerFor + setup.Ledger = filepath.Join(t.TempDir(), "usage.jsonl") + var banked []float64 + ctx := run.WithSpendBank(runContext(t), func(usd float64) { banked = append(banked, usd) }) + report, err := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0).Run(ctx, *store.Task(store.RootID())) + if err != nil { + t.Fatal(err) + } + answered, _ := os.ReadFile(reply) + if !strings.Contains(string(answered), `"content":"answered is it green"`) || !strings.Contains(string(answered), `"cost":0.03`) { + t.Fatalf("curl was answered %s", answered) + } + if report.USD != 0.03 || len(banked) != 1 || banked[0] != 0.03 { + t.Fatalf("usd %v banked %v, want the one metered call", report.USD, banked) + } + if turns, _ := delegate.ReadTurns(taskDir, 0); len(turns) != 1 || turns[0].Model != "z-ai/glm-5.1" || turns[0].Sent[0].Text != "is it green" { + t.Fatalf("turns = %+v", turns) + } + lines, _ := os.ReadFile(saved) + base, token, _ := strings.Cut(strings.TrimSpace(string(lines)), "\n") + after := exec.Command("curl", "-sS", "--max-time", "5", "-X", "POST", base+"/chat/completions", + "-H", "Authorization: Bearer "+token, "-d", `{"messages":[{"role":"user","content":"again"}]}`) + if out, err := after.CombinedOutput(); err == nil { + t.Fatalf("the token still opened the API after the run:\n%s", out) + } +} + +// THE PROGRAM'S OWN WORD ABOUT MONEY IS NOT MONEY: a run whose program made no +// call through the API spent nothing, whatever its spend lines and its +// terminal said, and leaves no spend row. +func TestDelegateWorkerRecordsStepsAndBelievesNoSpendItWasTold(t *testing.T) { + store := runOpenStore(t) + storeDir := filepath.Dir(store.Path()) + args := filepath.Join(t.TempDir(), "args") + t.Setenv("FAKE_ARGS", args) + workspace := t.TempDir() + m, setup := fakeDelegate(t, passLine("tests are green")) + worker := run.NewDelegateWorker(store, workspace, m, setup, 2.5, 0) + + var banked []float64 + ctx := run.WithSpendBank(runContext(t), func(usd float64) { banked = append(banked, usd) }) + report, err := worker.Run(ctx, *store.Task(store.RootID())) + if err != nil { + t.Fatalf("the delegate's run failed: %v", err) + } + if report.Steps != 2 { + t.Fatalf("steps = %d, want the two step records the program sent", report.Steps) + } + if report.USD != 0 || len(banked) != 0 { + t.Fatalf("usd %v banked %v, want nothing: no call was metered", report.USD, banked) + } + if spend := store.SpendSummary(); len(spend.ByModel) != 0 { + t.Fatalf("spend rows = %+v, want none", spend.ByModel) + } + if !strings.Contains(report.Result, "submitted and verified") || !strings.Contains(report.Result, "fake's model said: tests are green") || !strings.Contains(report.Result, "fake observed: pass") { + t.Fatalf("result = %q, want the message, the claim and the observation as separate sentences", report.Result) + } + // The brief the program was handed is the task's description, and the + // ceiling is the run's. + got, _ := os.ReadFile(args) + if want := "fake\nrun\n--json\n--dir\n" + workspace + "\n--max-cost\n2.5\n--\ndrive the plan to the ground\n"; string(got) != want { + t.Fatalf("argv =\n%s\nwant\n%s", got, want) + } + // The trajectory: the opening line, two steps, the ending. + steps, err := run.Trajectory(storeDir, store.RootID()) + if err != nil { + t.Fatal(err) + } + if len(steps) != 2 || steps[0].Command != "bash: go test ./..." || steps[0].Observation != "ok" || steps[1].Step != 2 { + t.Fatalf("trajectory steps = %+v", steps) + } + lines := rawTrajectory(t, storeDir, store.RootID()) + if len(lines) != 4 { + t.Fatalf("the trajectory holds %d lines, want the opening, two steps and the ending", len(lines)) + } + end := endLine(t, lines) + if end.Steps != 2 || !strings.HasPrefix(end.Reason, "finished: ") { + t.Fatalf("ending = %+v", end) + } + // The live step was cleared with the process, stderr went to the task's + // folder, and the hello left the program's record beside it. + if live := store.LiveSteps(); len(live) != 0 { + t.Fatalf("live steps = %+v, want none after the program ended", live) + } + taskDir := plandb.TaskDir(storeDir, store.RootID()) + if _, err := os.Stat(filepath.Join(taskDir, "delegate-stderr.log")); err != nil { + t.Fatalf("no stderr file beside the trajectory: %v", err) + } + if record, ok := delegate.ReadProgram(taskDir); !ok || record.Name != "fake" || len(record.Stages) != 2 { + t.Fatalf("program record = %+v %v", record, ok) + } +} + +func TestDelegateWorkerReportsAFailedEndingAsAnError(t *testing.T) { + store := runOpenStore(t) + m, setup := fakeDelegate(t, `echo '{"type":"terminal","status":"fail","message":"unsubmitted","data":{"cost_usd":0.2,"status":"unsubmitted"}}'`) + worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0) + report, err := worker.Run(runContext(t), *store.Task(store.RootID())) + if err == nil || !strings.Contains(err.Error(), "fake did not finish: unsubmitted") { + t.Fatalf("err = %v", err) + } + // The steps are kept on a failed ending; the terminal's $0.20 is the + // program's own word and is not money. + if report.USD != 0 || report.Steps != 2 { + t.Fatalf("report = %+v, want the steps kept and nothing banked on the program's word", report) + } +} + +// A program that ended without finishing says why, and the run carries its +// words whole to whoever drew the row: its status word, its sentence and its +// account, not only the run's one word for every unfinished ending. +func TestARunCarriesTheProgramsOwnEndingWhenItDidNotFinish(t *testing.T) { + store := runOpenStore(t) + m, setup := fakeDelegate(t, `echo '{"type":"terminal","status":"fail","message":"submitted a change the project tests do not pass","data":{"submission_reason":"all done","status":"fail"}}'`) + outcome, summary := run.Start(runContext(t), run.Spec{ + Store: store, Workspace: t.TempDir(), Title: "The run", Brief: "drive the plan to the ground", Slots: 1, + Factory: run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{}, nil), + }) + if outcome != run.OutcomeIncomplete { + t.Fatalf("outcome = %q, want incomplete", outcome) + } + ended := summary.Program + if ended == nil || ended.Status != delegate.StatusFail || + ended.Reason != "fake did not finish: submitted a change the project tests do not pass" || + !strings.Contains(ended.Result, "fake's model said: all done") { + t.Fatalf("the run's program ending = %+v, want the program's own status, sentence and account", ended) + } +} + +func TestDelegateWorkerNamesAnExitWithoutATerminal(t *testing.T) { + store := runOpenStore(t) + m, setup := fakeDelegate(t, "exit 7") + worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0) + _, err := worker.Run(runContext(t), *store.Task(store.RootID())) + if err == nil || err.Error() != "fake exited 7 without a terminal record; its last stage was implement" { + t.Fatalf("err = %v", err) + } +} + +func TestDelegateWorkerComesHomeWithTheContextsEndingWhenTheRunStopsIt(t *testing.T) { + store := runOpenStore(t) + m, setup := fakeDelegate(t, strings.Join([]string{ + `trap 'echo "{\"type\":\"terminal\",\"status\":\"budget-exhausted\",\"message\":\"told to stop\",\"data\":{\"cost_usd\":0.11}}"; exit 0' TERM`, + `sleep 30 &`, + `wait $!`, + }, "\n")) + worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0, 0) + ctx, cancel := context.WithCancel(runContext(t)) + go func() { + // Once the store has the program's live step, the program is past its + // trap line and the signal will be caught. + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if live := store.LiveSteps(); len(live) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + time.Sleep(50 * time.Millisecond) + cancel() + }() + report, err := worker.Run(ctx, *store.Task(store.RootID())) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want the context's own so the run records the cut", err) + } + if report.USD != 0 { + t.Fatalf("usd = %v, want nothing: the program made no metered call", report.USD) + } + lines := rawTrajectory(t, filepath.Dir(store.Path()), store.RootID()) + end := endLine(t, lines) + if end.Reason != "stopped by the run: fake said told to stop" { + t.Fatalf("ending reason = %q", end.Reason) + } +} + +// The whole road: a run of one task whose root is the delegate — a real child +// calling the real API — driven by the supervisor to done, with the +// delegate's words as the run's result and the metered calls as its dollars. +func TestARunSeatsTheDelegateOnItsRootAndEndsDone(t *testing.T) { + store := runOpenStore(t) + m, setup, _, _ := realChild(t, 0.06, "2") + factory := run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{CostUSD: 5}, nil) + outcome, summary := run.Start(runContext(t), run.Spec{ + Store: store, + Workspace: t.TempDir(), + Title: "The run", + Brief: "drive the plan to the ground", + Slots: 1, + Limits: run.Limits{CostUSD: 5}, + Factory: factory, + }) + if outcome != run.OutcomeDone { + t.Fatalf("outcome = %q, want done", outcome) + } + if !strings.Contains(summary.Result, "fake's model said: all green") { + t.Fatalf("result = %q", summary.Result) + } + if summary.USD != 0.12 || summary.Steps != 2 || summary.Nodes != 1 { + t.Fatalf("summary = %+v", summary) + } + if root := store.Task(store.RootID()); root.Status != plandb.StatusDone { + t.Fatalf("root status = %q", root.Status) + } +} + +// A run whose dollar ceiling the delegate's METERED calls cross is ended by +// the run on the limit word, with the program terminated and its own terminal +// kept — and the API refuses every call past the ceiling, so the run spent +// exactly what the calls under it cost. +func TestARunEndsADelegateThatCrossesTheCostCeiling(t *testing.T) { + store := runOpenStore(t) + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()) + m, setup, calling, _ := realChild(t, 0.06, "6") + t.Setenv("FAKE_ENDING", "wait") + factory := run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{CostUSD: 0.10}, nil) + outcome, summary := run.Start(runContext(t), run.Spec{ + Store: store, Workspace: t.TempDir(), Slots: 1, + Limits: run.Limits{CostUSD: 0.10}, + Factory: factory, + }) + if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost { + t.Fatalf("outcome = %q limit = %q, want the cost limit", outcome, summary.Limit) + } + if len(summary.Cut) != 1 { + t.Fatalf("cut = %v, want the root cut by the run's own ending", summary.Cut) + } + if summary.USD != 0.12 || len(calling.seen()) != 2 { + t.Fatalf("usd %v after %d funnel calls, want exactly the two calls that crossed the ceiling", summary.USD, len(calling.seen())) + } + turns, _ := delegate.ReadTurns(taskDir, 0) + for _, turn := range turns[2:] { + if turn.Refused == "" || turn.CostUSD != 0 { + t.Fatalf("a call past the ceiling was made: %+v", turn) + } + } +} + +// A PROGRAM REFUSED AT THE CEILING WAS STOPPED BY THE CEILING, however it says +// it ended: the fake ends as `crashed` the moment a call is refused, the way +// senior-dev does, and the task keeps the ceiling's words and not a crash's. +func TestDelegateWorkerReportsAProgramRefusedAtTheCeilingAsTheCeiling(t *testing.T) { + store := runOpenStore(t) + m, setup, calling, _ := realChild(t, 0.06, "5") + t.Setenv("FAKE_ENDING", "crash") + worker := run.NewDelegateWorker(store, t.TempDir(), m, setup, 0.10, 0) + report, err := worker.Run(runContext(t), *store.Task(store.RootID())) + if err == nil || !strings.Contains(err.Error(), "fake reached the run's dollar ceiling of $0.10") || strings.Contains(err.Error(), "crashed") { + t.Fatalf("err = %v, want the ceiling named and no crash", err) + } + if report.USD != 0.12 || len(calling.seen()) != 2 { + t.Fatalf("usd %v after %d calls, want the two calls that reached the ceiling", report.USD, len(calling.seen())) + } + end := endLine(t, rawTrajectory(t, filepath.Dir(store.Path()), store.RootID())) + if !strings.HasPrefix(end.Reason, "fake reached the run's dollar ceiling") { + t.Fatalf("the trajectory ends %q", end.Reason) + } +} + +// AND THE RUN ENDS ON THE PERSON'S COST LIMIT, whichever comes home first — +// the supervisor's own stop or the program's ending after its refusal. +func TestARunWhoseDelegateWasRefusedAtTheCeilingEndsOnTheCostLimit(t *testing.T) { + store := runOpenStore(t) + m, setup, _, _ := realChild(t, 0.06, "5") + t.Setenv("FAKE_ENDING", "crash") + factory := run.DelegateFactory(store, t.TempDir(), m, setup, run.Limits{CostUSD: 0.10}, nil) + outcome, summary := run.Start(runContext(t), run.Spec{ + Store: store, Workspace: t.TempDir(), Slots: 1, + Limits: run.Limits{CostUSD: 0.10}, + Factory: factory, + }) + if outcome != run.OutcomeLimit || summary.Limit != run.LimitCost || summary.USD != 0.12 { + t.Fatalf("outcome %q limit %q usd %v, want the cost limit at the two calls' 0.12", outcome, summary.Limit, summary.USD) + } +} + +// TWO BUILDS, ONE RUN: a child that says another protocol version than this +// build reads is stopped before it spends, and the reason names the fix. +func TestDelegateWorkerStopsAChildOfAnotherBuild(t *testing.T) { + store := runOpenStore(t) + script := filepath.Join(t.TempDir(), "newer.sh") + program := "#!/bin/sh\n" + strings.Join([]string{ + `echo '{"type":"hello","protocol":99,"delegate":"fake"}'`, + `trap 'exit 0' TERM`, + `sleep 30 &`, + `wait $!`, + }, "\n") + "\n" + if err := os.WriteFile(script, []byte(program), 0o755); err != nil { + t.Fatal(err) + } + worker := run.NewDelegateWorker(store, t.TempDir(), delegate.Delegate{Name: "fake", Default: "run"}, run.DelegateSetup{Exe: script, Grace: time.Second}, 0, 0) + started := time.Now() + _, err := worker.Run(runContext(t), *store.Task(store.RootID())) + if err == nil || !strings.Contains(err.Error(), "restart codeaf to run fake") || errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want the rebuild named and not the run's own ending", err) + } + if time.Since(started) > 10*time.Second { + t.Fatal("the mismatched child was not stopped") + } + if _, ok := delegate.ReadProgram(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())); ok { + t.Fatal("a child of another build was written down as this run's program") + } +} diff --git a/internal/run/enginewire.go b/internal/run/enginewire.go index 6d8ca1b010..a17aace74b 100644 --- a/internal/run/enginewire.go +++ b/internal/run/enginewire.go @@ -23,36 +23,62 @@ import ( type engine struct{} func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummary { + // THE REVIEW ROUND IS ON for every task the chat's door opens: a leaf + // that lands done is checked against its acceptance, and a check that + // does not hold becomes a fix task the run waits on. + limits := Limits{CostUSD: spec.CostUSD, Elapsed: spec.Elapsed, StepsPerTask: spec.StepsPerTask, ReviewRound: true} + // THE CREW IS THE PROFILE'S, read again at each launch, and the seat's + // provider is the door's own completer through the one seam a test + // scripts ([CrewFactory]). + // + // THE DOOR'S TWO SEATS RIDE WITH THE SPEC. The conversation resolved + // them itself (the enginewire spec's WorkModel and PlanModel), so the + // factory seats the work and plan roles on the door's answer rather than + // asking the profile again for a row the door already moved. + // + // AND THE CHECK SEAT CLIMBS THE SAME LADDER `codeaf do` CLIMBS, minus + // the flag no chat has ([chatCheckSeat]), so CODEAF_CHECK_MODEL reaches + // a `/task` run the way the manual says it reaches a headless one. + factory := CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ + Work: spec.WorkModel, + Plan: spec.PlanModel, + Check: chatCheckSeat(), + }, spec.CompleterFor) + if spec.Delegate != nil { + // A DELEGATED RUN SEATS THE PROGRAM ON ITS ROOT and has no review + // round: a check seat is a bash-belt worker, which the belt switch may + // have left off, and the program's own verification is what its + // terminal record reports ([DelegateWorker]). + limits.ReviewRound = false + // AND ITS MODEL API RIDES THE CONVERSATION'S OWN ROAD: the completer the + // door handed the run, the services the conversation can reach, and the + // work seat a leaf of this run would sit on — which is where a call on + // a model nothing here can reach is answered instead. + setup := DelegateSetup{ + CompleterFor: spec.CompleterFor, + Serves: spec.Serves, + Seat: WorkSeat(spec.ProfileDir, spec.WorkModel), + PlainFolder: spec.PlainFolder, + Crew: spec.Crew, + // AND ITS MONEY IS THE CONVERSATION'S, CALL BY CALL: every ledger row + // names the conversation and the task, and every call is folded + // into the conversation's books whole as it is metered. + Conversation: spec.Conversation, + OnCharge: spec.OnCharge, + } + factory = DelegateFactory(spec.Store, spec.Workspace, *spec.Delegate, setup, limits, factory) + } outcome, summary := Start(ctx, Spec{ Store: spec.Store, Workspace: spec.Workspace, Title: spec.Title, Brief: spec.Brief, Slots: spec.Slots, - // THE REVIEW ROUND IS ON for every task the chat's door opens: a leaf - // that lands done is checked against its acceptance, and a check that - // does not hold becomes a fix task the run waits on. - Limits: Limits{CostUSD: spec.CostUSD, Elapsed: spec.Elapsed, StepsPerTask: spec.StepsPerTask, ReviewRound: true}, - // THE CREW IS THE PROFILE'S, read again at each launch, and the seat's - // provider is the door's own completer through the one seam a test - // scripts ([CrewFactory]). - // - // THE DOOR'S TWO SEATS RIDE WITH THE SPEC. The conversation resolved - // them itself (the enginewire spec's WorkModel and PlanModel), so the - // factory seats the work and plan roles on the door's answer rather than - // asking the profile again for a row the door already moved. - // - // AND THE CHECK SEAT CLIMBS THE SAME LADDER `codeaf do` CLIMBS, minus - // the flag no chat has ([chatCheckSeat]), so CODEAF_CHECK_MODEL reaches - // a `/task` run the way the manual says it reaches a headless one. - Factory: CrewFactory(spec.Store, spec.Workspace, spec.ProfileDir, Seats{ - Work: spec.WorkModel, - Plan: spec.PlanModel, - Check: chatCheckSeat(), - }, spec.CompleterFor), - OnSpend: spec.OnSpend, - Gate: spec.Admission, - OnHold: spec.OnHold, + Limits: limits, + Factory: factory, + OnSpend: spec.OnSpend, + Gate: spec.Admission, + OnHold: spec.OnHold, }) return session.RunSummary{ Outcome: string(outcome), @@ -62,6 +88,13 @@ func (engine) Start(ctx context.Context, spec session.RunSpec) session.RunSummar // so the session draws the ending out of the fact and never parses the // sentence back apart. Limit: runLimitOf(summary.Limit), + // AND A PROGRAM'S OWN ENDING CROSSES AS ITSELF, the same way: its + // status word and its sentence, so the row names what the program said + // and not the run's one word for every unfinished ending. + Program: programEndingOf(summary.Program), + // AND THE WORD A PROGRAM FINISHED ON, which is how the session tells + // work its program checked from work nothing checked. + ProgramVerdict: summary.Verdict, // THE ROWS THE RUN'S OWN ENDING CUT CROSS AS THEMSELVES: the same // one-for-one carrying as the limit fact, so the session draws a row // the person's bound took down from the run's own record of it and @@ -100,6 +133,15 @@ func runLimitOf(limit Limit) session.RunLimit { return "" } +// programEndingOf is the program's ending in the session's words, nil where no +// program ended the run unfinished. +func programEndingOf(ended *ProgramEndedError) *session.ProgramEnding { + if ended == nil { + return nil + } + return &session.ProgramEnding{Status: ended.Status, Reason: ended.Reason, Result: ended.Result} +} + func (engine) Land(ctx context.Context, store *plandb.Store, workspace, base, rootID string) (session.RunLanding, error) { landing, err := Land(ctx, store, workspace, base, rootID) if err != nil { diff --git a/internal/run/review_test.go b/internal/run/review_test.go index 1f06e5e8e0..aa52505437 100644 --- a/internal/run/review_test.go +++ b/internal/run/review_test.go @@ -645,6 +645,10 @@ func TestSupervisorStillEndsIncompleteWhenRootErrorsWithoutStoredDone(t *testing if root.Status == plandb.StatusDone || root.Result != "" { t.Fatalf("root = %s with result %q, want no stored done or result", root.Status, root.Result) } + // AND THE RUN IS OVER IN THE STORE: a failed run left open read as running. + if root.Status != plandb.StatusFailed || root.Error != "root worker failed" { + t.Fatalf("root = %s (%q), want failed with its worker's error", root.Status, root.Error) + } } func TestSupervisorAcceptsARootsReadingDoesNotHoldConclusion(t *testing.T) { diff --git a/internal/run/run.go b/internal/run/run.go index 4a9e5f119d..4b78fb89a4 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -162,6 +162,20 @@ type Supervisor struct { steps int rootResult string rootFailed bool + // rootProgram is how the program a delegated run's root was handed to + // ended, when it ended without finishing ([ProgramEndedError]); nil for + // every other run. + rootProgram *ProgramEndedError + // rootVerdict is the program's own word for the work it finished + // ([Report.Verdict]); empty for every other run. + rootVerdict string + // rootFailure is the root worker's error when it failed, which the run's + // ending writes onto the root ([plandb.Store.FailRoot]). + rootFailure string + // rootCut says the root worker came home with a context's ending as its + // error: the run was cut, and its own task did not fail. The store is not + // ended for it ([Supervisor.pass] says why). + rootCut bool // limitHit is which limit a person set ended this run, and empty while none // has. It is set the moment the run decides a limit was reached (the // elapsed signal in Run, the spend counters in countLiveSpend and @@ -274,6 +288,7 @@ func (s *Supervisor) Run(ctx context.Context) Outcome { s.steps = 0 s.rootResult = "" s.rootFailed = false + s.rootCut = false s.limitHit = "" s.cut = make(map[string]bool) s.dispatchedRoot = false @@ -288,6 +303,13 @@ func (s *Supervisor) Run(ctx context.Context) Outcome { if s.staleAfter <= 0 { s.staleAfter = defaultStaleAfter } + // A RUN HANDED NOTHING OF ITS DOLLAR LIMIT STARTS NO WORKER. The limit was + // spent before the run began ([Limits.costReached]), so the first pass + // launches nothing and answers the limit: no worker is seated to make the + // one paid call that would have told the loop so. + if s.limits.costReached(s.spent) { + s.limitHit = LimitCost + } // TAKE-OVER BEFORE THE FIRST PASS: a claim a dead process left behind is // released here, so the ready set the first pass reads can offer it again // with no pass of waiting. @@ -415,6 +437,24 @@ func (s *Supervisor) pass(ctx context.Context, rootID string) Outcome { } if s.inFlight == 0 && (s.rootFailed || s.limitHit != "") { + if s.rootFailed && s.limitHit == "" && !s.rootCut && ctx.Err() == nil { + // THE RUN'S OWN TASK FAILED, SO THE RUN IS OVER, and the store says + // so: left open it read as running for ever, and a door that + // adopts open stores would take it up as live work + // ([plandb.Store.FailRoot]). A run a limit ended keeps its open + // work, which is what lets it be taken up again under a wider bound. + // + // AND A RUN THE CALLER CUT IS NOT A RUN THAT FAILED. When the + // caller's context ends, the root worker comes home with the + // context's own error, and that return and the context's end are + // both ready at the loop's select at once; Go picks either. Picked + // first, the return reached this line and wrote `context canceled` + // over the root as though the work had failed, on a run the caller's + // wall below deliberately leaves open for a later pass. Whichever + // the select picks, a cut root now ends the same way: incomplete, + // with the store as the run left it. + _ = s.store.FailRoot(s.rootFailure) + } // Nothing of ours is running and the run cannot complete itself: the // root's own worker failed, or the run has reached a limit a person set, // in dollars or in time. @@ -705,7 +745,8 @@ func (s *Supervisor) countLiveSpend() { // after the time limit already ended the run does not rename that ending, and // the workers are already ended by it. func (s *Supervisor) reachCostLimit() { - if s.limits.CostUSD <= 0 || s.spent < s.limits.CostUSD || s.limitHit != "" { + // A LIMIT WITH NOTHING LEFT IS REACHED WITH NOTHING SPENT ([Limits.costReached]). + if !s.limits.costReached(s.spent) || s.limitHit != "" { return } s.limitHit = LimitCost @@ -837,9 +878,19 @@ func (s *Supervisor) absorb(ret workerReturn) { s.addReviewCheck(ret.task, root.Result) } else { s.rootFailed = true + s.rootFailure = ret.err.Error() + s.rootCut = errors.Is(ret.err, context.Canceled) || errors.Is(ret.err, context.DeadlineExceeded) + // A PROGRAM THAT ENDED WITHOUT FINISHING SAID WHY, and its words + // are the run's to carry, never to drop: the session draws the + // row out of them ([Summary.Program]). + var ended *ProgramEndedError + if errors.As(ret.err, &ended) { + s.rootProgram = ended + } } } else { s.rootResult = ret.report.Result + s.rootVerdict = ret.report.Verdict // THE CHILDLESS ROOT IS A LEAF, and it is checked like any other. If // its worker already wrote the ending, the store preserves that result // and moves the root back to waiting on the check; CompleteRoot writes @@ -1721,6 +1772,14 @@ type Summary struct { // run that did not end on one. The outcome word is the same sentence for // both limits; this is what tells them apart. Limit Limit + // Program is how a delegated run's program ended when it ended without + // finishing: its status word and its own account ([ProgramEndedError]). + // Nil for a run that finished, and for every run no program worked. + Program *ProgramEndedError + // Verdict is a delegated run's program's own word for the work it + // finished ([Report.Verdict]): senior-dev's `pass` or `pass-unverified`. + // Empty for every other run. + Verdict string // Cut is every task the run's own ending cut mid-flight, by store id: its // wall, its spend ceiling, or a person's stop ended the context their // workers ran under. A task that failed on its own before the ending is @@ -1830,6 +1889,8 @@ func Start(ctx context.Context, spec Spec) (Outcome, Summary) { Outcome: outcome, Result: result, Limit: supervisor.limitHit, + Program: supervisor.rootProgram, + Verdict: supervisor.rootVerdict, Cut: supervisor.cutIDs(), Nodes: supervisor.nodes, Steps: supervisor.steps, diff --git a/internal/run/stage_words_internal_test.go b/internal/run/stage_words_internal_test.go new file mode 100644 index 0000000000..9e0b81b5d9 --- /dev/null +++ b/internal/run/stage_words_internal_test.go @@ -0,0 +1,100 @@ +package run + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// A PROGRAM'S STAGE IS SHOWN IN THE WORD IT GAVE A PERSON. senior-dev's page +// read `agent-runtime` for the whole of its work, which is its machinery's name +// for a model turn; the row now reads the program's own word for the phase, a +// stage it gave no word keeps the word already shown, and a program that gave +// no words at all is shown its stages as it spelled them. +func TestAProgramsStageIsShownInTheWordItGaveAPerson(t *testing.T) { + live := func(program delegate.Delegate, stages ...[2]string) string { + t.Helper() + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "p", "root", "root", "root") + if err != nil { + t.Fatal(err) + } + defer store.Close() + sink := &delegateSink{worker: &DelegateWorker{store: store, program: program}, taskID: "root", name: program.Name} + for _, stage := range stages { + sink.Stage(delegate.StageRecord{Stage: stage[0], Status: stage[1]}) + } + return store.LiveSteps()["root"].Command + } + worded := delegate.Delegate{Name: "senior-dev", StageWords: map[string]string{"implement": "working"}} + if got := live(worded, [2]string{"implement", "running"}); got != "senior-dev: working" { + t.Fatalf("a worded stage reads %q, want the program's word and no status", got) + } + if got := live(worded, [2]string{"implement", "running"}, [2]string{"agent-runtime", "configured"}); got != "senior-dev: working" { + t.Fatalf("a stage with no word reads %q, want the word already shown to stand", got) + } + if got := live(delegate.Delegate{Name: "fake"}, [2]string{"implement", "running"}); got != "fake: implement · running" { + t.Fatalf("a program with no words reads %q, want its own stage and status", got) + } +} + +// THE LIVE STEP FOLLOWS THE STEP OF THE PROGRAM'S PROCESS, AND EVERY RECORD IS +// KEPT. Each stage, step and ending is written to the task's action log the +// moment it arrives, stamped with codeaf's own clock; the row reads the word the +// program's own reader gives the step a record served, falls back to the stage +// words until a record has named one, and keeps the step's word through a stage +// that names none. +func TestTheLiveStepFollowsTheProgramsStepAndEveryRecordIsKept(t *testing.T) { + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "p", "root", "root", "root") + if err != nil { + t.Fatal(err) + } + defer store.Close() + program := delegate.Delegate{ + Name: "senior-dev", StageWords: map[string]string{"intake": "reading the brief", "implement": "working"}, + Present: func() delegate.ActionReader { + return func(action delegate.Action) (delegate.Shown, bool) { + if action.Kind == delegate.ActionStep { + return delegate.Shown{Step: action.Step, Text: action.Command}, true + } + return delegate.Shown{Text: action.Stage}, true + } + }, + } + taskDir := t.TempDir() + sink := &delegateSink{worker: &DelegateWorker{store: store, program: program}, taskID: "root", taskDir: taskDir, storeDir: t.TempDir(), name: program.Name} + live := func() string { return store.LiveSteps()["root"].Command } + + before := time.Now() + sink.Stage(delegate.StageRecord{Stage: "intake", Status: "captured"}) + if got := live(); got != "senior-dev: reading the brief" { + t.Fatalf("before any step the row reads %q, want the stage's word", got) + } + exit := 1 + sink.Step(delegate.StepRecord{Command: "bash: go test ./...", Tool: "bash", Step: "explore", Exit: &exit}) + if got := live(); got != "senior-dev: explore" { + t.Fatalf("after a step the row reads %q, want the step's word", got) + } + sink.Stage(delegate.StageRecord{Stage: "implement", Status: "running"}) + if got := live(); got != "senior-dev: explore" { + t.Fatalf("a stage that names no step moved the row to %q", got) + } + sink.Terminal(delegate.Terminal{Status: delegate.StatusPass, Message: "done"}) + + actions, err := delegate.ReadActions(taskDir, 0) + if err != nil || len(actions) != 4 { + t.Fatalf("the action log holds %+v (%v), want the four records", actions, err) + } + kinds := []string{actions[0].Kind, actions[1].Kind, actions[2].Kind, actions[3].Kind} + if strings.Join(kinds, ",") != "stage,step,stage,end" || actions[1].Exit == nil || *actions[1].Exit != 1 || actions[3].Message != "done" { + t.Fatalf("the action log = %+v", actions) + } + for i, action := range actions { + if action.At.Before(before) || (i > 0 && action.At.Before(actions[i-1].At)) { + t.Fatalf("action %d was stamped %v, want codeaf's own clock, in order", i, action.At) + } + } +} diff --git a/internal/run/testmain_test.go b/internal/run/testmain_test.go index b402cd1519..3fdefd6ba4 100644 --- a/internal/run/testmain_test.go +++ b/internal/run/testmain_test.go @@ -7,7 +7,15 @@ import ( // TestMain keeps subprocess workers made by this suite out of the plan that // launched go test. Tests that exercise the bound door set PLANDB_DB themselves. +// +// It is also the door a delegated run's REAL child comes in by: a test that +// starts this very test binary as a program's process (delegate_child_test.go) +// marks it in the environment, and the binary then runs the fake program's +// body instead of the suite. func TestMain(m *testing.M) { + if code, child := runAsDelegateChild(); child { + os.Exit(code) + } _ = os.Unsetenv("PLANDB_DB") _ = os.Unsetenv("PLANDB_RUN") os.Exit(m.Run()) diff --git a/internal/run/trajectory.go b/internal/run/trajectory.go index 2a6ca87662..cc36e58596 100644 --- a/internal/run/trajectory.go +++ b/internal/run/trajectory.go @@ -16,6 +16,7 @@ import ( "os" "path/filepath" "strings" + "time" "unicode/utf8" "github.com/Agent-Field/codeaf/internal/plandb" @@ -102,6 +103,16 @@ type Step struct { Result string `json:"result,omitempty"` Reason string `json:"reason,omitempty"` + // StartedAt and EndedAt are a PROGRAM's own clock on the ending line of the + // task it was handed: the instant codeaf started its process and the + // instant that process was gone — the pair the program record carries + // (delegate.ProgramRecord). They are zero on every other line, on an ending + // written by a road that never started a process, and on every line a + // worker of this conversation's own wrote. Step lines never carry them, so + // the session's mirror of the step line (PlanStep) has no use for them. + StartedAt time.Time `json:"started_at,omitzero"` + EndedAt time.Time `json:"ended_at,omitzero"` + // ExitsRecorded is stamped true by a build that records each command's // exit, on the OPENING line it writes before any step and on the ending // line; bashworker.go sets it at both. A reader uses it to tell a record diff --git a/internal/run/worker.go b/internal/run/worker.go index 039a5a0abb..995fef5af0 100644 --- a/internal/run/worker.go +++ b/internal/run/worker.go @@ -27,6 +27,10 @@ type Report struct { Steps int USD float64 Waiting bool + // Verdict is a program's own word for the finished work it handed in — + // senior-dev's `pass` or `pass-unverified` — when a delegated run's program + // finished; empty for every other worker ([delegate.Terminal.Verdict]). + Verdict string } // Worker is one task's executor. The supervisor never talks to a model @@ -80,6 +84,24 @@ type Limits struct { ReviewRound bool } +// costDust is the most a dollar limit may still have left and be reached: a +// billionth of a dollar, far below any call's price and far above the float +// rounding in a sum of prices. The model API a program's calls go through +// reads its ceiling the same way (internal/provider/modelapi's ceilingReached). +const costDust = 1e-9 + +// costReached reports whether a run's spend has reached its dollar limit. +// +// A LIMIT WITH NOTHING LEFT IS REACHED WITH NOTHING SPENT. The conversation +// hands a run whose person's limit is already spent the smallest positive +// figure, because zero means no limit at all; read as `spent >= limit`, +// nothing spent was still under it, and a run whose program's first call was +// refused for it ended as work that did not finish instead of on the limit the +// person set. +func (l Limits) costReached(spent float64) bool { + return l.CostUSD > 0 && l.CostUSD-spent <= costDust +} + // stepsPerTaskKey is the type behind the context value, so a worker reads its // cap with a typed lookup rather than a string key another package could // collide with. diff --git a/internal/seniordev/absentonwindows_test.go b/internal/seniordev/absentonwindows_test.go new file mode 100644 index 0000000000..259c4bb4df --- /dev/null +++ b/internal/seniordev/absentonwindows_test.go @@ -0,0 +1,53 @@ +//go:build !windows + +package seniordev + +import ( + "go/build" + "io/fs" + "path/filepath" + "strings" + "testing" +) + +// SENIOR-DEV IS ABSENT ON WINDOWS, NOT BROKEN THERE. Its engine has never had +// a Windows form of its process groups, file locks and bash shell, so no file +// of it may reach a Windows build: the build's list is empty there +// (internal/delegate/builtin/carried_windows.go), and this holds every Go file +// under this tree, tests included, to a constraint that keeps it out. A file +// that forgot one would put half an engine into a Windows build, where it +// either fails to compile or compiles into something that fails every time. +func TestNoFileOfSeniorDevReachesAWindowsBuild(t *testing.T) { + windows := build.Default + windows.GOOS, windows.GOARCH, windows.CgoEnabled = "windows", "amd64", false + checked := 0 + err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + if entry.Name() == "testdata" { + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + checked++ + included, err := windows.MatchFile(filepath.Dir(path), entry.Name()) + if err != nil { + return err + } + if included { + t.Errorf("%s would be compiled into a Windows build; give it //go:build !windows", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if checked < 200 { + t.Fatalf("only %d files were checked; the walk has stopped seeing the tree", checked) + } +} diff --git a/internal/seniordev/actions.go b/internal/seniordev/actions.go new file mode 100644 index 0000000000..43ae3ccbf8 --- /dev/null +++ b/internal/seniordev/actions.go @@ -0,0 +1,421 @@ +//go:build !windows + +package seniordev + +// senior-dev's page, in senior-dev's words: every line of its action log — a +// stage, a step, its ending — as what a person reads under the step of its +// process it served (delegate.Delegate's Present). codeaf draws the page; +// what senior-dev's records MEAN is said here, once, beside the words its +// stages already have. +// +// THE PAGE SHOWS ONLY WHAT senior-dev REALLY DOES. It has no planner, no +// reviewer and no subagent (baked/agents/coder.md): one model context works +// through its spec, explores, pins a check, lists the requirements, implements +// and hands in, in the order that model chooses; senior-dev then checks the +// tree itself with the project's own build and tests and finishes. Around that +// it compacts its model's memory, moves to another model, and steers its model +// when it stops short — and each of those is a line here, said as senior-dev +// steering its own work. Everything else it reports is machinery, and is left +// out. + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// stepWords is each step of senior-dev's process (app.Steps) in the one word +// its page prints at the head of the step and its task's row reads while it is +// in it. Reading the spec is `spec`, because that is where the brief is kept. +var stepWords = map[string]string{ + app.StepBrief: "spec", + app.StepExplore: "explore", + app.StepPin: "pin", + app.StepChecklist: "checklist", + app.StepImplement: "implement", + app.StepSubmit: "submit", + app.StepVerify: "verify", +} + +// The two words for what senior-dev does around its model context with no +// model at all: setting up the folder it works in, and finishing — putting +// back the tree it stands by and measuring the change. +const ( + setupWord = "setup" + finishWord = "finish" +) + +// presentActions is senior-dev's reader of its own action log: one per log, +// told every line in the order it was written. +// +// IT REMEMBERS ONE THING, THE HIGHEST ATTEMPT SO FAR. senior-dev reports +// `implement · running` with its attempt each time it starts its model on a +// turn: attempt 0 at the start, one more after each nudge, and the SAME attempt +// again when it retries a dropped call or corrects a malformed one. Only a +// higher attempt than any before is a nudge. +func presentActions() delegate.ActionReader { + attempt := 0 + return func(action delegate.Action) (delegate.Shown, bool) { + if action.Kind == delegate.ActionStage && action.Stage == "implement" && action.Status == "running" { + facts := stageFactsOf(action.Data) + if next := facts.whole("attempt"); next > attempt { + attempt = next + return nudged(next), true + } + return delegate.Shown{}, false + } + return presentAction(action) + } +} + +// nudged is senior-dev telling its model, which stopped without handing in, +// what it found about the tree and to finish (solo.go's nudge). +func nudged(attempt int) delegate.Shown { + return delegate.Shown{ + Step: stepWords[app.StepImplement], Steer: true, + Text: fmt.Sprintf("told its model what it found, and to finish and hand in (nudge %d)", attempt), + } +} + +// presentAction is one line of the log in senior-dev's words, for every line +// that needs nothing before it to be read. +func presentAction(action delegate.Action) (delegate.Shown, bool) { + switch action.Kind { + case delegate.ActionStep: + return presentStep(action) + case delegate.ActionStage: + return presentStage(action.Stage, action.Status, stageFactsOf(action.Data)) + case delegate.ActionEnd: + // THE ENDING IS senior-dev's OWN SENTENCE — what its check of the + // project found — under the step that finishes the run. + return delegate.Shown{Step: finishWord, Text: strings.TrimSpace(action.Message)}, true + } + return delegate.Shown{}, false +} + +// presentStep is one finished tool call: the verb a person would use for it, +// what it was aimed at, and for a command how it came out. +func presentStep(action delegate.Action) (delegate.Shown, bool) { + tool := strings.TrimSpace(action.Tool) + about := strings.TrimSpace(action.Command) + if tool != "" { + about = strings.TrimSpace(strings.TrimPrefix(about, tool+":")) + } + shown := delegate.Shown{Step: stepWords[action.Step]} + own := ownRecord(action.Step) + switch tool { + case "submit": + // The hand-in's own stage says whether it was taken and what it held. + return delegate.Shown{}, false + case "bash": + if action.Step == app.StepVerify { + // senior-dev's own check: the command is the whole of what it did. + shown.Text = about + } else { + shown.Text = "ran " + about + } + shown.Outcome = delegate.ExitWord(action.Exit) + if action.Exit == nil { + shown.Outcome = "did not finish" + } + case "read": + shown.Text = "read " + firstOf(own, about) + case "write": + switch action.Step { + case app.StepPin: + shown.Text = "pinned its check" + case app.StepChecklist: + shown.Text = "wrote its checklist" + default: + shown.Text = "wrote " + firstOf(own, about) + } + case "edit", "apply_patch": + switch action.Step { + case app.StepPin: + shown.Text = "changed its pinned check" + case app.StepChecklist: + shown.Text = "updated its checklist" + default: + shown.Text = "edited " + firstOf(own, patchedFile(tool, about)) + } + case "grep": + shown.Text = "searched " + about + case "glob": + shown.Text = "listed " + about + case "webfetch": + shown.Text = "fetched " + about + case "websearch": + shown.Text = "searched the web for " + about + case "question": + shown.Text = "asked a question, with nobody there to answer it" + default: + shown.Text = strings.TrimSpace(action.Command) + } + shown.Detail = stepDetail(action) + // A CHANGE TO THE WORK WEARS ITS LINES, `+N,-M`, the way git counts them. + // senior-dev's own records — its spec, pinned check and checklist — are + // its bookkeeping, not the work, and wear none. + if (tool == "write" || tool == "edit" || tool == "apply_patch") && own == "" && action.Added != nil && action.Removed != nil { + shown.Lines, shown.Added, shown.Removed = true, *action.Added, *action.Removed + } + return shown, strings.TrimSpace(shown.Text) != "" +} + +// stepDetail is the whole of one step as the log kept it: the command or +// argument the tool was called with, and what came back, for the page to open +// under the step's one line. +func stepDetail(action delegate.Action) string { + var parts []string + if command := strings.TrimSpace(action.Command); command != "" { + parts = append(parts, command) + } + if observation := strings.TrimRight(action.Observation, " \n\t"); strings.TrimSpace(observation) != "" { + parts = append(parts, observation) + } + return strings.Join(parts, "\n\n") +} + +// ownRecord is how the page names one of senior-dev's own records when an +// action served its step, and "" for every other step. +func ownRecord(step string) string { + switch step { + case app.StepBrief: + return "its spec" + case app.StepPin: + return "its pinned check" + case app.StepChecklist: + return "its checklist" + } + return "" +} + +// patchedFile is the first file a patch names, from its own header, when the +// action was a patch; anything else is what it was about already. +func patchedFile(tool, about string) string { + if tool != "apply_patch" { + return about + } + for _, header := range []string{"*** Update File: ", "*** Add File: ", "*** Delete File: "} { + if at := strings.Index(about, header); at >= 0 { + name := strings.TrimSpace(about[at+len(header):]) + if end := strings.Index(name, " "); end > 0 { + name = name[:end] + } + if name != "" { + return name + } + } + } + return "its files" +} + +// presentStage is one stage record. A stage that is only machinery — the +// run's contract, a model turn being configured, a withdrawn call, the usage +// rollup — is left out; so is one another line already says (the hand-in's +// `implement · submitted`, the check's `landing · checked`, a tree left as it +// was). `implement · running` is the reader's own ([presentActions]). +func presentStage(stage, status string, facts stageFacts) (delegate.Shown, bool) { + switch stage + "/" + status { + case "bootstrap/ready": + // How it keeps its record of the tree: in git, or — in a folder with no + // git history, `--in-place` — in checkpoints of its own outside it. + outcome := facts.text("recorder") + if outcome == "snapshot" { + outcome = "no git history" + } + return delegate.Shown{Step: setupWord, Text: "set up its workspace", Outcome: outcome}, true + case "intake/captured": + return delegate.Shown{Step: stepWords[app.StepBrief], Text: "wrote your brief down as its spec"}, true + + case "implement/transport-retry": + text := "the call to its model dropped; started a fresh turn" + if retry, most := facts.whole("retry"), facts.whole("max_retries"); retry > 0 && most > 0 { + text += fmt.Sprintf(" (retry %d of %d)", retry, most) + } + return delegate.Shown{Text: text, Steer: true}, true + case "implement/tool-call-leak": + return delegate.Shown{Text: "its model wrote a tool call as text; told it to call the tool", Steer: true}, true + case "implement/turn-error": + text := "its model's turn failed" + if why := facts.text("error"); why != "" { + text += ": " + why + } + return delegate.Shown{Text: text}, true + case "implement/unsubmitted": + return delegate.Shown{Text: "stopped without handing in its work"}, true + + case "compaction-capacity/pinned": + text := "learned how much its model can hold" + if limit := facts.whole("limit_tokens"); limit > 0 { + text = fmt.Sprintf("learned its model holds %s tokens", thousands(limit)) + } + return delegate.Shown{Text: text}, true + case "compaction/summarized", "compaction/fallback": + shown := delegate.Shown{Text: "compacted its memory", Memory: true} + if status == "fallback" { + shown.Outcome = "kept its own record" + } + return shown, true + case "model-switch/switched": + to := facts.text("to") + if to == "" { + return delegate.Shown{}, false + } + return delegate.Shown{Text: "switched to " + modelWord(to), Model: to, Reason: switchReason(facts.text("reason"))}, true + + case "submit/frozen": + var outcome []string + if files := facts.whole("patch_files"); files > 0 { + outcome = append(outcome, plural(files, "file", "files")) + } + if items := facts.whole("checklist_items"); items > 0 { + outcome = append(outcome, fmt.Sprintf("%d of %d ticked", facts.whole("checklist_ticked"), items)) + } + return delegate.Shown{Step: stepWords[app.StepSubmit], Text: "handed in its work", Outcome: strings.Join(outcome, " · ")}, true + case "submit/refused": + return delegate.Shown{Step: stepWords[app.StepSubmit], Text: "its hand-in was refused", Outcome: refusalWord(facts.text("reason_class"))}, true + + case "verification/running": + return delegate.Shown{Step: stepWords[app.StepVerify], Text: "checked its work itself, with the project's own build and tests"}, true + case "verification/pass", "verification/fail": + shown := delegate.Shown{Step: stepWords[app.StepVerify]} + switch { + case facts.yes("vacuous"): + shown.Text = "found no build or tests to run" + case status == "pass": + shown.Text = "the project's own build and tests pass" + default: + shown.Text = "the project's own build or tests fail" + } + if commands := facts.whole("commands"); commands > 0 { + shown.Outcome = plural(commands, "command", "commands") + } + return shown, true + case "landing/repair-turn": + return delegate.Shown{Step: stepWords[app.StepImplement], Steer: true, Text: "time is short: gave its model one last turn to finish"}, true + case "landing/restored": + return delegate.Shown{Step: finishWord, Text: "put the tree back to " + app.RestoredFrom(facts.text("source"))}, true + case "landing/restore-failed": + return delegate.Shown{Step: finishWord, Text: "could not put the tree back"}, true + case "ship/restored": + return delegate.Shown{Step: finishWord, Text: "put back the work it handed in, which had changed since"}, true + case "ship/restore-failed": + return delegate.Shown{Step: finishWord, Text: "could not put back the work it handed in"}, true + case "patch-summary/completed": + files := facts.whole("files") + if files < 1 { + return delegate.Shown{Step: finishWord, Text: "its change is empty"}, true + } + outcome := plural(files, "file", "files") + if facts.has("additions") || facts.has("deletions") { + outcome += fmt.Sprintf(" · +%d -%d", facts.whole("additions"), facts.whole("deletions")) + } + return delegate.Shown{Step: finishWord, Text: "measured its change", Outcome: outcome}, true + } + return delegate.Shown{}, false +} + +// refusalWord is a refused hand-in's reason, in a person's words. +func refusalWord(class string) string { + switch class { + case "already-submitted": + return "it had already handed in" + case "empty-tree": + return "nothing had changed" + case "no-checklist": + return "it had no checklist" + case "capture-error", "record-error": + return "the tree could not be recorded" + } + return "" +} + +// switchReason is why the router moved the coder, in a person's words, or "" +// when the move was the router's ordinary choice. +func switchReason(reason string) string { + reason = strings.TrimPrefix(reason, "constraint-relaxed:") + switch reason { + case "previous-cooling": + return "the last one kept failing" + case "previous-rate-limited": + return "the last one was rate-limited" + case "previous-busy": + return "the last one was busy" + case "better-score": + return "it was doing better" + case "all-cooling": + return "every model was failing" + } + return "" +} + +// modelWord is a model id as a line names it: the part after the last vendor. +func modelWord(id string) string { + id = strings.TrimSpace(id) + if at := strings.LastIndexByte(id, '/'); at >= 0 && at+1 < len(id) { + return id[at+1:] + } + return id +} + +func firstOf(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func plural(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return fmt.Sprintf("%d %s", n, many) +} + +// thousands writes a count with its thousands grouped, the way a person reads a +// window size. +func thousands(n int) string { + digits := fmt.Sprint(n) + var out strings.Builder + for i, digit := range digits { + if i > 0 && (len(digits)-i)%3 == 0 { + out.WriteByte(',') + } + out.WriteRune(digit) + } + return out.String() +} + +// stageFacts is a stage record's data, read forgivingly: a key that is absent +// or of another shape reads as nothing. +type stageFacts map[string]any + +func stageFactsOf(raw json.RawMessage) stageFacts { + var facts stageFacts + if len(raw) == 0 || json.Unmarshal(raw, &facts) != nil { + return nil + } + return facts +} + +func (f stageFacts) has(key string) bool { _, ok := f[key]; return ok } + +func (f stageFacts) text(key string) string { + text, _ := f[key].(string) + return strings.TrimSpace(text) +} + +func (f stageFacts) whole(key string) int { + n, _ := f[key].(float64) + return int(n) +} + +func (f stageFacts) yes(key string) bool { + yes, _ := f[key].(bool) + return yes +} diff --git a/internal/seniordev/actions_test.go b/internal/seniordev/actions_test.go new file mode 100644 index 0000000000..feffd7ad58 --- /dev/null +++ b/internal/seniordev/actions_test.go @@ -0,0 +1,196 @@ +//go:build !windows + +package seniordev + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// EVERY STEP OF senior-dev's PROCESS HAS ONE WORD ITS PAGE PRINTS, and every +// word is one plain lowercase word with no machinery in it — the same word leads +// its task's row while it is in that step. +func TestEveryStepHasOnePlainWord(t *testing.T) { + banned := []string{"auditor", "audit", "verdict", "verified", "refuted", "runtime", "contract", "router"} + words := map[string]bool{setupWord: true, finishWord: true} + for _, step := range app.Steps { + word := stepWords[step] + if word == "" { + t.Errorf("step %q has no word", step) + } + words[word] = true + } + for word := range words { + if word != strings.ToLower(word) || strings.ContainsAny(word, " \t") { + t.Errorf("step word %q is not one lowercase word", word) + } + for _, bad := range banned { + if strings.Contains(word, bad) { + t.Errorf("step word %q says %q", word, bad) + } + } + } +} + +// actionLog is a run as senior-dev reports it, each record received a second +// after the one before. +func actionLog(records ...delegate.Action) []delegate.Action { + start := time.Date(2026, 9, 24, 9, 0, 0, 0, time.UTC) + for i := range records { + records[i].At = start.Add(time.Duration(i) * time.Second) + } + return records +} + +func stage(name, status string, data map[string]any) delegate.Action { + raw, _ := json.Marshal(data) + if data == nil { + raw = nil + } + return delegate.Action{Kind: delegate.ActionStage, Stage: name, Status: status, Data: raw} +} + +func step(tool, step, command string, exit ...int) delegate.Action { + action := delegate.Action{Kind: delegate.ActionStep, Tool: tool, Step: step, Command: tool + ": " + command} + if len(exit) > 0 { + action.Exit = &exit[0] + } + return action +} + +// A RUN READS AS senior-dev's ACTIONS, EACH UNDER THE STEP OF ITS PROCESS IT +// SERVED: set up, the brief written down as its spec, what it read and ran and +// changed, the hand-in with its size and ticks, its own check command by +// command and the result, what it did to the tree and the change it measured, +// and its ending in its own sentence. Machinery — the run's contract, a model +// turn being configured, the usage rollup, the submit call itself — is left +// out; a nudge is senior-dev steering its model, and a retry of the same +// attempt is not a second nudge. +func TestARunReadsAsSeniorDevsActionsUnderItsSteps(t *testing.T) { + log := actionLog( + stage("bootstrap", "ready", map[string]any{"recorder": "git"}), + stage("run-contract", "ready", nil), + stage("intake", "captured", map[string]any{"spec_bytes": 42}), + stage("landing", "start-captured", nil), + stage("implement", "running", map[string]any{"attempt": 0}), + stage("agent-runtime", "configured", nil), + step("read", app.StepBrief, "/copy/.senior-dev/spec.md"), + step("read", app.StepExplore, "internal/auth/middleware.go"), + step("bash", app.StepExplore, "go test ./internal/auth/...", 1), + step("write", app.StepPin, ".senior-dev/pinned.txt"), + step("write", app.StepChecklist, ".senior-dev/checklist.md"), + step("edit", app.StepImplement, "internal/auth/middleware.go"), + stage("compaction", "summarized", map[string]any{"summary_status": "valid"}), + stage("model-switch", "switched", map[string]any{"from": "openrouter/vendor/one", "to": "openrouter/vendor/two", "reason": "previous-rate-limited"}), + stage("implement", "running", map[string]any{"attempt": 1}), + stage("implement", "transport-retry", map[string]any{"attempt": 1, "retry": 1, "max_retries": 3}), + stage("implement", "running", map[string]any{"attempt": 1}), + step("bash", app.StepImplement, "go test ./internal/auth/...", 0), + step("submit", app.StepSubmit, "tests pass"), + stage("submit", "frozen", map[string]any{"patch_files": 4, "checklist_items": 5, "checklist_ticked": 5}), + stage("implement", "submitted", nil), + stage("verification", "running", map[string]any{"commands": 2}), + step("bash", app.StepVerify, "go build ./...", 0), + step("bash", app.StepVerify, "go test ./...", 2), + stage("verification", "fail", map[string]any{"commands": 2}), + stage("ship", "unchanged", nil), + stage("patch-summary", "completed", map[string]any{"files": 4, "additions": 120, "deletions": 30}), + stage("agent-summary", "completed", nil), + delegate.Action{Kind: delegate.ActionEnd, Status: delegate.StatusFail, Message: "submitted a change that the project's own build or tests do not pass"}, + ) + read := Program.Reader() + type line struct{ step, text, outcome string } + var got []line + steers := 0 + for _, action := range log { + shown, ok := read(action) + if !ok { + continue + } + if !shown.At.Equal(action.At) { + t.Fatalf("%q lost its moment: %v, want %v", shown.Text, shown.At, action.At) + } + if shown.Steer { + steers++ + } + got = append(got, line{shown.Step, shown.Text, shown.Outcome}) + } + want := []line{ + {"setup", "set up its workspace", "git"}, + {"spec", "wrote your brief down as its spec", ""}, + {"spec", "read its spec", ""}, + {"explore", "read internal/auth/middleware.go", ""}, + {"explore", "ran go test ./internal/auth/...", "fails · exit 1"}, + {"pin", "pinned its check", ""}, + {"checklist", "wrote its checklist", ""}, + {"implement", "edited internal/auth/middleware.go", ""}, + {"", "compacted its memory", ""}, + {"", "switched to two", ""}, + {"implement", "told its model what it found, and to finish and hand in (nudge 1)", ""}, + {"", "the call to its model dropped; started a fresh turn (retry 1 of 3)", ""}, + {"implement", "ran go test ./internal/auth/...", "passes"}, + {"submit", "handed in its work", "4 files · 5 of 5 ticked"}, + {"verify", "checked its work itself, with the project's own build and tests", ""}, + {"verify", "go build ./...", "passes"}, + {"verify", "go test ./...", "fails · exit 2"}, + {"verify", "the project's own build or tests fail", "2 commands"}, + {"finish", "measured its change", "4 files · +120 -30"}, + {"finish", "submitted a change that the project's own build or tests do not pass", ""}, + } + if len(got) != len(want) { + t.Fatalf("read %d lines, want %d:\n%+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("line %d = %+v, want %+v", i, got[i], want[i]) + } + } + if steers != 2 { + t.Fatalf("%d lines are senior-dev steering its model, want the nudge and the retry", steers) + } +} + +// A SWITCH NAMES THE MODEL AND WHY, and a compaction whose summary failed says +// the run kept its own record instead. +func TestASwitchAndACompactionSayWhatHappened(t *testing.T) { + read := Program.Reader() + switched, ok := read(stage("model-switch", "switched", map[string]any{"to": "openrouter/vendor/two", "reason": "previous-rate-limited"})) + if !ok || switched.Model != "openrouter/vendor/two" || switched.Reason != "the last one was rate-limited" { + t.Fatalf("the switch read %+v", switched) + } + fallback, ok := read(stage("compaction", "fallback", nil)) + if !ok || !fallback.Memory || fallback.Outcome != "kept its own record" { + t.Fatalf("the fallback compaction read %+v", fallback) + } +} + +// A CHANGE TO THE WORK WEARS ITS LINES, and senior-dev's own records do not: +// an implement step's edit counts `+N,-M`, the checklist it writes counts +// nothing, a step whose program counted nothing wears nothing, and every step +// keeps its whole self for the page to open. +func TestAChangeToTheWorkWearsItsLinesAndItsOwnRecordsDoNot(t *testing.T) { + counted := func(action delegate.Action, added, removed int) delegate.Action { + action.Added, action.Removed = &added, &removed + action.Observation = "Edit applied successfully." + return action + } + read := Program.Reader() + edit, _ := read(counted(step("edit", "implement", "internal/auth/middleware.go"), 12, 3)) + if !edit.Lines || edit.Added != 12 || edit.Removed != 3 { + t.Fatalf("an implement edit = %+v, want +12,-3", edit) + } + if edit.Detail != "edit: internal/auth/middleware.go\n\nEdit applied successfully." { + t.Fatalf("the edit's whole step = %q", edit.Detail) + } + if own, _ := read(counted(step("write", "checklist", ".senior-dev/checklist.md"), 9, 0)); own.Lines { + t.Fatalf("its own checklist wears lines: %+v", own) + } + if bare, _ := read(step("write", "implement", "a.go")); bare.Lines { + t.Fatalf("a write nobody counted wears lines: %+v", bare) + } +} diff --git a/internal/seniordev/app/args.go b/internal/seniordev/app/args.go new file mode 100644 index 0000000000..9cd72c4f5b --- /dev/null +++ b/internal/seniordev/app/args.go @@ -0,0 +1,90 @@ +//go:build !windows + +package app + +import ( + "fmt" + "io" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" +) + +// DefaultHighModels is the pool the coder routes on when the command line +// names none: `--high` on `codeaf senior-dev run`. Each entry is a model on the +// service codeaf's model API speaks for, and senior-dev's own router picks +// among them call by call (internal/seniordev/router/adaptive); codeaf's funnel +// then serves the call the router picked. +const DefaultHighModels = "openrouter/deepseek/deepseek-v4-flash-0731,openrouter/deepseek/deepseek-v4-pro,openrouter/qwen/qwen3.6-plus,openrouter/moonshotai/kimi-k2.6,openrouter/z-ai/glm-5.1,openrouter/minimax/minimax-m2.7" + +// cliArgs is what one run was asked to do, as the command line said it: the +// run command's own flags (internal/seniordev) plus the ceilings codeaf hands +// every program it carries. senior-dev's own parser, its `--format`, `--tui` +// and help were codeaf's to replace, and are gone; this is what the run itself +// reads. +type cliArgs struct { + High string + Low string + Frontier string + // Variant is sent as `reasoning.effort`. Empty sends no `reasoning` key, + // so the service's own default applies. + Variant string + // InPlace forces the snapshot recorder: senior-dev edits the workspace + // without writing to any repository around it. Without it, the snapshot + // recorder is still chosen wherever there is no git history to use. + InPlace bool + MaxCost *float64 + MaxHours *float64 +} + +// CrewModel is a crew seat's model as a pool entry: the id filed under the +// service codeaf's model API speaks for, which is how every pool entry is +// spelled ([DefaultHighModels]). An id already filed there is left alone. +func CrewModel(id string) string { + id = strings.TrimSpace(id) + if id == "" || strings.HasPrefix(id, orclient.Service+"/") { + return id + } + return orclient.Service + "/" + id +} + +// crewPools keeps, of pools a conversation's crew filled, only the models the +// catalog can size — a call on one it cannot is a call senior-dev refuses to +// make — and says which it dropped. A --high with nothing left routes on +// [DefaultHighModels], because a crew of models this catalog does not know is +// no reason to stop a run codeaf already started; an empty --low or +// --frontier falls back to --high, as it always does. +// +// IT IS ONLY FOR A CREW. A person who types --high at a shell meant those +// models, and is told plainly when one cannot be served; a crew was chosen for +// the conversation, and a program that cannot use one of its seats uses its +// own list rather than failing an hour of work. +func crewPools(args cliArgs, known func(string) bool, notes io.Writer) cliArgs { + keep := func(raw string) string { + var kept []string + for _, ref := range splitPool(raw) { + if known(ref) { + kept = append(kept, ref) + continue + } + _, _ = fmt.Fprintf(notes, "[senior-dev] the crew's %s is not in the model catalog; it is left out of this run\n", ref) + } + return strings.Join(kept, ",") + } + args.High, args.Low, args.Frontier = keep(args.High), keep(args.Low), keep(args.Frontier) + if args.High == "" { + _, _ = fmt.Fprintf(notes, "[senior-dev] none of the crew's models can be sized; routing on senior-dev's own list\n") + args.High = DefaultHighModels + } + return args +} + +func splitPool(raw string) []string { + out := []string{} + for _, value := range strings.Split(raw, ",") { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} diff --git a/internal/seniordev/app/budget_helpers.go b/internal/seniordev/app/budget_helpers.go new file mode 100644 index 0000000000..20dadc884c --- /dev/null +++ b/internal/seniordev/app/budget_helpers.go @@ -0,0 +1,13 @@ +//go:build !windows + +package app + +// Budget queries for the solo loop. The run's ceilings are --max-cost and +// --max-hours. + +// budgetIsExhausted is the boolean-only form of budgetExhausted, for callers +// that do not need the reason string. +func (runner *pipeline) budgetIsExhausted() bool { + exhausted, _ := runner.budgetExhausted() + return exhausted +} diff --git a/internal/seniordev/app/catalog_test.go b/internal/seniordev/app/catalog_test.go new file mode 100644 index 0000000000..bbe4fbed07 --- /dev/null +++ b/internal/seniordev/app/catalog_test.go @@ -0,0 +1,75 @@ +//go:build !windows + +package app + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/modelsdev" +) + +func seniorDevCatalogFixture(t *testing.T) modelsdev.Catalog { + t.Helper() + client, err := modelsdev.New(modelsdev.Options{ + CatalogPath: "../modelsdev/testdata/catalog.json", + CacheDir: t.TempDir(), + DisableFetch: true, + }) + if err != nil { + t.Fatal(err) + } + catalog, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + return catalog +} + +func TestSeniorDevCatalogMetadataReachesSessionModel(t *testing.T) { + models := seniorDevModels{ + backend: &modelAPIBackend{catalog: seniorDevCatalogFixture(t)}, + sessionID: "ses_catalog", + agent: "coder", + } + resolved, err := models.Resolve(context.Background(), msgmodel.User{ + Model: msgmodel.UserModel{ + ProviderID: "openrouter", + ModelID: "fixture/vendor-model", + }, + }) + if err != nil { + t.Fatal(err) + } + if resolved.Calc.Cost == nil || resolved.Calc.Cost.Input != 1.25 || + resolved.Calc.Cost.Output != 4.5 || resolved.Calc.Limit.Context != 240_000 || + resolved.Calc.Limit.Input == nil || *resolved.Calc.Limit.Input != 220_000 || + resolved.Calc.Limit.Output != 12_000 || resolved.Request.MaxOutputTokens == nil || + *resolved.Request.MaxOutputTokens != 12_000 { + t.Fatalf("resolved catalog model = %#v", resolved) + } + projection, _, err := models.projection("openrouter", "fixture/vendor-model") + if err != nil { + t.Fatal(err) + } + if !projection.Capabilities.Temperature || !projection.Capabilities.Reasoning || + !projection.Capabilities.Attachment || !projection.Capabilities.ToolCall || + !projection.Capabilities.Input["text"] || !projection.Capabilities.Input["image"] || + projection.Capabilities.Input["audio"] || !projection.Capabilities.Output["text"] { + t.Fatalf("engine capability projection = %#v", projection.Capabilities) + } + + // OpenRouter ids are split at the provider prefix before the exact catalog + // key lookup, matching Provider.parseModel/splitModel. + if _, err := models.GetModel( + context.Background(), "", "openrouter/fixture/vendor-model", + ); err != nil { + t.Fatalf("normalized OpenRouter id: %v", err) + } + if _, err := models.GetModel( + context.Background(), "openrouter", "fixture/unknown", + ); err == nil { + t.Fatal("unknown catalog model unexpectedly resolved") + } +} diff --git a/internal/seniordev/app/compaction_events.go b/internal/seniordev/app/compaction_events.go new file mode 100644 index 0000000000..2d1b50030e --- /dev/null +++ b/internal/seniordev/app/compaction_events.go @@ -0,0 +1,61 @@ +//go:build !windows + +package app + +import ( + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction" +) + +var seniorDevCompactionDecisionEvent = bus.Define( + "session.compaction.decision", compaction.CompactionDecision{}, +) + +// seniorDevCompactionDecisionSink hears every compaction the run makes: it +// publishes the decision on the instance bus, as it always has, and reports it +// as the `compaction` stage, so codeaf's page can say the run compacted its +// memory and whether its model summarized it or the deterministic record stood +// in. +// +// THE STAGE IS A REPORT OF A DECISION ALREADY MADE. The sink is told after the +// history has been rewritten; nothing the model sees and nothing about when or +// how the run compacts depends on it. +type seniorDevCompactionDecisionSink struct { + bus *bus.Bus + events *eventWriter +} + +func newSeniorDevCompactionDecisionSink(instance *bus.Bus, events *eventWriter) compaction.DecisionSink { + if instance == nil && events == nil { + return nil + } + return seniorDevCompactionDecisionSink{bus: instance, events: events} +} + +func (sink seniorDevCompactionDecisionSink) CompactionDecision( + decision compaction.CompactionDecision, +) { + if sink.bus != nil { + sink.bus.Publish(seniorDevCompactionDecisionEvent, decision) + } + if sink.events != nil { + sink.events.stage("compaction", compactionStatus(decision.SummaryStatus), map[string]any{ + "summary_status": decision.SummaryStatus, + "before_tokens": decision.Before, "after_tokens": decision.After, + }) + } +} + +// compactionStatus is the compaction stage's status: `summarized` when the +// model's summary stands in the history (valid, or normalized into shape), and +// `fallback` for every other ending, each of which installs the deterministic +// record in its place (compaction's CompactionDecision.SummaryStatus). +func compactionStatus(summary string) string { + switch summary { + case "valid", "normalized": + return "summarized" + } + return "fallback" +} + +var _ compaction.DecisionSink = seniorDevCompactionDecisionSink{} diff --git a/internal/seniordev/app/compaction_pin.go b/internal/seniordev/app/compaction_pin.go new file mode 100644 index 0000000000..498a8bb43b --- /dev/null +++ b/internal/seniordev/app/compaction_pin.go @@ -0,0 +1,192 @@ +//go:build !windows + +package app + +import ( + "math" + "regexp" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +// The compaction budget is the model's advertised window (capped by +// capacity_tokens). When a request is routed to an endpoint that serves a +// smaller window than the catalog advertises, the provider rejects it and the +// step loop compacts (ResultCompact). That already recovers the run; what it +// does not do is remember the smaller limit, so the context grows back toward +// the window and can be rejected again on the same route. A pin records the +// limit the provider named, for the rest of the session, as a capacity_tokens +// minimum. +// +// Only a limit stated in the rejection text is pinned. A rejection that names +// no number is recorded as such and pins nothing: the compaction still +// happens, and a pin that cannot be justified from the error would be a +// silent behaviour change. + +// overflowLimitPatterns are the context-overflow messages (see +// retrysched.contextOverflowPatterns) that carry the endpoint's limit. +var overflowLimitPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)maximum context length is (\d+) tokens`), + regexp.MustCompile(`(?i)maximum prompt length is (\d+)`), + regexp.MustCompile(`(?i)context length is only (\d+) tokens`), + regexp.MustCompile(`(?i)exceeds the limit of (\d+)`), + regexp.MustCompile(`(?i)too large for model with (\d+) maximum context length`), +} + +// parseContextLimit extracts the limit an overflow rejection names. +func parseContextLimit(text string) (float64, bool) { + for _, pattern := range overflowLimitPatterns { + if match := pattern.FindStringSubmatch(text); match != nil { + if value, err := strconv.ParseFloat(match[1], 64); err == nil && value > 0 { + return value, true + } + } + } + return 0, false +} + +// overflowText joins every text a classified provider error carries: the +// message and, when present, the response body the provider sent. +func overflowText(err error) string { + classified := retrysched.FromError(err) + parts := []string{err.Error()} + for _, extra := range []*string{classified.Data.Message, classified.Data.ResponseBody} { + if extra == nil || *extra == "" { + continue + } + duplicate := false + for _, have := range parts { + if strings.Contains(have, *extra) || strings.Contains(*extra, have) { + duplicate = true + break + } + } + if !duplicate { + parts = append(parts, *extra) + } + } + return strings.Join(parts, "\n") +} + +// pinnedCapacityFor is the session's pinned capacity, if a rejection set one. +func (backend *modelAPIBackend) pinnedCapacityFor(sessionID string) (float64, bool) { + if backend == nil { + return 0, false + } + backend.pinMu.Lock() + defer backend.pinMu.Unlock() + value, ok := backend.pinnedCapacity[sessionID] + return value, ok +} + +// overflowConfigFor is the compaction config a session runs under: the project +// config, with a pinned capacity folded in as a minimum. +func (backend *modelAPIBackend) overflowConfigFor(sessionID string) (overflow.Config, error) { + cfg, err := backend.config.overflowConfig() + if err != nil { + return cfg, err + } + return backend.withPinnedCapacity(cfg, sessionID), nil +} + +// withPinnedCapacity folds the session's pin into a compaction config as a +// capacity_tokens minimum. Unpinned sessions get cfg back as is. +func (backend *modelAPIBackend) withPinnedCapacity(cfg overflow.Config, sessionID string) overflow.Config { + pinned, ok := backend.pinnedCapacityFor(sessionID) + if !ok { + return cfg + } + block := overflow.CompactionConfig{} + if cfg.Compaction != nil { + block = *cfg.Compaction + } + if block.CapacityTokens == nil || *block.CapacityTokens > pinned { + block.CapacityTokens = &pinned + } + cfg.Compaction = &block + return cfg +} + +// pinCapacityOnOverflow inspects a failed request. A context-overflow +// rejection that names a limit pins the session's capacity to that limit +// minus the output reservation; one that does not is recorded and pins +// nothing. Every path emits an event, so a pinned run is visible in the +// stream. +func (backend *modelAPIBackend) pinCapacityOnOverflow( + sessionID, agent, providerID, modelID string, err error, +) { + if backend == nil || err == nil { + return + } + cfg, cfgErr := backend.config.overflowConfig() + if cfgErr != nil { + return + } + if !retrysched.IsContextOverflow(retrysched.FromError(err)) { + return + } + text := overflowText(err) + excerpt := text + if len(excerpt) > 240 { + excerpt = excerpt[:240] + } + data := map[string]any{ + "agent": agent, "session_id": sessionID, + "provider_id": providerID, "model_id": modelID, + "message": excerpt, + } + limit, ok := parseContextLimit(text) + if !ok { + data["source"] = "unparsed" + backend.emitStage("compaction-capacity", "overflow-unpinned", data) + return + } + reservation := float64(0) + if _, model, projErr := (seniorDevModels{backend: backend, agent: agent}).projection(providerID, modelID); projErr == nil { + reservation = calc.MaxOutputTokens(model) + } + if cfg.Compaction != nil && cfg.Compaction.Reserved != nil { + reservation = *cfg.Compaction.Reserved + } + pinned := math.Floor(limit - reservation) + if pinned <= 0 || math.IsNaN(pinned) || math.IsInf(pinned, 0) { + data["source"] = "unparsed" + data["limit_tokens"] = limit + data["reason"] = "the named limit leaves no input space after the output reservation" + backend.emitStage("compaction-capacity", "overflow-unpinned", data) + return + } + backend.pinMu.Lock() + if backend.pinnedCapacity == nil { + backend.pinnedCapacity = map[string]float64{} + } + if previous, exists := backend.pinnedCapacity[sessionID]; exists && previous < pinned { + pinned = previous + } + backend.pinnedCapacity[sessionID] = pinned + backend.pinMu.Unlock() + data["source"] = "parsed" + data["limit_tokens"] = limit + data["reservation_tokens"] = reservation + data["pinned_capacity_tokens"] = pinned + if pinnedCfg, err := backend.overflowConfigFor(sessionID); err == nil { + if _, model, projErr := (seniorDevModels{backend: backend, agent: agent}).projection(providerID, modelID); projErr == nil { + marks := overflow.Watermarks(overflow.UsableInput{Cfg: pinnedCfg, Model: model}) + data["capacity_tokens"] = marks.Capacity + data["high_tokens"] = marks.High + data["low_tokens"] = marks.Low + } + } + backend.emitStage("compaction-capacity", "pinned", data) +} + +func (backend *modelAPIBackend) emitStage(stage, status string, data map[string]any) { + if backend == nil || backend.events == nil { + return + } + backend.events.stage(stage, status, data) +} diff --git a/internal/seniordev/app/compaction_pin_test.go b/internal/seniordev/app/compaction_pin_test.go new file mode 100644 index 0000000000..6a9f5cb46c --- /dev/null +++ b/internal/seniordev/app/compaction_pin_test.go @@ -0,0 +1,163 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "net/http" + "strings" + "testing" + + configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched" +) + +func TestParseContextLimitReadsTheNumberedOverflowMessages(t *testing.T) { + for text, want := range map[string]float64{ + "This endpoint's maximum context length is 262144 tokens. However, you requested about 301000 tokens": 262144, + "maximum prompt length is 131072": 131072, + "context length is only 200000 tokens": 200000, + "input exceeds the limit of 128000": 128000, + "prompt too large for model with 65536 maximum context length": 65536, + } { + if got, ok := parseContextLimit(text); !ok || got != want { + t.Errorf("%q → %v,%v want %v", text, got, ok, want) + } + } + for _, text := range []string{"prompt is too long", "context_length_exceeded", "400 (no body)", ""} { + if _, ok := parseContextLimit(text); ok { + t.Errorf("%q should not parse a limit", text) + } + } +} + +// overflowBackend is a backend whose transport rejects every request with the +// given body, so DoStream returns the provider error the pin logic inspects. +func overflowBackend(t *testing.T, info configpkg.Info, status int, body string) (*modelAPIBackend, *bytes.Buffer) { + t.Helper() + cfg, err := newSeniorDevConfig(info) + if err != nil { + t.Fatal(err) + } + var events bytes.Buffer + backend := &modelAPIBackend{ + api: testModelAPI, catalog: seniorDevCatalogFixture(t), config: cfg, + events: newEventWriter(&events), + client: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + return recordedResponse(request, status, "application/json", body), nil + })}, + } + return backend, &events +} + +func overflowStream(t *testing.T, backend *modelAPIBackend, session string) error { + t.Helper() + projection, _, err := (seniorDevModels{backend: backend, agent: "coder"}).projection("openrouter", "fixture/vendor-model") + if err != nil { + t.Fatal(err) + } + client := seniorDevStreamClient{ + backend: backend, sessionID: session, agent: "coder", model: projection, + client: &orclient.Client{ + BaseURL: backend.api.BaseURL, Fetcher: backend.fetch, + Compatibility: orclient.CompatibilityCompatible, + }, + } + _, err = client.DoStream(context.Background(), orclient.RequestParams{ + ModelID: "fixture/vendor-model", + Prompt: []msgmodel.ModelMessage{{Role: "user", Content: "hello"}}, + }) + return err +} + +const overflowBody = `{"error":{"message":"This endpoint's maximum context length is 131072 tokens. However, you requested about 150000 tokens (150000 of text input). Please reduce the length of either one.","code":400}}` + +func TestContextOverflowPinsCapacityUnderTheWindowPolicy(t *testing.T) { + backend, events := overflowBackend(t, configpkg.Info{"compaction": map[string]any{"policy": "window"}}, 400, overflowBody) + + err := overflowStream(t, backend, "ses-pin") + if err == nil || !retrysched.IsContextOverflow(retrysched.FromError(err)) { + t.Fatalf("DoStream error = %v, want a context-overflow rejection passed through", err) + } + // Fixture output limit 12,000 is the reservation: 131,072 − 12,000. The + // fixture's own input window (220,000 − 12,000 = 208,000) is wider, so + // the pin is what tightens. + pinned, ok := backend.pinnedCapacityFor("ses-pin") + if !ok || pinned != 119_072 { + t.Fatalf("pinned capacity = %v,%v want 119072", pinned, ok) + } + if _, ok := backend.pinnedCapacityFor("ses-other"); ok { + t.Fatal("a pin must be per session") + } + got := events.String() + for _, want := range []string{ + `"stage":"compaction-capacity"`, `"status":"pinned"`, `"source":"parsed"`, + `"limit_tokens":131072`, `"reservation_tokens":12000`, `"pinned_capacity_tokens":119072`, + `"capacity_tokens":119072`, `"high_tokens":71443`, `"low_tokens":47628`, + `"session_id":"ses-pin"`, `"model_id":"fixture/vendor-model"`, + } { + if !strings.Contains(got, want) { + t.Errorf("pinned event lacks %s: %s", want, got) + } + } + // The session's compaction config now carries the pin as capacity_tokens, + // and the project config is untouched for other sessions. + cfg, err := backend.overflowConfigFor("ses-pin") + if err != nil || cfg.Compaction.CapacityTokens == nil || *cfg.Compaction.CapacityTokens != 119_072 { + t.Fatalf("session config = %+v, %v", cfg.Compaction, err) + } + other, _ := backend.overflowConfigFor("ses-other") + if other.Compaction.CapacityTokens != nil { + t.Fatalf("other session inherited the pin: %+v", other.Compaction) + } + // A later rejection naming a larger limit never raises the pin. + backend.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + return recordedResponse(request, 400, "application/json", `{"error":{"message":"maximum context length is 262144 tokens","code":400}}`), nil + })} + _ = overflowStream(t, backend, "ses-pin") + if pinned, _ := backend.pinnedCapacityFor("ses-pin"); pinned != 119_072 { + t.Fatalf("pin was raised to %v", pinned) + } + // And the configured event for a later turn in that session records it. + var provenance bytes.Buffer + runtime := &runtimeAdapter{config: backend.config, backend: backend, events: newEventWriter(&provenance)} + if _, err := runtime.configureTurn(turn{Agent: "coder", SessionID: "ses-pin", AgentMarkdown: "p", ProviderID: "openrouter", ModelID: "fixture/vendor-model"}); err != nil { + t.Fatal(err) + } + if got := provenance.String(); !strings.Contains(got, `"pinned_capacity_tokens":119072`) || !strings.Contains(got, `"high_tokens":71443`) { + t.Fatalf("configured event does not carry the pin: %s", got) + } +} + +func TestContextOverflowWithoutANumberPinsNothing(t *testing.T) { + backend, events := overflowBackend(t, configpkg.Info{"compaction": map[string]any{"policy": "window"}}, 400, + `{"error":{"message":"prompt is too long for this model","code":400}}`) + if err := overflowStream(t, backend, "ses-unparsed"); err == nil { + t.Fatal("expected the rejection to pass through") + } + if _, ok := backend.pinnedCapacityFor("ses-unparsed"); ok { + t.Fatal("an unparsed rejection must not pin") + } + got := events.String() + if !strings.Contains(got, `"status":"overflow-unpinned"`) || !strings.Contains(got, `"source":"unparsed"`) { + t.Fatalf("unparsed rejection not recorded: %s", got) + } + cfg, _ := backend.overflowConfigFor("ses-unparsed") + if cfg.Compaction.CapacityTokens != nil { + t.Fatalf("config changed without a pin: %+v", cfg.Compaction) + } +} + +func TestNonOverflowErrorsDoNotPin(t *testing.T) { + backend, events := overflowBackend(t, configpkg.Info{"compaction": map[string]any{"policy": "window"}}, 429, + `{"error":{"message":"rate limited","code":429}}`) + if err := overflowStream(t, backend, "ses-429"); err == nil { + t.Fatal("expected the error to pass through") + } + if _, ok := backend.pinnedCapacityFor("ses-429"); ok || strings.Contains(events.String(), "compaction-capacity") { + t.Fatalf("a non-overflow error pinned or emitted: %s", events.String()) + } +} diff --git a/internal/seniordev/app/compaction_policy_test.go b/internal/seniordev/app/compaction_policy_test.go new file mode 100644 index 0000000000..814dd8865f --- /dev/null +++ b/internal/seniordev/app/compaction_policy_test.go @@ -0,0 +1,106 @@ +//go:build !windows + +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config" +) + +// A malformed compaction block must fail the config load, not the first turn. +func TestCompactionPolicyIsValidatedAtConfigLoad(t *testing.T) { + for name, block := range map[string]map[string]any{ + "unknown policy": {"policy": "adaptive"}, + "legacy policy": {"policy": "legacy"}, + "fraction of one": {"policy": "window", "preserve_recent_fraction": 1}, + "negative fraction": {"policy": "window", "preserve_recent_fraction": -0.2}, + "zero capacity": {"policy": "window", "capacity_tokens": 0}, + "non-numeric capacity": {"policy": "window", "capacity_tokens": "lots"}, + } { + _, err := newSeniorDevConfig(configpkg.Info{"compaction": block}) + if err == nil || !strings.Contains(err.Error(), "compaction") { + t.Errorf("%s: newSeniorDevConfig error = %v, want a compaction config failure", name, err) + } + } + for name, block := range map[string]map[string]any{ + "window by name": {"policy": "window", "capacity_tokens": 500000, "preserve_recent_fraction": 0.2}, + "window by absence": {"preserve_recent_tokens": 60000}, + } { + if _, err := newSeniorDevConfig(configpkg.Info{"compaction": block}); err != nil { + t.Errorf("%s: newSeniorDevConfig error = %v, want none", name, err) + } + } + if _, err := newSeniorDevConfig(configpkg.Info{}); err != nil { + t.Errorf("no compaction block: %v", err) + } +} + +// The configured event carries the compaction budget the turn runs under, so +// the budget a run used can be read back from the stream alone. +func TestConfiguredTurnProvenanceCarriesCompactionBudget(t *testing.T) { + emit := func(t *testing.T, info configpkg.Info, backend backend) map[string]any { + t.Helper() + cfg, err := newSeniorDevConfig(info) + if err != nil { + t.Fatal(err) + } + var output bytes.Buffer + runtime := &runtimeAdapter{config: cfg, backend: backend, events: newEventWriter(&output)} + if _, err := runtime.configureTurn(turn{ + Agent: "coder", SessionID: "ses-compaction", AgentMarkdown: "prompt", + ProviderID: "openrouter", ModelID: "fixture/vendor-model", + }); err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") { + var event map[string]any + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatalf("event line %q: %v", line, err) + } + if event["stage"] == "agent-runtime" && event["status"] == "configured" { + data := event["data"].(map[string]any) + record, ok := data["compaction"].(map[string]any) + if !ok { + t.Fatalf("configured event carries no compaction record: %s", line) + } + return record + } + } + t.Fatal("no configured event emitted") + return nil + } + // Fixture model: context 240,000, input 220,000, output 12,000. The + // reservation is min(12,000, 32,000) = 12,000, so raw = 208,000. + fixture := &modelAPIBackend{catalog: seniorDevCatalogFixture(t)} + + t.Run("no backend records no budget", func(t *testing.T) { + record := emit(t, configpkg.Info{}, nil) + if record["capacity_tokens"] != nil { + t.Fatalf("record = %v", record) + } + }) + t.Run("the default budgets from the window under the default cap", func(t *testing.T) { + record := emit(t, configpkg.Info{}, fixture) + // 208,000 is under the 500,000 default cap; high 124,800; low + // 83,200; tail 0.2 x high = 24,960. + if record["capacity_tokens"] != 208_000.0 || + record["high_tokens"] != 124_800.0 || record["low_tokens"] != 83_200.0 || + record["tail_budget_tokens"] != 24_960.0 || record["model_context_tokens"] != 240_000.0 { + t.Fatalf("default record = %v", record) + } + }) + t.Run("a configured cap records both the cap and its effect", func(t *testing.T) { + record := emit(t, configpkg.Info{"compaction": map[string]any{ + "policy": "window", "capacity_tokens": 100000, "preserve_recent_fraction": 0.1, + }}, fixture) + if record["configured_capacity_tokens"] != 100_000.0 || record["configured_preserve_recent_fraction"] != 0.1 || + record["capacity_tokens"] != 100_000.0 || record["high_tokens"] != 60_000.0 || + record["low_tokens"] != 40_000.0 || record["tail_budget_tokens"] != 6_000.0 { + t.Fatalf("capped record = %v", record) + } + }) +} diff --git a/internal/seniordev/app/config.go b/internal/seniordev/app/config.go new file mode 100644 index 0000000000..0a1f77dfe2 --- /dev/null +++ b/internal/seniordev/app/config.go @@ -0,0 +1,431 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/permission" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +type seniorDevConfig struct { + info configpkg.Info + service *configpkg.Service + global permission.Ruleset + agentRules map[string]permission.Ruleset + bakedRules map[string]permission.Ruleset + // variant is the run-level reasoning effort from --variant, applied to a + // turn that names none so the configured event records what is sent. + variant string +} + +func loadSeniorDevConfig(workspace string) (*seniorDevConfig, error) { + env := configpkg.NewEnv(os.LookupEnv) + globalDir, _ := env.Get("SENIOR_DEV_CONFIG_DIR") + service := configpkg.NewService(configpkg.Loader{GlobalDir: globalDir, Env: env}) + info, err := service.Get(workspace, workspace) + if err != nil { + return nil, err + } + result, err := newSeniorDevConfig(info) + if result != nil { + result.service = service + } + return result, err +} + +func newSeniorDevConfig(info configpkg.Info) (*seniorDevConfig, error) { + result := &seniorDevConfig{ + info: info, agentRules: map[string]permission.Ruleset{}, + bakedRules: map[string]permission.Ruleset{}, + } + var err error + result.global, err = configPermissionRules(info["permission"]) + if err != nil { + return nil, fmt.Errorf("permission config: %w", err) + } + if err := refuseRetiredModelKnobs(info); err != nil { + return nil, err + } + // The compaction block is parsed once here so a malformed block fails the + // load instead of the first turn. + if ovf, err := result.overflowConfig(); err != nil { + return nil, fmt.Errorf("compaction config: %w", err) + } else if err := overflow.ValidatePolicy(ovf); err != nil { + return nil, fmt.Errorf("compaction config: %w", err) + } + for _, name := range baked.ListBakedAgents() { + markdown, _ := baked.GetBakedAgentMarkdown(name) + rules, parseErr := permission.RulesetFromFrontmatter(markdown) + if parseErr != nil { + return nil, fmt.Errorf("baked agent %q permissions: %w", name, parseErr) + } + result.bakedRules[name] = rules + } + for name, raw := range objectValue(info["agent"]) { + agent := objectValue(raw) + rules := toolPermissionRules(agent["tools"]) + configured, parseErr := configPermissionRules(agent["permission"]) + if parseErr != nil { + return nil, fmt.Errorf("agent %q permission config: %w", name, parseErr) + } + result.agentRules[name] = permission.Merge(rules, configured) + } + return result, nil +} + +func configPermissionRules(value any) (permission.Ruleset, error) { + if value == nil { + return nil, nil + } + switch value.(type) { + case *configpkg.OrderedObject, string: + default: + return nil, fmt.Errorf("permission object did not preserve source order") + } + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + parsed, err := permission.ParseConfigJSON(data) + if err != nil { + return nil, err + } + return permission.FromConfig(parsed), nil +} + +func toolPermissionRules(settings any) permission.Ruleset { + entries := []configpkg.OrderedEntry{} + if ordered, ok := settings.(*configpkg.OrderedObject); ok { + entries = ordered.Entries() + } else { + mapping := objectValue(settings) + keys := make([]string, 0, len(mapping)) + for name := range mapping { + keys = append(keys, name) + } + sort.Strings(keys) + for _, name := range keys { + entries = append(entries, configpkg.OrderedEntry{Key: name, Value: mapping[name]}) + } + } + rules := make(permission.Ruleset, 0, len(entries)) + for _, entry := range entries { + name := entry.Key + enabled, ok := entry.Value.(bool) + if !ok { + continue + } + permissionName := name + if name == "write" || name == "edit" || name == "patch" || name == "apply_patch" { + permissionName = "edit" + } + action := permission.ActionDeny + if enabled { + action = permission.ActionAllow + } + rules = append(rules, permission.Rule{ + Permission: permissionName, Pattern: "*", Action: action, + }) + } + return rules +} + +func (cfg *seniorDevConfig) rulesForAgent(name string) permission.Ruleset { + if cfg == nil { + return nil + } + return permission.Merge(cfg.bakedRules[name], cfg.global, cfg.agentRules[name]) +} + +func (cfg *seniorDevConfig) registryOptions() tool.RegistryOptions { + if cfg == nil { + return tool.RegistryOptions{} + } + return tool.RegistryOptions{ + Instructions: cfg.instructions(), + Config: cfg.service, + AllowExternalDirectories: true, + // Reads may leave the workspace; writes may not (tool/path.go's + // resolveWritePath says why). + ConfineWrites: true, + PermissionRules: func(_ context.Context, call steploop.ToolCall) permission.Ruleset { + return cfg.rulesForAgent(call.Agent) + }, + } +} + +func (cfg *seniorDevConfig) instructions() []string { + if cfg == nil { + return nil + } + values, _ := cfg.info["instructions"].([]any) + out := make([]string, 0, len(values)) + for _, value := range values { + if text, ok := value.(string); ok { + out = append(out, text) + } + } + return out +} + +func (cfg *seniorDevConfig) agent(name string) map[string]any { + if cfg == nil { + return nil + } + return objectValue(objectValue(cfg.info["agent"])[name]) +} + +func (cfg *seniorDevConfig) overflowConfig() (overflow.Config, error) { + if cfg == nil { + return overflow.Config{}, nil + } + raw, err := json.Marshal(map[string]any{ + "compaction": cfg.info["compaction"], + }) + if err != nil { + return overflow.Config{}, err + } + var result overflow.Config + if err := json.Unmarshal(raw, &result); err != nil { + return overflow.Config{}, err + } + return result, nil +} + +func (cfg *seniorDevConfig) configureTurn(value turn) (turn, error) { + // Baked frontmatter is an executable agent contract, not model-visible + // decoration. Apply its deterministic controls first. An explicitly chosen + // pool model remains authoritative; a baked model is only a default when the + // caller supplied no model. Project config below has final precedence. + // + // A baked agent's temperature is deliberately not applied: no generation + // parameter is sent, so the provider's own default applies. + value = applyBakedTurnControls(value) + if cfg != nil && value.Variant == "" { + value.Variant = cfg.variant + } + return applyConfiguredTurnControls(value, cfg.agent(value.Agent)) +} + +// applyBakedTurnControls applies the deterministic controls in a baked +// agent's frontmatter. The other frontmatter control, `tier:`, is read by +// baked.TierFor at call time: it selects the router pool, not a turn field. +func applyBakedTurnControls(value turn) turn { + metadata, ok := baked.GetBakedAgentMetadata(value.Agent) + if !ok { + return value + } + if value.ProviderID == "" && value.ModelID == "" { + if model, ok := metadata["model"].(string); ok && model != "" && model != "inherit" { + value.ProviderID, value.ModelID = splitConfiguredModel(model) + } + } + if steps, ok := configNumber(metadata["steps"]); ok && steps > 0 { + value.MaxSteps = &steps + } else if steps, ok := configNumber(metadata["maxSteps"]); ok && steps > 0 { + value.MaxSteps = &steps + } + return value +} + +func applyConfiguredTurnControls(value turn, agent map[string]any) (turn, error) { + if disabled, _ := agent["disable"].(bool); disabled { + return value, fmt.Errorf("agent %q is disabled by config", value.Agent) + } + if prompt, ok := agent["prompt"].(string); ok { + value.AgentMarkdown = prompt + value.AgentPromptVerbatim = true + } + if model, ok := agent["model"].(string); ok && model != "" && model != "inherit" { + value.ProviderID, value.ModelID = splitConfiguredModel(model) + } + if variant, ok := agent["variant"].(string); ok { + value.Variant = variant + } + if steps, ok := configNumber(agent["steps"]); ok && steps > 0 { + value.MaxSteps = &steps + } else if steps, ok := configNumber(agent["maxSteps"]); ok && steps > 0 { + value.MaxSteps = &steps + } + return value, nil +} + +func (cfg *seniorDevConfig) disabledTools(agent string, ids []string) map[string]bool { + out := map[string]bool{} + if cfg == nil { + return out + } + for _, name := range permission.Disabled(ids, cfg.rulesForAgent(agent)).Values() { + out[name] = true + } + return out +} + +func (cfg *seniorDevConfig) options(agent, providerID, modelID string) *orclient.Object { + result := orclient.NewObject() + if cfg == nil { + return result + } + model := cfg.model(providerID, modelID) + for _, source := range []map[string]any{objectValue(model["options"]), objectValue(cfg.agent(agent)["options"])} { + data, err := json.Marshal(source) + if err != nil { + continue + } + parsed, err := orclient.ParseObject(data) + if err == nil { + result = orclient.MergeOptions(result, parsed) + } + } + return result +} + +// refuseRetiredModelKnobs refuses, by name, the three config keys that used +// to decide how senior-dev reached a model and no longer can: a service's +// `apiKey` and `baseURL`, and any `providerRouting` block. +// +// A REMOVED KNOB FAILS LOUDLY, which is senior-dev's rule for every knob it +// retires. It reaches a model only through the model API codeaf serves the +// run, which holds the key, the address and the routing itself; a config that +// still set them and was quietly ignored would label a run with a behaviour it +// did not have, and an apiKey or baseURL honoured would be a second road to a +// model that codeaf could not meter, cap or show. +func refuseRetiredModelKnobs(info configpkg.Info) error { + for providerID, rawProvider := range objectValue(info["provider"]) { + provider := objectValue(rawProvider) + options := objectValue(provider["options"]) + for _, key := range []string{"apiKey", "baseURL"} { + if _, set := options[key]; set && providerID == orclient.Service { + return fmt.Errorf("provider %q options.%s is not read: senior-dev reaches a model only through the model API codeaf serves it — remove the key", providerID, key) + } + } + if provider["providerRouting"] != nil { + return fmt.Errorf("provider %q providerRouting is not read: codeaf's model funnel decides which upstream serves a call — remove the block", providerID) + } + for modelID, rawModel := range objectValue(provider["models"]) { + if objectValue(rawModel)["providerRouting"] != nil { + return fmt.Errorf("provider %q model %q providerRouting is not read: codeaf's model funnel decides which upstream serves a call — remove the block", providerID, modelID) + } + } + } + for name, raw := range objectValue(info["agent"]) { + if objectValue(raw)["providerRouting"] != nil { + return fmt.Errorf("agent %q providerRouting is not read: codeaf's model funnel decides which upstream serves a call — remove the block", name) + } + } + return nil +} + +func (cfg *seniorDevConfig) provider(providerID string) map[string]any { + if cfg == nil { + return nil + } + return objectValue(objectValue(cfg.info["provider"])[providerID]) +} + +func (cfg *seniorDevConfig) model(providerID, modelID string) map[string]any { + provider := cfg.provider(providerID) + return objectValue(objectValue(provider["models"])[modelID]) +} + +func (cfg *seniorDevConfig) headers(providerID, modelID string) []orclient.HeaderPair { + if cfg == nil { + return nil + } + values := map[string]string{} + for _, source := range []map[string]any{ + objectValue(cfg.provider(providerID)["options"]), + cfg.model(providerID, modelID), + } { + for name, raw := range objectValue(source["headers"]) { + if value, ok := raw.(string); ok { + values[name] = value + } + } + } + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + result := make([]orclient.HeaderPair, 0, len(names)) + for _, name := range names { + result = append(result, orclient.HeaderPair{Name: name, Value: values[name]}) + } + return result +} + +func (cfg *seniorDevConfig) applyBackend(backend *modelAPIBackend) { + if cfg == nil || backend == nil { + return + } + backend.config = cfg + options := objectValue(cfg.provider(orclient.Service)["options"]) + if value, exists := options["timeout"]; exists { + if disabled, ok := value.(bool); ok && !disabled { + backend.totalTimeoutMS = -1 + } else if number, ok := configNumber(value); ok { + if number == 0 { + backend.totalTimeoutMS = -1 + } else if number > 0 { + backend.totalTimeoutMS = number + } + } + } + if value, exists := options["chunkTimeout"]; exists { + if disabled, ok := value.(bool); ok && !disabled { + backend.chunkTimeoutMS = -1 + } else if number, ok := configNumber(value); ok { + if number == 0 { + backend.chunkTimeoutMS = -1 + } else if number > 0 { + backend.chunkTimeoutMS = number + } + } + } +} + +func objectValue(value any) map[string]any { + object, _ := value.(map[string]any) + return object +} + +func configNumber(value any) (float64, bool) { + switch value := value.(type) { + case float64: + return value, true + case float32: + return float64(value), true + case int: + return float64(value), true + case int64: + return float64(value), true + case uint64: + return float64(value), true + case json.Number: + number, err := value.Float64() + return number, err == nil + default: + return 0, false + } +} + +func splitConfiguredModel(value string) (string, string) { + providerID, modelID, found := strings.Cut(value, "/") + if !found { + return orclient.Service, value + } + return providerID, modelID +} diff --git a/internal/seniordev/app/config_live_test.go b/internal/seniordev/app/config_live_test.go new file mode 100644 index 0000000000..245a9a6a6b --- /dev/null +++ b/internal/seniordev/app/config_live_test.go @@ -0,0 +1,262 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/permission" +) + +func TestProjectConfigChangesLiveRuntimePermissionsAndInstructions(t *testing.T) { + workspace := t.TempDir() + global := t.TempDir() + t.Setenv("SENIOR_DEV_CONFIG_DIR", global) + t.Setenv("SENIOR_DEV_CONFIG", "") + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "") + t.Setenv("SENIOR_DEV_PERMISSION", "") + shell := filepath.Join(global, "configured-shell") + if err := os.WriteFile(shell, []byte("#!/bin/sh\nprintf 'custom-config-dir-shell\\n'\n"), 0o755); err != nil { + t.Fatal(err) + } + shellJSON, _ := json.Marshal(shell) + if err := os.WriteFile(filepath.Join(global, "senior-dev.json"), []byte(`{ + "shell": `+string(shellJSON)+`, + "tools": {"read": false} +}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "EXTRA.md"), []byte("PROJECT CONFIG INSTRUCTION"), 0o644); err != nil { + t.Fatal(err) + } + configText := `{ + "instructions": ["EXTRA.md"], + "permission": {"edit": "deny", "read": "allow"}, + "agent": { + "coder": { + "prompt": "configured coder prompt", + "model": "openrouter/vendor/configured-model", + "temperature": 0.25, + "tools": {"bash": false}, + "options": {"agent_option": true} + } + }, + "provider": { + "openrouter": { + "options": { + "timeout": false, + "chunkTimeout": 45000, + "headers": {"X-Config": "provider", "X-Provider": "yes"} + }, + "models": { + "vendor/configured-model": { + "limit": {"context": 64000, "output": 4096}, + "headers": {"X-Config": "model"}, + "options": {"model_option": "configured"} + } + } + } + } +}` + if err := os.WriteFile(filepath.Join(workspace, "senior-dev.json"), []byte(configText), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := loadSeniorDevConfig(workspace) + if err != nil { + t.Fatal(err) + } + runtime := newConfiguredRuntime(workspace, &capturingBackend{}, cfg) + defer runtime.Close() + + // Project instructions reach the live system prompt service. + instructions := strings.Join(runtime.registry.SystemInstructions(context.Background()), "\n") + if !strings.Contains(instructions, "PROJECT CONFIG INSTRUCTION") { + t.Fatalf("configured instructions missing from live registry: %q", instructions) + } + // The registry consumes the exact loader configured by SENIOR_DEV_CONFIG_DIR, + // including shell/formatter settings. + bashInput, _ := json.Marshal(map[string]any{"command": "ignored"}) + bashResult, err := runtime.registry.Execute(context.Background(), steploop.ToolCall{ + Name: "bash", Input: bashInput, SessionID: "ses_config", + }) + if err != nil || !strings.Contains(bashResult.Output, "custom-config-dir-shell") { + t.Fatalf("custom config shell result = %#v, %v", bashResult, err) + } + // The tools block is normalized after config merging, so the explicit + // project permission remains authoritative. + if rule := permission.Evaluate("read", "anything", cfg.global); rule.Action != permission.ActionAllow { + t.Fatalf("merged tools/permission rule = %+v, want allow", rule) + } + + // A project deny policy blocks the live tool executor with a permission.DeniedError. + input, _ := json.Marshal(map[string]any{ + "filePath": filepath.Join(workspace, "blocked.txt"), "content": "blocked", + }) + _, err = runtime.registry.Execute(context.Background(), steploop.ToolCall{ + Name: "write", Input: input, Agent: "coder", + }) + var denied permission.DeniedError + if !errors.As(err, &denied) { + t.Fatalf("configured write error = %T %v", err, err) + } + + configured, err := cfg.configureTurn(turn{ + Agent: "coder", AgentMarkdown: "baked", ProviderID: "openrouter", ModelID: "old", + }) + if err != nil { + t.Fatal(err) + } + if configured.AgentMarkdown != "configured coder prompt" || + configured.ProviderID != "openrouter" || configured.ModelID != "vendor/configured-model" { + t.Fatalf("configured turn = %+v", configured) + } + definitions := runtime.definitionsFor(configured.ProviderID, configured.ModelID, "coder", nil) + for _, definition := range definitions { + if definition.Provider.Name == "bash" || definition.Provider.Name == "apply_patch" || + definition.Provider.Name == "edit" || definition.Provider.Name == "write" { + t.Fatalf("denied tool %q remained advertised", definition.Provider.Name) + } + } + + backend := &modelAPIBackend{} + cfg.applyBackend(backend) + model, err := (seniorDevModels{ + backend: backend, sessionID: "ses", agent: "coder", + }).GetModel(context.Background(), "openrouter", "vendor/configured-model") + if err != nil { + t.Fatal(err) + } + options, _ := model.Params.OpenRouterOptions.MarshalJSON() + if backend.totalTimeoutMS != -1 || backend.chunkTimeoutMS != 45000 || + !strings.Contains(string(options), `"model_option":"configured"`) || + !strings.Contains(string(options), `"agent_option":true`) || + model.Params.MaxOutputTokens == nil || *model.Params.MaxOutputTokens != 4096 { + t.Fatalf("provider/model config not consumed: backend=%+v options=%s model=%+v", backend, options, model) + } + headers := seniorDevHeaders("ses", cfg.headers("openrouter", "vendor/configured-model")) + headerText, _ := json.Marshal(headers) + if !strings.Contains(string(headerText), `"name":"x-config","value":"model"`) || + !strings.Contains(string(headerText), `"name":"x-provider","value":"yes"`) { + t.Fatalf("configured headers not consumed: %s", headerText) + } +} + +// The three knobs that used to decide how senior-dev reached a model are +// refused by name, never quietly ignored: a service's apiKey and baseURL, +// which would be a second road to a model codeaf could not meter, and any +// providerRouting block, which codeaf's model funnel now decides. +func TestConfigRefusesTheRetiredModelKnobsByName(t *testing.T) { + for _, test := range []struct { + name, config, want string + }{ + {"api key", `{"provider":{"openrouter":{"options":{"apiKey":"sk-anything"}}}}`, "options.apiKey is not read"}, + {"base url", `{"provider":{"openrouter":{"options":{"baseURL":"https://elsewhere.example/v1"}}}}`, "options.baseURL is not read"}, + {"provider routing", `{"provider":{"openrouter":{"providerRouting":{"sort":"price"}}}}`, "providerRouting is not read"}, + {"model routing", `{"provider":{"openrouter":{"models":{"vendor/m":{"providerRouting":{"sort":"price"}}}}}}`, "providerRouting is not read"}, + {"agent routing", `{"agent":{"coder":{"providerRouting":{"sort":"price"}}}}`, "providerRouting is not read"}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + t.Setenv("SENIOR_DEV_CONFIG_DIR", t.TempDir()) + t.Setenv("SENIOR_DEV_CONFIG", "") + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "") + t.Setenv("SENIOR_DEV_PERMISSION", "") + if err := os.WriteFile(filepath.Join(workspace, "senior-dev.json"), []byte(test.config), 0o644); err != nil { + t.Fatal(err) + } + _, err := loadSeniorDevConfig(workspace) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("load error = %v, want one saying %q", err, test.want) + } + }) + } +} + +// A routing preference spelled as a plain request option never leaves the +// program either: the `provider` key is taken out of the options every call +// carries, whoever put it there, and the rest of the options survive. +func TestAnAdHocProviderOptionIsDroppedFromTheRequest(t *testing.T) { + cfg, err := newSeniorDevConfig(map[string]any{ + "agent": map[string]any{"coder": map[string]any{"options": map[string]any{ + "provider": map[string]any{"order": []any{"somewhere"}}, + "agent_option": true, + }}}, + }) + if err != nil { + t.Fatal(err) + } + backend := &modelAPIBackend{api: testModelAPI} + cfg.applyBackend(backend) + model, err := (seniorDevModels{backend: backend, sessionID: "ses", agent: "coder"}). + GetModel(context.Background(), "openrouter", "vendor/model") + if err != nil { + t.Fatal(err) + } + options, _ := model.Params.OpenRouterOptions.MarshalJSON() + if strings.Contains(string(options), `"provider"`) || !strings.Contains(string(options), `"agent_option":true`) { + t.Fatalf("options = %s, want the agent's option without any provider routing", options) + } +} + +func TestSeniorDevPermissionEnvironmentPreservesLastMatchOrder(t *testing.T) { + // SENIOR_DEV_PERMISSION object order survives config loading because + // last-match-wins evaluation is observable behavior. + workspace := t.TempDir() + t.Setenv("SENIOR_DEV_CONFIG_DIR", t.TempDir()) + t.Setenv("SENIOR_DEV_CONFIG", "") + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "") + t.Setenv("SENIOR_DEV_PERMISSION", `{"read":"allow","*":"deny"}`) + cfg, err := loadSeniorDevConfig(workspace) + if err != nil { + t.Fatal(err) + } + if rule := permission.Evaluate("read", "README.md", cfg.global); rule.Action != permission.ActionDeny { + t.Fatalf("ordered env permission = %+v, want trailing wildcard deny", rule) + } +} + +func TestSeniorDevConfigDirFeedsRegistryFormatterContract(t *testing.T) { + // Formatter lookup shares the pipeline's SENIOR_DEV_CONFIG_DIR-aware loader + // instead of constructing a default loader. + workspace := t.TempDir() + global := t.TempDir() + t.Setenv("SENIOR_DEV_CONFIG_DIR", global) + t.Setenv("SENIOR_DEV_CONFIG", "") + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "") + t.Setenv("SENIOR_DEV_PERMISSION", "") + formatter := filepath.Join(global, "formatter") + if err := os.WriteFile(formatter, []byte("#!/bin/sh\nprintf 'formatted-by-custom-dir\\n' > \"$1\"\n"), 0o755); err != nil { + t.Fatal(err) + } + formatterJSON, _ := json.Marshal(formatter) + configText := `{"formatter":{"custom":{"extensions":[".fmtx"],"command":[` + + string(formatterJSON) + `,"$FILE"]}}}` + if err := os.WriteFile(filepath.Join(global, "senior-dev.json"), []byte(configText), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := loadSeniorDevConfig(workspace) + if err != nil { + t.Fatal(err) + } + runtime := newConfiguredRuntime(workspace, &capturingBackend{}, cfg) + defer runtime.Close() + target := filepath.Join(workspace, "sample.fmtx") + input, _ := json.Marshal(map[string]any{"filePath": target, "content": "unformatted\n"}) + if _, err := runtime.registry.Execute(context.Background(), steploop.ToolCall{ + Name: "write", Input: input, SessionID: "ses_formatter", + }); err != nil { + t.Fatal(err) + } + body, err := os.ReadFile(target) + if err != nil || string(body) != "formatted-by-custom-dir\n" { + t.Fatalf("custom-dir formatter output = %q, %v", body, err) + } +} diff --git a/internal/seniordev/app/crew_test.go b/internal/seniordev/app/crew_test.go new file mode 100644 index 0000000000..b0100be85a --- /dev/null +++ b/internal/seniordev/app/crew_test.go @@ -0,0 +1,62 @@ +//go:build !windows + +package app + +import ( + "bytes" + "strings" + "testing" +) + +// A crew's models are pool entries under the model API's service, and one +// already filed there is left as it is. +func TestCrewModelFilesASeatUnderTheService(t *testing.T) { + for in, want := range map[string]string{ + "z-ai/glm-5.3-flash": "openrouter/z-ai/glm-5.3-flash", + "openrouter/z-ai/glm-5.3-flash": "openrouter/z-ai/glm-5.3-flash", + " ": "", + } { + if got := CrewModel(in); got != want { + t.Errorf("CrewModel(%q) = %q, want %q", in, got, want) + } + } +} + +// Pools a crew filled keep only what the catalog can size, say what they +// dropped, and route on senior-dev's own list when the working seat is left +// with nothing. +func TestCrewPoolsDropWhatTheCatalogLacksAndFallBackToTheOwnList(t *testing.T) { + known := func(ref string) bool { return !strings.Contains(ref, "unknown") } + var notes bytes.Buffer + args := crewPools(cliArgs{ + High: "openrouter/vendor/hands", + Frontier: "openrouter/vendor/unknown-brain", + Low: "openrouter/vendor/light", + }, known, ¬es) + if args.High != "openrouter/vendor/hands" || args.Frontier != "" || args.Low != "openrouter/vendor/light" { + t.Fatalf("pools = %+v, want the unknown frontier dropped and the rest kept", args) + } + if !strings.Contains(notes.String(), "openrouter/vendor/unknown-brain is not in the model catalog") { + t.Fatalf("notes = %q, want the dropped model named", notes.String()) + } + notes.Reset() + if args := crewPools(cliArgs{High: "openrouter/vendor/unknown-hands"}, known, ¬es); args.High != DefaultHighModels { + t.Fatalf("an unusable working seat left --high = %q, want senior-dev's own list", args.High) + } + if !strings.Contains(notes.String(), "routing on senior-dev's own list") { + t.Fatalf("notes = %q, want the fallback said", notes.String()) + } +} + +// A model the person asked for that the catalog cannot size is a refusal that +// names it, before any call; one it can size is no refusal. +func TestAnAskedModelTheCatalogCannotSizeIsRefusedByName(t *testing.T) { + known := func(ref string) bool { return ref == "openrouter/vendor/known" } + if got := askedRefusal("openrouter/vendor/known", known); got != "" { + t.Fatalf("a known model was refused: %q", got) + } + got := askedRefusal("openrouter/vendor/known,openrouter/proxy/mystery", known) + if !strings.HasPrefix(got, "senior-dev cannot work with proxy/mystery: ") || !strings.Contains(got, "nothing was started") { + t.Fatalf("refusal = %q", got) + } +} diff --git a/internal/seniordev/app/durable_sessions.go b/internal/seniordev/app/durable_sessions.go new file mode 100644 index 0000000000..cc366d9540 --- /dev/null +++ b/internal/seniordev/app/durable_sessions.go @@ -0,0 +1,1012 @@ +//go:build !windows + +package app + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/project" + "github.com/Agent-Field/codeaf/internal/seniordev/session/projectors" + "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore" + "github.com/Agent-Field/codeaf/internal/seniordev/storage" + "golang.org/x/sys/unix" +) + +const ( + seniorDevDataDirectory = ".senior-dev" + seniorDevDatabaseFile = "senior-dev.db" + + projectionReconcileVersion = 1 +) + +type projectionSource interface { + List(prefix []string) ([][]string, error) + ReadInto(key []string, dst any) error +} + +type projectionRecordMark struct { + Key string `json:"key"` + Size int64 `json:"size"` + Modified int64 `json:"modified"` + Changed int64 `json:"changed"` + Device uint64 `json:"device"` + Inode uint64 `json:"inode"` +} + +type projectionManifest struct { + Version int `json:"version"` + ProjectID string `json:"projectID"` + Records []projectionRecordMark `json:"records"` +} + +type durableSessions struct { + store *storage.Store + projectionSource projectionSource + sessions *sessioncore.Service + bus *bus.Bus + db *sql.DB + projectID string + workspace string + unsubscribe func() + projector *projectors.Store + lockPath string + + operationMu sync.Mutex + projectionMu sync.Mutex + projectionError []error +} + +func openDurableSessions(ctx context.Context, workspace string) (*durableSessions, error) { + // Flat session storage and senior-dev.db both live under the workspace's + // .senior-dev directory. + dataDir := filepath.Join(workspace, seniorDevDataDirectory) + if err := os.MkdirAll(dataDir, 0o755); err != nil { + return nil, fmt.Errorf("senior-dev sessions: create data directory: %w", err) + } + + projectInfo, _, err := project.Discover(ctx, workspace) + if err != nil { + return nil, fmt.Errorf("senior-dev sessions: discover project: %w", err) + } + projectID := string(projectInfo.ID) + dbPath := filepath.Join(dataDir, seniorDevDatabaseFile) + db, err := projectors.Open(ctx, dbPath, projectors.BusyRetryOptions{Log: io.Discard}) + if err != nil { + return nil, err + } + closeOnError := func(err error) (*durableSessions, error) { + _ = db.Close() + return nil, err + } + if err := applyProjectSchema(ctx, db); err != nil { + return closeOnError(err) + } + if err := upsertProject(ctx, db, projectInfo); err != nil { + return closeOnError(err) + } + if err := projectors.ApplySchema(ctx, db); err != nil { + return closeOnError(err) + } + + instanceBus := bus.New(bus.Context{ + Directory: workspace, Project: projectID, Workspace: workspace, + }) + durable := &durableSessions{ + store: storage.NewFromDataDir(dataDir), bus: instanceBus, db: db, + projectID: projectID, workspace: workspace, + projector: projectors.NewStore(db, projectors.StoreOptions{}), + lockPath: filepath.Join(dataDir, "projection.lock"), + } + durable.projectionSource = durable.store + sessions, err := sessioncore.New(sessioncore.Options{ + Store: durable.store, Bus: instanceBus, ProjectID: projectID, + Worktree: string(projectInfo.Worktree), Directory: workspace, + Version: version, + }) + if err != nil { + durable.Close() + return nil, err + } + durable.sessions = sessions + if err := durable.reconcileProjection(ctx); err != nil { + durable.Close() + return nil, err + } + durable.unsubscribe = instanceBus.SubscribeAllCallback(durable.projectEvent) + return durable, nil +} + +func applyProjectSchema(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS project ( + id text PRIMARY KEY, + worktree text NOT NULL, + vcs text, + name text, + icon_url text, + icon_url_override text, + icon_color text, + time_created integer NOT NULL, + time_updated integer NOT NULL, + time_initialized integer, + sandboxes text NOT NULL, + commands text + )`) + if err != nil { + return fmt.Errorf("senior-dev sessions: apply project schema: %w", err) + } + return nil +} + +func upsertProject(ctx context.Context, db *sql.DB, info project.Info) error { + now := time.Now().UnixMilli() + created := info.Time.Created + if created == 0 { + created = now + } + updated := info.Time.Updated + if updated == 0 { + updated = now + } + sandboxes := info.Sandboxes + if len(sandboxes) == 0 { + sandboxes = []string{info.Worktree} + } + sandboxesJSON, err := json.Marshal(sandboxes) + if err != nil { + return err + } + var vcs any + if info.VCS != nil { + vcs = *info.VCS + } + _, err = db.ExecContext(ctx, `INSERT INTO project + (id, worktree, vcs, time_created, time_updated, sandboxes) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + worktree = excluded.worktree, + vcs = excluded.vcs, + time_updated = excluded.time_updated, + sandboxes = excluded.sandboxes`, + string(info.ID), info.Worktree, vcs, created, updated, string(sandboxesJSON), + ) + if err != nil { + return fmt.Errorf("senior-dev sessions: upsert project: %w", err) + } + return nil +} + +func projectedSessionEvent(eventType string) bool { + switch eventType { + case projectors.EventSessionCreated, + projectors.EventSessionUpdated, + projectors.EventSessionDeleted, + projectors.EventMessageUpdated, + projectors.EventMessageRemoved, + projectors.EventMessagePartUpdated, + projectors.EventMessagePartRemoved: + return true + default: + return false + } +} + +func (durable *durableSessions) recordProjectionError(err error) { + if err == nil { + return + } + durable.projectionMu.Lock() + durable.projectionError = append(durable.projectionError, err) + durable.projectionMu.Unlock() +} + +func (durable *durableSessions) projectEvent(payload bus.Payload) { + if !projectedSessionEvent(payload.Type) { + return + } + data, err := json.Marshal(payload.Properties) + if err == nil { + err = durable.projector.Apply(context.Background(), projectors.Event{ + ID: payload.ID, Type: payload.Type, Data: data, + }) + } + durable.recordProjectionError(err) +} + +func (durable *durableSessions) withProjection( + operation string, mutate func() error, +) error { + durable.operationMu.Lock() + defer durable.operationMu.Unlock() + return withAdvisoryFileLock(durable.lockPath, func() error { + durable.projectionMu.Lock() + durable.projectionError = nil + durable.projectionMu.Unlock() + mutationErr := mutate() + durable.projectionMu.Lock() + projectionErrs := durable.projectionError + durable.projectionError = nil + durable.projectionMu.Unlock() + if mutationErr != nil { + return mutationErr + } + if len(projectionErrs) != 0 { + return fmt.Errorf("senior-dev sessions: project %s: %w", operation, errors.Join(projectionErrs...)) + } + // Live projectors have already committed. Move only the O(1) database + // generation here; the filesystem manifest deliberately remains at the + // last startup so that the next startup discovers the new log records. + if err := durable.advanceProjectionGeneration(context.Background()); err != nil { + log.Printf("senior-dev sessions: leave projection generation stale after %s: %v", operation, err) + } + return nil + }) +} + +func (durable *durableSessions) Messages( + ctx context.Context, sessionID string, +) ([]msgmodel.WithParts, error) { + return durable.sessions.Messages(ctx, sessionID) +} + +func (durable *durableSessions) UpdateMessage(ctx context.Context, info msgmodel.Info) error { + return durable.withProjection("message "+info.MessageID(), func() error { + return durable.sessions.UpdateMessage(ctx, info) + }) +} + +func (durable *durableSessions) UpdatePart(ctx context.Context, part msgmodel.Part) error { + return durable.withProjection("part "+part.Base().ID, func() error { + return durable.sessions.UpdatePart(ctx, part) + }) +} + +func (durable *durableSessions) UpdatePartDelta(ctx context.Context, input msgmodel.PartDeltaEvent) { + durable.sessions.UpdatePartDelta(ctx, input) +} + +func (durable *durableSessions) UpdateMessageWithParts( + ctx context.Context, info msgmodel.Info, parts ...msgmodel.Part, +) error { + return durable.withProjection("message turn "+info.MessageID(), func() error { + return durable.sessions.UpdateMessageWithParts(ctx, info, parts...) + }) +} + +func (durable *durableSessions) CreateSession( + ctx context.Context, input sessioncore.CreateInput, +) (sessioncore.Info, error) { + var info sessioncore.Info + err := durable.withProjection("session create", func() error { + var err error + info, err = durable.sessions.Create(ctx, input) + return err + }) + return info, err +} + +func (durable *durableSessions) TouchSession(ctx context.Context, sessionID string) error { + return durable.withProjection("session touch "+sessionID, func() error { + return durable.sessions.Touch(ctx, sessionID) + }) +} + +func (durable *durableSessions) RemoveSession(ctx context.Context, sessionID string) error { + return durable.withProjection("session remove "+sessionID, func() error { + return durable.sessions.Remove(ctx, sessionID) + }) +} + +type replayProjectionEvent struct { + event projectors.Event + time int64 +} + +func (durable *durableSessions) reconcileProjection(ctx context.Context) error { + return withAdvisoryFileLock(durable.lockPath, func() error { + if err := durable.ensureProjectionReconcileSchema(ctx); err != nil { + return err + } + current, err := durable.projectionManifest() + if err != nil { + return fmt.Errorf("senior-dev sessions: inspect flat projection source: %w", err) + } + previous, markedGeneration, valid, err := durable.loadProjectionMark(ctx) + if err != nil { + return err + } + generation, err := durable.projectionGeneration(ctx, durable.db) + if err != nil { + return err + } + if valid && generation == markedGeneration { + changed, removed := changedProjectionRecords(previous, current) + if len(changed) == 0 && !removed { + return nil + } + if !removed { + events, quarantined, eventErr := durable.incrementalProjectionEvents(changed, current) + if eventErr != nil { + return fmt.Errorf("senior-dev sessions: read incremental projection source: %w", eventErr) + } + if !quarantined { + return durable.commitProjectionReplay(ctx, events, current, false) + } + } + } + return durable.fullProjectionReconcile(ctx) + }) +} + +const projectionReconcileSchema = ` +CREATE TABLE IF NOT EXISTS senior_dev_projection_generation ( + id integer PRIMARY KEY CHECK (id = 1), + generation integer NOT NULL +); +INSERT OR IGNORE INTO senior_dev_projection_generation (id, generation) VALUES (1, 0); +CREATE TABLE IF NOT EXISTS senior_dev_projection_reconcile ( + id integer PRIMARY KEY CHECK (id = 1), + format_version integer NOT NULL, + project_id text NOT NULL, + generation integer NOT NULL, + manifest text NOT NULL +); +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_session_insert +AFTER INSERT ON session BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_session_update +AFTER UPDATE ON session BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_session_delete +AFTER DELETE ON session BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_message_insert +AFTER INSERT ON message BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_message_update +AFTER UPDATE ON message BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_message_delete +AFTER DELETE ON message BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_part_insert +AFTER INSERT ON part BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_part_update +AFTER UPDATE ON part BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END; +CREATE TRIGGER IF NOT EXISTS senior_dev_projection_part_delete +AFTER DELETE ON part BEGIN + UPDATE senior_dev_projection_generation SET generation = generation + 1 WHERE id = 1; +END;` + +func (durable *durableSessions) ensureProjectionReconcileSchema(ctx context.Context) error { + if _, err := durable.db.ExecContext(ctx, projectionReconcileSchema); err != nil { + return fmt.Errorf("senior-dev sessions: apply projection reconciliation schema: %w", err) + } + return nil +} + +type projectionGenerationReader interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +func (durable *durableSessions) projectionGeneration( + ctx context.Context, reader projectionGenerationReader, +) (int64, error) { + var generation int64 + if err := reader.QueryRowContext(ctx, + "SELECT generation FROM senior_dev_projection_generation WHERE id = 1", + ).Scan(&generation); err != nil { + return 0, fmt.Errorf("senior-dev sessions: read projection generation: %w", err) + } + return generation, nil +} + +func (durable *durableSessions) loadProjectionMark( + ctx context.Context, +) (projectionManifest, int64, bool, error) { + var version int + var projectID, raw string + var generation int64 + err := durable.db.QueryRowContext(ctx, `SELECT format_version, project_id, generation, manifest + FROM senior_dev_projection_reconcile WHERE id = 1`).Scan( + &version, &projectID, &generation, &raw, + ) + if errors.Is(err, sql.ErrNoRows) { + return projectionManifest{}, 0, false, nil + } + if err != nil { + return projectionManifest{}, 0, false, + fmt.Errorf("senior-dev sessions: read projection reconciliation mark: %w", err) + } + var manifest projectionManifest + if version != projectionReconcileVersion || projectID != durable.projectID || + json.Unmarshal([]byte(raw), &manifest) != nil || !validProjectionManifest(manifest, durable.projectID) { + return projectionManifest{}, 0, false, nil + } + return manifest, generation, true, nil +} + +func validProjectionManifest(manifest projectionManifest, projectID string) bool { + if manifest.Version != projectionReconcileVersion || manifest.ProjectID != projectID { + return false + } + previous := "" + for _, record := range manifest.Records { + if record.Key == "" || record.Key <= previous { + return false + } + parts := strings.Split(record.Key, "/") + if len(parts) < 2 || (parts[0] != "session" && parts[0] != "message" && parts[0] != "part") { + return false + } + previous = record.Key + } + return true +} + +func (durable *durableSessions) projectionManifest() (projectionManifest, error) { + records := []projectionRecordMark{} + for _, prefix := range []string{"session", "message", "part"} { + keys, err := durable.projectionSource.List([]string{prefix}) + if err != nil { + return projectionManifest{}, err + } + for _, key := range keys { + if !validProjectionKey(key) { + continue + } + pathParts := append([]string{durable.store.Dir}, key...) + path := filepath.Join(pathParts...) + ".json" + info, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return projectionManifest{}, err + } + record := projectionRecordMark{ + Key: strings.Join(key, "/"), Size: info.Size(), Modified: info.ModTime().UnixNano(), + } + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + record.Device = uint64(stat.Dev) + record.Inode = stat.Ino + record.Changed = statChangedNanos(stat) + } + records = append(records, record) + } + } + sort.Slice(records, func(i, j int) bool { return records[i].Key < records[j].Key }) + return projectionManifest{ + Version: projectionReconcileVersion, ProjectID: durable.projectID, Records: records, + }, nil +} + +func validProjectionKey(key []string) bool { + if len(key) == 2 && key[0] == "session" { + return true + } + if len(key) == 3 && key[0] == "message" { + return true + } + return len(key) == 4 && key[0] == "part" +} + +func changedProjectionRecords(previous, current projectionManifest) ([]projectionRecordMark, bool) { + old := make(map[string]projectionRecordMark, len(previous.Records)) + for _, record := range previous.Records { + old[record.Key] = record + } + changed := make([]projectionRecordMark, 0) + for _, record := range current.Records { + if prior, exists := old[record.Key]; !exists || prior != record { + changed = append(changed, record) + } + delete(old, record.Key) + } + return changed, len(old) != 0 +} + +func (durable *durableSessions) writeProjectionMarkTx( + ctx context.Context, tx *sql.Tx, manifest projectionManifest, +) error { + raw, err := json.Marshal(manifest) + if err != nil { + return err + } + generation, err := durable.projectionGeneration(ctx, tx) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO senior_dev_projection_reconcile + (id, format_version, project_id, generation, manifest) VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET format_version = excluded.format_version, + project_id = excluded.project_id, generation = excluded.generation, manifest = excluded.manifest`, + projectionReconcileVersion, durable.projectID, generation, string(raw), + ) + return err +} + +func (durable *durableSessions) advanceProjectionGeneration(ctx context.Context) error { + _, err := durable.db.ExecContext(ctx, `UPDATE senior_dev_projection_reconcile + SET generation = (SELECT generation FROM senior_dev_projection_generation WHERE id = 1) + WHERE id = 1`) + return err +} + +func (durable *durableSessions) commitProjectionReplay( + ctx context.Context, events []replayProjectionEvent, manifest projectionManifest, prune bool, +) error { + tx, err := durable.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("senior-dev sessions: begin projection replay: %w", err) + } + var replayTime int64 + projector := projectors.NewStore(durable.db, projectors.StoreOptions{ + Now: func() int64 { return replayTime }, + }) + if prune { + if err := durable.pruneProjectionTx(ctx, tx, events); err != nil { + _ = tx.Rollback() + return fmt.Errorf("senior-dev sessions: prune projection replay: %w", err) + } + } + for _, item := range events { + replayTime = item.time + if err := projector.ApplyReconcileTx(ctx, tx, item.event); err != nil { + _ = tx.Rollback() + return fmt.Errorf("senior-dev sessions: replay %s: %w", item.event.Type, err) + } + } + if err := durable.writeProjectionMarkTx(ctx, tx, manifest); err != nil { + _ = tx.Rollback() + return fmt.Errorf("senior-dev sessions: write projection reconciliation mark: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("senior-dev sessions: commit projection replay: %w", err) + } + return nil +} + +func (durable *durableSessions) pruneProjectionTx( + ctx context.Context, tx *sql.Tx, events []replayProjectionEvent, +) error { + ids := map[string][]string{"session": {}, "message": {}, "part": {}} + for _, item := range events { + var data struct { + SessionID string `json:"sessionID"` + Info struct { + ID string `json:"id"` + } `json:"info"` + Part struct { + ID string `json:"id"` + } `json:"part"` + } + if err := json.Unmarshal(item.event.Data, &data); err != nil { + return err + } + switch item.event.Type { + case projectors.EventSessionCreated: + ids["session"] = append(ids["session"], data.SessionID) + case projectors.EventMessageUpdated: + ids["message"] = append(ids["message"], data.Info.ID) + case projectors.EventMessagePartUpdated: + ids["part"] = append(ids["part"], data.Part.ID) + } + } + encoded := map[string]string{} + for kind, values := range ids { + raw, err := json.Marshal(values) + if err != nil { + return err + } + encoded[kind] = string(raw) + } + for _, statement := range []struct { + query string + args []any + }{ + { + query: `DELETE FROM part WHERE session_id IN + (SELECT id FROM session WHERE project_id = ?) + AND id NOT IN (SELECT value FROM json_each(?))`, + args: []any{durable.projectID, encoded["part"]}, + }, + { + query: `DELETE FROM message WHERE session_id IN + (SELECT id FROM session WHERE project_id = ?) + AND id NOT IN (SELECT value FROM json_each(?))`, + args: []any{durable.projectID, encoded["message"]}, + }, + { + query: `DELETE FROM session WHERE project_id = ? + AND id NOT IN (SELECT value FROM json_each(?))`, + args: []any{durable.projectID, encoded["session"]}, + }, + } { + if _, err := tx.ExecContext(ctx, statement.query, statement.args...); err != nil { + return err + } + } + return nil +} + +func (durable *durableSessions) fullProjectionReconcile(ctx context.Context) error { + events, _, err := durable.flatProjectionEvents() + if err != nil { + return fmt.Errorf("senior-dev sessions: read flat projection source: %w", err) + } + manifest, err := durable.projectionManifest() + if err != nil { + return fmt.Errorf("senior-dev sessions: inspect reconciled projection source: %w", err) + } + return durable.commitProjectionReplay(ctx, events, manifest, true) +} + +func (durable *durableSessions) incrementalProjectionEvents( + changed []projectionRecordMark, current projectionManifest, +) ([]replayProjectionEvent, bool, error) { + changedByKind := map[string][]projectionRecordMark{} + currentKeys := make(map[string]bool, len(current.Records)) + for _, record := range current.Records { + currentKeys[record.Key] = true + } + for _, record := range changed { + parts := strings.Split(record.Key, "/") + changedByKind[parts[0]] = append(changedByKind[parts[0]], record) + } + + type sessionState struct { + checked bool + own bool + info sessioncore.Info + } + sessions := map[string]sessionState{} + quarantined := false + requiresFull := false + var sessionReadErr error + readSession := func(sessionID string) (sessioncore.Info, bool) { + if state := sessions[sessionID]; state.checked { + return state.info, state.own + } + key := []string{"session", sessionID} + var info sessioncore.Info + if err := durable.projectionSource.ReadInto(key, &info); err != nil { + durable.quarantineProjectionRecord(key, err) + quarantined = true + sessions[sessionID] = sessionState{checked: true} + return sessioncore.Info{}, false + } + if info.ID != sessionID { + requiresFull = true + } + state := sessionState{checked: true, own: info.ProjectID == durable.projectID, info: info} + if !state.own { + var projected bool + err := durable.db.QueryRow( + "SELECT EXISTS(SELECT 1 FROM session WHERE id = ? AND project_id = ?)", + sessionID, durable.projectID, + ).Scan(&projected) + if sessionReadErr == nil { + sessionReadErr = err + } + if projected { + requiresFull = true + } + } + sessions[sessionID] = state + return info, state.own + } + + events := make([]replayProjectionEvent, 0, len(changed)) + for _, record := range changedByKind["session"] { + key := strings.Split(record.Key, "/") + info, own := readSession(key[1]) + if !own { + continue + } + event, err := projectionEvent(projectors.EventSessionCreated, map[string]any{ + "sessionID": info.ID, "info": info, + }) + if err != nil { + return nil, quarantined, err + } + events = append(events, replayProjectionEvent{event: event, time: int64(info.Time.Updated)}) + } + for _, record := range changedByKind["message"] { + key := strings.Split(record.Key, "/") + info, own := readSession(key[1]) + if !own { + continue + } + var raw json.RawMessage + if err := durable.projectionSource.ReadInto(key, &raw); err != nil { + durable.quarantineProjectionRecord(key, err) + quarantined = true + continue + } + message, err := msgmodel.UnmarshalInfo(raw) + if err != nil { + durable.quarantineProjectionRecord(key, err) + quarantined = true + continue + } + if message.MessageID() != key[2] || projectionMessageSessionID(message) != key[1] { + requiresFull = true + } + created := messageCreatedMS(message) + event, err := projectionEvent(projectors.EventMessageUpdated, msgmodel.UpdatedEvent{ + SessionID: info.ID, Info: message, + }) + if err != nil { + return nil, quarantined, err + } + events = append(events, replayProjectionEvent{event: event, time: int64(created)}) + } + for _, record := range changedByKind["part"] { + key := strings.Split(record.Key, "/") + info, own := readSession(key[1]) + if !own || !currentKeys[strings.Join([]string{"message", key[1], key[2]}, "/")] { + continue + } + var raw json.RawMessage + if err := durable.projectionSource.ReadInto(key, &raw); err != nil { + durable.quarantineProjectionRecord(key, err) + quarantined = true + continue + } + part, err := msgmodel.UnmarshalPart(raw) + if err != nil { + durable.quarantineProjectionRecord(key, err) + quarantined = true + continue + } + base := part.Base() + if base.ID != key[3] || base.SessionID != key[1] || base.MessageID != key[2] { + requiresFull = true + } + created := uint64(0) + var messageRaw json.RawMessage + messageKey := []string{"message", key[1], key[2]} + if err := durable.projectionSource.ReadInto(messageKey, &messageRaw); err == nil { + if message, parseErr := msgmodel.UnmarshalInfo(messageRaw); parseErr == nil { + created = messageCreatedMS(message) + } + } + stamp := latestJSONTimestamp(part, created) + event, err := projectionEvent(projectors.EventMessagePartUpdated, msgmodel.PartUpdatedEvent{ + SessionID: info.ID, Part: part, Time: stamp, + }) + if err != nil { + return nil, quarantined, err + } + events = append(events, replayProjectionEvent{event: event, time: int64(stamp)}) + } + if sessionReadErr != nil { + return nil, false, sessionReadErr + } + return events, quarantined || requiresFull, nil +} + +func (durable *durableSessions) flatProjectionEvents() ([]replayProjectionEvent, bool, error) { + sessionKeys, err := durable.projectionSource.List([]string{"session"}) + if err != nil { + return nil, false, err + } + events := make([]replayProjectionEvent, 0, len(sessionKeys)) + quarantined := false + for _, sessionKey := range sessionKeys { + var info sessioncore.Info + if err := durable.projectionSource.ReadInto(sessionKey, &info); err != nil { + durable.quarantineProjectionRecord(sessionKey, err) + quarantined = true + continue + } + if info.ProjectID != durable.projectID { + continue + } + event, err := projectionEvent(projectors.EventSessionCreated, map[string]any{ + "sessionID": info.ID, "info": info, + }) + if err != nil { + return nil, quarantined, err + } + events = append(events, replayProjectionEvent{event: event, time: int64(info.Time.Updated)}) + + messageKeys, err := durable.projectionSource.List([]string{"message", info.ID}) + if err != nil { + return nil, quarantined, err + } + for _, messageKey := range messageKeys { + var raw json.RawMessage + if err := durable.projectionSource.ReadInto(messageKey, &raw); err != nil { + durable.quarantineProjectionRecord(messageKey, err) + quarantined = true + continue + } + message, err := msgmodel.UnmarshalInfo(raw) + if err != nil { + durable.quarantineProjectionRecord(messageKey, err) + quarantined = true + continue + } + created := messageCreatedMS(message) + event, err := projectionEvent(projectors.EventMessageUpdated, msgmodel.UpdatedEvent{ + SessionID: info.ID, Info: message, + }) + if err != nil { + return nil, quarantined, err + } + events = append(events, replayProjectionEvent{event: event, time: int64(created)}) + + partKeys, err := durable.projectionSource.List([]string{"part", info.ID, message.MessageID()}) + if err != nil { + return nil, quarantined, err + } + for _, partKey := range partKeys { + var partRaw json.RawMessage + if err := durable.projectionSource.ReadInto(partKey, &partRaw); err != nil { + durable.quarantineProjectionRecord(partKey, err) + quarantined = true + continue + } + part, err := msgmodel.UnmarshalPart(partRaw) + if err != nil { + durable.quarantineProjectionRecord(partKey, err) + quarantined = true + continue + } + stamp := latestJSONTimestamp(part, created) + event, err := projectionEvent(projectors.EventMessagePartUpdated, msgmodel.PartUpdatedEvent{ + SessionID: info.ID, Part: part, Time: stamp, + }) + if err != nil { + return nil, quarantined, err + } + events = append(events, replayProjectionEvent{event: event, time: int64(stamp)}) + } + } + } + return events, quarantined, nil +} + +func (durable *durableSessions) quarantineProjectionRecord(key []string, cause error) { + parts := append([]string{durable.store.Dir}, key...) + source := filepath.Join(parts...) + ".json" + relative, err := filepath.Rel(durable.store.Dir, source) + if err != nil { + relative = filepath.Base(source) + } + destination := filepath.Join( + durable.store.Dir, + "quarantine", + relative+".corrupt-"+strconv.FormatInt(time.Now().UnixNano(), 10), + ) + moveErr := os.MkdirAll(filepath.Dir(destination), 0o755) + if moveErr == nil { + moveErr = os.Rename(source, destination) + } + if moveErr != nil { + log.Printf("senior-dev sessions: ignored unreadable projection source %s: %v (quarantine failed: %v)", source, cause, moveErr) + return + } + log.Printf("senior-dev sessions: quarantined unreadable projection source %s as %s: %v", source, destination, cause) +} + +func projectionEvent(eventType string, properties any) (projectors.Event, error) { + data, err := json.Marshal(properties) + if err != nil { + return projectors.Event{}, err + } + return projectors.Event{Type: eventType, Data: data}, nil +} + +func messageCreatedMS(info msgmodel.Info) uint64 { + switch message := info.(type) { + case msgmodel.User: + return message.Time.Created + case msgmodel.Assistant: + return message.Time.Created + default: + return 0 + } +} + +func projectionMessageSessionID(info msgmodel.Info) string { + switch message := info.(type) { + case msgmodel.User: + return message.SessionID + case msgmodel.Assistant: + return message.SessionID + default: + return "" + } +} + +func latestJSONTimestamp(value any, fallback uint64) uint64 { + data, err := json.Marshal(value) + if err != nil { + return fallback + } + var decoded any + decoder := json.NewDecoder(strings.NewReader(string(data))) + decoder.UseNumber() + if decoder.Decode(&decoded) != nil { + return fallback + } + latest := fallback + var visit func(any) + visit = func(value any) { + switch value := value.(type) { + case map[string]any: + for key, nested := range value { + if key == "start" || key == "end" || key == "created" || key == "updated" { + if number, ok := nested.(json.Number); ok { + if stamp, err := number.Int64(); err == nil && stamp >= 0 && uint64(stamp) > latest { + latest = uint64(stamp) + } + } + } + visit(nested) + } + case []any: + for _, nested := range value { + visit(nested) + } + } + } + visit(decoded) + return latest +} + +func withAdvisoryFileLock(path string, fn func() error) error { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer file.Close() + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { + return err + } + defer unix.Flock(int(file.Fd()), unix.LOCK_UN) + return fn() +} + +func (durable *durableSessions) Close() { + if durable == nil { + return + } + if durable.unsubscribe != nil { + durable.unsubscribe() + } + if durable.bus != nil { + durable.bus.Dispose() + } + if durable.db != nil { + _ = durable.db.Close() + } +} diff --git a/internal/seniordev/app/durable_sessions_test.go b/internal/seniordev/app/durable_sessions_test.go new file mode 100644 index 0000000000..a2e0924d4e --- /dev/null +++ b/internal/seniordev/app/durable_sessions_test.go @@ -0,0 +1,808 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore" + "github.com/Agent-Field/codeaf/internal/seniordev/storage" +) + +func TestDurablePromptPersistsAndProjectsBeforeFirstModelCall(t *testing.T) { + // Session, message, and part records plus their projected views exist + // before the first provider call, and child-session lineage is durable. + workspace := t.TempDir() + type observation struct { + sessions int + messages int + parts int + dbRows [3]int + err error + } + seen := observation{} + var runtime *runtimeAdapter + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + seen.sessions, seen.err = countStoredJSON(filepath.Join(workspace, ".senior-dev", "storage", "session")) + if seen.err != nil { + return nil, seen.err + } + seen.messages, seen.err = countStoredJSON(filepath.Join(workspace, ".senior-dev", "storage", "message")) + if seen.err != nil { + return nil, seen.err + } + seen.parts, seen.err = countStoredJSON(filepath.Join(workspace, ".senior-dev", "storage", "part")) + if seen.err != nil { + return nil, seen.err + } + for index, table := range []string{"session", "message", "part"} { + seen.err = runtime.durable.db.QueryRowContext( + request.Context(), "SELECT COUNT(*) FROM "+table, + ).Scan(&seen.dbRows[index]) + if seen.err != nil { + return nil, seen.err + } + } + return recordedResponse( + request, http.StatusOK, "text/event-stream", chatReply("finished", 10), + ), nil + })} + runtime = newRuntime(workspace, &modelAPIBackend{api: testModelAPI, client: client}) + defer runtime.Close() + rootID, err := runtime.Create(context.Background(), "", "coder") + if err != nil { + t.Fatal(err) + } + result, err := runTestTurn(t, runtime, testTurn{ + ParentSessionID: rootID, SessionTitle: "durable child", + Agent: "coder", ProviderID: "openrouter", + ModelID: "vendor/model", Workspace: workspace, Prompt: "persist me first", + }) + if err != nil { + t.Fatal(err) + } + if seen.err != nil { + t.Fatal(seen.err) + } + if seen.sessions != 2 || seen.messages < 2 || seen.parts < 1 || + seen.dbRows[0] != 2 || seen.dbRows[1] < 2 || seen.dbRows[2] < 1 { + t.Fatalf("provider-start persistence = files(%d,%d,%d) db%v", + seen.sessions, seen.messages, seen.parts, seen.dbRows) + } + child, err := runtime.durable.sessions.Get(context.Background(), result.SessionID) + if err != nil { + t.Fatal(err) + } + if child.ParentID == nil || *child.ParentID != rootID { + t.Fatalf("child lineage = %#v, want parent %s", child.ParentID, rootID) + } + var projectedParent string + if err := runtime.durable.db.QueryRow( + "SELECT parent_id FROM session WHERE id = ?", result.SessionID, + ).Scan(&projectedParent); err != nil { + t.Fatal(err) + } + if projectedParent != rootID { + t.Fatalf("projected parent = %q, want %q", projectedParent, rootID) + } +} + +func TestDurableStartupReconcilesFlatStorageContract(t *testing.T) { + // Flat JSON is authoritative and reconstructs a missing database + // projection, including a complete user turn. + workspace := t.TempDir() + runtime := newRuntime(workspace, &capturingBackend{}) + t.Cleanup(runtime.Close) + rootID, err := runtime.Create(context.Background(), "", "coder") + if err != nil { + t.Fatal(err) + } + if _, err := persistTurnPrompt(context.Background(), runtime.durable, rootID, "msg_replay", turn{ + Agent: "coder", ProviderID: "p", ModelID: "m", Prompt: "replay me", + }); err != nil { + t.Fatal(err) + } + runtime.Close() + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.Remove(filepath.Join(workspace, ".senior-dev", "senior-dev.db") + suffix); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } + reopened, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + for table, want := range map[string]int{"session": 1, "message": 1, "part": 1} { + var got int + if err := reopened.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&got); err != nil || got != want { + t.Fatalf("replayed %s rows = %d, %v; want %d", table, got, err, want) + } + } +} + +func TestDurableStartupReconciliationPreservesProjectionOnlyRowsContract(t *testing.T) { + // Startup repairs stale flat-derived rows without deleting the + // todo/session_message projections that flat replay cannot rebuild. + workspace := t.TempDir() + runtime := newRuntime(workspace, &capturingBackend{}) + rootID, err := runtime.Create(context.Background(), "", "coder") + if err != nil { + t.Fatal(err) + } + if _, err := persistTurnPrompt(context.Background(), runtime.durable, rootID, "msg_incremental", turn{ + Agent: "coder", ProviderID: "p", ModelID: "m", Prompt: "keep projections", + }); err != nil { + t.Fatal(err) + } + if _, err := runtime.durable.db.Exec(`INSERT INTO todo + (session_id, content, status, priority, position, time_created, time_updated) + VALUES (?, 'todo survives', 'pending', 'high', 0, 1, 1)`, rootID); err != nil { + t.Fatal(err) + } + if _, err := runtime.durable.db.Exec(`INSERT INTO session_message + (id, session_id, type, time_created, time_updated, data) + VALUES ('projection-only', ?, 'note', 1, 1, '{}')`, rootID); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `UPDATE session SET title = 'stale title' WHERE id = '` + rootID + `'`, + `UPDATE message SET data = '{"role":"stale"}' WHERE id = 'msg_incremental'`, + `UPDATE part SET data = '{"type":"stale"}' WHERE message_id = 'msg_incremental'`, + } { + if _, err := runtime.durable.db.Exec(statement); err != nil { + t.Fatal(err) + } + } + runtime.Close() + + reopened, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + var title, messageData, partData string + if err := reopened.db.QueryRow("SELECT title FROM session WHERE id = ?", rootID).Scan(&title); err != nil { + t.Fatal(err) + } + if err := reopened.db.QueryRow("SELECT data FROM message WHERE id = 'msg_incremental'").Scan(&messageData); err != nil { + t.Fatal(err) + } + if err := reopened.db.QueryRow("SELECT data FROM part WHERE message_id = 'msg_incremental'").Scan(&partData); err != nil { + t.Fatal(err) + } + if title == "stale title" || strings.Contains(messageData, `"role":"stale"`) || + strings.Contains(partData, `"type":"stale"`) { + t.Fatalf("stale rows remain: title=%q message=%s part=%s", title, messageData, partData) + } + for _, table := range []string{"todo", "session_message"} { + var count int + if err := reopened.db.QueryRow("SELECT COUNT(*) FROM "+table+" WHERE session_id = ?", rootID).Scan(&count); err != nil || count != 1 { + t.Fatalf("projection-only %s rows = %d, %v; want 1", table, count, err) + } + } +} + +func TestDurableStartupQuarantinesTruncatedJSONContract(t *testing.T) { + // A truncated JSON record is moved aside and cannot prevent valid durable + // sessions from being reconciled at startup. + workspace := t.TempDir() + runtime := newRuntime(workspace, &capturingBackend{}) + rootID, err := runtime.Create(context.Background(), "", "coder") + if err != nil { + t.Fatal(err) + } + if _, err := persistTurnPrompt(context.Background(), runtime.durable, rootID, "msg_valid", turn{Prompt: "valid"}); err != nil { + t.Fatal(err) + } + runtime.Close() + broken := filepath.Join(workspace, ".senior-dev", "storage", "message", rootID, "msg_truncated.json") + if err := os.WriteFile(broken, []byte(`{"id":"msg_truncated"`), 0o644); err != nil { + t.Fatal(err) + } + reopened, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatalf("startup was bricked by truncated JSON: %v", err) + } + defer reopened.Close() + if _, err := os.Stat(broken); !os.IsNotExist(err) { + t.Fatalf("truncated source was not moved aside: %v", err) + } + matches, err := filepath.Glob(filepath.Join( + workspace, ".senior-dev", "storage", "quarantine", "message", rootID, "msg_truncated.json.corrupt-*", + )) + if err != nil || len(matches) != 1 { + t.Fatalf("quarantined files = %v, %v; want one", matches, err) + } + var valid int + if err := reopened.db.QueryRow("SELECT COUNT(*) FROM message WHERE id = 'msg_valid'").Scan(&valid); err != nil || valid != 1 { + t.Fatalf("valid replay row = %d, %v; want 1", valid, err) + } +} + +type countingProjectionSource struct { + projectionSource + reads int +} + +func (source *countingProjectionSource) ReadInto(key []string, dst any) error { + source.reads++ + return source.projectionSource.ReadInto(key, dst) +} + +func TestDurableWarmReconciliationReadsOnlyNewRecords(t *testing.T) { + workspace := t.TempDir() + durable, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "bounded"}) + if err != nil { + t.Fatal(err) + } + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "msg_bounded", SessionID: session.ID}, + Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder", + } + parts := make([]msgmodel.Part, 20) + for index := range parts { + parts[index] = msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: fmt.Sprintf("prt_%02d", index), SessionID: session.ID, MessageID: message.ID, + }, + Text: "old", + } + } + if err := durable.UpdateMessageWithParts(context.Background(), message, parts...); err != nil { + t.Fatal(err) + } + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + + counter := &countingProjectionSource{projectionSource: durable.store} + durable.projectionSource = counter + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if counter.reads != 0 { + t.Fatalf("up-to-date warm reconciliation reads = %d, want 0", counter.reads) + } + + newPart := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "prt_new", SessionID: session.ID, MessageID: message.ID}, + Text: "new", + } + if err := durable.store.Write([]string{"part", session.ID, message.ID, newPart.ID}, newPart); err != nil { + t.Fatal(err) + } + counter.reads = 0 + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if counter.reads > 3 { + t.Fatalf("one-record warm reconciliation reads = %d, want at most 3", counter.reads) + } + var projected int + if err := durable.db.QueryRow("SELECT COUNT(*) FROM part WHERE id = 'prt_new'").Scan(&projected); err != nil || projected != 1 { + t.Fatalf("new projected part = %d, %v; want 1", projected, err) + } + durable.Close() +} + +func TestDurableReconciliationCrashBetweenLogAndProjectionMatchesRebuild(t *testing.T) { + workspace := t.TempDir() + durable, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "crash"}) + if err != nil { + t.Fatal(err) + } + originalMessage := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "msg_before_crash", SessionID: session.ID}, + Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder", + } + originalPart := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "prt_removed_during_crash", SessionID: session.ID, MessageID: originalMessage.ID}, + Text: "removed before its projection event", + } + if err := durable.UpdateMessageWithParts(context.Background(), originalMessage, originalPart); err != nil { + t.Fatal(err) + } + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if err := durable.store.Remove([]string{"part", session.ID, originalMessage.ID, originalPart.ID}); err != nil { + t.Fatal(err) + } + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "msg_after_crash", SessionID: session.ID}, + Time: msgmodel.TimeCreated{Created: 10}, Agent: "coder", + } + part := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "prt_after_crash", SessionID: session.ID, MessageID: message.ID}, + Text: "durable before projection", + } + if err := durable.store.WriteBatch([]storage.WriteItem{ + {Key: []string{"part", session.ID, message.ID, part.ID}, Content: part}, + {Key: []string{"message", session.ID, message.ID}, Content: message}, + }); err != nil { + t.Fatal(err) + } + durable.Close() + + recovered, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + recoveredSnapshot := projectionSnapshotJSON(t, recovered) + recovered.Close() + removeProjectionDatabase(t, workspace) + rebuilt, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + defer rebuilt.Close() + if rebuiltSnapshot := projectionSnapshotJSON(t, rebuilt); !bytes.Equal(recoveredSnapshot, rebuiltSnapshot) { + t.Fatalf("recovered projection differs from full rebuild\nrecovered=%s\nrebuilt=%s", recoveredSnapshot, rebuiltSnapshot) + } +} + +func TestDurableCorruptAndMissingMarksFallBackToFullRebuild(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*testing.T, *durableSessions) + }{ + {name: "missing", mutate: func(t *testing.T, durable *durableSessions) { + _, err := durable.db.Exec("DELETE FROM senior_dev_projection_reconcile") + if err != nil { + t.Fatal(err) + } + }}, + {name: "corrupt", mutate: func(t *testing.T, durable *durableSessions) { + _, err := durable.db.Exec("UPDATE senior_dev_projection_reconcile SET manifest = '{'") + if err != nil { + t.Fatal(err) + } + }}, + {name: "older-version", mutate: func(t *testing.T, durable *durableSessions) { + _, err := durable.db.Exec("UPDATE senior_dev_projection_reconcile SET format_version = 0") + if err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + durable, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "authoritative"}) + if err != nil { + t.Fatal(err) + } + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := durable.db.Exec("UPDATE session SET title = 'stale' WHERE id = ?", session.ID); err != nil { + t.Fatal(err) + } + test.mutate(t, durable) + counter := &countingProjectionSource{projectionSource: durable.store} + durable.projectionSource = counter + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if counter.reads == 0 { + t.Fatal("fallback did not read the authoritative flat store") + } + var title string + if err := durable.db.QueryRow("SELECT title FROM session WHERE id = ?", session.ID).Scan(&title); err != nil || title != "authoritative" { + t.Fatalf("fallback title = %q, %v", title, err) + } + reconciledSnapshot := projectionSnapshotJSON(t, durable) + durable.Close() + removeProjectionDatabase(t, workspace) + rebuilt, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + defer rebuilt.Close() + if got := projectionSnapshotJSON(t, rebuilt); !bytes.Equal(got, reconciledSnapshot) { + t.Fatalf("fallback differs from full rebuild\nfallback=%s\nrebuilt=%s", reconciledSnapshot, got) + } + }) + } +} + +func TestDurableManualProjectionDeletionInvalidatesMark(t *testing.T) { + workspace := t.TempDir() + durable, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "restore me"}) + if err != nil { + t.Fatal(err) + } + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := durable.db.Exec("DELETE FROM session WHERE id = ?", session.ID); err != nil { + t.Fatal(err) + } + counter := &countingProjectionSource{projectionSource: durable.store} + durable.projectionSource = counter + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if counter.reads == 0 { + t.Fatal("manual projection deletion did not invalidate the mark") + } + var count int + if err := durable.db.QueryRow("SELECT COUNT(*) FROM session WHERE id = ?", session.ID).Scan(&count); err != nil || count != 1 { + t.Fatalf("restored session rows = %d, %v; want 1", count, err) + } + durable.Close() +} + +func TestDurableQuarantineForcesFullFallbackAndRefreshesMark(t *testing.T) { + workspace := t.TempDir() + durable, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + session, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "quarantine"}) + if err != nil { + t.Fatal(err) + } + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + broken := filepath.Join(durable.store.Dir, "message", session.ID, "msg_bad.json") + if err := os.MkdirAll(filepath.Dir(broken), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(broken, []byte(`{"id":"msg_bad"`), 0o644); err != nil { + t.Fatal(err) + } + counter := &countingProjectionSource{projectionSource: durable.store} + durable.projectionSource = counter + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if counter.reads < 2 { + t.Fatalf("quarantine reconciliation reads = %d, want incremental probe plus full fallback", counter.reads) + } + if _, err := os.Stat(broken); !os.IsNotExist(err) { + t.Fatalf("corrupt source still exists: %v", err) + } + counter.reads = 0 + if err := durable.reconcileProjection(context.Background()); err != nil { + t.Fatal(err) + } + if counter.reads != 0 { + t.Fatalf("post-quarantine warm reads = %d, want 0", counter.reads) + } + durable.Close() +} + +// projectionSnapshotJSON dumps every projector table in primary-key order so +// two databases can be compared for identical content. +func projectionSnapshotJSON(t testing.TB, durable *durableSessions) []byte { + t.Helper() + snapshot := map[string][]map[string]any{} + for _, table := range []string{"session", "message", "part", "session_message"} { + rows, err := durable.db.QueryContext(context.Background(), "SELECT * FROM "+table+" ORDER BY id") + if err != nil { + t.Fatal(err) + } + columns, err := rows.Columns() + if err != nil { + rows.Close() + t.Fatal(err) + } + images := []map[string]any{} + for rows.Next() { + values := make([]any, len(columns)) + targets := make([]any, len(columns)) + for index := range values { + targets[index] = &values[index] + } + if err := rows.Scan(targets...); err != nil { + rows.Close() + t.Fatal(err) + } + image := map[string]any{} + for index, column := range columns { + if raw, ok := values[index].([]byte); ok { + image[column] = string(raw) + } else { + image[column] = values[index] + } + } + images = append(images, image) + } + err = rows.Err() + rows.Close() + if err != nil { + t.Fatal(err) + } + snapshot[table] = images + } + raw, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + return raw +} + +func removeProjectionDatabase(t testing.TB, workspace string) { + t.Helper() + for _, suffix := range []string{"", "-wal", "-shm"} { + if err := os.Remove(filepath.Join(workspace, ".senior-dev", "senior-dev.db") + suffix); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + } +} + +func BenchmarkDurableProjectionReconciliation(b *testing.B) { + workspace := b.TempDir() + durable, err := openDurableSessions(context.Background(), workspace) + if err != nil { + b.Fatal(err) + } + projectID := durable.projectID + durable.Close() + seedSyntheticProjectionStore(b, workspace, projectID, 100, 100, 1) + durable, err = openDurableSessions(context.Background(), workspace) + if err != nil { + b.Fatal(err) + } + defer durable.Close() + counter := &countingProjectionSource{projectionSource: durable.store} + durable.projectionSource = counter + + b.Run("cold", func(b *testing.B) { + counter.reads = 0 + b.ResetTimer() + for range b.N { + if _, err := durable.db.Exec("DELETE FROM senior_dev_projection_reconcile"); err != nil { + b.Fatal(err) + } + if err := durable.reconcileProjection(context.Background()); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(counter.reads)/float64(b.N), "record-reads/op") + }) + b.Run("warm", func(b *testing.B) { + counter.reads = 0 + b.ResetTimer() + for range b.N { + if err := durable.reconcileProjection(context.Background()); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(counter.reads)/float64(b.N), "record-reads/op") + }) +} + +func seedSyntheticProjectionStore( + t testing.TB, workspace, projectID string, sessions, messages, parts int, +) { + t.Helper() + root := filepath.Join(workspace, ".senior-dev", "storage") + for sessionIndex := range sessions { + sessionID := fmt.Sprintf("ses_%03d", sessionIndex) + info := sessioncore.Info{ + ID: sessionID, Slug: sessionID, ProjectID: projectID, Directory: workspace, + Title: sessionID, Version: "test", Time: sessioncore.Time{Created: 1, Updated: 1}, + } + writeSyntheticProjectionRecord(t, filepath.Join(root, "session", sessionID+".json"), info) + for messageIndex := range messages { + messageID := fmt.Sprintf("msg_%03d_%03d", sessionIndex, messageIndex) + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID}, + Time: msgmodel.TimeCreated{Created: uint64(messageIndex + 1)}, Agent: "coder", + } + writeSyntheticProjectionRecord(t, + filepath.Join(root, "message", sessionID, messageID+".json"), message, + ) + for partIndex := range parts { + partID := fmt.Sprintf("prt_%03d_%03d_%03d", sessionIndex, messageIndex, partIndex) + part := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: partID, SessionID: sessionID, MessageID: messageID}, + Text: "synthetic projection payload", + } + writeSyntheticProjectionRecord(t, + filepath.Join(root, "part", sessionID, messageID, partID+".json"), part, + ) + } + } + } +} + +func writeSyntheticProjectionRecord(t testing.TB, path string, value any) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestProjectorFailureAttributedToOriginatingOperationContract(t *testing.T) { + // A failed projector write is returned by that mutation and cannot leak + // into the next operation's result. + workspace := t.TempDir() + durable, err := openDurableSessions(context.Background(), workspace) + if err != nil { + t.Fatal(err) + } + defer durable.Close() + root, err := durable.CreateSession(context.Background(), sessioncore.CreateInput{Title: "root"}) + if err != nil { + t.Fatal(err) + } + if _, err := durable.db.Exec(`CREATE TRIGGER reject_bad_message BEFORE INSERT ON message + WHEN NEW.id = 'bad' BEGIN SELECT RAISE(ABORT, 'bad projection'); END`); err != nil { + t.Fatal(err) + } + message := func(id string) msgmodel.User { + return msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: root.ID}, + Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder", + } + } + if err := durable.UpdateMessage(context.Background(), message("bad")); err == nil || !strings.Contains(err.Error(), "message bad") { + t.Fatalf("bad projection error = %v", err) + } + if err := durable.UpdateMessage(context.Background(), message("good")); err != nil { + t.Fatalf("next operation inherited projector error: %v", err) + } +} + +func TestNewPipelineCreatesAFreshRootSession(t *testing.T) { + // A new pipeline creates a fresh root session even when durable storage + // already has one from an earlier run. + workspace := t.TempDir() + first := newRuntime(workspace, &capturingBackend{}) + t.Cleanup(first.Close) + oldRoot, err := first.Create(context.Background(), "", "coder") + if err != nil { + t.Fatal(err) + } + first.Close() + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{Backend: &capturingBackend{}, Events: newEventWriter(io.Discard)}) + defer runner.runtime.Close() + if runner.sessionID == oldRoot { + t.Fatal("the new pipeline reused the newest durable root session") + } + if err := runner.runtime.ensureRootSession(context.Background(), runner.sessionID, "second run", "coder"); err != nil { + t.Fatal(err) + } + var roots int + if err := runner.runtime.durable.db.QueryRow("SELECT COUNT(*) FROM session WHERE parent_id IS NULL").Scan(&roots); err != nil || roots != 2 { + t.Fatalf("root sessions = %d, %v; want 2", roots, err) + } +} + +func TestDurableHistoryPreservesInstructionDedup(t *testing.T) { + // Instruction dedup reads completed read-tool metadata from the durable + // transcript after a process restart. + workspace := t.TempDir() + nested := filepath.Join(workspace, "nested") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + rules := filepath.Join(nested, "AGENTS.md") + target := filepath.Join(nested, "target.txt") + if err := os.WriteFile(rules, []byte("DURABLE NESTED RULE"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("target"), 0o644); err != nil { + t.Fatal(err) + } + arguments, _ := json.Marshal(map[string]string{"filePath": target}) + firstTransport := &scriptedRoundTripper{replies: []string{ + toolCallReply("read", string(arguments)), chatReply("first done", 10), + }} + firstRuntime := newRuntime(workspace, &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: firstTransport}, + }) + t.Cleanup(firstRuntime.Close) + rootID, err := firstRuntime.Create(context.Background(), "", "coder") + if err != nil { + firstRuntime.Close() + t.Fatal(err) + } + first, err := runTestTurn(t, firstRuntime, testTurn{ + ParentSessionID: rootID, SessionTitle: "instruction session", + Agent: "coder", ProviderID: "openrouter", + ModelID: "vendor/model", Workspace: workspace, Prompt: "read once", + }) + if err != nil { + firstRuntime.Close() + t.Fatal(err) + } + firstRuntime.Close() + + secondTransport := &scriptedRoundTripper{replies: []string{ + toolCallReply("read", string(arguments)), chatReply("second done", 10), + }} + secondRuntime := newRuntime(workspace, &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: secondTransport}, + }) + defer secondRuntime.Close() + // The second runtime is a fresh process against the same session: that is + // what makes this a durable-transcript test rather than an in-memory one. + second, err := runTestTurn(t, secondRuntime, testTurn{ + SessionID: first.SessionID, ParentSessionID: rootID, + SessionTitle: "instruction session", Agent: "coder", ProviderID: "openrouter", + ModelID: "vendor/model", Workspace: workspace, Prompt: "read again", + }) + if err != nil { + t.Fatal(err) + } + if second.SessionID != first.SessionID { + t.Fatalf("instruction session = %q, want %q", second.SessionID, first.SessionID) + } + messages, err := secondRuntime.durable.Messages(context.Background(), second.SessionID) + if err != nil { + t.Fatal(err) + } + loaded := []int{} + for _, message := range messages { + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.ToolPart) + if !ok || part.Tool != "read" { + continue + } + state, ok := part.State.(msgmodel.ToolStateCompleted) + if !ok { + continue + } + field, _ := state.Metadata.Field("loaded") + var paths []string + _ = json.Unmarshal(field, &paths) + loaded = append(loaded, len(paths)) + } + } + if len(loaded) != 2 || loaded[0] != 1 || loaded[1] != 0 { + t.Fatalf("durable instruction loaded metadata = %v, want [1 0]", loaded) + } +} + +func countStoredJSON(root string) (int, error) { + count := 0 + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() && filepath.Ext(path) == ".json" { + count++ + } + return nil + }) + return count, err +} diff --git a/internal/seniordev/app/engine_backend.go b/internal/seniordev/app/engine_backend.go new file mode 100644 index 0000000000..c34fbf636c --- /dev/null +++ b/internal/seniordev/app/engine_backend.go @@ -0,0 +1,420 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" + "github.com/Agent-Field/codeaf/internal/seniordev/project" + systemprompt "github.com/Agent-Field/codeaf/internal/seniordev/session/system" +) + +type turnToolExecutor struct{ request turn } + +func (backend *modelAPIBackend) Run( + ctx context.Context, request turn, +) (turnResult, error) { + return backend.runEngine(ctx, request) +} + +func (executor turnToolExecutor) Execute( + ctx context.Context, call steploop.ToolCall, +) (steploop.ToolResult, error) { + return executeAdvertisedTool(ctx, executor.request, call) +} + +func (backend *modelAPIBackend) runEngine( + ctx context.Context, request turn, +) (turnResult, error) { + sessionID := request.SessionID + if sessionID == "" { + sessionID = steploop.NewAscendingID("ses") + } + providerID, modelID := normalizeModelRef(request.ProviderID, request.ModelID) + // The only way composing the system prompt fails is a turn with no agent + // prompt, which is a property of the request rather than of any one step, + // so it is rejected once here and the per-step closure below cannot fail. + if _, err := composeTurnSystem(ctx, request, providerID, modelID, request.SystemInstructions); err != nil { + return turnResult{SessionID: sessionID}, err + } + system := func(callCtx context.Context) string { + // Root instructions are resolved again on every step. + instructions := request.SystemInstructions + if request.LoadInstructions != nil { + instructions = request.LoadInstructions(callCtx) + } + text, _ := composeTurnSystem(callCtx, request, providerID, modelID, instructions) + return text + } + + store := request.Store + if store == nil { + store = newTurnStore() + } + variant := request.Variant + if variant == "" { + variant = backend.variant + } + startMessageID := request.PromptMessageID + if !request.PromptPersisted { + var seedErr error + startMessageID, seedErr = seedTurn( + ctx, store, sessionID, request, providerID, modelID, variant, + ) + if seedErr != nil { + return turnResult{SessionID: sessionID}, seedErr + } + } + if startMessageID == "" { + return turnResult{SessionID: sessionID}, errors.New("senior-dev engine: prompt message is required") + } + ledger := &turnLedger{} + models := seniorDevModels{ + backend: backend, sessionID: sessionID, agent: request.Agent, variant: variant, + } + client := newSeniorDevLLM( + backend, sessionID, providerID, modelID, request.Agent, variant, system, ledger, + request.RawModelCall, + ) + client.modelRequests = request.ModelRequests + tasks := newSeniorDevCompactionController( + store, seniorDevSummaryClient{owner: client}, models, request.Workspace, + backend, system, request.Tools, request.CompactionDecisions, sessionID, + ) + loop := steploop.Loop{ + Store: store, Client: client, Models: models, + Executor: turnToolExecutor{request: request}, Tasks: tasks, + } + assistant, runErr := loop.Run(ctx, steploop.RunOptions{ + SessionID: sessionID, ParentID: request.ParentSessionID, + Workspace: request.Workspace, Worktree: request.Workspace, + MaxSteps: request.MaxSteps, + Tools: request.Tools, + InjectReminders: turnReminderInjector(store, sessionID, request.BetweenStepReminder), + AfterAssistant: request.AfterAssistant, + }) + messages, messagesErr := store.Messages(ctx, sessionID) + result := projectTurnResult( + sessionID, messagesSince(messages, startMessageID), ledger.snapshot(), + ) + if assistant.Finish != nil { + result.FinishReason = *assistant.Finish + } + if runErr != nil { + return result, runErr + } + if messagesErr != nil { + return result, messagesErr + } + if ctxErr := ctx.Err(); ctxErr != nil { + return result, ctxErr + } + if assistant.Error != nil { + return result, turnAssistantError(assistant.Error) + } + return result, nil +} + +func composeTurnSystem( + ctx context.Context, + request turn, + providerID string, + modelID string, + instructions []string, +) (string, error) { + if request.RawModelCall { + return "", nil + } + model := systemprompt.Model{ProviderID: providerID, API: systemprompt.API{ID: modelID}} + // The agent prompt is the whole role: there is no model-family base prompt + // behind it, so a turn without one has nothing to say and is refused. + agentPrompt := request.AgentMarkdown + if !request.AgentPromptVerbatim { + agentPrompt = baked.PromptContent(agentPrompt) + } + if strings.TrimSpace(agentPrompt) == "" { + return "", fmt.Errorf("senior-dev engine: agent %q has no system prompt", request.Agent) + } + parts := []string{agentPrompt} + service := systemprompt.New(turnSystemContext(ctx, request.Workspace)) + parts = append(parts, service.Environment(model)...) + // Restricted runs say so up front, so agents plan around the missing + // network instead of discovering it one failed command at a time. + parts = append(parts, netpolicy.Current().EnvironmentNotice()) + parts = append(parts, instructions...) + return strings.Join(nonEmpty(parts...), "\n"), nil +} + +func turnSystemContext(ctx context.Context, workspace string) systemprompt.Context { + directory, worktree, vcs := workspace, workspace, "" + if instance, ok := project.FromContext(ctx); ok { + if instance.Directory != "" { + directory = instance.Directory + } + if instance.Worktree != "" { + worktree = instance.Worktree + } + if instance.Project.VCS != nil { + vcs = *instance.Project.VCS + } + } + if vcs == "" && directory != "" { + command := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--is-inside-work-tree") + if output, err := command.Output(); err == nil && strings.TrimSpace(string(output)) == "true" { + vcs = "git" + } + } + return systemprompt.Context{ + Directory: directory, Worktree: worktree, + Project: systemprompt.Project{VCS: vcs}, + } +} + +func nonEmpty(values ...string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if strings.TrimSpace(value) != "" { + out = append(out, value) + } + } + return out +} + +func seedTurn( + ctx context.Context, + store steploop.Store, + sessionID string, + request turn, + providerID string, + modelID string, + variant string, +) (string, error) { + now := uint64(time.Now().UnixMilli()) + messageID := request.MessageID + if messageID == "" { + messageID = steploop.NewAscendingID("msg") + } + user := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID}, + Time: msgmodel.TimeCreated{Created: now}, + Agent: request.Agent, + Model: msgmodel.UserModel{ + ProviderID: providerID, ModelID: modelID, + }, + } + if variant != "" { + user.Model.Variant = &variant + } + if err := store.UpdateMessage(ctx, user); err != nil { + return "", err + } + if err := store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: steploop.NewAscendingID("prt"), SessionID: sessionID, MessageID: messageID, + }, + Text: request.Prompt, + }); err != nil { + return "", err + } + return messageID, nil +} + +func messagesSince(messages []msgmodel.WithParts, messageID string) []msgmodel.WithParts { + for index, message := range messages { + if message.Info.MessageID() == messageID { + return messages[index:] + } + } + return messages +} + +func turnReminderInjector( + store steploop.Store, + sessionID string, + next func() string, +) func(context.Context, []msgmodel.WithParts, msgmodel.User) ([]msgmodel.WithParts, error) { + if next == nil { + return nil + } + return func( + ctx context.Context, messages []msgmodel.WithParts, user msgmodel.User, + ) ([]msgmodel.WithParts, error) { + text := next() + if text == "" { + return messages, nil + } + messageID := steploop.NewAscendingID("msg") + reminder := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID}, + Time: msgmodel.TimeCreated{Created: uint64(time.Now().UnixMilli())}, + Agent: user.Agent, Model: user.Model, + } + part := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: steploop.NewAscendingID("prt"), SessionID: sessionID, MessageID: messageID, + }, + Text: text, Synthetic: boolPointer(true), + } + if err := store.UpdateMessage(ctx, reminder); err != nil { + return nil, err + } + if err := store.UpdatePart(ctx, part); err != nil { + return nil, err + } + out := append([]msgmodel.WithParts(nil), messages...) + return append(out, msgmodel.WithParts{Info: reminder, Parts: msgmodel.Parts{part}}), nil + } +} + +func boolPointer(value bool) *bool { return &value } + +func projectTurnResult( + sessionID string, messages []msgmodel.WithParts, calls []turnCall, +) turnResult { + result := turnResult{SessionID: sessionID} + for _, call := range calls { + result.CostUSD += call.CostUSD + } + + lastSummary := -1 + summaryText := "" + callIndex := 0 + messageCalls := make([]turnCall, 0, len(calls)) + for _, call := range calls { + if !call.Detached { + messageCalls = append(messageCalls, call) + } + } + assistantCalls := make(map[int]turnCall) + for index, message := range messages { + assistant, ok := message.Info.(msgmodel.Assistant) + if !ok { + continue + } + call := turnCall{} + if callIndex < len(messageCalls) { + call = messageCalls[callIndex] + } + assistantCalls[index] = call + callIndex++ + if assistant.Summary != nil && *assistant.Summary && + assistant.Finish != nil && *assistant.Finish != "" && assistant.Error == nil { + lastSummary = index + summaryText = messageText(message) + } + } + if lastSummary >= 0 { + result.Parts = append(result.Parts, turnPart{ + Type: "compaction", Text: summaryText, + }) + } + + pendingActionCost := 0.0 + for index, message := range messages { + assistant, ok := message.Info.(msgmodel.Assistant) + if !ok { + continue + } + call := assistantCalls[index] + if index <= lastSummary || (assistant.Summary != nil && *assistant.Summary) { + pendingActionCost += call.CostUSD + continue + } + pendingActionCost += call.CostUSD + charged := false + for _, raw := range message.Parts { + switch part := raw.(type) { + case msgmodel.TextPart: + if part.Text != "" { + result.Parts = append(result.Parts, turnPart{ + Type: "text", Text: part.Text, + }) + result.Text = part.Text + } + case msgmodel.ToolPart: + status := "pending" + args := "{}" + if part.State != nil { + status = part.State.ToolStatus() + args = string(part.State.ToolInput().Value()) + } + toolPart := turnPart{ + Type: "tool", Tool: part.Tool, ArgsKey: args, Status: status, + } + if !charged && pendingActionCost != 0 { + cost := pendingActionCost + toolPart.CostUSD = &cost + pendingActionCost = 0 + charged = true + } + result.Parts = append(result.Parts, toolPart) + } + } + } + return result +} + +func messageText(message msgmodel.WithParts) string { + var lines []string + for _, raw := range message.Parts { + if part, ok := raw.(msgmodel.TextPart); ok && strings.TrimSpace(part.Text) != "" { + lines = append(lines, part.Text) + } + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +type modelTurnError struct { + kind string + message string + statusCode *uint64 + retryable bool + responseBody string +} + +func (err *modelTurnError) Error() string { + if err.message != "" { + return err.message + } + if err.kind != "" { + return err.kind + } + return "senior-dev: model turn failed" +} + +func turnAssistantError(value *msgmodel.AssistantError) error { + if value == nil { + return nil + } + failure := &modelTurnError{kind: value.Name} + if value.Name == msgmodel.ErrNameAPI { + var data msgmodel.APIError + if json.Unmarshal(value.Data, &data) == nil { + failure.message = data.Message + failure.statusCode = data.StatusCode + failure.retryable = data.IsRetryable + if data.ResponseBody != nil { + failure.responseBody = *data.ResponseBody + } + } + } + var data struct { + Message string `json:"message"` + } + if failure.message == "" && json.Unmarshal(value.Data, &data) == nil { + failure.message = data.Message + } + return failure +} + +var _ steploop.ToolExecutor = turnToolExecutor{} diff --git a/internal/seniordev/app/engine_client.go b/internal/seniordev/app/engine_client.go new file mode 100644 index 0000000000..0146b84949 --- /dev/null +++ b/internal/seniordev/app/engine_client.go @@ -0,0 +1,503 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "runtime" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" + "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction" + "github.com/Agent-Field/codeaf/internal/seniordev/session/llmcall" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +type seniorDevModels struct { + backend *modelAPIBackend + sessionID string + agent string + variant string +} + +func (models seniorDevModels) GetModel( + _ context.Context, providerID, modelID string, +) (llmcall.Model, error) { + providerID, modelID = normalizeModelRef(providerID, modelID) + projection, _, err := models.projection(providerID, modelID) + if err != nil { + return llmcall.Model{}, err + } + maximum := orclient.MaxOutputTokens(projection) + options := orclient.Options(orclient.OptionsInput{ + Model: projection, SessionID: models.sessionID, + }) + options = orclient.MergeOptions(options, models.backend.config.options(models.agent, providerID, modelID)) + // NO ROUTING PREFERENCE LEAVES THIS PROGRAM. Which upstream serves a call + // is codeaf's model funnel's to decide — its router, its retries and its + // endpoint pins — so a `provider` block a config file spelled into the + // options is dropped here rather than sent for the API to trip over. + options = options.Without("provider") + effort := models.variant + if effort == "" { + effort = models.backend.variant + } + if effort != "" { + reasoning := orclient.NewObject() + reasoning.SetString("effort", effort) + options.SetObject("reasoning", reasoning) + } + params := orclient.RequestParams{ + MaxOutputTokens: &maximum, + OpenRouterOptions: options, + Compatibility: orclient.CompatibilityCompatible, + } + return llmcall.Model{ + ProviderID: providerID, + ID: modelID, + APIID: modelID, + Params: params, + }, nil +} + +func (models seniorDevModels) Resolve( + ctx context.Context, user msgmodel.User, +) (steploop.Model, error) { + resolved, err := models.GetModel(ctx, user.Model.ProviderID, user.Model.ModelID) + if err != nil { + return steploop.Model{}, err + } + projection, metadata, err := models.projection(resolved.ProviderID, resolved.ID) + if err != nil { + return steploop.Model{}, err + } + return steploop.Model{ + Message: msgmodel.Model{ + ProviderID: resolved.ProviderID, + ID: resolved.ID, + API: msgmodel.ModelAPI{ + Npm: projection.API.Npm, ID: projection.API.ID, + }, + }, + Calc: metadata, + Request: resolved.Params, + }, nil +} + +func (models seniorDevModels) projection( + providerID, modelID string, +) (orclient.Model, calc.Model, error) { + metadata, err := models.catalogModel(providerID, modelID) + if err != nil { + return orclient.Model{}, calc.Model{}, err + } + configured := models.backend.config.model(providerID, modelID) + limits := objectValue(configured["limit"]) + if value, ok := configNumber(limits["context"]); ok { + metadata.Limit.Context = value + } + if value, ok := configNumber(limits["output"]); ok { + metadata.Limit.Output = value + } + if value, ok := configNumber(limits["input"]); ok { + metadata.Limit.Input = &value + } + if models.backend.contextLimit != 0 { + metadata.Limit.Context = models.backend.contextLimit + } + if models.backend.outputLimit != 0 { + metadata.Limit.Output = models.backend.outputLimit + } + if metadata.Cost == nil { + metadata.Cost = &calc.ModelCost{Cache: &calc.CacheCost{}} + } + if metadata.Cost.Cache == nil { + metadata.Cost.Cache = &calc.CacheCost{} + } + cost := objectValue(configured["cost"]) + if value, ok := configNumber(cost["input"]); ok { + metadata.Cost.Input = value + } + if value, ok := configNumber(cost["output"]); ok { + metadata.Cost.Output = value + } + if value, ok := configNumber(cost["cache_read"]); ok { + metadata.Cost.Cache.Read = value + } + if value, ok := configNumber(cost["cache_write"]); ok { + metadata.Cost.Cache.Write = value + } + projection := orclient.Model{ + ProviderID: providerID, + ID: modelID, + API: orclient.ModelAPI{ + Npm: "@openrouter/ai-sdk-provider", ID: modelID, + }, + Capabilities: orclient.ModelCapabilities{ + Temperature: metadata.Capabilities.Temperature, + Reasoning: metadata.Capabilities.Reasoning, + Attachment: metadata.Capabilities.Attachment, + ToolCall: metadata.Capabilities.ToolCall, + Input: metadata.Capabilities.Input, + Output: metadata.Capabilities.Output, + }, + Limit: orclient.ModelLimit{ + Context: metadata.Limit.Context, + Input: metadata.Limit.Input, + Output: metadata.Limit.Output, + }, + } + return projection, metadata, nil +} + +func (models seniorDevModels) catalogModel(providerID, modelID string) (calc.Model, error) { + if models.backend.catalog != nil { + metadata, err := models.backend.catalog.Resolve(providerID, modelID) + if err == nil { + return metadata, nil + } + if len(models.backend.config.model(providerID, modelID)) == 0 { + return calc.Model{}, err + } + // A config-defined model absent from models.dev gets zero cost and zero + // context/output limits unless the config block supplies them. + return calc.Model{ + Cost: &calc.ModelCost{Cache: &calc.CacheCost{}}, + // Unknown capabilities are permissive. + Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true}, + }, nil + } + // A nil catalog is an explicit seam for injected engine tests. Every + // shipped CLI backend receives a loaded (possibly disabled/empty) catalog. + return calc.Model{ + Cost: &calc.ModelCost{Cache: &calc.CacheCost{}}, + // Unknown capabilities are permissive. + Capabilities: calc.ModelCapabilities{ToolCall: true, Temperature: true}, + }, nil +} + +// normalizeModelRef files a model under the service codeaf's model API speaks +// for (orclient.Service) when it names none, and takes that service's own +// prefix off the model's id, which is how the API is asked for it. +func normalizeModelRef(providerID, modelID string) (string, string) { + if providerID == "" { + if before, after, ok := strings.Cut(modelID, "/"); ok && before == orclient.Service { + providerID, modelID = before, after + } + } + if providerID == "" { + providerID = orclient.Service + } + if providerID == orclient.Service { + modelID = strings.TrimPrefix(modelID, orclient.Service+"/") + } + return providerID, modelID +} + +type seniorDevClientFactory struct { + backend *modelAPIBackend + sessionID string + models seniorDevModels + ledger *turnLedger + agent string + bypassToolFilter bool +} + +func (factory seniorDevClientFactory) Client( + _ context.Context, + model llmcall.Model, + choice *adaptive.RouteChoice, + router *adaptive.AdaptiveModelRouter, +) (llmcall.StreamClient, error) { + projection, _, err := factory.models.projection(model.ProviderID, model.ID) + if err != nil { + return nil, err + } + factory.ledger.setModel(model.ProviderID + "/" + model.ID) + client := &orclient.Client{ + BaseURL: factory.backend.api.BaseURL, + Headers: seniorDevHeaders( + factory.sessionID, factory.backend.config.headers(model.ProviderID, model.ID), + ), + Compatibility: orclient.CompatibilityCompatible, + Router: router, + RouteChoice: choice, + TotalTimeoutMS: factory.backend.totalTimeoutMS, + ChunkTimeoutMS: factory.backend.chunkTimeoutMS, + // The one door (runtime.go's fetch): the model API's token goes on + // every request here, over the backend's one HTTP client. + Fetcher: factory.backend.fetch, + } + return seniorDevStreamClient{ + client: client, model: projection, agent: factory.agent, + bypassToolFilter: factory.bypassToolFilter, + backend: factory.backend, sessionID: factory.sessionID, + }, nil +} + +type seniorDevStreamClient struct { + backend *modelAPIBackend + sessionID string + client *orclient.Client + model orclient.Model + agent string + bypassToolFilter bool +} + +func (client seniorDevStreamClient) DoStream( + ctx context.Context, params orclient.RequestParams, +) (llmcall.Stream, error) { + params.Prompt = orclient.Message(params.Prompt, client.model) + params.Tools = client.visibleTools(params.Tools) + stream, err := client.client.DoStream(ctx, params) + if err != nil { + // A context-length rejection is how a smaller-than-advertised + // endpoint announces itself; under the window policy it pins the + // session's capacity (compaction_pin.go). The error itself is + // unchanged: the step loop still turns it into a compaction. + client.backend.pinCapacityOnOverflow( + client.sessionID, client.agent, client.model.ProviderID, client.model.ID, err, + ) + return nil, err + } + return stream, nil +} + +func (client seniorDevStreamClient) visibleTools(tools []orclient.Tool) []orclient.Tool { + if client.bypassToolFilter { + return tools + } + definitions := make([]steploop.ToolDefinition, 0, len(tools)) + for _, provider := range tools { + definitions = append(definitions, steploop.ToolDefinition{Provider: provider}) + } + filtered := tool.FilterDefinitions(definitions, tool.FilterInput{ + ProviderID: client.model.ProviderID, + ModelID: client.model.ID, + Flags: tool.CurrentWebSearchFlags(), + }) + out := make([]orclient.Tool, 0, len(filtered)) + for _, definition := range filtered { + out = append(out, definition.Provider) + } + return out +} + +// seniorDevHeaders are the headers of one model request: any a config file +// named, the session affinity that keeps one conversation on one warm cache, +// and the composed user agent. The token is not among them; fetch sets it on +// the way out, over whatever these say. Nor are a service's attribution +// headers: the call is codeaf's to make and to attribute. +func seniorDevHeaders(sessionID string, configured []orclient.HeaderPair) []orclient.HeaderPair { + return orclient.BuildHeaders(orclient.HeaderInputs{ + Provider: configured, + ProviderUserAgentSuffix: "ai-sdk/openrouter/2.8.1", + Call: []orclient.HeaderPair{ + {Name: "x-session-affinity", Value: sessionID}, + }, + UtilsUserAgentSuffix: "ai-sdk/provider-utils/4.0.23", + RuntimeUserAgentSuffix: "runtime/" + runtime.Version(), + }) +} + +type turnCall struct { + Summary bool + Detached bool + CostUSD float64 + ModelID string +} + +type turnLedger struct { + mu sync.Mutex + calls []*turnCall +} + +func (ledger *turnLedger) begin(summary bool) *turnCall { + return ledger.beginCall(summary, false) +} + +func (ledger *turnLedger) beginCall(summary, detached bool) *turnCall { + call := &turnCall{Summary: summary, Detached: detached} + ledger.mu.Lock() + ledger.calls = append(ledger.calls, call) + ledger.mu.Unlock() + return call +} + +func (ledger *turnLedger) addCost(call *turnCall, cost float64) { + ledger.mu.Lock() + call.CostUSD += cost + ledger.mu.Unlock() +} + +func (ledger *turnLedger) setModel(modelID string) { + ledger.mu.Lock() + if len(ledger.calls) > 0 { + ledger.calls[len(ledger.calls)-1].ModelID = modelID + } + ledger.mu.Unlock() +} + +func (ledger *turnLedger) snapshot() []turnCall { + ledger.mu.Lock() + defer ledger.mu.Unlock() + out := make([]turnCall, 0, len(ledger.calls)) + for _, call := range ledger.calls { + out = append(out, *call) + } + return out +} + +type seniorDevLLM struct { + backend *modelAPIBackend + models seniorDevModels + service *llmcall.Service + ledger *turnLedger + sessionID string + providerID string + modelID string + agent string + system func(context.Context) string + modelRequests modelRequestSink +} + +func newSeniorDevLLM( + backend *modelAPIBackend, + sessionID, providerID, modelID, agent, variant string, + system func(context.Context) string, + ledger *turnLedger, + bypassToolFilter bool, +) *seniorDevLLM { + models := seniorDevModels{ + backend: backend, sessionID: sessionID, agent: agent, variant: variant, + } + factory := seniorDevClientFactory{ + backend: backend, sessionID: sessionID, models: models, ledger: ledger, + agent: agent, bypassToolFilter: bypassToolFilter, + } + return &seniorDevLLM{ + backend: backend, models: models, ledger: ledger, + service: &llmcall.Service{ + Models: models, Clients: factory, Router: backend.router, + DisableRouting: backend.router == nil, + }, + sessionID: sessionID, providerID: providerID, modelID: modelID, + agent: agent, system: system, + } +} + +func (client *seniorDevLLM) Stream( + ctx context.Context, params orclient.RequestParams, +) (steploop.PartStream, error) { + system := "" + if client.system != nil { + system = client.system(ctx) + } + return client.stream(ctx, params, client.agent, system, false) +} + +type seniorDevSummaryClient struct{ owner *seniorDevLLM } + +func (client seniorDevSummaryClient) Stream( + ctx context.Context, params orclient.RequestParams, +) (steploop.PartStream, error) { + return client.owner.stream( + ctx, params, "compaction", compaction.SummarySystemPrompt, true, + ) +} + +func (client *seniorDevLLM) stream( + ctx context.Context, + params orclient.RequestParams, + agent string, + system string, + summary bool, +) (steploop.PartStream, error) { + call := client.ledger.begin(summary) + return client.streamAttempt(ctx, params, agent, system, call) +} + +func (client *seniorDevLLM) streamAttempt( + ctx context.Context, + params orclient.RequestParams, + agent string, + system string, + call *turnCall, +) (steploop.PartStream, error) { + providerID, modelID := normalizeModelRef(client.providerID, params.ModelID) + if modelID == "" { + modelID = client.modelID + } + observation := beginModelRequest(ctx, client.modelRequests, client.sessionID, agent, providerID, modelID) + model, err := client.models.GetModel(ctx, providerID, modelID) + if err != nil { + observation.finish("resolve", err) + return nil, err + } + systems := []string{} + if system != "" { + systems = append(systems, system) + } + stream, err := client.service.Stream(ctx, llmcall.StreamInput{ + SessionID: client.sessionID, + Model: model, + Agent: llmcall.Agent{ + Name: agent, Mode: agent, + Tier: adaptive.ModelTier(baked.TierFor(agent)), + }, + System: systems, Messages: params.Prompt, + Tools: params.Tools, ToolChoice: params.ToolChoice, + }) + if err != nil { + observation.finish("begin", err) + return nil, err + } + return &costPartStream{inner: stream, ledger: client.ledger, call: call, observation: observation}, nil +} + +type costPartStream struct { + inner llmcall.Stream + ledger *turnLedger + call *turnCall + observation *modelRequestObservation +} + +func (stream *costPartStream) Next() (orclient.StreamPart, error) { + part, err := stream.inner.Next() + stream.observation.observe(part, err) + if finish, ok := part.(orclient.FinishPart); ok { + stream.ledger.addCost(stream.call, finishCost(finish)) + } + return part, err +} + +func (stream *costPartStream) Close() error { + err := stream.inner.Close() + stream.observation.finish("close", err) + return err +} + +func finishCost(finish orclient.FinishPart) float64 { + raw, ok := finish.Metadata.Usage.Get("cost") + if !ok { + return 0 + } + var cost float64 + if json.Unmarshal(raw, &cost) != nil { + return 0 + } + return cost +} + +var _ llmcall.ModelResolver = seniorDevModels{} +var _ steploop.ModelResolver = seniorDevModels{} +var _ llmcall.ClientFactory = seniorDevClientFactory{} +var _ steploop.LLMClient = (*seniorDevLLM)(nil) +var _ steploop.LLMClient = seniorDevSummaryClient{} diff --git a/internal/seniordev/app/engine_compaction.go b/internal/seniordev/app/engine_compaction.go new file mode 100644 index 0000000000..866d28982c --- /dev/null +++ b/internal/seniordev/app/engine_compaction.go @@ -0,0 +1,236 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "fmt" + "math" + "os/exec" + "strings" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type seniorDevCompactionModels struct { + resolver steploop.ModelResolver +} + +func (models seniorDevCompactionModels) GetModel( + ctx context.Context, providerID, modelID string, +) (compaction.Model, error) { + resolved, err := models.resolver.Resolve(ctx, msgmodel.User{ + Model: msgmodel.UserModel{ProviderID: providerID, ModelID: modelID}, + }) + if err != nil { + return compaction.Model{}, err + } + return compaction.Model{Message: resolved.Message, Overflow: resolved.Calc}, nil +} + +func (seniorDevCompactionModels) GetProvider( + context.Context, string, +) (compaction.ProviderInfo, error) { + return compaction.ProviderInfo{}, nil +} + +type seniorDevSummaryFactory struct { + store steploop.Store + client steploop.LLMClient +} + +func (factory seniorDevSummaryFactory) Create( + _ context.Context, + assistant *msgmodel.Assistant, + _ string, + model compaction.Model, +) (compaction.SummaryProcessor, error) { + stepModel := steploop.Model{Message: model.Message, Calc: model.Overflow} + return &seniorDevSummaryProcessor{ + processor: steploop.NewProcessor(steploop.ProcessorOptions{ + Store: factory.store, Assistant: *assistant, Model: stepModel, + }), + client: factory.client, + model: stepModel, + }, nil +} + +type seniorDevSummaryProcessor struct { + processor *steploop.Processor + client steploop.LLMClient + model steploop.Model +} + +func (processor *seniorDevSummaryProcessor) Process( + ctx context.Context, request compaction.SummaryRequest, +) (steploop.Result, error) { + params := processor.model.Request + params.ModelID = processor.model.Message.ID + params.Prompt = request.Messages + params.Tools = nil + params.ToolChoice = nil + if params.MaxOutputTokens == nil { + maximum := calc.MaxOutputTokens(processor.model.Calc) + params.MaxOutputTokens = &maximum + } + stream, err := processor.client.Stream(ctx, params) + if err != nil { + stream = &steploop.SliceStream{Failure: err} + } + return processor.processor.Process(ctx, stream) +} + +func (processor *seniorDevSummaryProcessor) Message() msgmodel.Assistant { + return processor.processor.Message() +} + +type seniorDevContextSizer struct { + system func(context.Context) string + tools []steploop.ToolDefinition +} + +func (sizer seniorDevContextSizer) EstimateContext( + ctx context.Context, messages []msgmodel.WithParts, model compaction.Model, +) (float64, error) { + projected, err := msgmodel.ToModelMessages(messages, model.Message, nil) + if err != nil { + return 0, err + } + tools := make([]orclient.Tool, 0, len(sizer.tools)) + for _, definition := range sizer.tools { + tools = append(tools, definition.Provider) + } + system := "" + if sizer.system != nil { + system = sizer.system(ctx) + } + raw, err := json.Marshal(struct { + System string `json:"system,omitempty"` + Messages []msgmodel.ModelMessage `json:"messages"` + Tools []orclient.Tool `json:"tools,omitempty"` + }{System: system, Messages: projected, Tools: tools}) + if err != nil { + return 0, err + } + units := len(utf16.Encode([]rune(string(raw)))) + return math.Ceil(float64(units) / 4), nil +} + +func newSeniorDevCompactionController( + store steploop.Store, + summaryClient steploop.LLMClient, + resolver steploop.ModelResolver, + workspace string, + backend *modelAPIBackend, + system func(context.Context) string, + tools []steploop.ToolDefinition, + decisions compaction.DecisionSink, + sessionID string, +) compaction.Controller { + service := compaction.NewService(compaction.Dependencies{ + Store: store, + // The session's config, not the project's: under the window policy + // a context-overflow rejection may have pinned this session's + // capacity below the window (compaction_pin.go). + Config: compaction.ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return backend.overflowConfigFor(sessionID) + }), + Agents: compaction.AgentProviderFunc(func( + context.Context, string, + ) (compaction.Agent, error) { + return compaction.Agent{Name: "compaction"}, nil + }), + Provider: seniorDevCompactionModels{resolver: resolver}, + Processors: seniorDevSummaryFactory{ + store: store, client: summaryClient, + }, + // Evidence is harvested deterministically by code: no second model call + // is made per compaction boundary. + Evidence: compaction.FallbackEvidenceSelector{}, + Sizer: seniorDevContextSizer{system: system, tools: tools}, + Decisions: decisions, + Instance: compaction.InstanceContext{Directory: workspace, Worktree: workspace}, + ChangedFiles: func(ctx context.Context) []string { + return seniorDevChangedFiles(ctx, workspace) + }, + NewID: func(prefix string) string { + if prefix == "message" { + prefix = "msg" + } else if prefix == "part" { + prefix = "prt" + } + return steploop.NewAscendingID(prefix) + }, + }) + return compaction.Controller{Compaction: service} +} + +// soloStartRef names the run's exact starting tree, written by the solo +// pipeline when the run begins (solo_finalize.go). It is what the changed-files +// record diffs against. +const soloStartRef = "refs/senior-dev/start" + +const changedFilesMaxLines = 40 + +// seniorDevChangedFiles computes the changed-files record pinned beside every +// compaction summary: a diffstat of the working tree against the starting +// tree (tracked files, which eager-commit makes every file the model writes) +// plus the short status (untracked files, in-progress edits). Read-only, no +// diff contents, hard line cap. An unavailable git answers with nothing. +func seniorDevChangedFiles(ctx context.Context, workspace string) []string { + if workspace == "" { + return nil + } + git := func(args ...string) ([]string, bool) { + argv := util.GitArgv(args...) + command := exec.CommandContext(ctx, argv[0], argv[1:]...) + command.Dir = workspace + out, err := command.Output() + if err != nil { + return nil, false + } + lines := []string{} + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) != "" { + lines = append(lines, strings.TrimRight(line, " \t")) + } + } + return lines, true + } + var record []string + if _, ok := git("rev-parse", "--verify", "--quiet", soloStartRef+"^{commit}"); ok { + if stat, ok := git("diff", "--stat=100", soloStartRef, "--"); ok { + record = append(record, "Against the starting tree (git diff --stat "+soloStartRef+"):") + if len(stat) == 0 { + record = append(record, " (no tracked file differs from the starting tree)") + } + record = append(record, capLines(stat, changedFilesMaxLines)...) + } + } + if status, ok := git("status", "--short"); ok { + record = append(record, "Working tree status (git status --short):") + if len(status) == 0 { + record = append(record, " (clean)") + } + record = append(record, capLines(status, changedFilesMaxLines)...) + } + return record +} + +func capLines(lines []string, maximum int) []string { + if len(lines) <= maximum { + return lines + } + return append(append([]string{}, lines[:maximum]...), + fmt.Sprintf(" ... %d more lines", len(lines)-maximum)) +} + +var _ steploop.TaskController = compaction.Controller{} diff --git a/internal/seniordev/app/engine_compaction_test.go b/internal/seniordev/app/engine_compaction_test.go new file mode 100644 index 0000000000..1509a062b1 --- /dev/null +++ b/internal/seniordev/app/engine_compaction_test.go @@ -0,0 +1,239 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +// recordingClient stands in for the OpenRouter transport: it keeps the +// request the summary processor built and answers with a scripted summary. +type recordingClient struct { + params []orclient.RequestParams + summary string +} + +func (client *recordingClient) Stream( + _ context.Context, params orclient.RequestParams, +) (steploop.PartStream, error) { + client.params = append(client.params, params) + total := float64(31_000) + out := float64(120) + return &steploop.SliceStream{Parts: []orclient.StreamPart{ + orclient.TextStartPart{ID: "t1"}, + orclient.TextDeltaPart{ID: "t1", Delta: client.summary}, + orclient.TextEndPart{ID: "t1"}, + orclient.FinishPart{ + FinishReason: orclient.FinishReason{Unified: "stop"}, + Usage: calc.LanguageModelV3Usage{ + InputTokens: calc.LanguageModelV3InputTokens{Total: &total}, + OutputTokens: calc.LanguageModelV3OutputTokens{Total: &out}, + }, + }, + }}, nil +} + +func wireTestModel() compaction.Model { + return compaction.Model{ + Message: msgmodel.Model{ + ProviderID: "openrouter", ID: "vendor/model", + API: msgmodel.ModelAPI{Npm: "@openrouter/ai-sdk-provider", ID: "vendor/model"}, + }, + Overflow: overflow.Model{Limit: calc.ModelLimit{Context: 131_072, Output: 8_192}}, + } +} + +type wireTestProvider struct{ model compaction.Model } + +func (p wireTestProvider) GetModel(context.Context, string, string) (compaction.Model, error) { + return p.model, nil +} + +func (wireTestProvider) GetProvider(context.Context, string) (compaction.ProviderInfo, error) { + return compaction.ProviderInfo{}, nil +} + +func validWireSummary() string { + return strings.Join([]string{ + "## Working State", + "### Completed", "- parse() implemented in src/a.js", + "### Current", "- (none)", + "### Verification", "- npm test: 2 failing", + "### Next", "- fix the failing assertions", + "### Files", "- src/a.js", + }, "\n") +} + +// Pinned end to end through the real summary factory, the real step +// processor, and the real request-body builder: the bytes that would leave +// for OpenRouter must carry the flattened transcript. +func TestSummaryRequestReachesTheWireWithTheTranscript(t *testing.T) { + store := newTurnStore() + client := &recordingClient{summary: validWireSummary()} + var decisions []compaction.CompactionDecision + budget := float64(0) + service := compaction.NewService(compaction.Dependencies{ + Store: store, + Config: compaction.ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{ + PreserveRecentTokens: &budget, + }}, nil + }), + Agents: compaction.AgentProviderFunc(func(context.Context, string) (compaction.Agent, error) { + return compaction.Agent{Name: "compaction"}, nil + }), + Provider: wireTestProvider{model: wireTestModel()}, + Processors: seniorDevSummaryFactory{store: store, client: client}, + Evidence: compaction.FallbackEvidenceSelector{}, + Decisions: compaction.DecisionSinkFunc(func(d compaction.CompactionDecision) { + decisions = append(decisions, d) + }), + Instance: compaction.InstanceContext{Directory: t.TempDir()}, + NewID: steploop.NewAscendingID, + }) + + const goal = "Fix parse() in src/a.js so nested refs resolve." + ctx := context.Background() + user := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "u0", SessionID: "ses"}, + Agent: "coder", + Model: msgmodel.UserModel{ProviderID: "openrouter", ModelID: "vendor/model"}, + } + finish := "tool_calls" + messages := []msgmodel.WithParts{ + {Info: user, Parts: msgmodel.Parts{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "p0", SessionID: "ses", MessageID: "u0"}, Text: goal, + }}}, + {Info: msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: "a0", SessionID: "ses"}, ParentID: "u0", + ModelID: "vendor/model", ProviderID: "openrouter", Finish: &finish, + }, Parts: msgmodel.Parts{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "p1", SessionID: "ses", MessageID: "a0"}, + Text: "Reading src/a.js before editing.", + }}}, + {Info: msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: "a1", SessionID: "ses"}, ParentID: "u0", + ModelID: "vendor/model", ProviderID: "openrouter", Finish: &finish, + }, Parts: msgmodel.Parts{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "p2", SessionID: "ses", MessageID: "a1"}, + Text: "Newest message, kept verbatim.", + }}}, + {Info: msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "uc", SessionID: "ses"}, Agent: "coder", + Model: user.Model, + }, Parts: msgmodel.Parts{msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses", MessageID: "uc"}, Auto: true, + }}}, + } + for _, message := range messages { + if err := store.UpdateMessage(ctx, message.Info); err != nil { + t.Fatal(err) + } + for _, part := range message.Parts { + if err := store.UpdatePart(ctx, part); err != nil { + t.Fatal(err) + } + } + } + + result, err := service.Process(ctx, compaction.ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if len(client.params) != 1 { + t.Fatalf("summary calls = %d, want 1", len(client.params)) + } + params := client.params[0] + if params.Tools != nil || params.ToolChoice != nil { + t.Fatalf("summary request carried tools: %#v", params.Tools) + } + body, err := orclient.BuildRequestBody(params) + if err != nil { + t.Fatalf("the summary request does not build a request body: %v", err) + } + var decoded struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("body shape: %v\n%s", err, body) + } + if len(decoded.Messages) != 1 || decoded.Messages[0].Role != "user" { + t.Fatalf("wire messages = %#v", decoded.Messages) + } + content := decoded.Messages[0].Content + for _, want := range []string{ + "<conversation>", "[User]: " + goal, "[Assistant]: Reading src/a.js before editing.", + "</conversation>", compaction.SummaryTemplate, + } { + if !strings.Contains(content, want) { + t.Fatalf("wire content missing %q:\n%s", want, content) + } + } + if strings.Contains(content, "Newest message, kept verbatim.") { + t.Fatalf("the verbatim tail was sent to the summarizer:\n%s", content) + } + if len(decisions) != 1 || decisions[0].SummaryStatus != "valid" || + decisions[0].SummaryPromptTokens != 31_000 || decisions[0].SummaryOutputTokens != 120 || + decisions[0].PromptChars < len(content) { + t.Fatalf("decision = %#v", decisions[0]) + } +} + +func gitIn(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.Command("git", append([]string{ + "-c", "user.name=t", "-c", "user.email=t@example.com", + }, args...)...) + command.Dir = dir + out, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func TestChangedFilesRecordDiffsAgainstTheStartRefAndListsStatus(t *testing.T) { + dir := t.TempDir() + gitIn(t, dir, "init", "-q") + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\n"), 0o600); err != nil { + t.Fatal(err) + } + gitIn(t, dir, "add", "a.txt") + gitIn(t, dir, "commit", "-q", "-m", "base") + gitIn(t, dir, "update-ref", soloStartRef, gitIn(t, dir, "rev-parse", "HEAD")) + if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("one\ntwo\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "probe.txt"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + record := strings.Join(seniorDevChangedFiles(context.Background(), dir), "\n") + for _, want := range []string{ + "git diff --stat " + soloStartRef, "a.txt | 1 +", "git status --short", "?? probe.txt", + } { + if !strings.Contains(record, want) { + t.Fatalf("record missing %q:\n%s", want, record) + } + } + if got := seniorDevChangedFiles(context.Background(), t.TempDir()); got != nil { + t.Fatalf("non-repository produced a record: %v", got) + } +} diff --git a/internal/seniordev/app/engine_contract_test.go b/internal/seniordev/app/engine_contract_test.go new file mode 100644 index 0000000000..0144d39205 --- /dev/null +++ b/internal/seniordev/app/engine_contract_test.go @@ -0,0 +1,268 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" + "github.com/Agent-Field/codeaf/internal/seniordev/session/loopguard" +) + +func recordedResponse( + request *http.Request, status int, contentType string, body string, +) *http.Response { + recorder := httptest.NewRecorder() + recorder.Header().Set("Content-Type", contentType) + recorder.WriteHeader(status) + _, _ = recorder.WriteString(body) + response := recorder.Result() + response.Request = request + return response +} + +func TestSeniorDevEngineStreamsShapesAndRepairsMisCasedToolCall(t *testing.T) { + // senior-dev uses the OpenRouter streaming/request-shaping path, and a + // mis-cased tool name (BASH for bash) is repaired before execute. + var requests [][]byte + var executed steploop.ToolCall + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + requests = append(requests, raw) + if len(requests) == 1 { + return recordedResponse( + request, http.StatusOK, "text/event-stream", + toolCallReply("BASH", `{"command":"true"}`), + ), nil + } + return recordedResponse( + request, http.StatusOK, "text/event-stream", chatReply("done", 10), + ), nil + })} + backend := &modelAPIBackend{api: testModelAPI, client: client, variant: "high"} + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", ProviderID: "openrouter", ModelID: "qwen/qwen3.6-plus", + Prompt: "repair the tool", Workspace: t.TempDir(), AgentMarkdown: testAgentPrompt, + Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "bash", Description: "run a command", + InputSchema: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`), + }}}, + Execute: func(_ context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + executed = call + return steploop.ToolResult{Output: "ok"}, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if executed.Name != "bash" || result.Text != "done" || len(result.Parts) != 2 || + result.Parts[0].Tool != "bash" || result.Parts[0].Status != "completed" { + t.Fatalf("executed=%+v result=%+v", executed, result) + } + if result.CostUSD != 0.02 || + result.Parts[0].CostUSD == nil || *result.Parts[0].CostUSD != 0.01 { + t.Fatalf("cost ledger = total %v part %+v", result.CostUSD, result.Parts[0]) + } + var body map[string]any + if err := json.Unmarshal(requests[0], &body); err != nil { + t.Fatal(err) + } + usage, _ := body["usage"].(map[string]any) + reasoning, _ := body["reasoning"].(map[string]any) + if body["stream"] != true || usage["include"] != true || + body["max_tokens"] != float64(32_000) || reasoning["effort"] != "high" || + body["prompt_cache_key"] == "" { + t.Fatalf("shaped request = %s", requests[0]) + } + // No source set a sampling parameter, so none is sent: the provider's own + // default applies. + for _, key := range []string{"temperature", "top_p", "top_k", "seed", "provider"} { + if _, present := body[key]; present { + t.Fatalf("unconfigured %s reached the wire: %s", key, requests[0]) + } + } +} + +func TestSeniorDevAdaptiveRouterFailsOverAndRegistersOutcomes(t *testing.T) { + // A failed request is registered but never replayed inside the engine. The + // next caller-owned turn resolves through the same live router and selects + // another pool candidate. + nowMS := float64(1_700_000_000_000) + restoreNow := orclient.SetNowForTesting(func() float64 { + nowMS += 1_000 + return nowMS + }) + defer restoreNow() + seed := float64(4) + var eventMu sync.Mutex + events := []adaptive.AdaptiveRouteEvent{} + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{ + testRouterCandidate("openrouter/qwen/qwen-primary", 0), + testRouterCandidate("openrouter/deepseek/deepseek-secondary", 1), + }, + RandomSeed: &seed, + OnEvent: func(event adaptive.AdaptiveRouteEvent) { + eventMu.Lock() + events = append(events, event) + eventMu.Unlock() + }, + }) + models := []string{} + primary := "" + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + var body struct { + Model string `json:"model"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + return nil, err + } + models = append(models, body.Model) + if primary == "" { + primary = body.Model + } + if body.Model == primary { + return recordedResponse( + request, http.StatusInternalServerError, "application/json", + `{"error":{"message":"Provider returned error"}}`, + ), nil + } + return recordedResponse( + request, http.StatusOK, "text/event-stream", chatReply("recovered", 10), + ), nil + })} + backend := &modelAPIBackend{api: testModelAPI, client: client, router: router} + request := turn{ + Agent: "coder", ProviderID: "openrouter", ModelID: "qwen/qwen-primary", + Prompt: "fail over", Workspace: t.TempDir(), AgentMarkdown: testAgentPrompt, + } + if _, err := backend.Run(context.Background(), request); err == nil { + t.Fatal("primary provider failure returned nil") + } + if len(models) != 1 { + t.Fatalf("first turn made %d model requests, want exactly 1", len(models)) + } + result, err := backend.Run(context.Background(), request) + if err != nil || result.Text != "recovered" { + t.Fatalf("caller-owned recovery turn = (%+v, %v)", result, err) + } + if len(models) != 2 || models[0] == models[1] { + t.Fatalf("routed models = %v, want failover", models) + } + eventMu.Lock() + defer eventMu.Unlock() + if len(events) != 2 || events[0].Failures != 1 || events[0].Error == "" || + events[0].ElapsedS != 1 || events[1].Successes != 1 || + events[1].ElapsedS != 1 || events[1].Error != "" { + t.Fatalf("router outcomes = %#v", events) + } +} + +func TestSeniorDevCostCapTripsFromEngineLedger(t *testing.T) { + // Provider usage reaches the turn result and is charged to its first tool + // action, so a cost cap can be enforced from the engine's own ledger. + responses := []string{ + toolCallReply("bash", `{"command":"true"}`), + chatReply("done", 10), + } + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + response := responses[0] + responses = responses[1:] + return recordedResponse(request, http.StatusOK, "text/event-stream", response), nil + })} + runtime := newRuntime(t.TempDir(), &modelAPIBackend{api: testModelAPI, client: client}) + t.Cleanup(runtime.Close) + result, err := runTestTurn(t, runtime, testTurn{ + Agent: "coder", ProviderID: "openrouter", + ModelID: "qwen/qwen3.6-plus", Workspace: t.TempDir(), Prompt: "spend once", + }) + if err != nil { + t.Fatal(err) + } + if result.CostUSD != 0.02 { + t.Fatalf("turn ledger = total %v", result.CostUSD) + } + maxCost := 0.005 + guard := loopguard.CreateLoopGuard(loopguard.LoopGuardOptions{MaxCostUsd: &maxCost}) + var verdict loopguard.LoopVerdict + for _, part := range result.Parts { + if part.Type == "tool" { + verdict = guard.Observe(loopguard.LoopAction{ + Tool: part.Tool, ArgsKey: part.ArgsKey, CostUsd: part.CostUSD, + }) + } + } + if verdict.Status != loopguard.LoopStatusStop || verdict.Reason == nil || + !strings.Contains(*verdict.Reason, "cost budget reached") { + t.Fatalf("cost verdict = %#v; parts=%#v", verdict, result.Parts) + } +} + +func TestSeniorDevDeadlineCancelsMidStream(t *testing.T) { + // The caller deadline reaches an already-open SSE stream + // and terminates it without waiting for provider/watchdog timeouts. + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + body := &deadlineStreamBody{ + ctx: request.Context(), + first: bytes.NewReader([]byte( + "data: {\"id\":\"gen-deadline\",\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n", + )), + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, Request: request, + }, nil + })} + backend := &modelAPIBackend{api: testModelAPI, client: client} + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + started := time.Now() + _, err := backend.Run(ctx, turn{ + Agent: "coder", ProviderID: "openrouter", ModelID: "qwen/qwen3.6-plus", + Prompt: "wait", Workspace: t.TempDir(), AgentMarkdown: testAgentPrompt, + }) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("deadline error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("mid-stream cancellation took %s", elapsed) + } +} + +func testRouterCandidate(id string, priority float64) adaptive.ModelCandidate { + return adaptive.ModelCandidate{ + ID: id, PromptUSDPerMtok: 1, CompletionUSDPerMtok: 1, + Priority: float64(priority), + } +} + +type deadlineStreamBody struct { + ctx context.Context + first *bytes.Reader +} + +func (body *deadlineStreamBody) Read(target []byte) (int, error) { + if body.first.Len() > 0 { + return body.first.Read(target) + } + <-body.ctx.Done() + return 0, body.ctx.Err() +} + +func (*deadlineStreamBody) Close() error { return nil } diff --git a/internal/seniordev/app/engine_prompt_test.go b/internal/seniordev/app/engine_prompt_test.go new file mode 100644 index 0000000000..c5ed217993 --- /dev/null +++ b/internal/seniordev/app/engine_prompt_test.go @@ -0,0 +1,233 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + configpkg "github.com/Agent-Field/codeaf/internal/seniordev/config" + "github.com/Agent-Field/codeaf/internal/seniordev/project" +) + +func systemTextFromRequest(t *testing.T, raw []byte) string { + t.Helper() + var body struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatal(err) + } + if len(body.Messages) == 0 || body.Messages[0].Role != "system" { + t.Fatalf("request has no leading system message: %s", raw) + } + var content []struct { + Text string `json:"text"` + } + if err := json.Unmarshal(body.Messages[0].Content, &content); err != nil || len(content) != 1 { + t.Fatalf("invalid system content: %s", body.Messages[0].Content) + } + return content[0].Text +} + +func TestCoderRequestSystemPromptOrderAndEnvironment(t *testing.T) { + // A coder request strips frontmatter, keeps the system prompt in order + // (role, model line, root instructions), and carries every environment + // field. + t.Setenv("AGENTFIELD_COMMIT_ATTRIBUTION", "0") + workspace := t.TempDir() + active := filepath.Join(workspace, "nested") + if err := gitRun(workspace, "init", "-b", "main"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(active, "placeholder"), "x\n"); err != nil { + t.Fatal(err) + } + rawAgent, ok := baked.GetBakedAgentMarkdown("coder") + if !ok { + t.Fatal("missing coder") + } + + var requestBody []byte + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + var err error + requestBody, err = readRequestBody(request) + if err != nil { + return nil, err + } + return recordedResponse(request, http.StatusOK, "text/event-stream", chatReply("done", 10)), nil + })} + backend := &modelAPIBackend{api: testModelAPI, client: client} + vcs := "git" + ctx := project.WithContext(context.Background(), project.InstanceContext{ + Directory: active, Worktree: workspace, + Project: project.Info{Worktree: workspace, VCS: &vcs}, + }) + _, err := backend.Run(ctx, turn{ + Agent: "coder", AgentMarkdown: rawAgent, + ProviderID: "openrouter", ModelID: "openai/gpt-6.1-codex", + Workspace: active, Prompt: "implement it", + SystemInstructions: []string{"ROOT INSTRUCTION"}, + }) + if err != nil { + t.Fatal(err) + } + system := systemTextFromRequest(t, requestBody) + for _, forbidden := range []string{"---\nmode: subagent", "permission:\n", "model: inherit"} { + if strings.Contains(system, forbidden) { + t.Fatalf("frontmatter fragment %q reached request:\n%s", forbidden, system) + } + } + ordered := []string{ + "<Role>", + "You are powered by the model named openai/gpt-6.1-codex.", + "ROOT INSTRUCTION", + } + position := -1 + for _, fragment := range ordered { + next := strings.Index(system, fragment) + if next <= position { + t.Fatalf("system sequence missing or reordered at %q:\n%s", fragment, system) + } + position = next + } + for _, field := range []string{ + "The exact model ID is openrouter/openai/gpt-6.1-codex", + " Working directory: " + active, + " Workspace root folder: " + workspace, + " Is directory a git repo: yes", + " Platform: " + runtime.GOOS, + " Today's date: ", + } { + if !strings.Contains(system, field) { + t.Errorf("environment missing %q:\n%s", field, system) + } + } +} + +func TestComposeTurnSystemRequiresAnAgentPrompt(t *testing.T) { + // There is no model-family base prompt behind the agent prompt: a turn + // without one is refused instead of being sent with an empty role. + t.Setenv("AGENTFIELD_COMMIT_ATTRIBUTION", "0") + if _, err := composeTurnSystem( + context.Background(), turn{Agent: "coder", Workspace: t.TempDir()}, + "openrouter", "deepseek/deepseek-v3", nil, + ); err == nil || !strings.Contains(err.Error(), "no system prompt") { + t.Fatalf("empty agent prompt composed a system prompt: err=%v", err) + } + agent := "<Role>specialist</Role>" + system, err := composeTurnSystem( + context.Background(), turn{Workspace: t.TempDir(), AgentMarkdown: agent}, + "openrouter", "deepseek/deepseek-v3", nil, + ) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(system, agent+"\nYou are powered") { + t.Fatalf("agent prompt must open the system prompt directly:\n%s", system) + } +} + +func TestConfiguredAgentPromptIsPassedVerbatim(t *testing.T) { + // A configured `agent.prompt` reaches the model verbatim. Only baked agent + // documents carry YAML frontmatter, so a config string that merely opens + // with a Markdown rule must survive whole. + t.Setenv("AGENTFIELD_COMMIT_ATTRIBUTION", "0") + config := &seniorDevConfig{info: configpkg.Info{ + "agent": map[string]any{ + "coder": map[string]any{"prompt": "---\nHouse rules\n---\nAlways run the linter."}, + }, + }} + configured, err := config.configureTurn(turn{Agent: "coder", Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + system, err := composeTurnSystem( + context.Background(), configured, "openrouter", "deepseek/deepseek-v3", nil, + ) + if err != nil { + t.Fatal(err) + } + for _, fragment := range []string{"House rules", "Always run the linter."} { + if !strings.Contains(system, fragment) { + t.Fatalf("configured prompt lost %q:\n%s", fragment, system) + } + } + + // An unterminated leading rule must not empty the prompt. + config.info = configpkg.Info{"agent": map[string]any{ + "coder": map[string]any{"prompt": "---\nOnly one rule: be careful."}, + }} + configured, err = config.configureTurn(turn{Agent: "coder", Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + system, err = composeTurnSystem( + context.Background(), configured, "openrouter", "deepseek/deepseek-v3", nil, + ) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(system, "Only one rule: be careful.") { + t.Fatalf("unterminated rule emptied the configured prompt:\n%s", system) + } +} + +func TestBakedRuntimeControlsKeepExplicitPoolModel(t *testing.T) { + // coder.md declares `model: inherit` and no step cap: the pool model the + // caller chose stays, and no baked cap is invented. + cfg := &seniorDevConfig{} + configured, err := cfg.configureTurn(turn{ + Agent: "coder", ProviderID: "openrouter", + ModelID: "deepseek/deepseek-v4-flash-0731", + }) + if err != nil { + t.Fatal(err) + } + if configured.ProviderID != "openrouter" || + configured.ModelID != "deepseek/deepseek-v4-flash-0731" { + t.Fatalf("baked model overrode the explicit pool model: %+v", configured) + } + if configured.MaxSteps != nil { + t.Fatalf("coder max steps = %v, want none from the baked document", *configured.MaxSteps) + } +} + +func TestConfiguredTurnEmitsEffectiveRuntimeProvenance(t *testing.T) { + var output bytes.Buffer + runtime := &runtimeAdapter{ + config: &seniorDevConfig{}, events: newEventWriter(&output), + } + if _, err := runtime.configureTurn(turn{ + Agent: "coder", SessionID: "ses-coder", + AgentMarkdown: "coder prompt", ProviderID: "openrouter", + ModelID: "deepseek/model", + }); err != nil { + t.Fatal(err) + } + for _, fragment := range []string{ + `"stage":"agent-runtime"`, `"agent":"coder"`, + `"session_id":"ses-coder"`, `"model_id":"deepseek/model"`, + `"prompt_sha256"`, + } { + if !strings.Contains(output.String(), fragment) { + t.Fatalf("runtime provenance missing %s: %s", fragment, output.String()) + } + } +} + +func readRequestBody(request *http.Request) ([]byte, error) { + defer request.Body.Close() + return io.ReadAll(request.Body) +} diff --git a/internal/seniordev/app/engine_router.go b/internal/seniordev/app/engine_router.go new file mode 100644 index 0000000000..9d7367326b --- /dev/null +++ b/internal/seniordev/app/engine_router.go @@ -0,0 +1,54 @@ +//go:build !windows + +package app + +import ( + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" + "github.com/Agent-Field/codeaf/internal/seniordev/router/state" +) + +type adaptiveRouterBackend interface { + setAdaptiveRouter(*adaptive.AdaptiveModelRouter) +} + +func (backend *modelAPIBackend) setAdaptiveRouter(router *adaptive.AdaptiveModelRouter) { + backend.router = router +} + +func initRunRouter(args cliArgs, events ...*eventWriter) *adaptive.AdaptiveModelRouter { + handle := state.InitRouter(adaptive.AdaptiveRouterConfig{ + // An empty low or frontier pool is left empty: the router routes + // that tier on the high pool. + HighModels: configuredCandidates(args.High, adaptive.ModelTierHigh), + LowModels: configuredCandidates(args.Low, adaptive.ModelTierLow), + FrontierModels: configuredCandidates(args.Frontier, adaptive.ModelTierFrontier), + OnEvent: func(event adaptive.AdaptiveRouteEvent) { + state.EmitRouteEvent(state.ToRouteEvent(event)) + if len(events) > 0 && events[0] != nil && + (event.Reason == "caller-canceled-pick" || event.Reason == "caller-canceled-request") { + events[0].stage("router-cancellation", event.Reason, map[string]any{ + "slot": event.Slot, "tier": event.Tier, "model": event.Model, + "provider_health_changed": false, + }) + } + // THE CODER MOVED TO ANOTHER MODEL: a stage, so codeaf's page can + // say why the model answering the work changed. Only a real change + // is one — a pick that stayed, or the first pick of the run, moved + // nothing — and only the coder's, because the history summary's + // model is not the one doing the work. The same event is on stderr + // as a `[router]` line, whole; the stage reports it and decides + // nothing. + if len(events) > 0 && events[0] != nil && event.Switched && event.Slot == "coder" { + events[0].stage("model-switch", "switched", map[string]any{ + "from": event.PreviousModel, "to": event.Model, "reason": event.Reason, + }) + } + }, + }) + router, _ := state.AdaptiveRouter(handle) + return router +} + +func configuredCandidates(raw string, tier adaptive.ModelTier) []adaptive.ModelCandidate { + return adaptive.ParseModelList(&raw, tier) +} diff --git a/internal/seniordev/app/engine_store.go b/internal/seniordev/app/engine_store.go new file mode 100644 index 0000000000..0987981a57 --- /dev/null +++ b/internal/seniordev/app/engine_store.go @@ -0,0 +1,103 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// turnStore is the in-memory fallback for direct engine tests. The shipped +// senior-dev runtime supplies its run-scoped durable session store instead. +type turnStore struct { + mu sync.Mutex + order []string + infos map[string]msgmodel.Info + parts map[string][]msgmodel.Part +} + +func newTurnStore() *turnStore { + return &turnStore{ + infos: map[string]msgmodel.Info{}, + parts: map[string][]msgmodel.Part{}, + } +} + +func (store *turnStore) Messages( + _ context.Context, sessionID string, +) ([]msgmodel.WithParts, error) { + store.mu.Lock() + defer store.mu.Unlock() + out := make([]msgmodel.WithParts, 0, len(store.order)) + for _, id := range store.order { + info := store.infos[id] + if info == nil { + continue + } + withParts := msgmodel.WithParts{Info: info, Parts: store.parts[id]} + if sessionID != "" { + switch typed := info.(type) { + case msgmodel.User: + if typed.SessionID != sessionID { + continue + } + case msgmodel.Assistant: + if typed.SessionID != sessionID { + continue + } + } + } + copied, err := copyTurnMessage(withParts) + if err != nil { + return nil, fmt.Errorf("senior-dev turn store: copy %s: %w", id, err) + } + out = append(out, copied) + } + return out, nil +} + +func (store *turnStore) UpdateMessage(_ context.Context, info msgmodel.Info) error { + store.mu.Lock() + defer store.mu.Unlock() + id := info.MessageID() + if _, exists := store.infos[id]; !exists { + store.order = append(store.order, id) + } + store.infos[id] = info + return nil +} + +func (store *turnStore) UpdatePart(_ context.Context, part msgmodel.Part) error { + store.mu.Lock() + defer store.mu.Unlock() + base := part.Base() + parts := store.parts[base.MessageID] + for index := range parts { + if parts[index].Base().ID == base.ID { + parts[index] = part + store.parts[base.MessageID] = parts + return nil + } + } + store.parts[base.MessageID] = append(parts, part) + return nil +} + +func copyTurnMessage(input msgmodel.WithParts) (msgmodel.WithParts, error) { + if input.Parts == nil { + input.Parts = msgmodel.Parts{} + } + raw, err := json.Marshal(input) + if err != nil { + return msgmodel.WithParts{}, err + } + var output msgmodel.WithParts + if err := json.Unmarshal(raw, &output); err != nil { + return msgmodel.WithParts{}, err + } + return output, nil +} diff --git a/internal/seniordev/app/events.go b/internal/seniordev/app/events.go new file mode 100644 index 0000000000..9d00f849ea --- /dev/null +++ b/internal/seniordev/app/events.go @@ -0,0 +1,232 @@ +//go:build !windows + +// This file is where the run's records go: the stage and step records the +// run reports to codeaf, and the run's own log of every record and bus +// payload for the tests that read one. +package app + +import ( + "encoding/json" + "fmt" + "io" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/bus" +) + +type event struct { + Type string `json:"type"` + Stage string `json:"stage,omitempty"` + Status string `json:"status,omitempty"` + Message string `json:"message,omitempty"` + SessionID string `json:"session_id,omitempty"` + Data map[string]any `json:"data,omitempty"` + Timestamp int64 `json:"ts"` + // `spend` only. A pointer because a run that has cost nothing yet still + // reports a figure, and omitempty would drop a real zero. + CostUSD *float64 `json:"cost_usd,omitempty"` + // `step` only: what was run, and what came back; the tool that ran it, the + // step of senior-dev's process it served (step_ids.go), and a command's + // exit code when it has one. + Command string `json:"command,omitempty"` + Observation string `json:"observation,omitempty"` + Tool string `json:"tool,omitempty"` + Step string `json:"step,omitempty"` + Exit *int `json:"exit,omitempty"` + // Added and Removed are a file tool's lines added and removed. + Added *int `json:"added,omitempty"` + Removed *int `json:"removed,omitempty"` +} + +// recordSink is where the run's protocol records go: codeaf, through the +// delegate.Host the run command was handed. It takes the two records the run +// writes as it goes; the first (hello) and the last (terminal) are the run +// command's own, because it is the one place that sees every ending. +type recordSink interface { + Stage(stage delegate.StageRecord) + Step(step delegate.StepRecord) +} + +// eventWriter is the run's one outlet for what it has to say. +// +// STDOUT CARRIES THE PROTOCOL'S RECORDS AND NOTHING ELSE (docs/design/delegate/ +// PROTOCOL.md), and it is codeaf's: a run codeaf hosts reports its stages and +// its finished steps through the host, and those are the only two records it +// writes as it goes. The instance bus's payloads — sessions, messages, parts, +// questions, model requests — and the `spend` record stay inside the process. +// The bus still carries them, and this writer still reads them: a finished +// tool part is a step, and the assistant messages are what the agent summary +// is added up from. Money is not reported here at all, because codeaf's model +// API meters every call itself. +// +// A test that wants to read the run the way senior-dev's own stream used to +// show it hands newEventWriter a writer instead, and gets every record and +// every bus payload on it, one JSON object per line. +type eventWriter struct { + mu sync.Mutex + // records is the host, in a run codeaf started. Nil in the tests that read + // the log instead. + records recordSink + // encoder is the log: every record and bus payload, for a test. Nil in a + // run codeaf started, where nothing but the host's records may reach stdout. + encoder *json.Encoder + // notes is where a stage's data goes for a person: one line per stage, on + // stderr, which codeaf keeps in a file beside the task. The protocol's + // stage record carries a curated copy of it (stage_data.go). + notes io.Writer + summary *agentSummary + // steps deduplicates `step` records: a tool part is republished as its + // state moves, so the same finished call arrives more than once. + steps map[string]struct{} + // progress is what the step classifier knows of the run so far + // (step_ids.go): whether a project file has changed, whether a submit was + // accepted. It is read and moved under mu, in the order the calls finish + // and the freeze's stage record is written. + progress stepProgress +} + +// newEventWriter is a writer whose only outlet is output: every record and +// every bus payload, one JSON object per line. It is the tests' view of a run. +func newEventWriter(output io.Writer) *eventWriter { + return &eventWriter{ + encoder: json.NewEncoder(output), + summary: newAgentSummary(), + steps: map[string]struct{}{}, + } +} + +// newRecordWriter is the writer of a run codeaf hosts: stages and steps to +// records, and each stage's data as one line on notes. +func newRecordWriter(records recordSink, notes io.Writer) *eventWriter { + return &eventWriter{ + records: records, + notes: notes, + summary: newAgentSummary(), + steps: map[string]struct{}{}, + } +} + +func (writer *eventWriter) emit(value event) { + if writer == nil { + return + } + if value.Timestamp == 0 { + value.Timestamp = time.Now().UnixMilli() + } + // ONE LOCK, SO THE RECORDS KEEP THE ORDER THE RUN MADE THEM IN. Two + // goroutines of the run can report at once (a tool finishing while the + // stage machine moves on), and codeaf reads the order as the order things + // happened in. + writer.mu.Lock() + defer writer.mu.Unlock() + if writer.encoder != nil { + _ = writer.encoder.Encode(value) + } + switch value.Type { + case "stage": + // The freeze's stage record is what says a submit was accepted, so it + // moves the step classifier's progress here, under the same lock as the + // steps and in the order the run wrote them: it is written inside the + // submit call, before that call's own step. + writer.progress = writer.progress.afterStage(value.Stage, value.Status) + if writer.records != nil { + writer.records.Stage(delegate.StageRecord{ + Stage: value.Stage, Status: value.Status, Data: stageRecordData(value.Data), + }) + } + writer.noteStage(value) + case "step": + if writer.records != nil { + writer.records.Step(delegate.StepRecord{ + Command: value.Command, Observation: value.Observation, + Tool: value.Tool, Step: value.Step, Exit: value.Exit, + Added: value.Added, Removed: value.Removed, + }) + } + } +} + +// noteStage writes a stage and its data as one line for a person reading the +// run's stderr: what the protocol's record has no field for, which is most of +// what senior-dev knows about why it did what it did. +func (writer *eventWriter) noteStage(value event) { + if writer.notes == nil { + return + } + line := "[senior-dev] " + value.Stage + " · " + value.Status + if len(value.Data) > 0 { + if data, err := json.Marshal(value.Data); err == nil { + line += " " + string(data) + } + } + _, _ = fmt.Fprintln(writer.notes, line) +} + +// busEvent reads one instance-bus payload for what the run reports from it: a +// finished tool call is a step, and a completed assistant message moves the +// agent summary. The payload itself reaches only the log. +func (writer *eventWriter) busEvent(value bus.Payload) { + if writer == nil { + return + } + // Observed OUTSIDE the writer lock: the summary keeps its own mutex, so + // aggregation never extends the encode critical section. + spend, completed := writer.summary.observeBus(value) + step, isStep := toolStepRecord(value) + writer.mu.Lock() + if writer.encoder != nil { + _ = writer.encoder.Encode(value) + } + if isStep { + if _, seen := writer.steps[step.key]; seen { + isStep = false + } else { + writer.steps[step.key] = struct{}{} + // THE STEP IS NAMED IN THE ORDER THE CALLS FINISHED, under the + // same lock that deduplicates them, so the progress it reads is the + // run's as of this call and no other. + step.step, writer.progress = stepOf(step.action, writer.progress) + } + } + writer.mu.Unlock() + // Both are emitted outside the lock, because emit takes the same one. + // Neither reaches the model: they are written after the fact, from state + // the bus already published. + if isStep { + writer.emit(event{ + Type: "step", Command: step.command, Observation: step.observation, + Tool: step.action.tool, Step: step.step, Exit: step.exit, + Added: step.added, Removed: step.removed, + }) + } + // The running total, after the message that moved it, for the log only: + // codeaf's model API meters every call itself, so a run it hosts never + // reports money. + if completed && writer.encoder != nil { + total := spend + writer.emit(event{Type: "spend", CostUSD: &total}) + } +} + +func (writer *eventWriter) stage(stage, status string, data map[string]any) { + writer.emit(event{Type: "stage", Stage: stage, Status: status, Data: data}) +} + +// verifyStep reports one command senior-dev itself ran on the tree — the +// project's own build or tests, with no model — as a step of its own: the +// command, its exit code (nil for one that hung or was cut, which has none), +// and the tail of what it printed. +// +// IT REPORTS WHAT RAN, AND CHANGES NOTHING ABOUT IT. The command, its ceiling +// and how its result is judged are the verification's own +// (full_verification_run.go); this is written after the command has exited, +// from the observation the verification already made. +func (writer *eventWriter) verifyStep(command, tail string, exit *int) { + writer.emit(event{ + Type: "step", Command: "bash: " + oneLine(command), + Observation: clipBytes(tail, stepObservationMax), + Tool: "bash", Step: StepVerify, Exit: exit, + }) +} diff --git a/internal/seniordev/app/events_agent_summary.go b/internal/seniordev/app/events_agent_summary.go new file mode 100644 index 0000000000..16819c06de --- /dev/null +++ b/internal/seniordev/app/events_agent_summary.go @@ -0,0 +1,250 @@ +//go:build !windows + +package app + +import ( + "sort" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// agentSummary aggregates per-agent model activity from the bus events the +// run already emits, so the terminal can report where the wall and the cost +// went without anyone re-deriving it from the raw event stream afterwards. +// Per-agent wall is a union of intervals over message.updated, computed the +// same way every time and read as one `agent-summary` stage event. +// +// Purely observational: it taps busEvent's existing write path, holds its own +// lock (never the writer's), and nothing reads it back into a prompt. +type agentSummary struct { + mu sync.Mutex + messages map[string]agentMessage +} + +type agentMessage struct { + id string + sessionID string + agent string + summary bool + upstream string + created uint64 + completed uint64 // 0 until the turn finishes + tokensIn uint64 + tokensOut uint64 + reasoning uint64 + cacheRead uint64 + cost float64 +} + +// Cache-miss attribution. A call whose prompt is the previous prompt plus one +// step should read nearly all of it from the provider cache; a call that reads +// well under that has lost the prefix. The first call after a compaction +// boundary is exempt (the prefix was rebuilt on purpose), as is any call too +// small for a miss to matter. The summary makes the miss count a field, and +// `cache_misses_after_upstream_switch` says how many of them coincided with +// OpenRouter changing the serving endpoint. +const ( + cacheMissMinPrompt = 4_096 + // cacheMissReadRatio: cache read below this fraction of the previous + // prompt is a miss. + cacheMissReadRatio = 0.6 +) + +func newAgentSummary() *agentSummary { + return &agentSummary{messages: map[string]agentMessage{}} +} + +// observeBus records assistant-message state. message.updated fires more than +// once per message (created, then completed with tokens), so the map keeps the +// LAST state per message ID and the rollup counts each message once. +// +// It returns the run's cumulative cost and whether THIS payload is the one +// that completed a message. Those two drive the `spend` record: a reader +// holding the run to a dollar limit needs a rising total during the run, and +// summing message.updated itself would double-count, since the same message +// arrives more than once. +func (summary *agentSummary) observeBus(value bus.Payload) (float64, bool) { + if summary == nil || value.Type != msgmodel.EventMessageUpdated { + return 0, false + } + var info msgmodel.Info + switch properties := value.Properties.(type) { + case msgmodel.UpdatedEvent: + info = properties.Info + case *msgmodel.UpdatedEvent: + if properties != nil { + info = properties.Info + } + default: + return 0, false + } + var assistant *msgmodel.Assistant + switch message := info.(type) { + case msgmodel.Assistant: + assistant = &message + case *msgmodel.Assistant: + assistant = message + } + if assistant == nil || assistant.ID == "" { + return 0, false + } + record := agentMessage{ + id: assistant.ID, + sessionID: assistant.SessionID, + agent: assistant.Agent, + summary: assistant.Summary != nil && *assistant.Summary, + upstream: assistant.Upstream, + created: assistant.Time.Created, + tokensIn: assistant.Tokens.Input, + tokensOut: assistant.Tokens.Output, + reasoning: assistant.Tokens.Reasoning, + cacheRead: assistant.Tokens.Cache.Read, + cost: float64(assistant.Cost), + } + if assistant.Time.Completed != nil { + record.completed = *assistant.Time.Completed + } + summary.mu.Lock() + previous, seen := summary.messages[assistant.ID] + summary.messages[assistant.ID] = record + completed := record.completed != 0 && (!seen || previous.completed == 0) + total := 0.0 + for _, message := range summary.messages { + total += message.cost + } + summary.mu.Unlock() + return total, completed +} + +// data rolls the per-message records up to one map per agent, with wall time +// as a union of that agent's [created, completed] intervals — concurrent +// sessions overlap, so a plain sum would overcount. +func (summary *agentSummary) data() map[string]any { + if summary == nil { + return nil + } + summary.mu.Lock() + defer summary.mu.Unlock() + type rollup struct { + calls int + intervals [][2]uint64 + tokensIn uint64 + tokensOut uint64 + reasoning uint64 + cacheRead uint64 + cost float64 + misses int + missTokens uint64 + switchMiss int + upstreams map[string]int + } + byAgent := map[string]*rollup{} + agentOf := func(message agentMessage) *rollup { + agent := message.agent + if agent == "" { + agent = "(unattributed)" + } + roll := byAgent[agent] + if roll == nil { + roll = &rollup{upstreams: map[string]int{}} + byAgent[agent] = roll + } + return roll + } + bySession := map[string][]agentMessage{} + for _, message := range summary.messages { + if message.completed == 0 || message.completed < message.created { + continue + } + roll := agentOf(message) + roll.calls++ + roll.intervals = append(roll.intervals, [2]uint64{message.created, message.completed}) + roll.tokensIn += message.tokensIn + roll.tokensOut += message.tokensOut + roll.reasoning += message.reasoning + roll.cacheRead += message.cacheRead + roll.cost += message.cost + if message.upstream != "" { + roll.upstreams[message.upstream]++ + } + bySession[message.sessionID] = append(bySession[message.sessionID], message) + } + // Misses are a property of consecutive calls in one session, so they are + // attributed on a per-session walk in call order. + for _, calls := range bySession { + sort.Slice(calls, func(i, j int) bool { + if calls[i].created != calls[j].created { + return calls[i].created < calls[j].created + } + return calls[i].id < calls[j].id + }) + var previous *agentMessage + afterBoundary := false + for index := range calls { + call := calls[index] + if call.summary { + afterBoundary = true + continue + } + prompt := call.tokensIn + call.cacheRead + if previous != nil && !afterBoundary && prompt > cacheMissMinPrompt && + float64(call.cacheRead) < cacheMissReadRatio*float64(previous.tokensIn+previous.cacheRead) { + roll := agentOf(call) + roll.misses++ + roll.missTokens += call.tokensIn + if call.upstream != "" && previous.upstream != "" && call.upstream != previous.upstream { + roll.switchMiss++ + } + } + afterBoundary = false + previous = &calls[index] + } + } + if len(byAgent) == 0 { + return nil + } + agents := map[string]any{} + names := make([]string, 0, len(byAgent)) + for name := range byAgent { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + roll := byAgent[name] + agents[name] = map[string]any{ + "calls": roll.calls, + "wall_union_ms": unionMillis(roll.intervals), + "tokens_in": roll.tokensIn, + "tokens_out": roll.tokensOut, + "tokens_reasoning": roll.reasoning, + "cache_read": roll.cacheRead, + "cost_usd": roll.cost, + "cache_misses": roll.misses, + "cache_miss_tokens_in": roll.missTokens, + "cache_misses_after_upstream_switch": roll.switchMiss, + "upstreams": roll.upstreams, + } + } + return map[string]any{"agents": agents} +} + +func unionMillis(intervals [][2]uint64) uint64 { + if len(intervals) == 0 { + return 0 + } + sort.Slice(intervals, func(i, j int) bool { + return intervals[i][0] < intervals[j][0] + }) + var total, start, end uint64 = 0, intervals[0][0], intervals[0][1] + for _, interval := range intervals[1:] { + if interval[0] > end { + total += end - start + start, end = interval[0], interval[1] + } else if interval[1] > end { + end = interval[1] + } + } + return total + end - start +} diff --git a/internal/seniordev/app/events_agent_summary_test.go b/internal/seniordev/app/events_agent_summary_test.go new file mode 100644 index 0000000000..f85caccf58 --- /dev/null +++ b/internal/seniordev/app/events_agent_summary_test.go @@ -0,0 +1,276 @@ +//go:build !windows + +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +func assistantPayload(id, agent string, created, completed uint64, tokens uint64, cost float64) bus.Payload { + message := msgmodel.Assistant{ + Role: "assistant", Agent: agent, + Time: msgmodel.AssistantTime{Created: created}, + Tokens: msgmodel.Tokens{Input: tokens, Output: tokens / 10}, + Cost: float64(cost), + } + message.ID = id + if completed > 0 { + message.Time.Completed = &completed + } + return bus.Payload{ + Type: msgmodel.EventMessageUpdated, + Properties: msgmodel.UpdatedEvent{SessionID: "ses", Info: message}, + } +} + +func summaryAgents(t *testing.T, summary *agentSummary) map[string]any { + t.Helper() + data := summary.data() + if data == nil { + t.Fatal("summary is empty") + } + agents, ok := data["agents"].(map[string]any) + if !ok { + t.Fatalf("data shape = %#v", data) + } + return agents +} + +// message.updated fires more than once per message — created first, tokens on +// completion. The rollup must count each message once, at its final state. +func TestAgentSummaryDeduplicatesMessageUpdates(t *testing.T) { + summary := newAgentSummary() + summary.observeBus(assistantPayload("m1", "coder", 1000, 0, 0, 0)) + summary.observeBus(assistantPayload("m1", "coder", 1000, 5000, 400, 0.02)) + + agents := summaryAgents(t, summary) + coder, ok := agents["coder"].(map[string]any) + if !ok { + t.Fatalf("agents = %#v", agents) + } + if coder["calls"] != 1 || coder["tokens_in"] != uint64(400) || + coder["wall_union_ms"] != uint64(4000) { + t.Fatalf("coder rollup = %#v", coder) + } +} + +// Concurrent sessions overlap; wall must be a union, never a sum. Two calls +// on [0,10s] and [5s,15s] are 15s of wall, not 20. +func TestAgentSummaryWallIsAUnionOfIntervals(t *testing.T) { + summary := newAgentSummary() + summary.observeBus(assistantPayload("m1", "coder", 0, 10_000, 100, 0.01)) + summary.observeBus(assistantPayload("m2", "coder", 5_000, 15_000, 100, 0.01)) + + coder := summaryAgents(t, summary)["coder"].(map[string]any) + if coder["calls"] != 2 || coder["wall_union_ms"] != uint64(15_000) { + t.Fatalf("coder rollup = %#v", coder) + } +} + +// An in-flight message (no completed time) is an incomplete observation and +// must not enter the rollup — a hard-killed run's dangling turn would +// otherwise contribute a zero-length or negative interval. +func TestAgentSummaryIgnoresIncompleteMessages(t *testing.T) { + summary := newAgentSummary() + summary.observeBus(assistantPayload("m1", "compaction", 1000, 0, 0, 0)) + if summary.data() != nil { + t.Fatalf("incomplete message entered the summary: %#v", summary.data()) + } +} + +// The bus publishes the concrete UpdatedEvent; pointer forms and non-message +// events must be tolerated silently — the tap can never panic the writer. +func TestAgentSummaryToleratesForeignPayloads(t *testing.T) { + summary := newAgentSummary() + summary.observeBus(bus.Payload{Type: "session.created", Properties: map[string]any{}}) + summary.observeBus(bus.Payload{Type: msgmodel.EventMessageUpdated, Properties: "garbage"}) + event := assistantPayload("m1", "coder", 0, 1_000, 10, 0) + pointerEvent := event + updated := pointerEvent.Properties.(msgmodel.UpdatedEvent) + pointerEvent.Properties = &updated + summary.observeBus(pointerEvent) + + coder := summaryAgents(t, summary)["coder"].(map[string]any) + if coder["calls"] != 1 { + t.Fatalf("pointer payload not counted: %#v", coder) + } + var nilSummary *agentSummary + nilSummary.observeBus(event) // must not panic + if nilSummary.data() != nil { + t.Fatal("nil summary produced data") + } +} + +// Wiring test: the busEvent tap must feed the summary, and the rollup must +// survive a round-trip through the real stage() encoder. The unit tests above +// exercise the aggregator directly; this one proves the writer is actually +// plumbed to it, which nothing short of a live run would otherwise check. +func TestBusEventTapFeedsTheEmittedSummary(t *testing.T) { + output := &bytes.Buffer{} + writer := newEventWriter(output) + + writer.busEvent(assistantPayload("m1", "coder", 0, 4_000, 300, 0.05)) + writer.busEvent(assistantPayload("m2", "compaction", 1_000, 9_000, 120, 0.02)) + + data := writer.summary.data() + if data == nil { + t.Fatal("bus tap did not reach the summary") + } + writer.stage("agent-summary", "completed", data) + + var seen map[string]any + for _, line := range strings.Split(output.String(), "\n") { + if !strings.Contains(line, `"stage":"agent-summary"`) { + continue + } + var record struct { + Data map[string]any `json:"data"` + } + if json.Unmarshal([]byte(line), &record) == nil { + seen = record.Data + } + } + if seen == nil { + t.Fatalf("no agent-summary line encoded; output:\n%s", output.String()) + } + agents, ok := seen["agents"].(map[string]any) + if !ok || len(agents) != 2 { + t.Fatalf("agents = %#v", seen) + } + compactionAgent, ok := agents["compaction"].(map[string]any) + if !ok { + t.Fatalf("compaction agent missing: %#v", agents) + } + // JSON round-trips numbers as float64. + if compactionAgent["wall_union_ms"].(float64) != 8_000 { + t.Fatalf("compaction wall = %v", compactionAgent["wall_union_ms"]) + } +} + +// callPayload is one completed call of a session: how much of its prompt was +// read from the provider cache, and which endpoint served it. A summary flag +// marks a compaction boundary. +func callPayload(id, agent string, created, tokensIn, cacheRead uint64, upstream string, summary bool) bus.Payload { + message := msgmodel.Assistant{ + Role: "assistant", Agent: agent, + Time: msgmodel.AssistantTime{Created: created}, + Tokens: msgmodel.Tokens{Input: tokensIn, Cache: msgmodel.TokenCache{Read: cacheRead}}, + Upstream: upstream, + } + message.ID = id + message.SessionID = "ses" + completed := created + 1000 + message.Time.Completed = &completed + if summary { + message.Summary = &summary + } + return bus.Payload{ + Type: msgmodel.EventMessageUpdated, + Properties: msgmodel.UpdatedEvent{SessionID: "ses", Info: message}, + } +} + +// A call that reads far less of the previous prompt from cache than the +// prefix it shares is a miss; the first call after a compaction boundary is +// not (its prefix was rebuilt on purpose), nor is a call too small to matter. +// Misses that coincide with an endpoint change are counted separately, and +// the calls per endpoint are reported so the switch rate is visible. +func TestAgentSummaryAttributesCacheMisses(t *testing.T) { + summary := newAgentSummary() + summary.observeBus(callPayload("m1", "coder", 1_000, 10_000, 0, "alpha", false)) // first call: no previous prompt + summary.observeBus(callPayload("m2", "coder", 2_000, 2_000, 10_000, "alpha", false)) // hit + summary.observeBus(callPayload("m3", "coder", 3_000, 12_000, 0, "beta", false)) // miss, on an endpoint switch + summary.observeBus(callPayload("m4", "coder", 4_000, 13_000, 1_000, "beta", false)) // miss, same endpoint + summary.observeBus(callPayload("m5", "compaction", 5_000, 20_000, 0, "beta", true)) // boundary + summary.observeBus(callPayload("m6", "coder", 6_000, 8_000, 0, "beta", false)) // rebuilt prefix: exempt + summary.observeBus(callPayload("m7", "coder", 7_000, 500, 8_000, "beta", false)) // hit + summary.observeBus(callPayload("m8", "coder", 8_000, 3_000, 0, "alpha", false)) // under the size floor: exempt + + agents := summaryAgents(t, summary) + coder := agents["coder"].(map[string]any) + if coder["calls"] != 7 || coder["cache_misses"] != 2 || + coder["cache_miss_tokens_in"] != uint64(25_000) || + coder["cache_misses_after_upstream_switch"] != 1 { + t.Fatalf("coder rollup = %#v", coder) + } + upstreams, ok := coder["upstreams"].(map[string]int) + if !ok || upstreams["alpha"] != 3 || upstreams["beta"] != 4 { + t.Fatalf("coder upstreams = %#v", coder["upstreams"]) + } + compaction := agents["compaction"].(map[string]any) + if compaction["calls"] != 1 || compaction["cache_misses"] != 0 { + t.Fatalf("compaction rollup = %#v", compaction) + } +} + +// The spend record is what a caller enforcing a dollar ceiling reads while the +// run is still alive. It fires once per message, on the update that completes +// it, and carries the run's cumulative cost rather than the message's own -- +// summing message.updated directly would double-count, because the same +// message arrives more than once. +func TestObserveBusReportsCumulativeSpendOncePerMessage(t *testing.T) { + summary := newAgentSummary() + + if _, completed := summary.observeBus( + assistantPayload("m1", "coder", 1000, 0, 0, 0), + ); completed { + t.Fatal("a created-but-unfinished message reported completion") + } + total, completed := summary.observeBus( + assistantPayload("m1", "coder", 1000, 5000, 400, 0.02), + ) + if !completed { + t.Fatal("the update that completed m1 did not report completion") + } + if total != 0.02 { + t.Fatalf("cumulative after m1 = %v, want 0.02", total) + } + + // A second message, on the compaction agent, adds to the same total. + total, completed = summary.observeBus( + assistantPayload("m2", "compaction", 6000, 7000, 100, 0.005), + ) + if !completed || total != 0.025 { + t.Fatalf("cumulative after m2 = %v (completed=%v), want 0.025 true", total, completed) + } + + // A late re-send of an already-complete message must not fire again, or a + // reader would see the same spend twice. + if _, completed = summary.observeBus( + assistantPayload("m1", "coder", 1000, 5000, 400, 0.02), + ); completed { + t.Fatal("a repeated completed message reported completion twice") + } +} + +// The stream carries the record, not just the accumulator. +func TestBusEventEmitsSpendRecord(t *testing.T) { + var stream bytes.Buffer + writer := newEventWriter(&stream) + writer.busEvent(assistantPayload("m1", "coder", 1000, 0, 0, 0)) + writer.busEvent(assistantPayload("m1", "coder", 1000, 5000, 400, 0.02)) + + var spends []float64 + for _, line := range bytes.Split(bytes.TrimSpace(stream.Bytes()), []byte("\n")) { + var value event + if err := json.Unmarshal(line, &value); err != nil || value.Type != "spend" { + continue + } + if value.CostUSD == nil { + t.Fatalf("spend record carries no cost_usd: %s", line) + } + spends = append(spends, *value.CostUSD) + } + if len(spends) != 1 { + t.Fatalf("spend records = %d, want 1 (only the completing update)", len(spends)) + } + if spends[0] != 0.02 { + t.Fatalf("spend cost_usd = %v, want 0.02", spends[0]) + } +} diff --git a/internal/seniordev/app/events_contract_test.go b/internal/seniordev/app/events_contract_test.go new file mode 100644 index 0000000000..ae8f088510 --- /dev/null +++ b/internal/seniordev/app/events_contract_test.go @@ -0,0 +1,156 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore" +) + +// The bus payloads senior-dev used to print on stdout are still published +// and still reach the run's log: a test's view of the run. In a run codeaf +// hosts there is no log on stdout (TestAHostedRunReportsOnlyStagesAndSteps). +func TestQuestionToolEventsReachTheLogAsBusPayloads(t *testing.T) { + workspace := testRepoWithEntrypoints(t) + var output bytes.Buffer + runner := newPipeline(cliArgs{High: "provider/high"}, workspace, pipelineDeps{ + Backend: backendFunc(func(context.Context, turn) (turnResult, error) { + return turnResult{}, nil + }), + Events: newEventWriter(&output), + }) + defer runner.runtime.Close() + if runner.runtime.initErr != nil { + t.Fatal(runner.runtime.initErr) + } + + input := json.RawMessage(`{"questions":[{"question":"Continue?","header":"Choice","options":[{"label":"Yes","description":"Continue now"}]}]}`) + _, err := runner.runtime.registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call-question", Name: "question", Input: input, + SessionID: "ses-question", MessageID: "msg-question", Agent: "coder", + }) + if err == nil || err.Error() != "The user dismissed this question" { + t.Fatalf("question error = %v, want headless rejection", err) + } + + seen := map[string]bool{} + for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) { + var value map[string]json.RawMessage + if err := json.Unmarshal(line, &value); err != nil { + t.Fatalf("event line %q: %v", line, err) + } + if len(value) != 3 || value["id"] == nil || value["type"] == nil || value["properties"] == nil { + t.Fatalf("bus line keys = %v, want exactly id/type/properties", value) + } + var eventType string + if err := json.Unmarshal(value["type"], &eventType); err != nil { + t.Fatal(err) + } + seen[eventType] = true + } + if !seen["question.asked"] || !seen["question.rejected"] { + t.Fatalf("stdout events = %v, want question.asked and question.rejected; stream=%s", seen, output.String()) + } +} + +func TestPipelineStreamsBusEventsToTheLog(t *testing.T) { + workspace := testRepoWithEntrypoints(t) + var output bytes.Buffer + runner := newPipeline(cliArgs{High: "provider/high"}, workspace, pipelineDeps{ + Backend: backendFunc(func(context.Context, turn) (turnResult, error) { + return turnResult{}, nil + }), + Events: newEventWriter(&output), + }) + defer runner.runtime.Close() + if runner.runtime.initErr != nil { + t.Fatal(runner.runtime.initErr) + } + if _, err := runner.runtime.durable.sessions.Create(context.Background(), sessioncore.CreateInput{ + ID: "ses_contract", Title: "contract", + }); err != nil { + t.Fatal(err) + } + + lines := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) + if len(lines) != 2 { + t.Fatalf("session creation lines = %d, want session.created then session.updated: %s", len(lines), output.String()) + } + for index, wantType := range []string{"session.created", "session.updated"} { + var value map[string]json.RawMessage + if err := json.Unmarshal(lines[index], &value); err != nil { + t.Fatal(err) + } + if len(value) != 3 || value["id"] == nil || value["type"] == nil || value["properties"] == nil { + t.Fatalf("bus line keys = %v, want exactly id/type/properties", value) + } + var gotType string + if err := json.Unmarshal(value["type"], &gotType); err != nil || gotType != wantType { + t.Fatalf("bus line %d type = %q (%v), want %q", index, gotType, err, wantType) + } + } +} + +// recordedHost is the part of a delegate host the event writer reports to. +type recordedHost struct { + stages []string + steps []string + stageRecords []delegate.StageRecord + stepRecords []delegate.StepRecord +} + +func (host *recordedHost) Stage(stage delegate.StageRecord) { + host.stages = append(host.stages, stage.Stage+"/"+stage.Status) + host.stageRecords = append(host.stageRecords, stage) +} + +func (host *recordedHost) Step(step delegate.StepRecord) { + host.steps = append(host.steps, step.Command) + host.stepRecords = append(host.stepRecords, step) +} + +// STDOUT IS THE PROTOCOL'S. A run codeaf hosts reports its stages and its +// finished steps and nothing else: no bus payload, no spend record, no second +// copy of a step a republished part would have made. A stage's data goes whole +// to the notes, which are stderr, for a person, and a curated copy of it rides +// the stage record; a step says its tool, the step of senior-dev's process it +// served, and a command's exit code. +func TestAHostedRunReportsOnlyStagesAndSteps(t *testing.T) { + host := &recordedHost{} + var notes bytes.Buffer + writer := newRecordWriter(host, ¬es) + + writer.stage("implement", "running", map[string]any{"attempt": 0}) + writer.busEvent(toolPartPayload("c1", "bash", "running", map[string]any{"command": "go test ./..."}, "", "")) + failing := toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "FAIL", "") + failing.Properties.(map[string]any)["part"].(map[string]any)["state"].(map[string]any)["metadata"] = map[string]any{"exitCode": 1} + writer.busEvent(failing) + writer.busEvent(failing) + writer.busEvent(assistantPayload("m1", "coder", 1, 2, 3, 0.01)) + + if len(host.stages) != 1 || host.stages[0] != "implement/running" { + t.Fatalf("stages = %v, want the one stage", host.stages) + } + if got := string(host.stageRecords[0].Data); got != `{"attempt":0}` { + t.Fatalf("stage data = %s, want the attempt", got) + } + if len(host.steps) != 1 || host.steps[0] != "bash: go test ./..." { + t.Fatalf("steps = %v, want the one finished call, once", host.steps) + } + if step := host.stepRecords[0]; step.Tool != "bash" || step.Step != StepExplore || step.Exit == nil || *step.Exit != 1 { + t.Fatalf("step = %+v, want the bash tool, the explore step and exit 1", step) + } + if !strings.Contains(notes.String(), `implement · running {"attempt":0}`) { + t.Fatalf("notes = %q, want the stage and its data for a person", notes.String()) + } + if strings.Contains(notes.String(), "message.updated") || strings.Contains(notes.String(), "spend") { + t.Fatalf("notes carry bus traffic: %q", notes.String()) + } +} diff --git a/internal/seniordev/app/full_verification.go b/internal/seniordev/app/full_verification.go new file mode 100644 index 0000000000..541e174f34 --- /dev/null +++ b/internal/seniordev/app/full_verification.go @@ -0,0 +1,76 @@ +//go:build !windows + +package app + +import ( + "context" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification" +) + +const fullVerificationTimeoutMS = 600_000 + +// The registry may be configured with another shell. POSIX-like shells with +// pipefail honor this strict mode; shells without it reject the preamble and +// therefore fail verification closed instead of trusting a masked pipeline. +const strictVerificationPreamble = "set -euo pipefail\n" + +type projectVerificationResult struct { + Commands []any + Prompt string + Failed *fullverification.Entrypoint + Failure string + // TimedOut is set when at least one entrypoint was killed at the + // fullVerificationTimeoutMS ceiling without ever producing an exit status. + // A hung suite is an INCOMPLETE observation, not a red one. + TimedOut bool + // NewFailures counts failures not excused as pre-existing (missing + // entrypoints included). + NewFailures int +} + +// timedOutEntrypoint records an entrypoint that exhausted the verification ceiling, +// together with the worktree fingerprint it hung against. +type timedOutEntrypoint struct { + Tail string + Fingerprint string + HaveFinger bool +} + +func verificationMemoKey(entrypoint fullverification.Entrypoint) string { + return entrypoint.Workdir + "\x00" + entrypoint.Command +} + +// runProjectVerification executes the discovered project-wide entrypoints +// through the live Bash registry. It deliberately disables the test memo +// while retaining the registry's process-derived exitCode metadata. +func (runner *pipeline) runProjectVerification( + ctx context.Context, +) projectVerificationResult { + return newProjectVerificationRun(runner, ctx).run() +} + +func planHasKind(plan fullverification.Plan, kind fullverification.EntrypointKind) bool { + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind == kind { + return true + } + } + return false +} + +func verificationTailSuffix(tail string) string { + if tail == "" { + return "" + } + return " — " + strings.ReplaceAll(tail, "\n", " ") +} + +func verificationOutputTail(output string, limit int) string { + output = strings.TrimSpace(output) + if output == "" { + return "" + } + return suffixUTF16(output, limit) +} diff --git a/internal/seniordev/app/full_verification_run.go b/internal/seniordev/app/full_verification_run.go new file mode 100644 index 0000000000..0388315a24 --- /dev/null +++ b/internal/seniordev/app/full_verification_run.go @@ -0,0 +1,369 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification" +) + +type projectVerificationRun struct { + runner *pipeline + ctx context.Context + plan fullverification.Plan + result projectVerificationResult + lines []string + issues []string + + currentFingerprint string + haveFingerprint bool + resolvedFingerprint bool +} + +type verificationObservation struct { + entrypoint fullverification.Entrypoint + memoKey string + exitCode int + timedOut bool + tail string + evidence map[string]any + // suiteDead marks a failure whose output shows the suite aborted before + // running at all (verification_deadtree.go). + suiteDead bool + // safetyRegression also includes suite-local parser aborts (notably Jest), + // which are unsafe to accept even when unrelated suites still ran. + safetyRegression bool +} + +func newProjectVerificationRun(runner *pipeline, ctx context.Context) *projectVerificationRun { + return &projectVerificationRun{ + runner: runner, + ctx: ctx, + plan: fullverification.Discover(runner.workspace), + result: projectVerificationResult{Commands: []any{}}, + lines: []string{ + "# Independent full project verification", + "senior-dev independently discovered and ran the standard project entrypoints", + "below in fresh Bash subprocesses. These are process-derived command/exit", + "observations, not the model's claims. Consult them, but still run and cite", + "your own fresh verification commands.", + }, + issues: []string{}, + } +} + +func (run *projectVerificationRun) run() projectVerificationResult { + // THE CHECK SAYS IT HAS STARTED, so a reader following the run knows + // senior-dev is running the project's build and tests itself before the + // first of them has finished. It reports; it decides nothing. + run.runner.events.stage("verification", "running", map[string]any{ + "commands": len(run.plan.Entrypoints), + }) + for _, entrypoint := range run.plan.Entrypoints { + observation := run.observe(entrypoint) + run.record(observation) + } + run.recordMissingEntrypoints() + vacuous := run.recordVacuousVerification() + return run.finish(vacuous) +} + +// fingerprint resolves the tree fingerprint lazily and at most once per pass. +// A run where nothing hangs must not pay for a scan. +func (run *projectVerificationRun) fingerprint() (string, bool) { + if !run.resolvedFingerprint { + run.currentFingerprint, run.haveFingerprint = run.runner.worktreeFingerprint(run.ctx) + run.resolvedFingerprint = true + } + return run.currentFingerprint, run.haveFingerprint +} + +func (run *projectVerificationRun) observe( + entrypoint fullverification.Entrypoint, +) verificationObservation { + observation := verificationObservation{ + entrypoint: entrypoint, + memoKey: verificationMemoKey(entrypoint), + exitCode: -1, + } + if run.replayPriorTimeout(&observation) { + observation.evidence = run.commandEvidence(observation) + return observation + } + run.execute(&observation) + run.updateTimeoutMemo(observation) + observation.evidence = run.commandEvidence(observation) + return observation +} + +func (run *projectVerificationRun) replayPriorTimeout( + observation *verificationObservation, +) bool { + prior, ok := run.runner.verificationTimeouts[observation.memoKey] + if !ok || !prior.HaveFinger { + return false + } + current, ok := run.fingerprint() + if !ok || current != prior.Fingerprint { + return false + } + observation.timedOut = true + observation.tail = prior.Tail + run.runner.note(fmt.Sprintf( + "[senior-dev] full verification %s: %s — replaying recorded timeout "+ + "(tree unchanged since it hung; not paying the %ds ceiling again)\n", + observation.entrypoint.Kind, observation.entrypoint.Command, + fullVerificationTimeoutMS/1000, + )) + return true +} + +func (run *projectVerificationRun) execute(observation *verificationObservation) { + entrypoint := observation.entrypoint + bashInput := map[string]any{ + "command": strictVerificationPreamble + entrypoint.Command, + "timeout_ms": fullVerificationTimeoutMS, + } + if entrypoint.Workdir != "" { + bashInput["workdir"] = entrypoint.Workdir + } + input, _ := json.Marshal(bashInput) + toolResult, err := run.runner.runtime.registry.Execute( + run.ctx, + steploop.ToolCall{ + ID: run.runner.runtime.nextID("verification"), Name: "bash", Input: input, + SessionID: run.runner.sessionID, Agent: "coder", + }, + ) + if err == nil { + observation.exitCode, observation.timedOut = verificationExit(toolResult.Metadata.Raw()) + } + // A COMMAND CUT BY THE RUN'S OWN ENDING HAS NO EXIT STATUS. When the run's + // context ends while the project's commands run — codeaf's stop, or the + // wall clock — the command is killed half way, and what it left reads as a + // failure it never reported. It is recorded the way a hang is: an + // incomplete observation, never a red one, so neither is a submitted + // candidate failed nor an unsubmitted tree restored on its account. A + // command that had already exited clean before the stop keeps its pass. + if run.ctx.Err() != nil && observation.exitCode != 0 { + observation.exitCode, observation.timedOut = -1, true + } + output := toolResult.Output + if err != nil { + output = err.Error() + } + observation.tail = verificationOutputTail(output, 600) + // Suite-abort detection for the unsubmitted-tree finalizer + // (verification_deadtree.go). + if observation.exitCode != 0 && !observation.timedOut && suiteDeadOutput(output) { + observation.suiteDead = true + } + if observation.exitCode != 0 && !observation.timedOut && safetyRegressionOutput(output) { + observation.safetyRegression = true + } +} + +func verificationExit(raw json.RawMessage) (int, bool) { + var metadata struct { + ExitCode *int `json:"exitCode"` + } + if json.Unmarshal(raw, &metadata) == nil && metadata.ExitCode != nil { + return *metadata.ExitCode, false + } + // bash.go omits exitCode on exactly one successful registry path: the + // timeout branch, where it kills the process group after the ceiling. + return -1, true +} + +func (run *projectVerificationRun) updateTimeoutMemo(observation verificationObservation) { + if !observation.timedOut { + delete(run.runner.verificationTimeouts, observation.memoKey) + return + } + if run.runner.verificationTimeouts == nil { + run.runner.verificationTimeouts = map[string]timedOutEntrypoint{} + } + recorded, ok := run.fingerprint() + run.runner.verificationTimeouts[observation.memoKey] = timedOutEntrypoint{ + Tail: observation.tail, Fingerprint: recorded, HaveFinger: ok, + } +} + +func (run *projectVerificationRun) commandEvidence( + observation verificationObservation, +) map[string]any { + entrypoint := observation.entrypoint + evidence := map[string]any{ + "cmd": entrypoint.Command, "exit": float64(observation.exitCode), + "tail": observation.tail, "source": entrypoint.Source, + "kind": string(entrypoint.Kind), "buildExpected": run.plan.BuildExpected, + "testExpected": run.plan.TestExpected, + } + if entrypoint.Workdir != "" { + evidence["workdir"] = entrypoint.Workdir + } + if observation.timedOut { + evidence["timedOut"] = true + } + if observation.suiteDead { + evidence["suite_dead"] = true + } + if observation.safetyRegression { + evidence["safety_regression"] = true + } + return evidence +} + +func (run *projectVerificationRun) record(observation verificationObservation) { + run.result.Commands = append(run.result.Commands, observation.evidence) + run.recordCommandLine(observation) + entrypoint := observation.entrypoint + // EACH COMMAND IS A STEP OF ITS OWN, reported after it ran and judged + // exactly as before: a command that hung, or that the run's own ending cut, + // has no exit to report. + var exit *int + if !observation.timedOut { + code := observation.exitCode + exit = &code + } + run.runner.events.verifyStep(entrypoint.Command, observation.tail, exit) + run.runner.note(fmt.Sprintf( + "[senior-dev] full verification %s: %s (exit=%d, source=%s)\n", + entrypoint.Kind, entrypoint.Command, observation.exitCode, entrypoint.Source, + )) + if observation.exitCode == 0 { + return + } + // Every non-zero exit is a failure, full stop. Excusing a red command as + // "pre-existing" on the strength of a pre-edit baseline probe would let a + // red baseline route every later red into the excused path, and the run + // would ship claiming it had verified. Whether an untouched test was + // already red is a question for the implement loop, on demand, at the + // moment of failure -- never a standing authority to ignore a failing + // command at ship time. + run.recordNewFailure(observation) +} + +func (run *projectVerificationRun) recordCommandLine(observation verificationObservation) { + entrypoint := observation.entrypoint + if observation.timedOut { + run.lines = append(run.lines, fmt.Sprintf( + "- [%s] `%s` (source: %s) HUNG — killed at the %ds verification ceiling with no exit status%s", + entrypoint.Kind, entrypoint.Command, entrypoint.Source, + fullVerificationTimeoutMS/1000, verificationTailSuffix(observation.tail), + )) + return + } + run.lines = append(run.lines, fmt.Sprintf( + "- [%s] `%s` (source: %s) exit=%d%s", + entrypoint.Kind, entrypoint.Command, entrypoint.Source, observation.exitCode, + verificationTailSuffix(observation.tail), + )) +} + +func (run *projectVerificationRun) recordNewFailure(observation verificationObservation) { + entrypoint := observation.entrypoint + if run.result.Failed == nil { + failed := entrypoint + run.result.Failed = &failed + } + run.result.NewFailures++ + issue := fmt.Sprintf( + "project %s verification failed: `%s` exited %d", + entrypoint.Kind, entrypoint.Command, observation.exitCode, + ) + if observation.timedOut { + run.result.TimedOut = true + issue = fmt.Sprintf( + "project %s verification did not complete: `%s` was killed after %ds "+ + "(the verification ceiling) without producing an exit status — the suite "+ + "hung, it did not report failures", + entrypoint.Kind, entrypoint.Command, fullVerificationTimeoutMS/1000, + ) + } + if observation.tail != "" { + issue += ": " + observation.tail + } + run.issues = append(run.issues, issue) +} + +func (run *projectVerificationRun) recordMissingEntrypoints() { + // Only demand entrypoints the discovered ecosystem is expected to have. + run.recordMissingEntrypoint( + run.plan.BuildExpected, fullverification.KindBuild, + "(project build/typecheck entrypoint not found)", + "project build/typecheck verification failed: no standard build/typecheck entrypoint was discoverable", + "- [build] no standard project build/typecheck entrypoint discovered", + ) + run.recordMissingEntrypoint( + run.plan.TestExpected, fullverification.KindTest, + "(project test entrypoint not found)", + "project test verification failed: no standard test entrypoint was discoverable", + "- [test] no standard project test entrypoint discovered", + ) +} + +func (run *projectVerificationRun) recordMissingEntrypoint( + expected bool, + kind fullverification.EntrypointKind, + command string, + issue string, + line string, +) { + if !expected || planHasKind(run.plan, kind) { + return + } + missing := fullverification.Entrypoint{ + Kind: kind, Command: command, Source: "manifest/CI/documentation discovery", + } + if run.result.Failed == nil { + run.result.Failed = &missing + } + run.result.NewFailures++ + run.issues = append(run.issues, issue) + run.lines = append(run.lines, line) +} + +func (run *projectVerificationRun) recordVacuousVerification() bool { + vacuous := len(run.plan.Entrypoints) == 0 && + !run.plan.BuildExpected && !run.plan.TestExpected + if !vacuous { + return false + } + run.lines = append(run.lines, + "- [none] no project build/typecheck or test entrypoint exists to discover:", + " this workspace carries no language manifest, build system, or test suite.", + " Full-project verification is VACUOUS here — it proves nothing.") + run.runner.note("[senior-dev] full project verification found nothing to run " + + "(no language manifest, build system, or test suite) — vacuous pass\n") + return true +} + +func (run *projectVerificationRun) finish(vacuous bool) projectVerificationResult { + if len(run.issues) == 1 { + run.result.Failure = run.issues[0] + } else if len(run.issues) > 1 { + run.result.Failure = "project verification failed: " + strings.Join(run.issues, "; ") + } + run.result.Prompt = strings.Join(run.lines, "\n") + status := "pass" + data := map[string]any{"commands": run.result.Commands} + if vacuous { + data["vacuous"] = true + } + if run.result.Failed != nil { + status = "fail" + data["reason"] = run.result.Failure + } + run.runner.events.stage("verification", status, data) + // The result is remembered against the tree it measured, so finalize can + // consult the last verdict on an unchanged tree without re-verifying — the + // runs that need the dead-tree check end with no wall left to verify. + run.runner.rememberVerifiedTree(run.result) + return run.result +} diff --git a/internal/seniordev/app/full_verification_test.go b/internal/seniordev/app/full_verification_test.go new file mode 100644 index 0000000000..7eb8bbe382 --- /dev/null +++ b/internal/seniordev/app/full_verification_test.go @@ -0,0 +1,221 @@ +//go:build !windows + +package app + +import ( + "context" + "io" + "path/filepath" + "strings" + "testing" +) + +// These tests describe what senior-dev's own project verification guarantees. +// +// The family they belong to is "you cannot manufacture a green verification": +// a run whose evidence array carries exit=1 commands must not report a pass, +// and a project whose suite was never discovered is not a project that passed. + +func writePassingPythonUnitTest(t *testing.T, workspace string) { + t.Helper() + const source = `import unittest + +class GreenTest(unittest.TestCase): + def test_green(self): + self.assertEqual(2 + 2, 4) +` + if err := writeFile(filepath.Join(workspace, "tests", "test_green.py"), source); err != nil { + t.Fatal(err) + } +} + +func TestRedEntrypointAlwaysFailsVerification(t *testing.T) { + // A command that exits non-zero fails verification. Unconditionally. + // + // An escape hatch that excused a command because a pre-edit baseline probe + // had also seen it red would log the command as evidence but never count + // it, so NewFailures would stay 0 and the run would report a green + // verification while its own evidence array carried exit=1 commands. + // + // This test is the floor: a broken build is a failed verification no matter + // what the tree looked like before the first edit. + workspace := t.TempDir() + writePassingPythonUnitTest(t, workspace) + if err := writeFile(filepath.Join(workspace, "Makefile"), + "build:\n\texit 1\ntest:\n\ttrue\n"); err != nil { + t.Fatal(err) + } + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + defer runner.runtime.Close() + + verification := runner.runProjectVerification(context.Background()) + if verification.Failed == nil { + t.Fatal("a build exiting 1 must fail verification") + } + if verification.NewFailures == 0 { + t.Fatalf("NewFailures = 0 with a red build; the excused path is back") + } +} + +func TestEveryRedVerificationCommandIsCounted(t *testing.T) { + // Every command recorded with a non-zero exit is counted as a new + // failure. A recorded red command that does not reach NewFailures means + // some caller has introduced a way to excuse a failure again. + workspace := t.TempDir() + writePassingPythonUnitTest(t, workspace) + if err := writeFile(filepath.Join(workspace, "Makefile"), + "build:\n\texit 1\ntest:\n\texit 1\n"); err != nil { + t.Fatal(err) + } + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + defer runner.runtime.Close() + + verification := runner.runProjectVerification(context.Background()) + red := 0 + for _, command := range verification.Commands { + evidence, ok := command.(map[string]any) + if !ok { + continue + } + if exit, ok := evidence["exit"].(float64); ok && exit != 0 { + red++ + } + } + if red == 0 { + t.Fatal("fixture produced no red command; the test proves nothing") + } + if verification.NewFailures < red { + t.Fatalf("NewFailures = %d but %d commands exited non-zero: "+ + "some red command was recorded as evidence without being counted", + verification.NewFailures, red) + } +} + +func verificationWorkspace(t *testing.T, files map[string]string) *pipeline { + t.Helper() + workspace := t.TempDir() + for name, content := range files { + if err := writeFile(filepath.Join(workspace, name), content); err != nil { + t.Fatal(err) + } + } + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + t.Cleanup(runner.runtime.Close) + return runner +} + +func TestShellControlFlowCannotManufactureGreenVerification(t *testing.T) { + // Six ways to make a red suite exit 0. Each of them turns "the tests pass" + // into "the shell returned zero", and a run that ships on that evidence has + // verified nothing. senior-dev executes discovered entrypoints under pipefail + // for exactly this reason. + for _, command := range []string{ + "go test ./... || true", + "true || go test ./...", + "exit 0; go test ./...", + "true; go test ./...", + "go test ./... | cat", + "go test ./... 2>&1 | tee test.log", + } { + t.Run(command, func(t *testing.T) { + runner := verificationWorkspace(t, map[string]string{ + "go.mod": "module example.test/red\n\ngo 1.23\n", + "red_test.go": "package red\n\nimport \"testing\"\n\n" + + "func TestRed(t *testing.T) { t.Fatal(\"red\") }\n", + "AGENTS.md": "Run `go build ./...` and `" + command + "`.\n", + }) + if verification := runner.runProjectVerification(context.Background()); verification.Failed == nil { + t.Fatalf("control-flow bypass produced a green verification: %#v", verification) + } + }) + } +} + +func TestAProjectWhoseTestEntrypointWasNeverFoundDoesNotPass(t *testing.T) { + // The vacuous-green shape. A Go module is expected to have a test + // entrypoint; if discovery cannot find one, that is a failure with no + // failing COMMAND behind it -- Failed is set and the command list is empty. + // + // This is why soloShip reads verification.Failed rather than counting + // non-zero exits: counting commands would report a verified pass for a + // project whose suite was never located. + // A Makefile with a build target and nothing else: discovering any command + // makes the workspace accountable, and accountability is what demands a + // test entrypoint. A go.mod would defeat the fixture -- the Go ecosystem + // defaults supply `go test ./...` unprompted, so nothing would be missing. + runner := verificationWorkspace(t, map[string]string{ + "Makefile": "build:\n\t@true\n", + }) + verification := runner.runProjectVerification(context.Background()) + if verification.Failed == nil { + t.Fatal("a project with no discoverable test entrypoint reported a green verification") + } + if !missingEntrypointFailure(verification) { + t.Fatalf("expected a discovery failure, got %#v", verification.Failed) + } + if countFailingEntrypoints(verification) != 0 { + t.Fatal("fixture no longer isolates the missing-entrypoint case from failing commands") + } +} + +func TestABareWorkspaceVerifiesVacuouslyRatherThanFailing(t *testing.T) { + // The complement, and the reason the check above is Failed rather than + // "did we run anything". A directory with no manifest, build system or + // suite has nothing to verify. Demanding entrypoints there would fail every + // documentation-only task on principle. + runner := verificationWorkspace(t, map[string]string{"NOTES.txt": "no build system here\n"}) + verification := runner.runProjectVerification(context.Background()) + if verification.Failed != nil { + t.Fatalf("a bare workspace was failed for having nothing to run: %#v", verification.Failed) + } + if !strings.Contains(verification.Prompt, "VACUOUS") { + t.Fatalf("a vacuous pass must say so in its evidence:\n%s", verification.Prompt) + } +} + +// EVERY COMMAND senior-dev RUNS ON THE TREE ITSELF IS A STEP OF ITS OWN — the +// verify step, the bash tool, the command, its exit code and the tail of what +// it printed — reported after it ran, and judged exactly as before. +func TestEachVerificationCommandIsReportedAsAVerifyStep(t *testing.T) { + workspace := t.TempDir() + writePassingPythonUnitTest(t, workspace) + if err := writeFile(filepath.Join(workspace, "Makefile"), + "build:\n\t@echo broken; exit 2\ntest:\n\t@true\n"); err != nil { + t.Fatal(err) + } + host := &recordedHost{} + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newRecordWriter(host, io.Discard), Notes: io.Discard, + }) + defer runner.runtime.Close() + + verification := runner.runProjectVerification(context.Background()) + if len(host.stepRecords) != len(verification.Commands) || len(host.stepRecords) == 0 { + t.Fatalf("steps = %d for %d commands, want one each", len(host.stepRecords), len(verification.Commands)) + } + failed := false + for i, step := range host.stepRecords { + evidence := verification.Commands[i].(map[string]any) + if step.Step != StepVerify || step.Tool != "bash" || step.Command != "bash: "+evidence["cmd"].(string) { + t.Fatalf("step %d = %+v, want the verify step for %v", i, step, evidence["cmd"]) + } + if step.Exit == nil || float64(*step.Exit) != evidence["exit"].(float64) { + t.Fatalf("step %d exit = %v, want the evidence's %v", i, step.Exit, evidence["exit"]) + } + if *step.Exit == 2 && strings.Contains(step.Observation, "broken") { + failed = true + } + } + if !failed || verification.Failed == nil { + t.Fatalf("the failing build is not a verify step with its exit and tail: %+v", host.stepRecords) + } + if first, last := host.stages[0], host.stages[len(host.stages)-1]; first != "verification/running" || last != "verification/fail" { + t.Fatalf("the stages are %v, want the check's start before its steps and its own result after them", host.stages) + } +} diff --git a/internal/seniordev/app/gitrepo_test.go b/internal/seniordev/app/gitrepo_test.go new file mode 100644 index 0000000000..7796aa055c --- /dev/null +++ b/internal/seniordev/app/gitrepo_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package app + +import ( + "io" + "os" + "os/exec" + "path/filepath" + "testing" +) + +// gitTestRepo is a one-commit repository with a pipeline pointed at it, for +// tests that exercise the tree helpers without a model or a runtime. +func gitTestRepo(t *testing.T) *pipeline { + t.Helper() + dir := t.TempDir() + git := func(args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", + "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + git("init", "-q") + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + git("add", "-A") + git("commit", "-q", "-m", "base") + return &pipeline{ + workspace: dir, events: newEventWriter(io.Discard), notes: io.Discard, + recorder: newGitRecorder(dir, func(string) {}), + } +} + +// verificationWith is a completed verification with the given number of +// failing entrypoints plus one passing one. +func verificationWith(failing int) projectVerificationResult { + commands := []any{} + for i := 0; i < failing; i++ { + commands = append(commands, map[string]any{"exit": float64(1)}) + } + commands = append(commands, map[string]any{"exit": float64(0)}) + return projectVerificationResult{Commands: commands} +} + +func writeWorkspace(t *testing.T, runner *pipeline, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(runner.workspace, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/seniordev/app/ignore.go b/internal/seniordev/app/ignore.go new file mode 100644 index 0000000000..25dcdb6627 --- /dev/null +++ b/internal/seniordev/app/ignore.go @@ -0,0 +1,197 @@ +//go:build !windows + +package app + +import ( + "bufio" + "os" + "path" + "path/filepath" + "regexp" + "strings" +) + +// A .gitignore reader for the snapshot recorder. Under the git recorder this +// file is dead weight: git answers "what belongs to the tree" itself. Without +// git something has to, and answering it wrongly is not a cosmetic bug -- an +// over-broad ignore silently drops the model's work out of the answer, and an +// under-broad one sweeps build output into it. +// +// WHAT IT IMPLEMENTS: blank lines and #comments; a leading ! negation; a +// trailing / restricting a pattern to directories; a leading or embedded / +// anchoring a pattern to the file it came from; *, ? and ** globs; per- +// directory .gitignore files, where a deeper file's rules win over a shallower +// one's, and a later rule in one file wins over an earlier one. +// +// WHAT IT DOES NOT: .git/info/exclude, core.excludesFile, .gitattributes, +// nested repositories, or character classes. Those are real gitignore features +// this deliberately skips. `senior-dev run` without --in-place uses git and is +// unaffected; the limits are documented in ARCHITECTURE.md so an in-place run +// on a repository that leans on them is a known, visible gap rather than a +// surprise. +type ignoreRules struct { + // byDir maps a directory (slash-separated, relative to the workspace, "" + // for the root) to the rules its own .gitignore declared. + byDir map[string][]ignoreRule +} + +type ignoreRule struct { + pattern *regexp.Regexp + negate bool + dirOnly bool + anchored bool + source string // the directory the rule came from +} + +func newIgnoreRules() *ignoreRules { + return &ignoreRules{byDir: map[string][]ignoreRule{}} +} + +// load reads the .gitignore in one directory, if it has one. dir is relative +// to the workspace, slash-separated, "" at the root. +func (rules *ignoreRules) load(workspace, dir string) { + name := filepath.Join(workspace, filepath.FromSlash(dir), ".gitignore") + file, err := os.Open(name) + if err != nil { + return + } + defer file.Close() + var parsed []ignoreRule + scanner := bufio.NewScanner(file) + for scanner.Scan() { + if rule, ok := parseIgnoreLine(scanner.Text(), dir); ok { + parsed = append(parsed, rule) + } + } + if len(parsed) > 0 { + rules.byDir[dir] = parsed + } +} + +func parseIgnoreLine(line, dir string) (ignoreRule, bool) { + trimmed := strings.TrimRight(line, " \t") + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return ignoreRule{}, false + } + rule := ignoreRule{source: dir} + if strings.HasPrefix(trimmed, "!") { + rule.negate = true + trimmed = trimmed[1:] + } + if strings.HasSuffix(trimmed, "/") { + rule.dirOnly = true + trimmed = strings.TrimSuffix(trimmed, "/") + } + if trimmed == "" { + return ignoreRule{}, false + } + // A pattern containing a slash anywhere but at its end is anchored to the + // directory its .gitignore sits in; one without is matched against every + // path component below that directory. + rule.anchored = strings.Contains(trimmed, "/") + trimmed = strings.TrimPrefix(trimmed, "/") + rule.pattern = compileIgnoreGlob(trimmed) + return rule, rule.pattern != nil +} + +// compileIgnoreGlob turns a gitignore glob into an anchored regexp. ** spans +// separators, * and ? do not. +func compileIgnoreGlob(glob string) *regexp.Regexp { + var builder strings.Builder + builder.WriteString("^") + for index := 0; index < len(glob); index++ { + switch glob[index] { + case '*': + if index+1 < len(glob) && glob[index+1] == '*' { + builder.WriteString(".*") + index++ + // A trailing separator after ** is optional, so "a/**" matches + // "a" as well as "a/b". + if index+1 < len(glob) && glob[index+1] == '/' { + index++ + } + continue + } + builder.WriteString("[^/]*") + case '?': + builder.WriteString("[^/]") + default: + builder.WriteString(regexp.QuoteMeta(string(glob[index]))) + } + } + builder.WriteString("$") + compiled, err := regexp.Compile(builder.String()) + if err != nil { + return nil + } + return compiled +} + +// ignored reports whether a path is excluded. relative is slash-separated and +// relative to the workspace. The deepest .gitignore that has an opinion wins, +// and within one file the last matching rule wins -- which is what makes a +// negation able to rescue a path an earlier rule excluded. +func (rules *ignoreRules) ignored(relative string, isDir bool) bool { + decided, excluded := false, false + // Shallowest first, so a deeper directory's rules overwrite the decision. + for _, dir := range ancestorDirs(relative) { + for _, rule := range rules.byDir[dir] { + if rule.dirOnly && !isDir { + continue + } + if rule.matches(relative, dir) { + decided, excluded = true, !rule.negate + } + } + } + if !decided { + return false + } + return excluded +} + +func (rule ignoreRule) matches(relative, dir string) bool { + within := relative + if dir != "" { + within = strings.TrimPrefix(relative, dir+"/") + if within == relative { + return false + } + } + if rule.anchored { + return rule.pattern.MatchString(within) + } + // Unanchored: the pattern applies to any component, and to any directory + // prefix, so "build" excludes "build" and everything under it. + for { + if rule.pattern.MatchString(within) { + return true + } + parent := path.Dir(within) + if parent == "." || parent == within { + return false + } + within = parent + } +} + +// ancestorDirs lists the directories whose .gitignore can speak about a path, +// shallowest first: "", then each parent, excluding the path itself. +func ancestorDirs(relative string) []string { + dirs := []string{""} + parent := path.Dir(relative) + if parent == "." || parent == "/" { + return dirs + } + parts := strings.Split(parent, "/") + current := "" + for _, part := range parts { + if current == "" { + current = part + } else { + current += "/" + part + } + dirs = append(dirs, current) + } + return dirs +} diff --git a/internal/seniordev/app/model_request_events.go b/internal/seniordev/app/model_request_events.go new file mode 100644 index 0000000000..a2a57f59da --- /dev/null +++ b/internal/seniordev/app/model_request_events.go @@ -0,0 +1,236 @@ +//go:build !windows + +package app + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" +) + +// This is an event-only observer. No field is persisted into model messages, +// used for routing, or read by the budget/exit machinery. IDs are process-local +// and deliberately do not consume the model-message ID generator. +type modelRequestEvent struct { + RequestID string `json:"requestID"` + SessionID string `json:"sessionID"` + Agent string `json:"agent"` + RequestedProvider string `json:"requestedProvider"` + RequestedModel string `json:"requestedModel"` + Phase string `json:"phase"` + Status string `json:"status,omitempty"` + ErrorStage string `json:"errorStage,omitempty"` + ResponseID string `json:"responseID,omitempty"` + ServedModel string `json:"servedModel,omitempty"` + Provider string `json:"provider,omitempty"` + FinishReason string `json:"finishReason,omitempty"` + ElapsedMS int64 `json:"elapsedMS"` + FirstDeltaMS *int64 `json:"firstDeltaMS,omitempty"` + LastDeltaMS *int64 `json:"lastDeltaMS,omitempty"` + TextCharacters int64 `json:"textCharacters"` + ReasoningCharacters int64 `json:"reasoningCharacters"` + SubstantiveDeltas int64 `json:"substantiveDeltas"` +} + +type modelRequestSink func(modelRequestEvent) + +var modelRequestEventDefinition = bus.Define("session.model.request", modelRequestEvent{}) +var modelRequestSequence atomic.Uint64 + +func newModelRequestSink(instance *bus.Bus) modelRequestSink { + if instance == nil { + return nil + } + return func(event modelRequestEvent) { instance.Publish(modelRequestEventDefinition, event) } +} + +type modelRequestObservation struct { + mu sync.Mutex + ctx context.Context + sink modelRequestSink + event modelRequestEvent + start time.Time + end time.Time + done bool + streamError error + providerError bool + aborted bool + sawEOF bool + sawFinish bool +} + +func beginModelRequest(ctx context.Context, sink modelRequestSink, session, agent, provider, model string) *modelRequestObservation { + if sink == nil { + return nil + } + o := &modelRequestObservation{ctx: ctx, sink: sink, start: time.Now(), event: modelRequestEvent{ + RequestID: fmt.Sprintf("request-%d", modelRequestSequence.Add(1)), + SessionID: modelRequestLabel(session), Agent: modelRequestLabel(agent), + RequestedProvider: modelRequestLabel(provider), RequestedModel: modelRequestLabel(model), + Phase: "begin", + }} + o.emit(o.event) + return o +} + +// Provider-origin fields are bounded identifiers, never arbitrary metadata. +// Reject rather than truncate malformed values, so they cannot resemble a valid +// generation ID after clipping. No raw error, header, usage object, text, tool +// argument, annotation, or reasoning record enters this event. +func modelRequestLabel(value string) string { + if len(value) > 200 { + return "" + } + for _, c := range value { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || + c == '-' || c == '_' || c == '.' || c == '/' || c == ':' || c == ' ' || c == '(' || c == ')') { + return "" + } + } + return value +} + +func modelRequestFinish(value string) string { + switch value { + case "stop", "length", "tool-calls", "content-filter", "error", "other", "unknown": + return value + default: + return "unknown" + } +} + +func (o *modelRequestObservation) observe(part orclient.StreamPart, err error) { + if o == nil { + return + } + o.mu.Lock() + defer o.mu.Unlock() + if o.done { + return + } + switch value := part.(type) { + case orclient.TextDeltaPart: + o.recordDelta(value.Delta, false) + case orclient.ReasoningDeltaPart: + o.recordDelta(value.Delta, true) + case orclient.ResponseMetadataPart: + if value.IsModel { + o.event.ServedModel = modelRequestLabel(value.ModelID) + } else { + o.event.ResponseID = modelRequestLabel(value.ID) + } + case orclient.FinishPart: + o.sawFinish = true + o.event.FinishReason = modelRequestFinish(value.FinishReason.Unified) + if value.Metadata.Provider != nil { + o.event.Provider = modelRequestLabel(*value.Metadata.Provider) + } + if o.end.IsZero() { + o.end = time.Now() + } + case orclient.ErrorPart: + o.providerError = true + if o.end.IsZero() { + o.end = time.Now() + } + case orclient.AbortPart: + o.aborted = true + if o.end.IsZero() { + o.end = time.Now() + } + } + if err != nil { + if errors.Is(err, io.EOF) { + o.sawEOF = true + } else if o.streamError == nil { + o.streamError = err + } + if o.end.IsZero() { + o.end = time.Now() + } + } +} + +// Called under mu; count Unicode code points without retaining content. +// Empty deltas and tool-input deltas are deliberately not substantive here. +func (o *modelRequestObservation) recordDelta(value string, reasoning bool) { + if value == "" { + return + } + elapsed := time.Since(o.start).Milliseconds() + if o.event.FirstDeltaMS == nil { + first := elapsed + o.event.FirstDeltaMS = &first + } + o.event.LastDeltaMS = &elapsed + o.event.SubstantiveDeltas++ + if reasoning { + o.event.ReasoningCharacters += int64(utf8.RuneCountInString(value)) + } else { + o.event.TextCharacters += int64(utf8.RuneCountInString(value)) + } +} + +// Close is the ownership boundary, so it emits the one final observation even +// for a caller that abandons a canceled stream without reading its last part. +// Delay publication until Close to include cleanup errors; elapsed time ends at +// the first stream terminal observation, excluding subsequent tool settlement. +func (o *modelRequestObservation) finish(stage string, err error) { + if o == nil { + return + } + o.mu.Lock() + if o.done { + o.mu.Unlock() + return + } + o.done = true + if o.end.IsZero() { + o.end = time.Now() + } + event := o.event + event.Phase = "end" + event.ElapsedMS = o.end.Sub(o.start).Milliseconds() + if o.streamError != nil { + err, stage = o.streamError, "stream" + } + switch { + case errors.Is(err, context.DeadlineExceeded): + event.Status, event.ErrorStage = "deadline", stage + case errors.Is(err, context.Canceled): + event.Status, event.ErrorStage = "canceled", stage + case err != nil: + event.Status, event.ErrorStage = "error", stage + case o.providerError: + event.Status, event.ErrorStage = "provider-error", "stream" + case o.aborted: + event.Status, event.ErrorStage = "aborted", "stream" + case o.sawFinish: + event.Status = "finished" + case errors.Is(o.ctx.Err(), context.DeadlineExceeded): + event.Status, event.ErrorStage = "deadline", "close" + case errors.Is(o.ctx.Err(), context.Canceled): + event.Status, event.ErrorStage = "canceled", "close" + case o.sawEOF: + event.Status = "eof-without-finish" + default: + event.Status = "closed-without-finish" + } + o.mu.Unlock() + o.emit(event) +} + +func (o *modelRequestObservation) emit(event modelRequestEvent) { + // Optional telemetry failures must never change model success, errors, or + // cleanup. The concrete sink only publishes to the existing local bus. + defer func() { _ = recover() }() + o.sink(event) +} diff --git a/internal/seniordev/app/model_request_events_test.go b/internal/seniordev/app/model_request_events_test.go new file mode 100644 index 0000000000..11fcb24f39 --- /dev/null +++ b/internal/seniordev/app/model_request_events_test.go @@ -0,0 +1,304 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +type observedFakeStream struct { + parts []orclient.StreamPart + err, closeErr error + closed int +} + +func (s *observedFakeStream) Next() (orclient.StreamPart, error) { + if len(s.parts) == 0 { + return nil, s.err + } + p := s.parts[0] + s.parts = s.parts[1:] + return p, nil +} +func (s *observedFakeStream) Close() error { s.closed++; return s.closeErr } + +func TestModelRequestObservationOnceOnly(t *testing.T) { + for _, tc := range []struct { + name string + parts []orclient.StreamPart + err, closeErr error + cancel bool + status, stage string + }{ + {"normal", []orclient.StreamPart{orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "stop"}}}, io.EOF, nil, false, "finished", ""}, + {"tool", []orclient.StreamPart{orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "tool-calls"}}}, io.EOF, nil, false, "finished", ""}, + {"failed", nil, errors.New("private transport message"), nil, false, "error", "stream"}, + {"provider-error", []orclient.StreamPart{orclient.ErrorPart{Error: json.RawMessage(`{"message":"secret"}`)}}, io.EOF, nil, false, "provider-error", "stream"}, + {"canceled-read", nil, context.Canceled, nil, false, "canceled", "stream"}, + {"deadline-read", nil, context.DeadlineExceeded, nil, false, "deadline", "stream"}, + {"canceled-close", nil, io.EOF, nil, true, "canceled", "close"}, + {"close-error", nil, io.EOF, errors.New("private close message"), false, "error", "close"}, + {"finish-close-error", []orclient.StreamPart{orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "stop"}}}, io.EOF, errors.New("private close message"), false, "error", "close"}, + {"abort", []orclient.StreamPart{orclient.AbortPart{}}, io.EOF, nil, false, "aborted", "stream"}, + {"eof", nil, io.EOF, nil, false, "eof-without-finish", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var events []modelRequestEvent + o := beginModelRequest(ctx, func(e modelRequestEvent) { events = append(events, e) }, "ses-test", "coder", "openrouter", "vendor/model") + inner := &observedFakeStream{parts: append([]orclient.StreamPart{}, tc.parts...), err: tc.err, closeErr: tc.closeErr} + ledger := &turnLedger{} + stream := &costPartStream{inner: inner, ledger: ledger, call: ledger.begin(false), observation: o} + for range tc.parts { + if _, err := stream.Next(); err != nil { + t.Fatal(err) + } + } + if _, err := stream.Next(); err != tc.err { + t.Fatalf("Next error changed: %v", err) + } + if tc.cancel { + cancel() + } + for range 2 { + if err := stream.Close(); err != tc.closeErr { + t.Fatalf("Close error changed: %v", err) + } + } + o.finish("begin", errors.New("duplicate")) + if len(events) != 2 || events[0].Phase != "begin" || events[1].Phase != "end" || events[0].RequestID != events[1].RequestID || events[1].Status != tc.status || events[1].ErrorStage != tc.stage { + t.Fatalf("events = %+v", events) + } + if inner.closed != 2 { + t.Fatalf("underlying Close calls changed: %d", inner.closed) + } + raw, _ := json.Marshal(events) + if strings.Contains(string(raw), "private") || strings.Contains(string(raw), "secret") { + t.Fatalf("error leaked: %s", raw) + } + }) + } +} + +func TestModelRequestMetadataWhitelistAndClock(t *testing.T) { + var events []modelRequestEvent + o := beginModelRequest(context.Background(), func(e modelRequestEvent) { events = append(events, e) }, "ses", "compaction", "openrouter", "vendor/model") + provider := "Provider (fast)" + for range 2 { + o.observe(orclient.ResponseMetadataPart{ID: "gen-123"}, nil) + o.observe(orclient.ResponseMetadataPart{ModelID: "vendor/served", IsModel: true}, nil) + } + o.observe(orclient.TextDeltaPart{Delta: "PRIVATE PROMPT"}, nil) + o.observe(orclient.ReasoningDeltaPart{Delta: "思考"}, nil) + o.observe(orclient.TextDeltaPart{Delta: ""}, nil) + o.observe(orclient.FinishPart{FinishReason: orclient.FinishReason{Unified: "stop"}, Metadata: orclient.OpenRouterMetadata{Provider: &provider}}, nil) + // Pin clock boundaries directly: tool settlement / Close must not count. + o.start = time.Unix(10, 0) + o.end = o.start.Add(1234 * time.Millisecond) + o.finish("close", nil) + end := events[1] + if end.ResponseID != "gen-123" || end.ServedModel != "vendor/served" || end.Provider != provider || end.Agent != "compaction" || end.ElapsedMS != 1234 { + t.Fatalf("end=%+v", end) + } + if end.TextCharacters != 14 || end.ReasoningCharacters != 2 || end.SubstantiveDeltas != 2 || end.FirstDeltaMS == nil || end.LastDeltaMS == nil || *end.LastDeltaMS < *end.FirstDeltaMS { + t.Fatalf("delta counters = %+v", end) + } + raw, _ := json.Marshal(events) + if strings.Contains(string(raw), "PRIVATE") { + t.Fatal("text leaked") + } + for _, value := range []string{strings.Repeat("x", 201), "line\nbreak", "{\"secret\":1}", "credential=secret"} { + if modelRequestLabel(value) != "" { + t.Fatalf("unsafe label accepted: %q", value) + } + } + if modelRequestFinish("raw private reason") != "unknown" { + t.Fatal("raw finish leaked") + } + second := beginModelRequest(context.Background(), func(modelRequestEvent) {}, "ses", "coder", "openrouter", "model") + if second.event.RequestID == o.event.RequestID { + t.Fatal("correlation reused") + } +} + +// Real request assembly and HTTP transport, without sockets or model calls. +// Compare all outbound bytes/headers and returned stream parts with nil, +// recording, and panicking sinks, for both coder and actual summary clients. +func TestModelRequestTelemetryLeavesWireAndResultsUnchanged(t *testing.T) { + for _, summary := range []bool{false, true} { + for _, reply := range []string{chatReply("answer", 10), toolCallReply("bash", `{"command":"true"}`)} { + var baseBody []byte + var baseHeader http.Header + var baseParts []string + var baseCalls []turnCall + for mode := 0; mode < 3; mode++ { + var body []byte + var header http.Header + var events []modelRequestEvent + backend := &modelAPIBackend{api: testModelAPI, variant: "high", client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + body, _ = io.ReadAll(r.Body) + header = r.Header.Clone() + // Metadata already in OpenRouter's supported stream format. + prefix := "data: {\"id\":\"gen-123\",\"model\":\"vendor/served\",\"provider\":\"Provider A\",\"choices\":[]}\n\n" + return recordedResponse(r, 200, "text/event-stream", prefix+reply), nil + })}} + ledger := &turnLedger{} + client := newSeniorDevLLM(backend, "ses-fixed", "openrouter", "vendor/model", "coder", "high", nil, ledger, false) + if mode == 1 { + client.modelRequests = func(e modelRequestEvent) { events = append(events, e) } + } + if mode == 2 { + client.modelRequests = func(modelRequestEvent) { panic("telemetry unavailable") } + } + params := orclient.RequestParams{ModelID: "vendor/model", Prompt: []msgmodel.ModelMessage{msgmodel.UserText("PRIVATE TASK")}} + var stream steploop.PartStream + var err error + if summary { + stream, err = (seniorDevSummaryClient{owner: client}).Stream(context.Background(), params) + } else { + stream, err = client.Stream(context.Background(), params) + } + if err != nil { + t.Fatal(err) + } + var parts []string + for { + p, e := stream.Next() + if e == io.EOF { + break + } + if e != nil { + t.Fatal(e) + } + raw, _ := json.Marshal(p) + parts = append(parts, string(raw)) + } + if err := stream.Close(); err != nil { + t.Fatal(err) + } + if mode == 0 { + baseBody = body + baseHeader = header + baseParts = parts + baseCalls = ledger.snapshot() + } else if !reflect.DeepEqual(body, baseBody) || !reflect.DeepEqual(header, baseHeader) || !reflect.DeepEqual(parts, baseParts) || !reflect.DeepEqual(ledger.snapshot(), baseCalls) { + t.Fatalf("telemetry changed wire/parts/costs: summary=%v mode=%d", summary, mode) + } + if mode == 1 { + if len(events) != 2 || events[1].Provider != "Provider A" || events[1].ServedModel != "vendor/served" || events[1].Status != "finished" { + t.Fatalf("events=%+v", events) + } + wantAgent := "coder" + if summary { + wantAgent = "compaction" + } + if events[1].Agent != wantAgent { + t.Fatal("wrong agent") + } + } + } + } + } +} + +func TestModelRequestBeginFailureAndCancellation(t *testing.T) { + for _, failure := range []error{errors.New("PRIVATE HTTP FAILURE"), context.Canceled, context.DeadlineExceeded} { + var events []modelRequestEvent + backend := &modelAPIBackend{api: testModelAPI, client: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, failure })}} + client := newSeniorDevLLM(backend, "ses", "openrouter", "vendor/model", "coder", "", nil, &turnLedger{}, false) + client.modelRequests = func(e modelRequestEvent) { events = append(events, e) } + _, err := client.Stream(context.Background(), orclient.RequestParams{}) + if err == nil || len(events) != 2 || events[1].ErrorStage != "begin" { + t.Fatalf("err=%v events=%+v", err, events) + } + want := "error" + if failure == context.Canceled { + want = "canceled" + } + if failure == context.DeadlineExceeded { + want = "deadline" + } + if events[1].Status != want { + t.Fatalf("status=%s want %s", events[1].Status, want) + } + raw, _ := json.Marshal(events) + if strings.Contains(string(raw), "PRIVATE") { + t.Fatal("raw error leaked") + } + } +} + +func TestModelRequestCanceledBeforeReadAndNilSinkClose(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + var events []modelRequestEvent + o := beginModelRequest(ctx, func(e modelRequestEvent) { events = append(events, e) }, "ses", "coder", "openrouter", "model") + cancel() + inner := &observedFakeStream{} + stream := &costPartStream{inner: inner, observation: o} + if err := stream.Close(); err != nil { + t.Fatal(err) + } + if len(events) != 2 || events[1].Status != "canceled" { + t.Fatalf("events=%+v", events) + } + plain := &costPartStream{inner: inner} + if err := plain.Close(); err != nil { + t.Fatal(err) + } +} + +func TestModelRequestRuntimeWiring(t *testing.T) { + backend := &modelAPIBackend{api: testModelAPI, client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return recordedResponse(r, 200, "text/event-stream", chatReply("done", 10)), nil + })}} + runtime := newRuntime(t.TempDir(), backend) + defer runtime.Close() + var events []modelRequestEvent + runtime.bus.SubscribeCallback(modelRequestEventDefinition, func(p bus.Payload) { events = append(events, p.Properties.(modelRequestEvent)) }) + result, err := runtime.runTurn(context.Background(), turn{Agent: "coder", ProviderID: "openrouter", ModelID: "vendor/model", Prompt: "PRIVATE TASK", RawModelCall: true}) + if err != nil { + t.Fatal(err) + } + if result.Text != "done" || len(events) != 2 || events[1].Status != "finished" || events[1].SessionID != result.SessionID { + t.Fatalf("result=%+v events=%+v", result, events) + } +} + +func TestModelRequestBusSink(t *testing.T) { + if newModelRequestSink(nil) != nil || beginModelRequest(context.Background(), nil, "", "", "", "") != nil { + t.Fatal("nil sink not inert") + } + instance := bus.New(bus.Context{}) + var got []bus.Payload + instance.SubscribeCallback(modelRequestEventDefinition, func(p bus.Payload) { got = append(got, p) }) + o := beginModelRequest(context.Background(), newModelRequestSink(instance), "ses", "coder", "openrouter", "model") + o.finish("close", nil) + if len(got) != 2 || got[0].Type != "session.model.request" { + t.Fatalf("got=%+v", got) + } +} + +func TestModelRequestResolutionFailure(t *testing.T) { + var events []modelRequestEvent + backend := &modelAPIBackend{catalog: seniorDevCatalogFixture(t)} + client := newSeniorDevLLM(backend, "ses", "openrouter", "missing/model", "coder", "", nil, &turnLedger{}, false) + client.modelRequests = func(e modelRequestEvent) { events = append(events, e) } + _, err := client.Stream(context.Background(), orclient.RequestParams{}) + if err == nil || len(events) != 2 || events[1].Status != "error" || events[1].ErrorStage != "resolve" { + t.Fatalf("err=%v events=%+v", err, events) + } +} diff --git a/internal/seniordev/app/netpolicy_visibility_test.go b/internal/seniordev/app/netpolicy_visibility_test.go new file mode 100644 index 0000000000..36b5ca6fb4 --- /dev/null +++ b/internal/seniordev/app/netpolicy_visibility_test.go @@ -0,0 +1,19 @@ +//go:build !windows + +package app + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +func TestPolicyDisabledTools(t *testing.T) { + if names := policyDisabledTools(netpolicy.Policy{Mode: netpolicy.ModeAllow}); names != nil { + t.Fatalf("allow mode should disable nothing, got %v", names) + } + names := policyDisabledTools(netpolicy.Policy{Mode: netpolicy.ModeOff}) + if len(names) != 2 || names[0] != "webfetch" || names[1] != "websearch" { + t.Fatalf("off mode should hide both web tools, got %v", names) + } +} diff --git a/internal/seniordev/app/patch_contract.go b/internal/seniordev/app/patch_contract.go new file mode 100644 index 0000000000..d5bcaa74aa --- /dev/null +++ b/internal/seniordev/app/patch_contract.go @@ -0,0 +1,24 @@ +//go:build !windows + +package app + +import ( + "context" +) + +type countingWriter int64 + +func (writer *countingWriter) Write(value []byte) (int, error) { + *writer += countingWriter(len(value)) + return len(value), nil +} + +// emitPatchSummary records the shape of the run's final diff against the base +// commit -- files, line counts, binaries, patch bytes, untracked files -- on +// the event stream. It is observational only: nothing in the run acts on it. +func (runner *pipeline) emitPatchSummary(baseSHA string) { + ctx, cancel := context.WithTimeout(context.Background(), summaryTimeout) + defer cancel() + data, status := runner.recorder.Summary(ctx, baseSHA) + runner.events.stage("patch-summary", status, data) +} diff --git a/internal/seniordev/app/patch_contract_test.go b/internal/seniordev/app/patch_contract_test.go new file mode 100644 index 0000000000..1d2a7170d9 --- /dev/null +++ b/internal/seniordev/app/patch_contract_test.go @@ -0,0 +1,38 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "strings" + "testing" +) + +func TestSafeSeniorDevEnvironmentRedactsSecretLikeValues(t *testing.T) { + t.Setenv("SENIOR_DEV_NET", "off") + t.Setenv("SENIOR_DEV_EXAMPLE_TOKEN", "do-not-record-me") + got := safeSeniorDevEnvironment() + if got["SENIOR_DEV_NET"] != "off" { + t.Fatalf("ordinary variable missing: %#v", got) + } + if got["SENIOR_DEV_EXAMPLE_TOKEN"] != "<redacted>" { + t.Fatalf("secret-like value was not redacted: %#v", got) + } +} + +func TestPatchSummaryEmitsBoundedMachineReadableMetrics(t *testing.T) { + runner := gitTestRepo(t) + var output bytes.Buffer + runner.events = newEventWriter(&output) + writeWorkspace(t, runner, "main.go", "candidate\n") + runner.emitPatchSummary(gitOutput(context.Background(), runner.workspace, "rev-parse", "HEAD")) + for _, fragment := range []string{ + `"stage":"patch-summary"`, `"status":"completed"`, + `"files":1`, `"additions":1`, `"deletions":1`, `"patch_bytes":`, + } { + if !strings.Contains(output.String(), fragment) { + t.Fatalf("patch summary missing %s: %s", fragment, output.String()) + } + } +} diff --git a/internal/seniordev/app/pipeline.go b/internal/seniordev/app/pipeline.go new file mode 100644 index 0000000000..45afd5c9f9 --- /dev/null +++ b/internal/seniordev/app/pipeline.go @@ -0,0 +1,413 @@ +//go:build !windows + +// This file is the pipeline driver: budget, run base and workspace +// preparation around the solo run in solo.go. +package app + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + "unicode/utf16" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/session/runbudget" + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type pipelineDeps struct { + Backend backend + Config *seniorDevConfig + Events *eventWriter + Notes io.Writer + Now func() time.Time + Sleep func(context.Context, time.Duration) error +} + +type pipeline struct { + args cliArgs + workspace string + sessionID string + runtime *runtimeAdapter + pool poolResolver + events *eventWriter + notes io.Writer + // recorder identifies, compares, freezes and restores the tree. Set in + // prepareWorkspace, once the workspace path is absolute. + recorder workspaceRecorder + now func() time.Time + sleep func(context.Context, time.Duration) error + wallStart time.Time + priorCost float64 + budget runbudget.RunBudget + budgetRun *runbudget.BudgetTracker + + budgetCost float64 + + fingerprintMu sync.Mutex + fingerprintFiles map[string]worktreeFileFingerprint + fingerprintNonce uint64 + + // verificationTimeouts remembers entrypoints that hung at the verification + // ceiling so a second pass does not pay the full ceiling again for an + // identical command against an unchanged tree. + verificationTimeouts map[string]timedOutEntrypoint + // verifyForTest overrides the project verification the finalizer runs. + // Nil in production; a seam for tests, which have no discoverable project + // entrypoints to verify. + verifyForTest func(context.Context) projectVerificationResult + // turnForTest overrides soloTurn. Nil in production; a seam for tests, + // which have no model to converse with. + turnForTest func(ctx context.Context, goal, prompt string) (turnResult, error) + // lastVerify remembers the most recent completed full verification and + // the git tree it measured, so the finalizer can judge an unchanged tree + // on the last verdict (rememberVerifiedTree in workspace_git.go). + lastVerify *projectVerificationResult + lastVerifyTreeSHA string +} + +type pipelineResult struct { + Status string + Reason string + BaseSHA string + CostUSD float64 + // Terminal is the solo run's own account of how it ended: whether it + // submitted, its stated reason, nudge count, the frozen tree, and what + // verification observed. It is emitted verbatim on the single terminal + // event; see persistTerminalResult. + Terminal map[string]any + WallStart time.Time +} + +var errWallClockBudget = errors.New("wall-clock budget exhausted") + +// errRunBudget marks a mid-dispatch budget stop. Exhaustion is an ordinary +// ending that exits 0, so this must not surface as a crash. +var errRunBudget = errors.New("run budget exhausted") + +func newPipeline(args cliArgs, workspace string, deps pipelineDeps) *pipeline { + now := deps.Now + if now == nil { + now = time.Now + } + sleep := deps.Sleep + if sleep == nil { + sleep = func(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } + } + } + pool := poolResolver{ + high: splitPool(args.High), low: splitPool(args.Low), + frontier: splitPool(args.Frontier), + } + router := initRunRouter(args, deps.Events) + if aware, ok := deps.Backend.(adaptiveRouterBackend); ok { + aware.setAdaptiveRouter(router) + } + runtime := newConfiguredRuntime(workspace, deps.Backend, deps.Config) + runtime.now = now + runtime.events = deps.Events + if deps.Events != nil && runtime.bus != nil { + runtime.unsubscribeEvents = runtime.bus.SubscribeAllCallback(deps.Events.busEvent) + } + notes := deps.Notes + if notes == nil { + notes = io.Discard + } + return &pipeline{ + args: args, workspace: workspace, + // Replaced in prepareWorkspace once the path is absolute. Set here so + // a pipeline is never half-built: every method that touches the tree + // has a recorder to ask. + recorder: newWorkspaceRecorder(args, workspace, func(message string) { + _, _ = io.WriteString(notes, message) + }), + sessionID: runtime.nextID("session"), runtime: runtime, pool: pool, + events: deps.Events, notes: notes, + now: now, sleep: sleep, wallStart: now(), + budget: runbudget.ResolveRunBudget(&runbudget.RunBudgetFlags{ + MaxCost: args.MaxCost, MaxHours: args.MaxHours, + }, nil), + } +} + +func (runner *pipeline) run( + ctx context.Context, goal string, +) (result pipelineResult, runErr error) { + result = runner.initializeRun() + if runner.budget.MaxWallMS != nil { + limit := time.Duration(*runner.budget.MaxWallMS * float64(time.Millisecond)) + deadline := runner.wallStart.Add(limit) + var cancel context.CancelFunc + ctx, cancel = context.WithDeadlineCause(ctx, deadline, errWallClockBudget) + defer cancel() + defer func() { + if !errors.Is(context.Cause(ctx), errWallClockBudget) || result.Status == "pass" { + return + } + result.Status = "budget-exhausted" + _, result.Reason = runner.budgetExhausted() + if result.Reason == "" { + result.Reason = errWallClockBudget.Error() + } + result.CostUSD = runner.totalCost() + runErr = nil + }() + } + baseSHA, done, err := runner.prepareRunBase(ctx, &result) + if err != nil { + return result, err + } + if done { + return result, nil + } + contract := map[string]any{ + "base_sha": baseSHA, + "high_models": runner.pool.values(baked.TierHigh), + "low_models": runner.pool.values(baked.TierLow), + "frontier_models": runner.pool.values(baked.TierFrontier), + "entry_agent": "coder", + "senior_dev_environment": safeSeniorDevEnvironment(), + // Which promises the run is keeping about the tree, and how. A reader + // comparing two runs needs this before it compares anything else. + "workspace_recorder": runner.recorder.Kind(), + } + runner.events.stage("run-contract", "ready", contract) + defer runner.emitPatchSummary(baseSHA) + if err := runner.runtime.ensureRootSession( + ctx, runner.sessionID, prefixUTF16(goal, 60), "coder", + ); err != nil { + return result, err + } + outcome, err := runner.runSolo(ctx, goal, baseSHA) + result.CostUSD = runner.totalCost() + // Captured before the error check: ship now runs on every ending, so even a + // wall-clock kill leaves the run's own account of what it did, and that + // account is the terminal event's payload. + result.Terminal = outcome.TerminalData + if err != nil { + return result, err + } + result.Status, result.Reason = soloResultStatus(outcome) + if exhausted, reason := runner.budgetExhausted(); exhausted && result.Status != "pass" { + result.Status, result.Reason = "budget-exhausted", reason + } + result.CostUSD = runner.totalCost() + return result, nil +} + +// soloResultStatus projects the run's own vocabulary onto the pass/fail +// statuses of the terminal event. The distinctions the solo pipeline draws -- +// unverified because an entrypoint hung, unsubmitted because the model never +// declared done -- are not lost: they are the reason string, and the terminal +// event carries them structurally. +func soloResultStatus(outcome soloOutcome) (string, string) { + reason := outcome.SubmissionReason + switch outcome.Status { + case "pass": + return "pass", reason + case "pass-unverified": + return "pass", "submitted; verification did not complete" + case "unsubmitted": + return "fail", "the run ended without submitting" + default: + if reason == "" { + reason = "the submitted candidate did not verify" + } + return "fail", reason + } +} + +// resolveRunBase is the commit every patch in this run is measured against. +// A run starts from wherever HEAD is: there is no inherited base, because +// there is no second process that could have moved the tree first. +func (runner *pipeline) resolveRunBase(ctx context.Context) (string, error) { + return runner.recorder.Base(ctx) +} + +func safeSeniorDevEnvironment() map[string]string { + result := map[string]string{} + for _, entry := range os.Environ() { + name, value, ok := strings.Cut(entry, "=") + if !ok || !strings.HasPrefix(name, "SENIOR_DEV_") { + continue + } + upper := strings.ToUpper(name) + if strings.Contains(upper, "KEY") || strings.Contains(upper, "TOKEN") || + strings.Contains(upper, "SECRET") || strings.Contains(upper, "PASSWORD") { + result[name] = "<redacted>" + continue + } + result[name] = value + } + return result +} + +func (runner *pipeline) prepareWorkspace(ctx context.Context) error { + absolute, err := filepath.Abs(runner.workspace) + if err != nil { + return err + } + runner.workspace = absolute + if info, err := os.Stat(absolute); err != nil || !info.IsDir() { + return fmt.Errorf("workspace is not a directory: %s", absolute) + } + runner.recorder = newWorkspaceRecorder(runner.args, absolute, runner.note) + if err := runner.recorder.Prepare(ctx); err != nil { + return err + } + if !runner.recorder.CommitsOnWrite() { + // The recorder keeps its own copies of the tree, so a per-write commit + // buys nothing -- and under --in-place the workspace may be a + // repository this run has no business writing history into. + util.DisableEagerCommit() + } + runner.events.stage("bootstrap", "ready", map[string]any{ + "workspace": absolute, "recorder": runner.recorder.Kind(), + }) + return nil +} + +func (runner *pipeline) note(message string) { + _, _ = io.WriteString(runner.notes, message) +} + +// worktreeFingerprint hashes the content and modes of every tracked or +// unignored file. Unlike `git status --porcelain`, it detects a formatter +// changing the bytes of an already-modified file; unlike HEAD+diff, it does not +// mistake a history-only rewrite with an identical checked-out tree for a +// source mutation. File count, bytes, and wall time are bounded. Metadata lets +// unchanged files reuse their prior content hash; only new or metadata-changed +// files are read again. +func (runner *pipeline) worktreeFingerprint(ctx context.Context) (string, bool) { + runner.fingerprintMu.Lock() + defer runner.fingerprintMu.Unlock() + return newWorktreeFingerprinter(runner, ctx).fingerprint() +} + +func (runner *pipeline) overBudgetFingerprint() string { + runner.fingerprintNonce++ + return fmt.Sprintf("changed:worktree-fingerprint-budget:%d", runner.fingerprintNonce) +} + +func (runner *pipeline) totalCost() float64 { + runner.ensureBudgetTracker() + runtimeCost := runner.runtime.cost() + if delta := runtimeCost - runner.budgetCost; delta > 0 { + runner.budgetRun.AddCost(delta) + } + runner.budgetCost = runtimeCost + return runner.budgetRun.CostUSD() +} + +func (runner *pipeline) budgetExhausted() (bool, string) { + runner.totalCost() + exhausted := runner.budgetRun.Exhausted(float64(runner.now().UnixMilli())) + if exhausted.Yes { + reason := "run budget exhausted" + if exhausted.Reason != nil { + reason = *exhausted.Reason + } + return true, reason + } + return false, "" +} + +func (runner *pipeline) ensureBudgetTracker() { + if runner.budgetRun != nil { + return + } + if !runbudget.IsBounded(runner.budget) { + runner.budget = runbudget.ResolveRunBudget(&runbudget.RunBudgetFlags{ + MaxCost: runner.args.MaxCost, MaxHours: runner.args.MaxHours, + }, nil) + } + runner.budgetRun = runbudget.MakeBudgetTracker( + runner.budget, float64(runner.wallStart.UnixMilli()), runner.priorCost, + ) +} + +func firstModel(models []string) string { + if len(models) == 0 { + return "" + } + return models[0] +} + +// splitModelID splits a "provider/model" reference on its first slash. A +// reference without a slash is all provider and no model. +func splitModelID(value string) (providerID, modelID string) { + providerID, modelID, _ = strings.Cut(value, "/") + return providerID, modelID +} + +func gitOutput(ctx context.Context, workspace string, args ...string) string { + command := exec.CommandContext(ctx, "git", args...) + command.Dir = workspace + output, err := command.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +func truncate(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +// prefixUTF16 truncates value to limit UTF-16 code units. +func prefixUTF16(value string, limit int) string { + units := utf16.Encode([]rune(value)) + if len(units) <= limit { + return value + } + units = units[:limit] + out := make([]byte, 0, len(value)) + for index := 0; index < len(units); index++ { + unit := units[index] + if unit >= 0xd800 && unit <= 0xdbff && + index+1 < len(units) && + units[index+1] >= 0xdc00 && units[index+1] <= 0xdfff { + out = utf8.AppendRune(out, utf16.DecodeRune(rune(unit), rune(units[index+1]))) + index++ + continue + } + if unit >= 0xd800 && unit <= 0xdfff { + out = append(out, + byte(0xe0|unit>>12), + byte(0x80|(unit>>6)&0x3f), + byte(0x80|unit&0x3f), + ) + continue + } + out = utf8.AppendRune(out, rune(unit)) + } + return string(out) +} + +func suffixUTF16(value string, limit int) string { + units := utf16.Encode([]rune(value)) + if len(units) <= limit { + return value + } + return string(utf16.Decode(units[len(units)-limit:])) +} diff --git a/internal/seniordev/app/pipeline_run.go b/internal/seniordev/app/pipeline_run.go new file mode 100644 index 0000000000..e26b8299ad --- /dev/null +++ b/internal/seniordev/app/pipeline_run.go @@ -0,0 +1,62 @@ +//go:build !windows + +package app + +import ( + "context" + "fmt" + "math" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/runbudget" +) + +// initializeRun sets the run's starting verdict and budget. The verdict starts +// at "crashed" so a process that dies before its terminal event is reported as +// having died, not as having quietly produced nothing. +func (runner *pipeline) initializeRun() pipelineResult { + result := pipelineResult{Status: "crashed", WallStart: runner.wallStart} + runner.budgetRun = runbudget.MakeBudgetTracker( + runner.budget, float64(runner.wallStart.UnixMilli()), runner.priorCost, + ) + runner.budgetCost = 0 + runner.noteRunBudget() + return result +} + +func (runner *pipeline) noteRunBudget() { + if !runbudget.IsBounded(runner.budget) { + return + } + cost := "cost=unbounded" + if runner.budget.MaxCostUSD != nil { + cost = fmt.Sprintf("maxCost=$%v", *runner.budget.MaxCostUSD) + } + wall := "wall=unbounded" + if runner.budget.MaxWallMS != nil { + wall = fmt.Sprintf("maxWall=%vh", math.Round(*runner.budget.MaxWallMS/36_000)/100) + } + restored := "" + if runner.priorCost > 0 { + restored = fmt.Sprintf(" (restored: $%.4f already spent)", runner.priorCost) + } + runner.note("[senior-dev] run budget: " + cost + " " + wall + restored + "\n") +} + +func (runner *pipeline) prepareRunBase( + ctx context.Context, result *pipelineResult, +) (string, bool, error) { + if err := runner.prepareWorkspace(ctx); err != nil { + return "", false, err + } + if exhausted, reason := runner.budgetExhausted(); exhausted { + result.Status, result.Reason = "budget-exhausted", reason + result.CostUSD = runner.totalCost() + return "", true, nil + } + baseSHA, err := runner.resolveRunBase(ctx) + if err != nil { + return "", false, err + } + result.BaseSHA = baseSHA + return baseSHA, false, nil +} diff --git a/internal/seniordev/app/pipeline_smoke_test.go b/internal/seniordev/app/pipeline_smoke_test.go new file mode 100644 index 0000000000..fdd51e4513 --- /dev/null +++ b/internal/seniordev/app/pipeline_smoke_test.go @@ -0,0 +1,336 @@ +//go:build !windows + +// This file exercises the solo run end to end against a scripted backend. +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func eventStages(t *testing.T, raw []byte) []string { + t.Helper() + lines := bytes.Split(bytes.TrimSpace(raw), []byte("\n")) + out := []string{} + for _, line := range lines { + var value event + if err := json.Unmarshal(line, &value); err != nil { + t.Fatalf("invalid NDJSON event %q: %v", line, err) + } + if value.Stage != "" { + out = append(out, value.Stage) + } + } + return out +} + +func assertOrderedStages(t *testing.T, got, want []string) { + t.Helper() + at := 0 + for _, stage := range got { + if at < len(want) && stage == want[at] { + at++ + } + } + if at != len(want) { + t.Fatalf("stage order = %v, missing ordered suffix %v", got, want[at:]) + } +} + +func writeFile(path, content string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0o644) +} + +func gitRun(directory string, args ...string) error { + command := exec.Command("git", args...) + command.Dir = directory + command.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=senior-dev-smoke", + "GIT_AUTHOR_EMAIL=senior-dev@example.test", + "GIT_COMMITTER_NAME=senior-dev-smoke", + "GIT_COMMITTER_EMAIL=senior-dev@example.test", + ) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, output) + } + return nil +} + +// soloScriptedBackend drives one full solo run offline: the coder explores, +// writes a file, pins a command, and submits. +type soloScriptedBackend struct { + calls int + // onTurn runs before the scripted tool calls, so a test can make the model + // misbehave -- stop without submitting, submit twice, edit after freezing. + onTurn func(call int, request turn) (turnResult, bool, error) +} + +func (backend *soloScriptedBackend) Run( + ctx context.Context, request turn, +) (turnResult, error) { + backend.calls++ + if backend.onTurn != nil { + if result, handled, err := backend.onTurn(backend.calls, request); handled { + return result, err + } + } + if request.Execute == nil { + return turnResult{Text: "no tools available"}, nil + } + call := func(name, input string) (steploop.ToolResult, error) { + return request.Execute(ctx, steploop.ToolCall{ + ID: fmt.Sprintf("call_%d", backend.calls), Name: name, + Input: json.RawMessage(input), SessionID: request.SessionID, Agent: request.Agent, + }) + } + if _, err := call("write", `{"filePath":"feature.txt","content":"implemented\n"}`); err != nil { + return turnResult{}, err + } + // The real protocol writes a checklist in stage 0 and submit refuses without + // one, so a backend that models the run has to write one too. + if _, err := call("write", `{"filePath":".senior-dev/checklist.md","content":"- [x] feature implemented\n"}`); err != nil { + return turnResult{}, err + } + if _, err := call("write", `{"filePath":".senior-dev/pinned.txt","content":"make test\n"}`); err != nil { + return turnResult{}, err + } + result, err := call("submit", `{"reason":"feature implemented",`+ + `"evidence":"make test exit 0","checklist_satisfied":true}`) + if err != nil { + return turnResult{}, err + } + return turnResult{Text: "done: " + result.Output}, nil +} + +func TestSoloRunGoesIntakeToFrozenShipInOneContext(t *testing.T) { + // The end-to-end shape, offline. What it proves is the sequence and the + // session count: one coding context, one submission, one terminal. + // + // The Makefile is part of the COMMITTED base: it is the project's existing + // build system, not something this run produced. Leaving it uncommitted + // would make it part of the candidate and the file count would not measure + // what the run actually contributed. + workspace := gitWorkspace(t, map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + }) + base := strings.TrimSpace(gitOutput(context.Background(), workspace, "rev-parse", "HEAD")) + var events bytes.Buffer + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Backend: &soloScriptedBackend{}, Events: newEventWriter(&events), Notes: io.Discard, + }) + defer runner.runtime.Close() + + outcome, err := runner.runSolo(context.Background(), "Add the feature.", base) + if err != nil { + t.Fatal(err) + } + if outcome.Status != "pass" { + t.Fatalf("status = %q (%#v)", outcome.Status, outcome) + } + if outcome.Nudges != 0 { + t.Fatalf("a run that submitted on its first turn was nudged %d time(s)", outcome.Nudges) + } + if outcome.Frozen == nil || outcome.Frozen.Reason != "feature implemented" { + t.Fatalf("frozen candidate = %#v", outcome.Frozen) + } + + // No "terminal" stage: the terminal is a type=="terminal" event emitted by + // the CLI layer, which runSolo is below. What runSolo must produce is the + // payload for it. + assertOrderedStages(t, eventStages(t, events.Bytes()), + []string{"intake", "implement", "submit", "implement", "verification", "ship"}) + if outcome.TerminalData["submitted"] != true { + t.Fatalf("terminal payload = %#v", outcome.TerminalData) + } + + // The submitted file is what is on disk, and senior-dev's own bookkeeping did + // not become the deliverable. + content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "implemented\n" { + t.Fatalf("shipped file = %q", content) + } + if outcome.Frozen.PatchFiles != 1 { + t.Fatalf("PatchFiles = %d, want 1", outcome.Frozen.PatchFiles) + } +} + +func TestSoloRunNudgesThenGivesUpHonestly(t *testing.T) { + // A model that never submits must not produce a run that reports an + // attempt. It gets soloMaxNudges chances carrying the facts senior-dev checked, + // and then the terminal says plainly that nothing was submitted. + workspace, base := guardWorkspace(t) + backend := &soloScriptedBackend{ + onTurn: func(int, turn) (turnResult, bool, error) { + return turnResult{Text: "I believe this is complete."}, true, nil + }, + } + var events bytes.Buffer + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Backend: backend, Events: newEventWriter(&events), Notes: io.Discard, + }) + defer runner.runtime.Close() + + outcome, err := runner.runSolo(context.Background(), "Add the feature.", base) + if err != nil { + t.Fatal(err) + } + if outcome.Status != "unsubmitted" { + t.Fatalf("status = %q, want unsubmitted", outcome.Status) + } + if backend.calls != soloMaxNudges+1 { + t.Fatalf("model turns = %d, want %d (one attempt plus %d nudges)", + backend.calls, soloMaxNudges+1, soloMaxNudges) + } + if status, reason := soloResultStatus(outcome); status != "fail" || + !strings.Contains(reason, "without submitting") { + t.Fatalf("result status = %q / %q", status, reason) + } +} + +func TestSoloRunCorrectsPlainTextDSMLWithoutSpendingANudge(t *testing.T) { + workspace := gitWorkspace(t, map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + }) + base := strings.TrimSpace(gitOutput(context.Background(), workspace, "rev-parse", "HEAD")) + backend := &soloScriptedBackend{ + onTurn: func(call int, _ turn) (turnResult, bool, error) { + if call == 1 { + return turnResult{Text: `<|DSML|bash>{"cmd":"make test"}`}, true, nil + } + return turnResult{}, false, nil + }, + } + var events bytes.Buffer + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Backend: backend, Events: newEventWriter(&events), Notes: io.Discard, + }) + defer runner.runtime.Close() + + outcome, err := runner.runSolo(context.Background(), "Add the feature.", base) + if err != nil { + t.Fatal(err) + } + if outcome.Status != "pass" || outcome.Nudges != 0 || backend.calls != 2 { + t.Fatalf("outcome=%#v calls=%d", outcome, backend.calls) + } + leaks := soloStageEvents(t, &events, "implement") + found := false + for _, event := range leaks { + found = found || event["status"] == "tool-call-leak" + } + if !found { + t.Fatal("plain-text tool call was not recorded") + } +} + +func TestToolLeakDetectorDoesNotOverrideAnExecutedToolCall(t *testing.T) { + result := turnResult{ + Text: "DSML bash markup appeared in an explanation", + Parts: []turnPart{{Type: "tool", Tool: "bash", Status: "completed"}}, + } + if leakedToolCall(result) { + t.Fatal("an executed tool call was misclassified as leaked markup") + } +} + +// TestBudgetExhaustedRunStillShipsAndReportsWhy pins ship running on every +// ending. If soloConverse returning an error sent runSolo home before +// soloShip, the common ending of a full-budget run would produce neither a +// restore nor any statement of whether the run had submitted: both halves of +// stage 4 skipped on the ending that happens most. +func TestBudgetExhaustedRunStillShipsAndReportsWhy(t *testing.T) { + workspace, base := guardWorkspace(t) + backend := &soloScriptedBackend{ + onTurn: func(int, turn) (turnResult, bool, error) { + return turnResult{}, true, context.DeadlineExceeded + }, + } + var events bytes.Buffer + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Backend: backend, Events: newEventWriter(&events), Notes: io.Discard, + }) + defer runner.runtime.Close() + + outcome, err := runner.runSolo(context.Background(), "Add the feature.", base) + if err == nil { + t.Fatal("a turn that died on its deadline returned no error") + } + // The error propagates -- the run did die -- but the account survives it. + if outcome.Status != "unsubmitted" { + t.Fatalf("status = %q, want unsubmitted", outcome.Status) + } + if outcome.TerminalData == nil { + t.Fatal("a run killed mid-turn produced no terminal payload") + } + if outcome.TerminalData["submitted"] != false { + t.Fatalf("terminal payload = %#v", outcome.TerminalData) + } + reason, _ := outcome.TerminalData["reason"].(string) + if !strings.Contains(reason, "without calling submit") { + t.Fatalf("terminal reason = %q, want it to name the missing submission", reason) + } + // The cause is carried too, so a reader can tell "never tried" from "ran + // out of time trying". + if !strings.Contains(reason, context.DeadlineExceeded.Error()) { + t.Fatalf("terminal reason = %q, want it to carry the underlying cause", reason) + } +} + +// TestTerminalEventCarriesTheRunsAccount pins the contract at the boundary an +// external reader sees: the type=="terminal" record -- not a stage named +// "terminal" -- has to answer whether the run submitted. A test that asserts +// on the stage event alone passes while the terminal carries only a cost. +func TestTerminalEventCarriesTheRunsAccount(t *testing.T) { + result := pipelineResult{ + Status: "fail", Reason: "the run ended without submitting", + CostUSD: 0.25, + Terminal: map[string]any{"submitted": false, "nudges": 2, "reason": "no submission"}, + } + var out bytes.Buffer + if err := delegate.NewEmitter(&out).Terminal(endingOf(result)); err != nil { + t.Fatal(err) + } + + var terminals []map[string]any + for _, line := range bytes.Split(out.Bytes(), []byte("\n")) { + if len(bytes.TrimSpace(line)) == 0 { + continue + } + var value map[string]any + if err := json.Unmarshal(line, &value); err != nil { + continue + } + if value["type"] == "terminal" { + terminals = append(terminals, value) + } + } + if len(terminals) != 1 { + t.Fatalf("type==terminal events = %d, want exactly 1", len(terminals)) + } + data, _ := terminals[0]["data"].(map[string]any) + for _, key := range []string{"submitted", "nudges", "reason", "cost_usd"} { + if _, ok := data[key]; !ok { + t.Fatalf("counted terminal event is missing %q: %#v", key, data) + } + } +} diff --git a/internal/seniordev/app/prompt_in_place.go b/internal/seniordev/app/prompt_in_place.go new file mode 100644 index 0000000000..7de31f6e49 --- /dev/null +++ b/internal/seniordev/app/prompt_in_place.go @@ -0,0 +1,108 @@ +//go:build !windows + +package app + +import ( + "fmt" + "strings" +) + +// The prompts the model reads name git in a few places, because under the +// default recorder git is how the promises are kept. Under --in-place they are +// not, and a prompt that says otherwise is a prompt that lies: the model would +// reach for `git diff` to review its own work and get an error back. +// +// The git-mode text is NOT edited. Every rewrite below is applied only on the +// in-place path, so a default run's prompt bytes — and therefore its prompt +// hash, its cache prefix and its comparability to earlier runs — are exactly +// what they were before this mode existed. That is the whole reason this is a +// substitution table rather than a reworded prompt. +// +// Each entry must fire. A rewrite that silently matches nothing would leave +// the model with git-shaped instructions it cannot follow, so applyInPlace +// returns an error naming the miss, and a test pins every entry against the +// real prompt text. +type promptRewrite struct { + from string + to string +} + +// coderPromptRewrites adapt the baked system prompt. +var coderPromptRewrites = []promptRewrite{ + { + from: "from the starting commit, when `.senior-dev/checklist.md` does not exist, when", + to: "from the tree senior-dev recorded at the start, when `.senior-dev/checklist.md` does not exist, when", + }, + { + from: "`.senior-dev/` and git-ignored paths are excluded from the answer. Everything else", + to: "`.senior-dev/` and ignored paths are excluded from the answer. Everything else", + }, +} + +// soloPromptRewrites adapt the run instruction, and add the one thing the +// model cannot infer: that git is not available to it here. +var soloPromptRewrites = []promptRewrite{ + { + from: "The workspace is a git repository. Your tools are the ones declared with this\nturn: a shell, file reading, editing, search, web access, and submit.", + to: "The workspace is a directory. It may or may not be a git repository, and " + + "either way\nthis run does not use git: it makes no commits and creates no " + + "branches, and\n`git diff` will not show you your work. What is on disk is " + + "the record.\n\nYour tools are the ones declared with this turn: a shell, " + + "file reading, editing,\nsearch, web access, and submit.", + }, + { + from: ".senior-dev/ and git-ignored paths are excluded from the answer. Everything else in\nthe working tree, committed or not, is part of what you submit.", + to: ".senior-dev/ and ignored paths are excluded from the answer. Everything else in\nthe working tree is part of what you submit.", + }, + { + from: "It refuses, naming the cause, when the tree is unchanged from the starting\ncommit, when .senior-dev/checklist.md does not exist, when reason or evidence is\nempty, or when this run already submitted. A refusal does not end the run.", + to: "It refuses, naming the cause, when the tree is unchanged from the one senior-dev\nrecorded at the start, when .senior-dev/checklist.md does not exist, when reason or\nevidence is empty, or when this run already submitted. A refusal does not end\nthe run.", + }, +} + +// applyPromptRewrites returns text with every rewrite applied, or an error +// naming the first one that matched nothing. +func applyPromptRewrites(text string, rewrites []promptRewrite) (string, error) { + for index, rewrite := range rewrites { + if !strings.Contains(text, rewrite.from) { + return "", fmt.Errorf( + "in-place prompt rewrite %d no longer matches the prompt: %q", + index, firstLine(rewrite.from), + ) + } + text = strings.Replace(text, rewrite.from, rewrite.to, 1) + } + return text, nil +} + +func firstLine(value string) string { + if index := strings.IndexByte(value, '\n'); index >= 0 { + return value[:index] + } + return value +} + +// rewritesGitText reports whether this recorder's prompts need adapting. Only +// the git recorder leaves them alone. +func rewritesGitText(recorder workspaceRecorder) bool { + return recorder != nil && recorder.Kind() != "git" +} + +// adaptCoderPrompt rewrites the baked system prompt. It runs on every turn, +// because the system prompt is rebuilt for each one. +func adaptCoderPrompt(recorder workspaceRecorder, coder string) (string, error) { + if !rewritesGitText(recorder) { + return coder, nil + } + return applyPromptRewrites(coder, coderPromptRewrites) +} + +// adaptSoloPrompt rewrites the run instruction. It runs once, where that +// instruction is assembled -- later turns carry short continuations that never +// contained this text and must not be searched for it. +func adaptSoloPrompt(recorder workspaceRecorder, solo string) (string, error) { + if !rewritesGitText(recorder) { + return solo, nil + } + return applyPromptRewrites(solo, soloPromptRewrites) +} diff --git a/internal/seniordev/app/question_autoreject_test.go b/internal/seniordev/app/question_autoreject_test.go new file mode 100644 index 0000000000..8be5704062 --- /dev/null +++ b/internal/seniordev/app/question_autoreject_test.go @@ -0,0 +1,58 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/question" +) + +// A headless run has nothing that answers question.asked, so an unanswered +// question would block the run forever. The senior-dev runtime auto-rejects +// through the service's own reject path, so the call returns promptly with +// the rejection instead of hanging the run. +func TestHeadlessQuestionAutoRejectsInsteadOfHanging(t *testing.T) { + runtime := newRuntime(t.TempDir(), nil) + t.Cleanup(runtime.Close) + + input, err := json.Marshal(map[string]any{ + "questions": []map[string]any{{ + "question": "Which storage backend should the service use?", + "header": "Storage", + "options": []map[string]any{ + {"label": "sqlite", "description": "Embedded file database"}, + {"label": "postgres", "description": "Networked relational database"}, + }, + }}, + }) + if err != nil { + t.Fatal(err) + } + type outcome struct { + result steploop.ToolResult + err error + } + done := make(chan outcome, 1) + go func() { + result, execErr := runtime.registry.Execute(context.Background(), steploop.ToolCall{ + Name: "question", Input: input, + ID: "call-q1", SessionID: "ses-headless", MessageID: "msg-q1", Agent: "coder", + }) + done <- outcome{result: result, err: execErr} + }() + select { + case got := <-done: + var rejected *question.RejectedError + if !errors.As(got.err, &rejected) { + t.Fatalf("question returned (%#v, %v), want the rejection error", got.result, got.err) + } + case <-time.After(10 * time.Second): + t.Fatal("question tool call hung: headless auto-reject did not fire") + } +} diff --git a/internal/seniordev/app/router_cancellation_test.go b/internal/seniordev/app/router_cancellation_test.go new file mode 100644 index 0000000000..e317a1abdc --- /dev/null +++ b/internal/seniordev/app/router_cancellation_test.go @@ -0,0 +1,79 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestRouterCancellationDecisionsAreTraced(t *testing.T) { + var output bytes.Buffer + router := initRunRouter(cliArgs{High: "openrouter/moonshotai/kimi-k3"}, newEventWriter(&output)) + choice, err := router.PickContext(context.Background(), "coder", adaptive.ModelTierHigh) + if err != nil { + t.Fatal(err) + } + router.RegisterCanceled(choice) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err = router.PickContext(ctx, "coder", adaptive.ModelTierHigh); err != context.Canceled { + t.Fatalf("err=%v", err) + } + lines := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) + if len(lines) != 2 { + t.Fatalf("trace=%s", output.Bytes()) + } + for i, reason := range []string{"caller-canceled-request", "caller-canceled-pick"} { + var event event + if err = json.Unmarshal(lines[i], &event); err != nil { + t.Fatal(err) + } + if event.Stage != "router-cancellation" || event.Status != reason || event.Data["provider_health_changed"] != false { + t.Fatalf("event=%+v", event) + } + } +} + +// THE CODER MOVING TO ANOTHER MODEL IS A STAGE, with where it came from, where +// it went and the router's reason; a pick that stays on its model, and the +// run's first pick, move nothing and say nothing. +func TestTheCodersMoveToAnotherModelIsAStage(t *testing.T) { + var output bytes.Buffer + router := initRunRouter(cliArgs{High: "openrouter/vendor/one,openrouter/vendor/two"}, newEventWriter(&output)) + first, err := router.PickContext(context.Background(), "coder", adaptive.ModelTierHigh) + if err != nil { + t.Fatal(err) + } + router.Register(first, 1, 10, errors.New("429 rate limit exceeded")) + second, err := router.PickContext(context.Background(), "coder", adaptive.ModelTierHigh) + if err != nil { + t.Fatal(err) + } + if !second.Switched { + t.Fatalf("the router stayed on %s after a rate limit; the test cannot see a switch", second.Candidate.ID) + } + router.Register(second, 1, 10, nil) + var switches []event + for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) { + var value event + if err := json.Unmarshal(line, &value); err != nil { + t.Fatal(err) + } + if value.Stage == "model-switch" { + switches = append(switches, value) + } + } + if len(switches) != 1 { + t.Fatalf("model-switch stages = %d, want the one real change: %s", len(switches), output.Bytes()) + } + got := switches[0] + if got.Status != "switched" || got.Data["from"] != first.Candidate.ID || got.Data["to"] != second.Candidate.ID || got.Data["reason"] == "" { + t.Fatalf("the switch = %+v, want from %s to %s with a reason", got, first.Candidate.ID, second.Candidate.ID) + } +} diff --git a/internal/seniordev/app/run.go b/internal/seniordev/app/run.go new file mode 100644 index 0000000000..e438fc53f6 --- /dev/null +++ b/internal/seniordev/app/run.go @@ -0,0 +1,382 @@ +//go:build !windows + +// This file is one run as codeaf starts it: what senior-dev's own command line +// used to do between parsing its flags and printing its terminal event, with +// codeaf's host in place of stdout and codeaf's model API in place of a key. +package app + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/Agent-Field/codeaf/internal/buildinfo" + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/modelsdev" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +// version is the build senior-dev reports itself as: to the session store it +// writes and to the model catalog it fetches. It is codeaf's own, because +// there is no senior-dev that differs from the codeaf it ships in. +var version = buildinfo.String() + +// Stages are the stages a run reports, in the order a run first reaches them: +// the `hello` codeaf draws the whole track from before the run has walked it. +// +// THE LIST IS CLOSED, and a test holds it to the source: every stage the run +// can emit is here, and nothing is here that it cannot emit +// (stages_test.go). compaction-capacity, compaction, router-cancellation and +// model-switch happen inside a model turn — when a rejection pins a window, the +// history is compacted, a call is withdrawn or the coder moves to another +// model — so they sit where the turns are. +var Stages = []string{ + "bootstrap", + "run-contract", + "intake", + "landing", + "implement", + "agent-runtime", + "compaction-capacity", + "compaction", + "router-cancellation", + "model-switch", + "submit", + "verification", + "ship", + "patch-summary", + "agent-summary", +} + +// Options is what one run is asked to do. +type Options struct { + // Goal is the brief, exactly as it was given. It is written to + // .senior-dev/spec.md byte for byte and read back from there, so nothing + // between the person and the model paraphrases it. + Goal string + // High, Low and Frontier are the model pools, comma-separated; an empty Low + // or Frontier routes on High. + High string + Low string + Frontier string + // Variant is the reasoning effort, sent as `reasoning.effort`. + Variant string + // InPlace edits the folder without git: no commits, no refs, and the run's + // checkpoints kept outside it. + InPlace bool + // Crew says the pools came from the crew of the conversation that started + // the run (`--crew`), not from a person typing them: a model the catalog + // cannot size is dropped with a note, and a --high left empty routes on + // [DefaultHighModels] ([crewPools]). + Crew bool + // Asked says the --high pool is the models the person asked for (`--asked`). + // It is kept whole under Crew: every one of them must be sizable, and the + // run refuses before its first call, naming the one that is not + // ([askedRefusal]). + Asked bool +} + +// Run runs senior-dev once in the host's workspace and answers how it ended. +// It reports its stages and finished steps through host as it goes; the +// hello before it and the terminal after it are the caller's, which is the +// one place every ending reaches, a panic's included. notes is where its lines +// for a person go: stderr, which codeaf keeps beside the task. +func Run(ctx context.Context, host delegate.Host, options Options, notes io.Writer) delegate.Ending { + return runWith(ctx, host, options, notes, nil) +} + +// runWith is Run with a model backend a test can put in the model API's +// place. An injected backend is the whole of the model side: the run then +// neither needs the host's model API nor loads the model catalog, the shape +// senior-dev's own in-process tests always ran in. +func runWith(ctx context.Context, host delegate.Host, options Options, notes io.Writer, injected backend) delegate.Ending { + if notes == nil { + notes = io.Discard + } + // A network policy that failed to parse refuses the run: an unrecognized + // SENIOR_DEV_NET must neither silently allow egress nor silently run a paid + // multi-hour job in a mode nobody asked for. + policy := netpolicy.Current() + if policy.Warning != "" { + return refused(policy.Warning) + } + if policy.Restricted() { + _, _ = io.WriteString(notes, "[senior-dev] network policy: off — agent-initiated egress disabled "+ + "(the model API is unaffected)\n") + } + if strings.TrimSpace(options.Goal) == "" { + return refused("there is no brief: senior-dev needs the change to make, in words, after the flags") + } + api := host.Models() + if injected == nil && !api.Ready() { + return refused("senior-dev was started without a model API; codeaf serves one to every run it starts") + } + args := cliArgs{ + High: options.High, Low: options.Low, Frontier: options.Frontier, + Variant: options.Variant, InPlace: options.InPlace, + } + if len(splitPool(args.High)) == 0 { + return refused("--high names no model, and the coder needs one to route on") + } + // A CEILING OF ZERO IS NO CEILING, and is passed as none, so senior-dev's + // own SENIOR_DEV_MAX_COST_USD and SENIOR_DEV_MAX_WALL_H still apply to a run + // codeaf set no limit on. + ceilings := host.Ceilings() + if ceilings.CostUSD > 0 { + args.MaxCost = &ceilings.CostUSD + } + if ceilings.Hours > 0 { + args.MaxHours = &ceilings.Hours + } + workspace := host.Workspace() + loadedConfig, err := loadSeniorDevConfig(workspace) + if err != nil { + return refused("load config: " + err.Error()) + } + loadedConfig.variant = args.Variant + events := newRecordWriter(host, notes) + model := injected + if model == nil { + client := newModelAPIBackend(api, args.Variant) + loadedConfig.applyBackend(client) + client.events = events + catalog, err := loadCatalog(ctx, notes) + if err != nil { + return refused("model catalog: " + err.Error()) + } + client.catalog = catalog + model = client + known := func(ref string) bool { + providerID, modelID := normalizeModelRef(splitModelID(ref)) + if _, err := catalog.Resolve(providerID, modelID); err == nil { + return true + } + return len(loadedConfig.model(providerID, modelID)) > 0 + } + if options.Asked { + if refusal := askedRefusal(args.High, known); refusal != "" { + return refused(refusal) + } + } + if options.Crew { + high := args.High + args = crewPools(args, known, notes) + if options.Asked { + args.High = high + } + } + } + + runner := newPipeline(args, workspace, pipelineDeps{ + Backend: model, Config: loadedConfig, Events: events, Notes: notes, + }) + defer runner.runtime.Close() + result, runErr := runner.run(ctx, options.Goal) + result, runErr = classifyRunError(ctx, runner, result, runErr) + if runErr != nil { + _, _ = fmt.Fprintf(notes, "[senior-dev] the run failed: %v\n", runErr) + } + // The per-agent rollup lands immediately before the terminal record, so + // every completed run carries its own account of wall time and model calls. + if summaryData := events.summary.data(); summaryData != nil { + events.stage("agent-summary", "completed", summaryData) + } + return endingOf(result) +} + +// loadCatalog is the models.dev catalog the run prices and sizes models from: +// the cached copy, else a fetch, kept fresh in the background for as long as +// the run lasts. SENIOR_DEV_MODELS_PATH, SENIOR_DEV_MODELS_URL and +// SENIOR_DEV_DISABLE_MODELS_FETCH steer it. It is not a model call: it names +// each model's window and prices, which is what compaction is sized by. +func loadCatalog(ctx context.Context, notes io.Writer) (modelsdev.Catalog, error) { + catalogClient, err := modelsdev.NewFromEnv(version) + if err != nil { + return nil, err + } + catalog, err := catalogClient.Get(ctx) + if err != nil { + return nil, err + } + catalogClient.StartRefresh(ctx, func(refreshErr error) { + _, _ = fmt.Fprintf(notes, "[senior-dev] failed to fetch models.dev: %v\n", refreshErr) + }) + return catalog, nil +} + +// askedRefusal is the sentence for models the person asked for that senior-dev +// cannot size — it needs each model's window to keep a long run's history in +// it — or "" when it can size them all. +func askedRefusal(pool string, known func(string) bool) string { + var unknown []string + for _, ref := range splitPool(pool) { + if !known(ref) { + unknown = append(unknown, strings.TrimPrefix(ref, orclient.Service+"/")) + } + } + if len(unknown) == 0 { + return "" + } + return "senior-dev cannot work with " + strings.Join(unknown, ", ") + + ": its model catalog does not know how much it can hold, so nothing was started; ask for a model it knows" +} + +// refused is the ending of a run that could not start: its brief, its +// settings or its model catalog stood in the way, and nothing ran. +func refused(reason string) delegate.Ending { + return delegate.Ending{Status: delegate.StatusCrashed, Message: reason} +} + +// classifyRunError maps a pipeline error onto the terminal result. Crossing a +// declared budget ceiling is an ordinary ending, not a crash: the run stops +// where it stopped and reports what it had. Only senior-dev's own failures +// crash. +// +// A STOP FROM OUTSIDE IS NOT A CRASH EITHER. codeaf ends a run with SIGTERM — +// the person stopped it, or the run it belongs to ended — and the run's +// context ends with it. The run stops starting new work, ships what it has +// (ship runs on every ending) and says what is true: a ceiling it had crossed +// is budget-exhausted, a candidate it had frozen stands, and anything else is +// work that did not finish, never a program that broke. +func classifyRunError(ctx context.Context, runner *pipeline, result pipelineResult, runErr error) (pipelineResult, error) { + if runErr == nil { + return result, nil + } + result.CostUSD = runner.totalCost() + result.WallStart = runner.wallStart + if errors.Is(runErr, errRunBudget) { + result.Status = delegate.StatusBudget + if exhausted, reason := runner.budgetExhausted(); exhausted && reason != "" { + result.Reason = reason + } else { + result.Reason = runErr.Error() + } + return result, nil + } + if ctx.Err() != nil { + if exhausted, reason := runner.budgetExhausted(); exhausted { + result.Status, result.Reason = delegate.StatusBudget, reason + return result, nil + } + result.Status, result.Reason = delegate.StatusFail, "stopped before it finished" + if account, _ := result.Terminal["reason"].(string); account != "" { + result.Reason += "; " + account + } + return result, nil + } + result.Status = delegate.StatusCrashed + result.Reason = runErr.Error() + return result, runErr +} + +// endingOf is the run's result as the one terminal record codeaf reads. +// +// TWO WITNESSES, KEPT APART. Claim is what senior-dev's model said when it +// submitted; Observed is what senior-dev itself saw when it ran the project's +// build and tests on the tree it froze. Neither is reconciled into the other, +// and everything else senior-dev knows about the ending travels beside them +// in its own spelling. +// +// The sentences are written for a person, because codeaf folds them into the +// commit that lands and the note that says so. +func endingOf(result pipelineResult) delegate.Ending { + extra := map[string]any{} + for key, value := range result.Terminal { + extra[key] = value + } + ending := delegate.Ending{ + Status: result.Status, + Message: messageOf(result, extra), + CostUSD: result.CostUSD, + } + if reason, _ := extra["reason"].(string); reason != "" && reason != ending.Message { + ending.Reason = reason + } + delete(extra, "reason") + ending.Claim, _ = extra["submission_reason"].(string) + ending.Observed = observedOf(extra) + if len(extra) > 0 { + ending.Extra = extra + } + return ending +} + +// messageOf is the ending in one sentence. A run that submitted is said in +// terms of what its own check of the project found, which is the fact the +// status projects; everything else keeps the reason the run gave. +func messageOf(result pipelineResult, data map[string]any) string { + inner, _ := data["status"].(string) + switch { + case result.Status == delegate.StatusPass && inner == "pass": + return "submitted a change, and the project's own build and tests passed" + case result.Status == delegate.StatusPass && inner == "pass-unverified": + return "submitted a change, and nothing finished checking it" + case result.Status == delegate.StatusFail && inner == "fail": + return "submitted a change that the project's own build or tests do not pass" + } + return result.Reason +} + +// observedOf says what senior-dev itself saw of the project's build and tests +// on the tree the run left, and what it did to that tree, empty when it ran +// nothing. +func observedOf(data map[string]any) string { + var said []string + inner, _ := data["status"].(string) + _, checked := data["verification_commands"] + commands := wholeNumber(data["verification_commands"]) + failing := wholeNumber(data["verification_failing"]) + failure, _ := data["verification_failure"].(string) + switch { + case inner == "pass-unverified": + said = append(said, "nothing finished running the project's build and tests on the submitted change") + case !checked: + case data["verification_timed_out"] == true: + said = append(said, "the project's build and tests did not finish in the time allowed") + case failing > 0: + said = append(said, fmt.Sprintf("%d of the project's %d build and test commands failed", failing, commands)) + case failure != "": + said = append(said, "the project's check could not run: "+failure) + case commands > 0: + said = append(said, fmt.Sprintf("the project's %d build and test commands all passed", commands)) + default: + said = append(said, "the project has no build or tests it could find to run") + } + if data["suite_dead"] == true { + said = append(said, "its test suite could not even start") + } + if source, _ := data["restore_source"].(string); source != "" { + said = append(said, "the tree was put back to "+RestoredFrom(source)) + } + return strings.Join(said, "; ") +} + +// RestoredFrom names a restore's source the way a person would: the ending's +// observation says it here, and senior-dev's page says it with the same words +// (internal/seniordev's actions.go). +func RestoredFrom(source string) string { + switch source { + case "coherent-checkpoint": + return "the last state whose build and tests could run" + case "starting-tree": + return "the tree it started from" + case "starting-commit": + return "the commit it started from" + } + return source +} + +// wholeNumber reads a count out of the terminal data, which holds it as an +// int when the run wrote it and as a float64 once it has been through JSON. +func wholeNumber(value any) int { + switch number := value.(type) { + case int: + return number + case float64: + return int(number) + } + return 0 +} diff --git a/internal/seniordev/app/run_error_classify_test.go b/internal/seniordev/app/run_error_classify_test.go new file mode 100644 index 0000000000..b9157dade6 --- /dev/null +++ b/internal/seniordev/app/run_error_classify_test.go @@ -0,0 +1,82 @@ +//go:build !windows + +package app + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "testing" +) + +// A budget stop is a truthful, checkpointable terminal — exit 0, status +// budget-exhausted — however deeply the errRunBudget sentinel is wrapped. +// Any other error still crashes. +func TestClassifyRunErrorMapsBudgetSentinelFromAnyPhase(t *testing.T) { + runner := newPipeline(cliArgs{}, t.TempDir(), pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + t.Cleanup(runner.runtime.Close) + + wrapped := fmt.Errorf("landing turn: %w", fmt.Errorf( + "%w: cost $0.6172 >= budget $0.6000", errRunBudget, + )) + result, err := classifyRunError(context.Background(), runner, pipelineResult{Status: "crashed"}, wrapped) + if err != nil { + t.Fatalf("budget sentinel returned an error (would exit 1): %v", err) + } + if result.Status != "budget-exhausted" || !strings.Contains(result.Reason, "cost $0.6172") { + t.Fatalf("result = %#v", result) + } + + infrastructure := errors.New("provider wiring exploded") + result, err = classifyRunError(context.Background(), runner, pipelineResult{Status: "crashed"}, infrastructure) + if !errors.Is(err, infrastructure) || result.Status != "crashed" || + result.Reason != "provider wiring exploded" { + t.Fatalf("infrastructure error result = %#v err = %v", result, err) + } + + passResult := pipelineResult{Status: "pass"} + result, err = classifyRunError(context.Background(), runner, passResult, nil) + if err != nil || result.Status != "pass" { + t.Fatalf("nil error result = %#v err = %v", result, err) + } +} + +// codeaf stops a run with SIGTERM, which ends its context. That is a stop, not +// a program that broke: the run's error is the context's own, and the ending +// says the work did not finish, carrying the run's own account of how far it +// got. A ceiling the run had already crossed is still the ceiling. +func TestAStopFromOutsideIsNotACrash(t *testing.T) { + runner := newPipeline(cliArgs{}, t.TempDir(), pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + t.Cleanup(runner.runtime.Close) + stopped, stop := context.WithCancel(context.Background()) + stop() + + result, err := classifyRunError(stopped, runner, pipelineResult{ + Status: "crashed", + Terminal: map[string]any{"reason": "no submission: the run stopped without calling submit"}, + }, context.Canceled) + if err != nil { + t.Fatalf("a stop returned an error, which the ending would call a crash: %v", err) + } + if result.Status != "fail" || !strings.HasPrefix(result.Reason, "stopped before it finished") || + !strings.Contains(result.Reason, "without calling submit") { + t.Fatalf("stopped result = %#v", result) + } + + spent := 1.0 + budgeted := newPipeline(cliArgs{MaxCost: &spent}, t.TempDir(), pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + t.Cleanup(budgeted.runtime.Close) + budgeted.runtime.addCost(2) + result, err = classifyRunError(stopped, budgeted, pipelineResult{Status: "crashed"}, context.Canceled) + if err != nil || result.Status != "budget-exhausted" { + t.Fatalf("a stop past the ceiling = %#v err = %v, want budget-exhausted", result, err) + } +} diff --git a/internal/seniordev/app/runtime.go b/internal/seniordev/app/runtime.go new file mode 100644 index 0000000000..7ccf0b6516 --- /dev/null +++ b/internal/seniordev/app/runtime.go @@ -0,0 +1,579 @@ +//go:build !windows + +// This file adapts the model backend, tool registry, durable session store and +// bus into the single `turn` the solo run drives. +package app + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net/http" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/id" + "github.com/Agent-Field/codeaf/internal/seniordev/modelsdev" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" + "github.com/Agent-Field/codeaf/internal/seniordev/question" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" + "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" + "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore" + "github.com/Agent-Field/codeaf/internal/seniordev/storage" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +type turn struct { + SessionID string + ParentSessionID string + MessageID string + SessionTitle string + Agent string + AgentMarkdown string + // AgentPromptVerbatim marks AgentMarkdown as a configured prompt string + // rather than a baked agent document. A configured `agent.prompt` reaches + // the model verbatim; only baked documents carry YAML frontmatter worth + // stripping. + AgentPromptVerbatim bool + Workspace string + ProviderID string + ModelID string + Variant string + MaxSteps *float64 + RawModelCall bool + Prompt string + SystemInstructions []string + LoadInstructions func(context.Context) []string + Tools []steploop.ToolDefinition + Execute func(context.Context, steploop.ToolCall) (steploop.ToolResult, error) + BetweenStepReminder func() string + AfterAssistant func(context.Context, string) + CompactionDecisions compaction.DecisionSink + ModelRequests modelRequestSink + Store steploop.Store + PromptPersisted bool + PromptMessageID string + ManageScratch bool +} + +// turnPart is one thing the model produced in a turn: a stretch of text, a +// compaction summary, or a tool call with the arguments it was given and how +// it ended. +type turnPart struct { + Type string + Text string + Tool string + ArgsKey string + Status string + CostUSD *float64 +} + +type turnResult struct { + // FinishReason is the final assistant's unified engine finish, not text + // inference or the finish of an earlier completed step within the turn. + FinishReason string + SessionID string + Text string + Parts []turnPart + CostUSD float64 +} + +type backend interface { + Run(context.Context, turn) (turnResult, error) +} + +type runtimeAdapter struct { + backend backend + registry *tool.Registry + config *seniorDevConfig + workspace string + durable *durableSessions + initErr error + now func() time.Time + ids atomic.Uint64 + mu sync.Mutex + costUSD float64 + bus *bus.Bus + question *question.Service + events *eventWriter + + unsubscribeQuestionAutoReject func() + unsubscribeEvents func() +} + +// configureTurn is the single provenance seam for every model turn. The +// event records what the engine will actually execute after baked metadata and +// project overrides have been resolved; it deliberately hashes prompts rather +// than copying potentially sensitive project instructions into telemetry. +func (runtime *runtimeAdapter) configureTurn(value turn) (turn, error) { + configured, err := runtime.config.configureTurn(value) + if err != nil { + return configured, err + } + runtime.emitTurnProvenance(configured) + return configured, nil +} + +func (runtime *runtimeAdapter) emitTurnProvenance(configured turn) { + if runtime.events != nil { + digest := sha256.Sum256([]byte(configured.AgentMarkdown)) + data := map[string]any{ + "agent": configured.Agent, "session_id": configured.SessionID, + "provider_id": configured.ProviderID, "model_id": configured.ModelID, + "prompt_sha256": fmt.Sprintf("%x", digest[:]), + "prompt_verbatim": configured.AgentPromptVerbatim, + } + if configured.MaxSteps != nil { + data["max_steps"] = *configured.MaxSteps + } + if configured.Variant != "" { + data["reasoning_effort"] = configured.Variant + } + data["compaction"] = runtime.compactionProvenance(configured) + runtime.events.stage("agent-runtime", "configured", data) + } +} + +// compactionProvenance records the compaction budget the turn will run under: +// the policy in force and the configured block, and when the model's limits +// are known, the capacity, the high and low watermarks and the verbatim-tail +// budget, so the budget a run used can be read back from the event stream. +func (runtime *runtimeAdapter) compactionProvenance(configured turn) map[string]any { + cfg, err := runtime.config.overflowConfig() + if concrete, ok := runtime.backend.(*modelAPIBackend); ok && concrete != nil && err == nil { + cfg = concrete.withPinnedCapacity(cfg, configured.SessionID) + } + // Config accepts only the window policy or an empty value, so the policy + // in force is always the window. + record := map[string]any{"policy": overflow.PolicyWindow} + if err != nil { + record["error"] = err.Error() + return record + } + if cfg.Compaction != nil { + if cfg.Compaction.CapacityTokens != nil { + record["configured_capacity_tokens"] = *cfg.Compaction.CapacityTokens + } + if cfg.Compaction.PreserveRecentTokens != nil { + record["configured_preserve_recent_tokens"] = *cfg.Compaction.PreserveRecentTokens + } + if cfg.Compaction.PreserveRecentFraction != nil { + record["configured_preserve_recent_fraction"] = *cfg.Compaction.PreserveRecentFraction + } + } + concrete, ok := runtime.backend.(*modelAPIBackend) + if !ok || concrete == nil { + return record + } + _, model, err := (seniorDevModels{backend: concrete, agent: configured.Agent}).projection( + configured.ProviderID, configured.ModelID, + ) + if err != nil { + record["error"] = err.Error() + return record + } + marks := overflow.Watermarks(overflow.UsableInput{Cfg: cfg, Model: model}) + if pinned, ok := concrete.pinnedCapacityFor(configured.SessionID); ok { + record["pinned_capacity_tokens"] = pinned + } + record["model_context_tokens"] = model.Limit.Context + record["capacity_tokens"] = marks.Capacity + record["high_tokens"] = marks.High + record["low_tokens"] = marks.Low + record["tail_budget_tokens"] = compaction.TailBudget(cfg, marks) + return record +} + +func newConfiguredRuntime(workspace string, client backend, cfg *seniorDevConfig) *runtimeAdapter { + runtime := &runtimeAdapter{ + backend: client, config: cfg, workspace: workspace, now: time.Now, + } + runtime.durable, runtime.initErr = openDurableSessions(context.Background(), workspace) + if runtime.durable != nil && runtime.durable.bus != nil { + runtime.bus = runtime.durable.bus + } else { + // Keep the runtime usable enough to report its initialization failure, + // while preserving the one-bus invariant for services constructed below. + runtime.bus = bus.New(bus.Context{Directory: workspace, Workspace: workspace}) + } + options := cfg.registryOptions() + // The registry identifies its client as "cli" unless SENIOR_DEV_CLIENT names + // something else. + if clientIdentity, ok := os.LookupEnv("SENIOR_DEV_CLIENT"); ok { + options.ClientIdentity = clientIdentity + } else { + options.ClientIdentity = "cli" + } + runtime.question = question.NewService(runtime.bus, nil) + options.Question = runtime.question + runtime.registry = tool.NewWithOptions(workspace, options) + // Headless senior-dev has nothing attached that could answer question.asked, + // so an unanswered question would hang the run for the rest of its wall + // clock. Auto-reject through the service's own reject path so the model + // receives the rejection ("The user dismissed this question") and the run + // keeps moving. The registry converts the third consecutive rejection into + // its documented terminal success result. + runtime.unsubscribeQuestionAutoReject = runtime.bus.SubscribeCallback( + question.Event.Asked, func(payload bus.Payload) { + if request, ok := payload.Properties.(question.Request); ok { + runtime.question.Reject(request.ID) + } + }) + return runtime +} + +func (runtime *runtimeAdapter) nextID(prefix string) string { + switch prefix { + case "session": + value, err := id.Descending("session") + if err == nil { + return value + } + case "message": + return steploop.NewAscendingID("msg") + case "part": + return steploop.NewAscendingID("prt") + } + return fmt.Sprintf("%s_%016x", prefix, runtime.ids.Add(1)) +} + +func (runtime *runtimeAdapter) addCost(cost float64) { + runtime.mu.Lock() + runtime.costUSD += cost + runtime.mu.Unlock() +} + +func (runtime *runtimeAdapter) cost() float64 { + runtime.mu.Lock() + defer runtime.mu.Unlock() + return runtime.costUSD +} + +func (runtime *runtimeAdapter) runTurn(ctx context.Context, request turn) (turnResult, error) { + if runtime.initErr != nil { + return turnResult{}, runtime.initErr + } + if runtime.backend == nil { + return turnResult{}, errors.New("senior-dev runtime: backend is required") + } + if request.Variant == "" { + if concrete, ok := runtime.backend.(*modelAPIBackend); ok { + request.Variant = concrete.variant + } + } + request.ProviderID, request.ModelID = normalizeModelRef(request.ProviderID, request.ModelID) + if request.SessionID == "" { + info, err := runtime.createSession(ctx, sessioncore.CreateInput{ + ParentID: request.ParentSessionID, Title: request.SessionTitle, + Agent: request.Agent, Directory: request.Workspace, + Model: sessionModel(request.ProviderID, request.ModelID, request.Variant), + }) + if err != nil { + return turnResult{}, err + } + request.SessionID = info.ID + } else if err := runtime.ensureSession(ctx, request); err != nil { + return turnResult{SessionID: request.SessionID}, err + } + request.Store = runtime.durable + if request.CompactionDecisions == nil { + request.CompactionDecisions = newSeniorDevCompactionDecisionSink(runtime.bus, runtime.events) + } + if request.ModelRequests == nil { + request.ModelRequests = newModelRequestSink(runtime.bus) + } + messageID, err := persistTurnPrompt( + ctx, runtime.durable, request.SessionID, request.MessageID, request, + ) + if err != nil { + return turnResult{SessionID: request.SessionID}, err + } + if err := runtime.durable.TouchSession(ctx, request.SessionID); err != nil { + return turnResult{SessionID: request.SessionID}, err + } + request.PromptPersisted = true + request.PromptMessageID = messageID + if request.ManageScratch { + releaseScratch := tool.AcquireShellScratch(request.SessionID) + defer releaseScratch() + } + return runtime.backend.Run(ctx, request) +} + +func persistTurnPrompt( + ctx context.Context, + store steploop.Store, + sessionID string, + messageID string, + request turn, +) (string, error) { + if messageID == "" { + messageID = steploop.NewAscendingID("msg") + } + user := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: messageID, SessionID: sessionID}, + Time: msgmodel.TimeCreated{Created: uint64(time.Now().UnixMilli())}, + Agent: request.Agent, + Model: msgmodel.UserModel{ + ProviderID: request.ProviderID, ModelID: request.ModelID, + }, + } + if request.Variant != "" { + user.Model.Variant = &request.Variant + } + parts := []msgmodel.Part{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: steploop.NewAscendingID("prt"), SessionID: sessionID, MessageID: messageID, + }, + Text: request.Prompt, + }} + if paired, ok := store.(interface { + UpdateMessageWithParts(context.Context, msgmodel.Info, ...msgmodel.Part) error + }); ok { + if err := paired.UpdateMessageWithParts(ctx, user, parts...); err != nil { + return "", err + } + return messageID, nil + } + for _, part := range parts { + if err := store.UpdatePart(ctx, part); err != nil { + return "", err + } + } + if err := store.UpdateMessage(ctx, user); err != nil { + return "", err + } + return messageID, nil +} + +func (runtime *runtimeAdapter) ensureSession(ctx context.Context, request turn) error { + if runtime.durable == nil { + return errors.New("senior-dev runtime: durable sessions are unavailable") + } + if _, err := runtime.durable.sessions.Get(ctx, request.SessionID); err == nil { + return nil + } else { + var missing *storage.NotFoundError + if !errors.As(err, &missing) { + return err + } + } + _, err := runtime.createSession(ctx, sessioncore.CreateInput{ + ID: request.SessionID, ParentID: request.ParentSessionID, + Title: request.SessionTitle, Agent: request.Agent, Directory: request.Workspace, + Model: sessionModel(request.ProviderID, request.ModelID, request.Variant), + }) + return err +} + +func (runtime *runtimeAdapter) createSession( + ctx context.Context, input sessioncore.CreateInput, +) (sessioncore.Info, error) { + if runtime.initErr != nil { + return sessioncore.Info{}, runtime.initErr + } + if runtime.durable == nil { + return sessioncore.Info{}, errors.New("senior-dev runtime: durable sessions are unavailable") + } + info, err := runtime.durable.CreateSession(ctx, input) + if err != nil { + return sessioncore.Info{}, err + } + return info, nil +} + +func (runtime *runtimeAdapter) ensureRootSession( + ctx context.Context, sessionID, title, agent string, +) error { + return runtime.ensureSession(ctx, turn{ + SessionID: sessionID, SessionTitle: title, Agent: agent, + Workspace: runtime.workspace, + }) +} + +func sessionModel(providerID, modelID, variant string) *sessioncore.Model { + if providerID == "" && modelID == "" && variant == "" { + return nil + } + model := &sessioncore.Model{ID: modelID, ProviderID: providerID} + if variant != "" { + model.Variant = &variant + } + return model +} + +func (runtime *runtimeAdapter) Close() { + if runtime == nil { + return + } + if runtime.unsubscribeQuestionAutoReject != nil { + runtime.unsubscribeQuestionAutoReject() + } + if runtime.unsubscribeEvents != nil { + runtime.unsubscribeEvents() + } + if runtime.question != nil { + runtime.question.Close() + } + if runtime.durable != nil { + runtime.durable.Close() + } else if runtime.bus != nil { + runtime.bus.Dispose() + } +} + +// policyDisabledTools names the builtin tools the network policy withholds +// from the model entirely: with egress off, the web tools disappear from the +// tool list so the model never sees, plans around, or probes them. +func policyDisabledTools(policy netpolicy.Policy) []string { + if policy.Restricted() { + return []string{"webfetch", "websearch"} + } + return nil +} + +func filterTools( + definitions []steploop.ToolDefinition, disabled map[string]bool, +) []steploop.ToolDefinition { + out := make([]steploop.ToolDefinition, 0, len(definitions)) + for _, definition := range definitions { + if !disabled[definition.Provider.Name] { + out = append(out, definition) + } + } + return out +} + +func (runtime *runtimeAdapter) definitionsFor( + providerID, modelID, agentName string, disabled map[string]bool, +) []steploop.ToolDefinition { + if disabled == nil { + disabled = map[string]bool{} + } + // Visibility gating lives here rather than in tool.FilterDefinitions. + // With the tools absent from the definitions + // the model never plans around them, so it cannot burn turns retrying + // policy errors; the execute-time checks remain as defense in depth. + for _, name := range policyDisabledTools(netpolicy.Current()) { + disabled[name] = true + } + for name := range runtime.config.disabledTools(agentName, runtime.registry.IDs()) { + disabled[name] = true + } + definitions := tool.FilterDefinitions(runtime.registry.Definitions(), tool.FilterInput{ + ProviderID: providerID, + ModelID: modelID, + Flags: tool.CurrentWebSearchFlags(), + }) + return filterTools(definitions, disabled) +} + +// poolResolver holds the model pools the run was started with and answers +// which one a tier routes on. A tier given no pool of its own routes on the +// high pool, the same degradation the router applies. +type poolResolver struct { + high []string + low []string + frontier []string +} + +func (resolver poolResolver) values(tier baked.Tier) []string { + pool := resolver.high + switch tier { + case baked.TierLow: + if len(resolver.low) > 0 { + pool = resolver.low + } + case baked.TierFrontier: + if len(resolver.frontier) > 0 { + pool = resolver.frontier + } + } + return append([]string{}, pool...) +} + +// modelAPIBackend runs the model turns of one run against the model API codeaf +// serves it: an endpoint that answers in OpenRouter's chat-completions shape, +// opened by a token that opens nothing else. +// +// IT HOLDS NO KEY. senior-dev read a provider key and a base URL out of its +// environment before codeaf carried it; both reads are gone, and so is every +// check that a key was set. The API's address and token arrive through the +// delegate.Host, and fetch is the one door every model request leaves by. +type modelAPIBackend struct { + api delegate.ModelAPI + variant string + client *http.Client + contextLimit float64 + outputLimit float64 + totalTimeoutMS float64 + chunkTimeoutMS float64 + config *seniorDevConfig + router *adaptive.AdaptiveModelRouter + catalog modelsdev.Catalog + // events receives the records the backend emits on its own, after + // configureTurn: the compaction-capacity pins (compaction_pin.go). + events *eventWriter + // pinnedCapacity is the per-session capacity a context-overflow rejection + // named (compaction_pin.go). A run is one process, so the map is the + // whole of the state. + pinMu sync.Mutex + pinnedCapacity map[string]float64 +} + +func executeAdvertisedTool( + ctx context.Context, request turn, call steploop.ToolCall, +) (steploop.ToolResult, error) { + available := make([]string, 0, len(request.Tools)) + for _, definition := range request.Tools { + name := definition.Provider.Name + available = append(available, name) + if name == call.Name { + return request.Execute(ctx, call) + } + } + message := "Model tried to call unavailable tool '" + call.Name + "'. " + if len(available) == 0 { + message += "No tools are available." + } else { + message += "Available tools: " + strings.Join(available, ", ") + "." + } + return steploop.ToolResult{}, errors.New(message) +} + +// newModelAPIBackend is the backend of a run whose model API is api. +func newModelAPIBackend(api delegate.ModelAPI, variant string) *modelAPIBackend { + return &modelAPIBackend{ + api: api, variant: variant, + // Streaming lifetime belongs to the caller context and the reader's + // inactivity watchdog. http.Client.Timeout measures total request age, + // including a healthy response body, so it must remain unset. + client: &http.Client{}, + } +} + +// fetch sends one model request: the model API's token goes on here and +// nowhere else, over the backend's one HTTP client. +// +// THIS IS THE ONE DOOR. The streaming client builds each request and hands it +// here (orclient.Client.Fetcher), so no request can leave without the token, +// and none can carry a credential of anybody else's: whatever a configured +// header said, the Authorization header is the API's, set last. +func (backend *modelAPIBackend) fetch(request *http.Request) (*http.Response, error) { + backend.api.Authorize(request) + client := backend.client + if client == nil { + client = http.DefaultClient + } + return client.Do(request) +} diff --git a/internal/seniordev/app/runtime_compaction_test.go b/internal/seniordev/app/runtime_compaction_test.go new file mode 100644 index 0000000000..23f4523661 --- /dev/null +++ b/internal/seniordev/app/runtime_compaction_test.go @@ -0,0 +1,677 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction" + "github.com/Agent-Field/codeaf/internal/seniordev/session/loopguard" +) + +type scriptedRoundTripper struct { + mu sync.Mutex + replies []string + statuses []int + requests [][]byte +} + +type recordedChatMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +func (transport *scriptedRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + body, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + transport.requests = append(transport.requests, body) + reply := transport.replies[0] + transport.replies = transport.replies[1:] + status := http.StatusOK + if len(transport.statuses) > 0 { + status = transport.statuses[0] + transport.statuses = transport.statuses[1:] + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(reply)), + Request: request, + }, nil +} + +func chatReply(content string, promptTokens float64) string { + encodedContent, _ := json.Marshal(content) + return `data: {"id":"gen-text","choices":[{"delta":{"content":` + + string(encodedContent) + `}}]}` + "\n\n" + + `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"cost":0.01,"prompt_tokens":` + + strconv.FormatFloat(promptTokens, 'f', -1, 64) + + `,"completion_tokens":10,"total_tokens":` + + strconv.FormatFloat(promptTokens+10, 'f', -1, 64) + `}}` + "\n\n" + + "data: [DONE]\n\n" +} + +// summaryPathConfig is a project config with a zero verbatim-tail budget. The +// histories these tests build are a few hundred tokens, which the default +// 20K-token tail would keep whole -- leaving nothing to summarize and no +// summary request for the scripted transport to answer. A zero budget keeps +// only the newest message verbatim, so every compaction here takes the summary +// path through the real transport, which is what these tests exist to prove. +func summaryPathConfig(t *testing.T) *seniorDevConfig { + t.Helper() + workspace := t.TempDir() + if err := os.WriteFile( + filepath.Join(workspace, "senior-dev.json"), + []byte(`{"compaction":{"preserve_recent_tokens":0}}`), 0o600, + ); err != nil { + t.Fatal(err) + } + loaded, err := loadSeniorDevConfig(workspace) + if err != nil { + t.Fatal(err) + } + return loaded +} + +func validCompactionSummary(goal string) string { + return strings.Join([]string{ + "## Working State", + "### Completed", "- " + goal, + "### Current", "- continue", + "### Verification", "- (none)", + "### Next", "- continue", + "### Files", "- (none)", + }, "\n") +} + +func TestSeniorDevCompactionSizerIncludesSystemPromptAndToolSchemas(t *testing.T) { + model := compaction.Model{Message: msgmodel.Model{ + ProviderID: "openrouter", ID: "vendor/model", + }} + base, err := (seniorDevContextSizer{}).EstimateContext(context.Background(), nil, model) + if err != nil { + t.Fatal(err) + } + large := strings.Repeat("context-bearing-token ", 500) + full, err := (seniorDevContextSizer{ + system: func(context.Context) string { return large }, + tools: []steploop.ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "large_tool", + Description: large, InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + }).EstimateContext(context.Background(), nil, model) + if err != nil { + t.Fatal(err) + } + if full <= base+4_000 { + t.Fatalf("full request estimate = %v, base = %v; system/tool context was not counted", full, base) + } +} + +func toolCallReply(name, arguments string) string { + encodedName, _ := json.Marshal(name) + encodedArguments, _ := json.Marshal(arguments) + return `data: {"choices":[{"delta":{"tool_calls":[{` + + `"index":0,"id":"call-1","type":"function","function":{"name":` + string(encodedName) + + `,"arguments":` + string(encodedArguments) + `}}]},"finish_reason":"tool_calls"}],` + + `"usage":{"cost":0.01,"prompt_tokens":10,"completion_tokens":10,"total_tokens":20}}` + + "\n\ndata: [DONE]\n\n" +} + +func TestOpenRouterRejectsToolOmittedFromRequestDefinitions(t *testing.T) { + // An unavailable write projects through the synthetic invalid tool as a + // successful correction, without mutating disk. + workspace := t.TempDir() + target := filepath.Join(workspace, "forbidden.txt") + arguments, err := json.Marshal(map[string]any{ + "filePath": target, + "content": "must not be written", + }) + if err != nil { + t.Fatal(err) + } + transport := &scriptedRoundTripper{replies: []string{ + toolCallReply("write", string(arguments)), + chatReply("continued after rejection", 10), + }} + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + } + runtime := newRuntime(workspace, backend) + t.Cleanup(runtime.Close) + result, err := runTestTurn(t, runtime, testTurn{ + Agent: "coder", ModelID: "openai/gpt-6.1-codex", + Workspace: workspace, Prompt: "test filtered execution", + }) + if err != nil { + t.Fatal(err) + } + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("filtered write changed the workspace: %v", statErr) + } + if len(result.Parts) != 2 || result.Parts[0].Type != "tool" || + result.Parts[0].Tool != "invalid" || result.Parts[0].Status != "completed" || + result.Parts[1].Text != "continued after rejection" { + t.Fatalf("turn parts = %#v", result.Parts) + } + if len(transport.requests) != 2 { + t.Fatalf("HTTP requests = %d, want rejected turn plus continuation", len(transport.requests)) + } + for _, part := range result.Parts { + if part.Type == "tool" && part.Status == "error" { + t.Fatalf("synthetic invalid call counted as a tool error: %#v", part) + } + } + want := "The arguments provided to the tool are invalid: Model tried to call unavailable tool 'write'." + if !strings.Contains(string(transport.requests[1]), want) { + t.Fatalf("model-visible rejection = %s, want substring %q", transport.requests[1], want) + } +} + +func TestOpenRouterSystemIncludesRootInstructionsAndReadOnlyInjectsNestedRules(t *testing.T) { + // Root AGENTS.md is in every engine system message, while only a read + // below a nested rules file gets a + // nested system-reminder (the root path is excluded from Resolve). + workspace := t.TempDir() + rootRules := filepath.Join(workspace, "AGENTS.md") + if err := os.WriteFile(rootRules, []byte("ROOT ENGINE CONTRACT"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "root.txt"), []byte("root target"), 0o644); err != nil { + t.Fatal(err) + } + nested := filepath.Join(workspace, "src") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "AGENTS.md"), []byte("NESTED READ CONTRACT"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nested, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + runRead := func(target string) [][]byte { + t.Helper() + arguments, err := json.Marshal(map[string]string{"filePath": target}) + if err != nil { + t.Fatal(err) + } + transport := &scriptedRoundTripper{replies: []string{ + toolCallReply("read", string(arguments)), chatReply("done", 10), + }} + runtime := newRuntime(workspace, &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + }) + if _, err := runTestTurn(t, runtime, testTurn{ + Agent: "coder", ModelID: "vendor/model", + Workspace: workspace, Prompt: "read the target", + }); err != nil { + t.Fatal(err) + } + return transport.requests + } + + rootRequests := runRead(filepath.Join(workspace, "root.txt")) + if !strings.Contains(string(rootRequests[0]), "ROOT ENGINE CONTRACT") { + t.Fatalf("root instruction missing from system message: %s", rootRequests[0]) + } + if strings.Contains(string(rootRequests[1]), "<system-reminder>") { + t.Fatalf("root-level read injected a nested reminder: %s", rootRequests[1]) + } + + nestedRequests := runRead(filepath.Join(nested, "main.go")) + if !strings.Contains(string(nestedRequests[0]), "ROOT ENGINE CONTRACT") || + !strings.Contains(string(nestedRequests[1]), + "<system-reminder>\\nInstructions from: "+filepath.Join(nested, "AGENTS.md")+"\\nNESTED READ CONTRACT") { + t.Fatalf("root/nested instruction projection = %s", nestedRequests[1]) + } +} + +func TestOpenRouterCompactsContextAndContinues(t *testing.T) { + // Inflated usage shrinks the next live iteration to [system, original + // user, summary context] and the loop keeps advancing. + transport := &scriptedRoundTripper{replies: []string{ + chatReply("working", 70_000), + chatReply(validCompactionSummary("anchored summary for the original task"), 10), + chatReply("finished after compaction", 10), + }} + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + contextLimit: 128_000, outputLimit: 32_768, + } + summaryPathConfig(t).applyBackend(backend) + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model", + Prompt: "original task", + }) + if err != nil { + t.Fatal(err) + } + if result.Text != "finished after compaction" { + t.Fatalf("result text = %q", result.Text) + } + if len(transport.requests) != 3 { + t.Fatalf("HTTP requests = %d, want response + summary + continued response", len(transport.requests)) + } + var summary map[string]json.RawMessage + if err := json.Unmarshal(transport.requests[1], &summary); err != nil { + t.Fatal(err) + } + if _, exists := summary["tools"]; exists { + t.Fatalf("summary request serialized tools: %s", transport.requests[1]) + } + var summaryMessages []recordedChatMessage + if err := json.Unmarshal(summary["messages"], &summaryMessages); err != nil { + t.Fatal(err) + } + systemCount := 0 + for _, message := range summaryMessages { + if message.Role != "system" { + continue + } + systemCount++ + var content []struct { + Text string `json:"text"` + } + if err := json.Unmarshal(message.Content, &content); err != nil { + t.Fatal(err) + } + texts := make([]string, 0, len(content)) + for _, part := range content { + texts = append(texts, part.Text) + } + if got := strings.Join(texts, "\n"); got != compaction.SummarySystemPrompt { + t.Fatalf("summary system prompt = %q", got) + } + } + if systemCount != 1 { + t.Fatalf("summary system message count = %d; messages=%#v", systemCount, summaryMessages) + } + var continued struct { + Messages []recordedChatMessage `json:"messages"` + } + if err := json.Unmarshal(transport.requests[2], &continued); err != nil { + t.Fatal(err) + } + if len(continued.Messages) < 3 || continued.Messages[0].Role != "system" { + t.Fatalf("continued context = %#v", continued.Messages) + } + continuedJSON := string(transport.requests[2]) + if !strings.Contains(continuedJSON, "anchored summary") || + !strings.Contains(continuedJSON, "Continue from the current state") || + !strings.Contains(continuedJSON, "working") { + t.Fatalf("continued context = %s", transport.requests[2]) + } + // The summary request carried the flattened head -- the original task -- + // and not the verbatim tail, and it carried it as real content. + summaryJSON := string(transport.requests[1]) + if !strings.Contains(summaryJSON, `<conversation>\n[User]: original task`) || + strings.Contains(summaryJSON, `[Assistant]: working`) { + t.Fatalf("summary request = %s", summaryJSON) + } +} + +func TestProjectConfigDisablesAutoCompactionOnLiveTurn(t *testing.T) { + // compaction.auto=false loaded from project config reaches the live + // controller and suppresses an otherwise-overflowing turn. + workspace := t.TempDir() + if err := os.WriteFile( + filepath.Join(workspace, "senior-dev.json"), + []byte(`{"compaction":{"auto":false}}`), 0o600, + ); err != nil { + t.Fatal(err) + } + loaded, err := loadSeniorDevConfig(workspace) + if err != nil { + t.Fatal(err) + } + transport := &scriptedRoundTripper{replies: []string{ + chatReply("finished without compaction", 70_000), + }} + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + } + loaded.applyBackend(backend) + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model", + Workspace: workspace, Prompt: "original task", + }) + if err != nil { + t.Fatal(err) + } + if result.Text != "finished without compaction" { + t.Fatalf("result text = %q", result.Text) + } + if len(transport.requests) != 1 { + t.Fatalf("HTTP requests = %d, want one un-compacted turn", len(transport.requests)) + } +} + +func TestOpenRouterCompactionHarvestsEvidenceByCodeAlone(t *testing.T) { + // Evidence is harvested from the summarized head by code: the failing-test + // signature survives the boundary, and no second model is asked anything + // -- exactly four requests, all to the coder's own model. + transport := &scriptedRoundTripper{replies: []string{ + toolCallReply("bash", `{"command":"go test ./..."}`), + chatReply("working before compaction", 70_000), + chatReply(validCompactionSummary("fix the widget"), 10), + chatReply("finished", 10), + }} + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + contextLimit: 128_000, outputLimit: 32_768, + } + summaryPathConfig(t).applyBackend(backend) + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", ModelID: "vendor/model", Workspace: t.TempDir(), Prompt: "fix the widget", + AgentMarkdown: testAgentPrompt, + Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "bash", InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + Execute: func(context.Context, steploop.ToolCall) (steploop.ToolResult, error) { + return steploop.ToolResult{ + Title: "go test ./...", + Output: "FAILED tests/widget_test.go::TestWidget\nAssertionError: got 2, want 3\n1 failed", + }, nil + }, + }) + if err != nil { + t.Fatal(err) + } + if len(result.Parts) < 2 || result.Parts[0].Type != "compaction" || + !strings.Contains(result.Parts[0].Text, "FAILED tests/widget_test.go::TestWidget") || + !strings.Contains(result.Parts[0].Text, "AssertionError: got 2, want 3") { + t.Fatalf("compaction projection = %#v", result.Parts) + } + if len(transport.requests) != 4 { + t.Fatalf("HTTP requests = %d, want tool turn + overflow turn + summary + continuation", len(transport.requests)) + } + for index, request := range transport.requests { + if strings.Contains(string(request), "cheap/evidence-model") { + t.Fatalf("request %d went to the evidence model: %s", index, request) + } + } +} + +func TestOpenRouterCompactionResetsTheObservationWindow(t *testing.T) { + // Compaction leaves one explicit boundary plus only post-compaction + // actions/messages for the loop guard and context counters. + workspace := t.TempDir() + transport := &scriptedRoundTripper{replies: []string{ + strings.Replace(toolCallReply("write", `{}`), `"prompt_tokens":10`, `"prompt_tokens":70000`, 1), + chatReply(validCompactionSummary("summary after rejected stale call"), 10), + chatReply("finished in fresh window", 10), + }} + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + contextLimit: 128_000, outputLimit: 32_768, + } + summaryPathConfig(t).applyBackend(backend) + runtime := newRuntime(workspace, backend) + t.Cleanup(runtime.Close) + result, err := runTestTurn(t, runtime, testTurn{ + Agent: "coder", ModelID: "openai/gpt-6.1-codex", + Workspace: workspace, Prompt: "compact the history", + }) + if err != nil { + t.Fatal(err) + } + if len(result.Parts) != 2 || result.Parts[0].Type != "compaction" || + !strings.Contains(result.Parts[0].Text, "summary after rejected stale call") || + result.Parts[1].Type != "text" || result.Parts[1].Text != "finished in fresh window" { + t.Fatalf("turn parts = %#v", result.Parts) + } + guard := loopguard.CreateLoopGuard(loopguard.LoopGuardOptions{}) + for _, part := range result.Parts { + if part.Type == "tool" { + guard.Observe(loopguard.LoopAction{Tool: part.Tool, ArgsKey: part.ArgsKey}) + } + } + if got := guard.Snapshot().ActionCount; got != 0 { + t.Fatalf("post-compaction loop actions = %v, want 0", got) + } + if result.CostUSD < 0.029 || result.CostUSD > 0.031 { + t.Fatalf("post-compaction cost = %v, want the three calls' 0.03", result.CostUSD) + } +} + +func TestOpenRouterSummaryFailureInstallsRecordAndContinues(t *testing.T) { + // A failed summary call is not a dead run: the boundary completes with the + // deterministic record after exactly one attempt, the verbatim tail is kept, + // the turn goes on, and completed live-call cost is still recorded. A 502 is + // deliberate: a retryable status must not make the summary request replay. + transport := &scriptedRoundTripper{ + replies: []string{ + chatReply("working", 70_000), + `{"error":{"message":"summary provider unavailable"}}`, + chatReply("finished after a failed summary", 10), + }, + statuses: []int{http.StatusOK, http.StatusBadGateway, http.StatusOK}, + } + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + contextLimit: 128_000, outputLimit: 32_768, + } + summaryPathConfig(t).applyBackend(backend) + runtime := newRuntime(t.TempDir(), backend) + t.Cleanup(runtime.Close) + result, err := runTestTurn(t, runtime, testTurn{ + Agent: "coder", ModelID: "vendor/model", + Workspace: t.TempDir(), Prompt: "original task", + }) + if err != nil { + t.Fatalf("a failed summary killed the turn: %v", err) + } + if result.Text != "finished after a failed summary" || len(transport.requests) != 3 { + t.Fatalf("result=%q requests=%d", result.Text, len(transport.requests)) + } + if len(result.Parts) == 0 || result.Parts[0].Type != "compaction" || + !strings.Contains(result.Parts[0].Text, "no state record could be generated") { + t.Fatalf("compaction projection = %#v", result.Parts) + } + continued := string(transport.requests[2]) + if !strings.Contains(continued, "working") || !strings.Contains(continued, "original task") { + t.Fatalf("continuation lost the tail or the pinned task: %s", continued) + } + if got := runtime.cost(); got < 0.019 || got > 0.021 { + t.Fatalf("recorded cost = %v, want the two completed live calls", got) + } +} + +func TestOpenRouterHardOverflowCompactsAndRetries(t *testing.T) { + // A hard provider overflow takes the same capped summary path as + // usage-based overflow, then retries with rebuilt context. + transport := &scriptedRoundTripper{ + replies: []string{ + `{"error":{"message":"maximum context length is 128000 tokens"}}`, + chatReply(validCompactionSummary("anchored summary"), 10), + chatReply("finished after hard overflow", 10), + }, + statuses: []int{http.StatusBadRequest, http.StatusOK, http.StatusOK}, + } + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + } + summaryPathConfig(t).applyBackend(backend) + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model", + Prompt: "original task", + }) + if err != nil { + t.Fatal(err) + } + if result.Text != "finished after hard overflow" || len(transport.requests) != 3 { + t.Fatalf("result=%+v requests=%d", result, len(transport.requests)) + } +} + +func TestOpenRouterAllowsMoreThanThreeSuccessfulCompactions(t *testing.T) { + // Compaction count is not a termination policy. A long but reducible run + // can compact repeatedly and still reach its natural terminal response. + const compactions = 5 + replies := []string{} + for index := 0; index < compactions; index++ { + replies = append(replies, + chatReply("overflow", 70_000), + chatReply(validCompactionSummary("task"), 10), + ) + } + replies = append(replies, chatReply("natural stop", 10)) + transport := &scriptedRoundTripper{replies: replies} + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + contextLimit: 128_000, outputLimit: 32_768, + } + summaryPathConfig(t).applyBackend(backend) + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", AgentMarkdown: "system", ModelID: "vendor/model", Prompt: "task", + }) + if err != nil { + t.Fatal(err) + } + if result.Text != "natural stop" { + t.Fatalf("turn result = %q", result.Text) + } + if len(transport.requests) != 2*compactions+1 { + t.Fatalf("HTTP requests = %d, want %d live/summary requests", len(transport.requests), 2*compactions+1) + } +} + +func TestOpenRouterStopsWhenAuthoritativeTaskCannotFitAfterRebuild(t *testing.T) { + // Unlimited successful compactions must not become an infinite retry loop. + // If the durable task itself cannot leave continuation headroom, fail with + // an explicit capacity error after one model summary and one local rebuild. + workspace := t.TempDir() + if err := os.MkdirAll(filepath.Join(workspace, ".senior-dev"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(workspace, ".senior-dev", "spec.md"), + []byte(strings.Repeat("irreducible authoritative requirement ", 10_000)), + 0o600, + ); err != nil { + t.Fatal(err) + } + transport := &scriptedRoundTripper{replies: []string{ + chatReply("overflow", 70_000), + chatReply(validCompactionSummary("task"), 10), + }} + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + contextLimit: 128_000, outputLimit: 32_768, + } + summaryPathConfig(t).applyBackend(backend) + _, err := backend.Run(context.Background(), turn{ + Agent: "coder", AgentMarkdown: "system", ModelID: "vendor/model", + Workspace: workspace, Prompt: "task", + }) + if !errors.Is(err, compaction.ErrContextCapacityExhausted) { + t.Fatalf("error = %v, want context capacity exhausted", err) + } + if len(transport.requests) != 2 { + t.Fatalf("HTTP requests = %d, want live request plus one summary", len(transport.requests)) + } +} + +func TestOpenRouterEngineHasNoUnconditionalSixtyFourTurnCap(t *testing.T) { + // The engine has no unconditional turn cap; action, loop, cost, and agent + // step budgets own termination. A valid 65-tool-turn + // sequence must therefore reach its natural terminal response. + replies := make([]string, 0, 66) + for index := 0; index < 65; index++ { + replies = append(replies, toolCallReply("bash", `{"command":"true"}`)) + } + replies = append(replies, chatReply("natural stop", 10)) + transport := &scriptedRoundTripper{replies: replies} + backend := &modelAPIBackend{api: testModelAPI, client: &http.Client{Transport: transport}} + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", ModelID: "vendor/model", Workspace: t.TempDir(), Prompt: "keep going", + AgentMarkdown: testAgentPrompt, + Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "bash", InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + Execute: func(context.Context, steploop.ToolCall) (steploop.ToolResult, error) { + return steploop.ToolResult{Output: "ok"}, nil + }, + }) + if err != nil || result.Text != "natural stop" || len(transport.requests) != 66 { + t.Fatalf("result=%+v err=%v requests=%d", result, err, len(transport.requests)) + } +} + +func TestOpenRouterReloadsRootInstructionsEachTurn(t *testing.T) { + // A root instruction created by turn one appears in turn two's system + // message. + workspace := t.TempDir() + rules := filepath.Join(workspace, "AGENTS.md") + arguments, err := json.Marshal(map[string]string{ + "filePath": rules, "content": "MID-TURN ROOT CONTRACT", + }) + if err != nil { + t.Fatal(err) + } + transport := &scriptedRoundTripper{replies: []string{ + toolCallReply("write", string(arguments)), chatReply("done", 10), + }} + runtime := newRuntime(workspace, &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + }) + t.Cleanup(runtime.Close) + if _, err := runTestTurn(t, runtime, testTurn{ + Agent: "coder", ModelID: "vendor/model", + Workspace: workspace, Prompt: "create instructions", + }); err != nil { + t.Fatal(err) + } + if strings.Contains(string(transport.requests[0]), "MID-TURN ROOT CONTRACT") { + t.Fatalf("turn one unexpectedly contained future instructions: %s", transport.requests[0]) + } + if !strings.Contains(string(transport.requests[1]), "MID-TURN ROOT CONTRACT") { + t.Fatalf("turn two did not reload root instructions: %s", transport.requests[1]) + } +} + +func TestOpenRouterEmptyBodyOverflowCompacts(t *testing.T) { + // An empty 400 body must classify as context overflow ("400 (no body)") + // and take the summary path. + transport := &scriptedRoundTripper{ + replies: []string{ + ``, + chatReply(validCompactionSummary("empty-body summary"), 10), + chatReply("finished after empty-body overflow", 10), + }, + statuses: []int{http.StatusBadRequest, http.StatusOK, http.StatusOK}, + } + backend := &modelAPIBackend{ + api: testModelAPI, client: &http.Client{Transport: transport}, + } + summaryPathConfig(t).applyBackend(backend) + result, err := backend.Run(context.Background(), turn{ + Agent: "coder", AgentMarkdown: "system prompt", ModelID: "vendor/model", + Prompt: "original task", + }) + if err != nil { + t.Fatal(err) + } + if result.Text != "finished after empty-body overflow" || len(transport.requests) != 3 { + t.Fatalf("result=%+v requests=%d", result, len(transport.requests)) + } +} diff --git a/internal/seniordev/app/runtime_retry_test.go b/internal/seniordev/app/runtime_retry_test.go new file mode 100644 index 0000000000..81f025041a --- /dev/null +++ b/internal/seniordev/app/runtime_retry_test.go @@ -0,0 +1,260 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +func retryTurn() turn { + return turn{ModelID: "test/model", Prompt: "hello", AgentMarkdown: testAgentPrompt} +} + +func TestModelCallNeverRetriesInsideTheEngine(t *testing.T) { + // A provider response belongs to exactly one HTTP request. Transient recovery + // happens at soloConverse, where it is globally bounded and session-aware. + for _, status := range []int{ + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusRequestTimeout, + http.StatusConflict, + http.StatusTooManyRequests, + http.StatusInternalServerError, + http.StatusServiceUnavailable, + } { + t.Run(fmt.Sprintf("status-%d", status), func(t *testing.T) { + requests := 0 + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + encoded, _ := json.Marshal(map[string]any{ + "error": map[string]any{"message": "provider failure"}, + }) + return recordedResponse(request, status, "application/json", string(encoded)), nil + })} + + backend := &modelAPIBackend{api: testModelAPI, client: client} + _, err := backend.Run(context.Background(), retryTurn()) + if err == nil { + t.Fatal("provider failure returned nil") + } + if requests != 1 { + t.Fatalf("HTTP requests = %d, want exactly 1", requests) + } + var failure *modelTurnError + if !errors.As(err, &failure) || failure.statusCode == nil || + *failure.statusCode != uint64(status) { + t.Fatalf("turn error = %#v, want structured status %d", err, status) + } + }) + } +} + +func TestInBandProviderFailureReachesRunClassifierWithStatus(t *testing.T) { + requests := 0 + body := `data: {"error":{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}},"choices":[]}` + + "\n\ndata: [DONE]\n\n" + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + return recordedResponse(request, http.StatusOK, "text/event-stream", body), nil + })} + backend := &modelAPIBackend{api: testModelAPI, client: client} + _, err := backend.Run(context.Background(), retryTurn()) + if err == nil || requests != 1 { + t.Fatalf("turn error=%v requests=%d, want one failed request", err, requests) + } + info, transient := transientTurnError(err) + if !transient || info.Class != "provider-5xx" || info.StatusCode == nil || + *info.StatusCode != 502 || info.ProviderCode != "provider_unavailable" { + t.Fatalf("run classification = %#v,%v for %v", info, transient, err) + } +} + +type errorAfterBody struct { + payload []byte + offset int +} + +func (body *errorAfterBody) Read(target []byte) (int, error) { + if body.offset >= len(body.payload) { + return 0, io.ErrUnexpectedEOF + } + n := copy(target, body.payload[body.offset:]) + body.offset += n + return n, nil +} + +func (*errorAfterBody) Close() error { return nil } + +type errorAfterFile struct { + payload []byte + offset int + path string +} + +func (body *errorAfterFile) Read(target []byte) (int, error) { + if body.offset < len(body.payload) { + n := copy(target, body.payload[body.offset:]) + body.offset += n + return n, nil + } + deadline := time.Now().Add(2 * time.Second) + for { + if _, err := os.Stat(body.path); err == nil || time.Now().After(deadline) { + return 0, io.ErrUnexpectedEOF + } + time.Sleep(5 * time.Millisecond) + } +} + +func (*errorAfterFile) Close() error { return nil } + +func TestFailureAfterToolCallDoesNotReplayRequestOrTool(t *testing.T) { + requests := 0 + payload := strings.TrimSuffix(toolCallReply("bash", `{"command":"true"}`), "data: [DONE]\n\n") + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + requests++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &errorAfterBody{payload: []byte(payload)}, + Request: request, + }, nil + })} + backend := &modelAPIBackend{api: testModelAPI, client: client, chunkTimeoutMS: -1} + var executions atomic.Int32 + _, err := backend.Run(context.Background(), turn{ + Agent: "coder", ModelID: "test/model", Prompt: "use the tool", Workspace: t.TempDir(), + AgentMarkdown: testAgentPrompt, + Tools: []steploop.ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "bash", Description: "run a command", + InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + Execute: func(context.Context, steploop.ToolCall) (steploop.ToolResult, error) { + executions.Add(1) + return steploop.ToolResult{Output: "ok"}, nil + }, + }) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unexpected eof") { + t.Fatalf("turn error = %v, want the dropped stream", err) + } + if requests != 1 || executions.Load() != 1 { + t.Fatalf("requests=%d tool executions=%d, want 1 and 1", requests, executions.Load()) + } +} + +func TestSoloRecoveryCrossesThePersistedEngineBoundaryWithoutReplayingToolEffects(t *testing.T) { + const effect = "RECOVERY_SIDE_EFFECT_48291" + workspace, base := guardWorkspace(t) + if err := writeFile( + filepath.Join(workspace, ".senior-dev", "checklist.md"), + "[x] preserve completed tool effects across recovery\n", + ); err != nil { + t.Fatal(err) + } + + var requestBodies [][]byte + var events bytes.Buffer + client := &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + raw, err := io.ReadAll(request.Body) + if err != nil { + return nil, err + } + requestBodies = append(requestBodies, raw) + + var reply string + switch len(requestBodies) { + case 1: + reply = toolCallReply("bash", `{"command":"printf '`+effect+`\\n' > recovered.txt"}`) + case 2: + reply = strings.Replace( + toolCallReply("submit", `{"reason":"recovered safely","evidence":"workspace effect inspected","checklist_satisfied":true}`), + "call-1", "call-submit", 1, + ) + return recordedResponse(request, http.StatusOK, "text/event-stream", reply), nil + case 3: + return recordedResponse(request, http.StatusOK, "text/event-stream", chatReply("done", 10)), nil + default: + t.Fatalf( + "unexpected model request %d (recovery=%v nudge=%v)", + len(requestBodies), strings.Contains(string(raw), soloRecoveryPrompt()), + strings.Contains(string(raw), "You stopped without calling submit"), + ) + } + // Only the first response drops after executing a tool. The recovered + // turn is healthy and can submit the preserved workspace normally. + payload := strings.TrimSuffix(reply, "data: [DONE]\n\n") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: &errorAfterFile{ + payload: []byte(payload), path: filepath.Join(workspace, "recovered.txt"), + }, + Request: request, + }, nil + })} + backend := &modelAPIBackend{ + api: testModelAPI, client: client, totalTimeoutMS: -1, chunkTimeoutMS: -1, + } + runner := newPipeline(cliArgs{High: "openrouter/test/model"}, workspace, pipelineDeps{ + Backend: backend, Events: newEventWriter(&events), Notes: discardWriter{}, + Sleep: func(context.Context, time.Duration) error { return nil }, + }) + t.Cleanup(runner.runtime.Close) + // The adaptive router is orthogonal to this test. Keeping its single + // candidate out of cooldown lets the fresh turn start immediately. + backend.router = nil + state := &soloState{baseSHA: base} + runner.runtime.registry.SetSubmitFreezer( + func(_ context.Context, submission tool.Submission) (string, error) { + return runner.soloFreezeWithContext(context.Background(), state, submission) + }, + ) + + outcome := soloOutcome{} + const goal = "Create recovered.txt and submit the result." + if err := runner.soloConverse(context.Background(), goal, state, &outcome); err != nil { + t.Fatalf("solo recovery: %v", err) + } + if outcome.TerminalTrigger != "submitted" || state.candidate() == nil { + t.Fatalf("outcome=%#v candidate=%#v, want submitted", outcome, state.candidate()) + } + if len(requestBodies) != 3 { + t.Fatalf("model requests=%d, want failed request plus one recovered tool cycle", len(requestBodies)) + } + if got, err := os.ReadFile(filepath.Join(workspace, "recovered.txt")); err != nil || + strings.TrimSpace(string(got)) != effect { + t.Fatalf("completed tool effect=%q err=%v", got, err) + } + second := string(requestBodies[1]) + if !strings.Contains(second, goal) || !strings.Contains(second, soloRecoveryPrompt()) { + t.Fatalf("fresh request lost the task or recovery prompt: %s", second) + } + if strings.Contains(second, effect) { + t.Fatalf("failed assistant/tool payload leaked into fresh context: %s", second) + } + retries := 0 + for _, event := range soloStageEvents(t, &events, "implement") { + if event["status"] == "transport-retry" { + retries++ + } + } + if retries != 1 { + t.Fatalf("outer recovery turns=%d, want exactly 1", retries) + } +} diff --git a/internal/seniordev/app/runtime_test.go b/internal/seniordev/app/runtime_test.go new file mode 100644 index 0000000000..2ecdc31df9 --- /dev/null +++ b/internal/seniordev/app/runtime_test.go @@ -0,0 +1,125 @@ +//go:build !windows + +package app + +import ( + "context" + "io" + "net/http" + "reflect" + "slices" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +type capturingBackend struct { + turns []turn +} + +func (backend *capturingBackend) Run(_ context.Context, request turn) (turnResult, error) { + backend.turns = append(backend.turns, request) + return turnResult{}, nil +} + +func TestTheBackendHasNoHTTPClientWallClockTimeout(t *testing.T) { + configured := newModelAPIBackend(testModelAPI, "") + if configured.client == nil { + t.Fatal("the backend has no HTTP client") + } + if configured.client.Timeout != 0 { + t.Fatalf("HTTP client timeout = %s, want disabled", configured.client.Timeout) + } +} + +// fetch is the one door every model request leaves by, and it puts the model +// API's token on whatever the request already said — a configured header +// naming another credential included. +func TestFetchCarriesTheModelAPIsTokenOverAnyOtherCredential(t *testing.T) { + var seen string + backend := newModelAPIBackend(testModelAPI, "") + backend.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + seen = request.Header.Get("Authorization") + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("")), Request: request}, nil + })} + request, err := http.NewRequest(http.MethodPost, testModelAPI.BaseURL, nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer somebody-elses-key") + if _, err := backend.fetch(request); err != nil { + t.Fatal(err) + } + if seen != "Bearer "+testModelAPI.Token { + t.Fatalf("Authorization = %q, want the model API's token", seen) + } +} + +func TestModelFilteringPreservesDisabledTools(t *testing.T) { + runtime := newRuntime(t.TempDir(), &capturingBackend{}) + t.Cleanup(runtime.Close) + got := requestToolNames(runtime.definitionsFor( + "openrouter", "deepseek/deepseek-v4-pro", "coder", map[string]bool{"write": true}, + )) + want := []string{"question", "bash", "read", "glob", "grep", "edit", "webfetch"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("tools = %v, want %v", got, want) + } +} + +func TestDefinitionsForSeniorDevProviderIncludesWebSearch(t *testing.T) { + for _, name := range []string{ + "SENIOR_DEV_EXPERIMENTAL", "SENIOR_DEV_ENABLE_EXA", "SENIOR_DEV_EXPERIMENTAL_EXA", + "SENIOR_DEV_ENABLE_PARALLEL", "SENIOR_DEV_EXPERIMENTAL_PARALLEL", + } { + t.Setenv(name, "") + } + runtime := newRuntime(t.TempDir(), &capturingBackend{}) + t.Cleanup(runtime.Close) + got := requestToolNames(runtime.definitionsFor( + "senior-dev", "deepseek/deepseek-v4-pro", "coder", nil, + )) + if !slices.Contains(got, "websearch") { + t.Fatalf("senior-dev tools = %v", got) + } +} + +func requestToolNames(definitions []steploop.ToolDefinition) []string { + names := make([]string, 0, len(definitions)) + for _, definition := range definitions { + names = append(names, definition.Provider.Name) + } + return names +} + +type turnCapturingBackend struct{ request turn } + +func (backend *turnCapturingBackend) Run(_ context.Context, request turn) (turnResult, error) { + backend.request = request + return turnResult{Text: "done"}, nil +} + +func TestSoloTurnClearsInstructionClaimsAfterAssistant(t *testing.T) { + // The coding turn must receive the per-assistant instruction-claim cleanup + // hook. The solo pipeline has exactly one such turn, so if it omits the + // hook nothing else will supply it. + workspace := t.TempDir() + backend := &turnCapturingBackend{} + runner := &pipeline{ + workspace: workspace, + runtime: newRuntime(workspace, backend), + pool: poolResolver{high: []string{"provider/model"}}, + events: newEventWriter(discardWriter{}), + notes: discardWriter{}, + } + if _, err := runner.soloTurn(context.Background(), "goal", "do the thing"); err != nil { + t.Fatal(err) + } + if backend.request.AfterAssistant == nil { + t.Fatal("the solo coding turn omitted AfterAssistant instruction cleanup") + } + if backend.request.Agent != "coder" { + t.Fatalf("solo turn agent = %q, want coder", backend.request.Agent) + } +} diff --git a/internal/seniordev/app/solo.go b/internal/seniordev/app/solo.go new file mode 100644 index 0000000000..5d7884ed65 --- /dev/null +++ b/internal/seniordev/app/solo.go @@ -0,0 +1,771 @@ +//go:build !windows + +package app + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/project" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +// The solo pipeline is one continuous coding context surrounded by small +// deterministic stages that do not think: +// +// intake (deterministic) -> ONE model context: explore, pin, implement, +// conform, submit -> freeze (inside the submit tool) -> bounded verification +// -> ship. +// +// The stages here own only what a model should not have to remember: writing +// the spec down verbatim, capturing the candidate the instant it is submitted, +// refusing to ship something worse than what was captured, and emitting one +// terminal event that says why the run ended. + +// soloMaxNudges bounds the continuations offered to a run that stops without +// submitting. Two is enough for "you forgot" and "here is what is actually +// wrong"; a third is the model arguing with the runner. +const ( + soloMaxNudges = 2 + soloMaxToolLeakCorrections = 2 +) + +// soloMaxRecoveryRetries bounds fresh turns offered after a transient +// provider or transport failure. This is the only model-call retry in senior-dev: +// re-entering the persisted session preserves completed work without silently +// replaying a partial streamed response. Without it one dropped stream is a +// lost run. +const soloMaxRecoveryRetries = 3 + +var errSoloLanding = errors.New("solo landing window reached") + +const ( + soloLandingReserveCap = 12 * time.Minute + soloCheckTimeout = 2 * time.Minute + soloLandingTurnTimeout = 5 * time.Minute + soloFinalCheckTimeout = 3 * time.Minute +) + +// soloOutcome is what the run decided, separated into what the model claimed +// and what senior-dev independently observed. Keeping the two apart is the +// point: a run that says "all tests pass" and did not run them must leave both +// facts in the event stream rather than one reconciled story. +type soloOutcome struct { + Status string + SubmissionReason string + Frozen *frozenCandidate + Verification *projectVerificationResult + Nudges int + LandingTurns int + TerminalTrigger string + RestoreSource string + LiveTree string + FinalTree string + SuiteDead bool + + // TerminalData and TerminalReason are what the run has to say about how it + // ended. They travel to the CLI layer rather than being emitted here so the + // run emits exactly one terminal event; see soloTerminal. + TerminalData map[string]any + TerminalReason string +} + +// frozenCandidate is the artifact of record. It is captured inside the submit +// tool call, so by the time the model's next step runs this already exists and +// nothing it does can reach what ships. +type frozenCandidate struct { + // CommitSHA is a real commit object holding the whole tree (tracked and + // untracked, ignored files excluded), written through a temporary index so + // the working tree and the real index are never touched. + CommitSHA string + TreeSHA string + // PatchBytes and PatchFiles describe the diff against the run's base, and + // exist so a later restore decision can be logged in terms a human reads. + PatchBytes int + PatchFiles int + PatchSHA string + At time.Time + + // The model's own claim, recorded verbatim and never reconciled with what + // senior-dev later observes. + Reason string + Evidence string + ChecklistSatisfied bool +} + +func (candidate *frozenCandidate) describe() string { + if candidate == nil { + return "nothing frozen" + } + return fmt.Sprintf( + "%d bytes across %d file(s), tree %s", + candidate.PatchBytes, candidate.PatchFiles, shortSHA(candidate.TreeSHA), + ) +} + +func shortSHA(value string) string { + if len(value) > 12 { + return value[:12] + } + return value +} + +// soloState carries the run's mutable freeze across the tool boundary. The +// mutex exists because the submit tool executes on the step loop's goroutine +// while the stage machine reads the result on its own. +type soloState struct { + mu sync.Mutex + frozen *frozenCandidate + start *soloCheckpoint + coherent *soloCheckpoint + baseSHA string +} + +type soloCheckpoint struct { + CommitSHA string + TreeSHA string + Source string +} + +func (state *soloState) candidate() *frozenCandidate { + state.mu.Lock() + defer state.mu.Unlock() + return state.frozen +} + +func (state *soloState) freeze(candidate frozenCandidate) { + state.mu.Lock() + defer state.mu.Unlock() + state.frozen = &candidate +} + +func (state *soloState) checkpoints() (*soloCheckpoint, *soloCheckpoint) { + state.mu.Lock() + defer state.mu.Unlock() + return state.start, state.coherent +} + +func (state *soloState) setStart(checkpoint soloCheckpoint) { + state.mu.Lock() + defer state.mu.Unlock() + state.start = &checkpoint +} + +func (state *soloState) setCoherent(checkpoint soloCheckpoint) { + state.mu.Lock() + defer state.mu.Unlock() + state.coherent = &checkpoint +} + +// runSolo executes the whole simplified pipeline for one request. +func (runner *pipeline) runSolo( + ctx context.Context, goal, baseSHA string, +) (soloOutcome, error) { + state := &soloState{baseSHA: baseSHA} + outcome := soloOutcome{Status: "fail"} + + if err := runner.soloIntake(goal); err != nil { + return outcome, err + } + if err := runner.soloCaptureStart(state); err != nil { + runner.note("[senior-dev] landing: could not capture the exact starting tree: " + err.Error() + "\n") + } + // Installed before the first turn so the tool is advertised, and left + // installed afterwards so a late submit during a nudge still freezes. + runner.runtime.registry.SetSubmitFreezer( + func(submitCtx context.Context, submission tool.Submission) (string, error) { + return runner.soloFreezeWithContext(submitCtx, state, submission) + }, + ) + + // Ship runs on EVERY ending, the wall-clock kill included. Returning early + // on a converse error would skip both halves of stage 4 on the common + // ending of a full-budget run: no restore, so a run that submitted and then + // kept editing would ship the post-submission tree; and no terminal, so the + // run could not say whether it had submitted at all. + converseErr := runner.soloConverse(ctx, goal, state, &outcome) + runner.soloShip(ctx, state, &outcome, converseErr) + return outcome, converseErr +} + +// soloIntake is stage 0. It writes the request down verbatim and nothing else. +// The spec travels to every later stage as a file rather than as a paraphrase: +// a restated request loses the exact identifiers the original names. +func (runner *pipeline) soloIntake(goal string) error { + directory := filepath.Join(runner.workspace, ".senior-dev") + if err := os.MkdirAll(directory, 0o755); err != nil { + return fmt.Errorf("solo intake: %w", err) + } + specPath := filepath.Join(directory, "spec.md") + if err := os.WriteFile(specPath, []byte(goal), 0o644); err != nil { + return fmt.Errorf("solo intake: %w", err) + } + runner.events.stage("intake", "captured", map[string]any{ + "spec_path": ".senior-dev/spec.md", "spec_bytes": len(goal), + }) + runner.note("[senior-dev] intake: request captured verbatim at .senior-dev/spec.md\n") + return nil +} + +// soloConverse runs the single coding context, plus bounded corrections for a +// real stop or provider markup that failed to execute as a tool call. +func (runner *pipeline) soloConverse( + ctx context.Context, goal string, state *soloState, outcome *soloOutcome, +) error { + workCtx, cancel := runner.soloWorkContext(ctx) + defer cancel() + prompt, err := adaptSoloPrompt( + runner.recorder, + buildSoloPrompt(goal, runner.readPinnedCommand(), ".senior-dev/checklist.md"), + ) + if err != nil { + return err + } + leakCorrections := 0 + recoveryRetries := 0 + for attempt := 0; ; { + runner.events.stage("implement", "running", map[string]any{"attempt": attempt}) + response, err := runner.soloTurn(workCtx, goal, prompt) + if err != nil { + // A turn that errored may still have submitted before it died; the + // freeze is what decides, not the error. + if state.candidate() == nil { + if errors.Is(context.Cause(workCtx), errSoloLanding) { + outcome.TerminalTrigger = "landing-window" + return runner.soloLandingTurn(ctx, goal, state, outcome) + } + if retryInfo, transient := transientTurnError(err); transient && + recoveryRetries < soloMaxRecoveryRetries && workCtx.Err() == nil { + recoveryRetries++ + delay := soloRecoveryDelay(recoveryRetries) + data := map[string]any{ + "attempt": attempt, "retry": recoveryRetries, + "max_retries": soloMaxRecoveryRetries, + "delay_ms": delay.Milliseconds(), + "class": retryInfo.Class, + "error": err.Error(), + } + if retryInfo.StatusCode != nil { + data["http_status"] = *retryInfo.StatusCode + } + if retryInfo.ProviderCode != "" { + data["provider_code"] = retryInfo.ProviderCode + } + runner.events.stage("implement", "transport-retry", data) + runner.note(fmt.Sprintf( + "[senior-dev] implement: turn died on a transient provider failure (%s); retry %d/%d\n", + retryInfo.Class, recoveryRetries, soloMaxRecoveryRetries, + )) + // A refused sleep means the work window closed during the + // wait; the next turn fails fast and lands above. + _ = runner.sleep(workCtx, delay) + prompt = soloRecoveryPrompt() + continue + } + // A dead turn is not a dead run: offer the landing turn so the + // tree that exists still gets independent verification and one + // bounded chance to submit. If nothing lands, the original + // error stands -- the trigger and the exit stay honest. + runner.events.stage("implement", "turn-error", map[string]any{ + "attempt": attempt, "transport_retries": recoveryRetries, + "error": err.Error(), + }) + if landErr := runner.soloLandingTurn(ctx, goal, state, outcome); landErr != nil { + runner.note("[senior-dev] implement: landing after a turn error also failed: " + + landErr.Error() + "\n") + } + if state.candidate() != nil { + return nil + } + outcome.TerminalTrigger = "turn-error" + return err + } + runner.note("[senior-dev] implement: turn ended with an error after submitting: " + + err.Error() + "\n") + } + if candidate := state.candidate(); candidate != nil { + outcome.TerminalTrigger = "submitted" + outcome.SubmissionReason = candidate.Reason + runner.events.stage("implement", "submitted", map[string]any{ + "attempt": attempt, "reason": candidate.Reason, + "checklist_satisfied": candidate.ChecklistSatisfied, + }) + return nil + } + if leakedToolCall(response) && leakCorrections < soloMaxToolLeakCorrections { + leakCorrections++ + runner.events.stage("implement", "tool-call-leak", map[string]any{ + "attempt": attempt, "correction": leakCorrections, + }) + prompt = soloToolLeakPrompt() + continue + } + findings := runner.soloUnsubmittedFindings(state.baseSHA) + verification := runner.soloCheckUnsubmitted( + workCtx, state, soloCheckTimeout, "nudge", + ) + if verification != nil { + outcome.Verification = verification + findings = append(findings, soloVerificationFindings(*verification)...) + } + if attempt >= soloMaxNudges || runner.budgetIsExhausted() { + if runner.budgetIsExhausted() { + outcome.TerminalTrigger = "budget" + } else { + outcome.TerminalTrigger = "nudge-cap" + } + runner.events.stage("implement", "unsubmitted", map[string]any{ + "attempt": attempt, "budget_exhausted": runner.budgetIsExhausted(), + }) + runner.note("[senior-dev] implement: run ended without a submission\n") + return nil + } + outcome.Nudges = attempt + 1 + prompt = soloNudge(attempt+1, findings) + attempt++ + } +} + +func (runner *pipeline) soloTurn(ctx context.Context, goal, prompt string) (turnResult, error) { + if runner.turnForTest != nil { + return runner.turnForTest(ctx, goal, prompt) + } + markdown, ok := baked.GetBakedAgent("coder") + if !ok { + return turnResult{}, errors.New("solo: the coder agent is not available") + } + markdown, err := adaptCoderPrompt(runner.recorder, markdown) + if err != nil { + return turnResult{}, err + } + providerID, modelID := splitModelID(firstModel(runner.pool.high)) + ctx = project.WithContext(ctx, project.InstanceContext{ + Directory: runner.workspace, Worktree: runner.workspace, + Project: project.Info{Worktree: runner.workspace}, + }) + configured, err := runner.runtime.configureTurn(turn{ + SessionID: runner.sessionID, SessionTitle: prefixUTF16(goal, 60), + Agent: "coder", AgentMarkdown: markdown, Workspace: runner.workspace, + ProviderID: providerID, ModelID: modelID, Prompt: prompt, + }) + if err != nil { + return turnResult{}, err + } + configured.Tools = runner.runtime.definitionsFor( + configured.ProviderID, configured.ModelID, "coder", nil, + ) + configured.Execute = runner.runtime.registry.Execute + configured.SystemInstructions = runner.runtime.registry.SystemInstructions(ctx) + configured.LoadInstructions = runner.runtime.registry.SystemInstructions + configured.AfterAssistant = runner.runtime.registry.ClearInstructionClaims + response, err := runner.runtime.runTurn(ctx, configured) + runner.runtime.addCost(response.CostUSD) + return response, err +} + +type turnRetryInfo struct { + Class string + StatusCode *uint64 + ProviderCode string +} + +// transientTurnError recognizes only failures for which a fresh model turn is +// useful. Structured provider data wins; the string table is a fallback for +// transports that expose only Error(). Context endings and permanent account, +// request, and configuration failures never retry. +func transientTurnError(err error) (turnRetryInfo, bool) { + if err == nil || errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) { + return turnRetryInfo{}, false + } + if errors.Is(err, io.ErrUnexpectedEOF) { + return turnRetryInfo{Class: "unexpected-eof"}, true + } + + text := strings.ToLower(err.Error()) + var failure *modelTurnError + if errors.As(err, &failure) { + text += " " + strings.ToLower(failure.responseBody) + if permanentProviderLimit(text) || failure.kind == msgmodel.ErrNameContextOverflow { + return turnRetryInfo{}, false + } + providerCode := providerErrorType(failure.responseBody) + info := turnRetryInfo{StatusCode: failure.statusCode, ProviderCode: providerCode} + if failure.statusCode != nil { + switch status := *failure.statusCode; { + case status == 408: + info.Class = "request-timeout" + case status == 409: + info.Class = "provider-conflict" + case status == 429: + info.Class = "rate-limit" + case status >= 500: + info.Class = "provider-5xx" + default: + return turnRetryInfo{}, false + } + return info, true + } + if providerCode == "provider_unavailable" { + info.Class = "provider-unavailable" + return info, true + } + if failure.retryable { + info.Class = "provider-retryable" + return info, true + } + } + + for _, transport := range []struct{ needle, class string }{ + {"sse read timed out", "sse-read-timeout"}, + {"the operation timed out", "request-timeout"}, + {"unexpected eof", "unexpected-eof"}, + {"connection reset", "connection-reset"}, + {"broken pipe", "broken-pipe"}, + {"connection refused", "connection-refused"}, + {"tls handshake timeout", "tls-handshake-timeout"}, + {"server closed idle connection", "idle-connection-closed"}, + {"http2: server sent goaway", "http2-goaway"}, + {"i/o timeout", "io-timeout"}, + {"network error", "network-error"}, + {"connection error", "connection-error"}, + {"connection lost", "connection-lost"}, + {"other side closed", "connection-closed"}, + {"fetch failed", "fetch-failed"}, + {"getaddrinfo", "dns-failure"}, + {"enotfound", "dns-failure"}, + {"eai_again", "dns-failure"}, + {"upstream connect", "upstream-connect"}, + {"reset before headers", "connection-reset"}, + {"socket hang up", "socket-hangup"}, + {"socket connection was closed", "socket-closed"}, + {"stream ended before", "stream-ended"}, + {"ended without", "stream-ended"}, + {"provider_unavailable", "provider-unavailable"}, + {"provider unavailable", "provider-unavailable"}, + {"service unavailable", "provider-unavailable"}, + {"overloaded", "provider-overloaded"}, + {"rate limit", "rate-limit"}, + {"too many requests", "rate-limit"}, + {"retry after", "provider-retry-requested"}, + {"you can retry your request", "provider-retry-requested"}, + {"please retry", "provider-retry-requested"}, + {"try your request again", "provider-retry-requested"}, + } { + if strings.Contains(text, transport.needle) { + return turnRetryInfo{Class: transport.class}, true + } + } + return turnRetryInfo{}, false +} + +func permanentProviderLimit(text string) bool { + for _, phrase := range []string{ + "gousagelimiterror", "freeusagelimiterror", "monthly usage limit reached", + "available balance", "insufficient_quota", "out of budget", "quota exceeded", + "billing", + } { + if strings.Contains(text, phrase) { + return true + } + } + return false +} + +func providerErrorType(body string) string { + if body == "" { + return "" + } + type metadata struct { + ErrorType string `json:"error_type"` + } + var value struct { + Metadata metadata `json:"metadata"` + Error *struct { + Metadata metadata `json:"metadata"` + } `json:"error"` + } + if json.Unmarshal([]byte(body), &value) != nil { + return "" + } + if value.Metadata.ErrorType != "" { + return strings.ToLower(value.Metadata.ErrorType) + } + if value.Error != nil { + return strings.ToLower(value.Error.Metadata.ErrorType) + } + return "" +} + +// soloRecoveryDelay escalates 5s, 15s, 45s: long enough for a proxy or +// provider blip to clear, short against a landing reserve measured in minutes. +func soloRecoveryDelay(retry int) time.Duration { + delay := 5 * time.Second + for i := 1; i < retry; i++ { + delay *= 3 + } + return delay +} + +func soloRecoveryPrompt() string { + return "The previous turn failed and is not in context. Its completed tool effects remain " + + "in the workspace. Inspect the diff and continue the original request." +} + +func leakedToolCall(result turnResult) bool { + for _, part := range result.Parts { + if part.Type == "tool" { + return false + } + } + text := strings.ToLower(result.Text) + if !strings.Contains(text, "dsml") { + return false + } + for _, toolName := range []string{"bash", "read", "write", "edit", "grep", "glob"} { + if strings.Contains(text, toolName) { + return true + } + } + return false +} + +// soloUnsubmittedFindings is what senior-dev can say about the tree without +// asking the model. A nudge carrying facts beats a nudge carrying +// encouragement: the usual cause of a missing submit is not sloth but a model +// that believes it is finished and is wrong about one mechanical thing. +func (runner *pipeline) soloUnsubmittedFindings(baseSHA string) []string { + var findings []string + // Whether the tree differs from the base is the first thing to say, and it + // is not the same question as whether git status is clean: a run can have + // committed everything and still have changed nothing that matters. + if change, err := runner.soloTreeChange(baseSHA); err == nil { + if !change.changed { + findings = append(findings, + "the tree is byte-identical to the starting commit — "+ + "nothing has been implemented, so there is nothing to submit") + } else { + findings = append(findings, fmt.Sprintf( + "the tree differs from the starting commit in %d file(s)", change.files)) + } + } + // Only the git recorder has an index to be unclean, and only it can act on + // the advice. Under --in-place nothing commits, so the finding would send + // the model after a step it cannot take. + if git, ok := runner.recorder.(*gitRecorder); ok { + findings = append(findings, git.statusFindings()...) + } + if pinned := runner.readPinnedCommand(); pinned == "" { + findings = append(findings, + "no pinned command was recorded in .senior-dev/pinned.txt — "+ + "you have no reproducible way to show the work passes") + } else { + findings = append(findings, "your pinned command is: "+pinned) + } + if _, err := os.Stat(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil { + findings = append(findings, + "no .senior-dev/checklist.md exists — the request's own requirements were never enumerated") + } + return findings +} + +func plural(count int, singular, many string) string { + if count == 1 { + return singular + } + return many +} + +func (runner *pipeline) readPinnedCommand() string { + data, err := os.ReadFile(filepath.Join(runner.workspace, ".senior-dev", "pinned.txt")) + if err != nil { + return "" + } + return strings.TrimSpace(strings.SplitN(strings.TrimSpace(string(data)), "\n", 2)[0]) +} + +// soloFreezeWithContext captures the candidate. It runs inside the submit tool +// call, and its refusals are the cheapest place in the whole run to catch an +// empty or debris-laden patch: the same defects found after the run cost +// everything. +func (runner *pipeline) soloFreezeWithContext( + ctx context.Context, state *soloState, submission tool.Submission, +) (string, error) { + existing := state.candidate() + if existing != nil { + return "", runner.soloRefuseSubmit("already-submitted", fmt.Errorf( + "this run already submitted at %s (%s); the frozen tree is the answer and cannot be replaced", + existing.At.Format(time.RFC3339), existing.describe(), + )) + } + change, err := runner.soloTreeChange(state.baseSHA) + if err != nil { + return "", runner.soloRefuseSubmit("capture-error", + fmt.Errorf("could not capture the tree: %w", err)) + } + if !change.changed { + return "", runner.soloRefuseSubmit("empty-tree", errors.New( + "the working tree is identical to the base commit — there is nothing to submit")) + } + checklist := runner.soloChecklistState() + if !checklist.present { + return "", runner.soloRefuseSubmit("no-checklist", errors.New( + "no .senior-dev/checklist.md exists — the request's own requirements were never "+ + "enumerated, so there is nothing to have checked the work against. "+ + "Write it, verify against it, then submit again")) + } + treeSHA, patch := change.treeSHA, change.patch + commitSHA, err := runner.soloCommitTree(treeSHA, submission.Reason) + if err != nil { + return "", runner.soloRefuseSubmit("record-error", + fmt.Errorf("could not record the tree: %w", err)) + } + digest := sha256.Sum256([]byte(patch)) + candidate := frozenCandidate{ + CommitSHA: commitSHA, TreeSHA: treeSHA, + PatchBytes: len(patch), PatchFiles: change.files, + PatchSHA: fmt.Sprintf("%x", digest[:8]), At: runner.now(), + Reason: submission.Reason, Evidence: submission.Evidence, + ChecklistSatisfied: submission.ChecklistSatisfied, + } + state.freeze(candidate) + // checklist_satisfied is the model's CLAIM; checklist_items/checklist_ticked + // are what the file actually says. They are recorded side by side and never + // reconciled: models routinely claim satisfaction without ticking a box, so + // gating on the ticks would refuse most submissions. The one refusal with + // evidence behind it is no checklist at all. + runner.events.stage(frozenStage, frozenStatus, map[string]any{ + "reason": submission.Reason, "evidence": submission.Evidence, + "checklist_satisfied": submission.ChecklistSatisfied, + "checklist_items": checklist.items, + "checklist_ticked": checklist.ticked, + "patch_bytes": candidate.PatchBytes, "patch_files": candidate.PatchFiles, + "tree_sha": treeSHA, "commit_sha": commitSHA, + }) + runner.note("[senior-dev] submit: candidate frozen — " + candidate.describe() + "\n") + return candidate.describe(), nil +} + +// soloRefuseSubmit makes a submit refusal countable. A refusal that travels +// only as tool-call error text is reconstructable from the message stream by +// callID and from nothing else; a refusal the event stream cannot count cannot +// be diagnosed. +func (runner *pipeline) soloRefuseSubmit(class string, err error) error { + runner.events.stage("submit", "refused", map[string]any{ + "reason_class": class, "detail": err.Error(), + }) + return err +} + +// soloCommitTree writes a commit object for an already-written tree without +// moving HEAD, the index, or the working tree. The commit exists so the +// candidate can be restored later by a single git command even if the run dies +// between here and finalize. +func (runner *pipeline) soloCommitTree(treeSHA, reason string) (string, error) { + message := "senior-dev: submitted candidate" + if trimmed := strings.TrimSpace(reason); trimmed != "" { + message += "\n\n" + trimmed + } + commitSHA, err := runner.soloRecordTree(treeSHA, message) + if err != nil { + return "", err + } + if err := runner.recorder.Publish(soloFrozenRef, commitSHA); err != nil { + // The ref is a convenience for a restore from outside this process; losing it does not + // invalidate the freeze, which is already a durable commit object. + runner.note("[senior-dev] submit: could not update " + soloFrozenRef + ": " + err.Error() + "\n") + } + return commitSHA, nil +} + +func (runner *pipeline) soloRecordTree(treeSHA, message string) (string, error) { + return runner.recorder.Record(treeSHA, message) +} + +// soloFrozenRef makes the frozen candidate reachable from outside this process, +// so a hard kill between submit and finalize still has something to restore. +const soloFrozenRef = "refs/senior-dev/submitted" + +// soloTreeChange describes the whole working tree against the run's base. +type soloTreeChange struct { + treeSHA string + patch string + files int + changed bool +} + +// seniorDevArtifactPathspecs exclude the run artifacts senior-dev itself writes into +// the workspace -- the session database, spec.md, the checklist, the pinned +// command -- from the answer. Without the exclusion, submit would accept a +// tree whose only change is senior-dev's own bookkeeping and the run would ship +// nothing while reporting success. +var seniorDevArtifactPathspecs = []string{ + ":(exclude).senior-dev", +} + +// soloChecklistState reports what .senior-dev/checklist.md actually contains, as +// distinct from what the model says about it. Both markdown task-list forms are +// counted ("- [ ] x" and "[ ] x"), because the prompt shows the bare form and +// models usually write the dashed one. +type soloChecklist struct { + present bool + items int + ticked int +} + +var soloChecklistItem = regexp.MustCompile(`^\s*(?:[-*]\s*)?\[([ xX])\]\s`) + +func (runner *pipeline) soloChecklistState() soloChecklist { + raw, err := os.ReadFile(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")) + if err != nil { + return soloChecklist{} + } + state := soloChecklist{present: true} + for _, line := range strings.Split(string(raw), "\n") { + match := soloChecklistItem.FindStringSubmatch(line) + if match == nil { + continue + } + state.items++ + if match[1] != " " { + state.ticked++ + } + } + return state +} + +// soloTreeChange compares the workspace against the base commit's tree, +// ignoring senior-dev's own artifacts. +// +// It deliberately does not use `git diff <base>` against the working copy, +// which reports only tracked changes. A run whose whole deliverable is a new +// file -- which is most of them -- produces an empty `git diff` while having +// changed everything that matters, so diffing that way would refuse exactly +// the submissions worth accepting. currentTreeSHA stages everything through a +// temporary index, so comparing against that tree sees new files the way a +// diff of the final tree will. +func (runner *pipeline) soloTreeChange(baseSHA string) (soloTreeChange, error) { + return runner.recorder.Change(baseSHA) +} + +func nonEmptyLines(value string) []string { + var lines []string + for _, line := range strings.Split(value, "\n") { + if strings.TrimSpace(line) != "" { + lines = append(lines, line) + } + } + return lines +} diff --git a/internal/seniordev/app/solo_finalize.go b/internal/seniordev/app/solo_finalize.go new file mode 100644 index 0000000000..dfc08e183f --- /dev/null +++ b/internal/seniordev/app/solo_finalize.go @@ -0,0 +1,236 @@ +//go:build !windows + +package app + +import ( + "context" + "errors" + "time" +) + +// soloLandingReserve sizes the landing window: two fifteenths of the wall +// budget, at least 45 seconds and at most soloLandingReserveCap, but never more +// than a quarter of the run so short runs keep most of their time for work. +func soloLandingReserve(limit time.Duration) time.Duration { + if limit <= 0 { + return 0 + } + reserve := limit * 2 / 15 + if reserve < 45*time.Second { + reserve = 45 * time.Second + } + if reserve > soloLandingReserveCap { + reserve = soloLandingReserveCap + } + if maximum := limit / 4; reserve > maximum { + reserve = maximum + } + return reserve +} + +func (runner *pipeline) soloWorkContext(ctx context.Context) (context.Context, context.CancelFunc) { + if runner.budget.MaxWallMS == nil { + return context.WithCancel(ctx) + } + limit := time.Duration(*runner.budget.MaxWallMS * float64(time.Millisecond)) + deadline := runner.wallStart.Add(limit - soloLandingReserve(limit)) + if parent, ok := ctx.Deadline(); ok && !parent.After(deadline) { + return context.WithCancel(ctx) + } + return context.WithDeadlineCause(ctx, deadline, errSoloLanding) +} + +func (runner *pipeline) soloCaptureStart(state *soloState) error { + treeSHA, err := runner.currentTreeSHA() + if err != nil { + return err + } + commitSHA, err := runner.soloRecordTree(treeSHA, "senior-dev: exact starting tree") + if err != nil { + return err + } + // The starting tree is also reachable by name, so the compaction + // changed-files record (engine_compaction.go) and anything outside this + // process can diff against it without knowing the commit. + if err := runner.recorder.Publish(soloStartRef, commitSHA); err != nil { + runner.note("[senior-dev] start: could not update " + soloStartRef + ": " + err.Error() + "\n") + } + state.setStart(soloCheckpoint{ + CommitSHA: commitSHA, TreeSHA: treeSHA, Source: "starting-tree", + }) + runner.events.stage("landing", "start-captured", map[string]any{"tree_sha": treeSHA}) + return nil +} + +func (runner *pipeline) soloLandingTurn( + ctx context.Context, goal string, state *soloState, outcome *soloOutcome, +) error { + findings := runner.soloUnsubmittedFindings(state.baseSHA) + verification := runner.soloCheckUnsubmitted( + ctx, state, soloCheckTimeout, "landing", + ) + if verification != nil { + outcome.Verification = verification + findings = append(findings, soloVerificationFindings(*verification)...) + } + if runner.budgetIsExhausted() || ctx.Err() != nil { + return nil + } + outcome.LandingTurns++ + runner.events.stage("landing", "repair-turn", map[string]any{ + "timeout_ms": soloLandingTurnTimeout.Milliseconds(), + }) + landingCtx, cancel := context.WithTimeout(ctx, soloLandingTurnTimeout) + defer cancel() + _, err := runner.soloTurn(landingCtx, goal, soloLandingPrompt(findings)) + if candidate := state.candidate(); candidate != nil { + outcome.TerminalTrigger = "submitted-during-landing" + outcome.SubmissionReason = candidate.Reason + return nil + } + if err != nil && !errors.Is(err, context.DeadlineExceeded) && + !errors.Is(err, context.Canceled) { + outcome.TerminalTrigger = "landing-turn-error" + return err + } + return nil +} + +// soloCheckUnsubmitted executes the standard entrypoints itself. It never +// trusts the model's shell pipeline exit status: `cargo build | tail` reports +// success while the build fails. +func (runner *pipeline) soloCheckUnsubmitted( + ctx context.Context, + state *soloState, + maximum time.Duration, + phase string, +) *projectVerificationResult { + change, err := runner.soloTreeChange(state.baseSHA) + if err != nil || !change.changed { + return nil + } + if runner.lastVerify != nil && runner.lastVerifyTreeSHA == change.treeSHA { + remembered := *runner.lastVerify + return &remembered + } + if ctx.Err() != nil || maximum <= 0 { + return nil + } + checkCtx, cancel := context.WithTimeout(ctx, maximum) + defer cancel() + verify := runner.verifyForTest + if verify == nil { + verify = runner.runProjectVerification + } + result := verify(checkCtx) + runner.rememberVerifiedTree(result) + command, dead := verificationShowsDeadTree(result) + _, unsafe := verificationShowsSafetyRegression(result) + runner.events.stage("landing", "checked", map[string]any{ + "phase": phase, "tree_sha": change.treeSHA, + "commands": len(result.Commands), "timed_out": result.TimedOut, + "failing": countFailingEntrypoints(result), "suite_dead": dead, + "safety_regression": unsafe, + "dead_command": command, + }) + if !result.TimedOut && len(result.Commands) > 0 && !unsafe { + runner.soloCaptureCoherent(state, change.treeSHA, "coherent-checkpoint") + } + return &result +} + +// soloCaptureCoherent records a tree whose verification completed without a +// build, parse or suite-start regression, as the checkpoint an unsubmitted +// dead tree is restored to. +func (runner *pipeline) soloCaptureCoherent(state *soloState, treeSHA, source string) { + commitSHA, err := runner.soloRecordTree(treeSHA, "senior-dev: coherent "+source+" checkpoint") + if err != nil { + return + } + state.setCoherent(soloCheckpoint{CommitSHA: commitSHA, TreeSHA: treeSHA, Source: source}) +} + +func soloVerificationFindings(result projectVerificationResult) []string { + if command, dead := verificationShowsDeadTree(result); dead { + return []string{ + "independent verification proves the suite cannot start: `" + command + "`", + "the exact failure is: " + verificationFailureSummary( + result, countFailingEntrypoints(result), + ), + } + } + if result.TimedOut { + return []string{"independent verification did not complete; do not claim it passed"} + } + if result.Failed != nil { + return []string{"independent verification failed: " + verificationFailureSummary( + result, countFailingEntrypoints(result), + )} + } + if len(result.Commands) > 0 { + return []string{"independent verification passed; finish the checklist and call submit"} + } + return nil +} + +func (runner *pipeline) soloFinalizeUnsubmitted( + ctx context.Context, state *soloState, outcome *soloOutcome, +) { + if live, err := runner.currentTreeSHA(); err == nil { + outcome.LiveTree = live + } + verification := runner.soloCheckUnsubmitted( + ctx, state, soloFinalCheckTimeout, "final", + ) + if verification != nil { + outcome.Verification = verification + _, outcome.SuiteDead = verificationShowsDeadTree(*verification) + } + if outcome.SuiteDead { + // A tree whose suite cannot start is restored to the strongest + // earlier checkpoint: the latest coherent one, else the starting + // tree, else the base commit. + start, coherent := state.checkpoints() + var target *soloCheckpoint + if coherent != nil && coherent.TreeSHA != outcome.LiveTree { + target = coherent + } + if target == nil && start != nil && start.TreeSHA != outcome.LiveTree { + target = start + } + if target == nil && state.baseSHA != "" { + if tree, ok := runner.recorder.BaseTree(state.baseSHA); ok { + target = &soloCheckpoint{ + CommitSHA: state.baseSHA, TreeSHA: tree, Source: "starting-commit", + } + } + } + if target != nil { + if err := runner.soloRestoreCheckpoint(*target); err != nil { + runner.events.stage("landing", "restore-failed", map[string]any{ + "source": target.Source, "error": err.Error(), + }) + } else { + outcome.RestoreSource = target.Source + runner.events.stage("landing", "restored", map[string]any{ + "source": target.Source, "from_tree": outcome.LiveTree, + "to_tree": target.TreeSHA, + }) + } + } + } + if final, err := runner.currentTreeSHA(); err == nil { + outcome.FinalTree = final + } +} + +func (runner *pipeline) soloRestoreCheckpoint(checkpoint soloCheckpoint) error { + return runner.soloRestoreTree(checkpoint.CommitSHA, checkpoint.TreeSHA) +} + +// soloRestoreTree makes the working tree the recorded one and proves it did. +// How that is achieved is the recorder's business; both implementations +// re-identify the result rather than trusting the operation. +func (runner *pipeline) soloRestoreTree(commitSHA, wantTree string) error { + return runner.recorder.Restore(commitSHA, wantTree) +} diff --git a/internal/seniordev/app/solo_prompt.go b/internal/seniordev/app/solo_prompt.go new file mode 100644 index 0000000000..3b200e4057 --- /dev/null +++ b/internal/seniordev/app/solo_prompt.go @@ -0,0 +1,164 @@ +//go:build !windows + +package app + +import ( + "fmt" + "strings" +) + +// These strings are the run instruction: the first user message of a solo run, +// and the bounded continuations sent when a turn ends without a submission. +// +// They carry mechanics only -- the files the run uses, what senior-dev does on +// its own, and what ends the run. They do not tell the model when to edit, how +// much to explore, or how fast to move; those are its decisions, and a sentence +// spent on them is a sentence competing with the repository it is about to +// read. Keep every claim here true of this binary: a prompt that describes +// behaviour the code does not have is worse than a prompt that omits it. + +// soloSystemPreamble frames the run instruction and nothing else. The detail is +// in the sections, and a long preamble is what a model under context pressure +// drops first. +const soloSystemPreamble = `You are implementing one change in this repository, by yourself, in one context. + +What follows is how this run works: the files it uses, what senior-dev does, +and what ends it.` + +// soloIntakeSection names the specification file. Intake writes .senior-dev/spec.md +// verbatim and compaction re-pins it from disk, so it is the one copy of the +// request that outlives the conversation. +const soloIntakeSection = `## The specification + +.senior-dev/spec.md holds the request verbatim. It is the specification, and it is +re-pinned from that file whenever this context is compacted.` + +// soloExploreSection names the pinned-command file. readPinnedCommand takes the +// first line of .senior-dev/pinned.txt and quotes it back in the nudge findings and +// in a later run's header, so the file has a reader even when the model forgets +// what it wrote there. +const soloExploreSection = `## The verification command + +Write the build or test command you verify with to .senior-dev/pinned.txt, on one +line. senior-dev reads that first line and quotes it back to you if this run +needs a continuation.` + +// soloImplementSection states what the workspace is and what leaves it. The +// exclusion list is seniorDevArtifactPathspecs: if the two disagree, submit +// accepts a tree whose only content is senior-dev's own bookkeeping. +const soloImplementSection = `## The workspace + +The workspace is a git repository. Your tools are the ones declared with this +turn: a shell, file reading, editing, search, web access, and submit. + +.senior-dev/ and git-ignored paths are excluded from the answer. Everything else in +the working tree, committed or not, is part of what you submit. + +Only the workspace is handed back. The file tools refuse to write outside it, and +anything a shell command changes outside it is lost: do the work here.` + +// soloConformanceSection names the checklist file. soloFreeze refuses a +// submission when it is missing and records its item and tick counts when it is +// present, and soloChecklistItem matches both "[ ] x" and "- [ ] x". +const soloConformanceSection = `## The checklist + +Write .senior-dev/checklist.md: one line per thing the request requires, each +starting "[ ] ", ticked to "[x]" when the code satisfies it. submit refuses if +this file does not exist, and records its item and tick counts.` + +// soloSubmitSection is the completion protocol. It has to agree with the system +// prompt that the run ends by calling submit and by nothing else: when the two +// disagreed, the model followed the system prompt. +const soloSubmitSection = `## Ending the run + +The run ends when you call the submit tool. Nothing else ends it: no status +tag, no report, no summary. + +submit takes a reason, the evidence you verified with, and checklist_satisfied. +It refuses, naming the cause, when the tree is unchanged from the starting +commit, when .senior-dev/checklist.md does not exist, when reason or evidence is +empty, or when this run already submitted. A refusal does not end the run. + +An accepted submit freezes the tree at that instant. senior-dev then runs this +project's own build and test entrypoints itself and records what they report; +that cannot change what ships, and neither can anything you edit afterwards — a +tree that moves after a submission is reverted to the frozen one. + +A run that never submits is recorded as unsubmitted and leaves behind the tree +as it stands, except that a tree whose suite cannot start is restored to an +earlier one.` + +// buildSoloPrompt assembles the run instruction. The request is repeated at the +// top verbatim: it travels through no paraphrase on its way to the model. +func buildSoloPrompt(goal string, pinned string, checklistPath string) string { + sections := []string{ + soloSystemPreamble, + soloIntakeSection, + soloExploreSection, + soloImplementSection, + soloConformanceSection, + soloSubmitSection, + } + body := strings.Join(sections, "\n\n") + header := "# The request\n\n" + strings.TrimSpace(goal) + + "\n\n(The same text is in .senior-dev/spec.md, which is the specification.)\n\n" + if strings.TrimSpace(pinned) != "" { + header += fmt.Sprintf( + ".senior-dev/pinned.txt already contains: %s\n\n", strings.TrimSpace(pinned), + ) + } + if checklistPath != "" { + header += "Write your checklist to " + checklistPath + ".\n\n" + } + return header + "# How this run works\n\n" + body +} + +// soloNudge is the bounded continuation for a run that stopped talking without +// submitting. It carries the facts senior-dev can see for itself rather than +// encouragement, and says what an unsubmitted ending actually does. +func soloNudge(attempt int, findings []string) string { + return "You stopped without calling submit, so no submission has been captured.\n\n" + + soloUnsubmittedBody(findings, attempt >= soloMaxNudges) +} + +// soloLandingPrompt is the one bounded turn offered after the open work window +// closes or a turn dies. No model turn follows it, and it is reached without +// the model having stopped, so it opens on its own terms rather than soloNudge's. +func soloLandingPrompt(findings []string) string { + return "This is the last turn of this run, and it is time-bounded. " + + "No submission has been captured yet.\n\n" + + soloUnsubmittedBody(findings, true) +} + +// soloUnsubmittedBody is what every continuation says: the facts senior-dev +// observed, the one ending there is, and what an unsubmitted run actually leaves +// behind (which is the live tree, not nothing). +func soloUnsubmittedBody(findings []string, last bool) string { + var builder strings.Builder + if len(findings) > 0 { + builder.WriteString("senior-dev checked the tree itself and found:\n\n") + for _, finding := range findings { + builder.WriteString(" - " + finding + "\n") + } + builder.WriteString("\n") + } + builder.WriteString( + "The run ends when you call submit and by nothing else. A run that never " + + "calls it is recorded as unsubmitted and leaves behind the tree as it stands.", + ) + if last { + builder.WriteString( + "\n\nThis is the last prompt you will get. senior-dev then checks that " + + "final tree itself, and restores an earlier tree only if the suite cannot start.", + ) + } + return builder.String() +} + +// soloToolLeakPrompt answers a response that carried provider markup as text +// instead of executing it. leakedToolCall detects it, and the correction is +// capped at soloMaxToolLeakCorrections. +func soloToolLeakPrompt() string { + return "Your last response contained DSML tool-call markup as plain text, so no tool ran. " + + "Make the intended call as a real tool call; this correction is offered at most twice." +} diff --git a/internal/seniordev/app/solo_ship.go b/internal/seniordev/app/solo_ship.go new file mode 100644 index 0000000000..140ed9349c --- /dev/null +++ b/internal/seniordev/app/solo_ship.go @@ -0,0 +1,237 @@ +//go:build !windows + +package app + +import ( + "context" + "fmt" +) + +// soloShip is stage 4's tail: bounded verification, then one decision about +// what submitted or unsubmitted tree the run leaves behind. +// +// The rule this file exists to enforce is that the run never ships a tree +// worse than the one it submitted. The comparison is against the frozen +// candidate, not against liveness. +func (runner *pipeline) soloShip( + ctx context.Context, state *soloState, outcome *soloOutcome, converseErr error, +) { + candidate := state.candidate() + if candidate == nil { + // No model-declared candidate exists, so independently check the exact + // live tree. Ordinary test failures and incomplete observations keep the + // benefit of the doubt. A build/parse regression restores the strongest + // earlier green or coherent checkpoint available. + outcome.Status = "unsubmitted" + runner.soloFinalizeUnsubmitted(ctx, state, outcome) + reason := "no submission: the run stopped without calling submit" + if converseErr != nil { + reason += " (" + converseErr.Error() + ")" + } + if outcome.RestoreSource != "" { + reason += "; the live tree's suite could not start and it was restored from " + outcome.RestoreSource + } + runner.soloTerminal(outcome, reason) + return + } + outcome.Frozen = candidate + + // Verification needs a live context and time to run. When the run is out of + // wall budget or its context is already cancelled, there is neither: the + // only honest thing left is to restore the frozen candidate and say that + // nothing checked it. Attempting it anyway would record an instantly-failed + // build as evidence against the candidate, which would be a false red. + if reason, blocked := runner.verificationUnaffordable(ctx); blocked { + outcome.Status = "pass-unverified" + runner.soloTerminal(outcome, fmt.Sprintf( + "%s; shipping the submitted candidate, which nothing checked: %s", + reason, candidate.describe(), + )) + runner.soloRestoreIfDiverged(state, outcome) + return + } + + // The candidate is already captured, so verification cannot change what + // ships -- only what the run says about it. That is the whole point of + // doing it after the freeze rather than before. + verification := runner.runProjectVerification(ctx) + outcome.Verification = &verification + failing := countFailingEntrypoints(verification) + + switch { + case verification.TimedOut && ctx.Err() != nil: + // The run was stopped while the check ran. What ships is the frozen + // candidate, and what the run can truthfully say is that it submitted + // and nothing finished checking it. + outcome.Status = "pass-unverified" + runner.soloTerminal(outcome, fmt.Sprintf( + "the run was stopped while the project's build and tests ran; "+ + "shipping the submitted candidate, which nothing finished checking: %s", + candidate.describe(), + )) + case verification.TimedOut: + // A hung entrypoint is an incomplete observation, not a verdict. The + // candidate stands. + outcome.Status = "pass-unverified" + runner.soloTerminal(outcome, fmt.Sprintf( + "verification did not complete (an entrypoint hung); shipping the submitted candidate: %s", + candidate.describe(), + )) + case verification.Failed == nil: + // Failed, not the failing-command count, is the verdict. An expected + // build or test entrypoint that could not be DISCOVERED sets Failed + // while recording no command at all, so counting commands would call a + // project whose suite was never found -- the vacuous-green shape -- a + // verified pass. + outcome.Status = "pass" + runner.soloTerminal(outcome, fmt.Sprintf( + "submitted, and its build and tests passed: %s (%s)", candidate.describe(), candidate.Reason, + )) + default: + // The candidate does not verify. It is still what ships: it is the only + // tree this run ever declared finished, and there is no better one -- + // the alternative is the unverified live tree, which by construction is + // the same tree. What changes is the honesty of the terminal. + outcome.Status = "fail" + runner.soloTerminal(outcome, fmt.Sprintf( + "submitted candidate failed verification (%s); "+ + "shipping it anyway as the run's own answer: %s", + verificationFailureSummary(verification, failing), candidate.describe(), + )) + } + runner.soloRestoreIfDiverged(state, outcome) +} + +// verificationUnaffordable reports whether post-submit verification can still +// be run at all, and why not. Both conditions are ordinary endings rather than +// faults: a run is expected to use its whole budget, and the context is +// cancelled when the wall deadline passes. +func (runner *pipeline) verificationUnaffordable(ctx context.Context) (string, bool) { + if err := ctx.Err(); err != nil { + return "the run's context ended before verification could start", true + } + if exhausted, reason := runner.budgetExhausted(); exhausted { + if reason == "" { + reason = "the run budget was exhausted" + } + return reason, true + } + return "", false +} + +// soloRestoreIfDiverged puts the frozen candidate back if anything moved the +// tree after submission. Nothing in the pipeline should -- the submit tool +// tells the model to stop, and no stage after it edits -- but "should not" is +// not a guarantee, and the check is two git commands. +func (runner *pipeline) soloRestoreIfDiverged(state *soloState, outcome *soloOutcome) { + candidate := state.candidate() + if candidate == nil { + return + } + current, err := runner.currentTreeSHA() + if err != nil { + runner.note("[senior-dev] ship: could not compare the tree to the frozen candidate: " + + err.Error() + "\n") + return + } + if current == candidate.TreeSHA { + runner.events.stage("ship", "unchanged", map[string]any{ + "tree_sha": current, "reason": "tree is identical to the frozen candidate", + }) + return + } + // Diverged. Restoring is unconditional: post-submission edits are not part + // of the answer by definition, whether they look like improvements or not. + // soloRestoreTree rather than a bare checkout: a file ADDED after submit is + // tracked by eager-commit and would survive an overlay checkout, shipping a + // tree that silently differs from the frozen candidate it claims to be. + if err := runner.soloRestoreTree(candidate.CommitSHA, candidate.TreeSHA); err != nil { + runner.events.stage("ship", "restore-failed", map[string]any{ + "error": err.Error(), "commit_sha": candidate.CommitSHA, + }) + runner.note("[senior-dev] ship: RESTORE FAILED, shipping the diverged tree: " + err.Error() + "\n") + return + } + runner.events.stage("ship", "restored", map[string]any{ + "from_tree": current, "to_tree": candidate.TreeSHA, + "commit_sha": candidate.CommitSHA, + "reason": "the tree changed after submission; the frozen candidate is the answer", + }) + runner.note(fmt.Sprintf( + "[senior-dev] ship: tree changed after submission (%s != %s) — restored the frozen candidate\n", + shortSHA(current), shortSHA(candidate.TreeSHA), + )) + // outcome.Status is deliberately untouched: the verdict was about the + // candidate, and the candidate is what is now on disk again. +} + +// soloTerminal records the reason the run ended and the evidence behind it. +// "Why did it exit?" must be answerable from the event stream without a log. +func (runner *pipeline) soloTerminal(outcome *soloOutcome, reason string) { + data := map[string]any{ + "status": outcome.Status, "reason": reason, + "submitted": outcome.Frozen != nil, "nudges": outcome.Nudges, + "landing_turns": outcome.LandingTurns, "terminal_trigger": outcome.TerminalTrigger, + } + if outcome.LiveTree != "" { + data["live_tree"] = outcome.LiveTree + } + if outcome.FinalTree != "" { + data["final_tree"] = outcome.FinalTree + } + if outcome.RestoreSource != "" { + data["restore_source"] = outcome.RestoreSource + } + if outcome.SuiteDead { + data["suite_dead"] = true + } + if candidate := outcome.Frozen; candidate != nil { + data["submission_reason"] = candidate.Reason + data["submission_evidence"] = candidate.Evidence + data["checklist_satisfied"] = candidate.ChecklistSatisfied + data["patch_bytes"] = candidate.PatchBytes + data["patch_files"] = candidate.PatchFiles + data["frozen_tree"] = candidate.TreeSHA + data["frozen_commit"] = candidate.CommitSHA + } + if verification := outcome.Verification; verification != nil { + failing := countFailingEntrypoints(*verification) + data["verification_failing"] = failing + data["verification_timed_out"] = verification.TimedOut + data["verification_commands"] = len(verification.Commands) + // Why the check failed, when it did, in the same words the run's own + // reason uses. A failure with no failing command (an expected build or + // test entrypoint nobody could find) is otherwise indistinguishable + // from a pass in the counts alone. + if verification.Failed != nil { + data["verification_failure"] = verificationFailureSummary(*verification, failing) + } + } + // Deliberately NOT emitted here. There is exactly one terminal event per + // run and the CLI layer emits it (persistTerminalResult), because that is + // the one place reached by every ending including a crash before ship. This + // hands it the payload; emitting a second "terminal" from here would produce + // two events with one name. + outcome.TerminalData = data + outcome.TerminalReason = reason + runner.note("[senior-dev] terminal: " + reason + "\n") +} + +// missingEntrypointFailure reports whether verification failed because an +// expected build or test entrypoint could not be discovered at all, rather +// than because a command it ran came back non-zero. recordMissingEntrypoint +// stamps that source string; it is the only producer of it. +func missingEntrypointFailure(result projectVerificationResult) bool { + return result.Failed != nil && + result.Failed.Source == "manifest/CI/documentation discovery" +} + +func verificationFailureSummary(result projectVerificationResult, failing int) string { + if missingEntrypointFailure(result) { + return "no " + string(result.Failed.Kind) + " entrypoint could be discovered" + } + if failing == 1 { + return "1 failing entrypoint" + } + return fmt.Sprintf("%d failing entrypoints", failing) +} diff --git a/internal/seniordev/app/solo_test.go b/internal/seniordev/app/solo_test.go new file mode 100644 index 0000000000..3abea22c98 --- /dev/null +++ b/internal/seniordev/app/solo_test.go @@ -0,0 +1,978 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification" + "github.com/Agent-Field/codeaf/internal/seniordev/tool" +) + +// soloEvents decodes the NDJSON the pipeline emitted so a test can assert on +// what the artifact will actually contain, rather than on internal state a +// reader of the run will never see. +func soloEvents(t *testing.T, raw *bytes.Buffer) []map[string]any { + t.Helper() + var events []map[string]any + for _, line := range strings.Split(strings.TrimSpace(raw.String()), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var decoded map[string]any + if err := json.Unmarshal([]byte(line), &decoded); err != nil { + continue + } + events = append(events, decoded) + } + return events +} + +func soloStageEvents(t *testing.T, raw *bytes.Buffer, stage string) []map[string]any { + t.Helper() + var matched []map[string]any + for _, event := range soloEvents(t, raw) { + if name, _ := event["stage"].(string); name != stage { + continue + } + // The stage name and status live on the envelope; everything the test + // asserts about lives in data. Flatten so a test reads one map. + flattened := map[string]any{"status": event["status"]} + if data, ok := event["data"].(map[string]any); ok { + for key, value := range data { + flattened[key] = value + } + } + matched = append(matched, flattened) + } + return matched +} + +// soloPipeline builds a pipeline over a real git workspace with a stub backend, +// plus the submit freezer wired exactly as runSolo wires it. +func soloPipeline(t *testing.T) (*pipeline, *soloState, string, *bytes.Buffer) { + t.Helper() + workspace, base := guardWorkspace(t) + var events bytes.Buffer + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(&events), Notes: io.Discard, + Sleep: func(context.Context, time.Duration) error { return nil }, + }) + t.Cleanup(runner.runtime.Close) + // A run that reaches submit has written one; tests of submit start there. + // TestSubmitRefusedWithoutAChecklist removes it to exercise the gate. + if err := writeFile(filepath.Join(workspace, ".senior-dev", "checklist.md"), + "- [ ] the thing the request asked for\n"); err != nil { + t.Fatal(err) + } + state := &soloState{baseSHA: base} + runner.runtime.registry.SetSubmitFreezer( + func(_ context.Context, submission tool.Submission) (string, error) { + return runner.soloFreezeWithContext(context.Background(), state, submission) + }, + ) + return runner, state, base, &events +} + +func soloSubmission(reason string) tool.Submission { + return tool.Submission{ + Reason: reason, Evidence: "make test: exit 0, 41 passed", + ChecklistSatisfied: true, SessionID: "ses_solo", + } +} + +func TestSoloIntakeWritesTheRequestVerbatim(t *testing.T) { + // The spec reaches every later stage as a file, never as a paraphrase. A + // restated request drops the exact identifiers the original names, and the + // run then ships code that does the right thing under names the request + // never used. + runner, _, _, events := soloPipeline(t) + goal := "Add `expandShorthand(property, value)` to lib/shorthand.js.\n" + + "It MUST be named exactly that. Trailing spaces matter: \n\ttabs too." + if err := runner.soloIntake(goal); err != nil { + t.Fatal(err) + } + written, err := os.ReadFile(filepath.Join(runner.workspace, ".senior-dev", "spec.md")) + if err != nil { + t.Fatal(err) + } + if string(written) != goal { + t.Fatalf("spec.md was not byte-identical to the request:\n got %q\nwant %q", written, goal) + } + captured := soloStageEvents(t, events, "intake") + if len(captured) != 1 || captured[0]["status"] != "captured" { + t.Fatalf("intake events = %#v", captured) + } +} + +func TestSubmitFreezesTheTreeAndRefusesASecondSubmission(t *testing.T) { + // One submission per run. A second one is not a mistake to absorb quietly: + // the model is telling us it thinks it can still change the answer, and it + // needs to be told plainly that it cannot. + runner, state, _, events := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + + description, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("feature implemented")) + if err != nil { + t.Fatalf("first submit refused: %v", err) + } + if !strings.Contains(description, "file") { + t.Fatalf("freeze description = %q", description) + } + candidate := state.candidate() + if candidate == nil || candidate.TreeSHA == "" || candidate.CommitSHA == "" { + t.Fatalf("candidate = %#v", candidate) + } + if candidate.Reason != "feature implemented" || !candidate.ChecklistSatisfied { + t.Fatalf("the model's claim was not recorded verbatim: %#v", candidate) + } + + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("actually, this version")); err == nil { + t.Fatal("a second submission was accepted") + } else if !strings.Contains(err.Error(), "cannot be replaced") { + t.Fatalf("second-submit refusal = %v", err) + } + if state.candidate().Reason != "feature implemented" { + t.Fatal("the second submission overwrote the frozen candidate") + } + + var frozen []map[string]any + for _, event := range soloStageEvents(t, events, "submit") { + if event["status"] == "frozen" { + frozen = append(frozen, event) + } + } + if len(frozen) != 1 { + t.Fatalf("frozen events = %d, want exactly 1", len(frozen)) + } + if frozen[0]["evidence"] != "make test: exit 0, 41 passed" { + t.Fatalf("submit event lost the evidence: %#v", frozen[0]) + } +} + +func TestSubmitOnAnUnchangedTreeIsRefused(t *testing.T) { + // A patch containing only probe scripts and a patch containing only + // documentation are the same failure -- declaring done on a tree that + // implements nothing -- and submit is the cheapest place in the run to + // catch it. + runner, state, _, _ := soloPipeline(t) + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err == nil { + t.Fatal("submitting an unchanged tree was accepted") + } else if !strings.Contains(err.Error(), "identical to the base commit") { + t.Fatalf("refusal = %v", err) + } + if state.candidate() != nil { + t.Fatal("a refused submit still froze something") + } +} + +func TestSubmitIgnoresSeniorDevsOwnArtifactsWhenDecidingSomethingChanged(t *testing.T) { + // senior-dev writes .senior-dev/ into the workspace it works in: the session + // database, spec.md, the pinned command. None of that is part of the answer, + // so a tree whose only content is senior-dev's own bookkeeping is an empty + // patch. + // + // Deciding "did anything change" from the raw tree would therefore accept + // exactly the submissions worth refusing: the run reports success, and + // ships nothing. + runner, state, _, _ := soloPipeline(t) + if err := runner.soloIntake("write a feature"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(runner.workspace, ".senior-dev", "pinned.txt"), []byte("make test\n"), 0o644, + ); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err == nil { + t.Fatal("a tree containing only senior-dev's own artifacts was accepted as a submission") + } else if !strings.Contains(err.Error(), "identical to the base commit") { + t.Fatalf("refusal = %v", err) + } + + // One real file makes it a real submission, and it is the only one counted. + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil { + t.Fatalf("a genuine one-file submission was refused: %v", err) + } + if files := state.candidate().PatchFiles; files != 1 { + t.Fatalf("PatchFiles = %d, want 1 — senior-dev's own artifacts are being counted", files) + } +} + +func TestShipRestoresTheFrozenCandidateWhenTheTreeMovesAfterSubmission(t *testing.T) { + // Nothing in the pipeline edits after submit, but "nothing should" is not a + // guarantee, and a run that keeps editing after submitting can leave a tree + // whose build no longer passes. Post-submission edits are not part of the + // answer whether they look like improvements or not. + runner, state, _, events := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "the submitted version\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("implemented")); err != nil { + t.Fatal(err) + } + frozenTree := state.candidate().TreeSHA + + // Something touches the tree after the freeze. + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "a later, unblessed edit\n"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "debris.tmp"), "scratch\n"); err != nil { + t.Fatal(err) + } + + outcome := &soloOutcome{Status: "pass", Frozen: state.candidate()} + runner.soloRestoreIfDiverged(state, outcome) + + content, err := os.ReadFile(filepath.Join(runner.workspace, "feature.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "the submitted version\n" { + t.Fatalf("shipped file = %q, want the submitted version", content) + } + restored := soloStageEvents(t, events, "ship") + if len(restored) != 1 || restored[0]["status"] != "restored" { + t.Fatalf("ship events = %#v", restored) + } + if restored[0]["to_tree"] != frozenTree { + t.Fatalf("restored to %v, want the frozen tree %v", restored[0]["to_tree"], frozenTree) + } + if reason, _ := restored[0]["reason"].(string); !strings.Contains(reason, "after submission") { + t.Fatalf("restore event does not say why: %#v", restored[0]) + } +} + +func TestShipLeavesAnUntouchedTreeAlone(t *testing.T) { + // The complement: when nothing moved, ship must not run a checkout at all. + // A restore that fires on every run is a restore nobody will believe when + // it matters. + runner, state, _, events := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("implemented")); err != nil { + t.Fatal(err) + } + runner.soloRestoreIfDiverged(state, &soloOutcome{Status: "pass", Frozen: state.candidate()}) + shipped := soloStageEvents(t, events, "ship") + if len(shipped) != 1 || shipped[0]["status"] != "unchanged" { + t.Fatalf("ship events = %#v, want a single unchanged", shipped) + } +} + +func TestTerminalEventIsEmittedOnceAndSaysWhyTheRunEnded(t *testing.T) { + // "Why did it exit?" is a question the event stream has to answer without + // a log. A decision that reports only through a log line is lost with the + // log. + runner, state, _, events := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("auto-toc rule implemented and green")); err != nil { + t.Fatal(err) + } + outcome := &soloOutcome{Status: "pass", Frozen: state.candidate(), Nudges: 1} + runner.soloTerminal(outcome, "submitted and verified") + + // Asserted on the payload that reaches the run's terminal event + // (type=="terminal"), not on a stage event named "terminal": a terminal + // that carries nothing but a cost cannot answer "did it submit?". + _ = events + data := outcome.TerminalData + if data == nil { + t.Fatal("no terminal payload was recorded") + } + for _, key := range []string{ + "reason", "submission_reason", "submission_evidence", + "checklist_satisfied", "patch_bytes", "frozen_tree", "nudges", + } { + if _, ok := data[key]; !ok { + t.Fatalf("terminal payload is missing %q: %#v", key, data) + } + } + if data["status"] != "pass" || data["submitted"] != true { + t.Fatalf("terminal payload = %#v", data) + } +} + +func TestAnUnsubmittedRunSaysSoRatherThanClaimingAnAttempt(t *testing.T) { + // A run that never submitted did not finish. Reporting it as anything else + // turns "done" into "whatever the tree looked like when the budget + // expired". + runner, state, _, events := soloPipeline(t) + outcome := &soloOutcome{Status: "fail"} + runner.soloShip(context.Background(), state, outcome, nil) + + if outcome.Status != "unsubmitted" { + t.Fatalf("status = %q, want unsubmitted", outcome.Status) + } + _ = events + data := outcome.TerminalData + if data == nil || data["submitted"] != false { + t.Fatalf("terminal payload = %#v", data) + } + if reason, _ := data["reason"].(string); !strings.Contains(reason, "without calling submit") { + t.Fatalf("terminal reason = %q", reason) + } +} + +func soloTestVerification(exit int, dead bool) projectVerificationResult { + entrypoint := fullverification.Entrypoint{ + Kind: fullverification.KindBuild, Command: "cargo build", Source: "Cargo.toml", + } + command := map[string]any{ + "cmd": entrypoint.Command, "exit": float64(exit), + "tail": "error: could not compile `widget`", + } + if dead { + command["suite_dead"] = true + } + result := projectVerificationResult{Commands: []any{command}} + if exit != 0 { + result.Failed = &entrypoint + result.Failure = "cargo build failed" + } + return result +} + +func TestUnsubmittedDeadTreeRestoresExactStartingTree(t *testing.T) { + runner, state, _, _ := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "preexisting.txt"), "keep me\n"); err != nil { + t.Fatal(err) + } + if err := runner.soloCaptureStart(state); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "broken.rs"), "does not compile\n"); err != nil { + t.Fatal(err) + } + runner.verifyForTest = func(context.Context) projectVerificationResult { + return soloTestVerification(101, true) + } + outcome := &soloOutcome{Status: "fail", TerminalTrigger: "nudge-cap"} + runner.soloShip(context.Background(), state, outcome, nil) + + if outcome.RestoreSource != "starting-tree" || !outcome.SuiteDead { + t.Fatalf("finalization = %#v", outcome) + } + if _, err := os.Stat(filepath.Join(runner.workspace, "broken.rs")); !os.IsNotExist(err) { + t.Fatalf("suite-dead file survived restore: %v", err) + } + kept, err := os.ReadFile(filepath.Join(runner.workspace, "preexisting.txt")) + if err != nil || string(kept) != "keep me\n" { + t.Fatalf("starting untracked file was not restored exactly: %q, %v", kept, err) + } +} + +func TestUnsubmittedOrdinaryFailureKeepsLiveTree(t *testing.T) { + runner, state, _, _ := soloPipeline(t) + if err := runner.soloCaptureStart(state); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "solution.js"), "working solution\n"); err != nil { + t.Fatal(err) + } + runner.verifyForTest = func(context.Context) projectVerificationResult { + result := soloTestVerification(1, false) + result.Commands[0].(map[string]any)["tail"] = "1 failed; 126 passed" + return result + } + outcome := &soloOutcome{Status: "fail", TerminalTrigger: "landing-window"} + runner.soloShip(context.Background(), state, outcome, nil) + + if outcome.RestoreSource != "" || outcome.SuiteDead { + t.Fatalf("ordinary failure triggered rollback: %#v", outcome) + } + if got, err := os.ReadFile(filepath.Join(runner.workspace, "solution.js")); err != nil || string(got) != "working solution\n" { + t.Fatalf("live solution was not preserved: %q, %v", got, err) + } +} + +func TestUnsubmittedDeadTreeRestoresLatestCoherentCheckpoint(t *testing.T) { + runner, state, _, _ := soloPipeline(t) + if err := runner.soloCaptureStart(state); err != nil { + t.Fatal(err) + } + feature := filepath.Join(runner.workspace, "feature.rs") + if err := writeFile(feature, "partial but coherent\n"); err != nil { + t.Fatal(err) + } + runner.verifyForTest = func(context.Context) projectVerificationResult { + return soloTestVerification(1, false) + } + if result := runner.soloCheckUnsubmitted( + context.Background(), state, time.Second, "nudge", + ); result == nil { + t.Fatal("coherent checkpoint verification did not run") + } + if err := writeFile(feature, "mid-edit and suite-dead\n"); err != nil { + t.Fatal(err) + } + runner.verifyForTest = func(context.Context) projectVerificationResult { + return soloTestVerification(101, true) + } + outcome := &soloOutcome{Status: "fail", TerminalTrigger: "nudge-cap"} + runner.soloShip(context.Background(), state, outcome, nil) + + if outcome.RestoreSource != "coherent-checkpoint" { + t.Fatalf("restore source = %q, want coherent checkpoint", outcome.RestoreSource) + } + if got, err := os.ReadFile(feature); err != nil || string(got) != "partial but coherent\n" { + t.Fatalf("coherent work was not restored: %q, %v", got, err) + } +} + +func TestSoloLandingReserveScalesWithoutConsumingShortRuns(t *testing.T) { + cases := map[time.Duration]time.Duration{ + 90 * time.Minute: 12 * time.Minute, + 7 * time.Minute: 56 * time.Second, + 1 * time.Minute: 15 * time.Second, + } + for limit, want := range cases { + if got := soloLandingReserve(limit); got != want { + t.Errorf("reserve(%s) = %s, want %s", limit, got, want) + } + } +} + +func TestUnsubmittedFindingsCarryFactsNotEncouragement(t *testing.T) { + // The nudge exists to correct a specific mechanical belief, so it has to + // carry what senior-dev can see for itself. On a clean tree the first + // thing it must say is that there is no change at all -- the failure mode + // where a run believes it implemented something it never wrote. + runner, _, base, _ := soloPipeline(t) + // The finding under test is about a MISSING checklist, so remove the one + // soloPipeline provides. + if err := os.Remove(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil { + t.Fatal(err) + } + findings := runner.soloUnsubmittedFindings(base) + joined := strings.Join(findings, "\n") + if !strings.Contains(joined, "nothing has been implemented") { + t.Fatalf("findings on an empty tree = %#v", findings) + } + if !strings.Contains(joined, "no pinned command") { + t.Fatalf("findings do not mention the missing pinned command: %#v", findings) + } + if !strings.Contains(joined, "checklist.md") { + t.Fatalf("findings do not mention the missing checklist: %#v", findings) + } + + if err := os.WriteFile( + filepath.Join(runner.workspace, ".senior-dev", "pinned.txt"), + []byte("pnpm exec jest auto-toc\n"), 0o644, + ); err != nil { + t.Fatal(err) + } + if pinned := runner.readPinnedCommand(); pinned != "pnpm exec jest auto-toc" { + t.Fatalf("pinned command = %q", pinned) + } +} + +func TestSoloPromptCarriesTheMechanicsSeniorDevReads(t *testing.T) { + // The run instruction carries mechanics only: the three files senior-dev + // reads or refuses over, and the one way the run ends. A silent edit that + // drops one of them breaks a code path no build catches -- submit refuses + // without the checklist, and readPinnedCommand has no other writer. + prompt := buildSoloPrompt("Add expandShorthand to lib/shorthand.js", "", ".senior-dev/checklist.md") + for _, mechanic := range []string{ + "Add expandShorthand to lib/shorthand.js", // the request, verbatim and first + ".senior-dev/spec.md", // the spec is a file, not a memory + ".senior-dev/pinned.txt", // readPinnedCommand's only writer + ".senior-dev/checklist.md", // soloFreeze refuses without it + "[ ] ", // the form soloChecklistItem counts + "submit", // the run's only ending + "Nothing else ends it", // and it is the only one + "refuses", // a refusal is not the end of the run + } { + if !strings.Contains(prompt, mechanic) { + t.Errorf("solo prompt no longer contains %q", mechanic) + } + } + if strings.Contains(prompt, "acceptance contract") || + strings.Contains(prompt, "contract.json") { + t.Error("the prompt names an acceptance contract, which nothing in the run reads") + } + // The prompt states mechanics, not history and not pacing: no anecdote + // from a past run, no fraction of wall to aim for, and no instruction about + // when to start editing. + for _, regression := range []string{ + "Finishing early", "winning", "A run that shipped", + "A run that implemented", "read-only", "Do not skip ahead", + } { + if strings.Contains(prompt, regression) { + t.Errorf("solo prompt carries history or pacing advice again: %q", regression) + } + } +} + +func TestNudgeEscalatesOnTheLastAttempt(t *testing.T) { + // The bound has to be visible to the model. A nudge loop the model cannot + // see the end of is one it can keep deferring. + early := soloNudge(1, []string{"git status is clean"}) + last := soloNudge(soloMaxNudges, []string{"git status is clean"}) + if strings.Contains(early, "last prompt") { + t.Error("the first nudge already claims to be the last") + } + if !strings.Contains(last, "last prompt") { + t.Error("the final nudge does not say it is final") + } + if !strings.Contains(early, "git status is clean") { + t.Error("the nudge dropped the findings") + } + // What an ignored nudge actually does, stated as solo_ship.go does it: the + // run is recorded unsubmitted and the live tree is what it leaves behind. + // It is NOT true that such a run ships nothing. + if !strings.Contains(early, "unsubmitted") { + t.Error("the nudge does not say what happens if it is ignored") + } + if strings.Contains(early, "ships nothing") { + t.Error("the nudge claims an unsubmitted run ships nothing, which it does not") + } +} + +// TestSubmitRefusedWithoutAChecklist pins the one checklist refusal there is. +// A run that writes no checklist at all has nothing to check its work against +// and has not finished; such runs ship mid-edit trees that break pre-existing +// tests. +func TestSubmitRefusedWithoutAChecklist(t *testing.T) { + runner, state, _, _ := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil { + t.Fatal(err) + } + _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")) + if err == nil { + t.Fatal("submit was accepted with no checklist") + } + if !strings.Contains(err.Error(), "checklist.md") { + t.Fatalf("refusal = %q, want it to name the missing checklist", err) + } + if state.candidate() != nil { + t.Fatal("a refused submission froze a candidate") + } +} + +// TestSubmitCountsTicksButDoesNotGateOnThem is the other half. Models +// routinely claim checklist_satisfied without ticking a box, so gating on +// ticks would refuse most submissions, verified passes included. The ticks are +// COUNTED and recorded next to the model's claim, and the gap between them is +// left visible rather than resolved into a refusal. +func TestSubmitCountsTicksButDoesNotGateOnThem(t *testing.T) { + runner, state, _, events := soloPipeline(t) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + checklist := "# Checklist\n\n- [ ] one\n- [x] two\n[ ] three\nnot an item\n" + if err := writeFile(filepath.Join(runner.workspace, ".senior-dev", "checklist.md"), checklist); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil { + t.Fatalf("submit refused despite a present checklist: %v", err) + } + submitted := soloStageEvents(t, events, "submit") + if len(submitted) != 1 { + t.Fatalf("submit events = %d, want 1", len(submitted)) + } + if got := submitted[0]["checklist_items"]; got != float64(3) && got != 3 { + t.Fatalf("checklist_items = %v (%T), want 3", got, got) + } + if got := submitted[0]["checklist_ticked"]; got != float64(1) && got != 1 { + t.Fatalf("checklist_ticked = %v (%T), want 1", got, got) + } + // The claim and the observation are both present and both unreconciled. + if _, ok := submitted[0]["checklist_satisfied"]; !ok { + t.Fatal("the model's own claim is no longer recorded alongside the count") + } +} + +// clearGitIdentity strips every source of a git committer identity for the +// duration of the test: the repo config, the global and system files, and the +// GIT_* / EMAIL environment. A container image that ships no git config is a +// normal case, not an exotic one. +func clearGitIdentity(t *testing.T, workspace string) { + t.Helper() + for _, key := range []string{"user.name", "user.email"} { + // --unset returns 5 when the key is already absent; that is fine. + _ = gitRun(workspace, "config", "--unset", key) + } + // Unsetting is not enough on a developer machine: git happily invents + // user@hostname when the hostname has a domain, and only refuses when it + // cannot (a container yields an identity like 'root@0123abcd.(none)'). + // useConfigOnly makes that refusal unconditional, so the test reproduces + // the container's condition on any host. + if err := gitRun(workspace, "config", "user.useConfigOnly", "true"); err != nil { + t.Fatal(err) + } + for _, name := range []string{ + "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", + "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL", + } { + t.Setenv(name, "") // registers restoration + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + } + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) + t.Setenv("HOME", t.TempDir()) +} + +// The freeze must not depend on the container having a git identity. +// +// If workspaceGit shelled plain `git` while eager-commit went through +// attribution.GitArgv, every wip(edit) commit would work and the one commit +// that decides what ships would fail with +// +// could not record the tree: git commit-tree …: exit status 128: +// Author identity unknown … unable to auto-detect email address +// +// leaving the model to run `git config user.email …` and submit again. +func TestFreezeRecordsTheTreeWithoutAConfiguredGitIdentity(t *testing.T) { + workspace, _ := guardWorkspace(t) + clearGitIdentity(t, workspace) + + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(discardWriter{}), Notes: discardWriter{}, + }) + t.Cleanup(runner.runtime.Close) + + if err := writeFile(filepath.Join(workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + treeSHA, err := runner.currentTreeSHA() + if err != nil { + t.Fatalf("capturing the tree failed: %v", err) + } + + // Guard the guard: if this environment can still resolve an identity, the + // assertion below would pass whether or not the fix is present. + bare := exec.Command("git", "commit-tree", treeSHA, "-m", "identity probe") + bare.Dir = workspace + if out, bareErr := bare.CombinedOutput(); bareErr == nil { + t.Fatalf("test environment still has a git identity, so it cannot detect the defect: %s", out) + } + + commitSHA, err := runner.soloCommitTree(treeSHA, "the candidate") + if err != nil { + t.Fatalf("soloCommitTree needs a configured git identity: %v", err) + } + if commitSHA == "" { + t.Fatal("soloCommitTree returned an empty commit") + } + recorded, err := runner.recorder.(*gitRecorder).git("rev-parse", commitSHA+"^{tree}") + if err != nil || recorded != treeSHA { + t.Fatalf("frozen commit points at %q (err %v); want tree %q", recorded, err, treeSHA) + } +} + +func TestCurrentTreeSHAIncludesTrackedIgnoredFiles(t *testing.T) { + workspace, _ := guardWorkspace(t) + // currentTreeSHA only needs a workspace. Avoid starting the durable runtime, + // which intentionally creates untracked .senior-dev state unrelated to this + // exact-index regression. + runner := &pipeline{workspace: workspace, recorder: newGitRecorder(workspace, func(string) {})} + if err := writeFile(filepath.Join(workspace, ".gitignore"), "tracked-ignored.txt\n"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "tracked-ignored.txt"), "base\n"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "add", ".gitignore"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "add", "-f", "tracked-ignored.txt"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "track an ignored file"); err != nil { + t.Fatal(err) + } + + headTree, err := runner.recorder.(*gitRecorder).git("rev-parse", "HEAD^{tree}") + if err != nil { + t.Fatal(err) + } + unchanged, err := runner.currentTreeSHA() + if err != nil || unchanged != headTree { + t.Fatalf("unchanged tree = %q (err %v), want HEAD tree %q", unchanged, err, headTree) + } + if err := writeFile(filepath.Join(workspace, "tracked-ignored.txt"), "modified\n"); err != nil { + t.Fatal(err) + } + modified, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + if modified == headTree { + t.Fatal("tracked-but-ignored modification was absent from the captured tree") + } + content, err := runner.recorder.(*gitRecorder).git("show", modified+":tracked-ignored.txt") + if err != nil || content != "modified" { + t.Fatalf("captured ignored file = %q (err %v), want modified", content, err) + } +} + +// A restore must remove files ADDED after the checkpoint, not only revert +// edits. Overlay checkout cannot: every model-written file is tracked by +// eager-commit, so probe debris (a scratch test file the model added) +// survives `checkout --force <commit> -- .` + `clean -fd`, and the "restored" +// tree is not the checkpoint. The runs whose debris breaks the suite are +// exactly the ones that need this to work. +func TestRestoreRemovesFilesAddedAfterTheCheckpoint(t *testing.T) { + workspace, _ := guardWorkspace(t) + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(discardWriter{}), Notes: discardWriter{}, + }) + t.Cleanup(runner.runtime.Close) + + if err := writeFile(filepath.Join(workspace, "feature.txt"), "good state\n"); err != nil { + t.Fatal(err) + } + wantTree, err := runner.currentTreeSHA() + if err != nil { + t.Fatal(err) + } + commitSHA, err := runner.soloRecordTree(wantTree, "checkpoint") + if err != nil { + t.Fatal(err) + } + + // The debris: a file added AND tracked after the checkpoint, the way + // eager-commit tracks everything the model writes. + if err := writeFile(filepath.Join(workspace, "probe.test.js"), "debris\n"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "add", "probe.test.js"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "wip(edit): probe.test.js"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "feature.txt"), "broken state\n"); err != nil { + t.Fatal(err) + } + + if err := runner.soloRestoreTree(commitSHA, wantTree); err != nil { + t.Fatalf("restore failed: %v", err) + } + if _, err := os.Stat(filepath.Join(workspace, "probe.test.js")); !os.IsNotExist(err) { + t.Fatalf("added file survived the restore (stat err %v)", err) + } + got, err := runner.currentTreeSHA() + if err != nil || got != wantTree { + t.Fatalf("restored tree %q (err %v), want %q", got, err, wantTree) + } +} + +func TestATurnKilledByADroppedStreamIsRetriedInTheSameSession(t *testing.T) { + // One dropped stream must not end the run: the run layer owns the only + // retry and resumes the persisted session. + runner, state, _, events := soloPipeline(t) + outcome := soloOutcome{} + turns := 0 + runner.turnForTest = func(_ context.Context, _, prompt string) (turnResult, error) { + turns++ + switch turns { + case 1: + return turnResult{}, errors.New("stream error: unexpected EOF") + case 2: + if prompt != soloRecoveryPrompt() || + !strings.Contains(prompt, "failed and is not in context") { + t.Fatalf("retry prompt does not say what happened: %q", prompt) + } + if err := writeFile(filepath.Join(runner.workspace, "fix.go"), "package fix\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("done")); err != nil { + t.Fatalf("freeze during the retried turn: %v", err) + } + return turnResult{}, nil + default: + t.Fatalf("turn %d should not run", turns) + return turnResult{}, nil + } + } + if err := runner.soloConverse(context.Background(), "fix it", state, &outcome); err != nil { + t.Fatalf("converse: %v", err) + } + if outcome.TerminalTrigger != "submitted" || outcome.Nudges != 0 { + t.Fatalf("trigger %q nudges %d, want submitted with 0 nudges", + outcome.TerminalTrigger, outcome.Nudges) + } + var retried []map[string]any + for _, event := range soloStageEvents(t, events, "implement") { + if event["status"] == "transport-retry" { + retried = append(retried, event) + } + } + if len(retried) != 1 || retried[0]["class"] != "unexpected-eof" || + retried[0]["retry"] != float64(1) || + retried[0]["max_retries"] != float64(soloMaxRecoveryRetries) || + retried[0]["delay_ms"] != float64(5_000) { + t.Fatalf("transport-retry events = %#v", retried) + } +} + +func TestExhaustedTransportRetriesStillGetALandingTurnAndAnHonestError(t *testing.T) { + runner, state, _, events := soloPipeline(t) + outcome := soloOutcome{} + turns := 0 + status := uint64(503) + runner.turnForTest = func(context.Context, string, string) (turnResult, error) { + turns++ + return turnResult{}, &modelTurnError{ + kind: "APIError", message: "provider down", statusCode: &status, + responseBody: `{"metadata":{"error_type":"provider_unavailable"}}`, + } + } + err := runner.soloConverse(context.Background(), "fix it", state, &outcome) + if err == nil || !strings.Contains(err.Error(), "provider down") { + t.Fatalf("converse err = %v, want the original provider error", err) + } + if outcome.TerminalTrigger != "turn-error" { + t.Fatalf("trigger %q, want turn-error", outcome.TerminalTrigger) + } + // 1 original turn + 3 transport retries + 1 landing turn. + if turns != 5 { + t.Fatalf("model turns = %d, want 5", turns) + } + retries, landings := 0, 0 + for _, event := range soloStageEvents(t, events, "implement") { + if event["status"] == "transport-retry" { + retries++ + if event["class"] != "provider-5xx" || event["http_status"] != float64(503) || + event["provider_code"] != "provider_unavailable" || + event["max_retries"] != float64(soloMaxRecoveryRetries) { + t.Fatalf("structured retry event = %#v", event) + } + } + } + for _, event := range soloStageEvents(t, events, "landing") { + if event["status"] == "repair-turn" { + landings++ + } + } + if retries != soloMaxRecoveryRetries || landings != 1 { + t.Fatalf("retries=%d landings=%d, want %d and 1", + retries, landings, soloMaxRecoveryRetries) + } +} + +func TestTransientTurnErrorSeparatesTransportFromDecisions(t *testing.T) { + for _, tc := range []struct { + err error + class string + }{ + {fmt.Errorf("stream: %w", io.ErrUnexpectedEOF), "unexpected-eof"}, + {errors.New("Post \"https://x\": read: connection reset by peer"), "connection-reset"}, + {errors.New("write: broken pipe"), "broken-pipe"}, + {errors.New("net/http: TLS handshake timeout"), "tls-handshake-timeout"}, + {errors.New("http2: server sent GOAWAY and closed the connection"), "http2-goaway"}, + {errors.New("SSE read timed out"), "sse-read-timeout"}, + {errors.New("fetch failed: getaddrinfo EAI_AGAIN"), "fetch-failed"}, + {errors.New("Upstream error: provider_unavailable; retry after 2s"), "provider-unavailable"}, + {errors.New("Service unavailable"), "provider-unavailable"}, + {errors.New("You can retry your request, or contact support"), "provider-retry-requested"}, + {context.Canceled, ""}, + {context.DeadlineExceeded, ""}, + {fmt.Errorf("turn: %w", context.Canceled), ""}, + {errors.New("assistant error: invalid request"), ""}, + {errors.New("status 400: bad request"), ""}, + {nil, ""}, + } { + info, transient := transientTurnError(tc.err) + if info.Class != tc.class || transient != (tc.class != "") { + t.Errorf("transientTurnError(%v) = %q,%v; want %q", tc.err, info.Class, transient, tc.class) + } + } +} + +func TestTransientTurnErrorUsesStructuredProviderStatusAndExcludesQuota(t *testing.T) { + status503 := uint64(503) + providerFailure := &modelTurnError{ + kind: "APIError", + message: "Upstream error", + statusCode: &status503, + responseBody: `{"error":{"metadata":{"error_type":"provider_unavailable"}}}`, + } + info, transient := transientTurnError(providerFailure) + if !transient || info.Class != "provider-5xx" || info.StatusCode == nil || + *info.StatusCode != 503 || info.ProviderCode != "provider_unavailable" { + t.Fatalf("structured 503 classification = %#v,%v", info, transient) + } + + status429 := uint64(429) + quota := &modelTurnError{ + kind: "APIError", message: "insufficient_quota: billing limit reached", + statusCode: &status429, + } + if info, transient := transientTurnError(quota); transient || info.Class != "" { + t.Fatalf("quota classification = %#v,%v; want terminal", info, transient) + } +} + +func TestSubmitRefusalsAreCountableEvents(t *testing.T) { + // A refusal that travels only as tool-call error text cannot be counted + // without opening a log. Every refusal is an event with a reason class. + runner, state, _, events := soloPipeline(t) + + // Refusal 1: nothing changed. + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("empty")); err == nil { + t.Fatal("an unchanged tree must refuse") + } + // Refusal 2: a change but no checklist. + if err := writeFile(filepath.Join(runner.workspace, "fix.go"), "package fix\n"); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(runner.workspace, ".senior-dev", "checklist.md")); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("no checklist")); err == nil { + t.Fatal("a missing checklist must refuse") + } + // A successful freeze, then refusal 3: a second submission. + if err := writeFile(filepath.Join(runner.workspace, ".senior-dev", "checklist.md"), + "- [x] done\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("real")); err != nil { + t.Fatalf("freeze: %v", err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("again")); err == nil { + t.Fatal("a second submission must refuse") + } + + var classes []string + for _, event := range soloStageEvents(t, events, "submit") { + if event["status"] == "refused" { + class, _ := event["reason_class"].(string) + if detail, _ := event["detail"].(string); detail == "" { + t.Fatalf("refusal %q carries no detail", class) + } + classes = append(classes, class) + } + } + want := []string{"empty-tree", "no-checklist", "already-submitted"} + if strings.Join(classes, ",") != strings.Join(want, ",") { + t.Fatalf("refusal classes = %v, want %v", classes, want) + } +} diff --git a/internal/seniordev/app/stage_data.go b/internal/seniordev/app/stage_data.go new file mode 100644 index 0000000000..bfc57a75e7 --- /dev/null +++ b/internal/seniordev/app/stage_data.go @@ -0,0 +1,114 @@ +//go:build !windows + +package app + +import ( + "encoding/json" + "sort" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// A stage's data goes two places. The whole of it goes to stderr, one line per +// stage, for a person reading why the run did what it did (eventWriter's +// noteStage). A small copy of it goes on the protocol's `stage` record, for +// codeaf's page to say in words: which attempt, how many requirements were +// ticked, how many files the hand-in held, what the project's own check +// found. +// +// THE COPY IS CURATED, NEVER COMPUTED. Every value on the record is a value the +// run already put in the stage's data; this file only chooses which, shortens a +// sentence and counts a list. Nothing here is new knowledge, and nothing here +// reaches the model. + +// stageDataKeys are the keys a stage record may carry, each a plain fact a +// page can say. Tree and commit ids, paths, pools of models and the run's +// environment are left to stderr: they are machinery, and a page has nothing +// to say with them. +var stageDataKeys = map[string]bool{ + // implement: the attempt, the retries and corrections. + "attempt": true, "retry": true, "max_retries": true, "delay_ms": true, + "class": true, "http_status": true, "correction": true, + "budget_exhausted": true, "transport_retries": true, + // submit, and the reasons given anywhere. + "reason": true, "reason_class": true, "detail": true, "error": true, + "checklist_satisfied": true, "checklist_items": true, "checklist_ticked": true, + "patch_bytes": true, "patch_files": true, + // patch-summary. + "files": true, "additions": true, "deletions": true, "binary_files": true, + // verification and the checks of the tree. + "commands": true, "vacuous": true, "phase": true, "failing": true, + "timed_out": true, "suite_dead": true, "safety_regression": true, + // landing and ship. + "source": true, "timeout_ms": true, + // bootstrap and intake. + "recorder": true, "spec_bytes": true, + // compaction-capacity and compaction. + "limit_tokens": true, "pinned_capacity_tokens": true, + "before_tokens": true, "after_tokens": true, "summary_status": true, + // model-switch. + "from": true, "to": true, +} + +// stageDataTextMost is the most bytes one sentence on the record keeps: a +// reason or an error is read by a person in one row, and the whole of it is on +// stderr. +const stageDataTextMost = 160 + +// stageRecordData is a stage's data as the protocol record carries it: the +// allowed keys, each a number, a yes or no, or a sentence cut to +// [stageDataTextMost] bytes; a list counted rather than carried (a +// verification's commands are each a step record of their own); and the whole +// held to [delegate.StageDataCap] by dropping the longest sentences first. Nil +// when nothing is left, so a stage with nothing to say carries no data. +func stageRecordData(data map[string]any) json.RawMessage { + kept := map[string]any{} + for key, value := range data { + if !stageDataKeys[key] { + continue + } + switch v := value.(type) { + case string: + if v != "" { + kept[key] = clipBytes(oneLine(v), stageDataTextMost) + } + case bool, int, int64, uint64, float64: + kept[key] = v + case []any: + kept[key] = len(v) + } + } + for len(kept) > 0 { + raw, err := json.Marshal(kept) + if err != nil { + return nil + } + if len(raw) <= delegate.StageDataCap { + return raw + } + delete(kept, longestText(kept)) + } + return nil +} + +// longestText is the key whose value takes the most bytes, a sentence before a +// number, and the first in key order among equals, so the cut is the same on +// every run. +func longestText(kept map[string]any) string { + keys := make([]string, 0, len(kept)) + for key := range kept { + keys = append(keys, key) + } + sort.Strings(keys) + longest, most := keys[0], -1 + for _, key := range keys { + size := 0 + if text, ok := kept[key].(string); ok { + size = len(text) + 1 + } + if size > most { + longest, most = key, size + } + } + return longest +} diff --git a/internal/seniordev/app/stage_data_test.go b/internal/seniordev/app/stage_data_test.go new file mode 100644 index 0000000000..d78b8cf32c --- /dev/null +++ b/internal/seniordev/app/stage_data_test.go @@ -0,0 +1,91 @@ +//go:build !windows + +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/session/compaction" +) + +// A STAGE RECORD CARRIES A CURATED COPY OF ITS DATA: the plain facts a page can +// say, a list counted rather than carried, a sentence cut to a row, and none of +// the machinery — tree ids, paths, model pools — that stays on stderr. +func TestAStageRecordCarriesACuratedCopyOfItsData(t *testing.T) { + raw := stageRecordData(map[string]any{ + "reason": "tests pass " + strings.Repeat("and more ", 40), "evidence": "go test ./... ok", + "checklist_satisfied": true, "checklist_items": 5, "checklist_ticked": 4, + "patch_bytes": 812, "patch_files": 4, "tree_sha": "t1", "commit_sha": "c1", + "commands": []any{map[string]any{"cmd": "go test ./..."}, map[string]any{"cmd": "go build ./..."}}, + "workspace": "/tmp/copy", + }) + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("data %s: %v", raw, err) + } + for _, key := range []string{"tree_sha", "commit_sha", "workspace", "evidence"} { + if _, ok := got[key]; ok { + t.Fatalf("the record carries %q, which is machinery: %s", key, raw) + } + } + if got["checklist_items"] != float64(5) || got["checklist_ticked"] != float64(4) || got["patch_files"] != float64(4) || got["checklist_satisfied"] != true { + t.Fatalf("the record lost a fact: %s", raw) + } + if got["commands"] != float64(2) { + t.Fatalf("commands = %v, want the list counted", got["commands"]) + } + if reason := got["reason"].(string); len(reason) > stageDataTextMost { + t.Fatalf("the reason is %d bytes, want it cut to %d", len(reason), stageDataTextMost) + } + if stageRecordData(map[string]any{"tree_sha": "t1"}) != nil || stageRecordData(nil) != nil { + t.Fatal("a stage with nothing to say carries data") + } +} + +// THE COPY FITS THE PROTOCOL'S CAP, whatever the stage held: the longest +// sentences go first, and the numbers stay. +func TestAStageRecordsDataFitsTheCap(t *testing.T) { + data := map[string]any{"attempt": 2, "retry": 1, "max_retries": 3} + for _, key := range []string{"reason", "detail", "error", "class", "reason_class", "phase", "source", "recorder", "summary_status", "from", "to"} { + data[key] = strings.Repeat("é", 400) + } + raw := stageRecordData(data) + if len(raw) > delegate.StageDataCap || len(raw) == 0 { + t.Fatalf("data is %d bytes, want some, and at most %d", len(raw), delegate.StageDataCap) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if got["attempt"] != float64(2) || got["retry"] != float64(1) || got["max_retries"] != float64(3) { + t.Fatalf("the numbers were dropped before the sentences: %s", raw) + } +} + +// A COMPACTION IS A STAGE, reported after the history was rewritten: summarized +// when the model's summary stands, fallback when the deterministic record +// stood in, with the tokens before and after. +func TestACompactionIsReportedAsAStage(t *testing.T) { + var output bytes.Buffer + sink := newSeniorDevCompactionDecisionSink(nil, newEventWriter(&output)) + sink.CompactionDecision(compaction.CompactionDecision{SummaryStatus: "valid", Before: 120000, After: 9000}) + sink.CompactionDecision(compaction.CompactionDecision{SummaryStatus: "summary-error"}) + var statuses []string + for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) { + var value event + if err := json.Unmarshal(line, &value); err != nil { + t.Fatal(err) + } + if value.Stage != "compaction" { + t.Fatalf("event = %+v, want the compaction stage", value) + } + statuses = append(statuses, value.Status) + } + if strings.Join(statuses, ",") != "summarized,fallback" { + t.Fatalf("statuses = %v, want summarized then fallback", statuses) + } +} diff --git a/internal/seniordev/app/stages_test.go b/internal/seniordev/app/stages_test.go new file mode 100644 index 0000000000..e95599d061 --- /dev/null +++ b/internal/seniordev/app/stages_test.go @@ -0,0 +1,89 @@ +//go:build !windows + +package app + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "sort" + "strconv" + "strings" + "testing" +) + +// THE HELLO NAMES THE RUN'S STAGES, ALL OF THEM AND ONLY THEM. codeaf draws +// the whole track of a run from its hello before the run has walked it, so a +// stage the run can emit and the hello did not name is a stop on no track, +// and a name the run never emits is a stop nobody reaches. +// +// The stages are read out of this package's sources with go/parser, as the +// first argument of every `.stage(…)` and `.emitStage(…)` call, so a stage +// added anywhere is held to the list the day it is written. +func TestTheHelloNamesEveryStageTheRunCanEmit(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + emitted := map[string]string{} + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) == 0 { + return true + } + selector, ok := call.Fun.(*ast.SelectorExpr) + if !ok || (selector.Sel.Name != "stage" && selector.Sel.Name != "emitStage") { + return true + } + literal, ok := call.Args[0].(*ast.BasicLit) + if !ok || literal.Kind != token.STRING { + return true + } + stage, err := strconv.Unquote(literal.Value) + if err == nil { + emitted[stage] = fset.Position(literal.Pos()).String() + } + return true + }) + } + if len(emitted) < 10 { + t.Fatalf("only %d stages were read out of the sources; the reader has stopped working", len(emitted)) + } + named := map[string]bool{} + for _, stage := range Stages { + if named[stage] { + t.Errorf("the hello names %q twice", stage) + } + named[stage] = true + } + var unnamed, unreached []string + for stage, where := range emitted { + if !named[stage] { + unnamed = append(unnamed, stage+" ("+where+")") + } + } + for _, stage := range Stages { + if _, ok := emitted[stage]; !ok { + unreached = append(unreached, stage) + } + } + sort.Strings(unnamed) + sort.Strings(unreached) + if len(unnamed) > 0 { + t.Errorf("the run emits stages its hello does not name: %v", unnamed) + } + if len(unreached) > 0 { + t.Errorf("the hello names stages the run never emits: %v", unreached) + } +} diff --git a/internal/seniordev/app/statctime_darwin.go b/internal/seniordev/app/statctime_darwin.go new file mode 100644 index 0000000000..1b8ba88b4c --- /dev/null +++ b/internal/seniordev/app/statctime_darwin.go @@ -0,0 +1,13 @@ +//go:build darwin + +// Darwin names the stat ctime field Ctimespec, not Ctim; same value, same units. +package app + +import ( + "syscall" + "time" +) + +func statChangedNanos(stat *syscall.Stat_t) int64 { + return int64(stat.Ctimespec.Sec)*int64(time.Second) + int64(stat.Ctimespec.Nsec) +} diff --git a/internal/seniordev/app/statctime_linux.go b/internal/seniordev/app/statctime_linux.go new file mode 100644 index 0000000000..bedf4fc7b7 --- /dev/null +++ b/internal/seniordev/app/statctime_linux.go @@ -0,0 +1,13 @@ +//go:build linux + +// Linux spelling of the stat ctime field read by durable_sessions.go's projection mark. +package app + +import ( + "syscall" + "time" +) + +func statChangedNanos(stat *syscall.Stat_t) int64 { + return int64(stat.Ctim.Sec)*int64(time.Second) + int64(stat.Ctim.Nsec) +} diff --git a/internal/seniordev/app/step_ids.go b/internal/seniordev/app/step_ids.go new file mode 100644 index 0000000000..d7eae80c3a --- /dev/null +++ b/internal/seniordev/app/step_ids.go @@ -0,0 +1,210 @@ +//go:build !windows + +package app + +import ( + "path/filepath" + "strings" +) + +// The steps of senior-dev's own process, as a `step` record names the one an +// action served. They are the parts of the one model context the run +// instruction lays out (solo_prompt.go) — read the spec, explore, pin a check, +// list the requirements, implement, submit — and the independent check the run +// makes of the tree itself afterwards. +// +// THEY ARE A REPORT, NEVER A PLAN. Inside the model context the order is the +// model's own: it may explore after it has pinned, and write its checklist last. +// A step id says which part of the process an action serves, so an id recurs +// whenever the model comes back to that part; nothing here moves the model from +// one to the next, and nothing the model sees depends on them. +// +// ONE SOURCE OF TRUTH: these constants are the ids on the wire, the classifier +// below answers only them, and senior-dev's page words are keyed by them +// (internal/seniordev's actions.go). +const ( + // StepBrief is reading the spec the brief was written down as. + StepBrief = "brief" + // StepExplore is reading, searching and running commands before the first + // change to a project file. + StepExplore = "explore" + // StepPin is writing down the command that shows the work passes. + StepPin = "pin" + // StepChecklist is listing the request's requirements, and ticking them. + StepChecklist = "checklist" + // StepImplement is every change to a project file, and whatever the model + // reads or runs once it has made one. + StepImplement = "implement" + // StepSubmit is the submit tool, and anything after an accepted submit. + StepSubmit = "submit" + // StepVerify is senior-dev running the project's own build and tests itself, + // with no model: after the hand-in, and when it checks the tree mid-run. + StepVerify = "verify" +) + +// Steps is every step id, in the order a run first reaches them when its +// model works through the process as the instruction lays it out. +var Steps = []string{StepBrief, StepExplore, StepPin, StepChecklist, StepImplement, StepSubmit, StepVerify} + +// The files senior-dev keeps its own records in, inside the folder it works in +// (seniorDevArtifactPathspecs keeps them out of the answer). An action on one +// of them serves that record's step, whatever tool it took. +const ( + seniorDevSpec = seniorDevDataDirectory + "/spec.md" + seniorDevPinned = seniorDevDataDirectory + "/pinned.txt" + seniorDevChecklist = seniorDevDataDirectory + "/checklist.md" +) + +// stepProgress is what the step classifier knows about the run so far: whether +// an edit tool has changed a project file, and whether a submit was accepted. +// It only ever moves forward. A finished tool call moves the first (stepOf); +// only the freeze's own stage record moves the second (afterStage). +type stepProgress struct { + changed bool + submitted bool +} + +// The stage record the freeze writes once it has captured the tree, and at no +// other time (solo.go's soloFreezeWithContext): the one record that says a +// submit was accepted. +const ( + frozenStage = "submit" + frozenStatus = "frozen" +) + +// afterStage is the run's progress after a stage record. Only the freeze's +// record moves it: from then on the tree is frozen, and everything is the +// submit step. +func (progress stepProgress) afterStage(stage, status string) stepProgress { + if stage == frozenStage && status == frozenStatus { + progress.submitted = true + } + return progress +} + +// stepAction is one finished tool call as the classifier reads it: the tool, +// what it was aimed at — the file a file tool named, a shell's command, a +// patch's text — and whether it failed. +type stepAction struct { + tool string + target string + failed bool +} + +// editTools are the tools that change a file. Only their success moves a run +// from exploring to implementing: a shell command may change files too, but a +// reader of the command cannot tell which, and the instruction's own line +// between the two parts is the first edit. +var editTools = map[string]bool{"edit": true, "write": true, "apply_patch": true} + +// stepOf is the step a finished tool call served, and the run's progress after +// it. It is pure: the same call on the same progress answers the same step. +// +// - Once a submit has been accepted, everything is the submit step: the +// tree is frozen and the run is handing in. +// - The submit tool is the submit step, accepted or refused, and it moves +// nothing. A refused submit tells its model why and lets it keep working, +// so it settles as a completed call exactly as an accepted one does +// (tool/submit.go): the call cannot say which it was, and the freeze's +// own stage record, which comes first, does (afterStage). +// - An action on one of senior-dev's own records is that record's step: the +// spec (brief), the pinned check (pin), the checklist. +// - A successful edit to a project file is the first change, and it and +// everything after it is implementing; before it, exploring. +// +// `question` is always refused in a run nobody attends (runtime.go), so it +// changes nothing and is read as whichever part the model was in when it +// asked. +func stepOf(action stepAction, progress stepProgress) (string, stepProgress) { + if progress.submitted { + return StepSubmit, progress + } + if action.tool == "submit" { + return StepSubmit, progress + } + if record := seniorDevRecordStep(action); record != "" { + return record, progress + } + if editTools[action.tool] { + if !action.failed { + progress.changed = true + } + return StepImplement, progress + } + if progress.changed { + return StepImplement, progress + } + return StepExplore, progress +} + +// seniorDevRecordStep is the step of an action on one of senior-dev's own +// records, and "" for an action on anything else. A patch counts as one only +// when every file it touches is one of them: a patch that also changes a +// project file is implementing. +func seniorDevRecordStep(action stepAction) string { + target := filepath.ToSlash(action.target) + if action.tool == "apply_patch" { + files := patchFiles(target) + if len(files) == 0 { + return "" + } + step := "" + for _, file := range files { + record := recordStepOf(file) + if record == "" { + return "" + } + if step == "" { + step = record + } + } + return step + } + return recordStepOf(target) +} + +// recordStepOf names the record a path or a command mentions, first the spec, +// then the pinned check, then the checklist. +func recordStepOf(text string) string { + for _, record := range []struct{ file, step string }{ + {seniorDevSpec, StepBrief}, + {seniorDevPinned, StepPin}, + {seniorDevChecklist, StepChecklist}, + } { + if mentionsPath(text, record.file) { + return record.step + } + } + return "" +} + +// mentionsPath reports whether text names the file at a path boundary: the +// relative path itself, or the same path at the end of a longer one — so +// `/copy/.senior-dev/spec.md` is the spec and `my.senior-dev/spec.md` is not. +func mentionsPath(text, file string) bool { + for from := 0; ; { + at := strings.Index(text[from:], file) + if at < 0 { + return false + } + at += from + if at == 0 || strings.ContainsRune("/ \t\n'\"=<>(;&|", rune(text[at-1])) { + return true + } + from = at + len(file) + } +} + +// patchFiles are the files an apply_patch text touches, from its own headers. +func patchFiles(text string) []string { + var files []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + for _, header := range []string{"*** Add File: ", "*** Update File: ", "*** Delete File: ", "*** Move to: "} { + if name, ok := strings.CutPrefix(line, header); ok && strings.TrimSpace(name) != "" { + files = append(files, strings.TrimSpace(name)) + } + } + } + return files +} diff --git a/internal/seniordev/app/step_ids_test.go b/internal/seniordev/app/step_ids_test.go new file mode 100644 index 0000000000..88c6cf9337 --- /dev/null +++ b/internal/seniordev/app/step_ids_test.go @@ -0,0 +1,200 @@ +//go:build !windows + +package app + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +// EVERY FINISHED TOOL CALL NAMES THE STEP OF THE PROCESS IT SERVED, from the +// tool, what it was aimed at and the run's progress — and the progress only +// ever moves forward: the first successful edit to a project file turns +// exploring into implementing, and an accepted submit turns everything after +// it into handing in. A submit call itself moves nothing, because a refused +// one completes exactly as an accepted one does; the freeze's stage record, +// written inside the call before its step, is what moved the progress of the +// accepted one (TestOnlyTheFreezeSaysASubmitWasAccepted). +func TestAToolCallNamesTheStepItServed(t *testing.T) { + fresh := stepProgress{} + changed := stepProgress{changed: true} + submitted := stepProgress{changed: true, submitted: true} + for _, tc := range []struct { + name string + action stepAction + progress stepProgress + want string + after stepProgress + }{ + {"reading the spec", stepAction{tool: "read", target: ".senior-dev/spec.md"}, fresh, StepBrief, fresh}, + {"reading the spec by its absolute path", stepAction{tool: "read", target: "/copy/.senior-dev/spec.md"}, fresh, StepBrief, fresh}, + {"a shell printing the spec", stepAction{tool: "bash", target: "cat .senior-dev/spec.md"}, changed, StepBrief, changed}, + {"a file that only ends like the spec", stepAction{tool: "read", target: "my.senior-dev/spec.md"}, fresh, StepExplore, fresh}, + {"reading code before any change", stepAction{tool: "read", target: "internal/auth/middleware.go"}, fresh, StepExplore, fresh}, + {"a search before any change", stepAction{tool: "grep", target: "internal"}, fresh, StepExplore, fresh}, + {"a command before any change", stepAction{tool: "bash", target: "go test ./internal/auth/..."}, fresh, StepExplore, fresh}, + {"a fetch before any change", stepAction{tool: "webfetch", target: "https://go.dev/doc"}, fresh, StepExplore, fresh}, + {"writing the pinned check", stepAction{tool: "write", target: ".senior-dev/pinned.txt"}, fresh, StepPin, fresh}, + {"a shell writing the pinned check", stepAction{tool: "bash", target: "echo 'go test ./...' > .senior-dev/pinned.txt"}, fresh, StepPin, fresh}, + {"writing the checklist", stepAction{tool: "write", target: ".senior-dev/checklist.md"}, changed, StepChecklist, changed}, + {"ticking the checklist", stepAction{tool: "edit", target: "/copy/.senior-dev/checklist.md"}, changed, StepChecklist, changed}, + {"the first edit to a project file", stepAction{tool: "edit", target: "internal/auth/middleware.go"}, fresh, StepImplement, changed}, + {"an edit that failed changes nothing", stepAction{tool: "edit", target: "internal/x.go", failed: true}, fresh, StepImplement, fresh}, + {"a new file", stepAction{tool: "write", target: "internal/auth/store.go"}, fresh, StepImplement, changed}, + {"a patch to a project file", stepAction{tool: "apply_patch", target: "*** Begin Patch\n*** Update File: a.go\n@@\n-x\n+y\n*** End Patch"}, fresh, StepImplement, changed}, + {"a patch to the checklist alone", stepAction{tool: "apply_patch", target: "*** Begin Patch\n*** Update File: .senior-dev/checklist.md\n@@\n-[ ] a\n+[x] a\n*** End Patch"}, changed, StepChecklist, changed}, + {"a read after the first change", stepAction{tool: "read", target: "internal/auth/middleware.go"}, changed, StepImplement, changed}, + {"a command after the first change", stepAction{tool: "bash", target: "go test ./..."}, changed, StepImplement, changed}, + {"a question before any change", stepAction{tool: "question"}, fresh, StepExplore, fresh}, + {"a question after a change", stepAction{tool: "question"}, changed, StepImplement, changed}, + {"a submit moves nothing, since a refused one completes too", stepAction{tool: "submit"}, changed, StepSubmit, changed}, + {"a submit that failed moves nothing either", stepAction{tool: "submit", failed: true}, changed, StepSubmit, changed}, + {"an accepted submit, after its freeze", stepAction{tool: "submit"}, submitted, StepSubmit, submitted}, + {"anything after an accepted submit", stepAction{tool: "edit", target: "a.go"}, submitted, StepSubmit, submitted}, + } { + t.Run(tc.name, func(t *testing.T) { + got, after := stepOf(tc.action, tc.progress) + if got != tc.want || after != tc.after { + t.Fatalf("stepOf(%+v, %+v) = %q, %+v; want %q, %+v", tc.action, tc.progress, got, after, tc.want, tc.after) + } + }) + } +} + +// ONLY THE FREEZE SAYS A SUBMIT WAS ACCEPTED: its `submit · frozen` record +// moves the progress, and a refusal's `submit · refused` and every other stage +// leave it where it was. Nothing moves it back. +func TestOnlyTheFreezeSaysASubmitWasAccepted(t *testing.T) { + changed := stepProgress{changed: true} + submitted := stepProgress{changed: true, submitted: true} + for _, tc := range []struct { + stage, status string + progress stepProgress + want stepProgress + }{ + {"submit", "frozen", changed, submitted}, + {"submit", "refused", changed, changed}, + {"implement", "running", changed, changed}, + {"verification", "pass", changed, changed}, + {"submit", "refused", submitted, submitted}, + } { + if got := tc.progress.afterStage(tc.stage, tc.status); got != tc.want { + t.Fatalf("%+v after %s · %s = %+v; want %+v", tc.progress, tc.stage, tc.status, got, tc.want) + } + } +} + +// THE STEPS ARE THE ONE LIST: every id the classifier can answer is in Steps, +// once, and verify — which no tool call is — is there for the run's own checks. +func TestEveryStepIdIsInTheOneList(t *testing.T) { + seen := map[string]bool{} + for _, id := range Steps { + if seen[id] { + t.Fatalf("step %q is listed twice", id) + } + seen[id] = true + } + for _, id := range []string{StepBrief, StepExplore, StepPin, StepChecklist, StepImplement, StepSubmit, StepVerify} { + if !seen[id] { + t.Fatalf("step %q is not in Steps", id) + } + } +} + +// A REFUSED SUBMIT MOVES NOTHING. The submit tool tells its model why it was +// refused and lets it keep working, so a refusal settles as a completed call +// exactly as an acceptance does (tool/submit.go); only an accepted submit +// freezes the tree, and only after one is everything the submit step. Here the +// model submits an unchanged tree, then a change with no checklist, and is +// refused both times; what it does after each refusal is still the part of the +// process it was in, and only what follows the third, accepted submit is +// handing in. +func TestOnlyAnAcceptedSubmitTurnsWhatFollowsIntoHandingIn(t *testing.T) { + runner, state, _, events := soloPipeline(t) + checklist := filepath.Join(runner.workspace, ".senior-dev", "checklist.md") + if err := os.Remove(checklist); err != nil { + t.Fatal(err) + } + calls := 0 + // finish reports one tool call the way the step loop settles it + // (engine/steploop/processor.go): a call whose tool returned an error + // fails, and every other call completes. + finish := func(tool string, input map[string]any, result steploop.ToolResult, err error) { + calls++ + callID := fmt.Sprintf("c%d", calls) + if err != nil { + runner.events.busEvent(toolPartPayload(callID, tool, "error", input, "", err.Error())) + return + } + runner.events.busEvent(toolPartPayload(callID, tool, "completed", input, result.Output, "")) + } + did := func(tool string, input map[string]any) { + finish(tool, input, steploop.ToolResult{Output: "ok"}, nil) + } + submit := func() steploop.ToolResult { + input := map[string]any{"reason": "done", "evidence": "make test: exit 0", "checklist_satisfied": true} + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + result, err := runner.runtime.registry.Execute(context.Background(), steploop.ToolCall{ + ID: "submit", Name: "submit", Input: raw, SessionID: "ses_solo", + }) + finish("submit", input, result, err) + return result + } + + did("read", map[string]any{"filePath": "README.md"}) + if refused := submit(); refused.Title != "submit refused" || state.candidate() != nil { + t.Fatalf("a submit of an unchanged tree was not refused: %+v", refused) + } + did("grep", map[string]any{"pattern": "base"}) + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + did("write", map[string]any{"filePath": "feature.txt"}) + if refused := submit(); refused.Title != "submit refused" || state.candidate() != nil { + t.Fatalf("a submit with no checklist was not refused: %+v", refused) + } + if err := writeFile(checklist, "- [x] the feature\n"); err != nil { + t.Fatal(err) + } + did("write", map[string]any{"filePath": checklist}) + did("edit", map[string]any{"filePath": "feature.txt"}) + did("bash", map[string]any{"command": "make test"}) + if accepted := submit(); accepted.Title != "submitted" || state.candidate() == nil { + t.Fatalf("a submit with a change and a checklist was not accepted: %+v", accepted) + } + did("edit", map[string]any{"filePath": "feature.txt"}) + + var got []string + for _, step := range streamSteps(t, events.Bytes()) { + got = append(got, step.Tool+" "+step.Step) + } + want := []string{ + "read " + StepExplore, + "submit " + StepSubmit, + "grep " + StepExplore, + "write " + StepImplement, + "submit " + StepSubmit, + "write " + StepChecklist, + "edit " + StepImplement, + "bash " + StepImplement, + "submit " + StepSubmit, + "edit " + StepSubmit, + } + if len(got) != len(want) { + t.Fatalf("steps = %q, want %q", got, want) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("steps = %q, want %q", got, want) + } + } +} diff --git a/internal/seniordev/app/step_records.go b/internal/seniordev/app/step_records.go new file mode 100644 index 0000000000..db35dcade4 --- /dev/null +++ b/internal/seniordev/app/step_records.go @@ -0,0 +1,252 @@ +//go:build !windows + +package app + +import ( + "encoding/json" + "sort" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" +) + +// The `step` record projects one finished tool call into a shape a reader can +// display without understanding the message model: what was run, what came +// back, the tool, a command's exit code, and the step of senior-dev's process +// it served. Every byte but the last is already inside the +// `message.part.updated` payload for the same call, and the last is read off +// those same payloads in the order the calls finished (step_ids.go) — this +// rearranges what the run already knows, and learns nothing new. +// +// Nothing here reaches the model. The record is written by the event layer +// after the tool result has been produced; it is not a prompt, not a tool +// result, and not a message. The model's transcript is identical whether or +// not anyone reads these. +const ( + // stepObservationMax caps the observation. A tool result can be a whole + // file or a full test log, and a reader that only renders steps should not + // have to hold one. + stepObservationMax = 2048 + // stepCommandMax caps the argument rendered beside the tool name, which is + // a label rather than a payload. + stepCommandMax = 200 +) + +// stepRecord is one finished tool call. key deduplicates: a tool part is +// republished as its state moves, so the same call arrives more than once in +// the same terminal state. action is what the step classifier reads +// (step_ids.go), step is the step it named, and exit is a command's exit code, +// which the bash tool keeps in its metadata (tool/bash.go) and nowhere else. +type stepRecord struct { + key string + command string + observation string + action stepAction + step string + exit *int + // added and removed are the lines a file tool's call added and removed, + // read off its metadata ([lineCounts]); nil for every other call. + added, removed *int +} + +// toolStepRecord reads a bus payload and reports the finished tool call in it, +// if it holds one. Pending and running states are ignored: a step is a thing +// that happened, and only `completed` and `error` have happened. +func toolStepRecord(value bus.Payload) (stepRecord, bool) { + if value.Type != "message.part.updated" { + return stepRecord{}, false + } + part := mapAt(object(value.Properties), "part") + if stringAt(part, "type") != "tool" { + return stepRecord{}, false + } + state := mapAt(part, "state") + status := stringAt(state, "status") + if status != "completed" && status != "error" { + return stepRecord{}, false + } + tool := stringAt(part, "tool") + input := mapAt(state, "input") + record := stepRecord{ + key: "tool:" + stringAt(part, "callID") + ":" + status, + action: stepAction{tool: tool, target: stepTarget(input), failed: status == "error"}, + exit: exitCode(mapAt(state, "metadata")), + } + if status == "completed" { + record.added, record.removed = lineCounts(tool, mapAt(state, "metadata")) + } + if argument := toolArgument(input); argument != "" { + record.command = tool + ": " + argument + } else { + record.command = tool + } + if status == "error" { + record.observation = stringAt(state, "error") + } else { + record.observation = stringAt(state, "output") + } + record.observation = clipBytes(record.observation, stepObservationMax) + return record, true +} + +// stepTargetKeys are the inputs that say what an action was aimed at, for the +// step classifier: the file a file tool named, a shell's command, a patch's +// whole text, where a search looked. It reads the input whole — the label on +// the record is cut to 200 bytes, and a patch names its files after its first +// line. +var stepTargetKeys = []string{"filePath", "command", "patchText", "path", "pattern"} + +func stepTarget(input map[string]any) string { + for _, key := range stepTargetKeys { + if text, ok := input[key].(string); ok && strings.TrimSpace(text) != "" { + return text + } + } + return "" +} + +// lineCounts is the lines a file tool's call added and removed, from the +// metadata the tool itself wrote: write's and edit's counts for the one file, +// and apply_patch's summed over the files it touched. nil, nil for every other +// tool, and for one whose metadata carried no counts. +func lineCounts(tool string, metadata map[string]any) (*int, *int) { + switch tool { + case "write": + return wholeAt(metadata, "additions"), wholeAt(metadata, "deletions") + case "edit": + diff := mapAt(metadata, "filediff") + return wholeAt(diff, "additions"), wholeAt(diff, "deletions") + case "apply_patch": + files, _ := metadata["files"].([]any) + if len(files) == 0 { + return nil, nil + } + added, removed := 0, 0 + for _, file := range files { + entry := object(file) + if n := wholeAt(entry, "additions"); n != nil { + added += *n + } + if n := wholeAt(entry, "deletions"); n != nil { + removed += *n + } + } + return &added, &removed + } + return nil, nil +} + +// wholeAt is a whole number in a metadata object, nil when it is absent. +func wholeAt(value map[string]any, key string) *int { + var n int + switch number := value[key].(type) { + case float64: + n = int(number) + case int: + n = number + default: + return nil + } + return &n +} + +// exitCode is a tool's exit code from its metadata, nil when it reported none: +// every tool but a shell, and a shell command killed at its ceiling. +func exitCode(metadata map[string]any) *int { + var code int + switch value := metadata["exitCode"].(type) { + case float64: + if value != float64(int(value)) { + return nil + } + code = int(value) + case int: + code = value + default: + return nil + } + return &code +} + +// toolArgumentKeys are the input fields that identify what a call was about, +// most identifying first. A tool that names none of them falls back to its +// first string input in key order, so a new tool still renders something. +var toolArgumentKeys = []string{ + "command", "filePath", "path", "pattern", "query", "url", "description", +} + +func toolArgument(input map[string]any) string { + if input == nil { + return "" + } + for _, key := range toolArgumentKeys { + if text, ok := input[key].(string); ok && strings.TrimSpace(text) != "" { + return clipBytes(oneLine(text), stepCommandMax) + } + } + keys := make([]string, 0, len(input)) + for key := range input { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + if text, ok := input[key].(string); ok && strings.TrimSpace(text) != "" { + return clipBytes(oneLine(text), stepCommandMax) + } + } + return "" +} + +// oneLine flattens a multi-line argument so the command reads as a label. +func oneLine(text string) string { + return strings.Join(strings.Fields(text), " ") +} + +// clipBytes truncates to at most max bytes without splitting a rune, so the +// result is always valid UTF-8 and always encodes. +func clipBytes(text string, max int) string { + if len(text) <= max { + return text + } + clipped := text[:max] + for len(clipped) > 0 && !utf8.ValidString(clipped) { + clipped = clipped[:len(clipped)-1] + } + return clipped +} + +// The payload readers below were the stderr trace's (trace.go, which stayed +// behind with the rest of senior-dev's command line); a step is read out of +// the same loosely typed bus payloads, so they came with it. + +// object reads a payload value as a JSON object, converting a typed value +// through its JSON form when it is not already a map. +func object(value any) map[string]any { + if mapped, ok := value.(map[string]any); ok { + return mapped + } + raw, err := json.Marshal(value) + if err != nil { + return nil + } + var mapped map[string]any + if json.Unmarshal(raw, &mapped) != nil { + return nil + } + return mapped +} + +func mapAt(value map[string]any, key string) map[string]any { return object(valueAt(value, key)) } + +func valueAt(value map[string]any, key string) any { + if value == nil { + return nil + } + return value[key] +} + +func stringAt(value map[string]any, key string) string { + result, _ := valueAt(value, key).(string) + return result +} diff --git a/internal/seniordev/app/step_records_test.go b/internal/seniordev/app/step_records_test.go new file mode 100644 index 0000000000..f53de78069 --- /dev/null +++ b/internal/seniordev/app/step_records_test.go @@ -0,0 +1,183 @@ +//go:build !windows + +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" +) + +func toolPartPayload(callID, tool, status string, input map[string]any, output, failure string) bus.Payload { + state := map[string]any{"status": status, "input": input} + switch status { + case "completed": + state["output"] = output + case "error": + state["error"] = failure + } + return bus.Payload{ + ID: "evt-" + callID + "-" + status, Type: "message.part.updated", + Properties: map[string]any{ + "sessionID": "ses-1", + "part": map[string]any{ + "id": "part-" + callID, "type": "tool", + "callID": callID, "tool": tool, "state": state, + }, + }, + } +} + +func streamSteps(t *testing.T, stream []byte) []event { + t.Helper() + var steps []event + for _, line := range bytes.Split(bytes.TrimSpace(stream), []byte("\n")) { + var value event + if err := json.Unmarshal(line, &value); err != nil || value.Type != "step" { + continue + } + steps = append(steps, value) + } + return steps +} + +// A step is a thing that happened: only completed and error have happened, and +// a tool part is republished as its state moves, so the same finished call +// arrives more than once and must be reported once. +func TestStepRecordsFireOncePerFinishedToolCall(t *testing.T) { + var stream bytes.Buffer + writer := newEventWriter(&stream) + + writer.busEvent(toolPartPayload("c1", "bash", "pending", map[string]any{"command": "go test ./..."}, "", "")) + writer.busEvent(toolPartPayload("c1", "bash", "running", map[string]any{"command": "go test ./..."}, "", "")) + if got := streamSteps(t, stream.Bytes()); len(got) != 0 { + t.Fatalf("unfinished tool call produced %d step records, want 0", len(got)) + } + + writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok \tpkg\t0.3s", "")) + writer.busEvent(toolPartPayload("c1", "bash", "completed", map[string]any{"command": "go test ./..."}, "ok \tpkg\t0.3s", "")) + + steps := streamSteps(t, stream.Bytes()) + if len(steps) != 1 { + t.Fatalf("step records = %d, want 1 (the republished part must not repeat)", len(steps)) + } + if steps[0].Command != "bash: go test ./..." { + t.Fatalf("command = %q, want %q", steps[0].Command, "bash: go test ./...") + } + if !strings.Contains(steps[0].Observation, "ok") { + t.Fatalf("observation = %q, want the tool output", steps[0].Observation) + } +} + +// An error carries the failure as its observation: a reader showing steps +// should see why a call failed, not an empty result. +func TestStepRecordCarriesTheFailureOnError(t *testing.T) { + var stream bytes.Buffer + writer := newEventWriter(&stream) + writer.busEvent(toolPartPayload( + "c2", "edit", "error", map[string]any{"filePath": "internal/x.go"}, "", "file does not exist", + )) + + steps := streamSteps(t, stream.Bytes()) + if len(steps) != 1 { + t.Fatalf("step records = %d, want 1", len(steps)) + } + if steps[0].Command != "edit: internal/x.go" { + t.Fatalf("command = %q, want %q", steps[0].Command, "edit: internal/x.go") + } + if steps[0].Observation != "file does not exist" { + t.Fatalf("observation = %q, want the error", steps[0].Observation) + } +} + +// A tool result can be a whole file. The observation is capped, and the cap is +// applied on a rune boundary so the record always encodes. +func TestStepObservationIsCappedAndStaysValidUTF8(t *testing.T) { + var stream bytes.Buffer + writer := newEventWriter(&stream) + // Three-byte runes, so a naive byte cut lands mid-rune. + output := strings.Repeat("→", stepObservationMax) + writer.busEvent(toolPartPayload( + "c3", "read", "completed", map[string]any{"filePath": "big.txt"}, output, "", + )) + + steps := streamSteps(t, stream.Bytes()) + if len(steps) != 1 { + t.Fatalf("step records = %d, want 1", len(steps)) + } + if got := len(steps[0].Observation); got > stepObservationMax { + t.Fatalf("observation = %d bytes, want at most %d", got, stepObservationMax) + } + if !utf8.ValidString(steps[0].Observation) { + t.Fatal("observation was cut mid-rune and is not valid UTF-8") + } +} + +// A tool whose input names none of the identifying keys still renders a label +// rather than a bare tool name, so a new tool needs no change here. +func TestStepCommandFallsBackToTheFirstStringInput(t *testing.T) { + record, ok := toolStepRecord(toolPartPayload( + "c4", "custom", "completed", map[string]any{"zeta": "last", "alpha": "first"}, "done", "", + )) + if !ok { + t.Fatal("a completed tool call was not recognised as a step") + } + if record.command != "custom: first" { + t.Fatalf("command = %q, want %q", record.command, "custom: first") + } +} + +// Anything that is not a finished tool part is not a step. +func TestNonToolPayloadsAreNotSteps(t *testing.T) { + if _, ok := toolStepRecord(assistantPayload("m1", "coder", 1, 2, 3, 0.01)); ok { + t.Fatal("an assistant message was read as a step") + } + if _, ok := toolStepRecord(bus.Payload{Type: "session.created"}); ok { + t.Fatal("a session event was read as a step") + } +} + +// A FILE TOOL'S STEP CARRIES THE LINES IT ADDED AND REMOVED, read off the +// metadata the tool wrote: write's and edit's counts for the one file, and +// apply_patch's summed; every other tool carries none. +func TestAFileToolsStepCarriesItsLinesAddedAndRemoved(t *testing.T) { + with := func(payload bus.Payload, metadata map[string]any) bus.Payload { + part := payload.Properties.(map[string]any)["part"].(map[string]any) + part["state"].(map[string]any)["metadata"] = metadata + return payload + } + for _, tc := range []struct { + name string + payload bus.Payload + added, removed int + none bool + }{ + {"write", with(toolPartPayload("w", "write", "completed", map[string]any{"filePath": "a.go"}, "ok", ""), + map[string]any{"additions": float64(7), "deletions": float64(2)}), 7, 2, false}, + {"edit", with(toolPartPayload("e", "edit", "completed", map[string]any{"filePath": "a.go"}, "ok", ""), + map[string]any{"filediff": map[string]any{"additions": float64(3), "deletions": float64(1)}}), 3, 1, false}, + {"apply_patch", with(toolPartPayload("p", "apply_patch", "completed", map[string]any{"patchText": "x"}, "ok", ""), + map[string]any{"files": []any{map[string]any{"additions": float64(4), "deletions": float64(0)}, map[string]any{"additions": float64(1), "deletions": float64(5)}}}), 5, 5, false}, + {"bash", with(toolPartPayload("b", "bash", "completed", map[string]any{"command": "ls"}, "ok", ""), + map[string]any{"exitCode": float64(0)}), 0, 0, true}, + {"a failed edit", toolPartPayload("f", "edit", "error", map[string]any{"filePath": "a.go"}, "", "no match"), 0, 0, true}, + } { + record, ok := toolStepRecord(tc.payload) + if !ok { + t.Fatalf("%s: no step record", tc.name) + } + if tc.none { + if record.added != nil || record.removed != nil { + t.Errorf("%s: lines %v/%v, want none", tc.name, record.added, record.removed) + } + continue + } + if record.added == nil || record.removed == nil || *record.added != tc.added || *record.removed != tc.removed { + t.Errorf("%s: lines %v/%v, want +%d -%d", tc.name, record.added, record.removed, tc.added, tc.removed) + } + } +} diff --git a/internal/seniordev/app/stop_test.go b/internal/seniordev/app/stop_test.go new file mode 100644 index 0000000000..1e9f2d3b6f --- /dev/null +++ b/internal/seniordev/app/stop_test.go @@ -0,0 +1,64 @@ +//go:build !windows + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A STOP DURING THE FINAL CHECK IS NOT A FAILED CHECK. codeaf stops a run +// with SIGTERM, which ends its context; if that lands while the project's own +// tests are running, the test command is killed half way and leaves no exit +// status. The run must not read that as the candidate failing: it ships the +// frozen candidate and says nothing finished checking it, which is what is +// true. +func TestAStopDuringTheCheckShipsTheCandidateUnchecked(t *testing.T) { + runner, state, _, _ := soloPipeline(t) + started := filepath.Join(t.TempDir(), "tests-started") + makefile := "build:\n\t@true\n\ntest:\n\t@touch " + started + " && sleep 30\n" + if err := writeFile(filepath.Join(runner.workspace, "Makefile"), makefile); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(runner.workspace, "feature.txt"), "implemented\n"); err != nil { + t.Fatal(err) + } + if _, err := runner.soloFreezeWithContext(context.Background(), state, soloSubmission("implemented")); err != nil { + t.Fatal(err) + } + + ctx, stop := context.WithCancel(context.Background()) + defer stop() + go func() { + // The stop lands once the test command is running, not before the + // check could start, which is the case this test is about. + for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) { + if _, err := os.Stat(started); err == nil { + stop() + return + } + } + }() + outcome := &soloOutcome{Status: "fail"} + began := time.Now() + runner.soloShip(ctx, state, outcome, nil) + if took := time.Since(began); took > 20*time.Second { + t.Fatalf("ship took %s after the stop; the test command was not cut", took) + } + if _, err := os.Stat(started); err != nil { + t.Fatalf("the test command never started, so nothing was stopped mid-check: %v", err) + } + if outcome.Status != "pass-unverified" { + t.Fatalf("status = %q, want the candidate shipped unchecked (%#v)", outcome.Status, outcome.TerminalData) + } + if status, _ := soloResultStatus(*outcome); status != "pass" { + t.Fatalf("result status = %q, want pass: the frozen candidate stands", status) + } + if reason, _ := outcome.TerminalData["reason"].(string); !strings.Contains(reason, "stopped while") { + t.Fatalf("reason = %q, want it to say the check was stopped", reason) + } +} diff --git a/internal/seniordev/app/testsupport_test.go b/internal/seniordev/app/testsupport_test.go new file mode 100644 index 0000000000..51edeafe23 --- /dev/null +++ b/internal/seniordev/app/testsupport_test.go @@ -0,0 +1,270 @@ +//go:build !windows + +package app + +// Shared fixtures for the pipeline tests. + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/session/sessioncore" +) + +// testAgentPrompt stands in for a baked agent document in tests that drive +// the engine directly: a turn must carry an agent prompt to be composed. +const testAgentPrompt = "<Role>test agent</Role>" + +// roundTripFunc is an http.RoundTripper made of one function, the stand-in +// transport the engine tests answer model requests with. (It lived beside the +// control-plane bridge's tests, which stayed behind with the bridge.) +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +// testHost is the codeaf a run reports to in these tests: it serves the +// workspace, the ceilings and the model API it was given, and keeps every +// record the run wrote, in order. +type testHost struct { + mu sync.Mutex + workspace string + ceilings delegate.Ceilings + api delegate.ModelAPI + hellos [][]string + stages []string + steps []string + terminals []delegate.Ending +} + +func (host *testHost) Workspace() string { return host.workspace } +func (host *testHost) Ceilings() delegate.Ceilings { return host.ceilings } +func (host *testHost) Models() delegate.ModelAPI { return host.api } + +func (host *testHost) Hello(stages []string) { + host.mu.Lock() + defer host.mu.Unlock() + host.hellos = append(host.hellos, stages) +} + +func (host *testHost) Stage(stage delegate.StageRecord) { + host.mu.Lock() + defer host.mu.Unlock() + host.stages = append(host.stages, stage.Stage+"/"+stage.Status) +} + +func (host *testHost) Step(step delegate.StepRecord) { + host.mu.Lock() + defer host.mu.Unlock() + host.steps = append(host.steps, step.Command) +} + +func (host *testHost) Terminal(end delegate.Ending) { + host.mu.Lock() + defer host.mu.Unlock() + host.terminals = append(host.terminals, end) +} + +// testModelAPI stands in for the model API codeaf serves a run. The tests that +// use it answer every request through their own transport, so nothing is sent +// to its address; a backend without one has nowhere to send a request at all. +var testModelAPI = delegate.ModelAPI{BaseURL: "http://model-api.invalid/v1", Token: "test-token"} + +// backendFunc is the stub model backend the pipeline tests run against. +type backendFunc func(context.Context, turn) (turnResult, error) + +func (f backendFunc) Run(ctx context.Context, request turn) (turnResult, error) { + return f(ctx, request) +} + +// guardWorkspace is a git repository with one commit, returning the workspace +// and its base SHA. Tests of the submission protocol need a real base to diff +// against, not a bare temp directory. +func guardWorkspace(t *testing.T) (string, string) { + t.Helper() + workspace := gitWorkspace(t, map[string]string{"README.md": "base\n"}) + return workspace, strings.TrimSpace( + gitOutput(context.Background(), workspace, "rev-parse", "HEAD"), + ) +} + +// testRepoWithEntrypoints is a git repository that also has runnable build and +// test entrypoints, for tests that let verification actually execute something. +func testRepoWithEntrypoints(t *testing.T) string { + t.Helper() + return gitWorkspace(t, map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + }) +} + +func gitWorkspace(t *testing.T, files map[string]string) string { + t.Helper() + workspace := t.TempDir() + if err := gitRun(workspace, "init", "-b", "main"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "config", "user.name", "senior-dev-test"); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "config", "user.email", "senior-dev@example.test"); err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(files)) + for name, content := range files { + if err := writeFile(filepath.Join(workspace, name), content); err != nil { + t.Fatal(err) + } + names = append(names, name) + } + if err := gitRun(workspace, append([]string{"add"}, names...)...); err != nil { + t.Fatal(err) + } + if err := gitRun(workspace, "commit", "-m", "base"); err != nil { + t.Fatal(err) + } + return workspace +} + +// verifiedTestPipeline is a pipeline over a workspace whose verification the test +// supplies directly, for cases that assert on how a verification RESULT is +// interpreted rather than on running one. +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +// testTurn is the request shape the engine tests drive a turn with. It is the +// subset of turn a caller has to supply; runTestTurn fills in the rest exactly +// as soloTurn does, so a test measures the engine the run actually uses. +type testTurn struct { + // SessionID drives the turn against an existing session instead of + // creating one. Tests of the durable transcript need a second runtime to + // land in the first one's session; production never needs this, because a + // run holds one session for its whole life. + SessionID string + Agent string + ProviderID string + ModelID string + Prompt string + Workspace string + SessionTitle string + ParentSessionID string +} + +// runTestTurn creates a session and runs one turn through the same seams +// soloTurn uses. The engine tests need an entry point that is not soloTurn +// itself, which builds its prompt from the checklist and the pinned command +// and so cannot be pointed at an arbitrary agent or model. +func runTestTurn( + t *testing.T, runtime *runtimeAdapter, input testTurn, +) (turnResult, error) { + t.Helper() + ctx := context.Background() + markdown, _ := baked.GetBakedAgent(input.Agent) + providerID, modelID := normalizeModelRef(input.ProviderID, input.ModelID) + sessionID := input.SessionID + if sessionID == "" { + info, err := runtime.createSession(ctx, sessioncore.CreateInput{ + ParentID: input.ParentSessionID, Title: input.SessionTitle, + Agent: input.Agent, Directory: input.Workspace, + Model: sessionModel(providerID, modelID, ""), + }) + if err != nil { + return turnResult{}, err + } + sessionID = info.ID + } + configured, err := runtime.configureTurn(turn{ + SessionID: sessionID, ParentSessionID: input.ParentSessionID, + SessionTitle: input.SessionTitle, Agent: input.Agent, + AgentMarkdown: markdown, Workspace: input.Workspace, + ProviderID: providerID, ModelID: modelID, Prompt: input.Prompt, + }) + if err != nil { + return turnResult{}, err + } + configured.ManageScratch = true + configured.SystemInstructions = runtime.registry.SystemInstructions(ctx) + configured.LoadInstructions = runtime.registry.SystemInstructions + configured.Tools = runtime.definitionsFor( + configured.ProviderID, configured.ModelID, input.Agent, nil, + ) + configured.Execute = runtime.registry.Execute + configured.AfterAssistant = runtime.registry.ClearInstructionClaims + result, err := runtime.runTurn(ctx, configured) + runtime.addCost(result.CostUSD) + if result.SessionID == "" { + result.SessionID = sessionID + } + return result, err +} + +type coderOnlyBackend struct { + mu sync.Mutex + + onCoder func(call int, request turn) error + + calls []turn + coderCalls int +} + +func (backend *coderOnlyBackend) Run( + _ context.Context, request turn, +) (turnResult, error) { + backend.mu.Lock() + backend.calls = append(backend.calls, request) + backend.mu.Unlock() + + switch request.Agent { + case "coder": + backend.mu.Lock() + backend.coderCalls++ + call := backend.coderCalls + backend.mu.Unlock() + if backend.onCoder != nil { + if err := backend.onCoder(call, request); err != nil { + return turnResult{}, err + } + } + return turnResult{Text: "coder completed"}, nil + default: + return turnResult{}, fmt.Errorf( + "test backend has no script for agent %q", request.Agent, + ) + } +} + +func (backend *coderOnlyBackend) count(agent string) int { + backend.mu.Lock() + defer backend.mu.Unlock() + total := 0 + for _, call := range backend.calls { + if call.Agent == agent { + total++ + } + } + return total +} + +func newRuntime(workspace string, client backend) *runtimeAdapter { + return newConfiguredRuntime(workspace, client, nil) +} + +// Create opens a session for agent under parentID. Tests of the durable +// transcript use it to hold a root session open across runtimes. +func (runtime *runtimeAdapter) Create( + ctx context.Context, parentID string, agent string, +) (string, error) { + info, err := runtime.createSession(ctx, sessioncore.CreateInput{ + ParentID: parentID, Agent: agent, Title: agent, Directory: runtime.workspace, + }) + return info.ID, err +} diff --git a/internal/seniordev/app/tier_test.go b/internal/seniordev/app/tier_test.go new file mode 100644 index 0000000000..5600a66973 --- /dev/null +++ b/internal/seniordev/app/tier_test.go @@ -0,0 +1,176 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// tierRouter builds the run's router from flag values exactly as a run does, +// with a seed so the picks are reproducible. +func tierRouter(args cliArgs) *adaptive.AdaptiveModelRouter { + seed := 17.0 + return adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: configuredCandidates(args.High, adaptive.ModelTierHigh), + LowModels: configuredCandidates(args.Low, adaptive.ModelTierLow), + FrontierModels: configuredCandidates(args.Frontier, adaptive.ModelTierFrontier), + RandomSeed: &seed, + }) +} + +func agentTier(agent string) adaptive.ModelTier { + return adaptive.ModelTier(baked.TierFor(agent)) +} + +func TestLowPoolChangesWhereTheCompactionSummaryRoutes(t *testing.T) { + // The compaction summary is the low tier's consumer. Without --low it + // routes on --high; with --low it routes on the low pool, and the coder + // stays on --high either way. + without := tierRouter(cliArgs{High: "openrouter/qwen/high-only"}) + with := tierRouter(cliArgs{ + High: "openrouter/qwen/high-only", Low: "openrouter/qwen/cheap", + }) + for _, test := range []struct { + router *adaptive.AdaptiveModelRouter + agent string + want string + tier adaptive.ModelTier + }{ + {without, "coder", "openrouter/qwen/high-only", adaptive.ModelTierHigh}, + {without, "compaction", "openrouter/qwen/high-only", adaptive.ModelTierHigh}, + {with, "coder", "openrouter/qwen/high-only", adaptive.ModelTierHigh}, + {with, "compaction", "openrouter/qwen/cheap", adaptive.ModelTierLow}, + } { + choice := test.router.PickSync(test.agent, agentTier(test.agent)) + if choice.Candidate.ID != test.want { + t.Errorf("%s routed to %q, want %q", test.agent, choice.Candidate.ID, test.want) + } + if choice.Tier != test.tier { + t.Errorf("%s routed on tier %q, want %q", test.agent, choice.Tier, test.tier) + } + test.router.Register(choice, 1, 10, nil) + } +} + +func TestFrontierPoolIsOptional(t *testing.T) { + router := tierRouter(cliArgs{High: "openrouter/qwen/high-only"}) + choice := router.PickSync("coder", adaptive.ModelTierFrontier) + if choice.Candidate.ID != "openrouter/qwen/high-only" { + t.Fatalf("frontier routed to %q with no frontier pool", choice.Candidate.ID) + } + router.Register(choice, 1, 10, nil) + + configured := tierRouter(cliArgs{ + High: "openrouter/qwen/high-only", Frontier: "openrouter/anthropic/big", + }) + choice = configured.PickSync("coder", adaptive.ModelTierFrontier) + if choice.Candidate.ID != "openrouter/anthropic/big" { + t.Fatalf("frontier routed to %q, want the frontier pool", choice.Candidate.ID) + } +} + +func TestASingleHighPoolRoutesEveryTierIdentically(t *testing.T) { + // The guarantee for a run that passes nothing but --high: the tier + // dimension must be invisible. `tiered` asks for each agent's configured + // tier, which degrades to high because no low or frontier pool exists; + // `flat` asks for high directly, which is the one code path a router with + // only a high pool had before tiers came back. Same seed, same pool, so + // every pick and every emitted event must agree field for field — the + // tier field itself excepted, since that is the field being added. + args := cliArgs{High: DefaultHighModels} + tiered, flat := tierRouter(args), tierRouter(args) + for round := 0; round < 6; round++ { + for _, agent := range []string{"coder", "compaction"} { + got := tiered.PickSync(agent, agentTier(agent)) + want := flat.PickSync(agent, adaptive.ModelTierHigh) + if got.Tier != adaptive.ModelTierHigh { + t.Fatalf("round %d: %s routed on %q, want a degraded high", round, agent, got.Tier) + } + if got != want { + t.Fatalf("round %d: %s chose\n %+v\n want %+v", round, agent, got, want) + } + gotEvent := tiered.Register(got, 1.5, 120, nil) + wantEvent := flat.Register(want, 1.5, 120, nil) + if gotEvent.Tier != adaptive.ModelTierHigh { + t.Fatalf("round %d: event tier = %q", round, gotEvent.Tier) + } + gotEvent.Tier, wantEvent.Tier = "", "" + if gotEvent != wantEvent { + t.Fatalf("round %d: %s event\n %+v\n want %+v", round, agent, gotEvent, wantEvent) + } + } + } +} + +func TestPoolResolverDegradesEmptyTiersToHigh(t *testing.T) { + resolver := poolResolver{high: []string{"a/one", "a/two"}} + for _, tier := range []baked.Tier{baked.TierHigh, baked.TierLow, baked.TierFrontier} { + if got := strings.Join(resolver.values(tier), ","); got != "a/one,a/two" { + t.Errorf("values(%q) = %q, want the high pool", tier, got) + } + } + resolver.low = []string{"b/cheap"} + resolver.frontier = []string{"c/big"} + for tier, want := range map[baked.Tier]string{ + baked.TierHigh: "a/one,a/two", + baked.TierLow: "b/cheap", + baked.TierFrontier: "c/big", + } { + if got := strings.Join(resolver.values(tier), ","); got != want { + t.Errorf("values(%q) = %q, want %q", tier, got, want) + } + } +} + +func TestRunContractRecordsThePoolEachTierRoutesOn(t *testing.T) { + workspace := gitWorkspace(t, map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + }) + var events bytes.Buffer + runner := newPipeline( + cliArgs{High: "a/one,a/two", Low: "b/cheap"}, + workspace, + pipelineDeps{ + Backend: &soloScriptedBackend{}, + Events: newEventWriter(&events), Notes: io.Discard, + }, + ) + defer runner.runtime.Close() + if _, err := runner.run(context.Background(), "Add the feature."); err != nil { + t.Fatal(err) + } + contract := map[string]any{} + for _, line := range bytes.Split(bytes.TrimSpace(events.Bytes()), []byte("\n")) { + var value event + if err := json.Unmarshal(line, &value); err != nil { + t.Fatalf("invalid NDJSON event %q: %v", line, err) + } + if value.Stage == "run-contract" { + contract = value.Data + } + } + for field, want := range map[string][]any{ + "high_models": {"a/one", "a/two"}, + "low_models": {"b/cheap"}, + "frontier_models": {"a/one", "a/two"}, + } { + got, _ := contract[field].([]any) + if len(got) != len(want) { + t.Fatalf("%s = %v, want %v", field, contract[field], want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("%s = %v, want %v", field, got, want) + } + } + } +} diff --git a/internal/seniordev/app/verification_deadtree.go b/internal/seniordev/app/verification_deadtree.go new file mode 100644 index 0000000000..34a4cb926f --- /dev/null +++ b/internal/seniordev/app/verification_deadtree.go @@ -0,0 +1,107 @@ +//go:build !windows + +package app + +import "strings" + +// Dead-tree detection for the unsubmitted-tree finalizer. +// +// A tree that can no longer be built or imported at all fails every test with +// certainty: one collection error aborts a pytest run, one compile error runs +// zero cargo tests. Such a tree is worth strictly less than an earlier +// checkpoint, so the finalizer restores one when the run ends unsubmitted on +// it (solo_finalize.go). +// +// A dead tree is a much stronger claim than a failing one -- degraded trees +// usually still carry most of their value, so the burden of proof stays on +// the restore. The tokens below are therefore SUITE-ABORT markers, not +// failure markers: each one is printed only when the toolchain stopped before +// running the suite. A failing test that merely mentions an ImportError in +// its assertion output matches nothing here. +var deadTreeTokens = []string{ + // cargo: `error: could not compile <crate>` -- zero tests ran. + "error: could not compile", + // go test's standard marker when a package fails to build. + "[build failed]", + // pytest's abort summary: `!! Interrupted: N error(s) during collection !!` + // (one import-time raise in a test file kills the whole run). + " during collection", + // pytest's per-file marker for the same condition. + "ERROR collecting", + // pytest aborts outright when a conftest fails to import. + "ImportError while loading", + // esbuild/mocha aborts before running any test when a TypeScript test file + // cannot be transformed. + "Exception during run: Error: Transform failed", +} + +// suiteDeadOutput reports whether a FAILING verification command's full +// output shows the suite aborted before running (as opposed to running and +// failing). +func suiteDeadOutput(output string) bool { + if output == "" { + return false + } + for _, token := range deadTreeTokens { + if strings.Contains(output, token) { + return true + } + } + return false +} + +// safetyRegressionOutput is broader than suiteDeadOutput. Jest can continue +// running unrelated suites after one changed TypeScript file fails to parse, +// so that tree is not globally dead, but it is still not a coherent +// candidate. The finalizer records it and withholds the coherent checkpoint; +// the narrower dead-tree classifier remains the only one allowed to restore +// an unsubmitted workspace to an earlier checkpoint. +func safetyRegressionOutput(output string) bool { + if suiteDeadOutput(output) { + return true + } + lower := strings.ToLower(output) + return strings.Contains(lower, "test suite failed to run") && + (strings.Contains(lower, "syntaxerror") || + strings.Contains(lower, "unexpected token") || + strings.Contains(lower, "transform failed")) +} + +// verificationShowsDeadTree scans a completed verification's command +// evidence for a failure that (a) is this run's own doing -- not a timeout -- +// and (b) carries a suite-abort signature. It returns the first such +// command. +func verificationShowsDeadTree(result projectVerificationResult) (string, bool) { + for _, command := range result.Commands { + evidence, ok := command.(map[string]any) + if !ok { + continue + } + exit, _ := evidence["exit"].(float64) + timedOut, _ := evidence["timedOut"].(bool) + dead, _ := evidence["suite_dead"].(bool) + if exit != 0 && !timedOut && dead { + cmd, _ := evidence["cmd"].(string) + return cmd, true + } + } + return "", false +} + +func verificationShowsSafetyRegression(result projectVerificationResult) (string, bool) { + for _, command := range result.Commands { + evidence, ok := command.(map[string]any) + if !ok { + continue + } + exit, _ := evidence["exit"].(float64) + timedOut, _ := evidence["timedOut"].(bool) + unsafe, _ := evidence["safety_regression"].(bool) + dead, _ := evidence["suite_dead"].(bool) + if exit != 0 && !timedOut && (unsafe || dead) { + cmd, _ := evidence["cmd"].(string) + return cmd, true + } + } + return "", false +} diff --git a/internal/seniordev/app/verification_deadtree_test.go b/internal/seniordev/app/verification_deadtree_test.go new file mode 100644 index 0000000000..0d08a7152d --- /dev/null +++ b/internal/seniordev/app/verification_deadtree_test.go @@ -0,0 +1,95 @@ +//go:build !windows + +package app + +import "testing" + +func deadCommandEvidence(cmd string, extra map[string]any) map[string]any { + evidence := map[string]any{"cmd": cmd, "exit": float64(1), "suite_dead": true} + for key, value := range extra { + evidence[key] = value + } + return evidence +} + +// The fixtures follow real toolchain output: each dead sample is the shape a +// build tool or test runner prints when it stops before running the suite, +// and each alive sample is a suite that ran and failed. +func TestSuiteDeadOutputClassifier(t *testing.T) { + dead := []string{ + // cargo build: a compile error, zero tests ran. + "error[E0063]: missing fields `alpha`, `beta`, `gamma` and 5 other fields\nerror: could not compile `widget` (bin \"widget\") due to 1 previous error", + // pytest: one import-time raise killed collection of the whole suite. + "!!!!!!!! Interrupted: 1 error during collection !!!!!!!!\n= 1 error in 1.73s =", + "ERROR collecting tests/unit/test_models.py", + "ImportError while loading conftest '/repo/tests/conftest.py'.", + "FAIL\tgithub.com/example/pkg [build failed]", + // esbuild aborted before mocha ran a test. + "Exception during run: Error: Transform failed with 1 error:\nref.test.js:12:9: ERROR: Expected \")\" but found \":\"", + } + for i, output := range dead { + if !suiteDeadOutput(output) { + t.Fatalf("dead fixture %d not classified:\n%s", i, output) + } + } + alive := []string{ + "", + // a suite that RAN and failed -- degraded, not dead + "FAILED tests/test_docs.py::test_commands_are_documented\n= 3 failed, 240 passed in 41.02s =", + // a failing test that merely mentions an import error in its output + "E ImportError: optional dependency 'foo' is not installed\n= 1 failed, 99 passed =", + // an assertion failed after many tests ran. + "TypeCheckError: Type 'Widget' does not satisfy constraint\n= 1 failed, 126 passed =", + // network failures are not suite aborts + "npm error 403 403 Forbidden - GET https://registry.npmjs.org/some-package", + "go: downloading github.com/example/migrate v1.0.0", + } + for i, output := range alive { + if suiteDeadOutput(output) { + t.Fatalf("alive fixture %d wrongly classified dead:\n%s", i, output) + } + } +} + +func TestVerificationShowsDeadTreeExcusesFailures(t *testing.T) { + excused := projectVerificationResult{Commands: []any{ + deadCommandEvidence("make test", map[string]any{"timedOut": true}), + map[string]any{"cmd": "cargo build", "exit": float64(0), "suite_dead": true}, + }} + if cmd, dead := verificationShowsDeadTree(excused); dead { + t.Fatalf("excused failures classified the tree dead via %q", cmd) + } + genuine := projectVerificationResult{Commands: []any{ + map[string]any{"cmd": "go vet", "exit": float64(0)}, + deadCommandEvidence("cargo build", nil), + }} + cmd, dead := verificationShowsDeadTree(genuine) + if !dead || cmd != "cargo build" { + t.Fatalf("genuine suite-abort not detected (cmd=%q dead=%v)", cmd, dead) + } +} + +func TestSafetyRegressionRecognizesJestParseAbortWithoutCallingWholeTreeDead(t *testing.T) { + output := "FAIL tests/feature.test.js\nTest suite failed to run\n" + + "SyntaxError: Jest encountered an unexpected token\n590 passed" + if suiteDeadOutput(output) { + t.Fatal("a suite-local Jest parse failure was classified as a globally dead tree") + } + if !safetyRegressionOutput(output) { + t.Fatal("the Jest parse failure was not classified as a safety regression") + } + result := projectVerificationResult{Commands: []any{map[string]any{ + "cmd": "npm test", "exit": float64(1), "safety_regression": true, + }}} + if command, unsafe := verificationShowsSafetyRegression(result); !unsafe || command != "npm test" { + t.Fatalf("safety regression = (%q, %v), want npm test, true", command, unsafe) + } +} + +func TestRememberVerifiedTreeRetainsTheVerdictForFinalization(t *testing.T) { + runner := gitTestRepo(t) + runner.rememberVerifiedTree(verificationWith(1)) + if runner.lastVerify == nil { + t.Fatal("verification was not retained for finalization") + } +} diff --git a/internal/seniordev/app/verification_timeout_test.go b/internal/seniordev/app/verification_timeout_test.go new file mode 100644 index 0000000000..ebcc53c940 --- /dev/null +++ b/internal/seniordev/app/verification_timeout_test.go @@ -0,0 +1,171 @@ +//go:build !windows + +package app + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/fullverification" +) + +// Contract for a verification entrypoint that hangs. Without it a run can +// spend half its budget running the same suite several times, each attempt +// killed at the 600s ceiling with byte-identical output, and then be told to +// "rerun the exact command until it exits 0": +// +// - a hung entrypoint must be reported as HUNG, not as "exited -1", so the +// model can tell an unfinished suite from a red one; +// - the same command must not be re-executed against an unchanged tree — the +// answer cannot differ and each attempt costs the full ceiling; +// - a changed tree DOES earn a fresh attempt, because the agent may have +// fixed the hang; +// - a hang must never let the run go green. +func newTimeoutWorkspace(t *testing.T) (workspace, marker string) { + t.Helper() + workspace = t.TempDir() + marker = filepath.Join(t.TempDir(), "executions") + if err := writeFile(filepath.Join(workspace, "go.mod"), + "module example.test/hang\n\ngo 1.23\n"); err != nil { + t.Fatal(err) + } + source := fmt.Sprintf(`package hang + +import ( + "os" + "testing" +) + +func TestHang(t *testing.T) { + file, err := os.OpenFile(%q, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { t.Fatal(err) } + defer file.Close() + if _, err := file.WriteString("x"); err != nil { t.Fatal(err) } +} +`, marker) + if err := writeFile(filepath.Join(workspace, "hang_test.go"), source); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "AGENTS.md"), + "Run `go build ./...` and `go test -count=1 ./...`.\n"); err != nil { + t.Fatal(err) + } + // worktreeFingerprint shells out to `git ls-files`, so the memo only has a + // tree identity to compare against inside a repository. + if out, err := exec.Command("git", "-C", workspace, "init", "-q").CombinedOutput(); err != nil { + t.Skipf("git unavailable: %v: %s", err, out) + } + return workspace, marker +} + +func executionCount(t *testing.T, marker string) int { + t.Helper() + body, err := os.ReadFile(marker) + if os.IsNotExist(err) { + return 0 + } + if err != nil { + t.Fatal(err) + } + return len(body) +} + +func TestVerificationTimeoutIsNotRerunAgainstAnUnchangedTree(t *testing.T) { + workspace, marker := newTimeoutWorkspace(t) + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + defer runner.runtime.Close() + + plan := fullverification.Discover(workspace) + var testEntry *fullverification.Entrypoint + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind == fullverification.KindTest { + found := entrypoint + testEntry = &found + break + } + } + if testEntry == nil { + t.Fatalf("no test entrypoint discovered from %#v", plan.Entrypoints) + } + fingerprint, ok := runner.worktreeFingerprint(context.Background()) + if !ok { + t.Skip("worktree fingerprint unavailable in this environment") + } + + // Stand in for a prior cycle that hung: the entrypoint is on record as + // having been killed at the ceiling against exactly this tree. + runner.verificationTimeouts = map[string]timedOutEntrypoint{ + verificationMemoKey(*testEntry): { + Tail: "tests/test_server.py .\ncommand timed out after 600000ms", + Fingerprint: fingerprint, + HaveFinger: true, + }, + } + + result := runner.runProjectVerification(context.Background()) + + if got := executionCount(t, marker); got != 0 { + t.Errorf("test entrypoint executed %d time(s); a recorded hang against an "+ + "unchanged tree must be replayed, not re-run at the full ceiling", got) + } + if !result.TimedOut { + t.Errorf("result.TimedOut = false; the replayed observation must stay a timeout") + } + if result.Failed == nil { + t.Fatalf("result.Failed = nil; a hung suite must not let the run go green") + } + if strings.Contains(result.Failure, "exited -1") { + t.Errorf("failure text reports an exit code for a command that never exited: %q", result.Failure) + } + if !strings.Contains(result.Failure, "hung") { + t.Errorf("failure text does not say the suite hung: %q", result.Failure) + } + // The evidence row must carry the distinction the prompt text now makes. + row, isMap := result.Commands[len(result.Commands)-1].(map[string]any) + if !isMap || row["timedOut"] != true { + t.Errorf("evidence row missing timedOut marker: %#v", result.Commands) + } +} + +func TestVerificationTimeoutIsRetriedOnceTheTreeChanges(t *testing.T) { + workspace, marker := newTimeoutWorkspace(t) + runner := newPipeline(cliArgs{}, workspace, pipelineDeps{ + Events: newEventWriter(io.Discard), Notes: io.Discard, + }) + defer runner.runtime.Close() + + plan := fullverification.Discover(workspace) + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind != fullverification.KindTest { + continue + } + // A stale fingerprint: the agent has edited the tree since the hang, so + // the command deserves a fresh attempt. + runner.verificationTimeouts = map[string]timedOutEntrypoint{ + verificationMemoKey(entrypoint): { + Tail: "command timed out after 600000ms", + Fingerprint: "stale-fingerprint-from-before-the-fix", HaveFinger: true, + }, + } + } + + result := runner.runProjectVerification(context.Background()) + + if got := executionCount(t, marker); got == 0 { + t.Errorf("test entrypoint never ran; a changed tree must earn a fresh attempt") + } + if result.TimedOut { + t.Errorf("result.TimedOut = true for a suite that completed") + } + if result.Failed != nil { + t.Errorf("green verification reported a failure: %#v", result.Failed) + } +} diff --git a/internal/seniordev/app/workspace_git.go b/internal/seniordev/app/workspace_git.go new file mode 100644 index 0000000000..f3f132a384 --- /dev/null +++ b/internal/seniordev/app/workspace_git.go @@ -0,0 +1,52 @@ +//go:build !windows + +package app + +// Git helpers shared by the submit freeze, the ship decision and the +// unsubmitted-tree finalizer. + +// countFailingEntrypoints counts the entrypoints whose command exited +// non-zero. +func countFailingEntrypoints(result projectVerificationResult) int { + failing := 0 + for _, command := range result.Commands { + evidence, ok := command.(map[string]any) + if !ok { + continue + } + if exit, _ := evidence["exit"].(float64); exit != 0 { + failing++ + } + } + return failing +} + +// workspaceGit runs git in the workspace with the committer identity pinned, +// the same way eager-commit does. Without it `commit-tree` dies with "Author +// identity unknown" in a container that has no git config -- and commit-tree +// is how submit records the frozen candidate, so the run could not submit at +// all. The identity flags are `-c` overrides, which git ranks below +// GIT_COMMITTER_*/GIT_AUTHOR_*, so an environment that sets those still wins. +// currentTreeSHA identifies the working tree as it stands. It is a thin name +// for the recorder's promise, kept because the run reads better saying what it +// wants than naming the thing that provides it. +func (runner *pipeline) currentTreeSHA() (string, error) { + return runner.recorder.Snapshot() +} + +// rememberVerifiedTree caches the last completed full verification against +// the git tree it measured, so the finalizer can consult the last verdict on +// an unchanged tree without re-verifying. The runs that need the dead-tree +// check end with no wall left to verify anything. +func (runner *pipeline) rememberVerifiedTree(result projectVerificationResult) { + if result.TimedOut || len(result.Commands) == 0 { + return + } + treeSHA, err := runner.currentTreeSHA() + if err != nil { + return + } + remembered := result + runner.lastVerify = &remembered + runner.lastVerifyTreeSHA = treeSHA +} diff --git a/internal/seniordev/app/workspace_recorder.go b/internal/seniordev/app/workspace_recorder.go new file mode 100644 index 0000000000..f5feef10e5 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder.go @@ -0,0 +1,105 @@ +//go:build !windows + +package app + +import ( + "context" +) + +// workspaceRecorder is how a run identifies, compares, freezes and restores +// the workspace tree. The run's logic is written against this and never against +// git: git is one way to keep these promises, not the only one. +// +// Identifiers (the strings returned by Base, Snapshot and Record) are opaque. +// The run passes them back in and compares them for equality; it never parses +// them. Under the git recorder they are object names, which is why they read +// like SHAs in the event stream. +type workspaceRecorder interface { + // Kind names the recorder on the run contract: "git" or "snapshot". + Kind() string + + // Prepare checks the workspace is usable and arranges for senior-dev's own + // artifacts to stay out of the answer. It runs once, before the base is + // resolved, and its error refuses the run. + Prepare(ctx context.Context) error + + // Base identifies the tree the run starts from. Everything the run reports + // as changed is changed relative to this. + Base(ctx context.Context) (string, error) + + // Snapshot identifies the tree as it stands right now, including files no + // one has committed or added. Two identical trees give the same identifier + // and two different trees do not. + Snapshot() (string, error) + + // Record captures the tree named by treeID as something Restore can bring + // back, and returns a handle to it. label is human-readable provenance. + Record(treeID, label string) (string, error) + + // Publish makes a recorded handle reachable from outside this process under + // a stable name, so a run killed between recording and finalizing still has + // something to recover. Best-effort: a failure is noted, never fatal. + Publish(name, handle string) error + + // Restore makes the working tree the one Record captured, and proves it by + // re-identifying the result. wantTree is that proof; a mismatch is an error. + Restore(handle, wantTree string) error + + // BaseTree resolves a base identifier from Base to the tree identifier it + // names, so a base can be used as a restore target of last resort. ok is + // false when the base cannot be resolved, which is not an error. + BaseTree(base string) (treeID string, ok bool) + + // Change compares the working tree against a base identifier, excluding + // senior-dev's own artifacts. This is what the submit gate consults. + Change(base string) (soloTreeChange, error) + + // ListPaths enumerates every path belonging to the tree, ignores honoured, + // sorted. It gives up rather than reading an unbounded tree: overBudget + // reports that the listing exceeded maxBytes. + ListPaths(ctx context.Context, maxBytes int) ( + paths []string, consumed int, overBudget bool, err error, + ) + + // Summary describes the run's final diff against base for the patch-summary + // event. It is observational: nothing in the run acts on it, so a recorder + // that cannot produce a field omits it rather than failing. The returned + // status is the event's status. + Summary(ctx context.Context, base string) (data map[string]any, status string) + + // CommitsOnWrite reports whether the recorder wants a checkpoint taken + // after each file write. Git does, because a per-write commit is nearly + // free; copying the tree after every edit would not be. + CommitsOnWrite() bool +} + +// newWorkspaceRecorder picks the recorder for a run. Git is used IF IT IS +// THERE: a workspace inside a repository with a commit gets the git recorder, +// and anything else — a plain folder, a repository with no commit yet, a +// broken .git, a machine with no git — gets the snapshot recorder, exactly as +// --in-place would. A run never ends for want of git. +// +// Inference only ever steps DOWN from git, never up. --in-place still forces +// the snapshot recorder inside a real repository, because a run there that +// must not touch the history is indistinguishable, from the filesystem, from +// one that should; that choice stays the caller's (codeaf makes it for a +// repository rooted at the home folder). +func newWorkspaceRecorder( + args cliArgs, workspace string, note func(string), +) workspaceRecorder { + if args.InPlace || !hasGitHistory(workspace) { + return newSnapshotRecorder(workspace, note) + } + return newGitRecorder(workspace, note) +} + +// hasGitHistory reports whether the workspace is inside a git work tree whose +// HEAD is a commit — the two things the git recorder cannot start without +// (Prepare's work tree, Base's commit). No git on PATH answers false. +func hasGitHistory(workspace string) bool { + ctx := context.Background() + if gitOutput(ctx, workspace, "rev-parse", "--is-inside-work-tree") != "true" { + return false + } + return gitOutput(ctx, workspace, "rev-parse", "--verify", "--quiet", "HEAD^{commit}") != "" +} diff --git a/internal/seniordev/app/workspace_recorder_git.go b/internal/seniordev/app/workspace_recorder_git.go new file mode 100644 index 0000000000..52867ba8b0 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder_git.go @@ -0,0 +1,346 @@ +//go:build !windows + +package app + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +// gitRecorder keeps the workspaceRecorder promises with git. It is the default +// and the only recorder that leaves the run's work in the repository's own +// history. +type gitRecorder struct { + workspace string + note func(string) +} + +func newGitRecorder(workspace string, note func(string)) *gitRecorder { + return &gitRecorder{workspace: workspace, note: note} +} + +func (recorder *gitRecorder) Kind() string { return "git" } +func (recorder *gitRecorder) CommitsOnWrite() bool { return true } + +// git runs a git command in the workspace and returns its trimmed output. +func (recorder *gitRecorder) git(args ...string) (string, error) { + argv := util.GitArgv(args...) + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Dir = recorder.workspace + out, err := cmd.CombinedOutput() + if err != nil { + // The logical args, not the identity flags: the message is read by a + // model deciding what to fix, and the flags are never the problem. + return "", fmt.Errorf( + "git %s: %v: %s", + strings.Join(args, " "), err, strings.TrimSpace(string(out)), + ) + } + return strings.TrimSpace(string(out)), nil +} + +func (recorder *gitRecorder) Prepare(ctx context.Context) error { + if gitOutput(ctx, recorder.workspace, "rev-parse", "--show-toplevel") == "" { + // Unreachable in practice: newWorkspaceRecorder picks this recorder + // only after reading a work tree with a commit. It stays for a folder + // whose repository vanished between that reading and this one. + return fmt.Errorf("workspace is not a git repository: %s; run with --in-place to work in a plain folder", recorder.workspace) + } + // Exclude senior-dev's own artifacts on the workspace at bootstrap + // (non-fatal): without it the run's commits sweep senior-dev's bookkeeping + // into the repository history, and the final patch carries files the + // request never asked for. + if _, err := util.EnsureSeniorDevExcluded(ctx, recorder.workspace); err != nil { + recorder.note("[senior-dev] ensureSeniorDevExcluded failed (non-fatal): " + err.Error() + "\n") + } + return nil +} + +func (recorder *gitRecorder) Base(ctx context.Context) (string, error) { + baseSHA := gitOutput(ctx, recorder.workspace, "rev-parse", "HEAD") + if baseSHA == "" { + return "", fmt.Errorf("senior-dev run requires a git repository with at least one commit, or --in-place") + } + resolved := gitOutput(ctx, recorder.workspace, "rev-parse", "--verify", baseSHA+"^{commit}") + if resolved == "" { + return "", fmt.Errorf("base commit %q is not available in the workspace", baseSHA) + } + return resolved, nil +} + +// Snapshot captures the full working tree (tracked and untracked, minus +// ignored) as a git tree object, through a temporary index so the real index +// and working tree are untouched. +func (recorder *gitRecorder) Snapshot() (string, error) { + gitDir, err := recorder.git("rev-parse", "--git-dir") + if err != nil { + return "", err + } + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(recorder.workspace, gitDir) + } + tmp, err := os.CreateTemp(gitDir, "senior-dev-tree-index-*") + if err != nil { + return "", fmt.Errorf("create temporary git index: %w", err) + } + tmpIndex := tmp.Name() + if closeErr := tmp.Close(); closeErr != nil { + _ = os.Remove(tmpIndex) + return "", fmt.Errorf("close temporary git index: %w", closeErr) + } + // GIT_INDEX_FILE requires either a valid index or no file. CreateTemp gives + // us a collision-free name; remove the empty file before asking Git to + // initialize it from HEAD. Loading HEAD is essential: git add -A against an + // empty index omits tracked-but-ignored files and invents phantom deletions. + if err := os.Remove(tmpIndex); err != nil { + return "", fmt.Errorf("prepare temporary git index: %w", err) + } + defer os.Remove(tmpIndex) + env := append(os.Environ(), "GIT_INDEX_FILE="+tmpIndex) + read := exec.Command("git", "read-tree", "HEAD") + read.Dir, read.Env = recorder.workspace, env + if out, err := read.CombinedOutput(); err != nil { + return "", fmt.Errorf("git read-tree HEAD: %v: %s", err, strings.TrimSpace(string(out))) + } + add := exec.Command("git", "add", "-A", ".") + add.Dir, add.Env = recorder.workspace, env + if out, err := add.CombinedOutput(); err != nil { + return "", fmt.Errorf("git add -A: %v: %s", err, strings.TrimSpace(string(out))) + } + write := exec.Command("git", "write-tree") + write.Dir, write.Env = recorder.workspace, env + out, err := write.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git write-tree: %v: %s", err, strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +// Record writes a commit object for an already-written tree without moving +// HEAD, the index, or the working tree. The commit exists so the candidate can +// be restored later by a single git command even if the run dies before +// finalize. +func (recorder *gitRecorder) Record(treeID, label string) (string, error) { + parent, parentErr := recorder.git("rev-parse", "HEAD") + args := []string{"commit-tree", treeID, "-m", label} + if parentErr == nil && parent != "" { + args = []string{"commit-tree", treeID, "-p", parent, "-m", label} + } + return recorder.git(args...) +} + +func (recorder *gitRecorder) Publish(name, handle string) error { + _, err := recorder.git("update-ref", name, handle) + return err +} + +// Restore makes the working tree byte-identical to a recorded commit's tree, +// and proves it did by re-hashing. +// +// `checkout --force <commit> -- .` alone is OVERLAY checkout: it writes the +// commit's files and deletes nothing. Every file the model writes is tracked +// (eager-commit), so a file ADDED after the checkpoint -- probe debris is the +// common case -- survives both the checkout and a `clean -fd`, and the +// "restored" tree does not match the checkpoint. `--no-overlay` would fix it +// but needs git >= 2.23, which not every image has; resetting the index to +// the commit first makes the extras untracked, so the same old-git `clean` +// removes them. +func (recorder *gitRecorder) Restore(handle, wantTree string) error { + if _, err := recorder.git("checkout", "--force", handle, "--", "."); err != nil { + return err + } + if _, err := recorder.git("reset", "-q", handle, "--", "."); err != nil { + return err + } + if _, err := recorder.git("clean", "-fd"); err != nil { + return err + } + actual, err := recorder.Snapshot() + if err != nil { + return err + } + if actual != wantTree { + return fmt.Errorf( + "restored tree %s, want %s", shortSHA(actual), shortSHA(wantTree), + ) + } + return nil +} + +func (recorder *gitRecorder) BaseTree(base string) (string, bool) { + tree, err := recorder.git("rev-parse", base+"^{tree}") + if err != nil || tree == "" { + return "", false + } + return tree, true +} + +// Change compares the workspace against the base commit's tree, ignoring +// senior-dev's own artifacts. +// +// It deliberately does not use `git diff <base>` against the working copy, +// which reports only tracked changes. A run whose whole deliverable is a new +// file -- which is most of them -- produces an empty `git diff` while having +// changed everything that matters, so diffing that way would refuse exactly +// the submissions worth accepting. Snapshot stages everything through a +// temporary index, so comparing against that tree sees new files the way a +// diff of the final tree will. +func (recorder *gitRecorder) Change(base string) (soloTreeChange, error) { + treeSHA, err := recorder.Snapshot() + if err != nil { + return soloTreeChange{}, err + } + change := soloTreeChange{treeSHA: treeSHA} + baseTree, ok := recorder.BaseTree(base) + if !ok { + // No resolvable base: any tree at all is a change, and refusing to + // submit because we cannot name the starting point would be worse than + // accepting one we cannot size. + change.changed = true + return change, nil + } + diffArgs := func(extra ...string) []string { + args := append([]string{"diff"}, extra...) + args = append(args, baseTree, treeSHA, "--") + return append(args, seniorDevArtifactPathspecs...) + } + names, err := recorder.git(diffArgs("--name-only")...) + if err != nil { + return change, err + } + change.files = len(nonEmptyLines(names)) + change.changed = change.files > 0 + if !change.changed { + return change, nil + } + if patch, err := recorder.git(diffArgs()...); err == nil { + change.patch = patch + } + return change, nil +} + +func (recorder *gitRecorder) ListPaths( + ctx context.Context, maxBytes int, +) ([]string, int, bool, error) { + command := exec.CommandContext( + ctx, "git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", + ) + command.Dir = recorder.workspace + stdout, err := command.StdoutPipe() + if err != nil { + return nil, 0, false, err + } + if err := command.Start(); err != nil { + return nil, 0, false, err + } + raw, readErr := io.ReadAll(io.LimitReader(stdout, int64(maxBytes)+1)) + if len(raw) > maxBytes { + _ = command.Process.Kill() + _ = command.Wait() + return nil, len(raw), true, nil + } + waitErr := command.Wait() + if readErr != nil { + return nil, len(raw), false, readErr + } + if waitErr != nil { + return nil, len(raw), false, waitErr + } + paths := strings.Split(string(raw), "\x00") + if len(paths) > 0 && paths[len(paths)-1] == "" { + paths = paths[:len(paths)-1] + } + sort.Strings(paths) + return paths, len(raw), false, nil +} + +// Summary records the shape of the run's final diff against the base commit -- +// files, line counts, binaries, patch bytes, untracked files. +func (recorder *gitRecorder) Summary( + ctx context.Context, base string, +) (map[string]any, string) { + workspace := recorder.workspace + data := map[string]any{"base_sha": base} + if head := gitOutput(ctx, workspace, "rev-parse", "HEAD"); head != "" { + data["head_sha"] = head + } + nameOutput := gitOutput(ctx, workspace, "diff", "--name-only", "--no-renames", base, "--") + files := 0 + if strings.TrimSpace(nameOutput) != "" { + files = len(strings.Split(strings.TrimSpace(nameOutput), "\n")) + } + data["files"] = files + + additions, deletions, binaries := int64(0), int64(0), 0 + for _, line := range strings.Split( + gitOutput(ctx, workspace, "diff", "--numstat", "--no-renames", base, "--"), "\n", + ) { + fields := strings.Fields(line) + if len(fields) < 3 { + continue + } + if fields[0] == "-" || fields[1] == "-" { + binaries++ + continue + } + if value, err := strconv.ParseInt(fields[0], 10, 64); err == nil { + additions += value + } + if value, err := strconv.ParseInt(fields[1], 10, 64); err == nil { + deletions += value + } + } + data["additions"], data["deletions"], data["binary_files"] = additions, deletions, binaries + + var patchBytes countingWriter + var diffError bytes.Buffer + command := exec.CommandContext(ctx, "git", "diff", "--binary", "--no-renames", base, "--") + command.Dir, command.Stdout, command.Stderr = workspace, &patchBytes, &diffError + status := "completed" + if err := command.Run(); err != nil { + status = "error" + data["error"] = strings.TrimSpace(diffError.String()) + } else { + data["patch_bytes"] = int64(patchBytes) + } + untracked := gitOutput(ctx, workspace, "ls-files", "--others", "--exclude-standard") + if strings.TrimSpace(untracked) != "" { + data["untracked_files"] = len(strings.Split(strings.TrimSpace(untracked), "\n")) + } else { + data["untracked_files"] = 0 + } + return data, status +} + +// gitStatusFindings reports an unclean index as a landing finding. It exists +// only under the git recorder: the advice it gives -- commit before verifying +// -- is meaningless where nothing commits. +func (recorder *gitRecorder) statusFindings() []string { + status, err := recorder.git("status", "--porcelain") + if err != nil { + return nil + } + entries := nonEmptyLines(status) + if len(entries) == 0 { + return nil + } + return []string{fmt.Sprintf( + "git status is not clean (%d uncommitted entr%s) — the pinned command must pass "+ + "on the COMMITTED tree, so commit before verifying", + len(entries), plural(len(entries), "y", "ies"), + )} +} + +// summaryTimeout bounds the observational patch summary. +const summaryTimeout = 15 * time.Second diff --git a/internal/seniordev/app/workspace_recorder_snapshot.go b/internal/seniordev/app/workspace_recorder_snapshot.go new file mode 100644 index 0000000000..414aed6146 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder_snapshot.go @@ -0,0 +1,515 @@ +//go:build !windows + +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "sync" +) + +// snapshotRecorder keeps the workspaceRecorder promises without git. It edits +// the workspace in place, takes no locks on it, writes nothing into it beyond +// what the model writes, and keeps its own copies of the tree outside it. +// +// Identifiers are content addresses: the SHA-256 of a manifest of every path, +// mode and content hash in the tree. Two identical trees therefore have the +// same identifier and two different trees do not, which is the only property +// the run relies on. +type snapshotRecorder struct { + workspace string + note func(string) + + mu sync.Mutex + store string // lazily created; "" until the first snapshot is kept + // published maps a name to the handle last published under it, so a + // restore target survives in-process even where nothing writes a ref. + published map[string]string +} + +func newSnapshotRecorder(workspace string, note func(string)) *snapshotRecorder { + return &snapshotRecorder{ + workspace: workspace, note: note, published: map[string]string{}, + } +} + +func (recorder *snapshotRecorder) Kind() string { return "snapshot" } +func (recorder *snapshotRecorder) CommitsOnWrite() bool { return false } + +func (recorder *snapshotRecorder) Prepare(ctx context.Context) error { + if _, err := os.Stat(recorder.workspace); err != nil { + return fmt.Errorf("workspace is not readable: %w", err) + } + // Nothing to arrange. The artifact exclusion the git recorder writes into + // .git/info/exclude is unnecessary here: the walker skips .senior-dev/ by + // construction, so the artifacts cannot enter a snapshot in the first + // place. + return nil +} + +// Base is the tree as the run found it. There is no commit to name, so the +// starting tree names itself, and the run's "unchanged since the start" test +// is an identifier comparison exactly as it is under git. +func (recorder *snapshotRecorder) Base(ctx context.Context) (string, error) { + return recorder.Snapshot() +} + +func (recorder *snapshotRecorder) Snapshot() (string, error) { + entries, err := recorder.walk() + if err != nil { + return "", err + } + return manifestID(entries), nil +} + +// Record copies the working tree into the store under its own identifier. A +// tree already stored is not copied again: identical identifiers mean +// identical content, so the first copy is as good as a second. +func (recorder *snapshotRecorder) Record(treeID, label string) (string, error) { + store, err := recorder.ensureStore() + if err != nil { + return "", err + } + target := filepath.Join(store, treeID) + if _, err := os.Stat(target); err == nil { + return treeID, nil + } + entries, err := recorder.walk() + if err != nil { + return "", err + } + if actual := manifestID(entries); actual != treeID { + return "", fmt.Errorf( + "tree changed while recording it: %s, want %s", + shortSHA(actual), shortSHA(treeID), + ) + } + // Assembled beside the final name and renamed into place, so a crash + // mid-copy cannot leave a half-tree that a later Stat would accept. + staging, err := os.MkdirTemp(store, "staging-*") + if err != nil { + return "", fmt.Errorf("create snapshot staging directory: %w", err) + } + defer os.RemoveAll(staging) + for _, entry := range entries { + source := filepath.Join(recorder.workspace, filepath.FromSlash(entry.path)) + destination := filepath.Join(staging, filepath.FromSlash(entry.path)) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return "", err + } + if err := copyFile(source, destination, entry.mode); err != nil { + return "", err + } + } + if err := os.WriteFile( + filepath.Join(staging, ".senior-dev-label"), []byte(label+"\n"), 0o644, + ); err != nil { + return "", err + } + if err := os.Rename(staging, target); err != nil { + // Another Record of the same tree won the race; its copy is identical. + if _, statErr := os.Stat(target); statErr == nil { + return treeID, nil + } + return "", fmt.Errorf("store snapshot: %w", err) + } + return treeID, nil +} + +// Publish records the name in memory. There is no repository to hang a ref on, +// so unlike the git recorder this does not survive the process -- which is +// why the interface calls it best-effort and nothing depends on it. +func (recorder *snapshotRecorder) Publish(name, handle string) error { + recorder.mu.Lock() + defer recorder.mu.Unlock() + recorder.published[name] = handle + return nil +} + +// Restore makes the working tree the recorded one: every path the tree has now +// and the snapshot does not is removed, every path the snapshot has is written, +// and the result is re-identified as proof. +func (recorder *snapshotRecorder) Restore(handle, wantTree string) error { + recorder.mu.Lock() + store := recorder.store + recorder.mu.Unlock() + if store == "" { + return fmt.Errorf("no snapshot store: nothing was recorded") + } + source := filepath.Join(store, handle) + if _, err := os.Stat(source); err != nil { + return fmt.Errorf("snapshot %s is not in the store: %w", shortSHA(handle), err) + } + wanted, err := walkTree(source, false) + if err != nil { + return err + } + wantedPaths := map[string]struct{}{} + for _, entry := range wanted { + wantedPaths[entry.path] = struct{}{} + } + current, err := recorder.walk() + if err != nil { + return err + } + // Remove first: a path that is a file in the snapshot and a directory now + // (or the reverse) cannot be written over in place. + for _, entry := range current { + if _, keep := wantedPaths[entry.path]; keep { + continue + } + if err := os.Remove( + filepath.Join(recorder.workspace, filepath.FromSlash(entry.path)), + ); err != nil && !os.IsNotExist(err) { + return err + } + } + for _, entry := range wanted { + destination := filepath.Join(recorder.workspace, filepath.FromSlash(entry.path)) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + if err := copyFile( + filepath.Join(source, filepath.FromSlash(entry.path)), destination, entry.mode, + ); err != nil { + return err + } + } + recorder.pruneEmptyDirs() + actual, err := recorder.Snapshot() + if err != nil { + return err + } + if actual != wantTree { + return fmt.Errorf( + "restored tree %s, want %s", shortSHA(actual), shortSHA(wantTree), + ) + } + return nil +} + +// BaseTree is the identity function: a snapshot base IS a tree identifier, +// where a git base is a commit that has to be resolved to one. +func (recorder *snapshotRecorder) BaseTree(base string) (string, bool) { + if base == "" { + return "", false + } + return base, true +} + +func (recorder *snapshotRecorder) Change(base string) (soloTreeChange, error) { + entries, err := recorder.walk() + if err != nil { + return soloTreeChange{}, err + } + change := soloTreeChange{treeSHA: manifestID(entries)} + recorder.mu.Lock() + store := recorder.store + recorder.mu.Unlock() + if base == "" || store == "" { + // Nothing to compare against: any tree at all is a change, and + // refusing to submit because we cannot name the starting point would + // be worse than accepting one we cannot size. + change.changed = true + return change, nil + } + baseDir := filepath.Join(store, base) + baseEntries, err := walkTree(baseDir, false) + if err != nil { + change.changed = true + return change, nil + } + change.files = countChangedPaths(baseEntries, entries) + change.changed = change.files > 0 + // No patch text: producing one needs a diff algorithm this recorder does + // not carry. The field is advisory -- the submit gate reads `changed` and + // `files` -- so it is left empty rather than faked. + return change, nil +} + +func (recorder *snapshotRecorder) ListPaths( + ctx context.Context, maxBytes int, +) ([]string, int, bool, error) { + entries, err := recorder.walk() + if err != nil { + return nil, 0, false, err + } + paths := make([]string, 0, len(entries)) + consumed := 0 + for _, entry := range entries { + consumed += len(entry.path) + 1 + if consumed > maxBytes { + return nil, consumed, true, nil + } + paths = append(paths, entry.path) + } + sort.Strings(paths) + return paths, consumed, false, nil +} + +// Summary reports what it can measure without a diff algorithm: which paths +// differ from the base and how many bytes they hold. `additions` and +// `deletions` are absent rather than guessed -- the event contract marks them +// optional for exactly this reason. +func (recorder *snapshotRecorder) Summary( + ctx context.Context, base string, +) (map[string]any, string) { + data := map[string]any{"base_sha": base} + entries, err := recorder.walk() + if err != nil { + data["error"] = err.Error() + return data, "error" + } + recorder.mu.Lock() + store := recorder.store + recorder.mu.Unlock() + if store == "" || base == "" { + data["files"] = len(entries) + data["untracked_files"] = 0 + return data, "completed" + } + baseEntries, err := walkTree(filepath.Join(store, base), false) + if err != nil { + data["error"] = err.Error() + return data, "error" + } + data["files"] = countChangedPaths(baseEntries, entries) + data["binary_files"] = 0 + data["untracked_files"] = 0 + changedBytes := int64(0) + baseByPath := map[string]treeEntry{} + for _, entry := range baseEntries { + baseByPath[entry.path] = entry + } + for _, entry := range entries { + if previous, ok := baseByPath[entry.path]; !ok || previous.hash != entry.hash { + changedBytes += entry.size + } + } + data["patch_bytes"] = changedBytes + return data, "completed" +} + +// ── the tree walk ──────────────────────────────────────────────────── + +type treeEntry struct { + path string // slash-separated, relative to the tree root + mode os.FileMode + size int64 + hash string +} + +func (recorder *snapshotRecorder) walk() ([]treeEntry, error) { + return walkTree(recorder.workspace, true) +} + +// walkTree lists every regular file in root, sorted, with its content hash. +// honourIgnores is false inside the store, where everything present belongs to +// the snapshot by construction and a stray .gitignore must not remove files +// from a tree that was already decided. +// +// A FOLDER OR FILE THE WORKSPACE WILL NOT LET US READ IS NOT PART OF THE TREE. +// It is skipped on every walk alike, so it is in no snapshot, no change count +// and no restore, and nothing of it is removed or written; one folder the +// system keeps to itself (macOS answers `operation not permitted` for some +// even to their owner) no longer ends the run before its first step. Inside +// the store every file is ours, and an error there is still an error. +func walkTree(root string, honourIgnores bool) ([]treeEntry, error) { + rules := newIgnoreRules() + if honourIgnores { + rules.load(root, "") + } + var entries []treeEntry + err := filepath.Walk(root, func(name string, info os.FileInfo, err error) error { + if err != nil { + return skipUnreadable(err, name != root && honourIgnores, info) + } + relative, relErr := filepath.Rel(root, name) + if relErr != nil { + return relErr + } + relative = filepath.ToSlash(relative) + if relative == "." { + return nil + } + if info.IsDir() { + // .git is never part of the answer, and .senior-dev is senior-dev's own + // bookkeeping -- the same exclusion seniorDevArtifactPathspecs makes + // under git. + if relative == ".git" || relative == ".senior-dev" || + strings.HasSuffix(relative, "/.git") { + return filepath.SkipDir + } + if honourIgnores { + if rules.ignored(relative, true) { + return filepath.SkipDir + } + rules.load(root, relative) + } + return nil + } + // Symlinks and devices are not content, and following them would let a + // link out of the workspace pull in a tree that is not the answer. + if !info.Mode().IsRegular() { + return nil + } + if relative == ".senior-dev-label" { + return nil + } + if honourIgnores && rules.ignored(relative, false) { + return nil + } + hash, hashErr := hashFile(name) + if hashErr != nil { + return skipUnreadable(hashErr, honourIgnores, info) + } + entries = append(entries, treeEntry{ + path: relative, mode: info.Mode().Perm(), + size: info.Size(), hash: hash, + }) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path }) + return entries, nil +} + +// skipUnreadable is [walkTree]'s answer to an error at one path: skip it when +// it is a refusal to read in a walk that may skip one, and stop otherwise. +func skipUnreadable(err error, mayskip bool, info os.FileInfo) error { + if !mayskip || !errors.Is(err, fs.ErrPermission) { + return err + } + if info != nil && info.IsDir() { + return filepath.SkipDir + } + return nil +} + +// manifestID is the tree's content address: every path, mode and content hash +// in sorted order, hashed. Mode is included so chmod +x alone is a change. +func manifestID(entries []treeEntry) string { + digest := sha256.New() + for _, entry := range entries { + fmt.Fprintf(digest, "%s\x00%o\x00%s\x00", entry.path, entry.mode, entry.hash) + } + return hex.EncodeToString(digest.Sum(nil)) +} + +func countChangedPaths(before, after []treeEntry) int { + beforeByPath := map[string]string{} + for _, entry := range before { + beforeByPath[entry.path] = entry.hash + } + afterByPath := map[string]string{} + for _, entry := range after { + afterByPath[entry.path] = entry.hash + } + changed := 0 + for path, hash := range afterByPath { + if previous, ok := beforeByPath[path]; !ok || previous != hash { + changed++ + } + } + for path := range beforeByPath { + if _, ok := afterByPath[path]; !ok { + changed++ + } + } + return changed +} + +func hashFile(name string) (string, error) { + file, err := os.Open(name) + if err != nil { + return "", err + } + defer file.Close() + digest := sha256.New() + if _, err := io.Copy(digest, file); err != nil { + return "", err + } + return hex.EncodeToString(digest.Sum(nil)), nil +} + +func copyFile(source, destination string, mode os.FileMode) error { + in, err := os.Open(source) + if err != nil { + return err + } + defer in.Close() + if err := os.RemoveAll(destination); err != nil { + return err + } + out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} + +// pruneEmptyDirs removes directories a restore emptied, so a restored tree has +// no leftover shape from the tree it replaced. Failures are ignored: an empty +// directory is invisible to the manifest and cannot make the proof fail. +func (recorder *snapshotRecorder) pruneEmptyDirs() { + var dirs []string + _ = filepath.Walk(recorder.workspace, func(name string, info os.FileInfo, err error) error { + if err != nil || !info.IsDir() { + return nil //nolint:nilerr // a walk error here is not worth failing a restore + } + relative, relErr := filepath.Rel(recorder.workspace, name) + if relErr != nil || relative == "." { + return nil + } + relative = filepath.ToSlash(relative) + if relative == ".git" || relative == ".senior-dev" { + return filepath.SkipDir + } + dirs = append(dirs, name) + return nil + }) + // Deepest first, so a directory emptied by removing its children is itself + // removable in the same pass. + sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) }) + for _, dir := range dirs { + _ = os.Remove(dir) + } +} + +func (recorder *snapshotRecorder) ensureStore() (string, error) { + recorder.mu.Lock() + defer recorder.mu.Unlock() + if recorder.store != "" { + return recorder.store, nil + } + // Outside the workspace on purpose: a store inside it would be part of the + // tree it is trying to describe. + root := strings.TrimSpace(os.Getenv("SENIOR_DEV_SCRATCH_ROOT")) + if root == "" { + root = os.TempDir() + } + if err := os.MkdirAll(root, 0o755); err != nil { + return "", fmt.Errorf("create scratch root: %w", err) + } + store, err := os.MkdirTemp(root, "senior-dev-snapshots-*") + if err != nil { + return "", fmt.Errorf("create snapshot store: %w", err) + } + recorder.store = store + return store, nil +} diff --git a/internal/seniordev/app/workspace_recorder_test.go b/internal/seniordev/app/workspace_recorder_test.go new file mode 100644 index 0000000000..0ac9c19af5 --- /dev/null +++ b/internal/seniordev/app/workspace_recorder_test.go @@ -0,0 +1,472 @@ +//go:build !windows + +package app + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" +) + +func snapshotWorkspace(t *testing.T, files map[string]string) (string, *snapshotRecorder) { + t.Helper() + workspace := t.TempDir() + for name, content := range files { + if err := writeFile(filepath.Join(workspace, name), content); err != nil { + t.Fatal(err) + } + } + recorder := newSnapshotRecorder(workspace, func(string) {}) + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + return workspace, recorder +} + +// The identifier is a content address: the same bytes give the same id from a +// different directory, and any change gives a different one. Everything the +// run does with these — the submit gate, the restore proof — rests on it. +func TestSnapshotIdentifiesTreesByContent(t *testing.T) { + first, recorderA := snapshotWorkspace(t, map[string]string{ + "main.go": "package main\n", "docs/readme.md": "hello\n", + }) + _, recorderB := snapshotWorkspace(t, map[string]string{ + "main.go": "package main\n", "docs/readme.md": "hello\n", + }) + idA, err := recorderA.Snapshot() + if err != nil { + t.Fatal(err) + } + idB, err := recorderB.Snapshot() + if err != nil { + t.Fatal(err) + } + if idA != idB { + t.Fatalf("identical trees gave different ids: %s and %s", shortSHA(idA), shortSHA(idB)) + } + + if err := writeFile(filepath.Join(first, "main.go"), "package main // edited\n"); err != nil { + t.Fatal(err) + } + changed, err := recorderA.Snapshot() + if err != nil { + t.Fatal(err) + } + if changed == idA { + t.Fatal("editing a file did not change the tree id") + } +} + +// A file's mode is part of the tree: chmod +x with no content change is a real +// change, and a restore that dropped it would ship a broken script. +func TestSnapshotIdentityIncludesFileMode(t *testing.T) { + workspace, recorder := snapshotWorkspace(t, map[string]string{"run.sh": "#!/bin/sh\n"}) + before, err := recorder.Snapshot() + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(workspace, "run.sh"), 0o755); err != nil { + t.Fatal(err) + } + after, err := recorder.Snapshot() + if err != nil { + t.Fatal(err) + } + if before == after { + t.Fatal("chmod +x did not change the tree id") + } +} + +// The restore has to handle all three shapes of divergence at once: a file the +// model edited, one it created, and one it deleted. +func TestSnapshotRestoreReturnsTheExactTree(t *testing.T) { + workspace, recorder := snapshotWorkspace(t, map[string]string{ + "keep.txt": "keep\n", "edit.txt": "before\n", "delete-me.txt": "doomed\n", + }) + original, err := recorder.Snapshot() + if err != nil { + t.Fatal(err) + } + if _, err := recorder.Record(original, "starting tree"); err != nil { + t.Fatal(err) + } + + if err := writeFile(filepath.Join(workspace, "edit.txt"), "after\n"); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "delete-me.txt")); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "nested/new.txt"), "added\n"); err != nil { + t.Fatal(err) + } + diverged, err := recorder.Snapshot() + if err != nil { + t.Fatal(err) + } + if diverged == original { + t.Fatal("the tree did not diverge") + } + + if err := recorder.Restore(original, original); err != nil { + t.Fatalf("restore: %v", err) + } + content, err := os.ReadFile(filepath.Join(workspace, "edit.txt")) + if err != nil || string(content) != "before\n" { + t.Fatalf("edit.txt = %q, %v; want the recorded content", content, err) + } + if _, err := os.Stat(filepath.Join(workspace, "delete-me.txt")); err != nil { + t.Fatal("a deleted file was not brought back") + } + if _, err := os.Stat(filepath.Join(workspace, "nested/new.txt")); !os.IsNotExist(err) { + t.Fatal("a file added after the checkpoint survived the restore") + } +} + +// Restore proves itself by re-identifying the result. A caller that asks for a +// tree it did not record must be told, not quietly given something else. +func TestSnapshotRestoreRefusesAMismatch(t *testing.T) { + _, recorder := snapshotWorkspace(t, map[string]string{"a.txt": "one\n"}) + id, err := recorder.Snapshot() + if err != nil { + t.Fatal(err) + } + if _, err := recorder.Record(id, "start"); err != nil { + t.Fatal(err) + } + err = recorder.Restore(id, strings.Repeat("0", 64)) + if err == nil { + t.Fatal("restore accepted a tree that is not the one requested") + } + if !strings.Contains(err.Error(), "restored tree") { + t.Fatalf("error does not name the mismatch: %v", err) + } +} + +// Ignored paths and senior-dev's own artifacts are not the answer, so they are +// not in the tree the run compares, freezes or restores. +func TestSnapshotHonoursIgnoresAndSkipsArtifacts(t *testing.T) { + workspace, recorder := snapshotWorkspace(t, map[string]string{ + ".gitignore": "build/\n*.log\n!keep.log\n", + "src/main.go": "package main\n", + "build/artifact.bin": "binary\n", + "debug.log": "noise\n", + "keep.log": "wanted\n", + ".senior-dev/spec.md": "the request\n", + }) + paths, _, overBudget, err := recorder.ListPaths(context.Background(), 1<<20) + if err != nil || overBudget { + t.Fatalf("ListPaths: err=%v overBudget=%v", err, overBudget) + } + listed := strings.Join(paths, " ") + for _, want := range []string{".gitignore", "src/main.go", "keep.log"} { + if !strings.Contains(listed, want) { + t.Fatalf("%s missing from the tree: %v", want, paths) + } + } + for _, unwanted := range []string{"build/artifact.bin", "debug.log", ".senior-dev/spec.md"} { + if strings.Contains(listed, unwanted) { + t.Fatalf("%s should not be part of the answer: %v", unwanted, paths) + } + } + // And the ignored files are still on disk: excluded from the answer is not + // the same as deleted. + if _, err := os.Stat(filepath.Join(workspace, "debug.log")); err != nil { + t.Fatal("an ignored file was removed from the workspace") + } +} + +// The submit gate asks exactly one question: has anything changed since the +// start. It has to answer that without git. +func TestSnapshotChangeDrivesTheSubmitGate(t *testing.T) { + workspace, recorder := snapshotWorkspace(t, map[string]string{"main.go": "package main\n"}) + base, err := recorder.Base(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := recorder.Record(base, "start"); err != nil { + t.Fatal(err) + } + + change, err := recorder.Change(base) + if err != nil { + t.Fatal(err) + } + if change.changed { + t.Fatal("an untouched tree reported a change") + } + + if err := writeFile(filepath.Join(workspace, "feature.go"), "package main\n"); err != nil { + t.Fatal(err) + } + change, err = recorder.Change(base) + if err != nil { + t.Fatal(err) + } + if !change.changed || change.files != 1 { + t.Fatalf("change = %+v, want one changed file", change) + } + + // senior-dev's own bookkeeping is not an implementation: a tree whose only + // new content is .senior-dev/ must still read as unchanged, or every run + // could submit having done nothing. + if err := writeFile(filepath.Join(workspace, "feature.go"), "package main\n"); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(workspace, "feature.go")); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, ".senior-dev/checklist.md"), "[x] done\n"); err != nil { + t.Fatal(err) + } + change, err = recorder.Change(base) + if err != nil { + t.Fatal(err) + } + if change.changed { + t.Fatalf("senior-dev's own artifacts counted as an implementation: %+v", change) + } +} + +// The point of the mode: a repository the run has no business writing to must +// come out with its history untouched. +func TestInPlaceRunLeavesGitHistoryAlone(t *testing.T) { + workspace := testRepoWithEntrypoints(t) + before := gitOutput(context.Background(), workspace, "rev-parse", "HEAD") + beforeLog := gitOutput(context.Background(), workspace, "log", "--oneline") + + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + host := &testHost{workspace: workspace} + var notes strings.Builder + ending := runWith(context.Background(), host, Options{ + Goal: "Implement the thing.", High: "provider/high", InPlace: true, + }, ¬es, &coderOnlyBackend{}) + if ending.Status == "crashed" { + t.Fatalf("in-place run failed: %s\n%s", ending.Message, notes.String()) + } + + after := gitOutput(context.Background(), workspace, "rev-parse", "HEAD") + if after != before { + t.Fatalf("HEAD moved: %s -> %s", shortSHA(before), shortSHA(after)) + } + if now := gitOutput(context.Background(), workspace, "log", "--oneline"); now != beforeLog { + t.Fatalf("the run wrote history:\nbefore:\n%s\nafter:\n%s", beforeLog, now) + } + if !strings.Contains(notes.String(), `"workspace_recorder":"snapshot"`) { + t.Fatal("the run contract does not record the snapshot recorder") + } +} + +// And the mode's other half: no repository at all. +func TestInPlaceRunNeedsNoRepository(t *testing.T) { + workspace := t.TempDir() + for name, content := range map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + } { + if err := writeFile(filepath.Join(workspace, name), content); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) { + t.Fatal("the fixture is a repository; this test needs one that is not") + } + + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + var notes strings.Builder + ending := runWith(context.Background(), &testHost{workspace: workspace}, Options{ + Goal: "Implement the thing.", High: "provider/high", InPlace: true, + }, ¬es, &coderOnlyBackend{}) + if ending.Status == "crashed" { + t.Fatalf("run without a repository failed: %s\n%s", ending.Message, notes.String()) + } + if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) { + t.Fatal("the run created a repository in a workspace that had none") + } +} + +// Without --in-place, git is used only if it is there. A plain folder runs +// on the snapshot recorder and ends like any run, never with "not a git +// repository", and no repository is made for it. +func TestADefaultRunInAPlainFolderUsesTheSnapshotRecorder(t *testing.T) { + workspace := t.TempDir() + for name, content := range map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + } { + if err := writeFile(filepath.Join(workspace, name), content); err != nil { + t.Fatal(err) + } + } + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + var notes strings.Builder + ending := runWith(context.Background(), &testHost{workspace: workspace}, Options{ + Goal: "Implement the thing.", High: "provider/high", + }, ¬es, &coderOnlyBackend{}) + if ending.Status == "crashed" { + t.Fatalf("a plain folder crashed the default run: %s\n%s", ending.Message, notes.String()) + } + if !strings.Contains(notes.String(), `"workspace_recorder":"snapshot"`) { + t.Fatal("the run contract does not record the snapshot recorder") + } + if _, err := os.Stat(filepath.Join(workspace, ".git")); !os.IsNotExist(err) { + t.Fatal("the run created a repository in a workspace that had none") + } +} + +// The recorder follows what git can actually give: a work tree with a commit +// is git's, and everything short of that — no repository, one with no commit +// yet, a .git folder with no HEAD — is the snapshot recorder's. --in-place +// still forces the snapshot recorder over a real repository. +func TestTheRecorderIsGitOnlyWhereThereIsGitHistory(t *testing.T) { + ctx := context.Background() + git := func(dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{ + "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", + }, args...)...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + plain := t.TempDir() + empty := t.TempDir() + git(empty, "init", "-q") + broken := t.TempDir() + if err := os.MkdirAll(filepath.Join(broken, ".git", "objects"), 0o755); err != nil { + t.Fatal(err) + } + committed := t.TempDir() + git(committed, "init", "-q") + if err := writeFile(filepath.Join(committed, "a.txt"), "a\n"); err != nil { + t.Fatal(err) + } + git(committed, "add", "a.txt") + git(committed, "commit", "-q", "-m", "base") + nested := filepath.Join(committed, "sub") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + for _, tc := range []struct { + name, dir string + inPlace bool + want string + }{ + {"plain folder", plain, false, "snapshot"}, + {"repository with no commit", empty, false, "snapshot"}, + {"a .git folder with no HEAD", broken, false, "snapshot"}, + {"repository with a commit", committed, false, "git"}, + {"a folder inside one", nested, false, "git"}, + {"--in-place over a repository", committed, true, "snapshot"}, + } { + got := newWorkspaceRecorder(cliArgs{InPlace: tc.inPlace}, tc.dir, func(string) {}) + if got.Kind() != tc.want { + t.Errorf("%s: recorder %q, want %q", tc.name, got.Kind(), tc.want) + } + if err := got.Prepare(ctx); err != nil { + t.Errorf("%s: %s recorder did not prepare: %v", tc.name, got.Kind(), err) + } + } +} + +// Every in-place rewrite must fire against the real prompt text. A rewrite +// that silently matched nothing would leave the model with git-shaped +// instructions it cannot follow, which is invisible at runtime. +func TestInPlacePromptRewritesAllMatch(t *testing.T) { + coder, ok := baked.GetBakedAgent("coder") + if !ok { + t.Fatal("the coder agent is not available") + } + solo := buildSoloPrompt("Do the thing.", "", ".senior-dev/checklist.md") + recorder := newSnapshotRecorder(t.TempDir(), func(string) {}) + + adaptedCoder, err := adaptCoderPrompt(recorder, coder) + if err != nil { + t.Fatalf("a coder rewrite no longer matches: %v", err) + } + adaptedSolo, err := adaptSoloPrompt(recorder, solo) + if err != nil { + t.Fatalf("a run-instruction rewrite no longer matches: %v", err) + } + for _, leftover := range []string{"starting commit", "git-ignored", "is a git repository"} { + if strings.Contains(adaptedCoder, leftover) { + t.Fatalf("coder prompt still says %q", leftover) + } + if strings.Contains(adaptedSolo, leftover) { + t.Fatalf("solo prompt still says %q", leftover) + } + } + if !strings.Contains(adaptedSolo, "does not use git") { + t.Fatal("the solo prompt does not tell the model git is unavailable") + } +} + +// The git path's prompt bytes are the deliverable of choosing substitution +// over rewording: an unchanged prompt hash keeps earlier runs comparable. +func TestGitRecorderLeavesPromptsByteIdentical(t *testing.T) { + coder, _ := baked.GetBakedAgent("coder") + solo := buildSoloPrompt("Do the thing.", "", ".senior-dev/checklist.md") + recorder := newGitRecorder(t.TempDir(), func(string) {}) + + adaptedCoder, err := adaptCoderPrompt(recorder, coder) + if err != nil { + t.Fatal(err) + } + adaptedSolo, err := adaptSoloPrompt(recorder, solo) + if err != nil { + t.Fatal(err) + } + if adaptedCoder != coder { + t.Fatal("the git path's system prompt changed") + } + if adaptedSolo != solo { + t.Fatal("the git path's run instruction changed") + } +} + +// A folder or file the workspace will not let the run read is not part of the +// tree: the run is not ended by it, no snapshot holds it, and a restore neither +// removes nor writes it. macOS answers `operation not permitted` for some +// folders even to their owner, and one of them ended a run at its first step. +func TestSnapshotSkipsWhatItMayNotRead(t *testing.T) { + workspace, recorder := snapshotWorkspace(t, map[string]string{ + "main.go": "package main\n", "locked/inside.txt": "private\n", "sealed.txt": "private\n", + }) + locked, sealed := filepath.Join(workspace, "locked"), filepath.Join(workspace, "sealed.txt") + for _, name := range []string{locked, sealed} { + if err := os.Chmod(name, 0); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { _ = os.Chmod(locked, 0o755); _ = os.Chmod(sealed, 0o644) }) + if _, err := os.ReadDir(locked); err == nil { + t.Skip("this user reads a folder with no permissions (root?)") + } + original, err := recorder.Snapshot() + if err != nil { + t.Fatalf("snapshot of a workspace holding an unreadable folder: %v", err) + } + paths, _, _, err := recorder.ListPaths(context.Background(), 1<<20) + if err != nil || strings.Join(paths, ",") != "main.go" { + t.Fatalf("paths = %q, %v; want only the readable file", paths, err) + } + if _, err := recorder.Record(original, "start"); err != nil { + t.Fatal(err) + } + if err := writeFile(filepath.Join(workspace, "main.go"), "package main // edited\n"); err != nil { + t.Fatal(err) + } + if err := recorder.Restore(original, original); err != nil { + t.Fatalf("restore: %v", err) + } + for _, name := range []string{locked, sealed} { + if _, err := os.Lstat(name); err != nil { + t.Fatalf("the restore touched %s: %v", name, err) + } + } +} diff --git a/internal/seniordev/app/worktree_fingerprint.go b/internal/seniordev/app/worktree_fingerprint.go new file mode 100644 index 0000000000..574c2fba22 --- /dev/null +++ b/internal/seniordev/app/worktree_fingerprint.go @@ -0,0 +1,225 @@ +//go:build !windows + +package app + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "os" + "path/filepath" + "time" +) + +const ( + worktreeFingerprintMaxFiles = 4096 + worktreeFingerprintMaxBytes = 8 * 1024 * 1024 + worktreeFingerprintTimeout = 2 * time.Second +) + +type worktreeFileFingerprint struct { + Mode os.FileMode + Size int64 + ModTimeNano int64 + Missing bool + ContentHash string +} + +type fingerprintStatus uint8 + +const ( + fingerprintOK fingerprintStatus = iota + fingerprintFailed + fingerprintOverBudget +) + +type worktreeFingerprinter struct { + runner *pipeline + parent context.Context + ctx context.Context + cancel context.CancelFunc +} + +func newWorktreeFingerprinter( + runner *pipeline, parent context.Context, +) *worktreeFingerprinter { + ctx, cancel := context.WithTimeout(parent, worktreeFingerprintTimeout) + return &worktreeFingerprinter{ + runner: runner, parent: parent, ctx: ctx, cancel: cancel, + } +} + +func (fingerprinter *worktreeFingerprinter) fingerprint() (string, bool) { + defer fingerprinter.cancel() + paths, remaining, status := fingerprinter.listPaths() + if status != fingerprintOK { + return fingerprinter.result(status) + } + digest := sha256.New() + nextFiles := make(map[string]worktreeFileFingerprint, len(paths)) + for _, relative := range paths { + if status = fingerprinter.contextStatus(); status != fingerprintOK { + return fingerprinter.result(status) + } + state, consumed, status := fingerprinter.fingerprintPath(relative, remaining) + if status != fingerprintOK { + return fingerprinter.result(status) + } + remaining -= consumed + nextFiles[relative] = state + writeFingerprintState(digest, relative, state) + } + fingerprinter.runner.fingerprintFiles = nextFiles + return fmt.Sprintf("%x", digest.Sum(nil)), true +} + +func (fingerprinter *worktreeFingerprinter) result( + status fingerprintStatus, +) (string, bool) { + if status == fingerprintOverBudget { + return fingerprinter.runner.overBudgetFingerprint(), true + } + return "", false +} + +func (fingerprinter *worktreeFingerprinter) contextStatus() fingerprintStatus { + if fingerprinter.ctx.Err() == nil { + return fingerprintOK + } + if fingerprinter.parent.Err() == nil { + return fingerprintOverBudget + } + return fingerprintFailed +} + +func (fingerprinter *worktreeFingerprinter) listPaths() ( + []string, int64, fingerprintStatus, +) { + paths, consumed, overBudget, err := fingerprinter.runner.recorder.ListPaths( + fingerprinter.ctx, worktreeFingerprintMaxBytes, + ) + if overBudget { + return nil, 0, fingerprintOverBudget + } + if status := fingerprinter.contextStatus(); status != fingerprintOK { + return nil, 0, status + } + if err != nil { + return nil, 0, fingerprintFailed + } + if len(paths) > worktreeFingerprintMaxFiles { + return nil, 0, fingerprintOverBudget + } + return paths, int64(worktreeFingerprintMaxBytes - consumed), fingerprintOK +} + +func (fingerprinter *worktreeFingerprinter) fingerprintPath( + relative string, remaining int64, +) (worktreeFileFingerprint, int64, fingerprintStatus) { + path := filepath.Join( + fingerprinter.runner.workspace, filepath.FromSlash(relative), + ) + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return worktreeFileFingerprint{Missing: true}, 0, fingerprintOK + } + if err != nil { + return worktreeFileFingerprint{}, 0, fingerprintFailed + } + state := worktreeFileFingerprint{ + Mode: info.Mode(), Size: info.Size(), ModTimeNano: info.ModTime().UnixNano(), + } + cached, ok := fingerprinter.runner.fingerprintFiles[relative] + metadataUnchanged := ok && !cached.Missing && cached.Mode == state.Mode && + cached.Size == state.Size && cached.ModTimeNano == state.ModTimeNano + if metadataUnchanged { + state.ContentHash = cached.ContentHash + return state, 0, fingerprintOK + } + if info.Mode()&os.ModeSymlink != 0 { + return fingerprintSymlink(path, state, remaining) + } + if info.Mode().IsRegular() { + return fingerprintRegularFile(fingerprinter.ctx, path, state, remaining) + } + return state, 0, fingerprintOK +} + +func fingerprintSymlink( + path string, state worktreeFileFingerprint, remaining int64, +) (worktreeFileFingerprint, int64, fingerprintStatus) { + target, err := os.Readlink(path) + if err != nil { + return worktreeFileFingerprint{}, 0, fingerprintFailed + } + consumed := int64(len(target)) + if consumed > remaining { + return worktreeFileFingerprint{}, 0, fingerprintOverBudget + } + sum := sha256.Sum256([]byte(target)) + state.ContentHash = fmt.Sprintf("%x", sum[:]) + return state, consumed, fingerprintOK +} + +func fingerprintRegularFile( + ctx context.Context, path string, state worktreeFileFingerprint, remaining int64, +) (worktreeFileFingerprint, int64, fingerprintStatus) { + contentHash, consumed, ok := boundedFileHash(ctx, path, remaining) + if !ok { + return worktreeFileFingerprint{}, 0, fingerprintOverBudget + } + state.ContentHash = contentHash + return state, consumed, fingerprintOK +} + +func writeFingerprintState( + digest io.Writer, relative string, state worktreeFileFingerprint, +) { + _, _ = digest.Write([]byte(relative + "\x00")) + if state.Missing { + _, _ = digest.Write([]byte("missing\x00" + state.ContentHash + "\x00")) + return + } + _, _ = digest.Write([]byte(fmt.Sprintf( + "%s\x00%d\x00%d\x00%s\x00", + state.Mode, state.Size, state.ModTimeNano, state.ContentHash, + ))) +} + +func boundedFileHash( + ctx context.Context, path string, remaining int64, +) (string, int64, bool) { + if remaining < 0 { + return "", 0, false + } + file, err := os.Open(path) + if err != nil { + return "", 0, false + } + defer file.Close() + digest := sha256.New() + buffer := make([]byte, 64*1024) + limited := io.LimitReader(file, remaining+1) + consumed := int64(0) + for { + if ctx.Err() != nil { + return "", consumed, false + } + read, readErr := limited.Read(buffer) + if read > 0 { + consumed += int64(read) + _, _ = digest.Write(buffer[:read]) + if consumed > remaining { + return "", consumed, false + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return "", consumed, false + } + } + return fmt.Sprintf("%x", digest.Sum(nil)), consumed, true +} diff --git a/internal/seniordev/baked/agents/coder.md b/internal/seniordev/baked/agents/coder.md new file mode 100644 index 0000000000..83ee5f4e16 --- /dev/null +++ b/internal/seniordev/baked/agents/coder.md @@ -0,0 +1,71 @@ +--- +mode: subagent +description: >- + End-to-end implementation generalist for one bounded task: orient, implement, + verify, and return evidence in a single context. +model: inherit +temperature: 0.2 +permission: + "*": allow + doom_loop: ask +tools: + read: true + grep: true + glob: true + bash: true + edit: true + write: true + apply_patch: true +--- + +<Role> + +You are the only agent in this run: one request, one context, from exploration +through implementation and verification. There is no planner, no reviewer, no +subagent and no tool to delegate with. + +The run is unattended. The `question` tool is available, but nobody is there +to answer it: every question it sends comes back rejected. + +The working tree you leave behind is the answer. + +</Role> + +<How_This_Run_Ends> + +**The run ends when you call the `submit` tool, and by nothing else.** No status +tag, verdict, report or summary finishes it, however well evidenced. + +`submit` takes a `reason`, the `evidence` you verified with, and +`checklist_satisfied`. It refuses, naming the cause, when the tree is unchanged +from the starting commit, when `.senior-dev/checklist.md` does not exist, when +`reason` or `evidence` is empty, or when this run already submitted. A refusal +does not end the run: fix what it names and call `submit` again. + +An accepted `submit` freezes the tree as your answer at that instant. Anything +changed afterwards is reverted to the frozen tree before it ships. + +</How_This_Run_Ends> + +<Workspace> + +`.senior-dev/spec.md` holds the request verbatim and is the specification. It is +re-pinned verbatim whenever the context is compacted, so it is readable from the +file at any point in the run. + +`.senior-dev/checklist.md` is the list of what the request requires: one item per +line starting `[ ] `, ticked to `[x]`. Its item and tick counts are recorded +when you submit. + +`.senior-dev/pinned.txt` holds the build or test command you are using, on one line. +senior-dev reads its first line and quotes it back to you if this run needs a +continuation. + +`.senior-dev/` and git-ignored paths are excluded from the answer. Everything else +in the working tree is part of what you submit. + +After you submit, senior-dev discovers and runs this project's own build and test +entrypoints itself, independently of anything you report. Do not edit this +project's test, CI or coverage configuration to make them pass. + +</Workspace> diff --git a/internal/seniordev/baked/registry.go b/internal/seniordev/baked/registry.go new file mode 100644 index 0000000000..209bf78ead --- /dev/null +++ b/internal/seniordev/baked/registry.go @@ -0,0 +1,105 @@ +//go:build !windows + +// Package baked embeds the agent document the run executes. The roster is a +// single agent, coder: the pipeline reads its prompt body, its frontmatter +// metadata (model, steps, tier) and its permission rules. +package baked + +import ( + "embed" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +var agentNames = []string{"coder"} + +//go:embed agents/*.md +var agentFiles embed.FS + +type agentDocument struct { + raw string + prompt string + metadata map[string]any +} + +var agentDocuments = loadAgentDocuments() + +func loadAgentDocuments() map[string]agentDocument { + out := make(map[string]agentDocument, len(agentNames)) + for _, name := range agentNames { + data, err := agentFiles.ReadFile("agents/" + name + ".md") + if err != nil { + panic("baked agent asset missing: " + name) + } + raw := string(data) + prompt, frontmatter, err := parseAgentMarkdown(raw) + if err != nil { + panic(fmt.Sprintf("baked agent %q frontmatter: %v", name, err)) + } + metadata := map[string]any{} + if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil { + panic(fmt.Sprintf("baked agent %q frontmatter: %v", name, err)) + } + out[name] = agentDocument{raw: raw, prompt: prompt, metadata: metadata} + } + return out +} + +func parseAgentMarkdown(markdown string) (string, string, error) { + normalized := strings.ReplaceAll(markdown, "\r\n", "\n") + if !strings.HasPrefix(normalized, "---\n") { + return strings.TrimSpace(normalized), "", nil + } + rest := normalized[len("---\n"):] + end := strings.Index(rest, "\n---") + if end < 0 { + return "", "", fmt.Errorf("unterminated YAML frontmatter") + } + after := rest[end+len("\n---"):] + if after != "" && !strings.HasPrefix(after, "\n") { + return "", "", fmt.Errorf("closing YAML delimiter is not on its own line") + } + return strings.TrimSpace(strings.TrimPrefix(after, "\n")), rest[:end], nil +} + +// PromptContent strips YAML frontmatter and trims the model-visible body. +func PromptContent(markdown string) string { + prompt, _, err := parseAgentMarkdown(markdown) + if err != nil { + return "" + } + return prompt +} + +// GetBakedAgent returns only the model-visible Markdown body for an agent. +func GetBakedAgent(name string) (string, bool) { + document, ok := agentDocuments[name] + return document.prompt, ok +} + +// GetBakedAgentMarkdown returns the source document for frontmatter consumers. +func GetBakedAgentMarkdown(name string) (string, bool) { + document, ok := agentDocuments[name] + return document.raw, ok +} + +// GetBakedAgentMetadata returns the parsed YAML fields used to configure an +// agent without exposing them to the model. +func GetBakedAgentMetadata(name string) (map[string]any, bool) { + document, ok := agentDocuments[name] + if !ok { + return nil, false + } + metadata := make(map[string]any, len(document.metadata)) + for key, value := range document.metadata { + metadata[key] = value + } + return metadata, true +} + +// ListBakedAgents returns the baked agent names in registry order. +func ListBakedAgents() []string { + return append([]string(nil), agentNames...) +} diff --git a/internal/seniordev/baked/tier.go b/internal/seniordev/baked/tier.go new file mode 100644 index 0000000000..5a6afe1032 --- /dev/null +++ b/internal/seniordev/baked/tier.go @@ -0,0 +1,70 @@ +//go:build !windows + +package baked + +import "strings" + +// Tier is a model routing pool. The router keeps one pool per tier and +// resolves any tier whose pool is empty to the high pool, so a run given +// nothing but `--high` routes every tier on that one pool. +type Tier string + +const ( + TierHigh Tier = "high" + TierLow Tier = "low" + TierFrontier Tier = "frontier" +) + +// tierMap is the agent-to-tier mapping. This table and the optional `tier:` +// frontmatter key that overrides it are the only things that decide which +// pool a call routes on. +// +// - coder: the implementation turns, on the high pool. +// - compaction: the transcript summary call, on the low pool. It is an +// auxiliary call that recurs through a long run, so it is the one place +// a cheaper pool is worth configuring. +var tierMap = map[string]Tier{ + "coder": TierHigh, + "compaction": TierLow, +} + +// TierFor returns the named agent's routing tier. A baked agent may override +// the table with a `tier:` frontmatter key; an absent or unrecognised value, +// and any name the table does not list, routes on the high pool. +func TierFor(name string) Tier { + metadata, _ := GetBakedAgentMetadata(name) + return tierFrom(metadata, name) +} + +// tierFrom answers for an agent whose frontmatter metadata is already in +// hand. A nil map means the name has no baked document, which is how the +// compaction summary reaches the table. +func tierFrom(metadata map[string]any, name string) Tier { + if tier, ok := parseTier(metadata["tier"]); ok { + return tier + } + if tier, ok := tierMap[name]; ok { + return tier + } + return TierHigh +} + +// parseTier reads a frontmatter `tier:` value. It reports false for anything +// that is not one of the three tier names, leaving the table's answer in +// place. +func parseTier(value any) (Tier, bool) { + text, ok := value.(string) + if !ok { + return "", false + } + switch Tier(strings.ToLower(strings.TrimSpace(text))) { + case TierHigh: + return TierHigh, true + case TierLow: + return TierLow, true + case TierFrontier: + return TierFrontier, true + default: + return "", false + } +} diff --git a/internal/seniordev/baked/tier_test.go b/internal/seniordev/baked/tier_test.go new file mode 100644 index 0000000000..8355b7858e --- /dev/null +++ b/internal/seniordev/baked/tier_test.go @@ -0,0 +1,79 @@ +//go:build !windows + +package baked + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestTierForMapsEachAgentToItsPool(t *testing.T) { + for _, test := range []struct { + agent string + want Tier + }{ + {"coder", TierHigh}, + {"compaction", TierLow}, + {"", TierHigh}, + {"some-agent-that-does-not-exist", TierHigh}, + } { + if got := TierFor(test.agent); got != test.want { + t.Errorf("TierFor(%q) = %q, want %q", test.agent, got, test.want) + } + } +} + +func TestShippedCoderDocumentLeavesTheTierToTheTable(t *testing.T) { + // The override exists for an operator; the shipped document must not use + // it, or the table stops describing what the binary does. + metadata, ok := GetBakedAgentMetadata("coder") + if !ok { + t.Fatal("the coder document is missing") + } + if value, present := metadata["tier"]; present { + t.Fatalf("coder.md sets tier: %v", value) + } +} + +func TestFrontmatterTierOverridesTheTable(t *testing.T) { + for _, test := range []struct { + name string + value string + want Tier + }{ + {"frontier", "tier: frontier\n", TierFrontier}, + {"low", "tier: low\n", TierLow}, + {"case and space are forgiven", "tier: \" Frontier \"\n", TierFrontier}, + {"an unknown value keeps the table's answer", "tier: platinum\n", TierHigh}, + {"a non-string keeps the table's answer", "tier: 3\n", TierHigh}, + {"no key at all keeps the table's answer", "", TierHigh}, + } { + t.Run(test.name, func(t *testing.T) { + // Parsed the same way the embedded documents are, so the test + // covers the frontmatter path and not just the lookup. + _, frontmatter, err := parseAgentMarkdown( + "---\nmodel: inherit\n" + test.value + "---\n\nbody\n", + ) + if err != nil { + t.Fatal(err) + } + metadata := map[string]any{} + if err := yaml.Unmarshal([]byte(frontmatter), &metadata); err != nil { + t.Fatal(err) + } + if got := tierFrom(metadata, "coder"); got != test.want { + t.Fatalf("tier = %q, want %q", got, test.want) + } + }) + } +} + +func TestFrontmatterTierOverridesTheCompactionDefaultToo(t *testing.T) { + if got := tierFrom(map[string]any{"tier": "high"}, "compaction"); got != TierHigh { + t.Fatalf("tier = %q, want %q", got, TierHigh) + } + if got := tierFrom(nil, "compaction"); got != TierLow { + t.Fatalf("tier = %q, want %q", got, TierLow) + } +} diff --git a/internal/seniordev/bus/bus.go b/internal/seniordev/bus/bus.go new file mode 100644 index 0000000000..0135488032 --- /dev/null +++ b/internal/seniordev/bus/bus.go @@ -0,0 +1,296 @@ +//go:build !windows + +// Package bus is the in-process event bus. Subscriber snapshots are invoked +// synchronously in registration order. All mutable state is protected for +// concurrent publishers/subscribers. +package bus + +import ( + "sync" + + idpkg "github.com/Agent-Field/codeaf/internal/seniordev/id" +) + +// Payload is the wire event delivered to subscribers. +type Payload struct { + ID string `json:"id"` + Type string `json:"type"` + Properties any `json:"properties"` +} + +// Context is the instance metadata a bus is created for. +type Context struct { + Directory string + Project string + Workspace string +} + +// PublishOptions lets a publisher pin the payload ID. +type PublishOptions struct { + ID string +} + +type subscriber struct { + id uint64 + callback func(Payload) +} + +// Bus is an instance-scoped pub/sub bus. +type Bus struct { + mu sync.RWMutex + nextID uint64 + typed map[string][]subscriber + wildcard []subscriber + context Context + createID func() string + disposed bool + streams map[*Subscription]struct{} +} + +// BusOption configures New. +type BusOption func(*Bus) + +// WithIDGenerator pins payload IDs. +func WithIDGenerator(createID func() string) BusOption { + return func(bus *Bus) { bus.createID = createID } +} + +// New constructs an instance bus. +func New(context Context, options ...BusOption) *Bus { + bus := &Bus{ + typed: make(map[string][]subscriber), + context: context, + createID: CreateID, + streams: make(map[*Subscription]struct{}), + } + for _, option := range options { + option(bus) + } + return bus +} + +// CreateID creates an ascending evt identifier. +func CreateID() string { + value, err := idpkg.Create("evt", idpkg.AscendingDirection) + if err != nil { + panic(err) + } + return value +} + +// Publish delivers to typed subscribers, then to wildcard subscribers, in +// that order. +func (b *Bus) Publish(def Definition, properties any, options ...PublishOptions) { + id := "" + if len(options) > 0 { + id = options[0].ID + } + if id == "" { + id = b.createID() + } + payload := Payload{ID: id, Type: def.Type, Properties: properties} + + b.mu.RLock() + if b.disposed { + b.mu.RUnlock() + return + } + typed := append([]subscriber(nil), b.typed[def.Type]...) + wildcard := append([]subscriber(nil), b.wildcard...) + b.mu.RUnlock() + + deliver(typed, payload) + deliver(wildcard, payload) +} + +// SubscribeCallback subscribes to one event definition. +func (b *Bus) SubscribeCallback(def Definition, callback func(Payload)) func() { + return b.subscribe(def.Type, callback, false) +} + +// SubscribeAllCallback subscribes to every event. +func (b *Bus) SubscribeAllCallback(callback func(Payload)) func() { + return b.subscribe("*", callback, true) +} + +func (b *Bus) subscribe(eventType string, callback func(Payload), all bool) func() { + b.mu.Lock() + if b.disposed { + b.mu.Unlock() + return func() {} + } + b.nextID++ + id := b.nextID + item := subscriber{id: id, callback: callback} + if all { + b.wildcard = append(b.wildcard, item) + } else { + b.typed[eventType] = append(b.typed[eventType], item) + } + b.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + b.mu.Lock() + defer b.mu.Unlock() + if all { + b.wildcard = removeSubscriber(b.wildcard, id) + return + } + b.typed[eventType] = removeSubscriber(b.typed[eventType], id) + }) + } +} + +func removeSubscriber(subscribers []subscriber, id uint64) []subscriber { + for i, item := range subscribers { + if item.id == id { + return append(subscribers[:i], subscribers[i+1:]...) + } + } + return subscribers +} + +func deliver(subscribers []subscriber, payload Payload) { + for _, item := range subscribers { + func() { + defer func() { _ = recover() }() + item.callback(payload) + }() + } +} + +// Dispose publishes InstanceDisposed to wildcard subscribers only, then closes +// streams and makes later publishes/subscriptions inert. +func (b *Bus) Dispose() { + b.mu.Lock() + if b.disposed { + b.mu.Unlock() + return + } + b.disposed = true + wildcard := append([]subscriber(nil), b.wildcard...) + streams := make([]*Subscription, 0, len(b.streams)) + for stream := range b.streams { + streams = append(streams, stream) + } + b.typed = make(map[string][]subscriber) + b.wildcard = nil + b.streams = make(map[*Subscription]struct{}) + directory := b.context.Directory + b.mu.Unlock() + + deliver(wildcard, Payload{ + ID: b.createID(), + Type: InstanceDisposed.Type, + Properties: map[string]any{"directory": directory}, + }) + for _, stream := range streams { + stream.close() + } +} + +// Subscription is an unbounded ordered stream subscription. +type Subscription struct { + C <-chan Payload + + out chan Payload + mu sync.Mutex + cond *sync.Cond + queue []Payload + closed bool + closeOnce sync.Once + unsub func() +} + +// Subscribe returns a typed stream. Call Close when finished. +func (b *Bus) Subscribe(def Definition) *Subscription { + return b.newStream(func(push func(Payload)) func() { + return b.SubscribeCallback(def, push) + }) +} + +// SubscribeAll returns a wildcard stream. +func (b *Bus) SubscribeAll() *Subscription { + return b.newStream(func(push func(Payload)) func() { + return b.SubscribeAllCallback(push) + }) +} + +func (b *Bus) newStream(register func(func(Payload)) func()) *Subscription { + out := make(chan Payload) + subscription := &Subscription{out: out} + subscription.C = out + subscription.cond = sync.NewCond(&subscription.mu) + subscription.unsub = register(subscription.push) + b.mu.Lock() + if b.disposed { + b.mu.Unlock() + subscription.close() + return subscription + } + b.streams[subscription] = struct{}{} + b.mu.Unlock() + go subscription.run() + return subscription +} + +func (s *Subscription) push(payload Payload) { + s.mu.Lock() + if !s.closed { + s.queue = append(s.queue, payload) + s.cond.Signal() + } + s.mu.Unlock() +} + +func (s *Subscription) run() { + defer close(s.out) + for { + s.mu.Lock() + for len(s.queue) == 0 && !s.closed { + s.cond.Wait() + } + if len(s.queue) == 0 && s.closed { + s.mu.Unlock() + return + } + payload := s.queue[0] + s.queue = s.queue[1:] + s.mu.Unlock() + s.out <- payload + } +} + +// Close unsubscribes and closes C after already queued events are delivered. +func (s *Subscription) Close() { s.close() } + +func (s *Subscription) close() { + s.closeOnce.Do(func() { + if s.unsub != nil { + s.unsub() + } + s.mu.Lock() + s.closed = true + s.cond.Broadcast() + s.mu.Unlock() + }) +} + +// Default is the package-level runtime used by the convenience functions. +var Default = New(Context{}) + +// Publish emits on Default. +func Publish(def Definition, properties any, options ...PublishOptions) { + Default.Publish(def, properties, options...) +} + +// SubscribeCallback subscribes on Default. +func SubscribeCallback(def Definition, callback func(Payload)) func() { + return Default.SubscribeCallback(def, callback) +} + +// SubscribeAllCallback subscribes on Default. +func SubscribeAllCallback(callback func(Payload)) func() { + return Default.SubscribeAllCallback(callback) +} diff --git a/internal/seniordev/bus/bus_test.go b/internal/seniordev/bus/bus_test.go new file mode 100644 index 0000000000..4c353fc003 --- /dev/null +++ b/internal/seniordev/bus/bus_test.go @@ -0,0 +1,136 @@ +//go:build !windows + +package bus + +import ( + "fmt" + "reflect" + "sync" + "testing" + "time" +) + +func sequenceIDs() func() string { + var mu sync.Mutex + next := 0 + return func() string { + mu.Lock() + defer mu.Unlock() + next++ + return fmt.Sprintf("evt_%d", next) + } +} + +func TestPublishOrderUnsubscribeAndPanicIsolation(t *testing.T) { + b := New( + Context{Directory: "/repo", Project: "p", Workspace: "w"}, + WithIDGenerator(sequenceIDs()), + ) + def := Define("test.order", nil) + var got []string + b.SubscribeCallback(def, func(Payload) { got = append(got, "typed-1") }) + b.SubscribeCallback(def, func(Payload) { panic("subscriber failed") }) + unsubscribe := b.SubscribeCallback(def, func(Payload) { got = append(got, "typed-3") }) + b.SubscribeAllCallback(func(Payload) { got = append(got, "all-1") }) + + b.Publish(def, map[string]any{"x": float64(1)}, PublishOptions{ID: "fixed"}) + want := []string{"typed-1", "typed-3", "all-1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("delivery order: %v, want %v", got, want) + } + unsubscribe() + unsubscribe() + got = nil + b.Publish(def, nil) + want = []string{"typed-1", "all-1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("after unsubscribe: %v, want %v", got, want) + } +} + +func TestSnapshotSemanticsForMutationDuringPublish(t *testing.T) { + b := New(Context{}, WithIDGenerator(sequenceIDs())) + def := Define("test.snapshot", nil) + var got []string + var unsubscribeSecond func() + b.SubscribeCallback(def, func(Payload) { + got = append(got, "first") + unsubscribeSecond() + b.SubscribeCallback(def, func(Payload) { got = append(got, "late") }) + }) + unsubscribeSecond = b.SubscribeCallback(def, func(Payload) { got = append(got, "second") }) + b.Publish(def, nil) + if want := []string{"first", "second"}; !reflect.DeepEqual(got, want) { + t.Fatalf("first publish: %v", got) + } + got = nil + b.Publish(def, nil) + if want := []string{"first", "late"}; !reflect.DeepEqual(got, want) { + t.Fatalf("second publish: %v", got) + } +} + +func TestDisposeOnlyNotifiesWildcardAndClosesStreams(t *testing.T) { + b := New(Context{Directory: "/d"}, WithIDGenerator(sequenceIDs())) + var typed []Payload + var all []Payload + b.SubscribeCallback(InstanceDisposed, func(event Payload) { typed = append(typed, event) }) + b.SubscribeAllCallback(func(event Payload) { all = append(all, event) }) + stream := b.SubscribeAll() + + b.Dispose() + if len(typed) != 0 { + t.Fatalf("typed disposed subscriber was called: %v", typed) + } + if len(all) != 1 || all[0].Type != InstanceDisposed.Type { + t.Fatalf("wildcard disposed events: %v", all) + } + properties := all[0].Properties.(map[string]any) + if properties["directory"] != "/d" { + t.Fatalf("disposed properties: %v", properties) + } + select { + case event := <-stream.C: + if event.Type != InstanceDisposed.Type { + t.Fatalf("stream event: %+v", event) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for disposal event") + } + select { + case _, ok := <-stream.C: + if ok { + t.Fatal("stream remained open") + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for stream close") + } + b.Publish(InstanceDisposed, nil) + if len(all) != 1 { + t.Fatalf("publish after dispose delivered: %v", all) + } +} + +func TestConcurrentPublishIsSafeAndComplete(t *testing.T) { + b := New(Context{}, WithIDGenerator(sequenceIDs())) + def := Define("test.concurrent", nil) + var mu sync.Mutex + count := 0 + b.SubscribeCallback(def, func(Payload) { + mu.Lock() + count++ + mu.Unlock() + }) + var wg sync.WaitGroup + for range 100 { + wg.Add(1) + go func() { + defer wg.Done() + b.Publish(def, nil) + }() + } + wg.Wait() + if count != 100 { + t.Fatalf("count = %d", count) + } +} diff --git a/internal/seniordev/bus/event.go b/internal/seniordev/bus/event.go new file mode 100644 index 0000000000..90b0211e02 --- /dev/null +++ b/internal/seniordev/bus/event.go @@ -0,0 +1,66 @@ +//go:build !windows + +// Event-definition registry. Registry iteration preserves first-definition +// order. +package bus + +import "sync" + +// Definition identifies an event type and carries its consumer-supplied +// property schema/descriptor. +type Definition struct { + Type string `json:"type"` + Properties any `json:"properties"` +} + +// PayloadDefinition describes one registered event type and its property +// schema. +type PayloadDefinition struct { + Type string `json:"type"` + Properties any `json:"properties"` + Identifier string `json:"identifier"` +} + +var definitions = struct { + sync.RWMutex + order []string + byID map[string]Definition +}{byID: make(map[string]Definition)} + +// Define registers and returns an event definition. Redefining a type updates +// its schema without changing its original insertion position. +func Define(eventType string, properties any) Definition { + definitions.Lock() + defer definitions.Unlock() + if _, exists := definitions.byID[eventType]; !exists { + definitions.order = append(definitions.order, eventType) + } + result := Definition{Type: eventType, Properties: properties} + definitions.byID[eventType] = result + return result +} + +// Payloads returns the payload descriptors in registry order. +func Payloads() []PayloadDefinition { + return payloadDefinitions() +} + +func payloadDefinitions() []PayloadDefinition { + definitions.RLock() + defer definitions.RUnlock() + out := make([]PayloadDefinition, 0, len(definitions.order)) + for _, eventType := range definitions.order { + def := definitions.byID[eventType] + out = append(out, PayloadDefinition{ + Type: eventType, + Properties: def.Properties, + Identifier: "Event." + eventType, + }) + } + return out +} + +// InstanceDisposed is published to wildcard subscribers during Bus.Dispose. +var InstanceDisposed = Define("server.instance.disposed", struct { + Directory string `json:"directory"` +}{}) diff --git a/internal/seniordev/config/config.go b/internal/seniordev/config/config.go new file mode 100644 index 0000000000..06123b9da7 --- /dev/null +++ b/internal/seniordev/config/config.go @@ -0,0 +1,584 @@ +//go:build !windows + +package config + +// Config file loading, JSONC parsing and merge. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" +) + +// Info is the deliberately open config object. The known fields sit next to +// several extensible record surfaces; retaining JSON values avoids lossy +// re-encoding during merges. +type Info map[string]any + +// OrderedEntry is one insertion-ordered JSON object field. +type OrderedEntry struct { + Key string + Value any +} + +// OrderedObject preserves source key order for the precedence-sensitive +// permission and tools objects. +type OrderedObject struct { + entries []OrderedEntry + index map[string]int +} + +func NewOrderedObject() *OrderedObject { + return &OrderedObject{index: map[string]int{}} +} + +func (o *OrderedObject) Set(key string, value any) { + if o.index == nil { + o.index = map[string]int{} + } + if index, ok := o.index[key]; ok { + o.entries[index].Value = value + return + } + o.index[key] = len(o.entries) + o.entries = append(o.entries, OrderedEntry{Key: key, Value: value}) +} + +func (o *OrderedObject) Get(key string) (any, bool) { + if o == nil { + return nil, false + } + index, ok := o.index[key] + if !ok { + return nil, false + } + return o.entries[index].Value, true +} + +func (o *OrderedObject) Entries() []OrderedEntry { + if o == nil { + return nil + } + return append([]OrderedEntry(nil), o.entries...) +} + +func (o *OrderedObject) MarshalJSON() ([]byte, error) { + var buffer bytes.Buffer + buffer.WriteByte('{') + for index, entry := range o.entries { + if index > 0 { + buffer.WriteByte(',') + } + key, err := json.Marshal(entry.Key) + if err != nil { + return nil, err + } + value, err := json.Marshal(entry.Value) + if err != nil { + return nil, err + } + buffer.Write(key) + buffer.WriteByte(':') + buffer.Write(value) + } + buffer.WriteByte('}') + return buffer.Bytes(), nil +} + +type InvalidError struct { + Path string + Message string +} + +func (e *InvalidError) Error() string { + if e.Message == "" { + return "invalid config: " + e.Path + } + return e.Message +} + +func stripJSONC(input string) string { + out := []byte(input) + inString, escaped := false, false + for index := 0; index < len(out); index++ { + if inString { + if escaped { + escaped = false + continue + } + if out[index] == '\\' { + escaped = true + } else if out[index] == '"' { + inString = false + } + continue + } + if out[index] == '"' { + inString = true + continue + } + if out[index] != '/' || index+1 >= len(out) { + continue + } + switch out[index+1] { + case '/': + for out[index] != '\n' && out[index] != '\r' { + out[index] = ' ' + index++ + if index >= len(out) { + break + } + } + case '*': + out[index], out[index+1] = ' ', ' ' + index += 2 + for index < len(out) { + if index+1 < len(out) && out[index] == '*' && out[index+1] == '/' { + out[index], out[index+1] = ' ', ' ' + index++ + break + } + if out[index] != '\n' && out[index] != '\r' { + out[index] = ' ' + } + index++ + } + } + } + // allowTrailingComma: true. Only commas whose next non-space byte closes an + // array/object are removed; string contents were left untouched above. + inString, escaped = false, false + for index := 0; index < len(out); index++ { + if inString { + if escaped { + escaped = false + } else if out[index] == '\\' { + escaped = true + } else if out[index] == '"' { + inString = false + } + continue + } + if out[index] == '"' { + inString = true + continue + } + if out[index] != ',' { + continue + } + next := index + 1 + for next < len(out) && strings.ContainsRune(" \t\r\n", rune(out[next])) { + next++ + } + if next < len(out) && (out[next] == '}' || out[next] == ']') { + out[index] = ' ' + } + } + return string(out) +} + +// ParseJSONC parses comments and trailing commas while preserving the source +// path in errors. +func ParseJSONC(text, source string) (any, error) { + return parseJSONC(text, source, false) +} + +func parseJSONC(text, source string, preserveRoot bool) (any, error) { + decoder := json.NewDecoder(strings.NewReader(stripJSONC(text))) + decoder.UseNumber() + value, err := decodeOrderedJSON(decoder) + if err != nil { + return nil, &InvalidError{ + Path: source, + Message: fmt.Sprintf("\n--- JSONC Input ---\n%s\n--- Errors ---\n%s\n--- End ---", text, err), + } + } + return materializeConfigJSON(value, preserveRoot), nil +} + +func materializeConfigJSON(value any, preserve bool) any { + switch value := value.(type) { + case orderedJSONObject: + if preserve { + out := NewOrderedObject() + for _, field := range value { + out.Set(field.key, materializeConfigJSON(field.value, true)) + } + return out + } + out := make(map[string]any, len(value)) + for _, field := range value { + keepOrder := field.key == "permission" || field.key == "tools" + nested := materializeConfigJSON(field.value, keepOrder) + if field.key == "permission" { + if normalized, ok := NormalizePermission(nested); ok { + nested = normalized + } + } + out[field.key] = nested + } + return out + case []any: + out := make([]any, len(value)) + for index, nested := range value { + out[index] = materializeConfigJSON(nested, preserve) + } + return out + default: + return value + } +} + +func cloneValue(value any) any { + switch value := value.(type) { + case map[string]any: + out := make(map[string]any, len(value)) + for key, nested := range value { + out[key] = cloneValue(nested) + } + return out + case *OrderedObject: + out := NewOrderedObject() + for _, entry := range value.Entries() { + out.Set(entry.Key, cloneValue(entry.Value)) + } + return out + case []any: + out := make([]any, len(value)) + for index, nested := range value { + out[index] = cloneValue(nested) + } + return out + default: + return value + } +} + +func mergeValue(target, source any) any { + if left, leftOK := asOrderedObject(target); leftOK { + if right, rightOK := asOrderedObject(source); rightOK { + out := cloneValue(left).(*OrderedObject) + for _, entry := range right.Entries() { + if current, ok := out.Get(entry.Key); ok { + out.Set(entry.Key, mergeValue(current, entry.Value)) + } else { + out.Set(entry.Key, cloneValue(entry.Value)) + } + } + return out + } + } + left, leftOK := target.(map[string]any) + right, rightOK := source.(map[string]any) + if !leftOK || !rightOK { + return cloneValue(source) + } + out := cloneValue(left).(map[string]any) + for key, value := range right { + if current, ok := out[key]; ok { + out[key] = mergeValue(current, value) + } else { + out[key] = cloneValue(value) + } + } + return out +} + +func asOrderedObject(value any) (*OrderedObject, bool) { + object, ok := value.(*OrderedObject) + return object, ok && object != nil +} + +// Merge deep-merges source over target; the instructions array is the one +// field that is concatenated (deduplicated) instead of replaced. +func Merge(target, source Info) Info { + merged := mergeValue(map[string]any(target), map[string]any(source)).(map[string]any) + left, leftOK := target["instructions"].([]any) + right, rightOK := source["instructions"].([]any) + if leftOK && rightOK { + seen := map[any]struct{}{} + joined := make([]any, 0, len(left)+len(right)) + for _, list := range [][]any{left, right} { + for _, value := range list { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + joined = append(joined, value) + } + } + merged["instructions"] = joined + } + return Info(merged) +} + +func normalizeLoadedConfig(value any) Info { + object, ok := value.(map[string]any) + if !ok { + return Info{} + } + out := Info(cloneValue(object).(map[string]any)) + delete(out, "theme") + delete(out, "keybinds") + delete(out, "tui") + return out +} + +// NormalizePermission expands the action shorthand to the "*" rule. +func NormalizePermission(value any) (*OrderedObject, bool) { + if action, ok := value.(string); ok { + if action != "ask" && action != "allow" && action != "deny" { + return nil, false + } + object := NewOrderedObject() + object.Set("*", action) + return object, true + } + object, ok := value.(*OrderedObject) + if !ok { + return nil, false + } + return object, true +} + +func normalizeTools(info Info) { + tools, ok := asOrderedObject(info["tools"]) + if !ok { + return + } + perms := NewOrderedObject() + for _, entry := range tools.Entries() { + tool, raw := entry.Key, entry.Value + enabled, _ := raw.(bool) + action := "deny" + if enabled { + action = "allow" + } + if tool == "write" || tool == "edit" || tool == "patch" { + perms.Set("edit", action) + } else { + perms.Set(tool, action) + } + } + if configured, ok := asOrderedObject(info["permission"]); ok { + perms = mergeValue(perms, configured).(*OrderedObject) + } + info["permission"] = perms +} + +// LoadText expands substitutions, parses JSONC, and applies schema-level +// normalizations used by the pipeline. +func LoadText(text string, input SubstituteInput) (Info, error) { + input.Text = text + expanded, err := Substitute(input) + if err != nil { + return nil, err + } + value, err := ParseJSONC(expanded, input.Source) + if err != nil { + return nil, err + } + info := normalizeLoadedConfig(value) + if autoshare, ok := info["autoshare"].(bool); ok && autoshare { + if _, exists := info["share"]; !exists { + info["share"] = "auto" + } + } + return info, nil +} + +// FileInDirectory returns the candidate paths for name, JSON before JSONC. +func FileInDirectory(dir, name string) []string { + return []string{filepath.Join(dir, name+".json"), filepath.Join(dir, name+".jsonc")} +} + +func withinOrSame(path, stop string) bool { + if stop == "" { + return true + } + rel, err := filepath.Rel(filepath.Clean(stop), filepath.Clean(path)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// ProjectFiles finds config files from the project boundary inward. +func ProjectFiles(name, directory, worktree string) []string { + found := []string{} + current := filepath.Clean(directory) + stop := filepath.Clean(worktree) + for { + for _, candidate := range FileInDirectory(current, name) { + if _, err := os.Stat(candidate); err == nil { + found = append(found, candidate) + } + } + if current == stop || current == filepath.Dir(current) || !withinOrSame(filepath.Dir(current), stop) { + break + } + current = filepath.Dir(current) + } + for left, right := 0, len(found)-1; left < right; left, right = left+1, right-1 { + found[left], found[right] = found[right], found[left] + } + return found +} + +// Loader reads and merges the config sources for one directory. +type Loader struct { + GlobalDir string + Env Env +} + +func (l Loader) globalDir() string { + if l.GlobalDir != "" { + return l.GlobalDir + } + if value, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok && value != "" { + return filepath.Join(value, "senior-dev") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "senior-dev") +} + +func readOptional(path string, lookup Lookup) (Info, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return Info{}, nil + } + if err != nil { + return nil, err + } + return LoadText(string(data), SubstituteInput{Path: path, Source: path, Lookup: lookup}) +} + +// Load merges the config sources, lowest precedence first: global files, +// SENIOR_DEV_CONFIG, project files, SENIOR_DEV_CONFIG_CONTENT, SENIOR_DEV_PERMISSION. +func (l Loader) Load(directory, worktree string) (Info, error) { + result := Info{} + lookup := l.Env.Get + for _, name := range []string{"config.json", "senior-dev.json", "senior-dev.jsonc"} { + next, err := readOptional(filepath.Join(l.globalDir(), name), lookup) + if err != nil { + return nil, err + } + result = Merge(result, next) + } + if custom, ok := l.Env.Get("SENIOR_DEV_CONFIG"); ok && custom != "" { + next, err := readOptional(custom, lookup) + if err != nil { + return nil, err + } + result = Merge(result, next) + } + if !l.Env.Enabled("SENIOR_DEV_DISABLE_PROJECT_CONFIG") { + for _, path := range ProjectFiles("senior-dev", directory, worktree) { + next, err := readOptional(path, lookup) + if err != nil { + return nil, err + } + result = Merge(result, next) + } + } + if content, ok := l.Env.Get("SENIOR_DEV_CONFIG_CONTENT"); ok && content != "" { + next, err := LoadText(content, SubstituteInput{ + Dir: directory, Source: "SENIOR_DEV_CONFIG_CONTENT", Lookup: lookup, + }) + if err != nil { + return nil, err + } + result = Merge(result, next) + } + if raw, ok := l.Env.Get("SENIOR_DEV_PERMISSION"); ok && raw != "" { + value, err := parseJSONC(raw, "SENIOR_DEV_PERMISSION", true) + if err != nil { + return nil, err + } + next, valid := NormalizePermission(value) + if !valid { + return nil, errors.New("SENIOR_DEV_PERMISSION must be a permission action or object") + } + current, _ := asOrderedObject(result["permission"]) + if current == nil { + current = NewOrderedObject() + } + result["permission"] = mergeValue(current, next) + } + normalizeTools(result) + if l.Env.Enabled("SENIOR_DEV_DISABLE_AUTOCOMPACT") { + compaction, _ := result["compaction"].(map[string]any) + if compaction == nil { + compaction = map[string]any{} + } + compaction["auto"] = false + result["compaction"] = compaction + } + if l.Env.Enabled("SENIOR_DEV_DISABLE_PRUNE") { + compaction, _ := result["compaction"].(map[string]any) + if compaction == nil { + compaction = map[string]any{} + } + compaction["prune"] = false + result["compaction"] = compaction + } + return result, nil +} + +// Service caches per-directory config and supports explicit invalidation. +type Service struct { + loader Loader + mu sync.RWMutex + cache map[string]Info +} + +func NewService(loader Loader) *Service { + return &Service{loader: loader, cache: map[string]Info{}} +} + +func (s *Service) Get(directory, worktree string) (Info, error) { + key := filepath.Clean(directory) + s.mu.RLock() + if value, ok := s.cache[key]; ok { + s.mu.RUnlock() + return Info(cloneValue(map[string]any(value)).(map[string]any)), nil + } + s.mu.RUnlock() + value, err := s.loader.Load(directory, worktree) + if err != nil { + return nil, err + } + s.mu.Lock() + s.cache[key] = value + s.mu.Unlock() + return Info(cloneValue(map[string]any(value)).(map[string]any)), nil +} + +func (s *Service) Update(directory string, info Info) error { + path := filepath.Join(directory, "config.json") + existing, err := readOptional(path, s.loader.Env.Get) + if err != nil { + return err + } + data, err := json.MarshalIndent(Merge(existing, info), "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return err + } + s.Invalidate(directory) + return nil +} + +func (s *Service) Invalidate(directory string) { + s.mu.Lock() + defer s.mu.Unlock() + if directory == "" { + s.cache = map[string]Info{} + return + } + delete(s.cache, filepath.Clean(directory)) +} diff --git a/internal/seniordev/config/env.go b/internal/seniordev/config/env.go new file mode 100644 index 0000000000..b04df29e34 --- /dev/null +++ b/internal/seniordev/config/env.go @@ -0,0 +1,153 @@ +//go:build !windows + +// Package config is the configuration layer: project config files, the +// SENIOR_DEV_* environment surface and their merge. +package config + +import ( + "os" + "strings" +) + +// BoolMode identifies how a boolean environment variable is spelled. The +// spellings are deliberately asymmetric (an opt-out reads "0", an opt-in reads +// "1", a truthy flag reads "true"/"1"), so they are not replaced with +// strconv.ParseBool. +type BoolMode string + +const ( + RawValue BoolMode = "raw" + OptInOne BoolMode = "opt-in-1" + OptOutZero BoolMode = "opt-out-0" + Truthy BoolMode = "truthy" +) + +// VariableNames is the environment surface the config layer snapshots. A +// variable not listed here is invisible to Env.Get. +var VariableNames = []string{ + "SENIOR_DEV_CLIENT", + "SENIOR_DEV_CONFIG", + "SENIOR_DEV_CONFIG_CONTENT", + "SENIOR_DEV_CONFIG_DIR", + "SENIOR_DEV_DISABLE_AUTOCOMPACT", + "SENIOR_DEV_DISABLE_MODELS_FETCH", + "SENIOR_DEV_DISABLE_PROJECT_CONFIG", + "SENIOR_DEV_DISABLE_PRUNE", + "SENIOR_DEV_EAGER_COMMIT", + "SENIOR_DEV_ENABLE_EXA", + "SENIOR_DEV_ENABLE_PARALLEL", + "SENIOR_DEV_ENABLE_QUESTION_TOOL", + "SENIOR_DEV_ENV_SIGNALS", + "SENIOR_DEV_EXPERIMENTAL", + "SENIOR_DEV_EXPERIMENTAL_EXA", + "SENIOR_DEV_EXPERIMENTAL_OXFMT", + "SENIOR_DEV_EXPERIMENTAL_PARALLEL", + "SENIOR_DEV_MODELS_PATH", + "SENIOR_DEV_MODELS_URL", + "SENIOR_DEV_OUTPUT_TOKEN_MAX", + "SENIOR_DEV_PERMISSION", + "SENIOR_DEV_SCRATCH_MAX_GB", + "SENIOR_DEV_SCRATCH_ROOT", + "SENIOR_DEV_SCRATCH_TTL_H", + "SENIOR_DEV_SHARED_BUILD_CACHE", + "SENIOR_DEV_WEBSEARCH_PROVIDER", + "SENIOR_DEV_MAX_COST_USD", + "SENIOR_DEV_MAX_WALL_H", +} + +var boolModes = map[string]BoolMode{ + // Exact opt-outs. + "SENIOR_DEV_EAGER_COMMIT": OptOutZero, + "SENIOR_DEV_ENV_SIGNALS": OptOutZero, + + // Exact opt-ins. + "SENIOR_DEV_SHARED_BUILD_CACHE": OptInOne, + + // Case-insensitive truthy flags. + "SENIOR_DEV_DISABLE_AUTOCOMPACT": Truthy, + "SENIOR_DEV_DISABLE_MODELS_FETCH": Truthy, + "SENIOR_DEV_DISABLE_PROJECT_CONFIG": Truthy, + "SENIOR_DEV_DISABLE_PRUNE": Truthy, + "SENIOR_DEV_ENABLE_EXA": Truthy, + "SENIOR_DEV_ENABLE_PARALLEL": Truthy, + "SENIOR_DEV_ENABLE_QUESTION_TOOL": Truthy, + "SENIOR_DEV_EXPERIMENTAL": Truthy, + "SENIOR_DEV_EXPERIMENTAL_EXA": Truthy, + "SENIOR_DEV_EXPERIMENTAL_OXFMT": Truthy, + "SENIOR_DEV_EXPERIMENTAL_PARALLEL": Truthy, +} + +// Mode returns the parsing mode for name. Non-boolean variables retain their +// raw string value. +func Mode(name string) BoolMode { + if mode, ok := boolModes[name]; ok { + return mode + } + return RawValue +} + +// ParseBoolean applies one of the exact boolean comparisons. raw=nil +// represents an absent environment entry. +func ParseBoolean(mode BoolMode, raw *string) bool { + value := "" + if raw != nil { + value = *raw + } + switch mode { + case OptInOne: + return value == "1" + case OptOutZero: + return value != "0" + case Truthy: + lower := strings.ToLower(value) + return lower == "true" || lower == "1" + default: + return false + } +} + +// Lookup is the minimal environment read boundary used by Config. +type Lookup func(string) (string, bool) + +// Env snapshots an environment without mutating the process-global map. +type Env struct { + values map[string]string +} + +// NewEnv snapshots lookup for the declared variables. +func NewEnv(lookup Lookup) Env { + if lookup == nil { + lookup = os.LookupEnv + } + values := make(map[string]string, len(VariableNames)) + for _, name := range VariableNames { + if value, ok := lookup(name); ok { + values[name] = value + } + } + return Env{values: values} +} + +// Get returns a raw value and preserves absent versus explicitly empty. +func (e Env) Get(name string) (string, bool) { + value, ok := e.values[name] + return value, ok +} + +// Enabled parses name according to its declared mode. +func (e Env) Enabled(name string) bool { + value, ok := e.Get(name) + if !ok { + return ParseBoolean(Mode(name), nil) + } + return ParseBoolean(Mode(name), &value) +} + +// All returns a defensive copy. +func (e Env) All() map[string]string { + out := make(map[string]string, len(e.values)) + for key, value := range e.values { + out[key] = value + } + return out +} diff --git a/internal/seniordev/config/helpers.go b/internal/seniordev/config/helpers.go new file mode 100644 index 0000000000..ef32d598ee --- /dev/null +++ b/internal/seniordev/config/helpers.go @@ -0,0 +1,87 @@ +//go:build !windows + +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "unicode" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// SubstituteInput is the input to Substitute: the config text plus where it +// came from, which anchors relative {file:...} references. +type SubstituteInput struct { + Text string + Path string + Dir string + Source string + Lookup Lookup +} + +var envToken = regexp.MustCompile(`\{env:([^}]+)\}`) +var fileToken = regexp.MustCompile(`\{file:[^}]+\}`) + +// Substitute applies {env:VAR} and {file:path} substitutions. A file +// reference on a line that starts with // is left alone; a missing file is an +// error. +func Substitute(input SubstituteInput) (string, error) { + lookup := input.Lookup + if lookup == nil { + lookup = os.LookupEnv + } + text := envToken.ReplaceAllStringFunc(input.Text, func(token string) string { + name := token[len("{env:") : len(token)-1] + value, _ := lookup(name) + return value + }) + matches := fileToken.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return text, nil + } + configDir := input.Dir + if input.Path != "" { + configDir = filepath.Dir(input.Path) + } + var out strings.Builder + cursor := 0 + for _, match := range matches { + token := text[match[0]:match[1]] + out.WriteString(text[cursor:match[0]]) + lineStart := strings.LastIndex(text[:match[0]], "\n") + 1 + if strings.HasPrefix(strings.TrimLeftFunc(text[lineStart:match[0]], unicode.IsSpace), "//") { + out.WriteString(token) + cursor = match[1] + continue + } + file := strings.TrimSuffix(strings.TrimPrefix(token, "{file:"), "}") + if strings.HasPrefix(file, "~/") { + if home, err := os.UserHomeDir(); err == nil { + file = filepath.Join(home, file[2:]) + } + } + if !filepath.IsAbs(file) { + file = filepath.Join(configDir, file) + } + file = filepath.Clean(file) + data, err := os.ReadFile(file) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf(`bad file reference: %q %s does not exist`, token, file) + } + return "", fmt.Errorf(`bad file reference: %q`, token) + } + quoted, _ := jsonutil.Marshal(strings.TrimSpace(string(data))) + if len(quoted) >= 2 { + out.Write(quoted[1 : len(quoted)-1]) + } + cursor = match[1] + } + out.WriteString(text[cursor:]) + return out.String(), nil +} diff --git a/internal/seniordev/config/loader_test.go b/internal/seniordev/config/loader_test.go new file mode 100644 index 0000000000..54665f7bdc --- /dev/null +++ b/internal/seniordev/config/loader_test.go @@ -0,0 +1,43 @@ +//go:build !windows + +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoaderMergesProjectAndPermissionEnvironment(t *testing.T) { + workspace := t.TempDir() + global := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "senior-dev.json"), []byte(`{ + "instructions": ["PROJECT.md"], + "permission": {"edit": "allow"} +}`), 0o644); err != nil { + t.Fatal(err) + } + values := map[string]string{ + "SENIOR_DEV_PERMISSION": `{"edit":"deny","read":"ask"}`, + } + env := NewEnv(func(name string) (string, bool) { + value, ok := values[name] + return value, ok + }) + + // The once-per-run loader layers env over project config. + loaded, err := (Loader{GlobalDir: global, Env: env}).Load(workspace, workspace) + if err != nil { + t.Fatal(err) + } + permission, _ := loaded["permission"].(*OrderedObject) + edit, _ := permission.Get("edit") + read, _ := permission.Get("read") + if edit != "deny" || read != "ask" { + t.Fatalf("permission merge = %#v", permission) + } + instructions, _ := loaded["instructions"].([]any) + if len(instructions) != 1 || instructions[0] != "PROJECT.md" { + t.Fatalf("instructions = %#v", instructions) + } +} diff --git a/internal/seniordev/config/orderedjson.go b/internal/seniordev/config/orderedjson.go new file mode 100644 index 0000000000..d3a9419ae0 --- /dev/null +++ b/internal/seniordev/config/orderedjson.go @@ -0,0 +1,65 @@ +//go:build !windows + +package config + +import ( + "encoding/json" + "errors" +) + +// orderedJSONField is one member of an object decoded with its source order +// intact. Config keeps source order for the permission and tools blocks, +// where rule order is meaningful. +type orderedJSONField struct { + key string + value any +} + +type orderedJSONObject []orderedJSONField + +// decodeOrderedJSON decodes the next value from decoder, keeping object +// members in source order. Numbers arrive as json.Number. +func decodeOrderedJSON(decoder *json.Decoder) (any, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + delimiter, isDelimiter := token.(json.Delim) + if !isDelimiter { + return token, nil + } + switch delimiter { + case '{': + object := orderedJSONObject{} + for decoder.More() { + keyToken, keyErr := decoder.Token() + if keyErr != nil { + return nil, keyErr + } + key, ok := keyToken.(string) + if !ok { + return nil, errors.New("JSON object key is not a string") + } + value, valueErr := decodeOrderedJSON(decoder) + if valueErr != nil { + return nil, valueErr + } + object = append(object, orderedJSONField{key: key, value: value}) + } + _, err = decoder.Token() + return object, err + case '[': + array := []any{} + for decoder.More() { + value, valueErr := decodeOrderedJSON(decoder) + if valueErr != nil { + return nil, valueErr + } + array = append(array, value) + } + _, err = decoder.Token() + return array, err + default: + return nil, errors.New("unexpected JSON delimiter") + } +} diff --git a/internal/seniordev/core/filesystem.go b/internal/seniordev/core/filesystem.go new file mode 100644 index 0000000000..bfe96e113b --- /dev/null +++ b/internal/seniordev/core/filesystem.go @@ -0,0 +1,502 @@ +//go:build !windows + +// Application filesystem. Plain writes and write-with-parent-directory retry +// are distinct methods. +package core + +import ( + "encoding/json" + "errors" + "io/fs" + "mime" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// FileSystemError wraps operations implemented outside the basic os package. +type FileSystemError struct { + Method string + Cause error +} + +func (e *FileSystemError) Error() string { + if e.Cause == nil { + return "FileSystemError: " + e.Method + } + return "FileSystemError: " + e.Method + ": " + e.Cause.Error() +} + +func (e *FileSystemError) Unwrap() error { return e.Cause } + +// DirEntry is the portable directory-entry shape from AppFileSystem. +type DirEntry struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// GlobOptions configures Glob. +type GlobOptions struct { + Cwd string + Absolute bool + Include string // "file" (default) or "all" + Dot bool + Symlink bool +} + +// AppFileSystem is the concrete application filesystem service. +type AppFileSystem struct{} + +// NewFileSystem constructs the default OS-backed service. +func NewFileSystem() *AppFileSystem { return &AppFileSystem{} } + +// ExistsSafe swallows all stat failures. +func (f *AppFileSystem) ExistsSafe(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// ReadFileStringSafe maps only NotExist to an absent result. +func (f *AppFileSystem) ReadFileStringSafe(path string) (string, bool, error) { + data, err := os.ReadFile(path) + if errors.Is(err, fs.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return string(data), true, nil +} + +// IsDir returns false on every stat failure. +func (f *AppFileSystem) IsDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +// IsFile returns false on every stat failure. +func (f *AppFileSystem) IsFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +// ReadDirectoryEntries preserves the operating system's readdir order. +func (f *AppFileSystem) ReadDirectoryEntries(path string) ([]DirEntry, error) { + dir, err := os.Open(path) + if err != nil { + return nil, &FileSystemError{Method: "readDirectoryEntries", Cause: err} + } + defer dir.Close() + entries, err := dir.Readdir(-1) + if err != nil { + return nil, &FileSystemError{Method: "readDirectoryEntries", Cause: err} + } + out := make([]DirEntry, 0, len(entries)) + for _, entry := range entries { + entryType := "other" + switch { + case entry.IsDir(): + entryType = "directory" + case entry.Mode()&os.ModeSymlink != 0: + entryType = "symlink" + case entry.Mode().IsRegular(): + entryType = "file" + } + out = append(out, DirEntry{Name: entry.Name(), Type: entryType}) + } + return out, nil +} + +// ReadJSON decodes a JSON file into dst; numbers decode as float64. +func (f *AppFileSystem) ReadJSON(path string, dst any) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + decoder := json.NewDecoder(file) + return decoder.Decode(dst) +} + +// WriteJSON does not create parent directories. +func (f *AppFileSystem) WriteJSON(path string, data any, mode ...fs.FileMode) error { + content, err := jsonutil.MarshalIndent(data) + if err != nil { + return err + } + if err := os.WriteFile(path, content, 0o666); err != nil { + return err + } + if len(mode) > 0 && mode[0] != 0 { + return os.Chmod(path, mode[0]) + } + return nil +} + +// EnsureDir creates path recursively. +func (f *AppFileSystem) EnsureDir(path string) error { + return os.MkdirAll(path, 0o777) +} + +// WriteWithDirs retries a missing-parent write after creating the parent. +func (f *AppFileSystem) WriteWithDirs(path string, content []byte, mode ...fs.FileMode) error { + err := os.WriteFile(path, content, 0o666) + if errors.Is(err, fs.ErrNotExist) { + if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o777); mkdirErr != nil { + return mkdirErr + } + err = os.WriteFile(path, content, 0o666) + } + if err != nil { + return err + } + if len(mode) > 0 && mode[0] != 0 { + return os.Chmod(path, mode[0]) + } + return nil +} + +// WriteStringWithDirs is the string overload of WriteWithDirs. +func (f *AppFileSystem) WriteStringWithDirs(path, content string, mode ...fs.FileMode) error { + return f.WriteWithDirs(path, []byte(content), mode...) +} + +// Glob scans from options.Cwd using minimatch-style ** path segments. +func (f *AppFileSystem) Glob(pattern string, options ...GlobOptions) ([]string, error) { + opt := GlobOptions{} + if len(options) > 0 { + opt = options[0] + } + cwd := opt.Cwd + if cwd == "" { + var err error + cwd, err = os.Getwd() + if err != nil { + return nil, &FileSystemError{Method: "glob", Cause: err} + } + } + out := []string{} + err := walkGlob(cwd, opt.Symlink, func(path string, entry fs.DirEntry) error { + if path == cwd { + return nil + } + rel, err := filepath.Rel(cwd, path) + if err != nil { + return err + } + slashRel := filepath.ToSlash(rel) + if !opt.Dot && hasDotSegment(slashRel) && !patternMentionsDot(pattern) { + if entry.IsDir() { + return fs.SkipDir + } + return nil + } + if !GlobMatch(pattern, slashRel) { + return nil + } + if opt.Include != "all" && entry.IsDir() { + return nil + } + if opt.Absolute { + absolute, err := filepath.Abs(path) + if err != nil { + return err + } + out = append(out, absolute) + } else { + out = append(out, filepath.FromSlash(slashRel)) + } + return nil + }) + if err != nil { + return nil, &FileSystemError{Method: "glob", Cause: err} + } + return out, nil +} + +func walkGlob(root string, followSymlinks bool, visit func(string, fs.DirEntry) error) error { + seen := map[string]bool{} + var walk func(string) error + walk = func(path string) error { + real := path + if followSymlinks { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + real = resolved + } + if seen[real] { + return nil + } + seen[real] = true + } + entries, err := os.ReadDir(path) + if err != nil { + return err + } + for _, entry := range entries { + child := filepath.Join(path, entry.Name()) + err := visit(child, entry) + if errors.Is(err, fs.SkipDir) { + continue + } + if err != nil { + return err + } + isDir := entry.IsDir() + if !isDir && followSymlinks && entry.Type()&os.ModeSymlink != 0 { + if info, err := os.Stat(child); err == nil { + isDir = info.IsDir() + } + } + if isDir { + if err := walk(child); err != nil { + return err + } + } + } + return nil + } + return walk(root) +} + +// GlobMatch matches a slash-separated path against a pattern whose "**" +// segment spans any number of directories. Dot files are not special here. +func GlobMatch(pattern, path string) bool { + patternParts := splitSlash(pattern) + pathParts := splitSlash(path) + var match func(int, int) bool + match = func(pi, si int) bool { + if pi == len(patternParts) { + return si == len(pathParts) + } + if patternParts[pi] == "**" { + if match(pi+1, si) { + return true + } + return si < len(pathParts) && match(pi, si+1) + } + if si >= len(pathParts) { + return false + } + ok, err := filepath.Match(patternParts[pi], pathParts[si]) + return err == nil && ok && match(pi+1, si+1) + } + return match(0, 0) +} + +func splitSlash(value string) []string { + value = strings.ReplaceAll(value, "\\", "/") + value = strings.TrimPrefix(value, "./") + return strings.Split(value, "/") +} + +func hasDotSegment(path string) bool { + for _, part := range splitSlash(path) { + if strings.HasPrefix(part, ".") { + return true + } + } + return false +} + +func patternMentionsDot(pattern string) bool { + for _, part := range splitSlash(pattern) { + if strings.HasPrefix(part, ".") { + return true + } + } + return false +} + +// FindUp finds target at start and each parent, nearest first. +func (f *AppFileSystem) FindUp(target, start string, stop ...string) ([]string, error) { + return f.Up(UpOptions{Targets: []string{target}, Start: start, Stop: first(stop)}) +} + +// UpOptions configures Up. +type UpOptions struct { + Targets []string + Start string + Stop string +} + +// Up finds all target names at every ancestor. +func (f *AppFileSystem) Up(options UpOptions) ([]string, error) { + result := []string{} + current := options.Start + for { + for _, target := range options.Targets { + search := filepath.Join(current, target) + if _, err := os.Stat(search); err == nil { + result = append(result, search) + } + } + if options.Stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result, nil +} + +// GlobUp scans each ancestor and swallows per-directory glob errors. +func (f *AppFileSystem) GlobUp(pattern, start string, stop ...string) ([]string, error) { + result := []string{} + current := start + stopAt := first(stop) + for { + matches, err := f.Glob(pattern, GlobOptions{Cwd: current, Absolute: true, Dot: true}) + if err == nil { + result = append(result, matches...) + } + if stopAt == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result, nil +} + +func first(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + +// MimeType returns the media type for path's extension, or +// application/octet-stream when it is unknown. +func MimeType(path string) string { + extension := strings.ToLower(filepath.Ext(path)) + switch extension { + case ".md", ".markdown": + return "text/markdown" + case ".ts": + return "video/mp2t" + case ".js", ".mjs": + return "text/javascript" + case ".json": + return "application/json" + case ".yaml", ".yml": + return "text/yaml" + case ".wasm": + return "application/wasm" + case ".tsx": + return "application/octet-stream" + } + if value := mime.TypeByExtension(extension); value != "" { + return strings.TrimSpace(strings.Split(value, ";")[0]) + } + return "application/octet-stream" +} + +// NormalizePath canonicalizes Windows paths; it is a no-op on other systems. +func NormalizePath(path string) string { + if runtime.GOOS != "windows" { + return path + } + resolved, _ := filepath.Abs(WindowsPath(path)) + if real, err := filepath.EvalSymlinks(resolved); err == nil { + return real + } + return resolved +} + +// NormalizePathPattern preserves a terminal wildcard during normalization. +func NormalizePathPattern(path string) string { + if runtime.GOOS != "windows" { + return path + } + if path == "*" { + return path + } + normalized := strings.ReplaceAll(path, "\\", "/") + if !strings.HasSuffix(normalized, "/*") { + return NormalizePath(path) + } + dir := strings.TrimSuffix(normalized, "/*") + if len(dir) == 2 && dir[1] == ':' { + dir += `\` + } + return filepath.Join(NormalizePath(dir), "*") +} + +// Resolve returns the real absolute path or the normalized absolute path when +// the target does not exist. +func Resolve(path string) (string, error) { + resolved, err := filepath.Abs(WindowsPath(path)) + if err != nil { + return "", err + } + real, err := filepath.EvalSymlinks(resolved) + if err == nil { + return NormalizePath(real), nil + } + if errors.Is(err, fs.ErrNotExist) { + return NormalizePath(resolved), nil + } + return "", err +} + +// WindowsPath translates common POSIX drive spellings on Windows. +func WindowsPath(path string) string { + if runtime.GOOS != "windows" { + return path + } + return windowsPath(path) +} + +func windowsPath(path string) string { + slash := strings.ReplaceAll(path, "\\", "/") + var rest string + var drive byte + switch { + case len(slash) >= 3 && slash[0] == '/' && isASCIIAlpha(slash[1]) && slash[2] == ':': + if len(slash) > 3 && slash[3] != '/' { + return path + } + drive, rest = slash[1], slash[3:] + case len(slash) >= 2 && slash[0] == '/' && isASCIIAlpha(slash[1]) && (len(slash) == 2 || slash[2] == '/'): + drive, rest = slash[1], slash[2:] + case strings.HasPrefix(slash, "/cygdrive/") && len(slash) >= 11 && isASCIIAlpha(slash[10]) && + (len(slash) == 11 || slash[11] == '/'): + drive, rest = slash[10], slash[11:] + case strings.HasPrefix(slash, "/mnt/") && len(slash) >= 6 && isASCIIAlpha(slash[5]) && + (len(slash) == 6 || slash[6] == '/'): + drive, rest = slash[5], slash[6:] + default: + return path + } + if drive >= 'a' && drive <= 'z' { + drive -= 'a' - 'A' + } + return string(drive) + ":/" + strings.TrimPrefix(rest, "/") +} + +func isASCIIAlpha(value byte) bool { + return value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' +} + +// Overlaps reports whether either path is within the other. +func Overlaps(a, b string) bool { + relA, _ := filepath.Rel(a, b) + relB, _ := filepath.Rel(b, a) + return relA == "" || !strings.HasPrefix(relA, "..") || relB == "" || !strings.HasPrefix(relB, "..") +} + +// Contains reports whether child is under parent: any relative path that +// starts with ".." counts as outside. +func Contains(parent, child string) bool { + relative, _ := filepath.Rel(parent, child) + return !strings.HasPrefix(relative, "..") +} diff --git a/internal/seniordev/core/filesystem_test.go b/internal/seniordev/core/filesystem_test.go new file mode 100644 index 0000000000..3085c812d8 --- /dev/null +++ b/internal/seniordev/core/filesystem_test.go @@ -0,0 +1,102 @@ +//go:build !windows + +package core + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" +) + +func TestFileSystemWritesReadsAndDirectoryEntries(t *testing.T) { + root := t.TempDir() + filesystem := NewFileSystem() + nested := filepath.Join(root, "a", "b.json") + if err := filesystem.WriteJSON(nested, map[string]any{"x": 1}); err == nil { + t.Fatal("WriteJSON unexpectedly created parents") + } + if err := filesystem.WriteStringWithDirs(nested, `{"x":1}`, 0o600); err != nil { + t.Fatal(err) + } + var value map[string]any + if err := filesystem.ReadJSON(nested, &value); err != nil { + t.Fatal(err) + } + if value["x"].(float64) != 1 { + t.Fatalf("read JSON: %#v", value) + } + info, err := os.Stat(nested) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %o", info.Mode().Perm()) + } + entries, err := filesystem.ReadDirectoryEntries(filepath.Join(root, "a")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0] != (DirEntry{Name: "b.json", Type: "file"}) { + t.Fatalf("entries: %#v", entries) + } +} + +func TestFileSystemUpAndGlob(t *testing.T) { + root := t.TempDir() + filesystem := NewFileSystem() + for _, rel := range []string{"package.json", "a/config.json", "a/b/file.go", "a/b/.hidden.go", ".root-hidden"} { + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + start := filepath.Join(root, "a", "b") + found, err := filesystem.Up(UpOptions{Targets: []string{"package.json", "config.json"}, Start: start}) + if err != nil { + t.Fatal(err) + } + wantFound := []string{filepath.Join(root, "a", "config.json"), filepath.Join(root, "package.json")} + if !reflect.DeepEqual(found, wantFound) { + t.Fatalf("up = %v, want %v", found, wantFound) + } + + matches, err := filesystem.Glob("**/*.go", GlobOptions{Cwd: root}) + if err != nil { + t.Fatal(err) + } + sort.Strings(matches) + if !reflect.DeepEqual(matches, []string{filepath.Join("a", "b", "file.go")}) { + t.Fatalf("glob without dot: %v", matches) + } + matches, err = filesystem.Glob("**/*.go", GlobOptions{Cwd: root, Dot: true, Absolute: true}) + if err != nil { + t.Fatal(err) + } + sort.Strings(matches) + want := []string{filepath.Join(root, "a", "b", ".hidden.go"), filepath.Join(root, "a", "b", "file.go")} + sort.Strings(want) + if !reflect.DeepEqual(matches, want) { + t.Fatalf("glob dot: %v, want %v", matches, want) + } +} + +func TestWindowsPathTransform(t *testing.T) { + cases := map[string]string{ + "/c/x": "C:/x", + "/c": "C:/", + "/c:/x": "C:/x", + "/cygdrive/d/x": "D:/x", + "/mnt/e/x": "E:/x", + "/code": "/code", + } + for input, want := range cases { + if got := windowsPath(input); got != want { + t.Errorf("windowsPath(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/seniordev/core/npm.go b/internal/seniordev/core/npm.go new file mode 100644 index 0000000000..cfb5725c50 --- /dev/null +++ b/internal/seniordev/core/npm.go @@ -0,0 +1,402 @@ +//go:build !windows + +// Npm package helper. Installation is behind the narrow Reifier interface; +// the default implementation invokes npm with save/ignore-scripts settings. +package core + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "sync" +) + +// InstallFailedError is the tagged npm installation failure. +type InstallFailedError struct { + Add []string + Dir string + Cause error +} + +func (e *InstallFailedError) Error() string { + if e.Cause == nil { + return fmt.Sprintf("NpmInstallFailedError: dir=%s add=%v", e.Dir, e.Add) + } + return fmt.Sprintf("NpmInstallFailedError: dir=%s add=%v: %v", e.Dir, e.Add, e.Cause) +} + +func (e *InstallFailedError) Unwrap() error { return e.Cause } + +// EntryPoint is the installed package directory and optional import entry. +type EntryPoint struct { + Directory string `json:"directory"` + Entrypoint *string `json:"entrypoint"` +} + +// PackageRequest is an extra dependency requested by Install. +type PackageRequest struct { + Name string + Version string +} + +// ReifiedNode is the package a reify installed. +type ReifiedNode struct { + Name string + Path string +} + +// Reifier performs one npm reify operation. +type Reifier interface { + Reify(ctx context.Context, dir string, add []string) (*ReifiedNode, error) +} + +// Npm is an npm package-cache service. +type Npm struct { + CacheDir string + FS *AppFileSystem + Reifier Reifier + + mu sync.Mutex + locks map[string]*sync.Mutex +} + +// NewNpm constructs an npm helper rooted at cacheDir. +func NewNpm(cacheDir string, reifier Reifier) *Npm { + fs := NewFileSystem() + if reifier == nil { + reifier = &commandReifier{spawner: NewSpawner()} + } + return &Npm{CacheDir: cacheDir, FS: fs, Reifier: reifier, locks: make(map[string]*sync.Mutex)} +} + +func (n *Npm) directory(pkg string) string { + return filepath.Join(n.CacheDir, "packages", Sanitize(pkg)) +} + +func (n *Npm) lock(dir string) func() { + n.mu.Lock() + lock := n.locks[dir] + if lock == nil { + lock = &sync.Mutex{} + n.locks[dir] = lock + } + n.mu.Unlock() + lock.Lock() + return lock.Unlock +} + +func (n *Npm) reify(ctx context.Context, dir string, add []string) (*ReifiedNode, error) { + unlock := n.lock(dir) + defer unlock() + node, err := n.Reifier.Reify(ctx, dir, add) + if err != nil { + return nil, &InstallFailedError{Add: append([]string(nil), add...), Dir: dir, Cause: err} + } + return node, nil +} + +// Add installs pkg in its isolated package cache. +func (n *Npm) Add(ctx context.Context, pkg string) (EntryPoint, error) { + dir := n.directory(pkg) + name := packageName(pkg) + installed := filepath.Join(dir, "node_modules", filepath.FromSlash(name)) + if n.FS.ExistsSafe(installed) { + return resolveEntryPoint(name, installed), nil + } + first, err := n.reify(ctx, dir, []string{pkg}) + if err != nil { + return EntryPoint{}, err + } + if first == nil { + result := resolveEntryPoint(name, installed) + if result.Entrypoint != nil { + return result, nil + } + return EntryPoint{}, &InstallFailedError{Add: []string{pkg}, Dir: dir} + } + return resolveEntryPoint(first.Name, first.Path), nil +} + +// Install reifies dir when node_modules is absent or the root lockfile omits a +// declared dependency. An unwritable directory is silently skipped. +func (n *Npm) Install(ctx context.Context, dir string, input ...[]PackageRequest) error { + if !writable(dir) { + return nil + } + requests := []PackageRequest{} + if len(input) > 0 { + requests = input[0] + } + add := make([]string, 0, len(requests)) + for _, pkg := range requests { + if pkg.Version == "" { + add = append(add, pkg.Name) + } else { + add = append(add, pkg.Name+"@"+pkg.Version) + } + } + if !n.FS.ExistsSafe(filepath.Join(dir, "node_modules")) { + _, err := n.reify(ctx, dir, add) + return err + } + + pkg := readJSONObject(filepath.Join(dir, "package.json")) + lock := readJSONObject(filepath.Join(dir, "package-lock.json")) + declared := map[string]bool{} + for _, key := range []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} { + for name := range objectMap(pkg[key]) { + declared[name] = true + } + } + for _, request := range requests { + declared[request.Name] = true + } + root := objectMap(objectMap(lock["packages"])[""]) + locked := map[string]bool{} + for _, key := range []string{"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"} { + for name := range objectMap(root[key]) { + locked[name] = true + } + } + for name := range declared { + if !locked[name] { + _, err := n.reify(ctx, dir, add) + return err + } + } + return nil +} + +// Which finds a package-provided executable, repairing the isolated install +// once when no bin exists. +func (n *Npm) Which(ctx context.Context, pkg string, bin ...string) (string, bool) { + dir := n.directory(pkg) + binDir := filepath.Join(dir, "node_modules", ".bin") + hint := "" + if len(bin) > 0 { + hint = bin[0] + } + pick := func() (string, bool) { + directory, err := os.Open(binDir) + if err != nil { + return "", false + } + files, err := directory.Readdirnames(-1) + _ = directory.Close() + if err != nil || len(files) == 0 { + return "", false + } + if hint != "" { + for _, file := range files { + if file == hint { + return file, true + } + } + return "", false + } + if len(files) == 1 { + return files[0], true + } + var manifest struct { + Bin json.RawMessage `json:"bin"` + } + if data, err := os.ReadFile(filepath.Join(dir, "node_modules", filepath.FromSlash(pkg), "package.json")); err == nil && + json.Unmarshal(data, &manifest) == nil && len(manifest.Bin) > 0 && string(manifest.Bin) != "null" { + var path string + if json.Unmarshal(manifest.Bin, &path) == nil { + return unscoped(pkg), true + } + order := orderedObjectKeys(manifest.Bin) + if len(order) == 1 { + return order[0], true + } + name := unscoped(pkg) + for _, key := range order { + if key == name { + return name, true + } + } + if len(order) > 0 { + return order[0], true + } + } + return files[0], true + } + if selected, ok := pick(); ok { + return filepath.Join(binDir, selected), true + } + _ = os.Remove(filepath.Join(dir, "package-lock.json")) + if _, err := n.Add(ctx, pkg); err != nil { + return "", false + } + selected, ok := pick() + if !ok { + return "", false + } + return filepath.Join(binDir, selected), true +} + +// Sanitize replaces Windows-illegal package path characters. It is a no-op +// on non-Windows platforms. +func Sanitize(pkg string) string { + return sanitizeForPlatform(pkg, runtime.GOOS) +} + +func sanitizeForPlatform(pkg, goos string) string { + if goos != "windows" { + return pkg + } + illegal := `<>:"|?*` + var out strings.Builder + for _, char := range pkg { + if char < 32 || strings.ContainsRune(illegal, char) { + out.WriteByte('_') + } else { + out.WriteRune(char) + } + } + return out.String() +} + +func packageName(pkg string) string { + if strings.HasPrefix(pkg, "@") { + slash := strings.IndexByte(pkg, '/') + if slash < 0 { + return pkg + } + if at := strings.IndexByte(pkg[slash:], '@'); at >= 0 { + return pkg[:slash+at] + } + return pkg + } + if at := strings.IndexByte(pkg, '@'); at > 0 { + return pkg[:at] + } + return pkg +} + +func unscoped(pkg string) string { + if strings.HasPrefix(pkg, "@") { + parts := strings.Split(pkg, "/") + if len(parts) > 1 { + return parts[1] + } + } + return pkg +} + +func resolveEntryPoint(_ string, dir string) EntryPoint { + manifest := filepath.Join(dir, "package.json") + data, err := os.ReadFile(manifest) + if err != nil { + return EntryPoint{Directory: dir} + } + var pkg struct { + Main string `json:"main"` + Module string `json:"module"` + } + if json.Unmarshal(data, &pkg) != nil { + return EntryPoint{Directory: dir} + } + entry := pkg.Module + if entry == "" { + entry = pkg.Main + } + if entry == "" { + entry = "index.js" + } + resolved := filepath.Join(dir, filepath.FromSlash(entry)) + if _, err := os.Stat(resolved); err != nil { + return EntryPoint{Directory: dir} + } + return EntryPoint{Directory: dir, Entrypoint: &resolved} +} + +func writable(dir string) bool { + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return false + } + file, err := os.CreateTemp(dir, ".senior-dev-write-*") + if err != nil { + return false + } + name := file.Name() + _ = file.Close() + _ = os.Remove(name) + return true +} + +func readJSONObject(path string) map[string]any { + data, err := os.ReadFile(path) + if err != nil { + return map[string]any{} + } + var out map[string]any + if json.Unmarshal(data, &out) != nil || out == nil { + return map[string]any{} + } + return out +} + +func objectMap(value any) map[string]any { + out, ok := value.(map[string]any) + if !ok || out == nil { + return map[string]any{} + } + return out +} + +func orderedObjectKeys(raw []byte) []string { + decoder := json.NewDecoder(strings.NewReader(string(raw))) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return nil + } + keys := []string{} + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return keys + } + key, ok := token.(string) + if !ok { + return keys + } + keys = append(keys, key) + var discard any + if err := decoder.Decode(&discard); err != nil { + return keys + } + } + return keys +} + +type commandReifier struct { + spawner *Spawner +} + +func (r *commandReifier) Reify(ctx context.Context, dir string, add []string) (*ReifiedNode, error) { + if err := os.MkdirAll(dir, 0o777); err != nil { + return nil, err + } + args := []string{"install", "--ignore-scripts", "--save", "--save-prod", "--save-prefix="} + args = append(args, add...) + _, stderr, code, err := r.spawner.Run(ctx, MakeCommand("npm", args, CommandOptions{Cwd: dir})) + if err != nil || code != 0 { + if err == nil { + err = errors.New(strings.TrimSpace(string(stderr))) + } + return nil, err + } + if len(add) == 0 { + return nil, nil + } + name := packageName(add[0]) + return &ReifiedNode{Name: name, Path: filepath.Join(dir, "node_modules", filepath.FromSlash(name))}, nil +} diff --git a/internal/seniordev/core/npm_test.go b/internal/seniordev/core/npm_test.go new file mode 100644 index 0000000000..3de92af197 --- /dev/null +++ b/internal/seniordev/core/npm_test.go @@ -0,0 +1,137 @@ +//go:build !windows + +package core + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +type reifyCall struct { + dir string + add []string +} + +type mockReifier struct { + calls []reifyCall + node *ReifiedNode + err error +} + +func (m *mockReifier) Reify(_ context.Context, dir string, add []string) (*ReifiedNode, error) { + m.calls = append(m.calls, reifyCall{dir: dir, add: append([]string(nil), add...)}) + return m.node, m.err +} + +func TestNpmAddExistingAndReified(t *testing.T) { + cache := t.TempDir() + reifier := &mockReifier{} + npm := NewNpm(cache, reifier) + installed := filepath.Join(cache, "packages", "@scope", "pkg@1", "node_modules", "@scope", "pkg") + if err := os.MkdirAll(installed, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "package.json"), []byte(`{"main":"main.js"}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "main.js"), nil, 0o644); err != nil { + t.Fatal(err) + } + entry, err := npm.Add(context.Background(), "@scope/pkg@1") + if err != nil { + t.Fatal(err) + } + if entry.Directory != installed || entry.Entrypoint == nil || len(reifier.calls) != 0 { + t.Fatalf("entry=%+v calls=%v", entry, reifier.calls) + } + + reifier.node = &ReifiedNode{Name: "foo", Path: filepath.Join(cache, "installed-foo")} + entry, err = npm.Add(context.Background(), "foo@2") + if err != nil { + t.Fatal(err) + } + if entry.Directory != reifier.node.Path || !reflect.DeepEqual(reifier.calls[0].add, []string{"foo@2"}) { + t.Fatalf("entry=%+v calls=%v", entry, reifier.calls) + } +} + +func TestNpmInstallChecksNodeModulesAndLock(t *testing.T) { + dir := t.TempDir() + reifier := &mockReifier{} + npm := NewNpm(t.TempDir(), reifier) + if err := npm.Install(context.Background(), dir, []PackageRequest{{Name: "a", Version: "1"}}); err != nil { + t.Fatal(err) + } + if len(reifier.calls) != 1 || !reflect.DeepEqual(reifier.calls[0].add, []string{"a@1"}) { + t.Fatalf("initial calls: %#v", reifier.calls) + } + + reifier.calls = nil + if err := os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"dependencies":{"a":"1","b":"1"}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte(`{"packages":{"":{"dependencies":{"a":"1"}}}}`), 0o644); err != nil { + t.Fatal(err) + } + if err := npm.Install(context.Background(), dir); err != nil { + t.Fatal(err) + } + if len(reifier.calls) != 1 { + t.Fatalf("dirty calls: %#v", reifier.calls) + } +} + +func TestNpmWhichSelectionAndRepairFailure(t *testing.T) { + cache := t.TempDir() + reifier := &mockReifier{err: errors.New("offline")} + npm := NewNpm(cache, reifier) + dir := filepath.Join(cache, "packages", "pkg") + binDir := filepath.Join(dir, "node_modules", ".bin") + if err := os.MkdirAll(filepath.Join(dir, "node_modules", "pkg"), 0o755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"other", "pkg"} { + if err := os.WriteFile(filepath.Join(binDir, name), nil, 0o755); err != nil { + if os.IsNotExist(err) { + if mkdirErr := os.MkdirAll(binDir, 0o755); mkdirErr != nil { + t.Fatal(mkdirErr) + } + if err := os.WriteFile(filepath.Join(binDir, name), nil, 0o755); err != nil { + t.Fatal(err) + } + } else { + t.Fatal(err) + } + } + } + if err := os.WriteFile(filepath.Join(dir, "node_modules", "pkg", "package.json"), []byte(`{"bin":{"pkg":"x","other":"y"}}`), 0o644); err != nil { + t.Fatal(err) + } + got, ok := npm.Which(context.Background(), "pkg") + if !ok || got != filepath.Join(binDir, "pkg") { + t.Fatalf("which = %q, %v", got, ok) + } + got, ok = npm.Which(context.Background(), "pkg", "other") + if !ok || got != filepath.Join(binDir, "other") { + t.Fatalf("which hint = %q, %v", got, ok) + } + if err := os.RemoveAll(binDir); err != nil { + t.Fatal(err) + } + if _, ok := npm.Which(context.Background(), "missing"); ok { + t.Fatal("missing package unexpectedly resolved") + } +} + +func TestSanitizeForWindows(t *testing.T) { + if got := sanitizeForPlatform("a:b?c\x00d", "windows"); got != "a_b_c_d" { + t.Fatalf("sanitize = %q", got) + } +} diff --git a/internal/seniordev/core/spawner.go b/internal/seniordev/core/spawner.go new file mode 100644 index 0000000000..20f1cfea34 --- /dev/null +++ b/internal/seniordev/core/spawner.go @@ -0,0 +1,701 @@ +//go:build !windows + +// Process spawner: argv/env/cwd/stdio configuration, pipelines and +// process-group kill. +package core + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +// SystemError is a tagged spawn or I/O failure. +type SystemError struct { + Tag string + Module string + Method string + PathOrDescriptor string + Syscall string + Cause error +} + +func (e *SystemError) Error() string { + return fmt.Sprintf("%s.%s(%s): %v", e.Module, e.Method, e.PathOrDescriptor, e.Cause) +} + +func (e *SystemError) Unwrap() error { return e.Cause } + +// EnvVar is one environment assignment. A slice preserves declaration order. +type EnvVar struct { + Name string + Value string +} + +// IOConfig configures a standard stream. +type IOConfig struct { + Mode string // "pipe", "inherit", "ignore" + Reader io.Reader + Writer io.Writer +} + +// FDConfig configures one fd >= 3. +type FDConfig struct { + Type string // "input" or "output" + Reader io.Reader + Writer io.Writer +} + +// CommandOptions configures a StandardCommand. +type CommandOptions struct { + Cwd string + Env []EnvVar + EnvSet bool + ExtendEnv *bool + + Stdin IOConfig + Stdout IOConfig + Stderr IOConfig + + AdditionalFDs map[int]FDConfig + Detached *bool + Shell string // "", "true", or an explicit shell path + KillSignal os.Signal + ForceKillAfter time.Duration +} + +// StandardCommand is one executable plus argv. +type StandardCommand struct { + Command string + Args []string + Options CommandOptions +} + +// PipeOptions selects the source and destination of a pipeline edge. +type PipeOptions struct { + From string // stdout (default), stderr, all, fdN + To string // stdin (default), fdN +} + +// Command is a StandardCommand or PipedCommand. +type Command interface{ commandNode() } + +func (StandardCommand) commandNode() {} + +// PipedCommand connects Left to Right. +type PipedCommand struct { + Left Command + Right Command + Options PipeOptions +} + +func (PipedCommand) commandNode() {} + +// MakeCommand constructs a standard command. +func MakeCommand(command string, args []string, options ...CommandOptions) StandardCommand { + opt := CommandOptions{} + if len(options) > 0 { + opt = options[0] + } + return StandardCommand{Command: command, Args: append([]string(nil), args...), Options: opt} +} + +// Pipe constructs a piped command. +func Pipe(left, right Command, options ...PipeOptions) PipedCommand { + opt := PipeOptions{} + if len(options) > 0 { + opt = options[0] + } + return PipedCommand{Left: left, Right: right, Options: opt} +} + +// SpawnSpec is the fully resolved command passed to os/exec. +type SpawnSpec struct { + Path string + Args []string + Cwd string + Env []string + EnvSet bool + Detached bool + Shell string +} + +// BuildSpawnSpec performs the pure argv/env/cwd construction. +func BuildSpawnSpec(command StandardCommand) (SpawnSpec, error) { + options := command.Options + cwd := "" + if options.Cwd != "" { + info, err := os.Stat(options.Cwd) + if err != nil { + return SpawnSpec{}, platformError("access", err, command) + } + if !info.IsDir() { + return SpawnSpec{}, platformError("access", syscall.ENOTDIR, command) + } + cwd, err = filepathAbs(options.Cwd) + if err != nil { + return SpawnSpec{}, platformError("access", err, command) + } + } + extend := true + if options.ExtendEnv != nil { + extend = *options.ExtendEnv + } + var environment []string + envSet := options.EnvSet || len(options.Env) > 0 + if extend { + environment = mergeEnvironment(os.Environ(), options.Env) + envSet = true + } else if envSet { + environment = make([]string, 0, len(options.Env)) + for _, item := range options.Env { + environment = append(environment, item.Name+"="+item.Value) + } + } + detached := runtime.GOOS != "windows" + if options.Detached != nil { + detached = *options.Detached + } + path := command.Command + args := append([]string(nil), command.Args...) + if options.Shell != "" { + shell := options.Shell + if shell == "true" { + if runtime.GOOS == "windows" { + shell = "cmd.exe" + } else { + shell = "/bin/sh" + } + } + line := strings.Join(append([]string{command.Command}, command.Args...), " ") + if runtime.GOOS == "windows" { + path, args = shell, []string{"/d", "/s", "/c", line} + } else { + path, args = shell, []string{"-c", line} + } + } + return SpawnSpec{ + Path: path, + Args: args, + Cwd: cwd, + Env: environment, + EnvSet: envSet, + Detached: detached, + Shell: options.Shell, + }, nil +} + +func filepathAbs(path string) (string, error) { + return filepathAbsolute(path) +} + +// kept in a variable-sized helper so Windows path resolution can be tested +// without exposing an os/exec detail. +var filepathAbsolute = func(path string) (string, error) { + return filepath.Abs(path) +} + +func mergeEnvironment(base []string, overrides []EnvVar) []string { + order := []string{} + values := map[string]string{} + for _, item := range base { + name, value, ok := strings.Cut(item, "=") + if !ok { + name, value = item, "" + } + if _, exists := values[name]; !exists { + order = append(order, name) + } + values[name] = value + } + for _, item := range overrides { + if _, exists := values[item.Name]; !exists { + order = append(order, item.Name) + } + values[item.Name] = item.Value + } + out := make([]string, 0, len(order)) + for _, name := range order { + out = append(out, name+"="+values[name]) + } + return out +} + +// Spawner starts commands and pipelines. +type Spawner struct{} + +// NewSpawner constructs the default spawner. +func NewSpawner() *Spawner { return &Spawner{} } + +type flatPipeline struct { + commands []StandardCommand + options []PipeOptions +} + +func flatten(command Command) (flatPipeline, error) { + out := flatPipeline{} + var walk func(Command) error + walk = func(command Command) error { + switch value := command.(type) { + case StandardCommand: + out.commands = append(out.commands, value) + case *StandardCommand: + out.commands = append(out.commands, *value) + case PipedCommand: + if err := walk(value.Left); err != nil { + return err + } + out.options = append(out.options, value.Options) + return walk(value.Right) + case *PipedCommand: + if err := walk(value.Left); err != nil { + return err + } + out.options = append(out.options, value.Options) + return walk(value.Right) + default: + return fmt.Errorf("unknown command type %T", command) + } + return nil + } + if err := walk(command); err != nil { + return out, err + } + if len(out.commands) == 0 { + return out, errors.New("flatten produced empty commands array") + } + return out, nil +} + +// Handle is a running command or pipeline. Stdout/Stderr belong to the final +// command. +type Handle struct { + PID int + Stdin io.WriteCloser + Stdout io.ReadCloser + Stderr io.ReadCloser + All io.Reader + + mu sync.Mutex + commands []*exec.Cmd + edges [][]io.Closer + done chan struct{} + waitErr error + exitCode int + options CommandOptions +} + +// Spawn starts command and returns after every child has started. +func (s *Spawner) Spawn(ctx context.Context, command Command) (*Handle, error) { + flat, err := flatten(command) + if err != nil { + return nil, err + } + commands := make([]*exec.Cmd, len(flat.commands)) + edges := make([][]io.Closer, len(flat.commands)) + specs := make([]SpawnSpec, len(flat.commands)) + for i, standard := range flat.commands { + spec, err := BuildSpawnSpec(standard) + if err != nil { + return nil, err + } + specs[i] = spec + cmd := exec.Command(spec.Path, spec.Args...) + cmd.Dir = spec.Cwd + if spec.EnvSet { + cmd.Env = spec.Env + } + if spec.Detached && runtime.GOOS != "windows" { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } + commands[i] = cmd + } + + // Wire pipeline edges before stdio defaults so edge streams win. + for i, option := range flat.options { + from := option.From + if from == "" { + from = "stdout" + } + to := option.To + if to == "" { + to = "stdin" + } + reader, writer := io.Pipe() + edges[i] = append(edges[i], writer) + switch from { + case "stderr": + commands[i].Stderr = writer + case "all": + commands[i].Stdout = writer + commands[i].Stderr = writer + default: + if fd, ok := parseFDName(from); ok { + if err := setOutputFD(commands[i], fd, writer); err != nil { + return nil, err + } + } else { + commands[i].Stdout = writer + } + } + if fd, ok := parseFDName(to); ok { + if err := setInputFD(commands[i+1], fd, reader); err != nil { + return nil, err + } + } else { + commands[i+1].Stdin = reader + } + } + + var finalStdout io.ReadCloser + var finalStderr io.ReadCloser + var finalStdin io.WriteCloser + var parentWriteEnds []*os.File + for i, standard := range flat.commands { + cmd := commands[i] + for _, fd := range SortedFDs(standard.Options.AdditionalFDs) { + if err := setExtraFile(cmd, fd, standard.Options.AdditionalFDs[fd]); err != nil { + return nil, platformError("additionalFd", err, standard) + } + } + if cmd.Stdin == nil { + switch { + case standard.Options.Stdin.Reader != nil: + cmd.Stdin = standard.Options.Stdin.Reader + case standard.Options.Stdin.Mode == "inherit": + cmd.Stdin = os.Stdin + case standard.Options.Stdin.Mode == "ignore": + cmd.Stdin = strings.NewReader("") + default: + finalStdin, err = cmd.StdinPipe() + if err != nil { + return nil, platformError("stdin", err, standard) + } + } + } + if cmd.Stdout == nil { + switch { + case standard.Options.Stdout.Writer != nil: + cmd.Stdout = standard.Options.Stdout.Writer + case standard.Options.Stdout.Mode == "inherit": + cmd.Stdout = os.Stdout + case standard.Options.Stdout.Mode == "ignore": + cmd.Stdout = io.Discard + default: + if i == len(commands)-1 { + // Explicit os.Pipe, not StdoutPipe: cmd.Wait (run from the + // background handle.wait goroutine) closes StdoutPipe pipes, + // racing consumers still draining Handle.Stdout. Output must + // stay readable after exit, so the consumer owns the read + // end. The parent write-end copy is closed after Start so EOF + // arrives on child exit. + pr, pw, err := os.Pipe() + if err != nil { + return nil, platformError("stdout", err, standard) + } + cmd.Stdout = pw + finalStdout = pr + parentWriteEnds = append(parentWriteEnds, pw) + } else { + pipe, err := cmd.StdoutPipe() + if err != nil { + return nil, platformError("stdout", err, standard) + } + _ = pipe + } + } + } + if cmd.Stderr == nil { + switch { + case standard.Options.Stderr.Writer != nil: + cmd.Stderr = standard.Options.Stderr.Writer + case standard.Options.Stderr.Mode == "inherit": + cmd.Stderr = os.Stderr + case standard.Options.Stderr.Mode == "ignore": + cmd.Stderr = io.Discard + default: + if i == len(commands)-1 { + pr, pw, err := os.Pipe() + if err != nil { + return nil, platformError("stderr", err, standard) + } + cmd.Stderr = pw + finalStderr = pr + parentWriteEnds = append(parentWriteEnds, pw) + } else { + pipe, err := cmd.StderrPipe() + if err != nil { + return nil, platformError("stderr", err, standard) + } + _ = pipe + } + } + } + } + + started := 0 + for i, cmd := range commands { + if err := cmd.Start(); err != nil { + for j := 0; j < started; j++ { + _ = killCommand(commands[j], syscall.SIGTERM, specs[j].Detached) + } + for _, w := range parentWriteEnds { + _ = w.Close() + } + return nil, platformError("spawn", err, flat.commands[i]) + } + for _, file := range cmd.ExtraFiles { + _ = file.Close() + } + started++ + } + for _, w := range parentWriteEnds { + _ = w.Close() + } + handle := &Handle{ + PID: commands[len(commands)-1].Process.Pid, + Stdin: finalStdin, + Stdout: finalStdout, + Stderr: finalStderr, + commands: commands, + edges: edges, + done: make(chan struct{}), + options: flat.commands[len(flat.commands)-1].Options, + } + if finalStdout != nil && finalStderr != nil { + handle.All = &mergedReader{readers: []io.Reader{finalStdout, finalStderr}} + } else if finalStdout != nil { + handle.All = finalStdout + } else { + handle.All = finalStderr + } + go handle.wait() + go func() { + select { + case <-ctx.Done(): + _ = handle.Kill() + case <-handle.done: + } + }() + return handle, nil +} + +type mergedReader struct { + once sync.Once + readers []io.Reader + reader *io.PipeReader +} + +func (m *mergedReader) Read(p []byte) (int, error) { + m.once.Do(func() { + reader, writer := io.Pipe() + m.reader = reader + var wg sync.WaitGroup + for _, source := range m.readers { + wg.Add(1) + go func(source io.Reader) { + defer wg.Done() + _, _ = io.Copy(writer, source) + }(source) + } + go func() { + wg.Wait() + _ = writer.Close() + }() + }) + return m.reader.Read(p) +} + +func (h *Handle) wait() { + var lastErr error + lastCode := 0 + for i, command := range h.commands { + err := command.Wait() + for _, closer := range h.edges[i] { + _ = closer.Close() + } + if i == len(h.commands)-1 { + lastErr = err + if command.ProcessState != nil { + lastCode = command.ProcessState.ExitCode() + } + } + } + h.mu.Lock() + h.waitErr = lastErr + h.exitCode = lastCode + h.mu.Unlock() + close(h.done) +} + +// Wait waits for the pipeline and returns the final command's exit code. +func (h *Handle) Wait() (int, error) { + <-h.done + h.mu.Lock() + defer h.mu.Unlock() + return h.exitCode, h.waitErr +} + +// IsRunning reports whether Wait has completed. +func (h *Handle) IsRunning() bool { + select { + case <-h.done: + return false + default: + return true + } +} + +// Kill sends the configured signal and optionally escalates to SIGKILL. +func (h *Handle) Kill() error { + signal := h.options.KillSignal + if signal == nil { + signal = syscall.SIGTERM + } + for _, command := range h.commands { + detached := command.SysProcAttr != nil && command.SysProcAttr.Setpgid + if err := killCommand(command, signal, detached); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + } + if h.options.ForceKillAfter > 0 { + timer := time.NewTimer(h.options.ForceKillAfter) + defer timer.Stop() + select { + case <-h.done: + return nil + case <-timer.C: + for _, command := range h.commands { + detached := command.SysProcAttr != nil && command.SysProcAttr.Setpgid + _ = killCommand(command, syscall.SIGKILL, detached) + } + } + } + return nil +} + +func killCommand(command *exec.Cmd, signal os.Signal, detached bool) error { + if command.Process == nil { + return os.ErrProcessDone + } + if detached && runtime.GOOS != "windows" { + if unixSignal, ok := signal.(syscall.Signal); ok { + return syscall.Kill(-command.Process.Pid, unixSignal) + } + } + return command.Process.Signal(signal) +} + +func parseFDName(name string) (int, bool) { + if !strings.HasPrefix(name, "fd") { + return 0, false + } + value, err := strconv.Atoi(strings.TrimPrefix(name, "fd")) + return value, err == nil && value >= 3 +} + +func setOutputFD(command *exec.Cmd, fd int, writer io.Writer) error { + return setExtraFile(command, fd, FDConfig{Type: "output", Writer: writer}) +} + +func setInputFD(command *exec.Cmd, fd int, reader io.Reader) error { + return setExtraFile(command, fd, FDConfig{Type: "input", Reader: reader}) +} + +func setExtraFile(command *exec.Cmd, fd int, config FDConfig) error { + // os/exec ExtraFiles only accepts *os.File. A small pipe bridges arbitrary + // readers/writers while preserving fd numbering. + for len(command.ExtraFiles) <= fd-3 { + null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) + if err != nil { + return err + } + command.ExtraFiles = append(command.ExtraFiles, null) + } + read, write, err := os.Pipe() + if err != nil { + return err + } + if config.Type == "input" { + command.ExtraFiles[fd-3] = read + go func() { + if config.Reader != nil { + _, _ = io.Copy(write, config.Reader) + } + _ = write.Close() + }() + } else { + command.ExtraFiles[fd-3] = write + go func() { + if config.Writer != nil { + _, _ = io.Copy(config.Writer, read) + } else { + _, _ = io.Copy(io.Discard, read) + } + _ = read.Close() + }() + } + return nil +} + +func platformError(method string, err error, command StandardCommand) error { + tag := "Unknown" + switch { + case errors.Is(err, fs.ErrNotExist), errors.Is(err, exec.ErrNotFound): + tag = "NotFound" + case errors.Is(err, fs.ErrPermission): + tag = "PermissionDenied" + case errors.Is(err, fs.ErrExist): + tag = "AlreadyExists" + case errors.Is(err, syscall.EBUSY): + tag = "Busy" + case errors.Is(err, syscall.EISDIR), errors.Is(err, syscall.ENOTDIR), errors.Is(err, syscall.ELOOP): + tag = "BadResource" + } + return &SystemError{ + Tag: tag, + Module: "ChildProcess", + Method: method, + PathOrDescriptor: strings.TrimSpace(command.Command + " " + strings.Join(command.Args, " ")), + Cause: err, + } +} + +// SortedFDs returns the valid additional fd numbers (>= 3) in ascending order. +func SortedFDs(fds map[int]FDConfig) []int { + out := make([]int, 0, len(fds)) + for fd := range fds { + if fd >= 3 { + out = append(out, fd) + } + } + sort.Ints(out) + return out +} + +// Run captures stdout/stderr and waits for one command. +func (s *Spawner) Run(ctx context.Context, command StandardCommand) ([]byte, []byte, int, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Options.Stdout = IOConfig{Writer: &stdout} + command.Options.Stderr = IOConfig{Writer: &stderr} + handle, err := s.Spawn(ctx, command) + if err != nil { + return nil, nil, -1, err + } + code, err := handle.Wait() + return stdout.Bytes(), stderr.Bytes(), code, err +} diff --git a/internal/seniordev/core/spawner_test.go b/internal/seniordev/core/spawner_test.go new file mode 100644 index 0000000000..caa0b4200d --- /dev/null +++ b/internal/seniordev/core/spawner_test.go @@ -0,0 +1,178 @@ +//go:build !windows + +package core + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" + "time" +) + +func TestCoreHelperProcess(t *testing.T) { + if os.Getenv("GO_CORE_HELPER") != "1" { + return + } + separator := 0 + for i, arg := range os.Args { + if arg == "--" { + separator = i + 1 + break + } + } + cwd, _ := os.Getwd() + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "args": os.Args[separator:], + "cwd": cwd, + "env": os.Getenv("CORE_VALUE"), + }) + os.Exit(0) +} + +func TestSpawnerArgvEnvAndCwd(t *testing.T) { + root := t.TempDir() + spawner := NewSpawner() + command := MakeCommand(os.Args[0], []string{ + "-test.run=TestCoreHelperProcess", "--", "space arg", "", "🙂", + }, CommandOptions{ + Cwd: root, + Env: []EnvVar{ + {Name: "GO_CORE_HELPER", Value: "1"}, + {Name: "CORE_VALUE", Value: "value"}, + }, + }) + stdout, stderr, code, err := spawner.Run(context.Background(), command) + if err != nil || code != 0 { + t.Fatalf("run code=%d err=%v stderr=%s", code, err, stderr) + } + var got struct { + Args []string `json:"args"` + Cwd string `json:"cwd"` + Env string `json:"env"` + } + if err := json.Unmarshal(stdout, &got); err != nil { + t.Fatalf("decode %q: %v", stdout, err) + } + if !reflect.DeepEqual(got.Args, []string{"space arg", "", "🙂"}) { + t.Fatalf("args: %#v", got.Args) + } + // The child reports its folder as the kernel resolves it, and a temporary + // folder on macOS is a symlink (/var/folders → /private/var/folders), so + // the two are compared resolved: the same folder spelled two ways is the + // same folder. + if resolvedPath(t, got.Cwd) != resolvedPath(t, root) || got.Env != "value" { + t.Fatalf("helper: %+v", got) + } +} + +func resolvedPath(t *testing.T, path string) string { + t.Helper() + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + t.Fatalf("resolve %s: %v", path, err) + } + return resolved +} + +func TestSpawnerPipeline(t *testing.T) { + if _, err := exec.LookPath("printf"); err != nil { + t.Skip("printf unavailable") + } + if _, err := exec.LookPath("tr"); err != nil { + t.Skip("tr unavailable") + } + spawner := NewSpawner() + handle, err := spawner.Spawn(context.Background(), Pipe( + MakeCommand("printf", []string{"alpha\nbeta\n"}), + MakeCommand("tr", []string{"a-z", "A-Z"}), + )) + if err != nil { + t.Fatal(err) + } + stdout, err := io.ReadAll(handle.Stdout) + if err != nil { + t.Fatal(err) + } + stderr, err := io.ReadAll(handle.Stderr) + if err != nil { + t.Fatal(err) + } + code, err := handle.Wait() + if err != nil || code != 0 { + t.Fatalf("wait code=%d err=%v stderr=%s", code, err, stderr) + } + if string(stdout) != "ALPHA\nBETA\n" { + t.Fatalf("stdout: %q", stdout) + } +} + +func TestSpawnerMissingCommandIsTagged(t *testing.T) { + _, err := NewSpawner().Spawn(context.Background(), MakeCommand("definitely-no-senior-dev-command", nil)) + var system *SystemError + if !errors.As(err, &system) { + t.Fatalf("error = %v", err) + } + if system.Tag != "NotFound" || system.Method != "spawn" { + t.Fatalf("system error: %+v", system) + } +} + +func TestSpawnerContextCancellationKillsProcess(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh unavailable") + } + ctx, cancel := context.WithCancel(context.Background()) + handle, err := NewSpawner().Spawn(ctx, MakeCommand("sh", []string{"-c", "sleep 30"})) + if err != nil { + t.Fatal(err) + } + cancel() + done := make(chan struct{}) + go func() { + _, _ = handle.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("process did not stop") + } + if handle.IsRunning() { + t.Fatal("handle still running") + } +} + +func TestBuildSpawnSpecEnvironmentModes(t *testing.T) { + no := false + spec, err := BuildSpawnSpec(MakeCommand("x", nil, CommandOptions{ + ExtendEnv: &no, + EnvSet: true, + Env: []EnvVar{{Name: "A", Value: "1"}, {Name: "B", Value: "2"}}, + })) + if err != nil { + t.Fatal(err) + } + if !spec.EnvSet || !reflect.DeepEqual(spec.Env, []string{"A=1", "B=2"}) { + t.Fatalf("env: %#v", spec) + } + spec, err = BuildSpawnSpec(MakeCommand("echo", []string{"$HOME"}, CommandOptions{Shell: "true"})) + if err != nil { + t.Fatal(err) + } + if filepath.Base(spec.Path) != "sh" || !reflect.DeepEqual(spec.Args, []string{"-c", "echo $HOME"}) { + t.Fatalf("shell spec: %#v", spec) + } +} + +func TestSortedFDs(t *testing.T) { + got := SortedFDs(map[int]FDConfig{9: {}, 2: {}, 3: {}, 5: {}}) + if !reflect.DeepEqual(got, []int{3, 5, 9}) { + t.Fatalf("fds: %v", got) + } +} diff --git a/internal/seniordev/crew_test.go b/internal/seniordev/crew_test.go new file mode 100644 index 0000000000..2b26d0fb92 --- /dev/null +++ b/internal/seniordev/crew_test.go @@ -0,0 +1,33 @@ +//go:build !windows + +package seniordev + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// The crew reaches senior-dev as its own flags: the working seat is the pool +// it routes on, the light seat its summaries, and a seat left unset keeps +// senior-dev's own default. The planning seat is not passed: no call senior-dev +// makes rides the tier it would set. +func TestTheCrewBecomesSeniorDevsOwnPools(t *testing.T) { + got := strings.Join(crewFlags(delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"}), " ") + if want := "--crew --high openrouter/vendor/hands --low openrouter/vendor/light"; got != want { + t.Fatalf("flags = %q, want %q", got, want) + } + if got := strings.Join(crewFlags(delegate.Crew{Hands: "vendor/hands"}), " "); got != "--crew --high openrouter/vendor/hands" { + t.Fatalf("flags for a crew with one seat = %q", got) + } +} + +// Models the person asked for are the working pool in place of the crew's +// working seat, kept as asked (`--asked`), and the light seat still summarises. +func TestTheModelsAPersonAskedForAreSeniorDevsWorkingPool(t *testing.T) { + got := strings.Join(crewFlags(delegate.Crew{Hands: "vendor/hands", Light: "vendor/light", Asked: []string{"vendor/one", "vendor/two"}}), " ") + if want := "--crew --asked --high openrouter/vendor/one,openrouter/vendor/two --low openrouter/vendor/light"; got != want { + t.Fatalf("flags = %q, want %q", got, want) + } +} diff --git a/internal/seniordev/engine/calc/calc.go b/internal/seniordev/engine/calc/calc.go new file mode 100644 index 0000000000..843df4a66b --- /dev/null +++ b/internal/seniordev/engine/calc/calc.go @@ -0,0 +1,69 @@ +//go:build !windows + +// Package calc holds the pure token and cost arithmetic that runs between an +// OpenRouter response and a persisted assistant message: it normalises the +// provider usage block, prices a call from the model catalog, and derives the +// compaction budget and its watermarks from the model limits and the +// compaction config. +package calc + +import ( + "math" + "os" + "strconv" + "strings" +) + +// ── process-start constants ────────────────────────────────────────────── + +// processEnv is the process environment as a map. Split on the first '=' so +// a value containing '=' survives. +func processEnv() map[string]string { + out := make(map[string]string) + for _, kv := range os.Environ() { + if i := strings.IndexByte(kv, '='); i >= 0 { + out[kv[:i]] = kv[i+1:] + } + } + return out +} + +// OUTPUT_TOKEN_MAX_DEFAULT is the output-token ceiling when +// SENIOR_DEV_OUTPUT_TOKEN_MAX is unset. +const OUTPUT_TOKEN_MAX_DEFAULT float64 = 32_000 + +// outputTokenMax is the output-token ceiling every request is capped at: +// SENIOR_DEV_OUTPUT_TOKEN_MAX, or OUTPUT_TOKEN_MAX_DEFAULT. It is evaluated ONCE +// at package init; a runtime change to the variable does not move it. +var outputTokenMax = evalOutputTokenMax(processEnv()) + +// evalOutputTokenMax reads SENIOR_DEV_OUTPUT_TOKEN_MAX: a positive integer +// (decimal or exponent notation) is the ceiling; absent, empty, "0" or +// anything else falls back to the default. +func evalOutputTokenMax(env map[string]string) float64 { + raw := env["SENIOR_DEV_OUTPUT_TOKEN_MAX"] + if raw == "" || raw == "0" { + return OUTPUT_TOKEN_MAX_DEFAULT + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err == nil && parsed > 0 && math.Trunc(parsed) == parsed && !math.IsInf(parsed, 0) { + return parsed + } + return OUTPUT_TOKEN_MAX_DEFAULT +} + +// SetModuleEnvForTesting re-runs the package-init evaluation of +// OUTPUT_TOKEN_MAX against the supplied environment. Returns a restore func. +func SetModuleEnvForTesting(env map[string]string) func() { + previous := outputTokenMax + outputTokenMax = evalOutputTokenMax(env) + return func() { outputTokenMax = previous } +} + +// safe maps a non-finite value to 0. +func safe(value float64) float64 { + if math.IsNaN(value) || math.IsInf(value, 0) { + return 0 + } + return value +} diff --git a/internal/seniordev/engine/calc/cost_test.go b/internal/seniordev/engine/calc/cost_test.go new file mode 100644 index 0000000000..561c745861 --- /dev/null +++ b/internal/seniordev/engine/calc/cost_test.go @@ -0,0 +1,100 @@ +//go:build !windows + +package calc + +import ( + "encoding/json" + "math" + "testing" +) + +func costTokens(input, output, reasoning, cacheRead, cacheWrite float64) UsageTokens { + return UsageTokens{ + Input: input, + Output: output, + Reasoning: reasoning, + Cache: UsageCache{Write: cacheWrite, Read: cacheRead}, + } +} + +func TestCostPricesEveryTokenClass(t *testing.T) { + cases := []struct { + name string + toks UsageTokens + rates costRates + want float64 + }{ + { + name: "sonnet-shaped run", + toks: costTokens(3_590_000, 250_000, 100_000, 2_000_000, 500_000), + rates: costRates{input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75}, + want: 18.495, + }, + { + name: "reasoning is billed at the output rate", + toks: costTokens(0, 0, 1_000_000, 0, 0), + rates: costRates{input: 1, output: 4}, + want: 4, + }, + { + name: "no rates means free", + toks: costTokens(10, 10, 10, 10, 10), + rates: costRates{}, + want: 0, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := cost(tc.toks, tc.rates); math.Abs(got-tc.want) > 1e-9 { + t.Errorf("cost = %v, want %v", got, tc.want) + } + }) + } +} + +func TestGetUsageSubtractsCacheTokensAndAppliesRates(t *testing.T) { + f := func(v float64) *float64 { return &v } + result := GetUsage(GetUsageInput{ + Model: Model{Cost: &ModelCost{Input: 1, Output: 2, Cache: &CacheCost{Read: 0.1, Write: 1.25}}}, + Usage: LanguageModelUsage{ + InputTokens: f(1_000_000), + InputTokenDetails: &InputTokenDetails{CacheReadTokens: f(400_000), CacheWriteTokens: f(100_000)}, + OutputTokens: f(200_000), + OutputTokenDetails: &OutputTokenDetails{ReasoningTokens: f(50_000)}, + TotalTokens: f(1_200_000), + }, + }) + if result.Tokens.Input != 500_000 || result.Tokens.Output != 150_000 || result.Tokens.Reasoning != 50_000 { + t.Fatalf("tokens = %+v", result.Tokens) + } + if result.Tokens.Cache.Read != 400_000 || result.Tokens.Cache.Write != 100_000 { + t.Fatalf("cache = %+v", result.Tokens.Cache) + } + // 0.5 + 0.3 + 0.1 + 0.04 + 0.125 = 1.065 + if math.Abs(result.Cost-1.065) > 1e-9 { + t.Fatalf("cost = %v", result.Cost) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + want := `{"cost":1.065,"tokens":{"total":1200000,"input":500000,"output":150000,"reasoning":50000,"cache":{"write":100000,"read":400000}}}` + if string(encoded) != want { + t.Fatalf("json:\n got %s\nwant %s", encoded, want) + } +} + +func TestGetUsageReadsProviderMetadataCacheWrites(t *testing.T) { + f := func(v float64) *float64 { return &v } + result := GetUsage(GetUsageInput{ + Model: Model{Cost: &ModelCost{Input: 1, Cache: &CacheCost{Write: 2}}}, + Usage: LanguageModelUsage{InputTokens: f(300)}, + Metadata: ProviderMetadata{"anthropic": {"cacheCreationInputTokens": float64(100)}}, + }) + if result.Tokens.Input != 200 || result.Tokens.Cache.Write != 100 { + t.Fatalf("tokens = %+v", result.Tokens) + } + if got := safe(math.NaN()); got != 0 { + t.Fatalf("safe(NaN) = %v", got) + } +} diff --git a/internal/seniordev/engine/calc/overflow.go b/internal/seniordev/engine/calc/overflow.go new file mode 100644 index 0000000000..3e77184643 --- /dev/null +++ b/internal/seniordev/engine/calc/overflow.go @@ -0,0 +1,250 @@ +//go:build !windows + +package calc + +import ( + "fmt" + "math" +) + +// ── the slice of config / model the compaction budget reads ────────────── + +// CompactionConfig is the `compaction` block of project config. Every field +// is optional, so every field is a pointer: `auto` is tested strictly (an +// absent value is NOT false) and `reserved` nullishly (an explicit 0 wins). +type CompactionConfig struct { + // Policy names how the compaction budget is derived. The only policy is + // "window": the budget is the model's own context window, capped by + // CapacityTokens. It may be spelled out or left empty; any other name is + // refused by ValidatePolicy. + Policy string `json:"policy,omitempty"` + Auto *bool `json:"auto"` + Prune *bool `json:"prune"` + // PreserveRecentTokens overrides the verbatim tail budget a compaction + // keeps ahead of the summary. The tail is sized in tokens after + // truncation, never in turns. + PreserveRecentTokens *float64 `json:"preserve_recent_tokens"` + // PreserveRecentFraction sizes the verbatim tail as a fraction of the + // high watermark instead of a fixed token count, so it scales with the + // window. PreserveRecentTokens wins when both are set. + PreserveRecentFraction *float64 `json:"preserve_recent_fraction"` + // CapacityTokens caps the working set below the model's window: a cost + // decision, or a model known to degrade before its advertised context. + // Absent means DefaultCapacityTokens. + CapacityTokens *float64 `json:"capacity_tokens"` + Reserved *float64 `json:"reserved"` +} + +// PolicyWindow is the compaction policy: the window is the budget. +const PolicyWindow = "window" + +// DefaultCapacityTokens caps the working set when no capacity_tokens is +// configured. A model with a smaller window is bounded by the window. +const DefaultCapacityTokens float64 = 500_000 + +// ValidatePolicy refuses a policy name this binary does not implement, and a +// budget field outside its range. Both are refused at config load so a +// misspelled block fails before any model call. +func ValidatePolicy(cfg Config) error { + if cfg.Compaction == nil { + return nil + } + switch cfg.Compaction.Policy { + case "", PolicyWindow: + default: + return fmt.Errorf("compaction.policy %q is not %q", cfg.Compaction.Policy, PolicyWindow) + } + if f := cfg.Compaction.PreserveRecentFraction; f != nil && (math.IsNaN(*f) || *f <= 0 || *f >= 1) { + return fmt.Errorf("compaction.preserve_recent_fraction %v must be between 0 and 1 exclusive", *f) + } + if c := cfg.Compaction.CapacityTokens; c != nil && (math.IsNaN(*c) || math.IsInf(*c, 0) || *c <= 0) { + return fmt.Errorf("compaction.capacity_tokens %v must be a positive token count", *c) + } + return nil +} + +// Config is the project-config projection this package needs: only the +// `compaction` block is read. +type Config struct { + Compaction *CompactionConfig `json:"compaction"` +} + +// ModelLimit is a model's context, input and output limits. Input is +// optional: a catalog entry that names none is budgeted from Context. +type ModelLimit struct { + Context float64 `json:"context"` + Input *float64 `json:"input"` + Output float64 `json:"output"` +} + +// CacheCost is the per-token price of prompt-cache reads and writes. +type CacheCost struct { + Read float64 `json:"read"` + Write float64 `json:"write"` +} + +// Over200KCost is the price block a provider applies above 200K context. +// The key order is cache, input, output. +type Over200KCost struct { + Cache *CacheCost `json:"cache"` + Input float64 `json:"input"` + Output float64 `json:"output"` +} + +// ModelCost is a model's price block. `cache` is optional in practice, so it +// is a pointer. +type ModelCost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + Cache *CacheCost `json:"cache"` + ExperimentalOver200K *Over200KCost `json:"experimentalOver200K"` +} + +// Model is the catalog projection this package needs: the limit block (for +// the budget and the output reservation) and the cost block (for usage). +type Model struct { + Cost *ModelCost `json:"cost"` + Limit ModelLimit `json:"limit"` + Capabilities ModelCapabilities `json:"-"` +} + +// ModelCapabilities is the models.dev capability slice retained alongside +// cost and limits so provider request assembly does not invent support. +type ModelCapabilities struct { + Attachment bool `json:"attachment"` + Reasoning bool `json:"reasoning"` + Temperature bool `json:"temperature"` + ToolCall bool `json:"toolcall"` + Input map[string]bool `json:"input"` + Output map[string]bool `json:"output"` +} + +// ── budget constants ───────────────────────────────────────────────────── + +// COMPACTION_BUFFER bounds the output reservation taken off the window. +const COMPACTION_BUFFER float64 = 20_000 + +// TRIGGER_PCT is the fraction of the capacity at which auto-compaction +// fires: the high watermark. +const TRIGGER_PCT float64 = 0.6 + +// COMPACTION_LOW_TO_HIGH_RATIO is the low-watermark half of the 40/60 +// hysteresis: low is two-thirds of high. +const COMPACTION_LOW_TO_HIGH_RATIO float64 = 2.0 / 3.0 + +// MaxOutputTokens is `min(model.limit.output, OUTPUT_TOKEN_MAX)`, falling +// back to OUTPUT_TOKEN_MAX when the minimum is 0 or NaN. A negative +// limit.output is passed through as is. +func MaxOutputTokens(model Model) float64 { + minimum := math.Min(model.Limit.Output, outputTokenMax) + if minimum != 0 && !math.IsNaN(minimum) { + return minimum + } + return outputTokenMax +} + +// UsableInput is what the budget is derived from: the compaction config and +// the model's limits. +type UsableInput struct { + Cfg Config + Model Model +} + +// EffectiveInputCapacity is the model-visible input capacity after reserving +// output space and applying the capacity cap. It deliberately does not apply +// the trigger percentage; Watermarks derives both marks from this one +// underlying capacity. +func EffectiveInputCapacity(input UsableInput) float64 { + context := input.Model.Limit.Context + if context == 0 { + return 0 + } + + reserved := math.Min(COMPACTION_BUFFER, MaxOutputTokens(input.Model)) + if input.Cfg.Compaction != nil && input.Cfg.Compaction.Reserved != nil { + reserved = *input.Cfg.Compaction.Reserved + } + + var raw float64 + // An input limit of 0 (or NaN, or absent) takes the context branch. + if input.Model.Limit.Input != nil && *input.Model.Limit.Input != 0 && !math.IsNaN(*input.Model.Limit.Input) { + raw = math.Max(0, *input.Model.Limit.Input-reserved) + } else { + raw = math.Max(0, context-MaxOutputTokens(input.Model)) + } + capacity := DefaultCapacityTokens + if input.Cfg.Compaction != nil && input.Cfg.Compaction.CapacityTokens != nil && + *input.Cfg.Compaction.CapacityTokens > 0 { + capacity = *input.Cfg.Compaction.CapacityTokens + } + return math.Min(raw, capacity) +} + +// CompactionWatermarks describes the preferred post-compaction target and the +// occupancy at which another compaction becomes necessary. +type CompactionWatermarks struct { + Capacity float64 + Low float64 + High float64 +} + +// Watermarks returns the 40/60 hysteresis around the capacity. +func Watermarks(input UsableInput) CompactionWatermarks { + capacity := EffectiveInputCapacity(input) + high := math.Floor(capacity * TRIGGER_PCT) + low := math.Floor(high * COMPACTION_LOW_TO_HIGH_RATIO) + return CompactionWatermarks{Capacity: capacity, Low: low, High: high} +} + +// ── the token counter the trigger scores ───────────────────────────────── + +// TokenCache is the persisted cache-token pair, declared read then write. +// Contrast UsageCache, which is the same data in the order usage builds it. +type TokenCache struct { + Read float64 `json:"read"` + Write float64 `json:"write"` +} + +// Tokens is the persisted assistant token block. `total` is optional, so it +// is a pointer and the key is dropped when it is absent. +type Tokens struct { + Total *float64 `json:"total,omitempty"` + Input float64 `json:"input"` + Output float64 `json:"output"` + Reasoning float64 `json:"reasoning"` + Cache TokenCache `json:"cache"` +} + +// tokenCount is `tokens.total || input + output + cache.read + cache.write`: +// a total of 0 or NaN falls through to the sum. +func tokenCount(tokens Tokens) float64 { + if tokens.Total != nil && *tokens.Total != 0 && !math.IsNaN(*tokens.Total) { + return *tokens.Total + } + return tokens.Input + tokens.Output + tokens.Cache.Read + tokens.Cache.Write +} + +// autoDisabled is `compaction.auto === false` -- a STRICT comparison, so an +// absent block or an absent `auto` does not disable compaction. +func autoDisabled(cfg Config) bool { + return cfg.Compaction != nil && cfg.Compaction.Auto != nil && !*cfg.Compaction.Auto +} + +// OverflowInput is what the trigger decides on. +type OverflowInput struct { + Cfg Config + Tokens Tokens + Model Model +} + +// IsOverflow reports whether the assistant's token count has reached the +// high watermark. +func IsOverflow(input OverflowInput) bool { + if autoDisabled(input.Cfg) { + return false + } + if input.Model.Limit.Context == 0 { + return false + } + return tokenCount(input.Tokens) >= Watermarks(UsableInput{Cfg: input.Cfg, Model: input.Model}).High +} diff --git a/internal/seniordev/engine/calc/overflow_test.go b/internal/seniordev/engine/calc/overflow_test.go new file mode 100644 index 0000000000..d82eb76ffe --- /dev/null +++ b/internal/seniordev/engine/calc/overflow_test.go @@ -0,0 +1,205 @@ +//go:build !windows + +package calc + +import ( + "math" + "testing" +) + +func cfgEmpty() Config { return Config{Compaction: &CompactionConfig{}} } + +func cfgWith(mutate func(*CompactionConfig)) Config { + c := &CompactionConfig{} + mutate(c) + return Config{Compaction: c} +} + +func testModel(context float64, input *float64, output float64) Model { + return Model{Limit: ModelLimit{Context: context, Input: input, Output: output}} +} + +func ptr[T any](v T) *T { return &v } + +func totalTokens(total float64) Tokens { + return Tokens{Total: &total, Input: 0, Output: 0, Cache: TokenCache{Read: 0, Write: 0}} +} + +// ── capacity ───────────────────────────────────────────────────────────── + +func TestEffectiveInputCapacity(t *testing.T) { + t.Run("a small window is the budget", func(t *testing.T) { + // 128K input limit - 8,192 output reserve, under the default cap. + got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(131_072, ptr(128_000.0), 8_192)}) + if got != 128_000-8_192 { + t.Errorf("capacity = %v, want %v", got, 128_000-8_192) + } + }) + + t.Run("a large window is capped at the default capacity", func(t *testing.T) { + got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(1_310_720, nil, 943_718)}) + if got != DefaultCapacityTokens { + t.Errorf("capacity = %v, want the %v default", got, DefaultCapacityTokens) + } + if absent := EffectiveInputCapacity(UsableInput{Model: testModel(1_310_720, nil, 943_718)}); absent != DefaultCapacityTokens { + t.Errorf("capacity with no compaction block = %v, want the default", absent) + } + }) + + t.Run("an absent input limit is budgeted from the context minus the output cap", func(t *testing.T) { + // OUTPUT_TOKEN_MAX (32,000) is the reservation when limit.output exceeds it. + got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(400_000, nil, 384_000)}) + if got != 400_000-32_000 { + t.Errorf("capacity = %v, want %v", got, 400_000-32_000) + } + }) + + t.Run("capacity_tokens tightens and never widens", func(t *testing.T) { + model := testModel(1_310_720, nil, 943_718) + tight := EffectiveInputCapacity(UsableInput{Cfg: cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(100_000.0) }), Model: model}) + if tight != 100_000 { + t.Errorf("tightened capacity = %v, want 100000", tight) + } + wide := EffectiveInputCapacity(UsableInput{Cfg: cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(5_000_000.0) }), Model: model}) + if wide != 1_310_720-32_000 { + t.Errorf("a cap above the window must not widen it: %v", wide) + } + }) + + t.Run("reserved overrides the output reservation", func(t *testing.T) { + got := EffectiveInputCapacity(UsableInput{ + Cfg: cfgWith(func(c *CompactionConfig) { c.Reserved = ptr(131_072.0) }), + Model: testModel(400_000, ptr(400_000.0), 943_718), + }) + if got != 400_000-131_072 { + t.Errorf("capacity = %v, want %v", got, 400_000-131_072) + } + }) + + t.Run("a zero context has no capacity", func(t *testing.T) { + if got := EffectiveInputCapacity(UsableInput{Cfg: cfgEmpty(), Model: testModel(0, nil, 0)}); got != 0 { + t.Errorf("capacity = %v, want 0", got) + } + }) +} + +func TestWatermarksAreASixtyFortySplitOfTheCapacity(t *testing.T) { + marks := Watermarks(UsableInput{ + Cfg: cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(500_000.0) }), + Model: testModel(1_310_720, nil, 943_718), + }) + if marks.Capacity != 500_000 || marks.High != 300_000 || marks.Low != 200_000 { + t.Fatalf("watermarks = %#v, want 500000/300000/200000", marks) + } + small := Watermarks(UsableInput{Cfg: cfgEmpty(), Model: testModel(100_000, ptr(100_000.0), 10_000)}) + if small.Capacity != 90_000 || small.High != 54_000 || small.Low != 36_000 { + t.Fatalf("small watermarks = %#v, want 90000/54000/36000", small) + } +} + +// ── the trigger ────────────────────────────────────────────────────────── + +func TestIsOverflowTriggersOnOccupancyOnly(t *testing.T) { + cfg := cfgWith(func(c *CompactionConfig) { c.CapacityTokens = ptr(500_000.0) }) + model := testModel(1_310_720, nil, 943_718) + if IsOverflow(OverflowInput{Cfg: cfg, Model: model, Tokens: totalTokens(299_999)}) { + t.Error("should not fire below high") + } + if !IsOverflow(OverflowInput{Cfg: cfg, Model: model, Tokens: totalTokens(300_000)}) { + t.Error("should fire at high") + } + // A total of 0 falls through to the component sum. + summed := Tokens{Input: 200_000, Output: 50_000, Cache: TokenCache{Read: 50_000}} + if !IsOverflow(OverflowInput{Cfg: cfg, Model: model, Tokens: summed}) { + t.Error("the component sum should trigger when total is absent") + } + // auto:false disables everything. + off := cfgWith(func(c *CompactionConfig) { c.Auto = ptr(false) }) + if IsOverflow(OverflowInput{Cfg: off, Model: model, Tokens: totalTokens(9_000_000)}) { + t.Error("auto=false must win") + } + // A model with no context never overflows. + if IsOverflow(OverflowInput{Cfg: cfg, Model: testModel(0, nil, 0), Tokens: totalTokens(9_000_000)}) { + t.Error("a zero-context model must not overflow") + } +} + +// ── the output cap ─────────────────────────────────────────────────────── + +// OUTPUT_TOKEN_MAX is read once at package init; only SetModuleEnvForTesting +// moves it. +func TestOutputTokenMaxIsReadAtInit(t *testing.T) { + if got := MaxOutputTokens(testModel(200_000, nil, 64_000)); got != 32_000 { + t.Errorf("default OUTPUT_TOKEN_MAX: got %v, want 32000", got) + } + restore := SetModuleEnvForTesting(map[string]string{"SENIOR_DEV_OUTPUT_TOKEN_MAX": "1000"}) + if got := MaxOutputTokens(testModel(200_000, nil, 64_000)); got != 1_000 { + t.Errorf("module env did not move OUTPUT_TOKEN_MAX: got %v, want 1000", got) + } + restore() + if got := MaxOutputTokens(testModel(200_000, nil, 64_000)); got != 32_000 { + t.Errorf("restore leaked: got %v, want 32000", got) + } + // A model whose own output limit is below the cap keeps its limit. + if got := MaxOutputTokens(testModel(200_000, nil, 12_000)); got != 12_000 { + t.Errorf("model limit below the cap: got %v, want 12000", got) + } +} + +// Only a positive integer moves the cap; the falsy strings and malformed +// values fall back to the default. +func TestEvalOutputTokenMax(t *testing.T) { + for _, tc := range []struct { + raw string + want float64 + }{ + {"", 32_000}, + {"0", 32_000}, + {"-4", 32_000}, + {"1.5", 32_000}, + {"banana", 32_000}, + {"8000", 8_000}, + {"1", 1}, + {"1e4", 10_000}, + {"0x20", 32_000}, + {"131072", 131_072}, + } { + got := evalOutputTokenMax(map[string]string{"SENIOR_DEV_OUTPUT_TOKEN_MAX": tc.raw}) + if got != tc.want { + t.Errorf("evalOutputTokenMax(%q) = %v, want %v", tc.raw, got, tc.want) + } + } + if got := evalOutputTokenMax(map[string]string{}); got != 32_000 { + t.Errorf("evalOutputTokenMax(absent) = %v, want 32000", got) + } +} + +// ── validation ─────────────────────────────────────────────────────────── + +func TestPolicyValidation(t *testing.T) { + for _, name := range []string{"", PolicyWindow} { + if err := ValidatePolicy(Config{Compaction: &CompactionConfig{Policy: name}}); err != nil { + t.Errorf("policy %q should validate: %v", name, err) + } + } + if err := ValidatePolicy(Config{}); err != nil { + t.Errorf("absent block should validate: %v", err) + } + for _, name := range []string{"legacy", "adaptive"} { + if err := ValidatePolicy(Config{Compaction: &CompactionConfig{Policy: name}}); err == nil { + t.Errorf("policy %q must be refused, not ignored", name) + } + } + for _, bad := range []float64{0, 1, 1.5, -0.2, math.NaN()} { + f := bad + if err := ValidatePolicy(cfgWith(func(c *CompactionConfig) { c.PreserveRecentFraction = &f })); err == nil { + t.Errorf("preserve_recent_fraction %v must be refused", bad) + } + } + for _, bad := range []float64{0, -1, math.Inf(1), math.NaN()} { + c := bad + if err := ValidatePolicy(cfgWith(func(cfg *CompactionConfig) { cfg.CapacityTokens = &c })); err == nil { + t.Errorf("capacity_tokens %v must be refused", bad) + } + } +} diff --git a/internal/seniordev/engine/calc/usage.go b/internal/seniordev/engine/calc/usage.go new file mode 100644 index 0000000000..8097a67161 --- /dev/null +++ b/internal/seniordev/engine/calc/usage.go @@ -0,0 +1,400 @@ +//go:build !windows + +package calc + +import ( + "encoding/json" + "strconv" +) + +// ── stage 1: the provider's usage block ────────────────────────────────── + +// OpenRouterPromptTokensDetails is `usage.prompt_tokens_details`. +type OpenRouterPromptTokensDetails struct { + CachedTokens *float64 `json:"cached_tokens"` + CacheWriteTokens *float64 `json:"cache_write_tokens"` +} + +// OpenRouterCompletionTokensDetails is `usage.completion_tokens_details`. +type OpenRouterCompletionTokensDetails struct { + ReasoningTokens *float64 `json:"reasoning_tokens"` +} + +// OpenRouterUsage is the numeric projection of OpenRouter's `usage` object +// that the token arithmetic reads, plus the untouched original carried +// through as Raw. +type OpenRouterUsage struct { + PromptTokens *float64 `json:"prompt_tokens"` + CompletionTokens *float64 `json:"completion_tokens"` + PromptTokensDetails *OpenRouterPromptTokensDetails `json:"prompt_tokens_details"` + CompletionTokensDetails *OpenRouterCompletionTokensDetails `json:"completion_tokens_details"` + + // Raw is the untouched wire object, including fields such as `cost` and + // `is_byok` that senior-dev never reads. + Raw json.RawMessage `json:"-"` +} + +// UnmarshalJSON decodes the numeric projection and keeps the original bytes. +func (u *OpenRouterUsage) UnmarshalJSON(data []byte) error { + type shadow OpenRouterUsage + var decoded shadow + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + *u = OpenRouterUsage(decoded) + u.Raw = append(json.RawMessage(nil), data...) + return nil +} + +// LanguageModelV3InputTokens is the provider-side `inputTokens` block. +type LanguageModelV3InputTokens struct { + Total *float64 `json:"total,omitempty"` + NoCache *float64 `json:"noCache,omitempty"` + CacheRead *float64 `json:"cacheRead,omitempty"` + CacheWrite *float64 `json:"cacheWrite,omitempty"` +} + +// LanguageModelV3OutputTokens is the provider-side `outputTokens` block. +type LanguageModelV3OutputTokens struct { + Total *float64 `json:"total,omitempty"` + Text *float64 `json:"text,omitempty"` + Reasoning *float64 `json:"reasoning,omitempty"` +} + +// LanguageModelV3Usage is ComputeTokenUsage's return shape. +type LanguageModelV3Usage struct { + InputTokens LanguageModelV3InputTokens `json:"inputTokens"` + OutputTokens LanguageModelV3OutputTokens `json:"outputTokens"` + Raw json.RawMessage `json:"raw,omitempty"` +} + +// ComputeTokenUsage splits the provider's usage block into input and output +// token groups. An absent cache-write count stays absent (nil) so that the +// provider-metadata fallbacks in GetUsage can still supply it. +func ComputeTokenUsage(usage *OpenRouterUsage) LanguageModelV3Usage { + promptTokens := float64(0) + completionTokens := float64(0) + cacheReadTokens := float64(0) + var cacheWriteTokens *float64 + reasoningTokens := float64(0) + + if usage != nil { + if usage.PromptTokens != nil { + promptTokens = *usage.PromptTokens + } + if usage.CompletionTokens != nil { + completionTokens = *usage.CompletionTokens + } + if usage.PromptTokensDetails != nil { + if usage.PromptTokensDetails.CachedTokens != nil { + cacheReadTokens = *usage.PromptTokensDetails.CachedTokens + } + cacheWriteTokens = usage.PromptTokensDetails.CacheWriteTokens + } + if usage.CompletionTokensDetails != nil && usage.CompletionTokensDetails.ReasoningTokens != nil { + reasoningTokens = *usage.CompletionTokensDetails.ReasoningTokens + } + } + + noCache := promptTokens - cacheReadTokens + text := completionTokens - reasoningTokens + return LanguageModelV3Usage{ + InputTokens: LanguageModelV3InputTokens{ + Total: &promptTokens, + NoCache: &noCache, + CacheRead: &cacheReadTokens, + CacheWrite: cacheWriteTokens, + }, + OutputTokens: LanguageModelV3OutputTokens{ + Total: &completionTokens, + Text: &text, + Reasoning: &reasoningTokens, + }, + Raw: rawOf(usage), + } +} + +func rawOf(usage *OpenRouterUsage) json.RawMessage { + if usage == nil { + return nil + } + return usage.Raw +} + +// ── stage 2: the flattened usage ───────────────────────────────────────── + +// InputTokenDetails is the flattened input breakdown. +type InputTokenDetails struct { + NoCacheTokens *float64 `json:"noCacheTokens,omitempty"` + CacheReadTokens *float64 `json:"cacheReadTokens,omitempty"` + CacheWriteTokens *float64 `json:"cacheWriteTokens,omitempty"` +} + +// OutputTokenDetails is the flattened output breakdown. +type OutputTokenDetails struct { + TextTokens *float64 `json:"textTokens,omitempty"` + ReasoningTokens *float64 `json:"reasoningTokens,omitempty"` +} + +// LanguageModelUsage is the flattened usage GetUsage consumes. The details +// blocks are optional; the two trailing fields are flat aliases GetUsage +// falls back to when the details are absent. +type LanguageModelUsage struct { + InputTokens *float64 `json:"inputTokens,omitempty"` + InputTokenDetails *InputTokenDetails `json:"inputTokenDetails,omitempty"` + OutputTokens *float64 `json:"outputTokens,omitempty"` + OutputTokenDetails *OutputTokenDetails `json:"outputTokenDetails,omitempty"` + TotalTokens *float64 `json:"totalTokens,omitempty"` + Raw json.RawMessage `json:"raw,omitempty"` + ReasoningTokens *float64 `json:"reasoningTokens,omitempty"` + CachedInputTokens *float64 `json:"cachedInputTokens,omitempty"` +} + +// AsLanguageModelUsage flattens the token groups. totalTokens is recomputed +// as input + output; the provider's own total survives only inside Raw. +func AsLanguageModelUsage(usage LanguageModelV3Usage) LanguageModelUsage { + return LanguageModelUsage{ + InputTokens: usage.InputTokens.Total, + InputTokenDetails: &InputTokenDetails{ + NoCacheTokens: usage.InputTokens.NoCache, + CacheReadTokens: usage.InputTokens.CacheRead, + CacheWriteTokens: usage.InputTokens.CacheWrite, + }, + OutputTokens: usage.OutputTokens.Total, + OutputTokenDetails: &OutputTokenDetails{ + TextTokens: usage.OutputTokens.Text, + ReasoningTokens: usage.OutputTokens.Reasoning, + }, + TotalTokens: addTokenCounts(usage.InputTokens.Total, usage.OutputTokens.Total), + Raw: usage.Raw, + ReasoningTokens: usage.OutputTokens.Reasoning, + CachedInputTokens: usage.InputTokens.CacheRead, + } +} + +// addTokenCounts is nil only when BOTH operands are absent; otherwise the +// absent side counts as 0. +func addTokenCounts(a, b *float64) *float64 { + if a == nil && b == nil { + return nil + } + sum := float64(0) + if a != nil { + sum += *a + } + if b != nil { + sum += *b + } + return &sum +} + +// ── stage 3: usage and cost ────────────────────────────────────────────── + +// ProviderMetadata is the per-provider metadata map a response may carry. +type ProviderMetadata map[string]map[string]any + +// UsageCache is the cache block of a usage result. +type UsageCache struct { + Write float64 `json:"write"` + Read float64 `json:"read"` +} + +// UsageTokens is the token block of a usage result. Total is optional. +type UsageTokens struct { + Total *float64 `json:"total,omitempty"` + Input float64 `json:"input"` + Output float64 `json:"output"` + Reasoning float64 `json:"reasoning"` + Cache UsageCache `json:"cache"` +} + +// UsageResult is GetUsage's result: the call's cost in USD and its tokens. +type UsageResult struct { + Cost float64 `json:"cost"` + Tokens UsageTokens `json:"tokens"` +} + +// GetUsageInput is GetUsage's parameter object. +type GetUsageInput struct { + Model Model + Usage LanguageModelUsage + Metadata ProviderMetadata +} + +// GetUsage derives the billed token counts and the cost of one model call. +// Cached input tokens are subtracted from the input count, since providers +// report inputTokens inclusive of cache reads and writes. +func GetUsage(input GetUsageInput) UsageResult { + usage := input.Usage + + inputTokens := safe(orZero(usage.InputTokens)) + outputTokens := safe(orZero(usage.OutputTokens)) + + var reasoningTokens float64 + if usage.OutputTokenDetails != nil && usage.OutputTokenDetails.ReasoningTokens != nil { + reasoningTokens = *usage.OutputTokenDetails.ReasoningTokens + } else if usage.ReasoningTokens != nil { + reasoningTokens = *usage.ReasoningTokens + } + reasoningTokens = safe(reasoningTokens) + + var cacheReadInputTokens float64 + if usage.InputTokenDetails != nil && usage.InputTokenDetails.CacheReadTokens != nil { + cacheReadInputTokens = *usage.InputTokenDetails.CacheReadTokens + } else if usage.CachedInputTokens != nil { + cacheReadInputTokens = *usage.CachedInputTokens + } + cacheReadInputTokens = safe(cacheReadInputTokens) + + cacheWriteInputTokens := safe(numberOf(cacheWriteCandidate(usage, input.Metadata))) + + adjustedInputTokens := safe(inputTokens - cacheReadInputTokens - cacheWriteInputTokens) + + tokens := UsageTokens{ + Total: usage.TotalTokens, + Input: adjustedInputTokens, + Output: safe(outputTokens - reasoningTokens), + Reasoning: reasoningTokens, + Cache: UsageCache{ + Write: cacheWriteInputTokens, + Read: cacheReadInputTokens, + }, + } + + rates := baseRates(input.Model.Cost) + if input.Model.Cost != nil && input.Model.Cost.ExperimentalOver200K != nil && + tokens.Input+tokens.Cache.Read > 200_000 { + rates = over200KRates(input.Model.Cost.ExperimentalOver200K) + } + + return UsageResult{ + Cost: safe(cost(tokens, rates)), + Tokens: tokens, + } +} + +// cacheWriteCandidate finds the cache-write token count: the flattened +// details first, then the provider-specific metadata keys some providers use +// instead. A nil result means none was reported. +func cacheWriteCandidate(usage LanguageModelUsage, metadata ProviderMetadata) any { + if usage.InputTokenDetails != nil && usage.InputTokenDetails.CacheWriteTokens != nil { + return *usage.InputTokenDetails.CacheWriteTokens + } + if value, ok := metadataGet(metadata, "anthropic", "cacheCreationInputTokens"); ok { + return value + } + if value, ok := metadataGet(metadata, "vertex", "cacheCreationInputTokens"); ok { + return value + } + if value, ok := metadataGetNested(metadata, "bedrock", "usage", "cacheWriteInputTokens"); ok { + return value + } + if value, ok := metadataGetNested(metadata, "venice", "usage", "cacheCreationInputTokens"); ok { + return value + } + return nil +} + +// metadataGet is metadata[provider][key]; ok=false when either level is +// absent or null. +func metadataGet(metadata ProviderMetadata, provider, key string) (any, bool) { + inner, ok := metadata[provider] + if !ok || inner == nil { + return nil, false + } + value, ok := inner[key] + if !ok || value == nil { + return nil, false + } + return value, true +} + +// metadataGetNested is metadata[provider][outer][key]. +func metadataGetNested(metadata ProviderMetadata, provider, outer, key string) (any, bool) { + middle, ok := metadataGet(metadata, provider, outer) + if !ok { + return nil, false + } + object, ok := middle.(map[string]any) + if !ok { + return nil, false + } + value, ok := object[key] + if !ok || value == nil { + return nil, false + } + return value, true +} + +// numberOf reads a token count out of a decoded JSON value. Anything that is +// not a number (or a numeric string) counts as 0. +func numberOf(value any) float64 { + switch typed := value.(type) { + case nil: + return 0 + case float64: + return typed + case float32: + return float64(typed) + case int: + return float64(typed) + case int64: + return float64(typed) + case json.Number: + f, _ := typed.Float64() + return f + case string: + f, err := strconv.ParseFloat(typed, 64) + if err != nil { + return 0 + } + return f + } + return 0 +} + +func orZero(value *float64) float64 { + if value == nil { + return 0 + } + return *value +} + +// costRates is the resolved price table in $/Mtok; an absent rate is 0. +type costRates struct { + input float64 + output float64 + cacheRead float64 + cacheWrite float64 +} + +func baseRates(cost *ModelCost) costRates { + if cost == nil { + return costRates{} + } + rates := costRates{input: cost.Input, output: cost.Output} + if cost.Cache != nil { + rates.cacheRead = cost.Cache.Read + rates.cacheWrite = cost.Cache.Write + } + return rates +} + +func over200KRates(cost *Over200KCost) costRates { + rates := costRates{input: cost.Input, output: cost.Output} + if cost.Cache != nil { + rates.cacheRead = cost.Cache.Read + rates.cacheWrite = cost.Cache.Write + } + return rates +} + +// cost prices the token block in USD. Reasoning tokens are billed at the +// output rate because catalogs carry no separate reasoning price. +func cost(tokens UsageTokens, rates costRates) float64 { + return (tokens.Input*rates.input + + tokens.Output*rates.output + + tokens.Cache.Read*rates.cacheRead + + tokens.Cache.Write*rates.cacheWrite + + tokens.Reasoning*rates.output) / 1_000_000 +} diff --git a/internal/seniordev/engine/msgmodel/convertmodelmessages.go b/internal/seniordev/engine/msgmodel/convertmodelmessages.go new file mode 100644 index 0000000000..d7eea3b20f --- /dev/null +++ b/internal/seniordev/engine/msgmodel/convertmodelmessages.go @@ -0,0 +1,390 @@ +//go:build !windows + +package msgmodel + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// ConvertToModelMessages turns UI messages into the model-facing message +// list. The load-bearing rule is that a `step-start` part FLUSHES the current +// assistant block, so one multi-step assistant UIMessage expands into an +// alternating assistant / tool / assistant / tool… run of ModelMessages. +// +// Deliberate omissions, all unreachable from senior-dev: +// - custom data-part conversion: senior-dev never supplies one, so `data-*` +// parts are dropped. +// - `source-url` / `source-document` parts match no branch on an assistant +// message: they are ignored AND do not break the block. They simply never +// enter `block`. + +// ToolModelOutputFn converts a tool's stored output into the shape the model +// sees. `output` is the raw JSON value; a nil `output` is absent. +type ToolModelOutputFn func(toolCallID string, input RawValue, output RawValue) ToolOutput + +// ConvertOptions are the conversion options. +type ConvertOptions struct { + IgnoreIncompleteToolCalls bool + Tools map[string]ToolModelOutputFn +} + +func (o *ConvertOptions) tool(name string) ToolModelOutputFn { + if o == nil || o.Tools == nil { + return nil + } + return o.Tools[name] +} + +// ConvertToModelMessages is the conversion described above. +func ConvertToModelMessages(messages []UIMessage, options *ConvertOptions) ([]ModelMessage, error) { + modelMessages := []ModelMessage{} + + if options != nil && options.IgnoreIncompleteToolCalls { + // A shallow message copy with incomplete tool parts filtered. + filtered := make([]UIMessage, 0, len(messages)) + for _, message := range messages { + parts := make([]UIPart, 0, len(message.Parts)) + for _, part := range message.Parts { + if part.isTool() && (part.State == UIToolInputStreaming || part.State == UIToolInputAvailable) { + continue + } + parts = append(parts, part) + } + message.Parts = parts + filtered = append(filtered, message) + } + messages = filtered + } + + for _, message := range messages { + switch message.Role { + case "system": + // Non-text parts are silently filtered, text is joined with "" + // and providerMetadata from every text part is shallow-merged one + // level. + var content bytes.Buffer + merged := []RawField{} + for _, part := range message.Parts { + if !part.isText() { + continue + } + content.WriteString(part.Text) + if len(part.ProviderMetadata) == 0 || string(part.ProviderMetadata) == "null" { + continue + } + for _, f := range RawObject(part.ProviderMetadata).Fields() { + merged = upsertField(merged, f) + } + } + msg := ModelMessage{Role: "system", Content: content.String()} + if len(merged) > 0 { + msg.ProviderOptions = encodeFields(merged) + } + modelMessages = append(modelMessages, msg) + + case "user": + content := []any{} + for _, part := range message.Parts { + switch { + case part.isText(): + content = append(content, TextContent{ + Type: "text", + Text: part.Text, + ProviderOptions: nonNull(part.ProviderMetadata), + }) + case part.isFile(): + content = append(content, FileContent{ + Type: "file", + MediaType: part.MediaType, + Filename: part.Filename, + Data: part.URL, + ProviderOptions: nonNull(part.ProviderMetadata), + }) + } + // Every other part kind (reasoning, tool-*, source-*, + // step-start, data-*) is dropped. + } + modelMessages = append(modelMessages, ModelMessage{Role: "user", Content: content}) + + case "assistant": + var block []UIPart + processBlock := func() error { + if len(block) == 0 { + return nil + } + content := []any{} + for _, part := range block { + switch { + case part.isText(): + content = append(content, TextContent{ + Type: "text", + Text: part.Text, + ProviderOptions: nonNull(part.ProviderMetadata), + }) + case part.isFile(): + content = append(content, FileContent{ + Type: "file", + MediaType: part.MediaType, + Filename: part.Filename, + Data: part.URL, + ProviderOptions: nonNull(part.ProviderMetadata), + }) + case part.isReasoning(): + content = append(content, ReasoningContent{ + Type: "reasoning", + Text: part.Text, + // Set unconditionally: an explicit null stays a + // null; only an absent value disappears. + ProviderOptions: part.ProviderMetadata, + }) + case part.isTool(): + toolName := part.ResolveToolName() + if part.State == UIToolInputStreaming { + // Emits nothing at all. + break + } + content = append(content, ToolCallContent{ + Type: "tool-call", + ToolCallID: part.ToolCallID, + ToolName: toolName, + Input: toolCallInput(part), + ProviderExecuted: part.ProviderExecuted, + ProviderOptions: nonNull(part.CallProviderMetadata), + }) + if isStrictTrue(part.ProviderExecuted) && + (part.State == UIToolOutputAvailable || part.State == UIToolOutputError) { + // Provider-executed results stay INSIDE the + // assistant message, with errorMode + // "json" (contrast the tool-role message below). + resultMeta := part.ResultProviderMetadata + if len(resultMeta) == 0 || string(resultMeta) == "null" { + resultMeta = part.CallProviderMetadata + } + errorMode := errorModeNone + output := part.Output + if part.State == UIToolOutputError { + errorMode = errorModeJSON + output = jsonString(part.ErrorText) + } + content = append(content, ToolResultContent{ + Type: "tool-result", + ToolCallID: part.ToolCallID, + ToolName: toolName, + Output: createToolModelOutput(part.ToolCallID, part.Input, output, options.tool(toolName), errorMode), + ProviderOptions: nonNull(resultMeta), + }) + } + case part.isData(): + // No data-part conversion is supplied; dropped. + default: + // Unreachable: `block` only ever receives the five + // kinds above. + return fmt.Errorf("Unsupported part: %s", part.Type) + } + } + modelMessages = append(modelMessages, ModelMessage{Role: "assistant", Content: content}) + + // Provider-executed parts are excluded from the tool-role + // message: their results already sit in the assistant message. + toolParts := make([]UIPart, 0, len(block)) + for _, part := range block { + if !part.isTool() { + continue + } + if !isStrictTrue(part.ProviderExecuted) { + toolParts = append(toolParts, part) + } + } + if len(toolParts) > 0 { + toolContent := []any{} + for _, toolPart := range toolParts { + switch toolPart.State { + case UIToolOutputError, UIToolOutputAvailable: + toolName := toolPart.ResolveToolName() + errorMode := errorModeNone + output := toolPart.Output + if toolPart.State == UIToolOutputError { + errorMode = errorModeText + output = jsonString(toolPart.ErrorText) + } + toolContent = append(toolContent, ToolResultContent{ + Type: "tool-result", + ToolCallID: toolPart.ToolCallID, + ToolName: toolName, + Output: createToolModelOutput(toolPart.ToolCallID, toolPart.Input, output, options.tool(toolName), errorMode), + ProviderOptions: nonNull(toolPart.CallProviderMetadata), + }) + } + } + // Pushed only if non-empty. A block whose tool parts are + // all input-available would yield an assistant message + // with a dangling tool-call and no tool message; + // ToModelMessages prevents that by replaying pending and + // running tools as errors. + if len(toolContent) > 0 { + modelMessages = append(modelMessages, ModelMessage{Role: "tool", Content: toolContent}) + } + } + block = nil + return nil + } + + for _, part := range message.Parts { + if part.isText() || part.isReasoning() || part.isFile() || part.isTool() || part.isData() { + block = append(block, part) + continue + } + if part.Type == "step-start" { + if err := processBlock(); err != nil { + return nil, err + } + } + } + if err := processBlock(); err != nil { + return nil, err + } + + default: + return nil, &MessageConversionError{Message: "Unsupported role: " + message.Role} + } + } + + return modelMessages, nil +} + +// ── helpers ────────────────────────────────────────────────────────────── + +const ( + errorModeNone = "" + errorModeText = "text" + errorModeJSON = "json" +) + +// toolCallInput is the input recorded on the call. On an output-error part a +// null or absent input falls through to rawInput. +func toolCallInput(part UIPart) RawValue { + if part.State != UIToolOutputError { + return part.Input + } + if len(part.Input) > 0 && string(part.Input) != "null" { + return part.Input + } + if len(part.RawInput) > 0 { + return part.RawInput + } + return nil +} + +// nonNull returns nil for an absent or explicitly-null value, so the key is +// omitted. +func nonNull(v RawValue) RawValue { + if len(v) == 0 || string(bytes.TrimSpace(v)) == "null" { + return nil + } + return v +} + +func isStrictTrue(v RawValue) bool { + return string(bytes.TrimSpace(v)) == "true" +} + +func jsonString(s string) RawValue { + raw, err := jsonutil.Marshal(s) + if err != nil { + return nil + } + return raw +} + +// createToolModelOutput builds the tool-result output the model sees: error +// text, error JSON, the tool's own converter, or a plain text/json value. +func createToolModelOutput(toolCallID string, input, output RawValue, tool ToolModelOutputFn, errorMode string) ToolOutput { + switch errorMode { + case errorModeText: + return ToolOutput{Type: "error-text", Value: getErrorMessage(output)} + case errorModeJSON: + return ToolOutput{Type: "error-json", Value: toJSONValue(output)} + } + if tool != nil { + return tool(toolCallID, input, output) + } + if s, ok := asJSONString(output); ok { + return ToolOutput{Type: "text", Value: s} + } + return ToolOutput{Type: "json", Value: toJSONValue(output)} +} + +// getErrorMessage renders a stored error output as text: "unknown error" for +// null or absent, the string itself, or the compact JSON otherwise. +func getErrorMessage(output RawValue) any { + if len(output) == 0 || string(bytes.TrimSpace(output)) == "null" { + return "unknown error" + } + if s, ok := asJSONString(output); ok { + return s + } + return string(compactJSON(output)) +} + +// toJSONValue maps an absent value to JSON null. +func toJSONValue(output RawValue) any { + if len(output) == 0 { + return json.RawMessage("null") + } + return output +} + +func asJSONString(raw RawValue) (string, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '"' { + return "", false + } + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + return "", false + } + return s, true +} + +func compactJSON(raw []byte) []byte { + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return raw + } + return buf.Bytes() +} + +// upsertField is one level of `{...acc, ...part.providerMetadata}`: a repeated +// key keeps its ORIGINAL position and takes the newer value. +func upsertField(acc []RawField, f RawField) []RawField { + for i := range acc { + if acc[i].Key == f.Key { + acc[i].Value = f.Value + return acc + } + } + return append(acc, f) +} + +func encodeFields(fields []RawField) RawValue { + var buf bytes.Buffer + buf.WriteByte('{') + for i, f := range fields { + if i > 0 { + buf.WriteByte(',') + } + key, err := jsonutil.Marshal(f.Key) + if err != nil { + return nil + } + buf.Write(key) + buf.WriteByte(':') + buf.Write(f.Value) + } + buf.WriteByte('}') + return buf.Bytes() +} diff --git a/internal/seniordev/engine/msgmodel/cursor.go b/internal/seniordev/engine/msgmodel/cursor.go new file mode 100644 index 0000000000..b9718a6bd6 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/cursor.go @@ -0,0 +1,64 @@ +//go:build !windows + +// Cursor encoding: a pagination cursor is `{id, time}` encoded as unpadded +// RFC 4648 URL-safe base64. +package msgmodel + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "math" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +type Cursor struct { + ID string `json:"id"` + Time float64 `json:"time"` +} + +func EncodeCursor(input Cursor) (string, error) { + raw, err := jsonutil.Marshal(input) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func DecodeCursor(input string) (Cursor, error) { + raw, err := base64.RawURLEncoding.DecodeString(input) + if err != nil { + return Cursor{}, err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return Cursor{}, err + } + idRaw, idOK := fields["id"] + timeRaw, timeOK := fields["time"] + if !idOK || !timeOK || bytes.Equal(bytes.TrimSpace(idRaw), []byte("null")) || + bytes.Equal(bytes.TrimSpace(timeRaw), []byte("null")) { + return Cursor{}, errors.New("msgmodel: cursor requires id and time") + } + var cursor Cursor + if err := json.Unmarshal(idRaw, &cursor.ID); err != nil { + return Cursor{}, err + } + if err := json.Unmarshal(timeRaw, &cursor.Time); err != nil { + return Cursor{}, err + } + if err := validateCursor(cursor); err != nil { + return Cursor{}, err + } + return cursor, nil +} + +func validateCursor(cursor Cursor) error { + n := float64(cursor.Time) + if math.IsNaN(n) || math.IsInf(n, 0) || n < 0 { + return errors.New("msgmodel: cursor time must be finite and non-negative") + } + return nil +} diff --git a/internal/seniordev/engine/msgmodel/events.go b/internal/seniordev/engine/msgmodel/events.go new file mode 100644 index 0000000000..65cd6f0a9c --- /dev/null +++ b/internal/seniordev/engine/msgmodel/events.go @@ -0,0 +1,47 @@ +//go:build !windows + +// Event names and payloads. The runtime's bus owns registration and +// delivery; this package owns the public names, versions, aggregate key, and +// wire payload shapes. +package msgmodel + +const ( + EventMessageUpdated = "message.updated" + EventMessageRemoved = "message.removed" + EventMessagePartUpdated = "message.part.updated" + EventMessagePartDelta = "message.part.delta" + EventMessagePartRemoved = "message.part.removed" + + SyncEventVersion = 1 + SyncAggregateKey = "sessionID" +) + +type UpdatedEvent struct { + SessionID string `json:"sessionID"` + Info Info `json:"info"` +} + +type RemovedEvent struct { + SessionID string `json:"sessionID"` + MessageID string `json:"messageID"` +} + +type PartUpdatedEvent struct { + SessionID string `json:"sessionID"` + Part Part `json:"part"` + Time uint64 `json:"time"` +} + +type PartDeltaEvent struct { + SessionID string `json:"sessionID"` + MessageID string `json:"messageID"` + PartID string `json:"partID"` + Field string `json:"field"` + Delta string `json:"delta"` +} + +type PartRemovedEvent struct { + SessionID string `json:"sessionID"` + MessageID string `json:"messageID"` + PartID string `json:"partID"` +} diff --git a/internal/seniordev/engine/msgmodel/filter.go b/internal/seniordev/engine/msgmodel/filter.go new file mode 100644 index 0000000000..5e02357837 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/filter.go @@ -0,0 +1,135 @@ +//go:build !windows + +package msgmodel + +// FilterCompacted projects a session onto what the model should see: a +// newest-first walk over the messages that stops at the last completed +// compaction, then (when the compaction names a `tail_start_id` that sits +// BEFORE it) rotates the summary block in front of the retained tail. +// +// `msgs` must arrive newest-first; the result is chronological. +// +// The step loop mutates the returned parts in place (WrapLateUserText), so +// the caller needs parts it owns. This function does NOT deep-copy; the +// storage layer that feeds it must hand over fresh values. +func FilterCompacted(msgs []WithParts) []WithParts { + result := []WithParts{} + completed := map[string]bool{} + var retain *string + + for _, msg := range msgs { + result = append(result, msg) + if retain != nil { + if msg.Info.MessageID() == *retain { + break + } + continue + } + if user, ok := msg.Info.(User); ok && completed[user.ID] { + part := findCompactionPart(msg.Parts) + if part == nil { + continue + } + if part.TailStartID == nil || *part.TailStartID == "" { + // An empty tail id counts as no tail. + break + } + retain = part.TailStartID + if msg.Info.MessageID() == *retain { + break + } + continue + } + if assistant, ok := msg.Info.(Assistant); ok && + boolValue(assistant.Summary) && + assistant.Finish != nil && *assistant.Finish != "" && + assistant.Error == nil { + completed[assistant.ParentID] = true + } + } + + reverseWithParts(result) + + compactionIndex := -1 + for i := len(result) - 1; i >= 0; i-- { + if _, ok := result[i].Info.(User); !ok { + continue + } + if findCompactionWithTail(result[i].Parts) != nil { + compactionIndex = i + break + } + } + if compactionIndex < 0 { + return result + } + compaction := result[compactionIndex] + part := findCompactionWithTail(compaction.Parts) + + summaryIndex := -1 + for i, msg := range result { + if i <= compactionIndex { + continue + } + assistant, ok := msg.Info.(Assistant) + if !ok { + continue + } + // The same rule that marked the compaction completed above: an + // errored summary attempt (a transport failure, a rejected draft) is + // never the boundary. Without this check a failed first attempt + // sitting before the accepted one would be picked here, and the tail + // would be rotated in front of the real summary instead of after it. + if boolValue(assistant.Summary) && assistant.Error == nil && + assistant.ParentID == compaction.Info.MessageID() { + summaryIndex = i + break + } + } + + tailIndex := -1 + if part != nil && part.TailStartID != nil && *part.TailStartID != "" { + for i, msg := range result { + if msg.Info.MessageID() == *part.TailStartID { + tailIndex = i + break + } + } + } + + if tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex { + out := make([]WithParts, 0, len(result)) + out = append(out, result[compactionIndex:summaryIndex+1]...) + out = append(out, result[tailIndex:compactionIndex]...) + out = append(out, result[summaryIndex+1:]...) + return out + } + return result +} + +func findCompactionPart(parts Parts) *CompactionPart { + for _, raw := range parts { + if part, ok := raw.(CompactionPart); ok { + return &part + } + } + return nil +} + +// findCompactionWithTail finds a compaction part whose tail id is present at +// all; unlike the walk above it accepts an empty-string tail id. +func findCompactionWithTail(parts Parts) *CompactionPart { + for _, raw := range parts { + part, ok := raw.(CompactionPart) + if ok && part.TailStartID != nil { + return &part + } + } + return nil +} + +func reverseWithParts(s []WithParts) { + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { + s[i], s[j] = s[j], s[i] + } +} diff --git a/internal/seniordev/engine/msgmodel/filter_test.go b/internal/seniordev/engine/msgmodel/filter_test.go new file mode 100644 index 0000000000..8b830b692a --- /dev/null +++ b/internal/seniordev/engine/msgmodel/filter_test.go @@ -0,0 +1,114 @@ +//go:build !windows + +package msgmodel + +import ( + "testing" +) + +func filterUser(id string, parts ...Part) WithParts { + return WithParts{ + Info: User{MessageBase: MessageBase{ID: id, SessionID: "ses"}}, + Parts: parts, + } +} + +func filterAssistant(id, parent string, summary bool, failed error) WithParts { + finish := "stop" + info := Assistant{ + MessageBase: MessageBase{ID: id, SessionID: "ses"}, + ParentID: parent, Finish: &finish, + } + if summary { + flag := true + info.Summary = &flag + } + if failed != nil { + converted := NewUnknownError(failed.Error()) + info.Error = &converted + errorFinish := "error" + info.Finish = &errorFinish + } + return WithParts{ + Info: info, + Parts: Parts{TextPart{ + PartBase: PartBase{ID: "p_" + id, SessionID: "ses", MessageID: id}, + Text: "text " + id, + }}, + } +} + +func ids(messages []WithParts) []string { + out := make([]string, 0, len(messages)) + for _, message := range messages { + out = append(out, message.Info.MessageID()) + } + return out +} + +func equalIDs(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +// chronological builds: u0, a0, a1, a2 (tail starts at a1), the compaction +// user message uc, then the summary attempts, then the auto-continue user. +func compactedSession(attempts ...WithParts) []WithParts { + tail := "a1" + messages := []WithParts{ + filterUser("u0"), + filterAssistant("a0", "u0", false, nil), + filterAssistant("a1", "u0", false, nil), + filterAssistant("a2", "u0", false, nil), + filterUser("uc", CompactionPart{ + PartBase: PartBase{ID: "pc", SessionID: "ses", MessageID: "uc"}, + Auto: true, TailStartID: &tail, + }), + } + messages = append(messages, attempts...) + return append(messages, filterUser("ucont")) +} + +func newestFirst(messages []WithParts) []WithParts { + out := make([]WithParts, len(messages)) + for i := range messages { + out[len(messages)-1-i] = messages[i] + } + return out +} + +func TestFilterCompactedRotatesTailAfterTheSummary(t *testing.T) { + session := compactedSession(filterAssistant("as", "uc", true, nil)) + got := ids(FilterCompacted(newestFirst(session))) + want := []string{"uc", "as", "a1", "a2", "ucont"} + if !equalIDs(got, want) { + t.Fatalf("projection = %v, want %v", got, want) + } +} + +// An errored summary attempt sitting before the accepted one (a transport +// failure retried by the run layer) must not be chosen as the boundary: the +// tail has to land AFTER the accepted summary, exactly as it does when the +// first attempt succeeds. +func TestFilterCompactedSkipsErroredSummaryAttemptWhenRotating(t *testing.T) { + session := compactedSession( + filterAssistant("afail", "uc", true, errString("unexpected EOF")), + filterAssistant("as", "uc", true, nil), + ) + got := ids(FilterCompacted(newestFirst(session))) + want := []string{"uc", "afail", "as", "a1", "a2", "ucont"} + if !equalIDs(got, want) { + t.Fatalf("projection = %v, want %v", got, want) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/seniordev/engine/msgmodel/fromerror.go b/internal/seniordev/engine/msgmodel/fromerror.go new file mode 100644 index 0000000000..adfe88ac13 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/fromerror.go @@ -0,0 +1,203 @@ +//go:build !windows + +// Error conversion. Stream-error parsing lives here because it is a pure JSON +// decision. +package msgmodel + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Marker errors FromError classifies by type. +type AbortFailure struct{ Message string } + +func (e AbortFailure) Error() string { return e.Message } + +type OutputLengthFailure struct{} + +func (OutputLengthFailure) Error() string { return ErrNameMessageOutputLength } + +// FromError classifies a failure value into the persisted AssistantError +// shape. `value` is the raw `error` payload of a model stream, a Go error, or +// one of the marker types above. +func FromError(value any) AssistantError { + switch e := value.(type) { + case AbortFailure: + return NewMessageAbortedError(e.Message) + case *AbortFailure: + if e != nil { + return NewMessageAbortedError(e.Message) + } + case OutputLengthFailure, *OutputLengthFailure: + return NewMessageOutputLengthError() + case AssistantError: + if e.Name == ErrNameMessageOutputLength { + return e + } + case *AssistantError: + if e != nil && e.Name == ErrNameMessageOutputLength { + return *e + } + } + + if err, ok := value.(error); ok { + // Recognize the OpenRouter in-band shape first so a provider failure + // wrapped in a Go error classifies as an APIError. + if apiErr := openRouterInBandAPIError(streamJSON(err.Error())); apiErr != nil { + return NewAPIError(*apiErr) + } + return NewUnknownError(errorMessage(err)) + } + if parsed := ParseStreamError(value); parsed != nil { + if parsed.Type == "context_overflow" { + return NewContextOverflowError(ContextOverflowErrorData{ + Message: parsed.Message, ResponseBody: parsed.ResponseBody, + }) + } + return NewAPIError(APIError{ + Message: parsed.Message, IsRetryable: parsed.IsRetryable, + ResponseBody: parsed.ResponseBody, + }) + } + // The raw `error` field of an OpenRouter chunk arrives here as a + // json.RawMessage. ParseStreamError has already declined it (no envelope); + // recognize the bare in-band shape before it degrades to UnknownError. + if apiErr := openRouterInBandAPIError(streamJSON(value)); apiErr != nil { + return NewAPIError(*apiErr) + } + raw, err := jsonutil.Marshal(value) + if err != nil { + return NewUnknownError("") + } + return NewUnknownError(string(raw)) +} + +func errorMessage(err error) string { + if err == nil { + return "Error" + } + if message := err.Error(); message != "" { + return message + } + return fmt.Sprintf("%T", err) +} + +type ParsedStreamError struct { + Type string + Message string + IsRetryable bool + ResponseBody *string +} + +func ParseStreamError(input any) *ParsedStreamError { + raw := streamJSON(input) + if len(raw) == 0 { + return nil + } + var outer json.RawMessage + if err := json.Unmarshal(raw, &outer); err != nil { + return nil + } + body := compactJSONValue(outer) + var probe struct { + Message any `json:"message"` + } + if err := json.Unmarshal(body, &probe); err == nil { + if message, ok := probe.Message.(string); ok { + nested := streamJSON(message) + if len(nested) > 0 { + var nestedValue json.RawMessage + if json.Unmarshal(nested, &nestedValue) == nil && isJSONObject(nestedValue) { + body = compactJSONValue(nestedValue) + } + } + } + } + + var envelope struct { + Type string `json:"type"` + Error struct { + Code string `json:"code"` + Message any `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &envelope); err != nil || envelope.Type != "error" { + return nil + } + response := string(body) + message, _ := envelope.Error.Message.(string) + result := &ParsedStreamError{ResponseBody: &response} + switch envelope.Error.Code { + case "context_length_exceeded": + result.Type = "context_overflow" + result.Message = "Input exceeds context window of this model" + case "insufficient_quota": + result.Type = "api_error" + result.Message = "Quota exceeded. Check your plan and billing details." + case "usage_not_included": + result.Type = "api_error" + result.Message = "Usage is not included in the current plan." + case "invalid_prompt": + result.Type = "api_error" + result.Message = message + if result.Message == "" { + result.Message = "Invalid prompt." + } + case "server_is_overloaded", "server_error": + result.Type = "api_error" + result.Message = message + if result.Message == "" { + result.Message = "Server error." + } + result.IsRetryable = true + default: + return nil + } + return result +} + +func streamJSON(input any) []byte { + switch value := input.(type) { + case json.RawMessage: + if json.Valid(value) { + return value + } + case RawObject: + if json.Valid(value) { + return value + } + case []byte: + if json.Valid(value) { + return value + } + case string: + trimmed := strings.TrimSpace(value) + if json.Valid([]byte(trimmed)) { + return []byte(trimmed) + } + default: + raw, err := jsonutil.Marshal(value) + if err == nil && json.Valid(raw) { + return raw + } + } + return nil +} + +func compactJSONValue(raw []byte) []byte { + var buffer bytes.Buffer + if err := json.Compact(&buffer, raw); err != nil { + return raw + } + return buffer.Bytes() +} + +func isJSONObject(raw []byte) bool { + trimmed := bytes.TrimSpace(raw) + return len(trimmed) > 0 && trimmed[0] == '{' +} diff --git a/internal/seniordev/engine/msgmodel/fromerror_openrouter502_test.go b/internal/seniordev/engine/msgmodel/fromerror_openrouter502_test.go new file mode 100644 index 0000000000..b95f8882ca --- /dev/null +++ b/internal/seniordev/engine/msgmodel/fromerror_openrouter502_test.go @@ -0,0 +1,91 @@ +//go:build !windows + +package msgmodel + +import ( + "encoding/json" + "errors" + "testing" +) + +// The in-band payload OpenRouter sends when an upstream provider drops the +// connection mid-stream. It arrives two ways: as the raw `error` field of a chunk +// (json.RawMessage, via FromStreamError), or wrapped in a Go error. Both must +// classify as an APIError with the status attached, or the run layer cannot +// offer its bounded fresh-turn recovery and one transient blip ends the run. +const openrouter502Body = `{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}}` + +func assertInBand502(t *testing.T, got AssistantError) { + t.Helper() + if got.Name != ErrNameAPI { + t.Fatalf("classified as %q, want %q -- the run cannot recover this", got.Name, ErrNameAPI) + } + var data APIError + if err := json.Unmarshal(got.Data, &data); err != nil { + t.Fatal(err) + } + if data.StatusCode == nil || *data.StatusCode != 502 { + t.Fatalf("StatusCode = %v, want 502 -- the run classifier keys on it", data.StatusCode) + } + if data.Message != "Network connection lost." { + t.Fatalf("Message = %q", data.Message) + } + if data.ResponseBody == nil || *data.ResponseBody != openrouter502Body { + t.Fatalf("ResponseBody not preserved: %v", data.ResponseBody) + } +} + +func TestOpenRouterInBand502ClassifiesAsAPIError(t *testing.T) { + t.Run("as the raw error field of a chunk (the runtime path)", func(t *testing.T) { + assertInBand502(t, FromError(json.RawMessage(openrouter502Body))) + }) + + t.Run("wrapped in a Go error", func(t *testing.T) { + assertInBand502(t, FromError(errors.New(openrouter502Body))) + }) +} + +// The recognizer classifies shape only; policy stays with the run. A 4xx +// in the same shape must carry its status and NOT be marked retryable here. +func TestOpenRouterInBand4xxCarriesStatusWithoutRetryFlag(t *testing.T) { + got := FromError(json.RawMessage(`{"code":400,"message":"bad request"}`)) + if got.Name != ErrNameAPI { + t.Fatalf("classified as %q, want %q", got.Name, ErrNameAPI) + } + var data APIError + if err := json.Unmarshal(got.Data, &data); err != nil { + t.Fatal(err) + } + if data.StatusCode == nil || *data.StatusCode != 400 { + t.Fatalf("StatusCode = %v, want 400", data.StatusCode) + } + if data.IsRetryable { + t.Fatal("recognizer must not set IsRetryable; retry policy belongs to the run") + } +} + +// Shapes the recognizer must decline, so nothing that previously classified +// changes behaviour. +func TestOpenRouterInBandRecognizerDeclines(t *testing.T) { + for name, payload := range map[string]string{ + "enveloped stream error": `{"type":"error","error":{"code":"overloaded_error","message":"x"}}`, + "nested error object": `{"error":{"code":502,"message":"x"}}`, + "string code": `{"code":"NOT_A_NUMBER","message":"x"}`, + "no message": `{"code":502}`, + "non-http code": `{"code":-32000,"message":"jsonrpc-style"}`, + "fractional code": `{"code":502.5,"message":"x"}`, + "not an object": `"Network connection lost."`, + } { + t.Run(name, func(t *testing.T) { + if apiErr := openRouterInBandAPIError([]byte(payload)); apiErr != nil { + t.Fatalf("recognized %s as %+v; must decline", payload, apiErr) + } + }) + } + // And a plain Go error with a non-JSON message still degrades to + // UnknownError exactly as before. + got := FromError(errors.New("Network connection lost.")) + if got.Name != ErrNameUnknown { + t.Fatalf("plain text error classified as %q, want %q", got.Name, ErrNameUnknown) + } +} diff --git a/internal/seniordev/engine/msgmodel/message.go b/internal/seniordev/engine/msgmodel/message.go new file mode 100644 index 0000000000..59dd66308d --- /dev/null +++ b/internal/seniordev/engine/msgmodel/message.go @@ -0,0 +1,246 @@ +//go:build !windows + +package msgmodel + +import ( + "encoding/json" + "fmt" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// ── AssistantError ─────────────────────────────────────────────────────── +// +// An assistant error is persisted as `{name, data}`. + +// APIError is the `data` payload of the APIError variant. `responseBody` is +// searched by substring by the error classifiers, so it is a string kept +// byte-for-byte, never re-encoded JSON. +type APIError struct { + Message string `json:"message"` + StatusCode *uint64 `json:"statusCode,omitempty"` + IsRetryable bool `json:"isRetryable"` + ResponseHeaders RawObject `json:"responseHeaders,omitempty"` + ResponseBody *string `json:"responseBody,omitempty"` + Metadata RawObject `json:"metadata,omitempty"` +} + +// UnknownErrorData is the UnknownError payload. +type UnknownErrorData struct { + Message string `json:"message"` +} + +// MessageOutputLengthErrorData is the MessageOutputLengthError payload: no +// fields. +type MessageOutputLengthErrorData struct{} + +// MessageAbortedErrorData is the MessageAbortedError payload. +type MessageAbortedErrorData struct { + Message string `json:"message"` +} + +// StructuredOutputErrorData is the StructuredOutputError payload. +type StructuredOutputErrorData struct { + Message string `json:"message"` + Retries uint64 `json:"retries"` +} + +// ContextOverflowErrorData is the ContextOverflowError payload. +type ContextOverflowErrorData struct { + Message string `json:"message"` + ResponseBody *string `json:"responseBody,omitempty"` +} + +// AssistantError is `{name, data}`. `Data` stays raw so an error minted +// elsewhere round-trips verbatim; the typed constructors below cover the seven +// known variants. +type AssistantError struct { + Name string `json:"name"` + Data json.RawMessage `json:"data"` +} + +func newAssistantError(name string, data any) (AssistantError, error) { + raw, err := jsonutil.Marshal(data) + if err != nil { + return AssistantError{}, err + } + return AssistantError{Name: name, Data: raw}, nil +} + +func mustAssistantError(name string, data any) AssistantError { + e, err := newAssistantError(name, data) + if err != nil { + panic(fmt.Sprintf("msgmodel: encode %s: %v", name, err)) + } + return e +} + +func NewAPIError(data APIError) AssistantError { + return mustAssistantError(ErrNameAPI, data) +} + +func NewUnknownError(message string) AssistantError { + return mustAssistantError(ErrNameUnknown, UnknownErrorData{Message: message}) +} + +func NewMessageOutputLengthError() AssistantError { + return mustAssistantError(ErrNameMessageOutputLength, MessageOutputLengthErrorData{}) +} + +func NewMessageAbortedError(message string) AssistantError { + return mustAssistantError(ErrNameMessageAborted, MessageAbortedErrorData{Message: message}) +} + +func NewStructuredOutputError(message string, retries uint64) AssistantError { + return mustAssistantError(ErrNameStructuredOutput, StructuredOutputErrorData{Message: message, Retries: retries}) +} + +func NewContextOverflowError(data ContextOverflowErrorData) AssistantError { + return mustAssistantError(ErrNameContextOverflow, data) +} + +// IsAborted is a bare `name` comparison, nothing more. +func (e *AssistantError) IsAborted() bool { + return e != nil && e.Name == ErrNameMessageAborted +} + +// ── User ───────────────────────────────────────── + +// UserSummary is User.summary. +type UserSummary struct { + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Diffs []FileDiff `json:"diffs"` +} + +// UserModel is User.model. +type UserModel struct { + ProviderID string `json:"providerID"` + ModelID string `json:"modelID"` + Variant *string `json:"variant,omitempty"` +} + +type User struct { + MessageBase + Role string `json:"role"` + Time TimeCreated `json:"time"` + Format OutputFormat `json:"format,omitempty"` + Summary *UserSummary `json:"summary,omitempty"` + Agent string `json:"agent"` + Model UserModel `json:"model"` + System *string `json:"system,omitempty"` + Tools *map[string]bool `json:"tools,omitempty"` +} + +func (m User) MessageRole() string { return "user" } +func (m User) MessageID() string { return m.ID } +func (m User) MarshalJSON() ([]byte, error) { + type alias User + m.Role = "user" + return tagged(alias(m)) +} + +// ── Assistant ──────────────────────────────────── + +// AssistantTime is Assistant.time. +type AssistantTime struct { + Created uint64 `json:"created"` + Completed *uint64 `json:"completed,omitempty"` +} + +// AssistantPath is Assistant.path. +type AssistantPath struct { + Cwd string `json:"cwd"` + Root string `json:"root"` +} + +type Assistant struct { + MessageBase + Role string `json:"role"` + Time AssistantTime `json:"time"` + Error *AssistantError `json:"error,omitempty"` + ParentID string `json:"parentID"` + ModelID string `json:"modelID"` + ProviderID string `json:"providerID"` + // Mode always carries the same value as Agent; both are persisted. + Mode string `json:"mode"` + Agent string `json:"agent"` + Path AssistantPath `json:"path"` + Summary *bool `json:"summary,omitempty"` + Cost float64 `json:"cost"` + Tokens Tokens `json:"tokens"` + Structured RawValue `json:"structured,omitempty"` + Variant *string `json:"variant,omitempty"` + // Finish is one of the unified finish reasons (orclient.Finish*). + Finish *string `json:"finish,omitempty"` + // Upstream is the endpoint that served the message's last step, copied + // from the step-finish part so a message-level consumer (the agent + // summary) can attribute cache misses without walking parts. + Upstream string `json:"upstream,omitempty"` +} + +func (m Assistant) MessageRole() string { return "assistant" } +func (m Assistant) MessageID() string { return m.ID } +func (m Assistant) MarshalJSON() ([]byte, error) { + type alias Assistant + m.Role = "assistant" + return tagged(alias(m)) +} + +// ── Info union ─────────────────────────────────────────────────────────── + +// Info is the User | Assistant union. +type Info interface { + MessageRole() string + MessageID() string + json.Marshaler +} + +// UnmarshalInfo dispatches on `role`. +func UnmarshalInfo(raw []byte) (Info, error) { + var probe struct { + Role string `json:"role"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, err + } + switch probe.Role { + case "user": + var m User + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return m, nil + case "assistant": + var m Assistant + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return m, nil + } + return nil, fmt.Errorf("msgmodel: unknown message role %q", probe.Role) +} + +// ── WithParts ──────────────────────────────────── + +type WithParts struct { + Info Info `json:"info"` + Parts Parts `json:"parts"` +} + +func (w *WithParts) UnmarshalJSON(b []byte) error { + var a struct { + Info json.RawMessage `json:"info"` + Parts Parts `json:"parts"` + } + if err := json.Unmarshal(b, &a); err != nil { + return err + } + info, err := UnmarshalInfo(a.Info) + if err != nil { + return err + } + w.Info = info + w.Parts = a.Parts + return nil +} diff --git a/internal/seniordev/engine/msgmodel/msgmodel.go b/internal/seniordev/engine/msgmodel/msgmodel.go new file mode 100644 index 0000000000..b373c4db41 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/msgmodel.go @@ -0,0 +1,172 @@ +//go:build !windows + +// Package msgmodel is the persisted message and part model: the assistant, +// user and tool parts a session stores, the conversion pipeline from stored +// messages to the model-facing message list, the compaction filter, and the +// part-assembly helpers the step loop and the stream processor build on. +// +// Opaque provider objects (metadata, tool input, structured output) are kept +// as raw JSON so their bytes round-trip unchanged; the doom-loop guard +// compares tool inputs byte for byte. Optional fields are pointers with +// omitempty. Every discriminated union re-asserts its own tag in +// MarshalJSON, and parts are stored and type-switched as VALUES, never +// pointers. +package msgmodel + +import ( + "encoding/json" +) + +// SyntheticAttachmentPrompt opens the synthetic user message that carries +// media extracted from a tool result. +const SyntheticAttachmentPrompt = "Attached media from tool result:" + +// ── opaque JSON aliases ────────────────────────────────────────────────── + +// RawValue is any JSON value carried verbatim. A zero-length RawValue is an +// absent value (the key is omitted), not JSON null. +type RawValue = json.RawMessage + +// ── discriminant tags ──────────────────────────────────────────────────── + +// Part `type` discriminants. +const ( + PartTypeText = "text" + PartTypeReasoning = "reasoning" + PartTypeFile = "file" + PartTypeTool = "tool" + PartTypeStepStart = "step-start" + PartTypeStepFinish = "step-finish" + PartTypeCompaction = "compaction" +) + +// ToolState `status` discriminants. +const ( + ToolStatusPending = "pending" + ToolStatusRunning = "running" + ToolStatusCompleted = "completed" + ToolStatusError = "error" +) + +// AssistantError `name` discriminants. +const ( + ErrNameUnknown = "UnknownError" + ErrNameMessageOutputLength = "MessageOutputLengthError" + ErrNameMessageAborted = "MessageAbortedError" + ErrNameStructuredOutput = "StructuredOutputError" + ErrNameContextOverflow = "ContextOverflowError" + ErrNameAPI = "APIError" +) + +// ── Provider.Model, narrowed ───────────────────────────────────────────── + +// ModelAPI is the `api` sub-object of a catalog model: the provider SDK +// identifier and the provider-side model id. supportsMediaInToolResult reads +// both. +type ModelAPI struct { + Npm string `json:"npm"` + ID string `json:"id"` +} + +// Model is the slice of a catalog model this package reads: `providerID` and +// `id` feed DifferentModel, `api` feeds supportsMediaInToolResult. +type Model struct { + ProviderID string `json:"providerID"` + ID string `json:"id"` + API ModelAPI `json:"api"` +} + +// ── output format ────────────────────────────────── + +// OutputFormat is the `OutputFormatText | OutputFormatJsonSchema` union. It is +// only carried, never inspected, by anything in this package, so it keeps its +// bytes verbatim. +type OutputFormat = json.RawMessage + +// ── shared bases ───────────────────────────────────────────────────────── + +// PartBase is embedded first in every part so id/sessionID/messageID lead +// the JSON. +type PartBase struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + MessageID string `json:"messageID"` +} + +// MessageBase is the id pair every message carries. +type MessageBase struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` +} + +// ── time sub-structs ───────────────────────────────────────────────────── + +// TimeStartEnd is `{start, end?}`. TextPart.time and ReasoningPart.time share +// the shape; only the outer optionality differs. +type TimeStartEnd struct { + Start uint64 `json:"start"` + End *uint64 `json:"end,omitempty"` +} + +// TimeCreated is `{created}`. +type TimeCreated struct { + Created uint64 `json:"created"` +} + +// TokenCache is `{read, write}`. +type TokenCache struct { + Read uint64 `json:"read"` + Write uint64 `json:"write"` +} + +// Tokens is the token block shared by StepFinishPart and Assistant. +type Tokens struct { + Total *uint64 `json:"total,omitempty"` + Input uint64 `json:"input"` + Output uint64 `json:"output"` + Reasoning uint64 `json:"reasoning"` + Cache TokenCache `json:"cache"` +} + +// ── file part sources ──────────────────────────── + +// FilePartSourceText is the text span a file part source covers. +type FilePartSourceText struct { + Value string `json:"value"` + Start uint64 `json:"start"` + End uint64 `json:"end"` +} + +// LSPPosition / LSPRange locate a symbol source in its file. +type LSPPosition struct { + Line uint64 `json:"line"` + Character uint64 `json:"character"` +} + +type LSPRange struct { + Start LSPPosition `json:"start"` + End LSPPosition `json:"end"` +} + +// FilePartSource is the file / symbol / resource source union, discriminated +// on `type`. Nothing in this package reads it, so it is a single carrier +// struct rather than an interface, with the shared `text` first. +type FilePartSource struct { + Text FilePartSourceText `json:"text"` + Type string `json:"type"` + Path string `json:"path,omitempty"` + Range *LSPRange `json:"range,omitempty"` + Name string `json:"name,omitempty"` + Kind *uint64 `json:"kind,omitempty"` + ClientName string `json:"clientName,omitempty"` + URI string `json:"uri,omitempty"` +} + +// ── FileDiff ───────────────────────────────────────────────────────────── + +type FileDiff struct { + File string `json:"file"` + Patch string `json:"patch"` + Additions float64 `json:"additions"` + Deletions float64 `json:"deletions"` +} diff --git a/internal/seniordev/engine/msgmodel/msgmodel_test.go b/internal/seniordev/engine/msgmodel/msgmodel_test.go new file mode 100644 index 0000000000..a1198f6083 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/msgmodel_test.go @@ -0,0 +1,410 @@ +//go:build !windows + +package msgmodel + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Tests for the seams and byte-level rules of the message model. + +func ptrBool(b bool) *bool { return &b } +func ptrU64(v uint64) *uint64 { return &v } +func ptrFloat(f float64) *float64 { return &f } +func ptrString(s string) *string { return &s } +func raw(s string) json.RawMessage { return json.RawMessage(s) } +func rawObj(s string) RawObject { return RawObject(s) } +func mustJSON(t *testing.T, v any) string { + t.Helper() + b, err := jsonutil.Marshal(v) + if err != nil { + t.Fatalf("stringify: %v", err) + } + return string(b) +} + +// ── DifferentModel ─────────────────────────────────────────────────────── + +func TestDifferentModel(t *testing.T) { + cases := []struct { + name string + model Model + assistant Assistant + wantDiffer bool + }{ + { + name: "identical", + model: Model{ProviderID: "openrouter", ID: "acme/model-pro"}, + assistant: Assistant{ProviderID: "openrouter", ModelID: "acme/model-pro"}, + }, + { + name: "different model id", + model: Model{ProviderID: "openrouter", ID: "acme/model-max"}, + assistant: Assistant{ProviderID: "openrouter", ModelID: "acme/model-pro"}, + wantDiffer: true, + }, + { + name: "different provider id", + model: Model{ProviderID: "anthropic", ID: "m"}, + assistant: Assistant{ProviderID: "openrouter", ModelID: "m"}, + wantDiffer: true, + }, + { + // The check is `${a}/${b}` string concatenation, so a slash inside + // either half can make two distinct pairs compare EQUAL. + name: "slash split ambiguity compares equal", + model: Model{ProviderID: "a", ID: "b/c"}, + assistant: Assistant{ProviderID: "a/b", ModelID: "c"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DifferentModel(tc.model, tc.assistant); got != tc.wantDiffer { + t.Fatalf("DifferentModel = %v, want %v", got, tc.wantDiffer) + } + }) + } +} + +// ── TruncateToolOutput ─────────────────────────────────────────────────── + +func TestTruncateToolOutput(t *testing.T) { + if got := TruncateToolOutput("abcdef", nil); got != "abcdef" { + t.Fatalf("nil maxChars: %q", got) + } + if got := TruncateToolOutput("abcdef", ptrFloat(0)); got != "abcdef" { + t.Fatalf("falsy 0 maxChars: %q", got) + } + if got := TruncateToolOutput("abcde", ptrFloat(5)); got != "abcde" { + t.Fatalf("exact length: %q", got) + } + want := "abc\n[Tool output truncated for compaction: omitted 3 chars]" + if got := TruncateToolOutput("abcdef", ptrFloat(3)); got != want { + t.Fatalf("truncated:\n got %q\nwant %q", got, want) + } + // The limit counts characters, never bytes, so a multi-byte character is + // kept whole. + got := TruncateToolOutput("a\U0001F600b", ptrFloat(2)) + if !strings.HasPrefix(got, "a\U0001F600\n") { + t.Fatalf("character cut: %q", got) + } + if !strings.HasSuffix(got, "omitted 1 chars]") { + t.Fatalf("omitted count should count characters: %q", got) + } +} + +// ── opaque JSON ────────────────────────────────────────────────────────── + +func TestRawObjectPreservesKeyOrderAndEmptyObject(t *testing.T) { + part := ToolPart{ + PartBase: PartBase{ID: "p", SessionID: "s", MessageID: "m"}, + CallID: "c", + Tool: "bash", + State: ToolStateCompleted{ + Input: rawObj(`{"zulu":1,"alpha":2,"0":3}`), + Output: "o", + Title: "t", + Metadata: nil, // required field: must serialise as {} + Time: ToolTimeCompleted{Start: 1, End: 2}, + }, + } + got := mustJSON(t, part) + want := `{"id":"p","sessionID":"s","messageID":"m","type":"tool","callID":"c","tool":"bash",` + + `"state":{"status":"completed","input":{"zulu":1,"alpha":2,"0":3},"output":"o","title":"t","metadata":{},"time":{"start":1,"end":2}}}` + if got != want { + t.Fatalf("\n got %s\nwant %s", got, want) + } +} + +func TestOptionalRawObjectIsOmittedWhenAbsentAndKeptWhenEmpty(t *testing.T) { + absent := TextPart{PartBase: PartBase{ID: "p", SessionID: "s", MessageID: "m"}, Text: "x"} + if got := mustJSON(t, absent); strings.Contains(got, "metadata") { + t.Fatalf("absent metadata should be omitted: %s", got) + } + empty := absent + empty.Metadata = rawObj("{}") + if got := mustJSON(t, empty); !strings.Contains(got, `"metadata":{}`) { + t.Fatalf("explicit {} metadata should survive: %s", got) + } +} + +func TestStringifyDoesNotEscapeHTMLInsideParts(t *testing.T) { + part := TextPart{PartBase: PartBase{ID: "p", SessionID: "s", MessageID: "m"}, Text: "<b>&</b>"} + if got := mustJSON(t, part); !strings.Contains(got, `"<b>&</b>"`) { + t.Fatalf("HTML should not be escaped: %s", got) + } +} + +func TestMarshalForcesTheDiscriminant(t *testing.T) { + // A hand-built value with no Type set must still carry its tag. + if got := mustJSON(t, StepStartPart{}); !strings.Contains(got, `"type":"step-start"`) { + t.Fatalf("step-start tag missing: %s", got) + } + if got := mustJSON(t, ToolStateError{}); !strings.Contains(got, `"status":"error"`) { + t.Fatalf("error status missing: %s", got) + } + if got := mustJSON(t, Assistant{}); !strings.Contains(got, `"role":"assistant"`) { + t.Fatalf("assistant role missing: %s", got) + } +} + +// ── providerMeta ───────────────────────────────────────────────────────── + +func TestProviderMeta(t *testing.T) { + cases := []struct { + name string + in RawObject + want string + }{ + {"absent", nil, ""}, + {"empty object", rawObj(`{}`), ""}, + {"only providerExecuted", rawObj(`{"providerExecuted":true}`), ""}, + {"strips and preserves order", rawObj(`{"zeta":1,"providerExecuted":true,"alpha":2}`), `{"zeta":1,"alpha":2}`}, + {"nothing to strip", rawObj(`{"a":{"b":[1,2]}}`), `{"a":{"b":[1,2]}}`}, + {"non-object", rawObj(`"str"`), ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := providerMeta(tc.in) + if string(got) != tc.want { + t.Fatalf("providerMeta = %q, want %q", got, tc.want) + } + }) + } +} + +// ── doom-loop key ──────────────────────────────────────────────────────── + +func TestSameInputIsStringifyEqualityNotDeepEquality(t *testing.T) { + if !SameInput(rawObj(`{"a":1,"b":2}`), rawObj(`{"a":1, "b":2}`)) { + t.Fatal("insignificant whitespace must not matter") + } + if SameInput(rawObj(`{"a":1,"b":2}`), rawObj(`{"b":2,"a":1}`)) { + t.Fatal("key ORDER is load-bearing: the stored bytes differ, so the guard must not fire") + } + if !SameInput(nil, rawObj(`{}`)) { + t.Fatal("absent input reads as {}") + } +} + +// ── tool-part settlement ───────────────────────────────────────────────── + +func TestPendingToolState(t *testing.T) { + got := mustJSON(t, PendingToolState()) + if got != `{"status":"pending","input":{},"raw":""}` { + t.Fatalf("pending literal: %s", got) + } + if _, ok := PendingToolState().StartTime(); ok { + t.Fatal("pending has no time at all") + } +} + +func TestSpreadAbortedToolStateCarriesPreviousFields(t *testing.T) { + // The spread carries the previous state's fields, so `raw` survives into + // an object ToolStateError does not declare. + got, err := SpreadAbortedToolState(PendingToolState(), 5) + if err != nil { + t.Fatal(err) + } + want := `{"status":"error","input":{},"raw":"","error":"Tool execution aborted","metadata":{"interrupted":true},"time":{"start":5,"end":5}}` + if string(got) != want { + t.Fatalf("\n got %s\nwant %s", got, want) + } + + running := ToolStateRunning{Input: rawObj(`{}`), Title: ptrString("bash"), Time: ToolTimeStart{Start: 3}} + got, err = SpreadAbortedToolState(running, 9) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), `"title":"bash"`) { + t.Fatalf("running title should carry through the spread: %s", got) + } + if !strings.Contains(string(got), `"time":{"start":3,"end":9}`) { + t.Fatalf("time should be overwritten in place: %s", got) + } +} + +func TestSpreadObjectKeyPositions(t *testing.T) { + got := SpreadObject(rawObj(`{"a":1,"b":2}`), + RawField{Key: "b", Value: raw("9")}, + RawField{Key: "c", Value: raw("3")}, + ) + if string(got) != `{"a":1,"b":9,"c":3}` { + t.Fatalf("spread = %s", got) + } + if string(SpreadObject(nil)) != "{}" { + t.Fatal("empty spread should be {}") + } +} + +func TestToolPartProviderExecutedIsTruthyNotStrict(t *testing.T) { + cases := map[string]bool{ + `{"providerExecuted":true}`: true, + `{"providerExecuted":"yes"}`: true, + `{"providerExecuted":1}`: true, + `{"providerExecuted":false}`: false, + `{"providerExecuted":0}`: false, + `{"providerExecuted":""}`: false, + `{"providerExecuted":null}`: false, + `{}`: false, + } + for meta, want := range cases { + part := ToolPart{Metadata: rawObj(meta)} + if got := part.ProviderExecuted(); got != want { + t.Fatalf("%s → %v, want %v", meta, got, want) + } + } +} + +// ── staticToolName ─────────────────────────────────────────────────────── + +func TestStaticToolNamePreservesInternalDashes(t *testing.T) { + cases := map[string]string{ + "tool-bash": "bash", + "tool-multi-word-name": "multi-word-name", + "tool-": "", + "nodash": "", + } + for typ, want := range cases { + if got := staticToolName(typ); got != want { + t.Fatalf("%s → %q, want %q", typ, got, want) + } + } +} + +// ── the synthetic-message seam ──────────────────────────────────────────── + +func TestSetMessageIDFactoryForTesting(t *testing.T) { + restore := SetMessageIDFactoryForTesting(func() string { return "msg_pinned" }) + if messageIDAscending() != "msg_pinned" { + t.Fatal("factory not installed") + } + restore() + if messageIDAscending() == "msg_pinned" { + t.Fatal("restore did not undo the swap") + } +} + +// ── the synthetic attachment message ───────────────────────────────────── + +func TestSupportsMediaInToolResultByProvider(t *testing.T) { + // supportsMediaInToolResult has no @openrouter case, so every media + // attachment on the OpenRouter path is extracted into the synthetic user + // message rather than staying in the tool result. + if supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@openrouter/ai-sdk-provider"}}, "image/png") { + t.Fatal("openrouter must not support media in tool results") + } + if !supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/amazon-bedrock"}}, "image/png") { + t.Fatal("bedrock supports images") + } + if supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/amazon-bedrock"}}, "application/pdf") { + t.Fatal("bedrock does not support pdfs") + } + // The gemini case lowercases first and requires gemini-3 AND not gemini-2. + if !supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/google", ID: "GEMINI-3-PRO"}}, "image/png") { + t.Fatal("gemini-3 is case-insensitive") + } + if supportsMediaInToolResult(Model{API: ModelAPI{Npm: "@ai-sdk/google", ID: "gemini-3-and-gemini-2"}}, "image/png") { + t.Fatal("a gemini-2 substring disqualifies") + } +} + +// ── FilterCompacted returns values the caller may mutate ────────────────── + +func TestFilterCompactedDoesNotAliasTheInputSlice(t *testing.T) { + in := []WithParts{ + {Info: User{MessageBase: MessageBase{ID: "u2"}}, Parts: Parts{}}, + {Info: User{MessageBase: MessageBase{ID: "u1"}}, Parts: Parts{}}, + } + out := FilterCompacted(in) + if len(out) != 2 || out[0].Info.MessageID() != "u1" || out[1].Info.MessageID() != "u2" { + t.Fatalf("expected chronological order, got %v", []string{out[0].Info.MessageID(), out[1].Info.MessageID()}) + } + if in[0].Info.MessageID() != "u2" { + t.Fatal("FilterCompacted must not reverse the caller's slice in place") + } +} + +// ── ToModelMessages seam smoke test ─────────────────────────────────────── + +func TestToModelMessagesIsMediaClassification(t *testing.T) { + for mime, want := range map[string]bool{ + "image/png": true, + "image/svg+xml": true, + "application/pdf": true, + "text/plain": false, + "application/x-directory": false, + } { + if got := IsMedia(mime); got != want { + t.Fatalf("IsMedia(%q) = %v", mime, got) + } + } +} + +func TestUnknownUnionTagsAreErrors(t *testing.T) { + if _, err := UnmarshalPart([]byte(`{"type":"nope"}`)); err == nil { + t.Fatal("expected an error for an unknown part type") + } + if _, err := UnmarshalToolState([]byte(`{"status":"nope"}`)); err == nil { + t.Fatal("expected an error for an unknown tool status") + } + if _, err := UnmarshalInfo([]byte(`{"role":"tool"}`)); err == nil { + t.Fatal("expected an error for an unknown message role") + } +} + +func TestAssistantErrorConstructors(t *testing.T) { + if got := mustJSON(t, NewMessageAbortedError("stopped")); got != `{"name":"MessageAbortedError","data":{"message":"stopped"}}` { + t.Fatalf("aborted: %s", got) + } + if got := mustJSON(t, NewMessageOutputLengthError()); got != `{"name":"MessageOutputLengthError","data":{}}` { + t.Fatalf("output length: %s", got) + } + api := NewAPIError(APIError{Message: "boom", StatusCode: ptrU64(429), IsRetryable: true, ResponseBody: ptrString(`{"e":1}`)}) + want := `{"name":"APIError","data":{"message":"boom","statusCode":429,"isRetryable":true,"responseBody":"{\"e\":1}"}}` + if got := mustJSON(t, api); got != want { + t.Fatalf("api:\n got %s\nwant %s", got, want) + } + if api.IsAborted() { + t.Fatal("APIError must not report as an abort") + } + aborted := NewMessageAbortedError("x") + if !aborted.IsAborted() { + t.Fatal("MessageAbortedError must report as an abort") + } + var nilErr *AssistantError + if nilErr.IsAborted() { + t.Fatal("nil error is not an abort") + } +} + +func TestSummaryAndBoolPointerHelpers(t *testing.T) { + if boolValue(nil) || !boolValue(ptrBool(true)) || boolValue(ptrBool(false)) { + t.Fatal("boolValue") + } +} + +// `upstream` on a step-finish part is present only when the provider reported +// an endpoint; a record without one marshals without the key. +func TestStepFinishUpstreamIsOptionalAndRoundTrips(t *testing.T) { + const withUpstream = `{"id":"p","sessionID":"s","messageID":"m","type":"step-finish","reason":"stop","cost":0,"tokens":{"input":10,"output":1,"reasoning":0,"cache":{"read":0,"write":0}},"upstream":"provider-b"}` + part, err := UnmarshalPart([]byte(withUpstream)) + if err != nil { + t.Fatal(err) + } + finish, ok := part.(StepFinishPart) + if !ok || finish.Upstream != "provider-b" { + t.Fatalf("decoded part = %#v", part) + } + if got := mustJSON(t, part); got != withUpstream { + t.Fatalf("step-finish with upstream changed shape:\n got %s\nwant %s", got, withUpstream) + } + finish.Upstream = "" + if got := mustJSON(t, finish); strings.Contains(got, "upstream") { + t.Fatalf("an unreported upstream must not be serialized: %s", got) + } +} diff --git a/internal/seniordev/engine/msgmodel/openrouter_inband.go b/internal/seniordev/engine/msgmodel/openrouter_inband.go new file mode 100644 index 0000000000..8896e31a61 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/openrouter_inband.go @@ -0,0 +1,58 @@ +//go:build !windows + +package msgmodel + +import "encoding/json" + +// OpenRouter reports some provider failures in-band: a chunk whose `error` +// field is a bare object like +// +// {"code":502,"message":"Network connection lost.", +// "metadata":{"error_type":"provider_unavailable"}} +// +// -- numeric `code`, no {"type":"error"} envelope, no nested `error` object. +// ParseStreamError cannot see it (it requires the envelope, with a string +// code), and when the same payload arrives wrapped in a Go error the +// `value.(error)` branch in FromError returns UnknownError before any parser +// runs. Either way the classification would not be an APIError, the run's +// structured classifier could not see a retryable provider failure, and one +// transient 502 would end the whole run. +// +// This recognizer classifies the shape; it deliberately sets no retry policy. +// StatusCode is carried through so the run layer can apply its bounded policy: +// transient statuses get a fresh turn while permanent 4xx errors fail fast. +func openRouterInBandAPIError(raw []byte) *APIError { + if len(raw) == 0 || !json.Valid(raw) { + return nil + } + var probe struct { + Code *float64 `json:"code"` + Message *string `json:"message"` + Metadata json.RawMessage `json:"metadata"` + // A {"type":...} or nested {"error":...} envelope means this is not + // the bare in-band shape; leave those to ParseStreamError. + Type *string `json:"type"` + Error json.RawMessage `json:"error"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil + } + if probe.Code == nil || probe.Message == nil || probe.Type != nil || len(probe.Error) > 0 { + return nil + } + code := *probe.Code + if code != float64(uint64(code)) || code < 100 || code > 599 { + return nil + } + status := uint64(code) + body := string(raw) + result := &APIError{ + Message: *probe.Message, + StatusCode: &status, + ResponseBody: &body, + } + if len(probe.Metadata) > 0 { + result.Metadata = RawObject(probe.Metadata) + } + return result +} diff --git a/internal/seniordev/engine/msgmodel/parts.go b/internal/seniordev/engine/msgmodel/parts.go new file mode 100644 index 0000000000..b20e0bc93f --- /dev/null +++ b/internal/seniordev/engine/msgmodel/parts.go @@ -0,0 +1,243 @@ +//go:build !windows + +package msgmodel + +import ( + "encoding/json" + "fmt" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Part is the seven-variant part union, discriminated on `type`. PartBase is +// embedded first in every variant, so id/sessionID/messageID lead the JSON. +type Part interface { + // PartType is the `type` discriminant. + PartType() string + // PartBase returns the shared {id, sessionID, messageID}. + Base() PartBase + json.Marshaler +} + +// tagged re-asserts a union discriminant on marshal, then encodes without +// HTML escaping so `<`, `>` and `&` inside a part stay readable. +func tagged(v any) ([]byte, error) { return jsonutil.Marshal(v) } + +// ── text ───────────────────────────────────────── + +type TextPart struct { + PartBase + Type string `json:"type"` + Text string `json:"text"` + Synthetic *bool `json:"synthetic,omitempty"` + Ignored *bool `json:"ignored,omitempty"` + Time *TimeStartEnd `json:"time,omitempty"` + Metadata RawObject `json:"metadata,omitempty"` +} + +func (p TextPart) PartType() string { return PartTypeText } +func (p TextPart) Base() PartBase { return p.PartBase } +func (p TextPart) MarshalJSON() ([]byte, error) { + type alias TextPart + p.Type = PartTypeText + return tagged(alias(p)) +} + +// ── reasoning ──────────────────────────────────── +// +// `time` is REQUIRED here, unlike TextPart's optional one. + +type ReasoningPart struct { + PartBase + Type string `json:"type"` + Text string `json:"text"` + Metadata RawObject `json:"metadata,omitempty"` + Time TimeStartEnd `json:"time"` +} + +func (p ReasoningPart) PartType() string { return PartTypeReasoning } +func (p ReasoningPart) Base() PartBase { return p.PartBase } +func (p ReasoningPart) MarshalJSON() ([]byte, error) { + type alias ReasoningPart + p.Type = PartTypeReasoning + return tagged(alias(p)) +} + +// ── file ───────────────────────────────────────── + +type FilePart struct { + PartBase + Type string `json:"type"` + Mime string `json:"mime"` + Filename *string `json:"filename,omitempty"` + URL string `json:"url"` + Source *FilePartSource `json:"source,omitempty"` +} + +func (p FilePart) PartType() string { return PartTypeFile } +func (p FilePart) Base() PartBase { return p.PartBase } +func (p FilePart) MarshalJSON() ([]byte, error) { + type alias FilePart + p.Type = PartTypeFile + return tagged(alias(p)) +} + +// ── tool ───────────────────────────────────────── + +type ToolPart struct { + PartBase + Type string `json:"type"` + CallID string `json:"callID"` + Tool string `json:"tool"` + State ToolState `json:"state"` + Metadata RawObject `json:"metadata,omitempty"` +} + +func (p ToolPart) PartType() string { return PartTypeTool } +func (p ToolPart) Base() PartBase { return p.PartBase } +func (p ToolPart) MarshalJSON() ([]byte, error) { + type alias ToolPart + p.Type = PartTypeTool + return tagged(alias(p)) +} + +// ProviderExecuted reads the one key of ToolPart.metadata that has a read +// path. It is a truthiness test, not a strict `true` comparison. +func (p ToolPart) ProviderExecuted() bool { return p.Metadata.Truthy("providerExecuted") } + +// ── step-start ─────────────────────────────────── + +type StepStartPart struct { + PartBase + Type string `json:"type"` + Snapshot *string `json:"snapshot,omitempty"` +} + +func (p StepStartPart) PartType() string { return PartTypeStepStart } +func (p StepStartPart) Base() PartBase { return p.PartBase } +func (p StepStartPart) MarshalJSON() ([]byte, error) { + type alias StepStartPart + p.Type = PartTypeStepStart + return tagged(alias(p)) +} + +// ── step-finish ────────────────────────────────── + +type StepFinishPart struct { + PartBase + Type string `json:"type"` + Reason string `json:"reason"` + Snapshot *string `json:"snapshot,omitempty"` + Cost float64 `json:"cost"` + Tokens Tokens `json:"tokens"` + // Upstream is the endpoint OpenRouter reports as having served the call + // (its response `provider` field). Cache-miss attribution needs to know + // when successive calls changed endpoint, and the wire already says so. + // Absent when the provider never reported one. + Upstream string `json:"upstream,omitempty"` +} + +func (p StepFinishPart) PartType() string { return PartTypeStepFinish } +func (p StepFinishPart) Base() PartBase { return p.PartBase } +func (p StepFinishPart) MarshalJSON() ([]byte, error) { + type alias StepFinishPart + p.Type = PartTypeStepFinish + return tagged(alias(p)) +} + +// ── compaction ─────────────────────────────────── + +type CompactionPart struct { + PartBase + Type string `json:"type"` + Auto bool `json:"auto"` + Overflow *bool `json:"overflow,omitempty"` + TailStartID *string `json:"tail_start_id,omitempty"` +} + +func (p CompactionPart) PartType() string { return PartTypeCompaction } +func (p CompactionPart) Base() PartBase { return p.PartBase } +func (p CompactionPart) MarshalJSON() ([]byte, error) { + type alias CompactionPart + p.Type = PartTypeCompaction + return tagged(alias(p)) +} + +// ── union decode ───────────────────────────────────────────────────────── + +// UnmarshalPart dispatches on `type`. +func UnmarshalPart(raw []byte) (Part, error) { + var probe struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, err + } + var target any + switch probe.Type { + case PartTypeText: + target = new(TextPart) + case PartTypeReasoning: + target = new(ReasoningPart) + case PartTypeFile: + target = new(FilePart) + case PartTypeTool: + target = new(ToolPart) + case PartTypeStepStart: + target = new(StepStartPart) + case PartTypeStepFinish: + target = new(StepFinishPart) + case PartTypeCompaction: + target = new(CompactionPart) + default: + return nil, fmt.Errorf("msgmodel: unknown part type %q", probe.Type) + } + if err := json.Unmarshal(raw, target); err != nil { + return nil, err + } + switch p := target.(type) { + case *TextPart: + return *p, nil + case *ReasoningPart: + return *p, nil + case *FilePart: + return *p, nil + case *ToolPart: + return *p, nil + case *StepStartPart: + return *p, nil + case *StepFinishPart: + return *p, nil + case *CompactionPart: + return *p, nil + } + return nil, fmt.Errorf("msgmodel: unknown part type %q", probe.Type) +} + +// Parts is `Part[]` with union-aware decoding. +type Parts []Part + +// MarshalJSON keeps a nil slice as `[]`; `parts` is a required array. +func (ps Parts) MarshalJSON() ([]byte, error) { + if ps == nil { + return []byte("[]"), nil + } + return jsonutil.Marshal([]Part(ps)) +} + +func (ps *Parts) UnmarshalJSON(b []byte) error { + var raws []json.RawMessage + if err := json.Unmarshal(b, &raws); err != nil { + return err + } + out := make(Parts, 0, len(raws)) + for _, raw := range raws { + p, err := UnmarshalPart(raw) + if err != nil { + return err + } + out = append(out, p) + } + *ps = out + return nil +} diff --git a/internal/seniordev/engine/msgmodel/rawobject.go b/internal/seniordev/engine/msgmodel/rawobject.go new file mode 100644 index 0000000000..fd99a78970 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/rawobject.go @@ -0,0 +1,260 @@ +//go:build !windows + +package msgmodel + +import ( + "bytes" + "encoding/json" + "io" + "strconv" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// RawObject is a JSON object kept as the verbatim bytes it arrived as. Never +// decode one into map[string]any: Go sorts map keys on re-marshal, and the +// doom-loop guard compares tool inputs byte for byte, key order included. +// +// A zero-length RawObject is an absent value. On an OPTIONAL field (tagged +// `omitempty`) that means the key is omitted; on a REQUIRED field it marshals +// as `{}`, which is what the processor writes for an empty input/metadata. +type RawObject json.RawMessage + +func (r RawObject) MarshalJSON() ([]byte, error) { + if len(r) == 0 { + return []byte("{}"), nil + } + return []byte(r), nil +} + +func (r *RawObject) UnmarshalJSON(b []byte) error { + *r = RawObject(append([]byte(nil), b...)) + return nil +} + +// Raw returns the underlying bytes, or nil when the value was absent: the +// reading for an OPTIONAL field. +func (r RawObject) Raw() json.RawMessage { + if len(r) == 0 { + return nil + } + return json.RawMessage(r) +} + +// Value returns `{}` for an absent value: the reading for a REQUIRED field +// (`input`, ToolStateCompleted.metadata), which is always at least an empty +// object. +func (r RawObject) Value() json.RawMessage { + if len(r) == 0 { + return json.RawMessage("{}") + } + return json.RawMessage(r) +} + +func trimSpace(b []byte) []byte { return bytes.TrimSpace(b) } + +// arrayElements walks a JSON array at the token level, keeping each element's +// bytes verbatim. ok=false when the value is not an array. +func arrayElements(raw []byte) ([]json.RawMessage, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '[' { + return nil, false + } + var out []json.RawMessage + if err := json.Unmarshal(trimmed, &out); err != nil { + return nil, false + } + return out, true +} + +// RawField is one own property of a JSON object, in source order. +type RawField struct { + Key string + Value json.RawMessage +} + +// objectFields walks a JSON object at the token level so key ORDER survives. +// Returns ok=false when the value is not an object; callers treat that as +// "absent". +func objectFields(raw []byte) ([]RawField, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil, false + } + dec := json.NewDecoder(bytes.NewReader(trimmed)) + dec.UseNumber() + tok, err := dec.Token() + if err != nil { + return nil, false + } + if delim, ok := tok.(json.Delim); !ok || delim != '{' { + return nil, false + } + var out []RawField + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return nil, false + } + key, ok := keyTok.(string) + if !ok { + return nil, false + } + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + return nil, false + } + out = append(out, RawField{Key: key, Value: value}) + } + if _, err := dec.Token(); err != nil { + return nil, false + } + // Reject trailing garbage. + if _, err := dec.Token(); err != io.EOF { + return nil, false + } + return out, true +} + +// Fields returns the object's own properties in insertion order, or nil when +// the value is absent or not an object. +func (r RawObject) Fields() []RawField { + fields, ok := objectFields(r) + if !ok { + return nil + } + return fields +} + +// Field returns the raw value at key, or ok=false when absent (or when the +// receiver is not an object). +func (r RawObject) Field(key string) (json.RawMessage, bool) { + var ( + value json.RawMessage + found bool + ) + // A duplicate key in the source text means the LAST one wins, so scan to + // the end. + for _, f := range r.Fields() { + if f.Key == key { + value, found = f.Value, true + } + } + return value, found +} + +// Truthy applies truthyJSON to the value at key; an absent key is false. +func (r RawObject) Truthy(key string) bool { + value, ok := r.Field(key) + if !ok { + return false + } + return truthyJSON(value) +} + +// StrictTrue reports whether the value at key is exactly `true`. +func (r RawObject) StrictTrue(key string) bool { + value, ok := r.Field(key) + if !ok { + return false + } + return string(bytes.TrimSpace(value)) == "true" +} + +// StringField returns the value at key when it is a string. +func (r RawObject) StringField(key string) (string, bool) { + value, ok := r.Field(key) + if !ok { + return "", false + } + var s string + if err := json.Unmarshal(value, &s); err != nil { + return "", false + } + return s, true +} + +// truthyJSON is the truthiness rule for a JSON value: objects and arrays are +// always truthy; "" / 0 / -0 / false / null are not. +func truthyJSON(raw []byte) bool { + trimmed := bytes.TrimSpace(raw) + switch { + case len(trimmed) == 0: + return false + case string(trimmed) == "null", string(trimmed) == "false": + return false + case string(trimmed) == "true": + return true + case trimmed[0] == '"': + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + return false + } + return s != "" + case trimmed[0] == '{' || trimmed[0] == '[': + return true + } + f, err := strconv.ParseFloat(string(trimmed), 64) + return err == nil && f != 0 +} + +// providerMeta is the tool metadata minus its providerExecuted key, or nil +// when nothing else is there. The surviving keys keep their original order, +// so this rebuilds the object from the token walk instead of decoding into a +// map. +func providerMeta(metadata RawObject) json.RawMessage { + if len(metadata) == 0 { + return nil + } + fields, ok := objectFields(metadata) + if !ok { + // Non-object metadata has no keys to keep. + return nil + } + kept := make([]RawField, 0, len(fields)) + for _, f := range fields { + if f.Key == "providerExecuted" { + continue + } + kept = append(kept, f) + } + if len(kept) == 0 { + return nil + } + var buf bytes.Buffer + buf.WriteByte('{') + for i, f := range kept { + if i > 0 { + buf.WriteByte(',') + } + key, err := jsonutil.Marshal(f.Key) + if err != nil { + return nil + } + buf.Write(key) + buf.WriteByte(':') + buf.Write(f.Value) + } + buf.WriteByte('}') + return json.RawMessage(buf.Bytes()) +} + +// SameInput is the doom-loop equality test: two tool inputs compared as +// stored bytes. Both sides are already verbatim, so this only has to +// normalise insignificant whitespace; key order is deliberately NOT +// normalised. +func SameInput(a, b RawObject) bool { + return bytes.Equal(compactRaw(a), compactRaw(b)) +} + +func compactRaw(r RawObject) []byte { + raw, err := r.MarshalJSON() + if err != nil { + return nil + } + var buf bytes.Buffer + if err := json.Compact(&buf, raw); err != nil { + return raw + } + return buf.Bytes() +} diff --git a/internal/seniordev/engine/msgmodel/settle.go b/internal/seniordev/engine/msgmodel/settle.go new file mode 100644 index 0000000000..3689b18f3f --- /dev/null +++ b/internal/seniordev/engine/msgmodel/settle.go @@ -0,0 +1,124 @@ +//go:build !windows + +package msgmodel + +import ( + "bytes" + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Tool-part assembly for the stream processor's settlement path. Everything +// here is pure: the in-flight tool-call registry belongs to the step loop's +// processor. + +// Fixed strings the settlement path writes. ToolAbortedError is replayed to +// the model as the tool's errorText; ToolInterruptedError is what a pending or +// running tool replays as. +const ( + ToolAbortedError = "Tool execution aborted" + ToolInterruptedError = "[Tool execution was interrupted]" + ToolCompactedOutput = "[Old tool result content cleared]" +) + +// PendingToolState is `{status:"pending", input:{}, raw:""}`. +func PendingToolState() ToolStatePending { + return ToolStatePending{Status: ToolStatusPending, Input: RawObject("{}"), Raw: ""} +} + +// CompletedToolState is the typed `completed` state. The processor writes its +// keys in the order status, input, output, metadata, title, time, attachments; +// this struct keeps the declared order (title before metadata). +func CompletedToolState(input RawObject, output, title string, metadata RawObject, start, end uint64, attachments *[]FilePart) ToolStateCompleted { + return ToolStateCompleted{ + Status: ToolStatusCompleted, + Input: input, + Output: output, + Title: title, + Metadata: metadata, + Time: ToolTimeCompleted{Start: start, End: end}, + Attachments: attachments, + } +} + +// SpreadAbortedToolState is the cleanup drain's force-write: `status:"error"`, +// `error:"Tool execution aborted"`, `metadata:{...existing, interrupted:true}` +// and `time.start` taken from the previous state, or `now` when the state has +// none (`pending` does not). It is a spread over the previous state, +// `{...state, status, error, metadata, time}`, so a `pending` state's +// `raw` and a `running` state's `title` survive into an object ToolStateError +// does not declare. Returned as raw JSON because those fields have no typed +// home. +func SpreadAbortedToolState(prev ToolState, now uint64) (json.RawMessage, error) { + start := now + if s, ok := prev.StartTime(); ok { + start = s + } + var existing RawObject + if prev != nil && IsRecord(prev.ToolMetadata()) { + existing = prev.ToolMetadata() + } + base, err := stateObject(prev) + if err != nil { + return nil, err + } + timeRaw, err := jsonutil.Marshal(ToolTimeSpan{Start: start, End: now}) + if err != nil { + return nil, err + } + return SpreadObject(base, + RawField{Key: "status", Value: jsonString(ToolStatusError)}, + RawField{Key: "error", Value: jsonString(ToolAbortedError)}, + RawField{Key: "metadata", Value: json.RawMessage(MergeInterrupted(existing))}, + RawField{Key: "time", Value: timeRaw}, + ), nil +} + +// SpreadToolState is `{...prev, ...overrides}` for any transition: the +// tool-call → running step as well as the cleanup drain. +func SpreadToolState(prev ToolState, overrides ...RawField) (json.RawMessage, error) { + base, err := stateObject(prev) + if err != nil { + return nil, err + } + return SpreadObject(base, overrides...), nil +} + +func stateObject(prev ToolState) (RawObject, error) { + if prev == nil { + return nil, nil + } + raw, err := jsonutil.Marshal(prev) + if err != nil { + return nil, err + } + return RawObject(raw), nil +} + +// MergeInterrupted is `{...metadata, interrupted: true}`. A pre-existing +// `interrupted` key keeps its original position. +func MergeInterrupted(metadata RawObject) RawObject { + return RawObject(SpreadObject(metadata, RawField{Key: "interrupted", Value: json.RawMessage("true")})) +} + +// SpreadObject is the object spread `{...base, k1: v1, k2: v2}`: an +// overridden key keeps the position it had in base and takes the new value; a +// new key is appended in the order given. Key order is load-bearing: the +// doom-loop guard compares the stored bytes verbatim. +func SpreadObject(base RawObject, overrides ...RawField) json.RawMessage { + fields := append([]RawField(nil), base.Fields()...) + for _, o := range overrides { + fields = upsertField(fields, o) + } + if len(fields) == 0 { + return json.RawMessage("{}") + } + return json.RawMessage(encodeFields(fields)) +} + +// IsRecord reports a JSON object: neither null nor an array. +func IsRecord(v RawObject) bool { + t := bytes.TrimSpace(v) + return len(t) > 0 && t[0] == '{' +} diff --git a/internal/seniordev/engine/msgmodel/storage.go b/internal/seniordev/engine/msgmodel/storage.go new file mode 100644 index 0000000000..9a25c34da5 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/storage.go @@ -0,0 +1,160 @@ +//go:build !windows + +// Storage functions. Query construction is behind Store; Page does the +// descending (time,id) pagination with a one-row lookahead, hydration, and +// per-page reversal. Stream returns newest-first order. +package msgmodel + +import ( + "context" + "errors" + "fmt" +) + +var ErrNotFound = errors.New("msgmodel: not found") + +type MessageRecord struct { + Info Info + TimeCreated float64 +} + +// Store methods must return messages in descending (time_created,id) order +// and parts in ascending (message_id,id) order. +type Store interface { + SelectMessages(ctx context.Context, sessionID string, limit int, before *Cursor) ([]MessageRecord, error) + SessionExists(ctx context.Context, sessionID string) (bool, error) + SelectParts(ctx context.Context, messageIDs []string) (Parts, error) + SelectMessage(ctx context.Context, sessionID, messageID string) (MessageRecord, bool, error) + SelectMessageParts(ctx context.Context, messageID string) (Parts, error) +} + +type PageInput struct { + SessionID string + Limit int + Before *string +} + +type PageResult struct { + Items []WithParts `json:"items"` + More bool `json:"more"` + Cursor *string `json:"cursor,omitempty"` +} + +func Page(ctx context.Context, store Store, input PageInput) (PageResult, error) { + var before *Cursor + if input.Before != nil && *input.Before != "" { + decoded, err := DecodeCursor(*input.Before) + if err != nil { + return PageResult{}, err + } + before = &decoded + } + rows, err := store.SelectMessages(ctx, input.SessionID, input.Limit+1, before) + if err != nil { + return PageResult{}, err + } + if len(rows) == 0 { + ok, err := store.SessionExists(ctx, input.SessionID) + if err != nil { + return PageResult{}, err + } + if !ok { + return PageResult{}, fmt.Errorf("%w: Session not found: %s", ErrNotFound, input.SessionID) + } + return PageResult{Items: []WithParts{}, More: false}, nil + } + + more := len(rows) > input.Limit + slice := rows + if more { + slice = rows[:input.Limit] + } + items, err := hydrateRecords(ctx, store, slice) + if err != nil { + return PageResult{}, err + } + reverseWithParts(items) + result := PageResult{Items: items, More: more} + if more && len(slice) > 0 { + tail := slice[len(slice)-1] + encoded, err := EncodeCursor(Cursor{ID: tail.Info.MessageID(), Time: float64(tail.TimeCreated)}) + if err != nil { + return PageResult{}, err + } + result.Cursor = &encoded + } + return result, nil +} + +func Stream(ctx context.Context, store Store, sessionID string) ([]WithParts, error) { + const size = 50 + var before *string + result := []WithParts{} + for { + next, err := Page(ctx, store, PageInput{SessionID: sessionID, Limit: size, Before: before}) + if err != nil { + return nil, err + } + if len(next.Items) == 0 { + break + } + for i := len(next.Items) - 1; i >= 0; i-- { + result = append(result, next.Items[i]) + } + if !next.More || next.Cursor == nil { + break + } + before = next.Cursor + } + return result, nil +} + +func MessageParts(ctx context.Context, store Store, messageID string) (Parts, error) { + return store.SelectMessageParts(ctx, messageID) +} + +func Get(ctx context.Context, store Store, sessionID, messageID string) (WithParts, error) { + row, ok, err := store.SelectMessage(ctx, sessionID, messageID) + if err != nil { + return WithParts{}, err + } + if !ok { + return WithParts{}, fmt.Errorf("%w: Message not found: %s", ErrNotFound, messageID) + } + parts, err := store.SelectMessageParts(ctx, messageID) + if err != nil { + return WithParts{}, err + } + return WithParts{Info: row.Info, Parts: nonnilParts(parts)}, nil +} + +func hydrateRecords(ctx context.Context, store Store, rows []MessageRecord) ([]WithParts, error) { + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.Info.MessageID()) + } + parts, err := store.SelectParts(ctx, ids) + if err != nil { + return nil, err + } + byMessage := make(map[string]Parts, len(ids)) + for _, part := range parts { + base := part.Base() + byMessage[base.MessageID] = append(byMessage[base.MessageID], part) + } + out := make([]WithParts, 0, len(rows)) + for _, row := range rows { + out = append(out, WithParts{ + Info: row.Info, + Parts: nonnilParts(byMessage[row.Info.MessageID()]), + }) + } + return out, nil +} + +func nonnilParts(parts Parts) Parts { + if parts == nil { + return Parts{} + } + return parts +} diff --git a/internal/seniordev/engine/msgmodel/storage_test.go b/internal/seniordev/engine/msgmodel/storage_test.go new file mode 100644 index 0000000000..6fc8933fe5 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/storage_test.go @@ -0,0 +1,199 @@ +//go:build !windows + +package msgmodel + +import ( + "context" + "errors" + "fmt" + "sort" + "testing" +) + +type memoryMessageStore struct { + sessions map[string]bool + records []MessageRecord + parts Parts +} + +func (s *memoryMessageStore) SelectMessages( + _ context.Context, sessionID string, limit int, before *Cursor, +) ([]MessageRecord, error) { + rows := []MessageRecord{} + for _, row := range s.records { + if infoSessionID(row.Info) != sessionID { + continue + } + if before != nil { + time := row.TimeCreated + if !(time < float64(before.Time) || + (time == float64(before.Time) && row.Info.MessageID() < before.ID)) { + continue + } + } + rows = append(rows, row) + } + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].TimeCreated != rows[j].TimeCreated { + return rows[i].TimeCreated > rows[j].TimeCreated + } + return rows[i].Info.MessageID() > rows[j].Info.MessageID() + }) + if limit < len(rows) { + rows = rows[:limit] + } + return rows, nil +} + +func (s *memoryMessageStore) SessionExists(_ context.Context, sessionID string) (bool, error) { + return s.sessions[sessionID], nil +} + +func (s *memoryMessageStore) SelectParts(_ context.Context, messageIDs []string) (Parts, error) { + wanted := map[string]bool{} + for _, id := range messageIDs { + wanted[id] = true + } + out := Parts{} + for _, part := range s.parts { + if wanted[part.Base().MessageID] { + out = append(out, part) + } + } + sort.SliceStable(out, func(i, j int) bool { + a, b := out[i].Base(), out[j].Base() + if a.MessageID != b.MessageID { + return a.MessageID < b.MessageID + } + return a.ID < b.ID + }) + return out, nil +} + +func (s *memoryMessageStore) SelectMessage( + _ context.Context, sessionID, messageID string, +) (MessageRecord, bool, error) { + for _, row := range s.records { + if row.Info.MessageID() == messageID && infoSessionID(row.Info) == sessionID { + return row, true, nil + } + } + return MessageRecord{}, false, nil +} + +func (s *memoryMessageStore) SelectMessageParts(_ context.Context, messageID string) (Parts, error) { + return s.SelectParts(context.Background(), []string{messageID}) +} + +func infoSessionID(info Info) string { + switch value := info.(type) { + case User: + return value.SessionID + case Assistant: + return value.SessionID + default: + return "" + } +} + +func storedUser(id string, created float64) MessageRecord { + return MessageRecord{ + Info: User{ + MessageBase: MessageBase{ID: id, SessionID: "ses_1"}, + Time: TimeCreated{Created: uint64(created)}, + Agent: "build", + Model: UserModel{ProviderID: "openrouter", ModelID: "m"}, + }, + TimeCreated: created, + } +} + +func TestPageHydratesAndPaginatesByTimeThenID(t *testing.T) { + store := &memoryMessageStore{ + sessions: map[string]bool{"ses_1": true}, + records: []MessageRecord{ + storedUser("m1", 1), storedUser("m4", 2), + storedUser("m2", 1), storedUser("m3", 2), + }, + parts: Parts{ + TextPart{PartBase: PartBase{ID: "p4b", SessionID: "ses_1", MessageID: "m4"}, Text: "b"}, + TextPart{PartBase: PartBase{ID: "p3", SessionID: "ses_1", MessageID: "m3"}, Text: "three"}, + TextPart{PartBase: PartBase{ID: "p4a", SessionID: "ses_1", MessageID: "m4"}, Text: "a"}, + }, + } + first, err := Page(context.Background(), store, PageInput{SessionID: "ses_1", Limit: 2}) + if err != nil { + t.Fatal(err) + } + if got := messageIDs(first.Items); fmt.Sprint(got) != "[m3 m4]" { + t.Fatalf("first page order = %v", got) + } + if !first.More || first.Cursor == nil { + t.Fatalf("first page cursor/more = %#v", first) + } + if len(first.Items[0].Parts) != 1 || len(first.Items[1].Parts) != 2 { + t.Fatalf("hydrated parts = %#v", first.Items) + } + if first.Items[1].Parts[0].Base().ID != "p4a" { + t.Fatalf("parts not in id order: %#v", first.Items[1].Parts) + } + + second, err := Page(context.Background(), store, PageInput{ + SessionID: "ses_1", Limit: 2, Before: first.Cursor, + }) + if err != nil { + t.Fatal(err) + } + if got := messageIDs(second.Items); fmt.Sprint(got) != "[m1 m2]" { + t.Fatalf("second page order = %v", got) + } + if second.More || second.Cursor != nil { + t.Fatalf("unexpected second-page continuation: %#v", second) + } +} + +func TestStreamKeepsGeneratorNewestFirstAcrossPages(t *testing.T) { + store := &memoryMessageStore{sessions: map[string]bool{"ses_1": true}} + for i := 1; i <= 53; i++ { + store.records = append(store.records, storedUser(fmt.Sprintf("m%03d", i), float64(i))) + } + got, err := Stream(context.Background(), store, "ses_1") + if err != nil { + t.Fatal(err) + } + if len(got) != 53 || got[0].Info.MessageID() != "m053" || got[52].Info.MessageID() != "m001" { + t.Fatalf("stream order/length: %d %s..%s", len(got), got[0].Info.MessageID(), got[len(got)-1].Info.MessageID()) + } +} + +func TestPageAndGetNotFoundMessages(t *testing.T) { + store := &memoryMessageStore{sessions: map[string]bool{"ses_1": true}} + empty, err := Page(context.Background(), store, PageInput{SessionID: "ses_1", Limit: 5}) + if err != nil || empty.More || len(empty.Items) != 0 || empty.Items == nil { + t.Fatalf("existing empty session = %#v, %v", empty, err) + } + _, err = Page(context.Background(), store, PageInput{SessionID: "missing", Limit: 5}) + if !errors.Is(err, ErrNotFound) || err.Error() != "msgmodel: not found: Session not found: missing" { + t.Fatalf("page missing error = %v", err) + } + _, err = Get(context.Background(), store, "ses_1", "missing") + if !errors.Is(err, ErrNotFound) || err.Error() != "msgmodel: not found: Message not found: missing" { + t.Fatalf("get missing error = %v", err) + } +} + +func messageIDs(items []WithParts) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + out = append(out, item.Info.MessageID()) + } + return out +} + +func TestDecodeCursorRejectsInvalidPayloads(t *testing.T) { + for _, input := range []string{"***", "bnVsbA", "eyJpZCI6Im0iLCJ0aW1lIjotMX0"} { + if _, err := DecodeCursor(input); err == nil { + t.Errorf("DecodeCursor(%q) unexpectedly succeeded", input) + } + } +} diff --git a/internal/seniordev/engine/msgmodel/tomodelmessages.go b/internal/seniordev/engine/msgmodel/tomodelmessages.go new file mode 100644 index 0000000000..bb6005e92c --- /dev/null +++ b/internal/seniordev/engine/msgmodel/tomodelmessages.go @@ -0,0 +1,485 @@ +//go:build !windows + +package msgmodel + +import ( + "strconv" + "strings" + "sync/atomic" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// ToModelOptions tune the conversion. Both are pointers: an absent StripMedia +// is false, and an absent or non-positive ToolOutputMaxChars leaves outputs +// untruncated. +type ToModelOptions struct { + StripMedia *bool + ToolOutputMaxChars *float64 +} + +func (o *ToModelOptions) stripMedia() bool { + return o != nil && o.StripMedia != nil && *o.StripMedia +} + +func (o *ToModelOptions) toolOutputMaxChars() *float64 { + if o == nil { + return nil + } + return o.ToolOutputMaxChars +} + +// messageIDAscending mints the id of the synthetic "Attached media from tool +// result:" UIMessage. ConvertToModelMessages drops that id, so nothing +// observable depends on the value; the default is a package-local counter. +var messageIDAscending = defaultMessageIDAscending + +var syntheticMessageCounter atomic.Uint64 + +func defaultMessageIDAscending() string { + return "msg_synthetic_" + strconv.FormatFloat(float64(syntheticMessageCounter.Add(1)), 'f', -1, 64) +} + +// SetMessageIDFactoryForTesting swaps the synthetic-message id source and +// returns a restore func. +func SetMessageIDFactoryForTesting(f func() string) func() { + prev := messageIDAscending + messageIDAscending = f + return func() { messageIDAscending = prev } +} + +// IsMedia reports whether a mime type is an image or a PDF. +func IsMedia(mime string) bool { + return strings.HasPrefix(mime, "image/") || mime == "application/pdf" +} + +// DifferentModel is a plain string comparison of "<providerID>/<modelID>" +// between the model about to be called and the model that produced the +// historical turn. When they differ, that turn's provider-specific metadata +// is stripped below. +func DifferentModel(model Model, msg Assistant) bool { + return model.ProviderID+"/"+model.ID != msg.ProviderID+"/"+msg.ModelID +} + +// supportsMediaInToolResult reports whether the provider SDK accepts media +// inside a tool result. No case matches the OpenRouter provider, so on that +// path tool-result media always reaches the model through the synthetic +// attachment message instead. +func supportsMediaInToolResult(model Model, mime string) bool { + switch model.API.Npm { + case "@ai-sdk/anthropic": + return true + case "@ai-sdk/openai": + return true + case "@ai-sdk/amazon-bedrock": + return strings.HasPrefix(mime, "image/") + case "@ai-sdk/google-vertex/anthropic": + return true + case "@ai-sdk/google": + id := strings.ToLower(model.API.ID) + return strings.Contains(id, "gemini-3") && !strings.Contains(id, "gemini-2") + } + return false +} + +// TruncateToolOutput keeps the first maxChars characters of a tool output and +// appends a marker naming how many were dropped. A nil or non-positive limit +// leaves the text alone. +func TruncateToolOutput(text string, maxChars *float64) string { + if maxChars == nil || !(*maxChars > 0) { + return text + } + limit := int(*maxChars) + runes := []rune(text) + if len(runes) <= limit { + return text + } + omitted := len(runes) - limit + return string(runes[:limit]) + "\n[Tool output truncated for compaction: omitted " + strconv.Itoa(omitted) + " chars]" +} + +// toModelOutput is the output converter handed to ConvertToModelMessages for +// every tool name seen, regardless of that tool's state. +func toModelOutput(_ string, _ RawValue, output RawValue) ToolOutput { + if s, ok := asJSONString(output); ok { + return ToolOutput{Type: "text", Value: s} + } + // Object-ish covers arrays and null too, but senior-dev only ever hands it + // the {text, attachments} shape or a string. + if isJSONObjectish(output) { + obj := RawObject(output) + text, _ := obj.StringField("text") + value := []any{} + if text != "" { + value = append(value, ToolOutputContentText{Type: "text", Text: text}) + } + for _, att := range attachmentList(obj) { + url, _ := att.StringField("url") + if !strings.HasPrefix(url, "data:") || !strings.Contains(url, ",") { + continue + } + mime, _ := att.StringField("mime") + value = append(value, ToolOutputContentMedia{ + Type: "media", + MediaType: mime, + Data: afterFirstComma(url), + }) + } + return ToolOutput{Type: "content", Value: value} + } + return ToolOutput{Type: "json", Value: toJSONValue(output)} +} + +// afterFirstComma is the payload of a data: URL; the whole url when there is +// no comma at all. +func afterFirstComma(url string) string { + i := strings.Index(url, ",") + if i == -1 { + return url + } + return url[i+1:] +} + +// ToModelMessages converts stored messages into the model-facing message +// list. +func ToModelMessages(input []WithParts, model Model, options *ToModelOptions) ([]ModelMessage, error) { + result := []UIMessage{} + // Tool names in first-seen order, deduplicated. + var toolNames []string + seenTool := map[string]bool{} + + for _, msg := range input { + if len(msg.Parts) == 0 { + continue + } + + if user, ok := msg.Info.(User); ok { + userMessage := UIMessage{ID: user.ID, Role: "user", Parts: []UIPart{}} + for _, raw := range msg.Parts { + // The three checks below are independent, not an else-if chain. + if part, ok := raw.(TextPart); ok { + if !boolValue(part.Ignored) && part.Text != "" { + userMessage.Parts = append(userMessage.Parts, UIPart{Type: "text", Text: part.Text}) + } + } + if part, ok := raw.(FilePart); ok { + if part.Mime != "text/plain" && part.Mime != "application/x-directory" { + if options.stripMedia() && IsMedia(part.Mime) { + name := "file" + if part.Filename != nil { + name = *part.Filename + } + userMessage.Parts = append(userMessage.Parts, UIPart{ + Type: "text", + Text: "[Attached " + part.Mime + ": " + name + "]", + }) + } else { + userMessage.Parts = append(userMessage.Parts, UIPart{ + Type: "file", + URL: part.URL, + MediaType: part.Mime, + Filename: optionalStringValue(part.Filename), + }) + } + } + } + if _, ok := raw.(CompactionPart); ok { + userMessage.Parts = append(userMessage.Parts, UIPart{Type: "text", Text: "What did we do so far?"}) + } + } + if len(userMessage.Parts) > 0 { + result = append(result, userMessage) + } + } + + assistant, isAssistant := msg.Info.(Assistant) + if !isAssistant { + continue + } + + differentModel := DifferentModel(model, assistant) + var media []mediaAttachment + + // Drop the whole message on any error UNLESS it is a + // MessageAbortedError and at least one part is neither step-start nor + // reasoning. + if assistant.Error != nil { + hasSubstantivePart := false + for _, raw := range msg.Parts { + if raw.PartType() != PartTypeStepStart && raw.PartType() != PartTypeReasoning { + hasSubstantivePart = true + break + } + } + if !(assistant.Error.IsAborted() && hasSubstantivePart) { + continue + } + } + + assistantMessage := UIMessage{ID: assistant.ID, Role: "assistant", Parts: []UIPart{}} + + // Anthropic adaptive thinking can persist an empty text + // part as a structural separator between signed reasoning blocks; + // replay it as a single space so it survives the SDK's empty-text + // filter. + hasSignedReasoning := false + for _, raw := range msg.Parts { + part, ok := raw.(ReasoningPart) + if !ok { + continue + } + anthropic, ok := part.Metadata.Field("anthropic") + if !ok { + continue + } + if signature, ok := RawObject(anthropic).Field("signature"); ok && !isJSONNull(signature) { + hasSignedReasoning = true + break + } + } + + for _, raw := range msg.Parts { + switch part := raw.(type) { + case TextPart: + text := part.Text + if text == "" && hasSignedReasoning { + text = " " + } + ui := UIPart{Type: "text", Text: text} + if !differentModel { + ui.ProviderMetadata = part.Metadata.Raw() + } + assistantMessage.Parts = append(assistantMessage.Parts, ui) + + case StepStartPart: + assistantMessage.Parts = append(assistantMessage.Parts, UIPart{Type: "step-start"}) + + case ToolPart: + if !seenTool[part.Tool] { + seenTool[part.Tool] = true + toolNames = append(toolNames, part.Tool) + } + providerExecuted := part.ProviderExecuted() + callMeta := providerMeta(part.Metadata) + + switch state := part.State.(type) { + case ToolStateCompleted: + // `time.compacted` is read for TRUTHINESS, so + // a stored 0 behaves as "not compacted". + compacted := state.Time.Compacted != nil && *state.Time.Compacted != 0 + outputText := "[Old tool result content cleared]" + if !compacted { + outputText = TruncateToolOutput(state.Output, options.toolOutputMaxChars()) + } + var attachments []FilePart + if !compacted && !options.stripMedia() && state.Attachments != nil { + attachments = *state.Attachments + } + + var finalAttachments []FilePart + for _, a := range attachments { + if IsMedia(a.Mime) && !supportsMediaInToolResult(model, a.Mime) { + media = append(media, mediaAttachment{Mime: a.Mime, URL: a.URL, Filename: a.Filename}) + } + if !IsMedia(a.Mime) || supportsMediaInToolResult(model, a.Mime) { + finalAttachments = append(finalAttachments, a) + } + } + + output := jsonString(outputText) + if len(finalAttachments) > 0 { + output = encodeToolOutputObject(outputText, finalAttachments) + } + + ui := UIPart{ + Type: "tool-" + part.Tool, + State: UIToolOutputAvailable, + ToolCallID: part.CallID, + Input: state.Input.Value(), + Output: output, + } + if providerExecuted { + ui.ProviderExecuted = jsonTrue + } + if !differentModel { + ui.CallProviderMetadata = callMeta + } + assistantMessage.Parts = append(assistantMessage.Parts, ui) + + case ToolStateError: + // Only an `interrupted === true` metadata bag can carry a + // replayable output; the cleanup drain never writes + // `metadata.output`, so in practice this lands on the + // output-error branch. + var replay string + replayable := false + if state.Metadata.StrictTrue("interrupted") { + if s, ok := state.Metadata.StringField("output"); ok { + replay, replayable = s, true + } + } + ui := UIPart{ + Type: "tool-" + part.Tool, + ToolCallID: part.CallID, + Input: state.Input.Value(), + } + if replayable { + ui.State = UIToolOutputAvailable + ui.Output = jsonString(replay) + } else { + ui.State = UIToolOutputError + ui.ErrorText = state.Error + } + if providerExecuted { + ui.ProviderExecuted = jsonTrue + } + if !differentModel { + ui.CallProviderMetadata = callMeta + } + assistantMessage.Parts = append(assistantMessage.Parts, ui) + + case ToolStatePending, ToolStateRunning: + // Pending/running replay as an error so no + // tool_use block is left dangling. + ui := UIPart{ + Type: "tool-" + part.Tool, + State: UIToolOutputError, + ToolCallID: part.CallID, + Input: part.State.ToolInput().Value(), + ErrorText: "[Tool execution was interrupted]", + } + if providerExecuted { + ui.ProviderExecuted = jsonTrue + } + if !differentModel { + ui.CallProviderMetadata = callMeta + } + assistantMessage.Parts = append(assistantMessage.Parts, ui) + _ = state + } + + case ReasoningPart: + if differentModel { + // Downgrade to text, or DROP the part entirely + // when it trims to nothing. + if strings.TrimSpace(part.Text) != "" { + assistantMessage.Parts = append(assistantMessage.Parts, UIPart{Type: "text", Text: part.Text}) + } + continue + } + assistantMessage.Parts = append(assistantMessage.Parts, UIPart{ + Type: "reasoning", + Text: part.Text, + // part.metadata passes straight through, NOT via + // providerMeta(), unlike every tool branch above. + ProviderMetadata: part.Metadata.Raw(), + }) + } + } + + if len(assistantMessage.Parts) > 0 { + result = append(result, assistantMessage) + if len(media) > 0 { + // The synthetic user message carrying the extracted media. + parts := []UIPart{{Type: "text", Text: SyntheticAttachmentPrompt}} + for _, a := range media { + parts = append(parts, UIPart{ + Type: "file", + URL: a.URL, + MediaType: a.Mime, + Filename: optionalStringValue(a.Filename), + }) + } + result = append(result, UIMessage{ID: messageIDAscending(), Role: "user", Parts: parts}) + } + } + } + + tools := make(map[string]ToolModelOutputFn, len(toolNames)) + for _, name := range toolNames { + tools[name] = toModelOutput + } + + // Drop any UIMessage whose parts are ALL step-start. + filtered := make([]UIMessage, 0, len(result)) + for _, msg := range result { + keep := false + for _, part := range msg.Parts { + if part.Type != "step-start" { + keep = true + break + } + } + if keep { + filtered = append(filtered, msg) + } + } + + return ConvertToModelMessages(filtered, &ConvertOptions{Tools: tools}) +} + +// ── small helpers ──────────────────────────────────────────────────────── + +type mediaAttachment struct { + Mime string + URL string + Filename *string +} + +var jsonTrue = RawValue("true") + +func boolValue(b *bool) bool { return b != nil && *b } + +func optionalStringValue(s *string) RawValue { + if s == nil { + return nil + } + return jsonString(*s) +} + +func isJSONNull(raw []byte) bool { + return len(raw) == 0 || string(trimSpace(raw)) == "null" +} + +// isJSONObjectish reports an object, an array or null. An array falls through +// harmlessly (no text, no attachments) and null yields +// `{type:"content", value:[]}`; the only callers set `output` to a string or +// to `{text, attachments}`, so neither branch is reached. +func isJSONObjectish(raw []byte) bool { + t := trimSpace(raw) + if len(t) == 0 { + return false + } + return t[0] == '{' || t[0] == '[' || string(t) == "null" +} + +func attachmentList(obj RawObject) []RawObject { + raw, ok := obj.Field("attachments") + if !ok || isJSONNull(raw) { + return nil + } + items, ok := arrayElements(raw) + if !ok { + return nil + } + out := make([]RawObject, 0, len(items)) + for _, item := range items { + out = append(out, RawObject(item)) + } + return out +} + +// encodeToolOutputObject builds `{text, attachments}` with the attachment +// parts kept verbatim, so the FilePart bytes that reach toModelOutput are the +// stored ones. +func encodeToolOutputObject(text string, attachments []FilePart) RawValue { + payload := struct { + Text string `json:"text"` + Attachments []FilePart `json:"attachments"` + }{Text: text, Attachments: attachments} + raw, err := jsonutil.Marshal(payload) + if err != nil { + return nil + } + return raw +} diff --git a/internal/seniordev/engine/msgmodel/toolstate.go b/internal/seniordev/engine/msgmodel/toolstate.go new file mode 100644 index 0000000000..bb46495e4a --- /dev/null +++ b/internal/seniordev/engine/msgmodel/toolstate.go @@ -0,0 +1,189 @@ +//go:build !windows + +package msgmodel + +import ( + "encoding/json" + "fmt" +) + +// ToolState is the four-variant tool state union, discriminated on `status`. +// The field sets differ: `pending` has no `time` at all, which is why the +// cleanup drain falls back to `now`. +type ToolState interface { + ToolStatus() string + // Input is `Record<string, any>`, present on every variant. + ToolInput() RawObject + // ToolMetadata is the variant's `metadata`, or a zero RawObject when the + // variant has none (pending). + ToolMetadata() RawObject + // StartTime is `state.time.start`; ok=false for `pending`. + StartTime() (uint64, bool) + json.Marshaler +} + +// ── pending ────────────────────────────────────── + +type ToolStatePending struct { + Status string `json:"status"` + Input RawObject `json:"input"` + Raw string `json:"raw"` +} + +func (s ToolStatePending) ToolStatus() string { return ToolStatusPending } +func (s ToolStatePending) ToolInput() RawObject { return s.Input } +func (s ToolStatePending) ToolMetadata() RawObject { return nil } +func (s ToolStatePending) StartTime() (uint64, bool) { return 0, false } +func (s ToolStatePending) MarshalJSON() ([]byte, error) { + type alias ToolStatePending + s.Status = ToolStatusPending + return tagged(alias(s)) +} + +// ── running ────────────────────────────────────── + +// ToolTimeStart is ToolStateRunning.time. +type ToolTimeStart struct { + Start uint64 `json:"start"` +} + +type ToolStateRunning struct { + Status string `json:"status"` + Input RawObject `json:"input"` + Title *string `json:"title,omitempty"` + Metadata RawObject `json:"metadata,omitempty"` + Time ToolTimeStart `json:"time"` +} + +func (s ToolStateRunning) ToolStatus() string { return ToolStatusRunning } +func (s ToolStateRunning) ToolInput() RawObject { return s.Input } +func (s ToolStateRunning) ToolMetadata() RawObject { return s.Metadata } +func (s ToolStateRunning) StartTime() (uint64, bool) { return s.Time.Start, true } +func (s ToolStateRunning) MarshalJSON() ([]byte, error) { + type alias ToolStateRunning + s.Status = ToolStatusRunning + return tagged(alias(s)) +} + +// ── completed ──────────────────────────────────── +// +// `title` and `metadata` are REQUIRED here, unlike every other variant. + +// ToolTimeCompleted is ToolStateCompleted.time. +type ToolTimeCompleted struct { + Start uint64 `json:"start"` + End uint64 `json:"end"` + Compacted *uint64 `json:"compacted,omitempty"` +} + +type ToolStateCompleted struct { + Status string `json:"status"` + Input RawObject `json:"input"` + Output string `json:"output"` + Title string `json:"title"` + Metadata RawObject `json:"metadata"` + Time ToolTimeCompleted `json:"time"` + Attachments *[]FilePart `json:"attachments,omitempty"` +} + +func (s ToolStateCompleted) ToolStatus() string { return ToolStatusCompleted } +func (s ToolStateCompleted) ToolInput() RawObject { return s.Input } +func (s ToolStateCompleted) ToolMetadata() RawObject { return s.Metadata } +func (s ToolStateCompleted) StartTime() (uint64, bool) { return s.Time.Start, true } +func (s ToolStateCompleted) MarshalJSON() ([]byte, error) { + type alias ToolStateCompleted + s.Status = ToolStatusCompleted + return tagged(alias(s)) +} + +// ── error ──────────────────────────────────────── + +// ToolTimeSpan is ToolStateError.time. +type ToolTimeSpan struct { + Start uint64 `json:"start"` + End uint64 `json:"end"` +} + +type ToolStateError struct { + Status string `json:"status"` + Input RawObject `json:"input"` + Error string `json:"error"` + Metadata RawObject `json:"metadata,omitempty"` + Time ToolTimeSpan `json:"time"` +} + +func (s ToolStateError) ToolStatus() string { return ToolStatusError } +func (s ToolStateError) ToolInput() RawObject { return s.Input } +func (s ToolStateError) ToolMetadata() RawObject { return s.Metadata } +func (s ToolStateError) StartTime() (uint64, bool) { return s.Time.Start, true } +func (s ToolStateError) MarshalJSON() ([]byte, error) { + type alias ToolStateError + s.Status = ToolStatusError + return tagged(alias(s)) +} + +// ── union decode ───────────────────────────────────────────────────────── + +// UnmarshalToolState dispatches on `status`. +func UnmarshalToolState(raw []byte) (ToolState, error) { + var probe struct { + Status string `json:"status"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, err + } + switch probe.Status { + case ToolStatusPending: + var s ToolStatePending + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + return s, nil + case ToolStatusRunning: + var s ToolStateRunning + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + return s, nil + case ToolStatusCompleted: + var s ToolStateCompleted + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + return s, nil + case ToolStatusError: + var s ToolStateError + if err := json.Unmarshal(raw, &s); err != nil { + return nil, err + } + return s, nil + } + return nil, fmt.Errorf("msgmodel: unknown tool state status %q", probe.Status) +} + +// UnmarshalJSON on ToolPart has to route `state` through the union decoder. +func (p *ToolPart) UnmarshalJSON(b []byte) error { + type alias struct { + PartBase + Type string `json:"type"` + CallID string `json:"callID"` + Tool string `json:"tool"` + State json.RawMessage `json:"state"` + Metadata RawObject `json:"metadata"` + } + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + state, err := UnmarshalToolState(a.State) + if err != nil { + return err + } + p.PartBase = a.PartBase + p.Type = a.Type + p.CallID = a.CallID + p.Tool = a.Tool + p.State = state + p.Metadata = a.Metadata + return nil +} diff --git a/internal/seniordev/engine/msgmodel/uimessage.go b/internal/seniordev/engine/msgmodel/uimessage.go new file mode 100644 index 0000000000..fbaded5929 --- /dev/null +++ b/internal/seniordev/engine/msgmodel/uimessage.go @@ -0,0 +1,173 @@ +//go:build !windows + +package msgmodel + +import "encoding/json" + +// UIMessage / UIPart are the intermediate value ToModelMessages builds before +// handing it to ConvertToModelMessages. +// +// A UIPart is ONE flat struct rather than a Go union: the parts are +// duck-typed on their `type` string, and the tool discriminant is a DYNAMIC +// string `"tool-" + toolName`, so an interface would buy nothing. UIMessages +// are never serialised as an output, so field order here is documentation +// rather than contract. +type UIMessage struct { + ID string `json:"id"` + Role string `json:"role"` + Parts []UIPart `json:"parts"` +} + +type UIPart struct { + Type string `json:"type"` + + // text / reasoning + Text string `json:"text,omitempty"` + + // text / file / reasoning + ProviderMetadata RawValue `json:"providerMetadata,omitempty"` + + // file + MediaType string `json:"mediaType,omitempty"` + Filename RawValue `json:"filename,omitempty"` + URL string `json:"url,omitempty"` + + // dynamic-tool + ToolName string `json:"toolName,omitempty"` + + // tool-* / dynamic-tool + ToolCallID string `json:"toolCallId,omitempty"` + State string `json:"state,omitempty"` + Input RawValue `json:"input,omitempty"` + RawInput RawValue `json:"rawInput,omitempty"` + Output RawValue `json:"output,omitempty"` + ErrorText string `json:"errorText,omitempty"` + ProviderExecuted RawValue `json:"providerExecuted,omitempty"` + CallProviderMetadata RawValue `json:"callProviderMetadata,omitempty"` + ResultProviderMetadata RawValue `json:"resultProviderMetadata,omitempty"` +} + +// UI part `state` values. +const ( + UIToolInputStreaming = "input-streaming" + UIToolInputAvailable = "input-available" + UIToolOutputAvailable = "output-available" + UIToolOutputError = "output-error" +) + +// Part-kind predicates over the `type` string. +func (p UIPart) isStaticTool() bool { return len(p.Type) >= 5 && p.Type[:5] == "tool-" } +func (p UIPart) isDynamicTool() bool { return p.Type == "dynamic-tool" } +func (p UIPart) isTool() bool { return p.isStaticTool() || p.isDynamicTool() } +func (p UIPart) isData() bool { return len(p.Type) >= 5 && p.Type[:5] == "data-" } +func (p UIPart) isText() bool { return p.Type == "text" } +func (p UIPart) isFile() bool { return p.Type == "file" } +func (p UIPart) isReasoning() bool { return p.Type == "reasoning" } + +// ResolveToolName returns the tool a part refers to: a dynamic part carries +// the name; a static part's name is everything after the first dash of its +// type, which preserves every internal dash. That is load-bearing because the +// type is built as `"tool-" + part.tool` and tool names contain dashes. +func (p UIPart) ResolveToolName() string { + if p.isDynamicTool() { + return p.ToolName + } + return staticToolName(p.Type) +} + +func staticToolName(typ string) string { + // Everything after the first "-", and "" when there is no "-" at all. + for i := 0; i < len(typ); i++ { + if typ[i] == '-' { + return typ[i+1:] + } + } + return "" +} + +// ── ModelMessage (the convertToModelMessages output) ───────────────────── + +// ModelMessage is one entry of the `ModelMessage[]` handed to the provider. +// `Content` is a string for `role:"system"` and a content-part slice +// otherwise. +type ModelMessage struct { + Role string `json:"role"` + Content any `json:"content"` + ProviderOptions RawValue `json:"providerOptions,omitempty"` +} + +// The content-part structs below declare fields in the order they reach the +// wire body. + +// TextContent is a text content part. +type TextContent struct { + Type string `json:"type"` + Text string `json:"text"` + ProviderOptions RawValue `json:"providerOptions,omitempty"` +} + +// FileContent is a file content part. +type FileContent struct { + Type string `json:"type"` + MediaType string `json:"mediaType"` + Filename RawValue `json:"filename,omitempty"` + Data string `json:"data"` + ProviderOptions RawValue `json:"providerOptions,omitempty"` +} + +// ReasoningContent is a reasoning content part. `providerOptions` is copied +// unconditionally (contrast text/file); an absent value is dropped by +// `omitempty`. +type ReasoningContent struct { + Type string `json:"type"` + Text string `json:"text"` + ProviderOptions RawValue `json:"providerOptions,omitempty"` +} + +// ToolCallContent is a tool-call content part. +type ToolCallContent struct { + Type string `json:"type"` + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Input RawValue `json:"input,omitempty"` + ProviderExecuted RawValue `json:"providerExecuted,omitempty"` + ProviderOptions RawValue `json:"providerOptions,omitempty"` +} + +// ToolResultContent is a tool-result content part. +type ToolResultContent struct { + Type string `json:"type"` + ToolCallID string `json:"toolCallId"` + ToolName string `json:"toolName"` + Output ToolOutput `json:"output"` + ProviderOptions RawValue `json:"providerOptions,omitempty"` +} + +// ToolOutput is a tool result as the model sees it: one of +// text / json / error-text / error-json / content. +type ToolOutput struct { + Type string `json:"type"` + Value any `json:"value"` +} + +// ToolOutputContentText / ToolOutputContentMedia are the two element shapes +// toModelOutput emits inside `{type:"content"}`. +type ToolOutputContentText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type ToolOutputContentMedia struct { + Type string `json:"type"` + MediaType string `json:"mediaType"` + Data string `json:"data"` +} + +// MessageConversionError is the only error ConvertToModelMessages raises. +type MessageConversionError struct { + Message string +} + +func (e *MessageConversionError) Error() string { return e.Message } + +var _ json.Marshaler = Parts(nil) diff --git a/internal/seniordev/engine/msgmodel/usertext.go b/internal/seniordev/engine/msgmodel/usertext.go new file mode 100644 index 0000000000..bed401327a --- /dev/null +++ b/internal/seniordev/engine/msgmodel/usertext.go @@ -0,0 +1,18 @@ +//go:build !windows + +package msgmodel + +// UserText builds a plain-text user message in the one content shape the wire +// converter accepts: a []any list holding a single TextContent, which is what +// ConvertToModelMessages emits for the coder's own prompt. +// +// The shape is easy to get wrong: a typed []TextContent slice fails the +// converter's `msg.Content.([]any)` assertion. Every hand-built user message +// must come from here, and the converter rejects any other shape instead of +// sending an empty turn. +func UserText(text string) ModelMessage { + return ModelMessage{ + Role: "user", + Content: []any{TextContent{Type: "text", Text: text}}, + } +} diff --git a/internal/seniordev/engine/orclient/body.go b/internal/seniordev/engine/orclient/body.go new file mode 100644 index 0000000000..5472350bdf --- /dev/null +++ b/internal/seniordev/engine/orclient/body.go @@ -0,0 +1,332 @@ +//go:build !windows + +package orclient + +// Request assembly: the chat-completions body and the header set. +// +// The body is the client's own fields (model, sampling, messages, tools), +// then the merged provider option bag from config applied on top as a +// shallow spread, then the streaming flags. The option bag is an unvalidated +// whole-body override: anything config puts there replaces the matching +// top-level field. stream_options is emitted only in strict compatibility +// mode; senior-dev runs in compatible mode. + +import ( + "bytes" + "encoding/json" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// Compatibility modes. +const ( + CompatibilityCompatible = "compatible" + CompatibilityStrict = "strict" +) + +// Tool is one registered tool as the request sees it. InputSchema is the JSON +// Schema the provider receives. +type Tool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` + ProviderOptions json.RawMessage `json:"providerOptions,omitempty"` +} + +// ToolChoice constrains which tool the model may call. senior-dev sends +// `{"type":"required"}` only for a json_schema output format; otherwise nil. +type ToolChoice struct { + Type string `json:"type"` + ToolName string `json:"toolName,omitempty"` +} + +// RequestParams are the inputs to one request. +type RequestParams struct { + // ModelID is the full `<vendor>/<name>` OpenRouter model id. + ModelID string + + Prompt []msgmodel.ModelMessage + MaxOutputTokens *float64 + + // Sampling parameters. A nil field is omitted from the body, so the + // serving provider's default applies; the caller decides what to set. + Temperature *float64 + TopP *float64 + TopK *float64 + MinP *float64 + Seed *float64 + FrequencyPenalty *float64 + PresencePenalty *float64 + RepetitionPenalty *float64 + + Tools []Tool + ToolChoice *ToolChoice + + // OpenRouterOptions is the merged provider option bag from config (base, + // model, agent and variant options, in that order). `cacheControl` is + // split out of it before the spread. + OpenRouterOptions *Object + + // Compatibility selects whether `stream_options` is emitted. + Compatibility string +} + +// BuildRequestBody returns the bytes POSTed to /chat/completions: the model and +// sampling parameters, the converted messages, the tool definitions, then any +// provider options from config (which may override a base field), then the +// streaming flags. +func BuildRequestBody(p RequestParams) ([]byte, error) { + body, err := baseArgs(p) + if err != nil { + return nil, err + } + + // Provider options are applied last with shallow-spread semantics. A + // cacheControl entry is renamed to the wire field cache_control unless the + // options already carry one. + var cacheControl json.RawMessage + if p.OpenRouterOptions != nil { + for _, m := range p.OpenRouterOptions.members { + if m.Key == "cacheControl" { + raw, err := marshalJSONValue(m.Value) + if err != nil { + return nil, err + } + cacheControl = raw + continue + } + body.set(m.Key, m.Value) + } + } + if cacheControl != nil && (p.OpenRouterOptions == nil || !p.OpenRouterOptions.Has("cache_control")) { + if err := body.Set("cache_control", cacheControl); err != nil { + return nil, err + } + } + + body.SetBool("stream", true) + if p.Compatibility == CompatibilityStrict { + streamOptions := NewObject() + streamOptions.SetBool("include_usage", true) + body.SetObject("stream_options", streamOptions) + } + return body.MarshalJSON() +} + +// baseArgs writes the request fields this client sets itself. A nil sampling +// parameter is omitted so the serving provider's default applies. +func baseArgs(p RequestParams) (*Object, error) { + base := NewObject() + base.SetString("model", p.ModelID) + base.SetNumberPtr("max_tokens", p.MaxOutputTokens) + base.SetNumberPtr("temperature", p.Temperature) + base.SetNumberPtr("top_p", p.TopP) + base.SetNumberPtr("frequency_penalty", p.FrequencyPenalty) + base.SetNumberPtr("presence_penalty", p.PresencePenalty) + base.SetNumberPtr("seed", p.Seed) + base.SetNumberPtr("top_k", p.TopK) + base.SetNumberPtr("min_p", p.MinP) + base.SetNumberPtr("repetition_penalty", p.RepetitionPenalty) + + messages, err := ConvertToOpenRouterChatMessages(p.Prompt) + if err != nil { + return nil, err + } + base.SetArray("messages", messages) + + if len(p.Tools) == 0 { + return base, nil + } + mapped := make([]*Object, 0, len(p.Tools)) + for _, tool := range p.Tools { + if tool.Type != "function" { + continue + } + entry := NewObject() + entry.SetString("type", "function") + fn := NewObject() + fn.SetString("name", tool.Name) + fn.SetString("description", tool.Description) + if len(tool.InputSchema) > 0 { + if err := fn.Set("parameters", tool.InputSchema); err != nil { + return nil, err + } + } + entry.SetObject("function", fn) + if eager, ok := openrouterNamespaceField(tool.ProviderOptions, "eager_input_streaming"); ok && !bytes.Equal(eager, []byte("null")) { + if err := entry.Set("eager_input_streaming", eager); err != nil { + return nil, err + } + } + mapped = append(mapped, entry) + } + base.SetArray("tools", mapped) + if p.ToolChoice != nil { + choice, err := chatCompletionToolChoice(*p.ToolChoice) + if err != nil { + return nil, err + } + base.set("tool_choice", choice) + } + return base, nil +} + +// chatCompletionToolChoice maps a ToolChoice to the wire `tool_choice` value. +func chatCompletionToolChoice(tc ToolChoice) (jsonValue, error) { + switch tc.Type { + case "auto", "none", "required": + return stringValue(tc.Type), nil + case "tool": + o := NewObject() + o.SetString("type", "function") + fn := NewObject() + fn.SetString("name", tc.ToolName) + o.SetObject("function", fn) + return o.value(), nil + } + rendered := NewObject() + rendered.SetString("type", tc.Type) + if tc.ToolName != "" { + rendered.SetString("toolName", tc.ToolName) + } + encoded, _ := rendered.MarshalJSON() + return jsonValue{}, &InvalidArgumentError{ + Argument: "toolChoice", + Message: "Invalid tool choice type: " + string(encoded), + } +} + +// InvalidArgumentError reports an unusable request parameter. +type InvalidArgumentError struct { + Argument string + Message string +} + +func (e *InvalidArgumentError) Error() string { return e.Message } + +// ── headers ─────────────────────────────────────────────────────────────── + +// HeaderPair is one final header, lowercase-named. +type HeaderPair struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// HeaderInputs are the header contributors, in the order they are combined. +type HeaderInputs struct { + // Provider is the provider-level header set before its user-agent + // suffix: Authorization, X-OpenRouter-Title, HTTP-Referer, then the + // configured provider headers (HTTP-Referer + X-Title). + Provider []HeaderPair + // ProviderUserAgentSuffix is appended to the user agent first. + ProviderUserAgentSuffix string + // Call is the per-call header set. + Call []HeaderPair + // UtilsUserAgentSuffix / RuntimeUserAgentSuffix are appended to the user + // agent last. + UtilsUserAgentSuffix string + RuntimeUserAgentSuffix string +} + +// BuildHeaders reproduces the whole merge, which is worth doing as one function +// because the precedence is genuinely surprising: `X-Title` and +// `X-OpenRouter-Title` are BOTH on the wire, `HTTP-Referer` is set three times +// with the call-level value winning, and `User-Agent`/`user-agent` collide only +// after normalizeHeaders lowercases them. +// +// The pipeline is: +// +// provider = withUserAgentSuffix(providerHeaders, providerSuffix) +// combined = {...provider, ...call} // case-SENSITIVE spread +// withType = {"Content-Type": "application/json", ...combined} +// final = withUserAgentSuffix(withType, utilsSuffix, runtimeSuffix) +// +// withUserAgentSuffix lowercases every name, joins the non-empty user-agent +// parts with a space, and returns the pairs sorted by name. +func BuildHeaders(in HeaderInputs) []HeaderPair { + provider := withUserAgentSuffix(in.Provider, in.ProviderUserAgentSuffix) + + combined := append([]HeaderPair{}, provider...) + combined = spreadHeaders(combined, in.Call) + + withType := append([]HeaderPair{{Name: "Content-Type", Value: "application/json"}}, nil...) + withType = spreadHeaders(withType, combined) + + return withUserAgentSuffix(withType, in.UtilsUserAgentSuffix, in.RuntimeUserAgentSuffix) +} + +// spreadHeaders is the `{...a, ...b}` object spread: case-SENSITIVE, later +// wins, new keys appended. +func spreadHeaders(target, source []HeaderPair) []HeaderPair { + out := append([]HeaderPair{}, target...) + for _, s := range source { + replaced := false + for i := range out { + if out[i].Name == s.Name { + out[i].Value = s.Value + replaced = true + break + } + } + if !replaced { + out = append(out, s) + } + } + return out +} + +func withUserAgentSuffix(headers []HeaderPair, suffixes ...string) []HeaderPair { + normalized := []HeaderPair{} + for _, h := range headers { + lower := strings.ToLower(h.Name) + replaced := false + for i := range normalized { + if normalized[i].Name == lower { + normalized[i].Value = h.Value + replaced = true + break + } + } + if !replaced { + normalized = append(normalized, HeaderPair{Name: lower, Value: h.Value}) + } + } + + current := "" + for _, h := range normalized { + if h.Name == "user-agent" { + current = h.Value + break + } + } + parts := make([]string, 0, len(suffixes)+1) + if current != "" { + parts = append(parts, current) + } + for _, s := range suffixes { + if s != "" { + parts = append(parts, s) + } + } + ua := strings.Join(parts, " ") + + set := false + for i := range normalized { + if normalized[i].Name == "user-agent" { + normalized[i].Value = ua + set = true + break + } + } + if !set { + normalized = append(normalized, HeaderPair{Name: "user-agent", Value: ua}) + } + + sort.SliceStable(normalized, func(i, j int) bool { + return normalized[i].Name < normalized[j].Name + }) + return normalized +} diff --git a/internal/seniordev/engine/orclient/cancellation_test.go b/internal/seniordev/engine/orclient/cancellation_test.go new file mode 100644 index 0000000000..676368c64f --- /dev/null +++ b/internal/seniordev/engine/orclient/cancellation_test.go @@ -0,0 +1,148 @@ +//go:build !windows + +package orclient + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestCallerCancellationNeutralBeforeHeadersAndOnUndrainedClose(t *testing.T) { + for _, closeOnly := range []bool{false, true} { + t.Run(map[bool]string{false: "before-headers", true: "undrained-close"}[closeOnly], func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + cause := errors.New("caller gave up") + router := &spyRouter{inflight: 1} + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + if !closeOnly { + cancel(cause) + return nil, context.Cause(req.Context()) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil + }} + stream, err := client.DoStream(ctx, RequestParams{ModelID: "test"}) + if closeOnly { + if err != nil { + t.Fatal(err) + } + cancel(cause) + if err = stream.Close(); err != nil { + t.Fatal(err) + } + if err = stream.Close(); err != nil { + t.Fatal(err) + } + } else if !errors.Is(err, cause) { + t.Fatalf("err=%v", err) + } + if router.canceled != 1 || len(router.calls) != 0 || router.inflight != 0 { + t.Fatalf("canceled=%d calls=%d inflight=%d", router.canceled, len(router.calls), router.inflight) + } + }) + } +} + +func TestProviderFailureIsNotHiddenByLaterCallerCancellation(t *testing.T) { + for _, watchdog := range []bool{false, true} { + t.Run(map[bool]string{false: "http-502", true: "watchdog-first"}[watchdog], func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + router := &spyRouter{inflight: 1} + var fire func() + t.Cleanup(SetTimerFactoryForTesting(func(_ float64, fn func()) Timer { fire = fn; return &cancellationTestTimer{} })) + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + if watchdog { + fire() + cancel(errors.New("caller gave up")) + return nil, context.Cause(req.Context()) + } + cancel(errors.New("caller gave up")) + return &http.Response{StatusCode: 502, Body: io.NopCloser(strings.NewReader("provider unavailable"))}, nil + }} + if _, err := client.DoStream(ctx, RequestParams{ModelID: "test"}); err == nil { + t.Fatal("provider failure suppressed") + } + if router.canceled != 0 || len(router.calls) != 1 || router.calls[0].err == nil || router.inflight != 0 { + t.Fatalf("canceled=%d calls=%+v inflight=%d", router.canceled, router.calls, router.inflight) + } + if watchdog && !adaptive.IsLikelyTimeout(router.calls[0].err) { + t.Fatal("watchdog timeout taxonomy changed") + } + }) + } +} + +type cancellationTestTimer struct{} + +func (*cancellationTestTimer) Stop() {} + +func TestCompletedSuccessWinsOverLaterCancellation(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + router := &spyRouter{inflight: 1} + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n"))}, nil + }} + stream, err := client.DoStream(ctx, RequestParams{ModelID: "test"}) + if err != nil { + t.Fatal(err) + } + if _, err = stream.Parts(); err != nil { + t.Fatal(err) + } + cancel(errors.New("caller gave up")) + if err = stream.Close(); err != nil { + t.Fatal(err) + } + if err = stream.Close(); err != nil { + t.Fatal(err) + } + if router.canceled != 0 || len(router.calls) != 1 || router.calls[0].err != nil || router.inflight != 0 { + t.Fatalf("completed success reclassified: canceled=%d calls=%+v inflight=%d", router.canceled, router.calls, router.inflight) + } +} + +func TestActualParentDeadlineAndMidstreamAbortAreNeutral(t *testing.T) { + for _, midstream := range []bool{false, true} { + t.Run(map[bool]string{false: "deadline-before-headers", true: "deadline-in-stream"}[midstream], func(t *testing.T) { + ctx, cancel := context.WithTimeoutCause(context.Background(), 10*time.Millisecond, errors.New("caller deadline")) + defer cancel() + router := &spyRouter{inflight: 1} + client := &Client{BaseURL: testBaseURL, Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(req *http.Request) (*http.Response, error) { + if midstream { + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil + } + <-req.Context().Done() + return nil, req.Context().Err() + }} + stream, err := client.DoStream(ctx, RequestParams{ModelID: "test"}) + if midstream { + if err != nil { + t.Fatal(err) + } + <-ctx.Done() + parts, readErr := stream.Parts() + if readErr != nil || len(parts) != 1 { + t.Fatalf("parts=%+v err=%v", parts, readErr) + } + if _, ok := parts[0].(AbortPart); !ok { + t.Fatalf("part=%T", parts[0]) + } + _ = stream.Close() + } else if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err=%v", err) + } + if router.canceled != 1 || len(router.calls) != 0 || router.inflight != 0 { + t.Fatalf("canceled=%d calls=%d inflight=%d", router.canceled, len(router.calls), router.inflight) + } + }) + } +} diff --git a/internal/seniordev/engine/orclient/client.go b/internal/seniordev/engine/orclient/client.go new file mode 100644 index 0000000000..f1f33bd751 --- /dev/null +++ b/internal/seniordev/engine/orclient/client.go @@ -0,0 +1,711 @@ +//go:build !windows + +package orclient + +// One HTTP request, one stream. +// +// ── the four abort layers ───────────────────────────────────────────────── +// +// A model may stream useful reasoning for longer than ten minutes. Elapsed +// request age is therefore not evidence that it has stalled. senior-dev leaves the +// total-request deadline disabled by default and composes cancellation through +// the caller context plus a progress-sensitive reader watchdog: +// +// callerCtx ← run lifetime +// └─ ctxTotal = callerCtx ← default +// or context.WithTimeout(callerCtx, configured duration) ← explicit opt-in +// └─ ctxChunk = context.WithCancelCause(ctxTotal) ← inactivity +// └─ req = req.WithContext(ctxChunk) +// └─ fetcher may derive its own context +// +// - The caller context is the authoritative lifetime bound: the run can +// cancel every request at once, and Stream.Close cancels an abandoned +// request immediately. +// - TotalTimeoutMS > 0 is an explicit operator override. Its cause remains +// `errors.New("The operation timed out.")`, preserving timeout routing and +// retry classification for configured deployments. +// - The 120 s reader watchdog resets after every read, including reasoning +// tokens and keepalives. It aborts only when transport progress stops, with +// `errors.New("SSE read timed out")`. +// +// ── early teardown must cancel, not Close ──────────────────────────────── +// +// Closing a response body does not by itself cancel an in-flight request, so +// `Stream.Close` cancels the request context FIRST and treats `Body.Close()` +// as best-effort cleanup. That is what lets a stream be abandoned mid-way +// (for example when compaction is needed) without leaking a connection. +// +// ── router registration ────────────────────────────────────────────────── +// +// The route lease taken for a request is settled exactly once: a finished or +// failed stream registers its outcome, a caller-cancelled stream releases the +// lease without health credit, and a stream abandoned through Close before it +// finished is released the same way so the router's in-flight count never +// leaks. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// Timeout defaults. Total request age is not a stall signal; only the +// progress-sensitive reader watchdog is enabled by default. +const ( + // DefaultTimeoutMS disables the total-request deadline. A positive + // TotalTimeoutMS on Client opts back into one. + DefaultTimeoutMS float64 = -1 + // DefaultChunkTimeoutMS is the reader watchdog's inactivity bound. + DefaultChunkTimeoutMS float64 = 120_000 +) + +// errNoModelAPI is a request made with no model API to send it to. +var errNoModelAPI = errors.New("senior-dev has no model API to call: codeaf serves one to every run it starts") + +// Abort cause messages. Both are matched by `adaptive.IsLikelyTimeout` and +// `retrysched.IsTimeoutError`, which is the whole reason they are literals. +var ( + // ErrOperationTimedOut is the total-request deadline's cause. + ErrOperationTimedOut = errors.New("The operation timed out.") + // ErrSSEReadTimedOut is the reader watchdog's cause. + ErrSSEReadTimedOut = errors.New("SSE read timed out") +) + +// ── seams ───────────────────────────────────────────────────────────────── + +// Fetcher performs one HTTP round trip. The default is a plain +// http.DefaultClient; the CLI installs its own configured client per Client. +type Fetcher func(req *http.Request) (*http.Response, error) + +var fetcher Fetcher = http.DefaultClient.Do + +// SetFetcherForTesting swaps the fetch seam. Returns a restore func. +func SetFetcherForTesting(f Fetcher) func() { + seamMu.Lock() + prev := fetcher + fetcher = f + seamMu.Unlock() + return func() { + seamMu.Lock() + fetcher = prev + seamMu.Unlock() + } +} + +func currentFetcher() Fetcher { + seamMu.Lock() + defer seamMu.Unlock() + return fetcher +} + +// Timer / TimerFactory are the reader watchdog's timer seam, so tests can +// drive it on a virtual clock. +type Timer interface{ Stop() } + +type TimerFactory func(ms float64, fn func()) Timer + +// TimeoutContextFactory mirrors context.WithTimeoutCause for the layer-2 +// total-request deadline. Keeping this as a separate seam preserves the +// production context's real Deadline while allowing tests to fire the +// deadline without waiting on wall-clock time. +type TimeoutContextFactory func(context.Context, time.Duration, error) (context.Context, context.CancelFunc) + +type realTimer struct{ t *time.Timer } + +func (r realTimer) Stop() { r.t.Stop() } + +var timerFactory TimerFactory = func(ms float64, fn func()) Timer { + return realTimer{t: time.AfterFunc(time.Duration(ms)*time.Millisecond, fn)} +} + +var timeoutContextFactory TimeoutContextFactory = context.WithTimeoutCause + +// SetTimerFactoryForTesting swaps the watchdog timer. Returns a restore func. +func SetTimerFactoryForTesting(f TimerFactory) func() { + seamMu.Lock() + prev := timerFactory + timerFactory = f + seamMu.Unlock() + return func() { + seamMu.Lock() + timerFactory = prev + seamMu.Unlock() + } +} + +func currentTimerFactory() TimerFactory { + seamMu.Lock() + defer seamMu.Unlock() + return timerFactory +} + +// SetTimeoutContextFactoryForTesting swaps the total-request timeout seam. +// Returns a restore func. +func SetTimeoutContextFactoryForTesting(f TimeoutContextFactory) func() { + seamMu.Lock() + prev := timeoutContextFactory + timeoutContextFactory = f + seamMu.Unlock() + return func() { + seamMu.Lock() + timeoutContextFactory = prev + seamMu.Unlock() + } +} + +func currentTimeoutContextFactory() TimeoutContextFactory { + seamMu.Lock() + defer seamMu.Unlock() + return timeoutContextFactory +} + +// ── router ──────────────────────────────────────────────────────────────── + +// RouterRegistrar is the narrow slice of *adaptive.AdaptiveModelRouter this +// package uses to settle a route lease. +type RouterRegistrar interface { + Register(choice adaptive.RouteChoice, elapsedSeconds, completionTokens float64, err error) adaptive.AdaptiveRouteEvent + RegisterCanceled(choice adaptive.RouteChoice) +} + +// ── client ──────────────────────────────────────────────────────────────── + +// Client is one configured model API: an endpoint that answers in +// OpenRouter's chat-completions shape. +type Client struct { + // BaseURL is the API's OpenAI-style base URL, the one codeaf serves this + // run. It has no default: a client with none has nowhere to send a request, + // and the only road senior-dev has to a model is the one codeaf hands it. + BaseURL string + // Headers is BuildHeaders' output. + Headers []HeaderPair + // Compatibility selects whether `stream_options` is emitted; senior-dev uses + // "compatible". + Compatibility string + + // TotalTimeoutMS / ChunkTimeoutMS control the optional total-request bound + // and the progress-sensitive reader watchdog. Zero means the default; a + // negative value disables the corresponding mechanism. + TotalTimeoutMS float64 + ChunkTimeoutMS float64 + + // Fetcher overrides the package fetch seam for this client only. Embedders + // use it to retain their configured HTTP transport without mutating the + // process-wide testing seam. + Fetcher Fetcher + + // Router and RouteChoice drive the exactly-once registration. Both nil + // means no routing was performed and registration is skipped entirely. + Router RouterRegistrar + RouteChoice *adaptive.RouteChoice +} + +// Stream is one in-flight response. It is NOT safe for concurrent use; the one +// concurrency rule that matters is that Close may be called from another +// goroutine, which is exactly what an early teardown needs. +type Stream struct { + parts []StreamPart + next int + + translator *Translator + decoder *SSEDecoder + body io.ReadCloser + response *http.Response + + ctx context.Context + callerCtx context.Context + cancel context.CancelCauseFunc + stopTot context.CancelFunc + chunkMS float64 + + closeOnce sync.Once + bodyOnce sync.Once + bodyErr error + + closedByAPI atomic.Bool + + watchdogMu sync.Mutex + watchdog Timer + watchdogGeneration atomic.Uint64 + watchdogStopped atomic.Bool + + finished bool + failed error + + registerOnce sync.Once + register func(completionTokens float64, err error) + // release settles the route lease for a stream abandoned before it + // finished: no success, failure or latency sample is attributed. + release func() +} + +// Response exposes the HTTP response (status + headers) for the error taxonomy +// the caller layers on top. The body is owned by the Stream. +func (s *Stream) Response() *http.Response { return s.response } + +// DoStream issues the request and returns a Stream positioned before the first +// part, with the abort layers and the router bookkeeping installed. +func (c *Client) DoStream(ctx context.Context, params RequestParams) (*Stream, error) { + if params.Compatibility == "" { + params.Compatibility = c.Compatibility + } + body, err := BuildRequestBody(params) + if err != nil { + return nil, err + } + + routeStart := currentNow()() + stream := &Stream{ + translator: NewTranslator(), + chunkMS: c.chunkTimeout(), + callerCtx: ctx, + } + stream.register = func(completionTokens float64, failure error) { + if c.Router == nil || c.RouteChoice == nil { + return + } + if stream.callerCanceled(failure) { + c.Router.RegisterCanceled(*c.RouteChoice) + return + } + elapsed := (currentNow()() - routeStart) / 1000 + // Register invokes the configured event hook, so nothing is emitted here. + c.Router.Register(*c.RouteChoice, elapsed, completionTokens, failure) + } + stream.release = func() { + if c.Router != nil && c.RouteChoice != nil { + c.Router.RegisterCanceled(*c.RouteChoice) + } + } + + // Layer 2 total, then layers 2-chunk/3. + ctxTotal := ctx + if total := c.totalTimeout(); total > 0 { + ctxTotal, stream.stopTot = currentTimeoutContextFactory()(ctx, + time.Duration(total)*time.Millisecond, ErrOperationTimedOut) + } + ctxChunk, cancel := context.WithCancelCause(ctxTotal) + stream.ctx = ctxChunk + stream.cancel = cancel + + // THE ROUTE IS CODEAF'S TO SPELL (modelapi.ChatURL): internal/provider is + // the one package a model route may be written in, and senior-dev's calls + // go to the model API codeaf serves this run and nowhere else. A client + // with no API fails here rather than earlier, so the route lease the router + // took for this call is settled on the same path every other failure takes. + var req *http.Request + if strings.TrimSpace(c.BaseURL) == "" { + err = errNoModelAPI + } else { + req, err = http.NewRequestWithContext(ctxChunk, http.MethodPost, modelapi.ChatURL(c.BaseURL), bytes.NewReader(body)) + } + if err != nil { + stream.teardown() + stream.registerOnce.Do(func() { stream.register(0, err) }) + return nil, err + } + for _, h := range c.Headers { + // Assigned directly rather than through Set so the lowercase names + // BuildHeaders produced go out as-is; HTTP header names are + // case-insensitive on the wire. + req.Header[h.Name] = []string{h.Value} + } + + // Start the inactivity clock before dispatch so a connection that never + // produces response headers cannot occupy the rest of the run. Once headers + // arrive, the same watchdog is reset and follows every response-body read. + stream.armWatchdog() + fetch := c.Fetcher + if fetch == nil { + fetch = currentFetcher() + } + resp, err := fetch(req) + if err != nil { + stream.teardown() + stream.registerOnce.Do(func() { stream.register(0, err) }) + return nil, err + } + stream.response = resp + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Error classification is the caller's; this layer only has to make + // the status classifiable and release the route. + payload, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + stream.teardown() + body := string(payload) + failure := retrysched.NewProviderError( + statusMessage(resp, payload), float64(resp.StatusCode), + retrysched.HeaderPairs(resp.Header), &body, + ) + stream.registerOnce.Do(func() { stream.register(0, failure) }) + return nil, failure + } + if resp.Body == nil { + stream.teardown() + failure := errors.New("Empty response body") + stream.registerOnce.Do(func() { stream.register(0, failure) }) + return nil, failure + } + + stream.body = resp.Body + stream.bumpWatchdog() + stream.decoder = NewSSEDecoder(watchdogReader{stream: stream, body: resp.Body}) + return stream, nil +} + +func (c *Client) totalTimeout() float64 { + if c.TotalTimeoutMS == 0 { + return DefaultTimeoutMS + } + return c.TotalTimeoutMS +} + +func (c *Client) chunkTimeout() float64 { + if c.ChunkTimeoutMS == 0 { + return DefaultChunkTimeoutMS + } + return c.ChunkTimeoutMS +} + +func statusMessage(resp *http.Response, payload []byte) string { + // Decode `{error:{message}}` and fall back to the status line. An empty + // payload becomes "<status> (no body)", which the overflow classifier + // recognises. + if len(bytes.TrimSpace(payload)) == 0 { + return fmt.Sprintf("%d (no body)", resp.StatusCode) + } + if chunk := ParseChunk(string(payload)); chunk.Success && chunk.Value != nil && chunk.Value.ErrorField != nil { + if obj, err := ParseObject(chunk.Value.ErrorField); err == nil { + if msg, ok := obj.Get("message"); ok { + return rawString(msg) + } + } + } + if resp.Status != "" { + return resp.Status + } + return fmt.Sprintf("%d", resp.StatusCode) +} + +// ── the drain ───────────────────────────────────────────────────────────── + +// Next returns the next stream part. It returns io.EOF exactly once, after the +// `finish` part. +// +// An abort — from any of the four layers — surfaces as `{type:"abort", +// reason:getErrorMessage(cause)}` and the stream then ends cleanly. The cause +// still goes to router registration, where its exact message is what the +// cooldown and retry classifiers substring-match. +func (s *Stream) Next() (StreamPart, error) { + if s.next < len(s.parts) { + p := s.parts[s.next] + s.next++ + return p, nil + } + if s.failed != nil { + return nil, s.failed + } + if s.finished { + return nil, io.EOF + } + if s.closedByAPI.Load() { + s.finished = true + return nil, io.EOF + } + + for { + ev, err := s.decoder.Next() + if err != nil { + if s.closedByAPI.Load() { + s.finished = true + s.stopAndClose() + return nil, io.EOF + } + if cause := s.abortCause(); cause != nil { + s.parts = []StreamPart{AbortPart{Reason: cause.Error(), HasReason: true}} + s.next = 1 + s.finished = true + s.finishRegister(cause) + s.stopAndClose() + return s.parts[0], nil + } + var registerFailure error + if err != io.EOF { + // A mid-stream READ error surfaces as an `error` PART at + // flush, never as a returned error. + s.translator.SetStreamError(readErrorValue(err)) + registerFailure = err + } + s.parts = s.translator.Flush() + s.finished = true + s.finishRegister(registerFailure) + s.stopAndClose() + if len(s.parts) == 0 { + return nil, io.EOF + } + s.next = 1 + return s.parts[0], nil + } + if ev.Data == DoneSentinel { + continue + } + emitted, err := s.translator.Transform(ParseChunk(ev.Data)) + if len(emitted) > 0 { + s.parts = emitted + s.next = 1 + for _, part := range emitted { + if streamError, ok := part.(ErrorPart); ok { + // An in-band error releases the route immediately. A + // later finish/Close is suppressed by Once. + s.finishRegister(errorPartFailure(streamError.Error)) + break + } + } + if err != nil { + // A throw tears the stream down: the parts already enqueued + // are delivered, then the error. + s.failed = err + s.finishRegister(err) + s.stopAndClose() + } + return s.parts[0], nil + } + if err != nil { + s.failed = err + s.finishRegister(err) + s.stopAndClose() + return nil, err + } + } +} + +func errorPartFailure(raw []byte) error { + if obj, err := ParseObject(raw); err == nil { + if message, ok := obj.Get("message"); ok && rawString(message) != "" { + return streamProviderError{message: rawString(message), body: string(raw)} + } + if data, ok := obj.Get("data"); ok { + if nested, nestedErr := ParseObject(data); nestedErr == nil { + if message, ok := nested.Get("message"); ok && rawString(message) != "" { + return streamProviderError{message: rawString(message), body: string(raw)} + } + } + } + } + if len(raw) == 0 { + return errors.New("openrouter stream error") + } + return fmt.Errorf("openrouter stream error: %s", raw) +} + +// streamProviderError is an in-band error payload from a model stream. The +// raw body is exposed as detail so the router classifiers can match provider +// fields such as error_type. +type streamProviderError struct { + message string + body string +} + +func (failure streamProviderError) Error() string { return failure.message } +func (failure streamProviderError) ErrorDetail() string { return failure.body } + +// Parts drains the whole stream. Convenience for callers that do not need +// incremental delivery — and for tests. +func (s *Stream) Parts() ([]StreamPart, error) { + var out []StreamPart + for { + p, err := s.Next() + if err == io.EOF { + return out, nil + } + if err != nil { + return out, err + } + out = append(out, p) + } +} + +// finishRegister fires the exactly-once router registration with the stream's +// output token count (0 on failure). +func (s *Stream) finishRegister(failure error) { + s.registerOnce.Do(func() { + completion := float64(0) + if failure == nil { + for _, p := range s.parts { + finish, ok := p.(FinishPart) + if !ok { + continue + } + flat := calc.AsLanguageModelUsage(finish.Usage) + if flat.OutputTokens != nil { + completion = *flat.OutputTokens + } + } + } + s.register(completion, failure) + }) +} + +// Close abandons the stream. It cancels the request context first and only +// then closes the body as best-effort cleanup. Safe to call from another +// goroutine, and safe to call twice. +func (s *Stream) Close() error { + var err error + s.closeOnce.Do(func() { + // Preserve the cancellation origin before teardown manufactures a + // local Close cause. Closing a caller-aborted stream still owns its + // lease even when Next did not drain the abort part. + if cause := s.abortCause(); s.callerCanceled(cause) { + s.finishRegister(cause) + } + s.closedByAPI.Store(true) + s.teardown() + err = s.closeBody() + // A stream abandoned before it finished releases its route lease. + s.registerOnce.Do(func() { + if s.release != nil { + s.release() + } + }) + }) + return err +} + +func (s *Stream) callerCanceled(failure error) bool { + if failure == nil || s.callerCtx == nil || s.callerCtx.Err() == nil || s.ctx == nil { + return false + } + cause := context.Cause(s.callerCtx) + // A child watchdog/provider timeout that won the cancellation race must + // still count as a provider failure even if the parent cancels later. + return errors.Is(context.Cause(s.ctx), cause) && + (errors.Is(failure, cause) || errors.Is(failure, s.callerCtx.Err())) +} + +func (s *Stream) teardown() { + s.watchdogStopped.Store(true) + s.watchdogGeneration.Add(1) + s.watchdogMu.Lock() + if s.watchdog != nil { + s.watchdog.Stop() + s.watchdog = nil + } + s.watchdogMu.Unlock() + if s.cancel != nil { + s.cancel(context.Canceled) + } + if s.stopTot != nil { + s.stopTot() + } +} + +// armWatchdog starts the collapsed layer-2-chunk / layer-3 timer. +func (s *Stream) armWatchdog() { + if s.chunkMS <= 0 || s.watchdogStopped.Load() { + return + } + generation := s.watchdogGeneration.Add(1) + timer := currentTimerFactory()(s.chunkMS, func() { + if s.watchdogStopped.Load() || s.watchdogGeneration.Load() != generation { + return + } + if s.cancel != nil { + s.cancel(ErrSSEReadTimedOut) + } + }) + s.watchdogMu.Lock() + if s.watchdogStopped.Load() || s.watchdogGeneration.Load() != generation { + timer.Stop() + } else { + s.watchdog = timer + } + s.watchdogMu.Unlock() +} + +// bumpWatchdog re-arms the per-read timer on every read, so a slow but +// progressing stream never trips it. +func (s *Stream) bumpWatchdog() { + if s.chunkMS <= 0 || s.watchdogStopped.Load() { + return + } + s.watchdogMu.Lock() + if s.watchdog != nil { + s.watchdog.Stop() + s.watchdog = nil + } + s.watchdogMu.Unlock() + s.armWatchdog() +} + +// abortCause resolves the context cause to the manufactured abort reason — +// `The operation timed out.` (layer 2), `SSE read timed out` (layers 2-chunk/3), +// or whatever the caller cancelled its own context with (layer 1). +// +// A caller-initiated Close is NOT a failure: post-Close reads report the end +// of the stream. +func (s *Stream) abortCause() error { + if s.closedByAPI.Load() || s.ctx == nil || s.ctx.Err() == nil { + return nil + } + cause := context.Cause(s.ctx) + if cause == nil { + return s.ctx.Err() + } + return cause +} + +func (s *Stream) closeBody() error { + s.bodyOnce.Do(func() { + if s.body != nil { + s.bodyErr = s.body.Close() + } + }) + return s.bodyErr +} + +func (s *Stream) stopAndClose() { + s.teardown() + _ = s.closeBody() +} + +// watchdogReader resets the collapsed layer-2/3 timer for every underlying +// body read, including comment-only keepalives and partial SSE +// frames. Resetting only after a complete event would time out a healthy +// OpenRouter stream whose `: OPENROUTER PROCESSING` comments keep arriving. +type watchdogReader struct { + stream *Stream + body io.Reader +} + +func (r watchdogReader) Read(p []byte) (int, error) { + n, err := r.body.Read(p) + if n > 0 { + r.stream.bumpWatchdog() + } + return n, err +} + +// readErrorValue renders a mid-stream reader error as `{name, message}` so the +// error part carries the reason. +func readErrorValue(err error) []byte { + w := newObjectWriter() + w.str("name", "Error") + w.str("message", err.Error()) + out, encodeErr := w.done() + if encodeErr != nil { + return []byte(`{"name":"Error"}`) + } + return out +} diff --git a/internal/seniordev/engine/orclient/client_test.go b/internal/seniordev/engine/orclient/client_test.go new file mode 100644 index 0000000000..d879af5886 --- /dev/null +++ b/internal/seniordev/engine/orclient/client_test.go @@ -0,0 +1,1135 @@ +//go:build !windows + +package orclient + +// httptest-driven wire tests. +// +// Covered here: SSE framing against a real socket (CRLF, a frame split across +// two TCP writes, keepalive comments, `[DONE]`, a trailing frame with no +// terminating blank line), the reader watchdog and its EXACT abort message, +// the total-request timeout and its EXACT abort message, the early-teardown +// rule (cancel the context, do not merely close the body), goroutine +// cleanliness, and router registration exactly-once on success / failure / +// abandonment. + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// ── helpers ─────────────────────────────────────────────────────────────── + +// sseServer serves a handler over httptest and returns a Client pointed at it. +// The fetch seam is swapped to the test's own http.Client so the test +// exercises this package's plumbing against a local listener. +func sseServer(t *testing.T, handler http.HandlerFunc) (*Client, func()) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + // Some hermetic runners prohibit loopback sockets entirely. Keep the + // tests as real httptest servers wherever sockets exist, and report a + // capability skip rather than letting httptest panic in that sandbox. + t.Skipf("loopback sockets unavailable: %v", err) + } + srv := &httptest.Server{ + Listener: listener, + Config: &http.Server{Handler: handler}, + } + srv.Start() + httpClient := srv.Client() + restore := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + return httpClient.Do(req) + }) + c := &Client{BaseURL: srv.URL, Compatibility: CompatibilityCompatible} + return c, func() { + restore() + srv.Close() + } +} + +func writeSSE(w http.ResponseWriter, chunks ...string) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + for _, c := range chunks { + _, _ = io.WriteString(w, c) + if flusher != nil { + flusher.Flush() + } + } +} + +func minimalParams() RequestParams { + return RequestParams{ModelID: "vendor/model"} +} + +func partTypes(parts []StreamPart) []string { + out := make([]string, 0, len(parts)) + for _, p := range parts { + out = append(out, p.PartType()) + } + return out +} + +// ── framing ─────────────────────────────────────────────────────────────── + +func TestWireFramingOverHTTP(t *testing.T) { + cases := []struct { + name string + chunks []string + want []string + }{ + { + name: "lf frames", + chunks: []string{"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n", "data: [DONE]\n\n"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "crlf frames", + chunks: []string{"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\r\n\r\n", "data: [DONE]\r\n\r\n"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "frame split across two writes", + chunks: []string{ + "data: {\"choices\":[{\"delta\":", + "{\"content\":\"a\"}}]}\n\n", + "data: [DONE]\n\n", + }, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "keepalive comments are ignored", + chunks: []string{ + ": OPENROUTER PROCESSING\n\n", + ": OPENROUTER PROCESSING\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n", + "data: [DONE]\n\n", + }, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "trailing frame with no blank line is still dispatched", + chunks: []string{"data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "multiple data lines in one frame join with a newline", + chunks: []string{"data: {\"choices\":[{\"delta\":\ndata: {\"content\":\"a\"}}]}\n\n", "data: [DONE]\n\n"}, + want: []string{PartTypeTextStart, PartTypeTextDelta, PartTypeTextEnd, PartTypeFinish}, + }, + { + name: "empty body yields only finish", + chunks: []string{"data: [DONE]\n\n"}, + want: []string{PartTypeFinish}, + }, + { + name: "malformed frame becomes an error part, not a failure", + chunks: []string{"data: {not json\n\n", "data: [DONE]\n\n"}, + want: []string{PartTypeError, PartTypeFinish}, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, tc.chunks...) + }) + defer cleanup() + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("drain: %v", err) + } + got := partTypes(parts) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("part types:\n want %v\n got %v", tc.want, got) + } + }) + } +} + +func TestWireRequestBodyAndHeadersReachTheServer(t *testing.T) { + var gotBody []byte + var gotHeader http.Header + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotHeader = r.Header.Clone() + writeSSE(w, "data: [DONE]\n\n") + }) + defer cleanup() + + c.Headers = []HeaderPair{ + {Name: "authorization", Value: "Bearer KEY"}, + {Name: "content-type", Value: "application/json"}, + {Name: "x-session-affinity", Value: "ses_1"}, + } + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + + want := `{"model":"vendor/model","messages":[],"stream":true}` + if string(gotBody) != want { + t.Errorf("body:\n want %s\n got %s", want, gotBody) + } + if got := gotHeader.Get("X-Session-Affinity"); got != "ses_1" { + t.Errorf("x-session-affinity: got %q", got) + } + if got := gotHeader.Get("Authorization"); got != "Bearer KEY" { + t.Errorf("authorization: got %q", got) + } +} + +func TestWireClientFetcherOverridesPackageSeam(t *testing.T) { + packageCalls := 0 + restore := SetFetcherForTesting(func(*http.Request) (*http.Response, error) { + packageCalls++ + return nil, errors.New("package fetcher should not run") + }) + defer restore() + + clientCalls := 0 + client := &Client{ + BaseURL: "http://provider.invalid/api/v1", + Fetcher: func(request *http.Request) (*http.Response, error) { + clientCalls++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + Request: request, + }, nil + }, + } + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatal(err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatal(err) + } + if clientCalls != 1 || packageCalls != 0 { + t.Fatalf("client fetches=%d package fetches=%d", clientCalls, packageCalls) + } +} + +// ── abort layers ────────────────────────────────────────────────────────── + +func TestClientDefaultHasNoTotalDeadlineAndKeepsProgressWatchdog(t *testing.T) { + totalContexts := 0 + restoreTimeout := SetTimeoutContextFactoryForTesting(func(parent context.Context, _ time.Duration, _ error) (context.Context, context.CancelFunc) { + totalContexts++ + return context.WithCancel(parent) + }) + defer restoreTimeout() + + var armed []float64 + restoreTimer := SetTimerFactoryForTesting(func(ms float64, _ func()) Timer { + armed = append(armed, ms) + return fakeTimer{} + }) + defer restoreTimer() + + client := &Client{BaseURL: testBaseURL, Fetcher: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), + Request: req, + }, nil + }} + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + + if totalContexts != 0 { + t.Fatalf("default client installed %d total-request deadlines, want none", totalContexts) + } + if len(armed) == 0 { + t.Fatal("default client did not arm the progress-sensitive reader watchdog") + } + for _, ms := range armed { + if ms != DefaultChunkTimeoutMS { + t.Fatalf("reader watchdog = %vms, want %vms", ms, DefaultChunkTimeoutMS) + } + } +} + +func TestClientProgressWatchdogCoversResponseHeaders(t *testing.T) { + var fire func() + restoreTimer := SetTimerFactoryForTesting(func(_ float64, fn func()) Timer { + fire = fn + return fakeTimer{} + }) + defer restoreTimer() + + client := &Client{BaseURL: testBaseURL, Fetcher: func(req *http.Request) (*http.Response, error) { + if fire == nil { + t.Fatal("reader watchdog was not armed before request dispatch") + } + fire() + <-req.Context().Done() + return nil, context.Cause(req.Context()) + }} + _, err := client.DoStream(context.Background(), minimalParams()) + if !errors.Is(err, ErrSSEReadTimedOut) { + t.Fatalf("DoStream error = %v, want %v", err, ErrSSEReadTimedOut) + } +} + +func TestWireChunkWatchdogProducesTheLayer3Message(t *testing.T) { + var fire func() + restoreTimer := SetTimerFactoryForTesting(func(_ float64, fn func()) Timer { + fire = fn + return fakeTimer{} + }) + defer restoreTimer() + + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.ChunkTimeoutMS = 30 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + fire() + parts, err := stream.Parts() + if err != nil { + t.Fatalf("an abort must close the stream cleanly: %v", err) + } + abort := requireAbortPart(t, parts) + if abort.Reason != "SSE read timed out" { + t.Errorf("abort message: want %q, got %q", "SSE read timed out", abort.Reason) + } + // The text is the contract: it is what the router's cooldown class and + // retrysched.IsTimeoutError match on. + abortErr := errors.New(abort.Reason) + if !adaptive.IsLikelyTimeout(abortErr) { + t.Error("adaptive.IsLikelyTimeout must classify the collapsed layer-2/3 abort as a timeout") + } + message := abort.Reason + if !retrysched.IsTimeoutError(retrysched.Err{Name: "APIError", Data: retrysched.ErrData{Message: &message}}) { + t.Error("retrysched.IsTimeoutError must classify the collapsed layer-2/3 abort as a timeout") + } +} + +func TestWireTotalTimeoutProducesTheAbortSignalMessage(t *testing.T) { + fire := make(chan context.CancelCauseFunc, 1) + restoreTimeout := SetTimeoutContextFactoryForTesting(func(parent context.Context, _ time.Duration, cause error) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancelCause(parent) + fire <- func(error) { cancel(cause) } + return ctx, func() { cancel(context.Canceled) } + }) + defer restoreTimeout() + + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.TotalTimeoutMS = 30 + c.ChunkTimeoutMS = -1 // disable the reader watchdog so layer 2 wins + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + (<-fire)(nil) + parts, err := stream.Parts() + if err != nil { + t.Fatalf("an abort must close the stream cleanly: %v", err) + } + abort := requireAbortPart(t, parts) + if abort.Reason != "The operation timed out." { + t.Errorf("abort message: want %q, got %q", "The operation timed out.", abort.Reason) + } + // This string contains "timed out", not "timeout"; the classifier must + // still fire. + if !adaptive.IsLikelyTimeout(errors.New(abort.Reason)) { + t.Error("adaptive.IsLikelyTimeout must classify the layer-2 abort as a timeout") + } +} + +func TestWireCallerCancellationSurfacesItsOwnCause(t *testing.T) { + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.ChunkTimeoutMS = -1 + ctx, cancel := context.WithCancelCause(context.Background()) + stream, err := c.DoStream(ctx, minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + callerCause := errors.New("caller gave up") + cancel(callerCause) + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("an abort must close the stream cleanly: %v", err) + } + abort := requireAbortPart(t, parts) + if abort.Reason != callerCause.Error() { + t.Fatalf("want the caller's own cause, got %q", abort.Reason) + } +} + +func requireAbortPart(t *testing.T, parts []StreamPart) AbortPart { + t.Helper() + for _, part := range parts { + if abort, ok := part.(AbortPart); ok { + return abort + } + } + t.Fatalf("expected an abort part, got %v", partTypes(parts)) + return AbortPart{} +} + +func TestWireCommentKeepalivesResetTheReadWatchdog(t *testing.T) { + var arms int + var stops int + var stopsMu sync.Mutex + restoreTimer := SetTimerFactoryForTesting(func(_ float64, _ func()) Timer { + stopsMu.Lock() + arms++ + stopsMu.Unlock() + return fakeTimerFunc(func() { + stopsMu.Lock() + stops++ + stopsMu.Unlock() + }) + }) + defer restoreTimer() + + restoreFetcher := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &chunkedBody{chunks: [][]byte{ + []byte(": OPENROUTER PROCESSING\n\n"), + []byte(": OPENROUTER PROCESSING\n\n"), + []byte(": OPENROUTER PROCESSING\n\n"), + []byte(": OPENROUTER PROCESSING\n\n"), + []byte("data: [DONE]\n\n"), + }}, + }, nil + }) + defer restoreFetcher() + + c := &Client{BaseURL: testBaseURL, Compatibility: CompatibilityCompatible} + c.ChunkTimeoutMS = 30 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("keepalives must keep the read watchdog alive: %v", err) + } + for _, part := range parts { + if part.PartType() == PartTypeAbort { + t.Fatalf("keepalives must reset the underlying read timer, got %v", partTypes(parts)) + } + } + stopsMu.Lock() + defer stopsMu.Unlock() + if arms < 5 { + t.Fatalf("watchdog armed %d times, want initial arm plus one per underlying read", arms) + } + if stops < 4 { + t.Fatalf("watchdog stopped %d times, want at least one reset per keepalive", stops) + } +} + +// Early teardown must cancel the request context, not merely Close the +// body. The assertion is server-side: the handler must observe its request +// context finish, which closing the body alone would not guarantee. +func TestWireEarlyTeardownCancelsTheRequestContext(t *testing.T) { + serverSawCancel := make(chan struct{}) + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-r.Context().Done(): + close(serverSawCancel) + case <-release: + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + c.ChunkTimeoutMS = -1 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + // Read one part, then abandon, as the loop does when compaction is needed. + if _, err := stream.Next(); err != nil { + t.Fatalf("first part: %v", err) + } + if err := stream.Close(); err != nil && err != io.EOF { + t.Fatalf("Close: %v", err) + } + + select { + case <-serverSawCancel: + case <-time.After(2 * time.Second): + t.Fatal("Close must cancel the request context — the server never saw the request finish") + } +} + +func TestWireCloseIsIdempotentAndLeaksNoGoroutines(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, + "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n", + "data: [DONE]\n\n", + ) + }) + defer cleanup() + + before := runtime.NumGoroutine() + for i := 0; i < 20; i++ { + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + _ = stream.Close() + _ = stream.Close() + } + // Transport goroutine retirement is inherently scheduler-driven. Poll for + // up to 2s (20x the old 100ms grace) so CPU starvation cannot turn a slow + // cleanup into a false leak report. + deadline := time.Now().Add(2 * time.Second) + for { + runtime.GC() + after := runtime.NumGoroutine() + if after <= before+10 { + break + } + if time.Now().After(deadline) { + t.Errorf("goroutine leak: before %d, after %d", before, after) + break + } + runtime.Gosched() + } +} + +// ── HTTP error responses ────────────────────────────────────────────────── + +func TestWireNon2xxBecomesAClassifiableStatusError(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"error":{"message":"rate limit exceeded","code":429}}`) + }) + defer cleanup() + + _, err := c.DoStream(context.Background(), minimalParams()) + if err == nil { + t.Fatal("expected a 429 to fail the request") + } + if err.Error() != "rate limit exceeded" { + t.Errorf("message: want %q, got %q", "rate limit exceeded", err.Error()) + } + value := err + if !adaptive.IsLikelyRateLimit(value) { + t.Error("a 429 must classify as a rate limit for the router") + } +} + +func TestErrorClassificationThroughRetryAdapter(t *testing.T) { + type want struct { + rateLimit, incompatible, timeout, structured, transient, retryable bool + } + cases := []struct { + name string + err error + want want + }{ + {"layer 2 total timeout", ErrOperationTimedOut, want{timeout: true, retryable: true}}, + {"layer 3 read timeout", ErrSSEReadTimedOut, want{timeout: true, retryable: true}}, + {"http 429 status", retrysched.NewProviderError("request rejected", 429, nil, nil), want{rateLimit: true, retryable: true}}, + {"provider incompatibility", errors.New("unsupported parameter top_k"), want{incompatible: true, retryable: true}}, + {"structured failure", errors.New("invalid json parse"), want{structured: true, retryable: true}}, + {"http 503 status", retrysched.NewProviderError("request rejected", 503, nil, nil), want{transient: true, retryable: true}}, + {"ordinary application error", errors.New("tool execution failed"), want{}}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + value := tc.err + got := want{ + rateLimit: adaptive.IsLikelyRateLimit(value), + incompatible: adaptive.IsLikelyProviderIncompatible(value), + timeout: adaptive.IsLikelyTimeout(value), + structured: adaptive.IsLikelyStructuredFailure(value), + transient: adaptive.IsLikelyTransientProviderError(value), + retryable: adaptive.IsRetryableRouteError(value), + } + if got != tc.want { + t.Errorf("classification:\n want %+v\n got %+v", tc.want, got) + } + }) + } +} + +// ── router registration exactly once ────────────────────────────────────── + +type spyRouter struct { + mu sync.Mutex + inflight int + canceled int + calls []struct { + completion float64 + err error + } +} + +func (s *spyRouter) RegisterCanceled(choice adaptive.RouteChoice) { + s.mu.Lock() + defer s.mu.Unlock() + s.canceled++ + if s.inflight > 0 { + s.inflight-- + } +} + +func (s *spyRouter) Register(choice adaptive.RouteChoice, elapsedSeconds, completionTokens float64, err error) adaptive.AdaptiveRouteEvent { + s.mu.Lock() + defer s.mu.Unlock() + s.calls = append(s.calls, struct { + completion float64 + err error + }{completionTokens, err}) + if s.inflight > 0 { + s.inflight-- + } + return adaptive.AdaptiveRouteEvent{} +} + +func (s *spyRouter) canceledCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.canceled +} + +func (s *spyRouter) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.calls) +} + +func (s *spyRouter) inFlight() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.inflight +} + +func TestWireRouterRegistersExactlyOnceOnSuccess(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, + "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":7,\"total_tokens\":17}}\n\n", + "data: [DONE]\n\n", + ) + }) + defer cleanup() + + spy := &spyRouter{} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + _ = stream.Close() + + if spy.count() != 1 { + t.Fatalf("register must fire exactly once, got %d", spy.count()) + } + if spy.calls[0].err != nil { + t.Errorf("a successful stream must register a nil error") + } + // The registered completion count is the provider's completion_tokens. + if spy.calls[0].completion != 7 { + t.Errorf("completion tokens: want 7, got %v", spy.calls[0].completion) + } +} + +func TestWireRouterRegistersExactlyOnceOnHTTPFailure(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"error":{"message":"boom"}}`) + }) + defer cleanup() + + spy := &spyRouter{} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + + if _, err := c.DoStream(context.Background(), minimalParams()); err == nil { + t.Fatal("expected a 500 to fail") + } + if spy.count() != 1 { + t.Fatalf("register must fire exactly once, got %d", spy.count()) + } + if spy.calls[0].err == nil { + t.Error("a failed request must register the error") + } +} + +func TestWireErrorPartRegistersFailureAndReleasesRoute(t *testing.T) { + // An in-band error part registers a failure exactly once before teardown; + // the Close that follows must not settle the lease a second time. + c, cleanup := sseServer(t, func(w http.ResponseWriter, _ *http.Request) { + writeSSE(w, + "data: {\"error\":{\"message\":\"provider exploded\"},\"choices\":[]}\n\n", + "data: [DONE]\n\n", + ) + }) + defer cleanup() + spy := &spyRouter{inflight: 1} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatal(err) + } + parts, err := stream.Parts() + if err != nil { + t.Fatal(err) + } + _ = stream.Close() + if len(parts) == 0 || parts[0].PartType() != PartTypeError { + t.Fatalf("parts = %v", partTypes(parts)) + } + if spy.count() != 1 || spy.inFlight() != 0 || spy.calls[0].err == nil || + spy.calls[0].completion != 0 { + t.Fatalf("router calls=%#v inflight=%d", spy.calls, spy.inFlight()) + } +} + +func TestWireErrorPartPreservesStatusForRouterCooldown(t *testing.T) { + // Structured stream error fields reach the router, so a numeric 503 is + // classified as transient and cools the route. + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{ + {ID: "provider/first"}, + {ID: "provider/second"}, + }, + }) + choice, err := router.Pick("coder", adaptive.ModelTierHigh) + if err != nil { + t.Fatal(err) + } + c := &Client{BaseURL: testBaseURL, Fetcher: func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"error\":{\"message\":\"upstream unavailable\",\"status\":503},\"choices\":[]}\n\n" + + "data: [DONE]\n\n", + )), + Request: request, + }, nil + }} + c.Router = router + c.RouteChoice = &choice + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatal(err) + } + if _, err := stream.Parts(); err != nil { + t.Fatal(err) + } + next, err := router.Pick("coder", adaptive.ModelTierHigh) + if err != nil { + t.Fatal(err) + } + if next.Candidate.ID == choice.Candidate.ID { + t.Fatalf("503 route %q was not cooled; next choice = %#v", choice.Candidate.ID, next) + } +} + +// A stream abandoned through Close before it finished releases its route +// lease without attributing a success or a failure. +func TestWireAbandonedStreamReleasesRoute(t *testing.T) { + + release := make(chan struct{}) + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case <-release: + case <-r.Context().Done(): + case <-time.After(5 * time.Second): + } + }) + defer cleanup() + defer close(release) + + spy := &spyRouter{inflight: 1} + c.Router = spy + c.RouteChoice = &adaptive.RouteChoice{Slot: "coder"} + c.ChunkTimeoutMS = -1 + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Next(); err != nil { + t.Fatalf("first part: %v", err) + } + _ = stream.Close() + + if spy.count() != 0 { + t.Fatalf("abandoned stream registered %d outcomes, want 0", spy.count()) + } + if spy.canceledCount() != 1 || spy.inFlight() != 0 { + t.Fatalf("abandoned stream must release its lease: canceled=%d inflight=%d", spy.canceledCount(), spy.inFlight()) + } +} + +func TestWireNoRouteChoiceSkipsRegistration(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, "data: [DONE]\n\n") + }) + defer cleanup() + + spy := &spyRouter{} + c.Router = spy // RouteChoice deliberately nil + + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + _ = stream.Close() + + if spy.count() != 0 { + t.Fatalf("no registration without a route choice, got %d", spy.count()) + } +} + +// ── mid-stream read error ───────────────────────────────────────────────── + +// A mid-stream reader error closes the stream cleanly; the error surfaces as +// an `error` PART at flush, never as a returned error. The httptest server +// aborts the connection mid-frame. +func TestWireMidStreamReadErrorBecomesAnErrorPart(t *testing.T) { + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Content-Length", "512") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + // Returning early with an unsatisfied Content-Length makes the client + // see an unexpected EOF. + }) + defer cleanup() + + c.ChunkTimeoutMS = -1 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + + parts, err := stream.Parts() + if err != nil { + t.Fatalf("a mid-stream read error must NOT be returned as an error: %v", err) + } + var sawError bool + var finish *FinishPart + for i := range parts { + if parts[i].PartType() == PartTypeError { + sawError = true + } + if f, ok := parts[i].(FinishPart); ok { + finish = &f + } + } + if !sawError { + t.Errorf("expected an error part, got %v", partTypes(parts)) + } + if finish == nil { + t.Fatal("expected a finish part") + } + if finish.FinishReason.Unified != FinishError { + t.Errorf("a captured stream error forces finishReason error, got %q", finish.FinishReason.Unified) + } +} + +// ── timer seam ──────────────────────────────────────────────────────────── + +func TestWireWatchdogUsesTheInjectableTimer(t *testing.T) { + var mu sync.Mutex + var armed []float64 + fire := make(chan func(), 8) + restore := SetTimerFactoryForTesting(func(ms float64, fn func()) Timer { + mu.Lock() + armed = append(armed, ms) + mu.Unlock() + select { + case fire <- fn: + default: + } + return fakeTimer{} + }) + defer restore() + + c, cleanup := sseServer(t, func(w http.ResponseWriter, r *http.Request) { + writeSSE(w, "data: [DONE]\n\n") + }) + defer cleanup() + + c.ChunkTimeoutMS = 4321 + stream, err := c.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + if _, err := stream.Parts(); err != nil { + t.Fatalf("drain: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(armed) == 0 { + t.Fatal("the reader watchdog must go through the injectable timer") + } + for _, ms := range armed { + if ms != 4321 { + t.Errorf("watchdog armed with %v ms, want 4321", ms) + } + } +} + +type fakeTimer struct{} + +func (fakeTimer) Stop() {} + +type fakeTimerFunc func() + +func (f fakeTimerFunc) Stop() { f() } + +// ── socket-free abort plumbing ──────────────────────────────────────────── + +type contextBody struct { + ctx context.Context + closed chan struct{} + closeOnce sync.Once +} + +type chunkedBody struct { + chunks [][]byte + next int +} + +func (b *chunkedBody) Read(p []byte) (int, error) { + if b.next >= len(b.chunks) { + return 0, io.EOF + } + chunk := b.chunks[b.next] + b.next++ + return copy(p, chunk), nil +} + +func (*chunkedBody) Close() error { return nil } + +func newContextBody(ctx context.Context) *contextBody { + return &contextBody{ctx: ctx, closed: make(chan struct{})} +} + +func (b *contextBody) Read([]byte) (int, error) { + select { + case <-b.ctx.Done(): + return 0, b.ctx.Err() + case <-b.closed: + return 0, io.EOF + } +} + +func (b *contextBody) Close() error { + b.closeOnce.Do(func() { close(b.closed) }) + return nil +} + +func TestClientTotalTimeoutEmitsAbortWithoutSocket(t *testing.T) { + fire := make(chan context.CancelCauseFunc, 1) + restoreTimeout := SetTimeoutContextFactoryForTesting(func(parent context.Context, _ time.Duration, cause error) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancelCause(parent) + fire <- func(error) { cancel(cause) } + return ctx, func() { cancel(context.Canceled) } + }) + defer restoreTimeout() + + restore := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: newContextBody(req.Context()), + Header: make(http.Header), + }, nil + }) + defer restore() + + client := &Client{BaseURL: testBaseURL, TotalTimeoutMS: 5, ChunkTimeoutMS: -1} + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + defer stream.Close() + (<-fire)(nil) + parts, err := stream.Parts() + if err != nil { + t.Fatalf("abort must close cleanly: %v", err) + } + if got := requireAbortPart(t, parts).Reason; got != ErrOperationTimedOut.Error() { + t.Errorf("want %q, got %q", ErrOperationTimedOut, got) + } +} + +func TestClientCloseCancelsRequestContextWithoutSocket(t *testing.T) { + var requestContext context.Context + restore := SetFetcherForTesting(func(req *http.Request) (*http.Response, error) { + requestContext = req.Context() + return &http.Response{ + StatusCode: http.StatusOK, + Body: newContextBody(req.Context()), + Header: make(http.Header), + }, nil + }) + defer restore() + + client := &Client{BaseURL: testBaseURL, TotalTimeoutMS: -1, ChunkTimeoutMS: -1} + stream, err := client.DoStream(context.Background(), minimalParams()) + if err != nil { + t.Fatalf("DoStream: %v", err) + } + if err := stream.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + select { + case <-requestContext.Done(): + default: + t.Fatal("Close must cancel the HTTP request context") + } +} + +// ── the error-part payload of a mid-stream failure ──────────────────────── + +func TestErrorPartPayloadShape(t *testing.T) { + got, err := ErrorPart{Error: readErrorValue(errors.New("connection reset"))}.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"type":"error","error":{"name":"Error","message":"connection reset"}}` + if string(got) != want { + t.Errorf("want %s, got %s", want, got) + } + var probe map[string]json.RawMessage + if err := json.Unmarshal(got, &probe); err != nil { + t.Fatalf("error part must be valid JSON: %v", err) + } +} diff --git a/internal/seniordev/engine/orclient/convert.go b/internal/seniordev/engine/orclient/convert.go new file mode 100644 index 0000000000..b2e72b7649 --- /dev/null +++ b/internal/seniordev/engine/orclient/convert.go @@ -0,0 +1,766 @@ +//go:build !windows + +package orclient + +// The `messages` field of the request body. +// +// Everything about this conversion is byte-order-sensitive: the wire body +// feeds OpenRouter's prompt cache, and the assistant +// `tool_calls[].function.arguments` string feeds Anthropic signature +// validation through DeterministicStringify. So every emitted object is an +// ordered Object, not a map, and every optional key is *absent* rather than +// null unless the wire wants an explicit null. +// +// `cache_control` is threaded through from provider options: it decides +// whether a single-text-part user message serialises as a bare string or as a +// one-element array. + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// ── getCacheControl ────────────────────────────────────────────────────── + +// getCacheControl reads `openrouter.cacheControl ?? openrouter.cache_control ?? +// anthropic.cacheControl ?? anthropic.cache_control` off a providerOptions bag. +func getCacheControl(providerOptions json.RawMessage) json.RawMessage { + if len(providerOptions) == 0 { + return nil + } + obj, err := ParseObject(providerOptions) + if err != nil { + return nil + } + for _, ns := range []string{Service, "anthropic"} { + nsRaw, ok := obj.Get(ns) + if !ok { + continue + } + inner, err := ParseObject(nsRaw) + if err != nil { + continue + } + for _, key := range []string{"cacheControl", "cache_control"} { + if v, ok := inner.Get(key); ok && rawTruthy(v) { + return v + } + } + } + return nil +} + +// ── the duplicate tracker ──────────────────────────────────────────────── +// +// One tracker per convertToOpenRouterChatMessages CALL, shared across every +// assistant message in the prompt — so the same reasoning text appearing on two +// assistant turns is emitted once, on the first. + +type reasoningDuplicateTracker struct{ seen map[string]bool } + +func newReasoningDuplicateTracker() *reasoningDuplicateTracker { + return &reasoningDuplicateTracker{seen: map[string]bool{}} +} + +func (t *reasoningDuplicateTracker) upsert(d ReasoningDetail) bool { + key, ok := canonicalReasoningKey(d) + if !ok { + return false + } + if t.seen[key] { + return false + } + t.seen[key] = true + return true +} + +func canonicalReasoningKey(d ReasoningDetail) (string, bool) { + switch d.Type { + case ReasoningDetailSummary: + return d.Summary, true + case ReasoningDetailEncrypted: + if id := rawString(d.ID); id != "" { + return id, true + } + return d.Data, true + case ReasoningDetailText: + if text := rawString(d.Text); text != "" { + return text, true + } + if sig := rawString(d.Signature); sig != "" { + return sig, true + } + return "", false + } + return "", false +} + +// ── ConvertToOpenRouterChatMessages ─────────────────────────────────────── + +// ConvertToOpenRouterChatMessages converts the prompt into wire messages. +// The returned slice is what lands in the body's `messages` field. +func ConvertToOpenRouterChatMessages(prompt []msgmodel.ModelMessage) ([]*Object, error) { + messages := []*Object{} + tracker := newReasoningDuplicateTracker() + + // Images lifted out of tool results. The chat-completions schema types a + // tool message's content as text and specifies `image_url` for user + // messages, so the image travels in a user message instead. It cannot go + // out immediately: every tool message of an assistant turn has to stay + // contiguous and directly follow that turn, so the images buffer across a + // whole run of tool messages and flush as ONE user message when the run + // ends -- at the next non-tool message, or at the end of the prompt. + var pendingImages []msgmodel.ToolOutputContentMedia + flushImages := func() error { + if len(pendingImages) == 0 { + return nil + } + out, err := toolImageUserMessage(pendingImages) + if err != nil { + return err + } + pendingImages = nil + messages = append(messages, out) + return nil + } + + for _, msg := range prompt { + if msg.Role != "tool" { + if err := flushImages(); err != nil { + return nil, err + } + } + switch msg.Role { + case "system": + cacheControl := getCacheControl(msg.ProviderOptions) + text := NewObject() + text.SetString("type", "text") + content, _ := msg.Content.(string) + text.SetString("text", content) + if cacheControl != nil { + if err := text.Set("cache_control", cacheControl); err != nil { + return nil, err + } + } + out := NewObject() + out.SetString("role", "system") + out.set("content", jsonValue{Kind: kindArray, Array: []jsonValue{text.value()}}) + messages = append(messages, out) + + case "user": + out, err := convertUserMessage(msg) + if err != nil { + return nil, err + } + messages = append(messages, out) + + case "assistant": + out, err := convertAssistantMessage(msg, tracker) + if err != nil { + return nil, err + } + messages = append(messages, out) + + case "tool": + parts, _ := msg.Content.([]any) + for _, part := range parts { + tr, ok := part.(msgmodel.ToolResultContent) + if !ok { + // Anything that is not a tool result is skipped. + continue + } + content, images, err := getToolResultContent(tr) + if err != nil { + return nil, err + } + pendingImages = append(pendingImages, images...) + out := NewObject() + out.SetString("role", "tool") + out.SetString("tool_call_id", tr.ToolCallID) + out.set("content", content) + out.SetString("name", tr.ToolName) + cc := getCacheControl(msg.ProviderOptions) + if cc == nil { + cc = getCacheControl(tr.ProviderOptions) + } + if cc != nil { + if err := out.Set("cache_control", cc); err != nil { + return nil, err + } + } + messages = append(messages, out) + } + + default: + // Any other role is dropped entirely. + } + } + if err := flushImages(); err != nil { + return nil, err + } + return messages, nil +} + +// toolImageUserMessage carries the images named in a run of tool results, +// introduced by a line that ties them back to those results. Each image part +// is built by the user-message part converter, so it is the part the provider +// would see for the same file attached by hand. +func toolImageUserMessage(images []msgmodel.ToolOutputContentMedia) (*Object, error) { + parts := make([]jsonValue, 0, len(images)+1) + lead := NewObject() + lead.SetString("type", "text") + lead.SetString("text", toolImageUserMessageText) + parts = append(parts, lead.value()) + for _, image := range images { + part, err := convertUserPart(msgmodel.FileContent{ + Type: "file", + MediaType: image.MediaType, + Data: image.Data, + }, nil) + if err != nil { + return nil, err + } + parts = append(parts, part) + } + out := NewObject() + out.SetString("role", "user") + out.set("content", jsonValue{Kind: kindArray, Array: parts}) + return out, nil +} + +// toolImageUserMessageText introduces the relocated images. +const toolImageUserMessageText = "The images below are attachments from the preceding tool results, " + + "in the order those tools returned them." + +// contentParts is the only reader of ModelMessage.Content for user and +// assistant messages. It accepts the two shapes the runtime legitimately +// produces -- a []any of content parts (what ConvertToModelMessages builds) and +// a bare string (a hand-written prompt) -- and REFUSES everything else. +// +// A silent fallback here would turn any other shape into an empty message: a +// typed []TextContent slice, or an empty trailing user turn, would go out as +// `"content": []` and the model would see no transcript at all. An +// unconvertible message is a programming error and must fail here, at the +// wire. +func contentParts(msg msgmodel.ModelMessage) ([]any, error) { + switch content := msg.Content.(type) { + case []any: + if len(content) == 0 { + return nil, fmt.Errorf( + "orclient: %s message has no content parts", msg.Role, + ) + } + return content, nil + case string: + if content == "" { + return nil, fmt.Errorf("orclient: %s message has empty text content", msg.Role) + } + return []any{msgmodel.TextContent{Type: "text", Text: content}}, nil + case nil: + return nil, fmt.Errorf("orclient: %s message has nil content", msg.Role) + default: + return nil, fmt.Errorf( + "orclient: %s message content must be a string or []any of content parts, got %T (build it with msgmodel.UserText)", + msg.Role, msg.Content, + ) + } +} + +func convertUserMessage(msg msgmodel.ModelMessage) (*Object, error) { + parts, err := contentParts(msg) + if err != nil { + return nil, err + } + + // Single text part → bare string content, unless a cache_control applies. + if len(parts) == 1 { + if text, ok := parts[0].(msgmodel.TextContent); ok { + cc := getCacheControl(msg.ProviderOptions) + if cc == nil { + cc = getCacheControl(text.ProviderOptions) + } + out := NewObject() + out.SetString("role", "user") + if cc != nil { + part := NewObject() + part.SetString("type", "text") + part.SetString("text", text.Text) + if err := part.Set("cache_control", cc); err != nil { + return nil, err + } + out.set("content", jsonValue{Kind: kindArray, Array: []jsonValue{part.value()}}) + } else { + out.SetString("content", text.Text) + } + return out, nil + } + } + + messageCacheControl := getCacheControl(msg.ProviderOptions) + lastTextPartIndex := -1 + for i := len(parts) - 1; i >= 0; i-- { + if _, ok := parts[i].(msgmodel.TextContent); ok { + lastTextPartIndex = i + break + } + } + + contentParts := make([]jsonValue, 0, len(parts)) + for index, part := range parts { + var partProviderOptions json.RawMessage + isText := false + switch p := part.(type) { + case msgmodel.TextContent: + partProviderOptions = p.ProviderOptions + isText = true + case msgmodel.FileContent: + partProviderOptions = p.ProviderOptions + } + partCacheControl := getCacheControl(partProviderOptions) + cacheControl := partCacheControl + if isText && partCacheControl == nil && index == lastTextPartIndex { + cacheControl = messageCacheControl + } + + converted, err := convertUserPart(part, cacheControl) + if err != nil { + return nil, err + } + contentParts = append(contentParts, converted) + } + + out := NewObject() + out.SetString("role", "user") + out.set("content", jsonValue{Kind: kindArray, Array: contentParts}) + return out, nil +} + +func convertUserPart(part any, cacheControl json.RawMessage) (jsonValue, error) { + withCC := func(o *Object) (jsonValue, error) { + if cacheControl != nil { + if err := o.Set("cache_control", cacheControl); err != nil { + return jsonValue{}, err + } + } + return o.value(), nil + } + + switch p := part.(type) { + case msgmodel.TextContent: + o := NewObject() + o.SetString("type", "text") + o.SetString("text", p.Text) + return withCC(o) + + case msgmodel.FileContent: + switch { + case strings.HasPrefix(p.MediaType, "image/"): + o := NewObject() + o.SetString("type", "image_url") + inner := NewObject() + inner.SetString("url", buildFileDataURL(p.Data, p.MediaType, "image/jpeg")) + o.SetObject("image_url", inner) + return withCC(o) + case strings.HasPrefix(p.MediaType, "video/"): + o := NewObject() + o.SetString("type", "video_url") + inner := NewObject() + inner.SetString("url", buildFileDataURL(p.Data, p.MediaType, "video/mp4")) + o.SetObject("video_url", inner) + return withCC(o) + case strings.HasPrefix(p.MediaType, "audio/"): + audio, err := inputAudioData(p) + if err != nil { + return jsonValue{}, err + } + o := NewObject() + o.SetString("type", "input_audio") + o.SetObject("input_audio", audio) + return withCC(o) + } + fileName := "" + if opts, err := ParseObject(p.ProviderOptions); err == nil { + if nsRaw, ok := opts.Get(Service); ok { + if ns, err := ParseObject(nsRaw); err == nil { + if v, ok := ns.Get("filename"); ok { + fileName = textOf(rawJSONValue(v)) + } + } + } + } + if fileName == "" && len(p.Filename) > 0 { + fileName = textOf(rawJSONValue(p.Filename)) + } + fileData := buildFileDataURL(p.Data, p.MediaType, "application/pdf") + o := NewObject() + o.SetString("type", "file") + inner := NewObject() + inner.SetString("filename", fileName) + inner.SetString("file_data", fileData) + o.SetObject("file", inner) + if isHTTPURL(fileData) { + // The http(s) branch returns WITHOUT cache_control. + return o.value(), nil + } + return withCC(o) + } + + // Anything else becomes an empty text part. + o := NewObject() + o.SetString("type", "text") + o.SetString("text", "") + return withCC(o) +} + +func convertAssistantMessage(msg msgmodel.ModelMessage, tracker *reasoningDuplicateTracker) (*Object, error) { + parts, err := contentParts(msg) + if err != nil { + return nil, err + } + + var text, reasoning strings.Builder + toolCalls := []jsonValue{} + for _, part := range parts { + switch p := part.(type) { + case msgmodel.TextContent: + text.WriteString(p.Text) + case msgmodel.ToolCallContent: + args, err := DeterministicStringify(p.Input) + if err != nil { + if errors.Is(err, errUndefinedStringify) { + // An absent input means no `arguments` key on the wire + // object. + args = nil + } else { + return nil, errors.New("orclient: tool call " + p.ToolCallID + ": " + err.Error()) + } + } + call := NewObject() + call.SetString("id", p.ToolCallID) + call.SetString("type", "function") + fn := NewObject() + fn.SetString("name", p.ToolName) + if args != nil { + fn.SetString("arguments", string(args)) + } + call.SetObject("function", fn) + toolCalls = append(toolCalls, call.value()) + case msgmodel.ReasoningContent: + reasoning.WriteString(p.Text) + case msgmodel.FileContent: + // File parts are not sent on assistant messages. + } + } + + messageDetails, messageDetailsPresent := openrouterReasoningDetails(msg.ProviderOptions) + annotations, _ := openrouterAnnotations(msg.ProviderOptions) + + candidate := messageDetails + haveCandidate := messageDetailsPresent + if !haveCandidate { + candidate, haveCandidate = findFirstReasoningDetails(parts) + } + + var finalDetails []ReasoningDetail + haveFinal := false + if haveCandidate { + valid := make([]ReasoningDetail, 0, len(candidate)) + for _, d := range candidate { + if d.Type != ReasoningDetailText { + valid = append(valid, d) + continue + } + format := rawString(d.Format) + if format == "" { + format = DefaultReasoningFormat + } + if format != "anthropic-claude-v1" && format != "google-gemini-v1" { + valid = append(valid, d) + continue + } + if rawTruthy(d.Signature) { + valid = append(valid, d) + } + } + unique := make([]ReasoningDetail, 0, len(valid)) + for _, d := range valid { + if tracker.upsert(d) { + unique = append(unique, d) + } + } + finalDetails = unique + haveFinal = true + } + + out := NewObject() + out.SetString("role", "assistant") + out.SetString("content", text.String()) + if len(toolCalls) > 0 { + out.set("tool_calls", jsonValue{Kind: kindArray, Array: toolCalls}) + } + // Reasoning text is sent only alongside non-empty reasoning details. + if reasoning.Len() > 0 && haveFinal && len(finalDetails) > 0 { + out.SetString("reasoning", reasoning.String()) + } + if haveFinal { + encoded, err := json.Marshal(finalDetails) + if err != nil { + return nil, err + } + if err := out.Set("reasoning_details", encoded); err != nil { + return nil, err + } + } + if annotations != nil { + if err := out.Set("annotations", annotations); err != nil { + return nil, err + } + } + if cc := getCacheControl(msg.ProviderOptions); cc != nil { + if err := out.Set("cache_control", cc); err != nil { + return nil, err + } + } + return out, nil +} + +// findFirstReasoningDetails looks at tool-call parts first, then reasoning +// parts, taking the first non-empty array found. +func findFirstReasoningDetails(parts []any) ([]ReasoningDetail, bool) { + for _, part := range parts { + p, ok := part.(msgmodel.ToolCallContent) + if !ok { + continue + } + if details, present := openrouterReasoningDetails(p.ProviderOptions); present && len(details) > 0 { + return details, true + } + } + for _, part := range parts { + p, ok := part.(msgmodel.ReasoningContent) + if !ok { + continue + } + if details, present := openrouterReasoningDetails(p.ProviderOptions); present && len(details) > 0 { + return details, true + } + } + return nil, false +} + +func openrouterReasoningDetails(providerOptions json.RawMessage) ([]ReasoningDetail, bool) { + raw, ok := openrouterNamespaceField(providerOptions, "reasoning_details") + if !ok { + return nil, false + } + details := ParseReasoningDetails(raw) + if details == nil { + // Not an array at all: treated as absent. + return nil, false + } + return details, true +} + +func openrouterAnnotations(providerOptions json.RawMessage) (json.RawMessage, bool) { + return openrouterNamespaceField(providerOptions, "annotations") +} + +func openrouterNamespaceField(providerOptions json.RawMessage, field string) (json.RawMessage, bool) { + if len(providerOptions) == 0 { + return nil, false + } + obj, err := ParseObject(providerOptions) + if err != nil { + return nil, false + } + nsRaw, ok := obj.Get(Service) + if !ok { + return nil, false + } + ns, err := ParseObject(nsRaw) + if err != nil { + return nil, false + } + v, ok := ns.Get(field) + if !ok { + return nil, false + } + return v, true +} + +// ── getToolResultContent ───────────────────────────────────────────────── + +// getToolResultContent returns the tool message's content and the images that +// content only names: the caller sends those on in a user message. +func getToolResultContent(tr msgmodel.ToolResultContent) (jsonValue, []msgmodel.ToolOutputContentMedia, error) { + switch tr.Output.Type { + case "text", "error-text": + s, _ := tr.Output.Value.(string) + return stringValue(s), nil, nil + case "json", "error-json": + encoded, err := json.Marshal(tr.Output.Value) + if err != nil { + return jsonValue{}, nil, err + } + return stringValue(string(encoded)), nil, nil + case "content": + items, _ := tr.Output.Value.([]any) + out := make([]jsonValue, 0, len(items)) + var images []msgmodel.ToolOutputContentMedia + for _, item := range items { + mapped, image, err := mapToolResultContentPart(item) + if err != nil { + return jsonValue{}, nil, err + } + out = append(out, mapped) + if image != nil { + images = append(images, *image) + } + } + return jsonValue{Kind: kindArray, Array: out}, images, nil + case "execution-denied": + reason, ok := tr.Output.Value.(string) + if !ok || reason == "" { + reason = "Tool execution denied" + } + return stringValue(reason), nil, nil + } + // An unknown output type serialises as null content. + return jsonValue{Kind: kindNull}, nil, nil +} + +// mapToolResultContentPart handles the two element shapes toModelOutput can +// produce: a text part and a `media` part. Anything else is stringified whole. +// An image is returned alongside its note so the caller can send it where the +// wire accepts an image; every other part returns a nil image. +func mapToolResultContentPart(item any) (jsonValue, *msgmodel.ToolOutputContentMedia, error) { + switch p := item.(type) { + case msgmodel.ToolOutputContentText: + o := NewObject() + o.SetString("type", "text") + o.SetString("text", p.Text) + return o.value(), nil, nil + case msgmodel.ToolOutputContentMedia: + mediaType := p.MediaType + if mediaType == "" { + mediaType = "unknown" + } + // A tool message's content is text on this schema, so an image is + // only announced here and sent as an `image_url` part on the user + // message that follows the tool run. + if strings.HasPrefix(p.MediaType, "image/") { + o := NewObject() + o.SetString("type", "text") + o.SetString("text", "[attachment: "+mediaType+" (sent as an image in the next user message)]") + return o.value(), &p, nil + } + // Any other media -- a PDF, say -- has no inline shape the model + // can read at all, and its base64 payload would cost a fortune in + // tokens for nothing. Name the attachment instead of sending it. + o := NewObject() + o.SetString("type", "text") + o.SetString("text", "[attachment: "+mediaType+" (content not sent)]") + return o.value(), nil, nil + } + encoded, err := json.Marshal(item) + if err != nil { + return jsonValue{}, nil, err + } + o := NewObject() + o.SetString("type", "text") + o.SetString("text", string(encoded)) + return o.value(), nil, nil +} + +// ── file url helpers ───────────────────────────────────────────────────── + +func buildFileDataURL(data, mediaType, defaultMediaType string) string { + if isHTTPURL(data) { + return data + } + if strings.HasPrefix(data, "data:") { + return data + } + mt := mediaType + if mt == "" { + mt = defaultMediaType + } + return "data:" + mt + ";base64," + data +} + +func isHTTPURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil { + return false + } + return u.Scheme == "http" || u.Scheme == "https" +} + +// mimeToAudioFormat maps an audio mime subtype to the wire format name. +var mimeToAudioFormat = map[string]string{ + "mpeg": "mp3", + "mp3": "mp3", + "x-wav": "wav", + "wave": "wav", + "wav": "wav", + "ogg": "ogg", + "x-flac": "flac", + "flac": "flac", + "aac": "aac", + "x-m4a": "m4a", + "m4a": "m4a", + "mp4": "m4a", + "webm": "webm", + "opus": "opus", + "pcm": "pcm16", + "pcm16": "pcm16", + "L16": "pcm16", +} + +func inputAudioData(p msgmodel.FileContent) (*Object, error) { + fileData := buildFileDataURL(p.Data, p.MediaType, "audio/mpeg") + data := base64FromDataURL(fileData) + mediaType := p.MediaType + if mediaType == "" { + mediaType = "audio/mpeg" + } + rawFormat := strings.Replace(mediaType, "audio/", "", 1) + format, ok := mimeToAudioFormat[rawFormat] + if !ok { + return nil, fmt.Errorf("Unsupported audio format: %q", mediaType) + } + o := NewObject() + o.SetString("data", data) + o.SetString("format", format) + return o, nil +} + +// base64FromDataURL extracts the payload of a `data:<type>;base64,<payload>` +// URL, falling back to the input when it does not match. +func base64FromDataURL(dataURL string) string { + if !strings.HasPrefix(dataURL, "data:") { + return dataURL + } + rest := dataURL[len("data:"):] + semi := strings.IndexByte(rest, ';') + if semi < 0 { + return dataURL + } + if !strings.HasPrefix(rest[semi:], ";base64,") { + return dataURL + } + payload := rest[semi+len(";base64,"):] + if payload == "" { + return dataURL + } + // `[^;]*` forbids a `;` before the `;base64,`. + if strings.ContainsRune(rest[:semi], ';') { + return dataURL + } + return payload +} diff --git a/internal/seniordev/engine/orclient/convert_tool_result_test.go b/internal/seniordev/engine/orclient/convert_tool_result_test.go new file mode 100644 index 0000000000..52aedc2680 --- /dev/null +++ b/internal/seniordev/engine/orclient/convert_tool_result_test.go @@ -0,0 +1,344 @@ +//go:build !windows + +package orclient + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// Long-enough payloads that a stringified copy is unmistakable in the output, +// and distinct enough from each other to pin the order images go out in. +const ( + firstPayload = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + secondPayload = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + thirdPayload = "Qk1GAAAAAAAAADYAAAAoAAAAAQAAAAEAAAABABgAAAAAABAAAAATCwAAEwsAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAA" +) + +// The wordings this converter uses. They are pinned here because they are the +// only thing telling the model that an image it was promised is elsewhere. +const ( + imageNoteWording = "[attachment: image/png (sent as an image in the next user message)]" + imageLeadWording = "The images below are attachments from the preceding tool results, " + + "in the order those tools returned them." +) + +type wireMessage struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` +} + +// convertPrompt converts a prompt and decodes each wire message's role and +// raw content. +func convertPrompt(t *testing.T, prompt ...msgmodel.ModelMessage) []wireMessage { + t.Helper() + messages, err := ConvertToOpenRouterChatMessages(prompt) + if err != nil { + t.Fatal(err) + } + out := make([]wireMessage, 0, len(messages)) + for _, message := range messages { + encoded, err := json.Marshal(message) + if err != nil { + t.Fatal(err) + } + var decoded wireMessage + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("message is not an object: %s", encoded) + } + out = append(out, decoded) + } + return out +} + +// roles is the message sequence a test asserts on. +func roles(messages []wireMessage) []string { + out := make([]string, 0, len(messages)) + for _, message := range messages { + out = append(out, message.Role) + } + return out +} + +func assertRoles(t *testing.T, messages []wireMessage, want ...string) { + t.Helper() + got := roles(messages) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("roles = %v, want %v", got, want) + } +} + +// partsOf decodes a message's content as an array of typed parts. +func partsOf(t *testing.T, message wireMessage) []json.RawMessage { + t.Helper() + var parts []json.RawMessage + if err := json.Unmarshal(message.Content, &parts); err != nil { + t.Fatalf("%s content is not a part array: %s", message.Role, message.Content) + } + return parts +} + +// toolResult is one `{type:"content"}` tool result carrying `items`. +func toolResult(callID string, items ...any) msgmodel.ToolResultContent { + return msgmodel.ToolResultContent{ + Type: "tool-result", + ToolCallID: callID, + ToolName: "read", + Output: msgmodel.ToolOutput{Type: "content", Value: items}, + } +} + +// toolMessage is a tool message carrying a single tool result. +func toolMessage(callID string, items ...any) msgmodel.ModelMessage { + return msgmodel.ModelMessage{Role: "tool", Content: []any{toolResult(callID, items...)}} +} + +func mediaPart(mediaType, data string) msgmodel.ToolOutputContentMedia { + return msgmodel.ToolOutputContentMedia{Type: "media", MediaType: mediaType, Data: data} +} + +func textPart(s string) msgmodel.ToolOutputContentText { + return msgmodel.ToolOutputContentText{Type: "text", Text: s} +} + +func assistantText(s string) msgmodel.ModelMessage { + return msgmodel.ModelMessage{Role: "assistant", Content: []any{ + msgmodel.TextContent{Type: "text", Text: s}, + }} +} + +// assertNoPayload fails when any base64 blob reached the given messages. +func assertNoPayload(t *testing.T, messages []wireMessage) { + t.Helper() + for _, message := range messages { + for _, payload := range []string{firstPayload, secondPayload, thirdPayload} { + if strings.Contains(string(message.Content), payload) { + t.Fatalf("base64 payload reached the %s message: %s", message.Role, message.Content) + } + } + } +} + +// userImagePart is the `image_url` part the user-message path builds for a +// file, which the tool-result path has to match exactly. +func userImagePart(t *testing.T, mediaType, data string) json.RawMessage { + t.Helper() + messages := convertPrompt(t, msgmodel.ModelMessage{Role: "user", Content: []any{ + msgmodel.FileContent{Type: "file", MediaType: mediaType, Data: data}, + }}) + parts := partsOf(t, messages[0]) + if len(parts) != 1 { + t.Fatalf("want one user part, got %d", len(parts)) + } + return parts[0] +} + +// An image is named in the tool result and delivered by the user message that +// follows it, because the wire accepts an image only on a user message. +func TestToolResultImageIsNamedAndSentInAFollowingUserMessage(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", + textPart("Image read successfully"), + mediaPart("image/png", firstPayload), + )) + assertRoles(t, messages, "tool", "user") + + toolParts := partsOf(t, messages[0]) + if len(toolParts) != 2 { + t.Fatalf("want two tool parts, got %d: %s", len(toolParts), messages[0].Content) + } + if want := `{"type":"text","text":"Image read successfully"}`; string(toolParts[0]) != want { + t.Fatalf("text part = %s, want %s", toolParts[0], want) + } + wantNote := `{"type":"text","text":"` + imageNoteWording + `"}` + if string(toolParts[1]) != wantNote { + t.Fatalf("note part = %s\nwant %s", toolParts[1], wantNote) + } + assertNoPayload(t, messages[:1]) + + userParts := partsOf(t, messages[1]) + if len(userParts) != 2 { + t.Fatalf("want a lead text and one image, got %d: %s", len(userParts), messages[1].Content) + } + var lead struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(userParts[0], &lead); err != nil { + t.Fatalf("lead part is not a text part: %s", userParts[0]) + } + if lead.Type != "text" || lead.Text != imageLeadWording { + t.Fatalf("lead part = %s, want text %q", userParts[0], imageLeadWording) + } + want := `{"type":"image_url","image_url":{"url":"data:image/png;base64,` + firstPayload + `"}}` + if string(userParts[1]) != want { + t.Fatalf("image part = %s\nwant %s", userParts[1], want) + } +} + +// The delivered image part must be byte-identical to the one the user-message +// path builds for the same file, so a tool result and an attached file look +// the same to the provider. +func TestToolResultImagePartMatchesUserImagePart(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", mediaPart("image/png", firstPayload))) + assertRoles(t, messages, "tool", "user") + got := partsOf(t, messages[1])[1] + want := userImagePart(t, "image/png", firstPayload) + if string(got) != string(want) { + t.Fatalf("tool image part = %s\nuser image part = %s", got, want) + } +} + +// Every image of a run reaches one user message, in the order the tools +// returned them -- whether they came from one tool result or several. +func TestToolRunImagesGoOutTogetherInOrder(t *testing.T) { + messages := convertPrompt(t, + msgmodel.ModelMessage{Role: "tool", Content: []any{ + toolResult("call_1", + textPart("Image read successfully"), + mediaPart("image/png", firstPayload), + mediaPart("image/gif", secondPayload), + ), + toolResult("call_2", + textPart("Image read successfully"), + mediaPart("image/bmp", thirdPayload), + ), + }}, + ) + assertRoles(t, messages, "tool", "tool", "user") + assertNoPayload(t, messages[:2]) + + userParts := partsOf(t, messages[2]) + if len(userParts) != 4 { + t.Fatalf("want a lead text and three images, got %d: %s", len(userParts), messages[2].Content) + } + for i, want := range []string{ + `{"type":"image_url","image_url":{"url":"data:image/png;base64,` + firstPayload + `"}}`, + `{"type":"image_url","image_url":{"url":"data:image/gif;base64,` + secondPayload + `"}}`, + `{"type":"image_url","image_url":{"url":"data:image/bmp;base64,` + thirdPayload + `"}}`, + } { + if string(userParts[i+1]) != want { + t.Fatalf("image %d = %s\nwant %s", i, userParts[i+1], want) + } + } +} + +// The tool messages of one assistant turn stay contiguous: the images wait for +// the end of the run and go out in a single user message after the last of +// them, before whatever follows. +func TestToolRunFlushesAfterTheLastToolMessage(t *testing.T) { + messages := convertPrompt(t, + assistantText("reading both"), + toolMessage("call_1", textPart("Image read successfully"), mediaPart("image/png", firstPayload)), + toolMessage("call_2", textPart("Image read successfully"), mediaPart("image/gif", secondPayload)), + assistantText("both read"), + ) + assertRoles(t, messages, "assistant", "tool", "tool", "user", "assistant") + + userParts := partsOf(t, messages[3]) + if len(userParts) != 3 { + t.Fatalf("want a lead text and two images, got %d: %s", len(userParts), messages[3].Content) + } + if !strings.Contains(string(userParts[1]), firstPayload) || + !strings.Contains(string(userParts[2]), secondPayload) { + t.Fatalf("images out of order: %s", messages[3].Content) + } +} + +// A run that ends the prompt flushes at the end, so the images are the last +// thing the model sees. +func TestToolRunAtTheEndOfThePromptFlushesLast(t *testing.T) { + messages := convertPrompt(t, + msgmodel.ModelMessage{Role: "user", Content: "read this"}, + assistantText("reading"), + toolMessage("call_1", textPart("Image read successfully"), mediaPart("image/png", firstPayload)), + ) + assertRoles(t, messages, "user", "assistant", "tool", "user") + if !strings.Contains(string(messages[3].Content), firstPayload) { + t.Fatalf("trailing user message carries no image: %s", messages[3].Content) + } +} + +// A run with nothing to relocate emits no user message at all. +func TestToolRunWithoutImagesEmitsNoUserMessage(t *testing.T) { + messages := convertPrompt(t, + assistantText("reading"), + toolMessage("call_1", textPart(" 1\thello\n")), + toolMessage("call_2", textPart(" 1\tworld\n")), + assistantText("done"), + ) + assertRoles(t, messages, "assistant", "tool", "tool", "assistant") +} + +// A PDF has no inline shape anywhere here: it is named, its payload never goes +// out, and nothing follows the tool message. +func TestToolResultPDFIsNamedAndSendsNoUserMessage(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", + textPart("PDF read successfully"), + mediaPart("application/pdf", firstPayload), + )) + assertRoles(t, messages, "tool") + parts := partsOf(t, messages[0]) + if len(parts) != 2 { + t.Fatalf("want two parts, got %d: %s", len(parts), messages[0].Content) + } + want := `{"type":"text","text":"[attachment: application/pdf (content not sent)]"}` + if string(parts[1]) != want { + t.Fatalf("pdf part = %s\nwant %s", parts[1], want) + } + assertNoPayload(t, messages) +} + +// A media part with no media type still gets named rather than stringified. +func TestToolResultMediaWithoutMediaTypeIsNamed(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", mediaPart("", firstPayload))) + assertRoles(t, messages, "tool") + parts := partsOf(t, messages[0]) + want := `{"type":"text","text":"[attachment: unknown (content not sent)]"}` + if string(parts[0]) != want { + t.Fatalf("part = %s\nwant %s", parts[0], want) + } + assertNoPayload(t, messages) +} + +// A text-only tool result is untouched by the media handling: one tool +// message, exactly these bytes, and nothing after it. +func TestToolResultTextOnlyIsUnchanged(t *testing.T) { + converted, err := ConvertToOpenRouterChatMessages([]msgmodel.ModelMessage{ + toolMessage("call_1", textPart(" 1\thello\n")), + }) + if err != nil { + t.Fatal(err) + } + if len(converted) != 1 { + t.Fatalf("want one message, got %d", len(converted)) + } + encoded, err := json.Marshal(converted[0]) + if err != nil { + t.Fatal(err) + } + want := `{"role":"tool","tool_call_id":"call_1",` + + `"content":[{"type":"text","text":" 1\thello\n"}],"name":"read"}` + if string(encoded) != want { + t.Fatalf("tool message = %s\nwant %s", encoded, want) + } +} + +// An element that is neither text nor media keeps the stringified fallback. +func TestToolResultUnknownElementIsStillStringified(t *testing.T) { + messages := convertPrompt(t, toolMessage("call_1", map[string]any{"type": "widget", "n": 1})) + assertRoles(t, messages, "tool") + parts := partsOf(t, messages[0]) + var typed struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(parts[0], &typed); err != nil { + t.Fatal(err) + } + if typed.Type != "text" || !strings.Contains(typed.Text, `"widget"`) { + t.Fatalf("unknown element = %s", parts[0]) + } +} diff --git a/internal/seniordev/engine/orclient/convert_user_content_test.go b/internal/seniordev/engine/orclient/convert_user_content_test.go new file mode 100644 index 0000000000..81669583c2 --- /dev/null +++ b/internal/seniordev/engine/orclient/convert_user_content_test.go @@ -0,0 +1,108 @@ +//go:build !windows + +package orclient + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// A user message whose content is anything but a string or a []any of content +// parts must be REFUSED at the wire, not sent as `"content": []`. +func TestConvertUserMessageRefusesUnconvertibleContent(t *testing.T) { + cases := map[string]any{ + "typed text slice": []msgmodel.TextContent{{Type: "text", Text: "hello"}}, + "empty part list": []any{}, + "nil": nil, + "empty string": "", + "number": 42, + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + _, err := ConvertToOpenRouterChatMessages([]msgmodel.ModelMessage{ + {Role: "user", Content: content}, + }) + if err == nil { + t.Fatalf("content %#v was converted instead of refused", content) + } + }) + } +} + +func TestConvertUserMessageAcceptsStringAndCanonicalParts(t *testing.T) { + const text = "<conversation>\nHELLO TRANSCRIPT\n</conversation>" + for name, msg := range map[string]msgmodel.ModelMessage{ + "bare string": {Role: "user", Content: text}, + "UserText": msgmodel.UserText(text), + } { + t.Run(name, func(t *testing.T) { + body, err := BuildRequestBody(RequestParams{ModelID: "m", Prompt: []msgmodel.ModelMessage{msg}}) + if err != nil { + t.Fatal(err) + } + want := `{"role":"user","content":"<conversation>\nHELLO TRANSCRIPT\n</conversation>"}` + if !strings.Contains(string(body), want) { + t.Fatalf("wire body = %s\nwant message %s", body, want) + } + }) + } +} + +// An assistant message with bare string content (the max-steps prompt is +// built that way) must reach the wire as text, not as an empty message. +func TestConvertAssistantMessageAcceptsStringContent(t *testing.T) { + body, err := BuildRequestBody(RequestParams{ModelID: "m", Prompt: []msgmodel.ModelMessage{ + {Role: "assistant", Content: "You have reached the step limit."}, + }}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"content":"You have reached the step limit."`) { + t.Fatalf("assistant text lost on the wire: %s", body) + } + if _, err := ConvertToOpenRouterChatMessages([]msgmodel.ModelMessage{ + {Role: "assistant", Content: []msgmodel.TextContent{{Type: "text", Text: "x"}}}, + }); err == nil { + t.Fatal("typed assistant content was converted instead of refused") + } +} + +// The whole request body, the way DoStream builds it: the transcript handed +// to the summarizer must be present verbatim in the bytes that leave. +func TestBuildRequestBodyCarriesTheSummaryTranscript(t *testing.T) { + const transcript = "[User]: Fix src/a.go\n[Assistant]: Reading the file first." + maximum := float64(1024) + body, err := BuildRequestBody(RequestParams{ + ModelID: "vendor/model", + MaxOutputTokens: &maximum, + Prompt: []msgmodel.ModelMessage{ + {Role: "system", Content: "You are a context serializer."}, + msgmodel.UserText("<conversation>\n" + transcript + "\n</conversation>\n\n## Working State"), + }, + }) + if err != nil { + t.Fatal(err) + } + var decoded struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("body is not the expected shape: %v\n%s", err, body) + } + if len(decoded.Messages) != 2 || decoded.Messages[1].Role != "user" { + t.Fatalf("wire messages = %#v", decoded.Messages) + } + var content string + if err := json.Unmarshal(decoded.Messages[1].Content, &content); err != nil { + t.Fatalf("user content is not a bare string: %s", decoded.Messages[1].Content) + } + if !strings.Contains(content, transcript) { + t.Fatalf("summary request lost its transcript on the wire:\n%s", body) + } +} diff --git a/internal/seniordev/engine/orclient/helpers_test.go b/internal/seniordev/engine/orclient/helpers_test.go new file mode 100644 index 0000000000..4c0ecbf028 --- /dev/null +++ b/internal/seniordev/engine/orclient/helpers_test.go @@ -0,0 +1,45 @@ +//go:build !windows + +package orclient + +import ( + "io" + "math/rand" + "strings" +) + +// Shared helpers for the tests in this package. + +// testBaseURL stands in for the model API codeaf serves a run. The tests that +// use it answer every request through their own fetcher, so nothing is ever +// sent to it; a client without one has nowhere to send a request at all. +const testBaseURL = "http://model-api.invalid/v1" + +// runSSE drives the translator over a raw SSE body the way Stream.Next does: +// decode a frame, drop `[DONE]`, parse, transform; flush at end of stream. +func runSSE(raw string, seed uint32) (parts []StreamPart, thrown error) { + restore := SetRandomForTesting(rand.New(rand.NewSource(int64(seed))).Float64) + defer restore() + + tr := NewTranslator() + dec := NewSSEDecoder(strings.NewReader(raw)) + for { + ev, err := dec.Next() + if err == io.EOF { + break + } + if err != nil { + break + } + if ev.Data == DoneSentinel { + continue + } + emitted, err := tr.Transform(ParseChunk(ev.Data)) + parts = append(parts, emitted...) + if err != nil { + // A throw out of the transform tears the stream down: no flush. + return parts, err + } + } + return append(parts, tr.Flush()...), nil +} diff --git a/internal/seniordev/engine/orclient/jsonval.go b/internal/seniordev/engine/orclient/jsonval.go new file mode 100644 index 0000000000..8599247d9a --- /dev/null +++ b/internal/seniordev/engine/orclient/jsonval.go @@ -0,0 +1,502 @@ +//go:build !windows + +package orclient + +// An insertion-ordered JSON value model. +// +// The request body, the messages inside it and the tool definitions are +// built with this model instead of map[string]any so that the same input +// always produces the same bytes: key order is the order the code writes +// keys in (or, for DeterministicStringify, sorted), which keeps prompt-cache +// keys stable across calls. Numbers parsed from JSON keep their original +// literal so a re-encoded document does not drift. + +import ( + "bytes" + "encoding/json" + "errors" + "sort" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// jsonKind tags a jsonValue. +type jsonKind uint8 + +const ( + kindNull jsonKind = iota + kindBool + kindNumber + kindString + kindArray + kindObject +) + +// jsonValue is a JSON value with object key order preserved. +type jsonValue struct { + Kind jsonKind + Bool bool + Number float64 + // Literal is the number's original JSON text when it was parsed rather + // than computed; it is re-emitted verbatim. + Literal string + String string + Array []jsonValue + Object []jsonMember +} + +type jsonMember struct { + Key string + Value jsonValue +} + +// parseJSONValue decodes raw into the ordered model. +func parseJSONValue(raw []byte) (jsonValue, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + v, err := decodeJSONValue(dec) + if err != nil { + return jsonValue{}, err + } + if _, err := dec.Token(); err == nil { + return jsonValue{}, errors.New("unexpected trailing JSON content") + } + return v, nil +} + +func decodeJSONValue(dec *json.Decoder) (jsonValue, error) { + tok, err := dec.Token() + if err != nil { + return jsonValue{}, err + } + return decodeJSONValueFrom(dec, tok) +} + +func decodeJSONValueFrom(dec *json.Decoder, tok json.Token) (jsonValue, error) { + switch t := tok.(type) { + case nil: + return jsonValue{Kind: kindNull}, nil + case bool: + return jsonValue{Kind: kindBool, Bool: t}, nil + case string: + return jsonValue{Kind: kindString, String: t}, nil + case json.Number: + f, _ := strconv.ParseFloat(t.String(), 64) + return jsonValue{Kind: kindNumber, Number: f, Literal: t.String()}, nil + case json.Delim: + switch t { + case '[': + items := []jsonValue{} + for dec.More() { + item, err := decodeJSONValue(dec) + if err != nil { + return jsonValue{}, err + } + items = append(items, item) + } + if _, err := dec.Token(); err != nil { + return jsonValue{}, err + } + return jsonValue{Kind: kindArray, Array: items}, nil + case '{': + var members []jsonMember + index := map[string]int{} + for dec.More() { + keyTok, err := dec.Token() + if err != nil { + return jsonValue{}, err + } + key, ok := keyTok.(string) + if !ok { + return jsonValue{}, errors.New("expected object key") + } + value, err := decodeJSONValue(dec) + if err != nil { + return jsonValue{}, err + } + // A duplicate key keeps its first position and takes the + // later value. + if at, seen := index[key]; seen { + members[at].Value = value + continue + } + index[key] = len(members) + members = append(members, jsonMember{Key: key, Value: value}) + } + if _, err := dec.Token(); err != nil { + return jsonValue{}, err + } + return jsonValue{Kind: kindObject, Object: members}, nil + } + } + return jsonValue{}, errors.New("unexpected JSON token") +} + +// marshalJSONValue encodes a value compactly, without HTML escaping. +func marshalJSONValue(v jsonValue) ([]byte, error) { + var buf bytes.Buffer + if err := writeJSONValue(&buf, v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeJSONValue(buf *bytes.Buffer, v jsonValue) error { + switch v.Kind { + case kindNull: + buf.WriteString("null") + case kindBool: + if v.Bool { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case kindNumber: + if v.Literal != "" { + buf.WriteString(v.Literal) + } else { + b, err := json.Marshal(v.Number) + if err != nil { + return err + } + buf.Write(b) + } + case kindString: + b, err := jsonutil.Marshal(v.String) + if err != nil { + return err + } + buf.Write(b) + case kindArray: + buf.WriteByte('[') + for i, el := range v.Array { + if i > 0 { + buf.WriteByte(',') + } + if err := writeJSONValue(buf, el); err != nil { + return err + } + } + buf.WriteByte(']') + case kindObject: + buf.WriteByte('{') + for i, m := range v.Object { + if i > 0 { + buf.WriteByte(',') + } + k, err := jsonutil.Marshal(m.Key) + if err != nil { + return err + } + buf.Write(k) + buf.WriteByte(':') + if err := writeJSONValue(buf, m.Value); err != nil { + return err + } + } + buf.WriteByte('}') + } + return nil +} + +// ── DeterministicStringify ──────────────────────────────────────────────── + +// DeterministicStringify re-encodes a JSON document with every object's keys +// sorted, recursively. Assistant tool-call arguments go through it before +// they are sent back to the provider so the same call always serialises the +// same way. An empty input is reported rather than encoded, since the caller +// omits the field in that case. +func DeterministicStringify(raw json.RawMessage) ([]byte, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, errUndefinedStringify + } + v, err := parseJSONValue(raw) + if err != nil { + return nil, err + } + return marshalJSONValue(sortKeys(v)) +} + +var errUndefinedStringify = errors.New("deterministicStringify: undefined value") + +// sortKeys sorts object keys recursively; arrays recurse element-wise. +func sortKeys(v jsonValue) jsonValue { + switch v.Kind { + case kindArray: + out := make([]jsonValue, len(v.Array)) + for i, el := range v.Array { + out[i] = sortKeys(el) + } + return jsonValue{Kind: kindArray, Array: out} + case kindObject: + members := make([]jsonMember, len(v.Object)) + copy(members, v.Object) + sort.SliceStable(members, func(i, j int) bool { + return members[i].Key < members[j].Key + }) + for i := range members { + members[i].Value = sortKeys(members[i].Value) + } + return jsonValue{Kind: kindObject, Object: members} + default: + return v + } +} + +// ── ordered object builder ──────────────────────────────────────────────── + +// Object is an insertion-ordered JSON object. Setting an existing key +// replaces its value in place. +type Object struct { + members []jsonMember + index map[string]int +} + +// NewObject builds an empty ordered object. +func NewObject() *Object { return &Object{index: map[string]int{}} } + +// ParseObject decodes a JSON object literal into an ordered Object. Empty +// input yields an empty object. +func ParseObject(raw []byte) (*Object, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return NewObject(), nil + } + v, err := parseJSONValue(raw) + if err != nil { + return nil, err + } + if v.Kind != kindObject { + return nil, errors.New("orclient: expected a JSON object") + } + return objectFromValue(v), nil +} + +func (o *Object) set(key string, val jsonValue) { + if o.index == nil { + o.index = map[string]int{} + } + if at, ok := o.index[key]; ok { + o.members[at].Value = val + return + } + o.index[key] = len(o.members) + o.members = append(o.members, jsonMember{Key: key, Value: val}) +} + +// Set assigns raw JSON to key. +func (o *Object) Set(key string, raw json.RawMessage) error { + v, err := parseJSONValue(raw) + if err != nil { + return err + } + o.set(key, v) + return nil +} + +// SetString assigns a string value. +func (o *Object) SetString(key, value string) { + o.set(key, jsonValue{Kind: kindString, String: value}) +} + +// SetNumber assigns a numeric value. +func (o *Object) SetNumber(key string, value float64) { + o.set(key, jsonValue{Kind: kindNumber, Number: value}) +} + +// SetBool assigns a boolean value. +func (o *Object) SetBool(key string, value bool) { + o.set(key, jsonValue{Kind: kindBool, Bool: value}) +} + +// SetObject assigns a nested ordered object. +func (o *Object) SetObject(key string, value *Object) { + o.set(key, value.value()) +} + +// SetNumberPtr assigns a number, or leaves the key absent when value is nil. +func (o *Object) SetNumberPtr(key string, value *float64) { + if value == nil { + return + } + o.SetNumber(key, *value) +} + +// SetArray assigns an array of already-built ordered objects. +func (o *Object) SetArray(key string, items []*Object) { + vals := make([]jsonValue, 0, len(items)) + for _, it := range items { + vals = append(vals, it.value()) + } + o.set(key, jsonValue{Kind: kindArray, Array: vals}) +} + +// Has reports whether key is present. +func (o *Object) Has(key string) bool { + if o == nil || o.index == nil { + return false + } + _, ok := o.index[key] + return ok +} + +// Keys returns the keys in insertion order. +func (o *Object) Keys() []string { + if o == nil { + return nil + } + out := make([]string, 0, len(o.members)) + for _, m := range o.members { + out = append(out, m.Key) + } + return out +} + +// Len is the number of keys. +func (o *Object) Len() int { + if o == nil { + return 0 + } + return len(o.members) +} + +// Get returns the raw JSON of one key. +func (o *Object) Get(key string) (json.RawMessage, bool) { + if o == nil || o.index == nil { + return nil, false + } + at, ok := o.index[key] + if !ok { + return nil, false + } + b, err := marshalJSONValue(o.members[at].Value) + if err != nil { + return nil, false + } + return b, true +} + +func (o *Object) value() jsonValue { + if o == nil { + return jsonValue{Kind: kindObject} + } + members := make([]jsonMember, len(o.members)) + copy(members, o.members) + return jsonValue{Kind: kindObject, Object: members} +} + +// MarshalJSON writes the object in insertion order. +func (o *Object) MarshalJSON() ([]byte, error) { + return marshalJSONValue(o.value()) +} + +// Clone is a shallow copy (values are immutable in this model). +func (o *Object) Clone() *Object { + out := NewObject() + if o == nil { + return out + } + for _, m := range o.members { + out.set(m.Key, m.Value) + } + return out +} + +// Without is a copy of the object with the named keys left out, in the order +// the rest were set. +func (o *Object) Without(keys ...string) *Object { + drop := make(map[string]bool, len(keys)) + for _, key := range keys { + drop[key] = true + } + out := NewObject() + if o == nil { + return out + } + for _, m := range o.members { + if !drop[m.Key] { + out.set(m.Key, m.Value) + } + } + return out +} + +// MergeOptions deep-merges source into target: target's keys come first in +// their own order, source-only keys are appended in source order, and a key +// whose value is an object on both sides is merged recursively in place. +func MergeOptions(target, source *Object) *Object { + out := NewObject() + if target != nil { + for _, m := range target.members { + out.set(m.Key, m.Value) + } + } + if source == nil { + return out + } + for _, m := range source.members { + out.set(m.Key, m.Value) + } + for _, m := range source.members { + if target == nil || !target.Has(m.Key) { + continue + } + left := target.members[target.index[m.Key]].Value + if left.Kind != kindObject || m.Value.Kind != kindObject { + continue + } + out.set(m.Key, MergeOptions(objectFromValue(left), objectFromValue(m.Value)).value()) + } + return out +} + +func objectFromValue(v jsonValue) *Object { + o := NewObject() + for _, m := range v.Object { + o.set(m.Key, m.Value) + } + return o +} + +// stringValue is a small helper for building literal JSON in the body writer. +func stringValue(s string) jsonValue { return jsonValue{Kind: kindString, String: s} } + +func rawJSONValue(raw json.RawMessage) jsonValue { + v, err := parseJSONValue(raw) + if err != nil { + return jsonValue{Kind: kindNull} + } + return v +} + +// textOf renders a JSON scalar as plain text: strings verbatim, numbers and +// booleans as their literals, null as "null". Arrays join their elements +// with commas; objects have no useful text form. +func textOf(v jsonValue) string { + switch v.Kind { + case kindNull: + return "null" + case kindBool: + return strconv.FormatBool(v.Bool) + case kindNumber: + if v.Literal != "" { + return v.Literal + } + return strconv.FormatFloat(v.Number, 'f', -1, 64) + case kindString: + return v.String + case kindArray: + parts := make([]string, 0, len(v.Array)) + for _, el := range v.Array { + if el.Kind == kindNull { + parts = append(parts, "") + continue + } + parts = append(parts, textOf(el)) + } + return strings.Join(parts, ",") + default: + return "" + } +} diff --git a/internal/seniordev/engine/orclient/lowercase.go b/internal/seniordev/engine/orclient/lowercase.go new file mode 100644 index 0000000000..ad76f08355 --- /dev/null +++ b/internal/seniordev/engine/orclient/lowercase.go @@ -0,0 +1,74 @@ +//go:build !windows + +package orclient + +// Full Unicode lowercasing. strings.ToLower uses simple rune mappings; the two +// unconditional rules from Unicode SpecialCasing.txt (dotted capital I, final +// sigma) are applied here as well, so a tool name in any script lowercases the +// way its speakers expect. Tool-call repair runs this on provider-controlled +// names. + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +func unicodeLower(s string) string { + ascii := true + for i := 0; i < len(s); i++ { + if s[i] >= utf8.RuneSelf { + ascii = false + break + } + } + if ascii { + return strings.ToLower(s) + } + + runes := []rune(s) + var b strings.Builder + b.Grow(len(s)) + for i, r := range runes { + switch { + case r == 0x0130: + b.WriteRune('i') + b.WriteRune(0x0307) + case r == 0x03A3 && isFinalSigma(runes, i): + b.WriteRune(0x03C2) + default: + b.WriteRune(unicode.ToLower(r)) + } + } + return b.String() +} + +func isFinalSigma(runes []rune, i int) bool { + j := i - 1 + for j >= 0 && isCaseIgnorable(runes[j]) { + j-- + } + if j < 0 || !isCased(runes[j]) { + return false + } + k := i + 1 + for k < len(runes) && isCaseIgnorable(runes[k]) { + k++ + } + return k >= len(runes) || !isCased(runes[k]) +} + +func isCased(r rune) bool { + return unicode.IsUpper(r) || unicode.IsLower(r) || unicode.IsTitle(r) || + unicode.Is(unicode.Other_Lowercase, r) || unicode.Is(unicode.Other_Uppercase, r) +} + +func isCaseIgnorable(r rune) bool { + switch r { + case '\'', 0x2019, 0x00AD, 0x02B9, 0x0385, 0x1FBF, 0x1FC1, 0x1FCD, 0x1FCE, + 0x1FCF, 0x1FDD, 0x1FDE, 0x1FDF, 0x1FED, 0x1FEE, 0x1FEF, 0x1FFD, 0x1FFE, 0x2027: + return true + } + return unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Cf, r) || + unicode.Is(unicode.Lm, r) || unicode.Is(unicode.Sk, r) +} diff --git a/internal/seniordev/engine/orclient/modelapi_test.go b/internal/seniordev/engine/orclient/modelapi_test.go new file mode 100644 index 0000000000..784ce97cdf --- /dev/null +++ b/internal/seniordev/engine/orclient/modelapi_test.go @@ -0,0 +1,59 @@ +//go:build !windows + +package orclient + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// Every request goes to the route codeaf spells for the base URL it hands the +// run, whatever that base carries at its end: the client appends nothing of +// its own. +func TestARequestGoesToTheModelAPIsOwnRoute(t *testing.T) { + for _, base := range []string{"http://127.0.0.1:4100/v1", "http://127.0.0.1:4100/v1/"} { + var sent string + client := &Client{BaseURL: base, Fetcher: func(req *http.Request) (*http.Response, error) { + sent = req.URL.String() + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil + }} + stream, err := client.DoStream(context.Background(), RequestParams{ModelID: "vendor/model"}) + if err != nil { + t.Fatal(err) + } + _ = stream.Close() + if want := modelapi.ChatURL(base); sent != want { + t.Fatalf("base %q sent the request to %q, want %q", base, sent, want) + } + } +} + +// A client with no model API has nowhere to go, and says so rather than +// reaching for a default service. The route lease the router took for the +// call is settled on the way out, so a refused call never leaks an in-flight +// count. +func TestAClientWithNoModelAPIRefusesAndSettlesItsLease(t *testing.T) { + router := &spyRouter{inflight: 1} + fetched := false + client := &Client{Router: router, RouteChoice: &adaptive.RouteChoice{}, Fetcher: func(*http.Request) (*http.Response, error) { + fetched = true + return nil, errors.New("unreachable") + }} + _, err := client.DoStream(context.Background(), RequestParams{ModelID: "vendor/model"}) + if !errors.Is(err, errNoModelAPI) { + t.Fatalf("err = %v, want the missing model API", err) + } + if fetched { + t.Fatal("a client with no model API sent a request anyway") + } + if router.inflight != 0 || len(router.calls) != 1 { + t.Fatalf("the lease was not settled: inflight=%d calls=%d", router.inflight, len(router.calls)) + } +} diff --git a/internal/seniordev/engine/orclient/orclient.go b/internal/seniordev/engine/orclient/orclient.go new file mode 100644 index 0000000000..cf2fe0f245 --- /dev/null +++ b/internal/seniordev/engine/orclient/orclient.go @@ -0,0 +1,91 @@ +//go:build !windows + +// Package orclient is the OpenRouter streaming client: it assembles the +// chat-completions request body and headers, decodes the SSE response into +// stream parts (text, reasoning, tool calls, usage, finish), validates and +// repairs tool calls against the registered tool set, and registers the +// outcome of each call with the adaptive router. +// +// One DoStream call is one HTTP request. Nothing here loops, retries, +// executes a tool or touches session state; the caller owns all four. Early +// teardown cancels the request context rather than merely closing the body, +// so an abandoned stream never holds its connection open. +package orclient + +import ( + "math/rand/v2" + "sync" + "time" +) + +// ── seams ───────────────────────────────────────────────────────────────── + +var seamMu sync.Mutex + +// random is the id generator's randomness source; generateId draws one +// number per character. +var random func() float64 = defaultRandom + +// nowMS is the millisecond clock. +var nowMS func() float64 = func() float64 { return float64(time.Now().UnixMilli()) } + +// SetRandomForTesting swaps the randomness source. Returns a restore func. +func SetRandomForTesting(f func() float64) func() { + seamMu.Lock() + prev := random + random = f + seamMu.Unlock() + return func() { + seamMu.Lock() + random = prev + seamMu.Unlock() + } +} + +// SetNowForTesting swaps the clock. Returns a restore func. +func SetNowForTesting(f func() float64) func() { + seamMu.Lock() + prev := nowMS + nowMS = f + seamMu.Unlock() + return func() { + seamMu.Lock() + nowMS = prev + seamMu.Unlock() + } +} + +func currentRandom() func() float64 { + seamMu.Lock() + defer seamMu.Unlock() + return random +} + +func currentNow() func() float64 { + seamMu.Lock() + defer seamMu.Unlock() + return nowMS +} + +// idAlphabet is the id character set. +const idAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// idSize is the id length. +const idSize = 16 + +// generateId mints a 16-character id from the alphabet above, drawing one +// random number per character. +func generateId() string { + draw := currentRandom() + out := make([]byte, 0, idSize) + for i := 0; i < idSize; i++ { + idx := int(draw() * float64(len(idAlphabet))) + if idx < 0 || idx >= len(idAlphabet) { + continue + } + out = append(out, idAlphabet[idx]) + } + return string(out) +} + +func defaultRandom() float64 { return rand.Float64() } diff --git a/internal/seniordev/engine/orclient/orclient_test.go b/internal/seniordev/engine/orclient/orclient_test.go new file mode 100644 index 0000000000..0feb59ccc3 --- /dev/null +++ b/internal/seniordev/engine/orclient/orclient_test.go @@ -0,0 +1,516 @@ +//go:build !windows + +package orclient + +// Unit tests for the SSE frame decoder, the tool-call accumulator's index +// model, the ordered-JSON spread semantics, and the surrogate handling. + +import ( + "encoding/json" + "io" + "strings" + "testing" + "unicode/utf16" +) + +// ── SSE decoder ─────────────────────────────────────────────────────────── + +func decodeAll(t *testing.T, raw string) []SSEEvent { + t.Helper() + dec := NewSSEDecoder(strings.NewReader(raw)) + var out []SSEEvent + for { + ev, err := dec.Next() + if err == io.EOF { + return out + } + if err != nil { + t.Fatalf("decode: %v", err) + } + out = append(out, ev) + } +} + +func TestSSEDecoder(t *testing.T) { + cases := []struct { + name string + in string + want []string + }{ + {"lf", "data: a\n\ndata: b\n\n", []string{"a", "b"}}, + {"crlf", "data: a\r\n\r\ndata: b\r\n\r\n", []string{"a", "b"}}, + {"bare cr", "data: a\r\rdata: b\r\r", []string{"a", "b"}}, + {"no space after colon", "data:a\n\n", []string{"a"}}, + {"exactly one leading space stripped", "data: a\n\n", []string{" a"}}, + {"comment lines ignored", ": OPENROUTER PROCESSING\n\ndata: a\n\n", []string{"a"}}, + {"utf8 bom is stripped", "\uFEFFdata: a\n\n", []string{"a"}}, + {"comment inside a frame", "data: a\n: note\ndata: b\n\n", []string{"a\nb"}}, + {"multiple data lines join with newline", "data: a\ndata: b\n\n", []string{"a\nb"}}, + {"empty data field", "data:\n\n", []string{""}}, + {"blank line with no data dispatches nothing", "\n\n\n", nil}, + {"trailing frame without blank line", "data: a\n\ndata: b", []string{"a", "b"}}, + {"trailing frame without newline at all", "data: a", []string{"a"}}, + {"event and id fields do not dispatch", "event: x\nid: 7\ndata: a\n\n", []string{"a"}}, + {"unknown field ignored", "banana: x\ndata: a\n\n", []string{"a"}}, + {"retry field ignored", "retry: 500\ndata: a\n\n", []string{"a"}}, + {"DONE is just data", "data: [DONE]\n\n", []string{"[DONE]"}}, + {"empty input", "", nil}, + {"only comments", ": a\n\n: b\n\n", nil}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got := decodeAll(t, tc.in) + if len(got) != len(tc.want) { + t.Fatalf("event count: want %d %v, got %d %v", len(tc.want), tc.want, len(got), got) + } + for i := range got { + if got[i].Data != tc.want[i] { + t.Errorf("event %d: want %q, got %q", i, tc.want[i], got[i].Data) + } + } + }) + } +} + +func TestSSEDecoderKeepsEventAndIDFields(t *testing.T) { + got := decodeAll(t, "event: message\nid: 42\ndata: a\n\ndata: b\n\n") + if len(got) != 2 { + t.Fatalf("want 2 events, got %d", len(got)) + } + if got[0].Event != "message" || got[0].ID != "42" { + t.Errorf("first event: %+v", got[0]) + } + // `lastEventId` persists across events; `event` resets. + if got[1].Event != "" || got[1].ID != "42" { + t.Errorf("second event: %+v", got[1]) + } +} + +// ── the tool-call accumulator's index model ────────────────────────────── + +// Only a non-negative integer index extends length; any other key is stored +// without being iterated. +func TestToolCallArrayIndexSemantics(t *testing.T) { + a := newToolCallArray() + if a.length != 0 { + t.Fatalf("fresh array length: %d", a.length) + } + + a.setKey("-1", &toolCallSlot{id: "ghost"}) + if a.length != 0 { + t.Errorf("a negative index must NOT extend length, got %d", a.length) + } + if a.get(-1) == nil { + t.Error("the slot must still be reachable at -1") + } + + a.setKey("2", &toolCallSlot{id: "third"}) + if a.length != 3 { + t.Errorf("index 2 must set length to 3, got %d", a.length) + } + + var visited []string + a.iterate(func(s *toolCallSlot) { + if s == nil { + visited = append(visited, "<hole>") + return + } + visited = append(visited, s.id) + }) + want := []string{"<hole>", "<hole>", "third"} + if strings.Join(visited, ",") != strings.Join(want, ",") { + t.Errorf("iteration must walk 0..length-1 including holes: want %v, got %v", want, visited) + } + + a.setKey("1", &toolCallSlot{id: "second"}) + if a.length != 3 { + t.Errorf("filling a hole must not shrink or grow length, got %d", a.length) + } + + a.setKey("1.5", &toolCallSlot{id: "fractional"}) + a.setKey("01", &toolCallSlot{id: "leading-zero"}) + a.setKey("4294967295", &toolCallSlot{id: "uint32-max"}) + if a.length != 3 { + t.Errorf("non-canonical array properties must not extend length, got %d", a.length) + } + for _, key := range []string{"1.5", "01", "4294967295"} { + if a.getKey(key) == nil { + t.Errorf("plain array property %q must remain addressable", key) + } + } +} + +// ── isParsableJson ──────────────────────────────────────────────────────── + +func TestIsParsableJSON(t *testing.T) { + cases := map[string]bool{ + ``: false, + `{}`: true, + `{"a":1}`: true, + `{`: false, + `{"a":`: false, + `[]`: true, + `null`: true, + `"str"`: true, + `42`: true, + `{"a":1}{"b":2}`: false, + ` {"a":1} `: true, + `{"__proto__":{}}`: false, // secureJsonParse rejects it + `{"constructor":{"prototype":{}}}`: false, + `{"constructor":{"x":1}}`: true, + } + for input, want := range cases { + if got := isParsableJSON(input); got != want { + t.Errorf("isParsableJSON(%q) = %v, want %v", input, got, want) + } + } +} + +// ── SanitizeSurrogates: WTF-8 input ────────────────────────────────────── + +// A lone surrogate is not reachable through encoding/json (which maps +// `\uD800` to U+FFFD on decode), but it IS through the WTF-8 byte sequence, +// which a byte-level splice or a non-strict decoder can produce. +func TestSanitizeSurrogatesWTF8(t *testing.T) { + // WTF-8 for U+D800 (a lone high surrogate). + loneHigh := string([]byte{0xED, 0xA0, 0x80}) + // WTF-8 for U+DC00 (a lone low surrogate). + loneLow := string([]byte{0xED, 0xB0, 0x80}) + + if got := SanitizeSurrogates("a" + loneHigh + "b"); got != "a�b" { + t.Errorf("lone high (WTF-8): got %q", got) + } + if got := SanitizeSurrogates("a" + loneLow + "b"); got != "a�b" { + t.Errorf("lone low (WTF-8): got %q", got) + } + // CESU-8: a well-formed PAIR encoded as two 3-byte sequences is a valid + // pair and must be returned unchanged. + pair := loneHigh + string([]byte{0xED, 0xB0, 0x80}) + if got := SanitizeSurrogates(pair); got != pair { + t.Errorf("a well-formed CESU-8 pair must be returned byte-identical: got %q", got) + } + // Two lone highs in a row: the first is unpaired, the second is too. + both := loneHigh + loneHigh + if got := SanitizeSurrogates(both); got != "��" { + t.Errorf("two lone highs: got %q", got) + } + // A genuine astral character survives untouched and does not take the slow + // path at all. + if got := SanitizeSurrogates("a😀b"); got != "a😀b" { + t.Errorf("astral char must be untouched: got %q", got) + } + // A string that cannot contain a surrogate is returned by identity. + plain := "hello, 世界" + if got := SanitizeSurrogates(plain); got != plain { + t.Errorf("plain text must be untouched: got %q", got) + } +} + +func TestUTF16UnitsRoundTrip(t *testing.T) { + for _, s := range []string{"", "abc", "😀", "a😀b", "日本語", "�"} { + units := utf16Units(s) + if got := string(utf16.Decode(units)); got != s { + t.Errorf("round trip of %q gave %q", s, got) + } + } +} + +// ── ordered JSON ────────────────────────────────────────────────────────── + +func TestObjectSpreadKeepsPositionOnOverwrite(t *testing.T) { + target := NewObject() + target.SetNumber("a", 1) + target.SetBool("usage", false) + target.SetNumber("z", 2) + + source := NewObject() + source.SetBool("usage", true) + source.SetString("new", "x") + + out := target.Clone() + for _, k := range source.Keys() { + v, _ := source.Get(k) + if err := out.Set(k, v); err != nil { + t.Fatalf("set: %v", err) + } + } + encoded, err := out.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `{"a":1,"usage":true,"z":2,"new":"x"}` + if string(encoded) != want { + t.Errorf("want %s, got %s", want, encoded) + } +} + +func TestInvalidToolChoiceErrorMatchesProvider(t *testing.T) { + _, err := BuildRequestBody(RequestParams{ + ModelID: "x/y", + Tools: []Tool{{Type: "function", Name: "bash"}}, + ToolChoice: &ToolChoice{Type: "future", ToolName: "bash"}, + }) + if err == nil { + t.Fatal("expected an invalid tool choice to fail") + } + if got, want := err.Error(), `Invalid tool choice type: {"type":"future","toolName":"bash"}`; got != want { + t.Errorf("want %q, got %q", want, got) + } +} + +func TestMergeOptionsDeepMergeSemantics(t *testing.T) { + target, err := ParseObject([]byte(`{"a":{"x":1,"y":2},"b":1,"keep":true}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + source, err := ParseObject([]byte(`{"a":{"y":9,"z":3},"b":{"deep":1},"new":5}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + got, err := MergeOptions(target, source).MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + // `a` recurses in place; `b` is replaced wholesale (target's value is not a + // plain object); `new` is appended. + want := `{"a":{"x":1,"y":9,"z":3},"b":{"deep":1},"keep":true,"new":5}` + if string(got) != want { + t.Errorf("want %s, got %s", want, got) + } +} + +// ── DeterministicStringify edge cases ──────────────────────────────────── + +func TestDeterministicStringifyUndefined(t *testing.T) { + // An absent input is reported to the caller, which drops the key. + if _, err := DeterministicStringify(nil); err == nil { + t.Error("an absent input must be reported, not guessed at") + } + if _, err := DeterministicStringify(json.RawMessage(" ")); err == nil { + t.Error("a whitespace-only input must be reported") + } + if _, err := DeterministicStringify(json.RawMessage("{not json")); err == nil { + t.Error("an unparsable input must be reported") + } +} + +func TestDeterministicStringifySortsKeysRecursively(t *testing.T) { + got, err := DeterministicStringify(json.RawMessage(`{"zeta":1,"Alpha":{"y":[{"b":1,"a":2}],"x":0},"_x":3.50}`)) + if err != nil { + t.Fatalf("DeterministicStringify: %v", err) + } + want := `{"Alpha":{"x":0,"y":[{"a":2,"b":1}]},"_x":3.50,"zeta":1}` + if string(got) != want { + t.Errorf("want %s, got %s", want, got) + } +} + +// ── generateId ──────────────────────────────────────────────────────────── + +func TestGenerateIDDrawsOncePerCharacter(t *testing.T) { + draws := 0 + restore := SetRandomForTesting(func() float64 { + draws++ + return 0 + }) + defer restore() + + id := generateId() + if draws != idSize { + t.Errorf("generateId must draw exactly %d times, drew %d", idSize, draws) + } + if len(id) != idSize { + t.Errorf("id length: want %d, got %d (%q)", idSize, len(id), id) + } + if id != strings.Repeat("0", idSize) { + t.Errorf("random()==0 must select alphabet[0]: got %q", id) + } +} + +// ── finish-reason mapping ───────────────────────────────────────────────── + +func TestMapToUnifiedCoversTheWholeDomain(t *testing.T) { + cases := map[string]string{ + "stop": FinishStop, + "length": FinishLength, + "content_filter": FinishContentFilter, + "function_call": FinishToolCalls, + "tool_calls": FinishToolCalls, + // `error` falls through to `other`, NOT to `error`. The only sources + // of unified `error` are a parse failure, a top-level error payload, + // and a reader error. + "error": FinishOther, + "": FinishOther, + "banana": FinishOther, + "STOP": FinishOther, + "toolUse": FinishOther, + } + for raw, want := range cases { + if got := MapToUnified(raw); got != want { + t.Errorf("MapToUnified(%q) = %q, want %q", raw, got, want) + } + } + if got := MapOpenRouterFinishReason(nil); got.Unified != FinishOther || got.Raw != nil { + t.Errorf("a nil finish_reason must give {other, absent}, got %+v", got) + } +} + +// ── tool map ordering ───────────────────────────────────────────────────── + +func TestSortedToolMapSortsByName(t *testing.T) { + m := SortedToolMap( + ToolSpec{Name: "zebra"}, + ToolSpec{Name: "Apple"}, + ToolSpec{Name: "_hidden"}, + ToolSpec{Name: "invalid"}, + ToolSpec{Name: "apple"}, + ) + got := strings.Join(m.Names(), ",") + want := "Apple,_hidden,apple,invalid,zebra" + if got != want { + t.Errorf("tool map order:\n want %s\n got %s", want, got) + } + active := strings.Join(m.ActiveTools(), ",") + if strings.Contains(active, "invalid") { + t.Errorf("activeTools must exclude the invalid tool, got %s", active) + } +} + +// ── reasoning-details live reference ────────────────────────────────────── + +func TestReasoningDetailsViewIsLive(t *testing.T) { + acc := []ReasoningDetail{{Type: ReasoningDetailText, Text: rawStringLiteral("A")}} + view := detailsRef(&acc) + + first, err := view.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(first) != `[{"type":"reasoning.text","text":"A"}]` { + t.Fatalf("initial: %s", first) + } + + acc = append(acc, ReasoningDetail{Type: ReasoningDetailSummary, Summary: "LATE"}) + second, err := view.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := `[{"type":"reasoning.text","text":"A"},{"type":"reasoning.summary","summary":"LATE"}]` + if string(second) != want { + t.Errorf("a view handed out earlier must see LATER appends:\n want %s\n got %s", want, second) + } + + detached := DetailsValue(acc...) + acc = append(acc, ReasoningDetail{Type: ReasoningDetailEncrypted, Data: "E"}) + third, err := detached.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(third) != want { + t.Errorf("a DETACHED view must not see later appends: got %s", third) + } +} + +func TestEmptyReasoningDetailsMarshalAsAnArrayNotNull(t *testing.T) { + got, err := DetailsValue().MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != "[]" { + t.Errorf("the empty case is meaningful and must be `[]`, got %s", got) + } + var nilView ReasoningDetailsView + got, err = nilView.MarshalJSON() + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(got) != "[]" { + t.Errorf("a zero view must also be `[]`, got %s", got) + } +} + +// ── orTruthy over nullish JSON ─────────────────────────────────────────── + +func TestOrTruthyFalsiness(t *testing.T) { + cases := []struct { + a, b, want string + }{ + {`"x"`, `"y"`, `"x"`}, + {`null`, `"y"`, `"y"`}, + {`""`, `"y"`, `"y"`}, + {``, `"y"`, `"y"`}, + {`null`, ``, ``}, + {`""`, `null`, `null`}, + {`false`, `"y"`, `"y"`}, + {`0`, `"y"`, `"y"`}, + } + for _, tc := range cases { + var a, b json.RawMessage + if tc.a != "" { + a = json.RawMessage(tc.a) + } + if tc.b != "" { + b = json.RawMessage(tc.b) + } + got := orTruthy(a, b) + gotStr := "" + if got != nil { + gotStr = string(got) + } + if gotStr != tc.want { + t.Errorf("orTruthy(%q, %q) = %q, want %q", tc.a, tc.b, gotStr, tc.want) + } + } +} + +// ── tool-call deltas without an index ───────────────────────────────────── + +func TestToolCallDeltaWithoutIndex(t *testing.T) { + // A first no-index delta with non-parsable args must land at slot 0 and be + // flushed rather than dropped. + t.Run("first no-index delta appends at slot 0 and flushes", func(t *testing.T) { + sse := "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"f\",\"arguments\":\"\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n" + parts, thrown := runSSE(sse, 1234) + if thrown != nil { + t.Fatalf("unexpected error: %v", thrown) + } + // The tool call must be present: tool-input-start, tool-input-delta, + // tool-input-end, tool-call, then finish. + hasToolCall := false + for _, p := range parts { + if p.PartType() == PartTypeToolCall { + hasToolCall = true + break + } + } + if !hasToolCall { + t.Error("tool-call part missing — no-index delta was dropped instead of appended at slot 0") + } + }) + + // A later no-index delta still targets the last slot (length-1), merging into it. + t.Run("later no-index delta merges into last slot", func(t *testing.T) { + sse := "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_a\",\"type\":\"function\",\"function\":{\"name\":\"f\",\"arguments\":\"{\\\"x\\\":\"}}]}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\ndata: [DONE]\n\n" + parts, thrown := runSSE(sse, 1234) + if thrown != nil { + t.Fatalf("unexpected error: %v", thrown) + } + // The second chunk has no index; it must merge into index 0 (the last slot). + var toolCall ToolCallPart + found := false + for _, p := range parts { + if tc, ok := p.(ToolCallPart); ok { + toolCall = tc + found = true + break + } + } + if !found { + t.Fatal("no tool-call part found") + } + if toolCall.Input != "{\"x\":1}" { + t.Errorf("merged input = %q, want %q", toolCall.Input, "{\"x\":1}") + } + }) +} diff --git a/internal/seniordev/engine/orclient/parts.go b/internal/seniordev/engine/orclient/parts.go new file mode 100644 index 0000000000..810555f6df --- /dev/null +++ b/internal/seniordev/engine/orclient/parts.go @@ -0,0 +1,565 @@ +//go:build !windows + +package orclient + +// The normalized stream-part union the OpenRouter translator emits. +// +// This union is distinct from the persisted message parts the step loop +// produces: this one uses `delta`, has `response-metadata`, and has no +// `step-start`/`step-finish` framing. +// +// Every variant carries its own MarshalJSON with an explicit key order, so a +// part serialises the same way every time. + +import ( + "bytes" + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// StreamPart is one emitted part. +type StreamPart interface { + PartType() string + json.Marshaler +} + +// Part type tags. +const ( + PartTypeResponseMetadata = "response-metadata" + PartTypeReasoningStart = "reasoning-start" + PartTypeReasoningDelta = "reasoning-delta" + PartTypeReasoningEnd = "reasoning-end" + PartTypeTextStart = "text-start" + PartTypeTextDelta = "text-delta" + PartTypeTextEnd = "text-end" + PartTypeSource = "source" + PartTypeToolInputStart = "tool-input-start" + PartTypeToolInputDelta = "tool-input-delta" + PartTypeToolInputEnd = "tool-input-end" + PartTypeToolCall = "tool-call" + PartTypeFile = "file" + PartTypeError = "error" + PartTypeFinish = "finish" + PartTypeAbort = "abort" +) + +// objectWriter builds a JSON object with explicit key order, skipping keys +// whose value is absent (nil). +type objectWriter struct { + buf bytes.Buffer + first bool + err error +} + +func newObjectWriter() *objectWriter { + w := &objectWriter{first: true} + w.buf.WriteByte('{') + return w +} + +func (w *objectWriter) raw(key string, value json.RawMessage) { + if w.err != nil || value == nil { + return + } + if !w.first { + w.buf.WriteByte(',') + } + w.first = false + k, err := jsonutil.Marshal(key) + if err != nil { + w.err = err + return + } + w.buf.Write(k) + w.buf.WriteByte(':') + w.buf.Write(value) +} + +func (w *objectWriter) str(key, value string) { + enc, err := jsonutil.Marshal(value) + if err != nil { + w.err = err + return + } + w.raw(key, enc) +} + +func (w *objectWriter) marshal(key string, value any) { + enc, err := json.Marshal(value) + if err != nil { + w.err = err + return + } + w.raw(key, enc) +} + +func (w *objectWriter) done() ([]byte, error) { + if w.err != nil { + return nil, w.err + } + w.buf.WriteByte('}') + return w.buf.Bytes(), nil +} + +// ── response-metadata ───────────────────────────────────────────────────── + +// ResponseMetadataPart is emitted TWICE per chunk that carries both an `id` +// and a `model`: once with only `id`, once with only `modelId`. They are +// deliberately separate parts, not one merged part. +type ResponseMetadataPart struct { + ID string + ModelID string + // IsModel selects which of the two emissions this is. + IsModel bool +} + +func (p ResponseMetadataPart) PartType() string { return PartTypeResponseMetadata } + +func (p ResponseMetadataPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeResponseMetadata) + if p.IsModel { + w.str("modelId", p.ModelID) + } else { + w.str("id", p.ID) + } + return w.done() +} + +// ── reasoning ───────────────────────────────────────────────────────────── + +// ReasoningStartPart is `{type:"reasoning-start", id}`. +type ReasoningStartPart struct{ ID string } + +func (p ReasoningStartPart) PartType() string { return PartTypeReasoningStart } + +func (p ReasoningStartPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeReasoningStart) + w.str("id", p.ID) + return w.done() +} + +// ReasoningDeltaPart is `{type:"reasoning-delta", delta, id}` — note `delta` +// precedes `id` here, the reverse of the tool-input parts. +type ReasoningDeltaPart struct { + Delta string + ID string +} + +func (p ReasoningDeltaPart) PartType() string { return PartTypeReasoningDelta } + +func (p ReasoningDeltaPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeReasoningDelta) + w.str("delta", p.Delta) + w.str("id", p.ID) + return w.done() +} + +// ReasoningDetailsView is a LIVE reference to the provider's +// `accumulatedReasoningDetails` array. +// +// This indirection is deliberate: every emitted part shares the SAME +// accumulator, so a `reasoning-end` emitted at chunk 2 and serialised after +// the stream ends shows entries that only arrived at chunk 3. Copying at emit +// time would lose them. +type ReasoningDetailsView struct { + ref *[]ReasoningDetail + own []ReasoningDetail +} + +// DetailsValue builds a detached view, for hand-constructed parts and tests. +func DetailsValue(details ...ReasoningDetail) ReasoningDetailsView { + return ReasoningDetailsView{own: details} +} + +func detailsRef(ref *[]ReasoningDetail) ReasoningDetailsView { + return ReasoningDetailsView{ref: ref} +} + +// Slice resolves the view. +func (v ReasoningDetailsView) Slice() []ReasoningDetail { + if v.ref != nil { + return *v.ref + } + return v.own +} + +// MarshalJSON always writes an array, never null: the empty case is meaningful. +// It signals "the provider produced no reasoning tokens this turn", which is a +// different statement from "no metadata". +func (v ReasoningDetailsView) MarshalJSON() ([]byte, error) { + details := v.Slice() + if details == nil { + details = []ReasoningDetail{} + } + return json.Marshal(details) +} + +// ReasoningEndPart carries the FULL accumulated reasoning_details array. +type ReasoningEndPart struct { + ID string + Details ReasoningDetailsView +} + +func (p ReasoningEndPart) PartType() string { return PartTypeReasoningEnd } + +func (p ReasoningEndPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeReasoningEnd) + w.str("id", p.ID) + w.marshal("providerMetadata", reasoningProviderMetadata(p.Details)) + return w.done() +} + +// reasoningProviderMetadata is `{openrouter:{reasoning_details:[...]}}`. +type reasoningProviderMetadataValue struct { + Openrouter reasoningDetailsEnvelope `json:"openrouter"` +} + +type reasoningDetailsEnvelope struct { + ReasoningDetails ReasoningDetailsView `json:"reasoning_details"` +} + +func reasoningProviderMetadata(details ReasoningDetailsView) reasoningProviderMetadataValue { + return reasoningProviderMetadataValue{Openrouter: reasoningDetailsEnvelope{ReasoningDetails: details}} +} + +// ── text ────────────────────────────────────────────────────────────────── + +// TextStartPart's id is the OpenRouter response id (`gen-…`) when one has been +// seen, else a freshly minted 16-char id. +type TextStartPart struct{ ID string } + +func (p TextStartPart) PartType() string { return PartTypeTextStart } + +func (p TextStartPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeTextStart) + w.str("id", p.ID) + return w.done() +} + +// TextDeltaPart is `{type:"text-delta", delta, id}`. +type TextDeltaPart struct { + Delta string + ID string +} + +func (p TextDeltaPart) PartType() string { return PartTypeTextDelta } + +func (p TextDeltaPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeTextDelta) + w.str("delta", p.Delta) + w.str("id", p.ID) + return w.done() +} + +// TextEndPart is `{type:"text-end", id}`. +type TextEndPart struct{ ID string } + +func (p TextEndPart) PartType() string { return PartTypeTextEnd } + +func (p TextEndPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeTextEnd) + w.str("id", p.ID) + return w.done() +} + +// ── source ──────────────────────────────────────────────────────────────── + +// SourcePart's `id` is THE URL ITSELF, not a generated id. +type SourcePart struct { + URL string + Title string + Content string + StartIndex float64 + EndIndex float64 +} + +func (p SourcePart) PartType() string { return PartTypeSource } + +func (p SourcePart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeSource) + w.str("sourceType", "url") + w.str("id", p.URL) + w.str("url", p.URL) + w.str("title", p.Title) + inner := newObjectWriter() + inner.str("content", p.Content) + inner.marshal("startIndex", float64(p.StartIndex)) + inner.marshal("endIndex", float64(p.EndIndex)) + innerRaw, err := inner.done() + if err != nil { + return nil, err + } + outer := newObjectWriter() + outer.raw(Service, innerRaw) + outerRaw, err := outer.done() + if err != nil { + return nil, err + } + w.raw("providerMetadata", outerRaw) + return w.done() +} + +// ── tool input / call ───────────────────────────────────────────────────── + +// ToolInputStartPart is `{type:"tool-input-start", id, toolName}`. +type ToolInputStartPart struct { + ID string + ToolName string +} + +func (p ToolInputStartPart) PartType() string { return PartTypeToolInputStart } + +func (p ToolInputStartPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolInputStart) + w.str("id", p.ID) + w.str("toolName", p.ToolName) + return w.done() +} + +// ToolInputDeltaPart is `{type:"tool-input-delta", id, delta}`: `id` first, +// unlike the text/reasoning deltas. The processor ignores it; it exists so a +// consumer can render arguments as they stream. +type ToolInputDeltaPart struct { + ID string + Delta string +} + +func (p ToolInputDeltaPart) PartType() string { return PartTypeToolInputDelta } + +func (p ToolInputDeltaPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolInputDelta) + w.str("id", p.ID) + w.str("delta", p.Delta) + return w.done() +} + +// ToolInputEndPart is `{type:"tool-input-end", id}`. +type ToolInputEndPart struct{ ID string } + +func (p ToolInputEndPart) PartType() string { return PartTypeToolInputEnd } + +func (p ToolInputEndPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolInputEnd) + w.str("id", p.ID) + return w.done() +} + +// ToolCallPart's `input` is the RAW accumulated argument STRING, not a parsed +// object. `providerMetadata` is attached to the FIRST tool call only; later +// calls omit the key entirely. +type ToolCallPart struct { + ToolCallID string + ToolName string + Input string + + HasProviderMetadata bool + Details ReasoningDetailsView +} + +func (p ToolCallPart) PartType() string { return PartTypeToolCall } + +func (p ToolCallPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeToolCall) + w.str("toolCallId", p.ToolCallID) + w.str("toolName", p.ToolName) + w.str("input", p.Input) + if p.HasProviderMetadata { + w.marshal("providerMetadata", reasoningProviderMetadata(p.Details)) + } + return w.done() +} + +// ── file ────────────────────────────────────────────────────────────────── + +// FilePart is `{type:"file", mediaType, data}` from `delta.images[]`. +type FilePart struct { + MediaType string + Data string +} + +func (p FilePart) PartType() string { return PartTypeFile } + +func (p FilePart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeFile) + w.str("mediaType", p.MediaType) + w.str("data", p.Data) + return w.done() +} + +// ── error ───────────────────────────────────────────────────────────────── + +// ErrorPart carries whatever the translator put in `error`. Three sources: +// +// chunk parse failure → a validation-error object (see wire.go) +// `error` in the chunk → the RAW `error` object from the chunk +// reader error → the caught error, at flush +// +// In the second case, when the chunk validates as the CHUNK shape (because +// `choices` is present) the error object is passed through unchanged; when it +// validates as the ERROR shape, `code`/`type`/`param` are filled with null and +// the keys reordered to shape order first, extras after. +type ErrorPart struct { + Error json.RawMessage +} + +func (p ErrorPart) PartType() string { return PartTypeError } + +func (p ErrorPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeError) + if p.Error == nil { + w.raw("error", json.RawMessage("null")) + } else { + w.raw("error", p.Error) + } + return w.done() +} + +// AbortPart ends a stream that was cancelled. The reason, when present, is +// the cancellation cause's message. +type AbortPart struct { + Reason string + HasReason bool +} + +func (p AbortPart) PartType() string { return PartTypeAbort } + +func (p AbortPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeAbort) + if p.HasReason { + w.str("reason", p.Reason) + } + return w.done() +} + +// ── finish ──────────────────────────────────────────────────────────────── + +// FinishReason is the `{unified, raw}` pair. `raw` is absent when no +// `finish_reason` was ever seen, and survives the two synthetic promotions. +type FinishReason struct { + Unified string + Raw *string +} + +// MarshalJSON writes `{unified, raw?}`. +func (f FinishReason) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("unified", f.Unified) + if f.Raw != nil { + w.str("raw", *f.Raw) + } + return w.done() +} + +// Unified finish-reason values. +const ( + FinishStop = "stop" + FinishLength = "length" + FinishContentFilter = "content-filter" + FinishToolCalls = "tool-calls" + FinishError = "error" + FinishOther = "other" +) + +// MapToUnified maps a provider finish_reason to the unified set. Everything +// unrecognised, including `"error"`, becomes "other": unified `error` is +// reserved for a parse failure, a top-level error payload or a reader error. +func MapToUnified(finishReason string) string { + switch finishReason { + case "stop": + return FinishStop + case "length": + return FinishLength + case "content_filter": + return FinishContentFilter + case "function_call", "tool_calls": + return FinishToolCalls + } + return FinishOther +} + +// MapOpenRouterFinishReason wraps MapToUnified, keeping the raw value. +func MapOpenRouterFinishReason(finishReason *string) FinishReason { + if finishReason == nil { + return FinishReason{Unified: FinishOther} + } + raw := *finishReason + return FinishReason{Unified: MapToUnified(raw), Raw: &raw} +} + +// The openrouter usage metadata is an ORDERED accumulator, not a struct: keys +// are added as chunks arrive, so a key that first appears on chunk 2 lands at +// the END, after keys chunk 1 already wrote. A stream that reports +// `prompt_tokens_details` only on its second usage chunk therefore serialises +// as `{promptTokens, completionTokens, totalTokens, promptTokensDetails}`. +// Hence *Object, not a Go struct. +// +// senior-dev reads none of it: `cost` never enters the usage block, and +// `totalTokens` is discarded in favour of the `input + output` recomputation. + +// FinishPart is the single terminal part, emitted from `flush`. +type FinishPart struct { + FinishReason FinishReason + Usage calc.LanguageModelV3Usage + Metadata OpenRouterMetadata +} + +// OpenRouterMetadata is `providerMetadata.openrouter` at flush: `{usage}` +// first, then `provider` if the stream ever carried one, then +// `reasoning_details` (always), then `annotations` only when non-empty. +type OpenRouterMetadata struct { + Usage *Object + Provider *string + ReasoningDetails ReasoningDetailsView + Annotations []json.RawMessage +} + +func (m OpenRouterMetadata) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + usage := m.Usage + if usage == nil { + usage = NewObject() + } + w.marshal("usage", usage) + if m.Provider != nil { + w.str("provider", *m.Provider) + } + w.marshal("reasoning_details", m.ReasoningDetails) + if len(m.Annotations) > 0 { + w.marshal("annotations", m.Annotations) + } + return w.done() +} + +func (p FinishPart) PartType() string { return PartTypeFinish } + +func (p FinishPart) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", PartTypeFinish) + w.marshal("finishReason", p.FinishReason) + w.marshal("usage", p.Usage) + inner := newObjectWriter() + inner.marshal(Service, p.Metadata) + innerRaw, err := inner.done() + if err != nil { + return nil, err + } + w.raw("providerMetadata", innerRaw) + return w.done() +} diff --git a/internal/seniordev/engine/orclient/reasoning.go b/internal/seniordev/engine/orclient/reasoning.go new file mode 100644 index 0000000000..45982a26df --- /dev/null +++ b/internal/seniordev/engine/orclient/reasoning.go @@ -0,0 +1,330 @@ +//go:build !windows + +package orclient + +// reasoning_details: the provider's reasoning metadata, round-tripped. +// +// Three variants, all sharing `{id?: string|null, format?: enum|null, +// index?: number}`: +// +// reasoning.summary summary: string +// reasoning.encrypted data: string +// reasoning.text text?: string|null, signature?: string|null +// +// An entry that matches none of the three is dropped SILENTLY and PER-ENTRY, +// never fatally. +// +// Two things make this more than a struct: +// +// - parsing DROPS unknown keys and re-emits the known ones in a fixed +// SHAPE ORDER, not input order: `{signature,index,text,format,type,id,zzz}` +// comes back as `{type,text,signature,id,format,index}`. +// - `text` / `signature` / `id` / `format` may be null, and an explicit +// `null` is distinct from an absent key. They are therefore +// json.RawMessage (nil == absent) rather than *string. + +import ( + "bytes" + "encoding/json" + "strconv" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Reasoning detail type tags. +const ( + ReasoningDetailSummary = "reasoning.summary" + ReasoningDetailEncrypted = "reasoning.encrypted" + ReasoningDetailText = "reasoning.text" +) + +// DefaultReasoningFormat is assumed when a text detail names no format. +const DefaultReasoningFormat = "anthropic-claude-v1" + +// reasoningFormats is the accepted `format` set. A `format` outside it drops +// the whole entry. +var reasoningFormats = map[string]bool{ + "unknown": true, + "openai-responses-v1": true, + "azure-openai-responses-v1": true, + "xai-responses-v1": true, + "anthropic-claude-v1": true, + "google-gemini-v1": true, +} + +// ReasoningDetail is one parsed entry. +type ReasoningDetail struct { + Type string + + // Summary is required for the summary variant. + Summary string + // Data is required for the encrypted variant. + Data string + // Text / Signature may be null or absent on the text variant. + Text json.RawMessage + Signature json.RawMessage + + // Common, all optional. + ID json.RawMessage + Format json.RawMessage + Index *float64 + + // Raw is the normalized provider object. Once a detail crosses the + // provider boundary it is opaque metadata; keeping these bytes avoids a + // decode-to-map/re-encode round trip changing key order or number spelling. + // It is cleared only when the provider's consecutive-text merge mutates + // the detail. + Raw json.RawMessage +} + +// MarshalJSON writes the shape order: the variant's own keys first, then the +// three common ones. Absent keys are omitted; explicit nulls are written. +func (d ReasoningDetail) MarshalJSON() ([]byte, error) { + if d.Raw != nil { + return append([]byte(nil), d.Raw...), nil + } + return d.marshalNormalized() +} + +func (d ReasoningDetail) marshalNormalized() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + first := true + write := func(key string, raw json.RawMessage) error { + if raw == nil { + return nil + } + if !first { + buf.WriteByte(',') + } + first = false + k, err := jsonutil.Marshal(key) + if err != nil { + return err + } + buf.Write(k) + buf.WriteByte(':') + buf.Write(raw) + return nil + } + typeRaw, err := jsonutil.Marshal(d.Type) + if err != nil { + return nil, err + } + if err := write("type", typeRaw); err != nil { + return nil, err + } + switch d.Type { + case ReasoningDetailSummary: + s, err := jsonutil.Marshal(d.Summary) + if err != nil { + return nil, err + } + if err := write("summary", s); err != nil { + return nil, err + } + case ReasoningDetailEncrypted: + s, err := jsonutil.Marshal(d.Data) + if err != nil { + return nil, err + } + if err := write("data", s); err != nil { + return nil, err + } + case ReasoningDetailText: + if err := write("text", d.Text); err != nil { + return nil, err + } + if err := write("signature", d.Signature); err != nil { + return nil, err + } + } + if err := write("id", d.ID); err != nil { + return nil, err + } + if err := write("format", d.Format); err != nil { + return nil, err + } + if d.Index != nil { + if err := write("index", []byte(strconv.FormatFloat(*d.Index, 'f', -1, 64))); err != nil { + return nil, err + } + } + buf.WriteByte('}') + return buf.Bytes(), nil +} + +// ParseReasoningDetails parses each entry and drops the unrecognised ones. +func ParseReasoningDetails(raw json.RawMessage) []ReasoningDetail { + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil + } + out := make([]ReasoningDetail, 0, len(entries)) + for _, entry := range entries { + d, ok := parseReasoningDetail(entry) + if !ok { + continue + } + out = append(out, d) + } + return out +} + +func parseReasoningDetail(raw json.RawMessage) (ReasoningDetail, bool) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return ReasoningDetail{}, false + } + typeRaw, ok := obj["type"] + if !ok { + return ReasoningDetail{}, false + } + var typ string + if err := json.Unmarshal(typeRaw, &typ); err != nil { + return ReasoningDetail{}, false + } + + d := ReasoningDetail{Type: typ} + switch typ { + case ReasoningDetailSummary: + s, ok := requiredString(obj, "summary") + if !ok { + return ReasoningDetail{}, false + } + d.Summary = s + case ReasoningDetailEncrypted: + s, ok := requiredString(obj, "data") + if !ok { + return ReasoningDetail{}, false + } + d.Data = s + case ReasoningDetailText: + text, ok := nullishRaw(obj, "text") + if !ok { + return ReasoningDetail{}, false + } + sig, ok := nullishRaw(obj, "signature") + if !ok { + return ReasoningDetail{}, false + } + d.Text = text + d.Signature = sig + default: + return ReasoningDetail{}, false + } + + id, ok := nullishRaw(obj, "id") + if !ok { + return ReasoningDetail{}, false + } + d.ID = id + + format, ok := nullishRaw(obj, "format") + if !ok { + return ReasoningDetail{}, false + } + if format != nil && !bytes.Equal(format, []byte("null")) { + var f string + if err := json.Unmarshal(format, &f); err != nil || !reasoningFormats[f] { + return ReasoningDetail{}, false + } + } + d.Format = format + + if idxRaw, present := obj["index"]; present { + var idx float64 + if err := json.Unmarshal(idxRaw, &idx); err != nil { + return ReasoningDetail{}, false + } + d.Index = &idx + } + normalized, err := d.marshalNormalized() + if err != nil { + return ReasoningDetail{}, false + } + d.Raw = normalized + return d, true +} + +func requiredString(obj map[string]json.RawMessage, key string) (string, bool) { + raw, present := obj[key] + if !present { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", false + } + return s, true +} + +// nullishRaw accepts absent (nil, true), explicit null, or a string. +func nullishRaw(obj map[string]json.RawMessage, key string) (json.RawMessage, bool) { + raw, present := obj[key] + if !present { + return nil, true + } + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return json.RawMessage("null"), true + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, false + } + enc, err := jsonutil.Marshal(s) + if err != nil { + return nil, false + } + return enc, true +} + +// orTruthy is `a || b` over a nullish JSON field: `null`, `""` and an absent +// value all fall through to b. The result may be nil (absent), which the +// enclosing marshal drops; that is how a merge can turn an explicit `null` +// signature into an ABSENT one. +func orTruthy(a, b json.RawMessage) json.RawMessage { + if rawTruthy(a) { + return a + } + return b +} + +func rawTruthy(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + switch { + case len(trimmed) == 0: + return false + case bytes.Equal(trimmed, []byte("null")): + return false + case bytes.Equal(trimmed, []byte(`""`)): + return false + case bytes.Equal(trimmed, []byte("false")): + return false + case bytes.Equal(trimmed, []byte("0")): + return false + } + return true +} + +// rawString unwraps a nullish JSON string to its Go value; `null` and absent +// both give "". +func rawString(raw json.RawMessage) string { + if !rawTruthy(raw) { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + return s +} + +// rawStringLiteral encodes a Go string as a JSON string. +func rawStringLiteral(s string) json.RawMessage { + enc, err := jsonutil.Marshal(s) + if err != nil { + return json.RawMessage(`""`) + } + return enc +} diff --git a/internal/seniordev/engine/orclient/sampling_body_test.go b/internal/seniordev/engine/orclient/sampling_body_test.go new file mode 100644 index 0000000000..eba4c9f22f --- /dev/null +++ b/internal/seniordev/engine/orclient/sampling_body_test.go @@ -0,0 +1,56 @@ +//go:build !windows + +package orclient + +import ( + "encoding/json" + "testing" +) + +func pointer(value float64) *float64 { return &value } + +// TestBuildRequestBodyWritesOnlyTheSamplingFieldsThatAreSet: a nil field is +// absent from the body, not null, so the provider's default applies; a set +// field is written under its OpenRouter key. `provider` is never written by +// the sampling fields — it belongs to the routing block in OpenRouterOptions. +func TestBuildRequestBodyWritesOnlyTheSamplingFieldsThatAreSet(t *testing.T) { + bare, err := BuildRequestBody(RequestParams{ModelID: "m"}) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(bare, &body); err != nil { + t.Fatal(err) + } + for _, key := range []string{ + "temperature", "top_p", "top_k", "min_p", "seed", + "frequency_penalty", "presence_penalty", "repetition_penalty", "provider", + } { + if _, present := body[key]; present { + t.Fatalf("unset %s written: %s", key, bare) + } + } + + full, err := BuildRequestBody(RequestParams{ + ModelID: "m", MaxOutputTokens: pointer(4096), + Temperature: pointer(0.2), TopP: pointer(0.9), TopK: pointer(40), + MinP: pointer(0.05), Seed: pointer(7), FrequencyPenalty: pointer(0.1), + PresencePenalty: pointer(0.2), RepetitionPenalty: pointer(1.05), + }) + if err != nil { + t.Fatal(err) + } + body = map[string]any{} + if err := json.Unmarshal(full, &body); err != nil { + t.Fatal(err) + } + if _, present := body["provider"]; present { + t.Fatalf("sampling fields wrote provider: %s", full) + } + if body["max_tokens"] != 4096.0 || body["temperature"] != 0.2 || body["top_p"] != 0.9 || + body["top_k"] != 40.0 || body["min_p"] != 0.05 || body["seed"] != 7.0 || + body["frequency_penalty"] != 0.1 || body["presence_penalty"] != 0.2 || + body["repetition_penalty"] != 1.05 { + t.Fatalf("shaped body = %s", full) + } +} diff --git a/internal/seniordev/engine/orclient/service.go b/internal/seniordev/engine/orclient/service.go new file mode 100644 index 0000000000..8aeb911b97 --- /dev/null +++ b/internal/seniordev/engine/orclient/service.go @@ -0,0 +1,17 @@ +//go:build !windows + +package orclient + +import "github.com/Agent-Field/codeaf/internal/modelsource" + +// Service is the identity of the model service whose wire this client speaks: +// OpenRouter's, which is the shape codeaf's model API answers in. It is the +// provider every model senior-dev asks for is filed under, the key a request's +// service options are kept under, and the namespace the service's reasoning +// details and finish metadata come back in. +// +// CODEAF SPELLS THAT IDENTITY ONCE, as modelsource.DefaultID, and a law holds +// the whole module to it (internal/modelsource/purity_law_test.go). Every use +// in senior-dev reads it from here, so the word is written in one place in +// codeaf and in none in this program. +const Service = modelsource.DefaultID diff --git a/internal/seniordev/engine/orclient/sse.go b/internal/seniordev/engine/orclient/sse.go new file mode 100644 index 0000000000..9f809ebc85 --- /dev/null +++ b/internal/seniordev/engine/orclient/sse.go @@ -0,0 +1,161 @@ +//go:build !windows + +package orclient + +// The SSE frame decoder. +// +// Hand-rolled (no third-party dependencies). The rules that matter: +// +// - fields are separated by a newline; an event is dispatched on a BLANK +// line. Both LF and CRLF terminate a line, and a bare CR does too (the SSE +// spec's three line terminators). +// - a line beginning with `:` is a comment. OpenRouter sends +// `: OPENROUTER PROCESSING` keepalives every few seconds; they must not +// produce an event and must not disturb the data buffer. +// - `data:` takes the rest of the line with ONE optional leading space +// stripped. Multiple `data:` lines in one frame are joined with `\n`. +// - an event with an EMPTY data buffer is not dispatched at all. +// - `data: [DONE]` is dropped by the caller, not by the frame decoder, so +// it terminates nothing on its own; the stream ends when the body does. +// - a trailing frame with no terminating blank line IS dispatched at EOF. + +import ( + "bufio" + "io" + "strings" +) + +// SSEEvent is one dispatched event. Only `data` is consumed downstream; `event` +// and `id` are decoded because the grammar requires skipping them correctly. +type SSEEvent struct { + Event string + Data string + ID string +} + +// SSEDecoder splits a byte stream into events. +type SSEDecoder struct { + r *bufio.Reader + + data strings.Builder + hasData bool + event string + lastID string + done bool + started bool +} + +// NewSSEDecoder wraps a reader. The buffer is generous because a single +// reasoning-heavy chunk can exceed the default 4 KiB line limit by a lot. +func NewSSEDecoder(r io.Reader) *SSEDecoder { + return &SSEDecoder{r: bufio.NewReaderSize(r, 64*1024)} +} + +// Next returns the next event, or io.EOF when the stream ends. +func (d *SSEDecoder) Next() (SSEEvent, error) { + for { + if d.done { + return SSEEvent{}, io.EOF + } + line, err := d.readLine() + if !d.started { + d.started = true + line = strings.TrimPrefix(line, "\uFEFF") + } + if err != nil { + d.done = true + if err != io.EOF { + return SSEEvent{}, err + } + if len(line) == 0 && !d.hasData { + return SSEEvent{}, io.EOF + } + if ev, ok := d.feed(line); ok { + return ev, nil + } + if ev, ok := d.dispatch(); ok { + return ev, nil + } + return SSEEvent{}, io.EOF + } + if ev, ok := d.feed(line); ok { + return ev, nil + } + } +} + +// feed consumes one line, returning an event when the line dispatches one. +func (d *SSEDecoder) feed(line string) (SSEEvent, bool) { + if line == "" { + return d.dispatch() + } + if strings.HasPrefix(line, ":") { + // Comment / keepalive. + return SSEEvent{}, false + } + field, value := line, "" + if colon := strings.IndexByte(line, ':'); colon >= 0 { + field = line[:colon] + value = line[colon+1:] + // Exactly ONE leading space is stripped. + if strings.HasPrefix(value, " ") { + value = value[1:] + } + } + switch field { + case "data": + if d.hasData { + d.data.WriteByte('\n') + } + d.data.WriteString(value) + d.hasData = true + case "event": + d.event = value + case "id": + // The spec ignores an id containing NUL; nothing downstream reads it. + if !strings.ContainsRune(value, 0) { + d.lastID = value + } + case "retry": + // Reconnection time; this client never reconnects. + } + return SSEEvent{}, false +} + +func (d *SSEDecoder) dispatch() (SSEEvent, bool) { + if !d.hasData { + d.event = "" + return SSEEvent{}, false + } + ev := SSEEvent{Event: d.event, Data: d.data.String(), ID: d.lastID} + d.data.Reset() + d.hasData = false + d.event = "" + return ev, true +} + +// readLine reads one line, accepting LF, CRLF and a bare CR as terminators. +func (d *SSEDecoder) readLine() (string, error) { + var sb strings.Builder + for { + b, err := d.r.ReadByte() + if err != nil { + return sb.String(), err + } + switch b { + case '\n': + return sb.String(), nil + case '\r': + next, err := d.r.ReadByte() + if err == nil && next != '\n' { + _ = d.r.UnreadByte() + } + return sb.String(), nil + default: + sb.WriteByte(b) + } + } +} + +// DoneSentinel is the payload the stream reader drops before parsing. +const DoneSentinel = "[DONE]" diff --git a/internal/seniordev/engine/orclient/stream.go b/internal/seniordev/engine/orclient/stream.go new file mode 100644 index 0000000000..487b5e7d34 --- /dev/null +++ b/internal/seniordev/engine/orclient/stream.go @@ -0,0 +1,601 @@ +//go:build !windows + +package orclient + +// The chunk → stream-part translation: one Transform per decoded SSE chunk +// plus a Flush at end of stream. The order of checks is part of the +// contract: finish_reason is captured before the delta of the same chunk; a +// parse failure or a top-level error payload returns without looking at +// anything else; reasoning-end fires before text-start. + +import ( + "encoding/json" + "strconv" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" +) + +// toolCallSlot is one entry of the provider's `toolCalls` array. +type toolCallSlot struct { + id string + name string + arguments string + inputStarted bool + sent bool +} + +// toolCallArray is the accumulator for tool-call deltas, keyed by the delta's +// index. A non-negative integer index extends `length`; any other key is +// stored but never iterated. +type toolCallArray struct { + slots map[string]*toolCallSlot + length int +} + +func newToolCallArray() *toolCallArray { + return &toolCallArray{slots: map[string]*toolCallSlot{}} +} + +func (a *toolCallArray) get(i int) *toolCallSlot { + return a.getKey(strconv.Itoa(i)) +} + +func (a *toolCallArray) getKey(key string) *toolCallSlot { return a.slots[key] } + +// setKey stores a slot under the delta's index key. Only a non-negative +// integer key extends `length`; any other key (the wire schema accepts any +// number for toolCallDelta.index) is stored but never iterated. +func (a *toolCallArray) setKey(key string, slot *toolCallSlot) { + a.slots[key] = slot + if index, ok := arrayIndex(key); ok && index+1 > a.length { + a.length = index + 1 + } +} + +// arrayIndex reports whether key is a plain non-negative integer. +func arrayIndex(key string) (int, bool) { + if key == "" || (len(key) > 1 && key[0] == '0') { + return 0, false + } + n, err := strconv.ParseUint(key, 10, 31) + if err != nil { + return 0, false + } + return int(n), true +} + +// iterate walks indices 0..length-1. Holes yield nil; callers skip them. +func (a *toolCallArray) iterate(f func(*toolCallSlot)) { + for i := 0; i < a.length; i++ { + f(a.get(i)) + } +} + +// Translator is the per-stream state the chunk translation mutates. +type Translator struct { + toolCalls *toolCallArray + seenToolCallIDs map[string]bool + finishReason FinishReason + + usage calc.LanguageModelV3Usage + openrouterUse *Object + rawUsage json.RawMessage + accumulated []ReasoningDetail + detailsOnCall bool + fileAnnotation []json.RawMessage + + promptTokensSeen *float64 + completionTokensSeen *float64 + + textStarted bool + reasoningStarted bool + textID string + reasoningID string + responseID string + provider *string + + // streamError is a captured mid-stream read error. It surfaces as an + // `error` part at flush. + streamError json.RawMessage + flushed bool +} + +// NewTranslator builds the initial state. finishReason starts as `other`. +func NewTranslator() *Translator { + return &Translator{ + toolCalls: newToolCallArray(), + seenToolCallIDs: map[string]bool{}, + openrouterUse: NewObject(), + finishReason: FinishReason{Unified: FinishOther}, + accumulated: []ReasoningDetail{}, + } +} + +// SetStreamError records a mid-stream reader error. The stream CLOSES on it, +// so the error only shows up in flush. +func (t *Translator) SetStreamError(value json.RawMessage) { t.streamError = value } + +// Transform translates one decoded chunk. A returned error (only +// InvalidResponseDataError from the tool-call accumulator) tears the stream +// down; it is not an `error` part. +func (t *Translator) Transform(chunk Chunk) ([]StreamPart, error) { + var out []StreamPart + emit := func(p StreamPart) { out = append(out, p) } + + if !chunk.Success { + t.finishReason = FinishReason{Unified: FinishError} + emit(ErrorPart{Error: chunk.ParseError}) + return out, nil + } + value := chunk.Value + if value.ErrorField != nil { + t.finishReason = FinishReason{Unified: FinishError} + emit(ErrorPart{Error: value.ErrorField}) + return out, nil + } + if value.Provider != nil && *value.Provider != "" { + p := *value.Provider + t.provider = &p + } + if value.ID != nil && *value.ID != "" { + t.responseID = *value.ID + emit(ResponseMetadataPart{ID: *value.ID}) + } + if value.Model != nil && *value.Model != "" { + emit(ResponseMetadataPart{ModelID: *value.Model, IsModel: true}) + } + if value.Usage != nil { + t.accumulateUsage(value.Usage) + } + + // Only the FIRST choice is ever read. + var choice *Choice + if len(value.Choices) > 0 { + choice = &value.Choices[0] + } + if choice != nil && choice.FinishReason != nil { + t.finishReason = MapOpenRouterFinishReason(choice.FinishReason) + } + if choice == nil || choice.Delta == nil { + return out, nil + } + delta := choice.Delta + + emitReasoningChunk := func(text string) { + if !t.reasoningStarted { + t.reasoningID = generateId() + emit(ReasoningStartPart{ID: t.reasoningID}) + t.reasoningStarted = true + } + id := t.reasoningID + if id == "" { + id = generateId() + } + emit(ReasoningDeltaPart{Delta: text, ID: id}) + } + + if delta.HasReasoningDeta && len(delta.ReasoningDetails) > 0 { + for _, detail := range delta.ReasoningDetails { + if detail.Type == ReasoningDetailText { + if n := len(t.accumulated); n > 0 && t.accumulated[n-1].Type == ReasoningDetailText { + last := &t.accumulated[n-1] + // A null text on either side contributes "", and the + // result is always a string, so a merge turns an ABSENT + // text into "". + last.Text = rawStringLiteral(rawString(last.Text) + rawString(detail.Text)) + last.Signature = orTruthy(last.Signature, detail.Signature) + last.Format = orTruthy(last.Format, detail.Format) + last.Raw = nil + continue + } + // Appended by value, so later merges do not mutate the + // caller's entry. + t.accumulated = append(t.accumulated, detail) + continue + } + t.accumulated = append(t.accumulated, detail) + } + if !t.textStarted { + for _, detail := range delta.ReasoningDetails { + switch detail.Type { + case ReasoningDetailText: + emitReasoningChunk(rawString(detail.Text)) + case ReasoningDetailEncrypted: + // Emits NOTHING — it only accumulates. + case ReasoningDetailSummary: + if detail.Summary != "" { + emitReasoningChunk(detail.Summary) + } + } + } + } + } else if delta.Reasoning != nil && *delta.Reasoning != "" && !t.textStarted { + emitReasoningChunk(*delta.Reasoning) + } + + // An empty content string produces nothing at all. + if delta.Content != nil && *delta.Content != "" { + if t.reasoningStarted && !t.textStarted { + id := t.reasoningID + if id == "" { + id = generateId() + } + emit(ReasoningEndPart{ID: id, Details: t.detailsView()}) + t.reasoningStarted = false + } + if !t.textStarted { + t.textID = t.responseID + if t.textID == "" { + t.textID = generateId() + } + emit(TextStartPart{ID: t.textID}) + t.textStarted = true + } + id := t.textID + if id == "" { + id = generateId() + } + emit(TextDeltaPart{Delta: *delta.Content, ID: id}) + } + + if delta.HasAnnotations { + for _, annotation := range delta.Annotations { + switch annotation.Type { + case "url_citation": + part := SourcePart{URL: annotation.URL} + if annotation.Title != nil { + part.Title = *annotation.Title + } + if annotation.Content != nil { + part.Content = *annotation.Content + } + if annotation.StartIndex != nil { + part.StartIndex = *annotation.StartIndex + } + if annotation.EndIndex != nil { + part.EndIndex = *annotation.EndIndex + } + emit(part) + case "file": + t.fileAnnotation = append(t.fileAnnotation, annotation.Raw) + } + // `file_annotation` (the old format) is parsed and then IGNORED. + } + } + + if delta.HasToolCalls { + parts, err := t.accumulateToolCalls(delta.ToolCalls) + out = append(out, parts...) + if err != nil { + return out, err + } + } + + for _, image := range delta.Images { + emit(FilePart{ + MediaType: getMediaType(image.URL, "image/jpeg"), + Data: base64FromDataURLLoose(image.URL), + }) + } + return out, nil +} + +// accumulateToolCalls folds tool-call deltas into slots and emits the +// tool-input and tool-call parts. +func (t *Translator) accumulateToolCalls(deltas []ToolCallDelta) ([]StreamPart, error) { + var out []StreamPart + emit := func(p StreamPart) { out = append(out, p) } + + for _, d := range deltas { + // A delta without an index continues the most recent tool call; the + // first such delta opens slot 0. + index := strconv.Itoa(max(t.toolCalls.length-1, 0)) + if d.Index != nil { + index = strconv.FormatFloat(*d.Index, 'f', -1, 64) + } + + if t.toolCalls.getKey(index) == nil { + if d.Type == nil || *d.Type != "function" { + return out, newInvalidResponseDataError("Expected 'function' type.", json.RawMessage(d.Raw)) + } + if d.Name == nil { + return out, newInvalidResponseDataError("Expected 'function.name' to be a string.", json.RawMessage(d.Raw)) + } + toolCallID := "" + if d.ID != nil { + toolCallID = *d.ID + } + // Id uniqueness is enforced by the CLIENT, not the server. + if toolCallID == "" || t.seenToolCallIDs[toolCallID] { + toolCallID = generateId() + } + t.seenToolCallIDs[toolCallID] = true + + arguments := "" + if d.Arguments != nil { + arguments = *d.Arguments + } + slot := &toolCallSlot{id: toolCallID, name: *d.Name, arguments: arguments} + t.toolCalls.setKey(index, slot) + + // The whole burst fires immediately when the FIRST delta already + // carries parsable arguments. + if isParsableJSON(slot.arguments) { + slot.inputStarted = true + emit(ToolInputStartPart{ID: slot.id, ToolName: slot.name}) + emit(ToolInputDeltaPart{ID: slot.id, Delta: slot.arguments}) + emit(ToolInputEndPart{ID: slot.id}) + emit(t.toolCallPart(slot)) + slot.sent = true + } + continue + } + + slot := t.toolCalls.getKey(index) + if !slot.inputStarted { + slot.inputStarted = true + emit(ToolInputStartPart{ID: slot.id, ToolName: slot.name}) + if slot.arguments != "" { + emit(ToolInputDeltaPart{ID: slot.id, Delta: slot.arguments}) + } + } + if d.Arguments != nil { + slot.arguments += *d.Arguments + } + // Emitted on EVERY subsequent delta, even for null args. + deltaText := "" + if d.Arguments != nil { + deltaText = *d.Arguments + } + emit(ToolInputDeltaPart{ID: slot.id, Delta: deltaText}) + + if isParsableJSON(slot.arguments) && !slot.sent { + emit(ToolInputEndPart{ID: slot.id}) + emit(t.toolCallPart(slot)) + slot.sent = true + } + } + return out, nil +} + +// toolCallPart builds a `tool-call` and consumes the one-shot +// reasoning-details attachment. +func (t *Translator) toolCallPart(slot *toolCallSlot) ToolCallPart { + part := ToolCallPart{ + ToolCallID: slot.id, + ToolName: slot.name, + Input: slot.arguments, + } + if !t.detailsOnCall { + part.HasProviderMetadata = true + part.Details = t.detailsView() + } + t.detailsOnCall = true + return part +} + +// detailsView hands out a LIVE reference to the accumulator, never a copy: +// every part that carries `reasoning_details` shares the same array, so a +// `reasoning-end` emitted at chunk 2 shows entries that arrived at chunk 3, +// and a merged `reasoning.text` entry shows its FINAL concatenated text +// everywhere it appears. A per-emit copy would lose both on the first stream +// that interleaves reasoning with text. +func (t *Translator) detailsView() ReasoningDetailsView { + return detailsRef(&t.accumulated) +} + +func (t *Translator) accumulateUsage(usage *calc.OpenRouterUsage) { + computed := calc.ComputeTokenUsage(usage) + // ComputeTokenUsage always writes every input and output key, so the + // whole block is replaced on every usage chunk. + t.usage.InputTokens = computed.InputTokens + t.usage.OutputTokens = computed.OutputTokens + t.rawUsage = computed.Raw + + promptTokens := float64(0) + if usage.PromptTokens != nil { + promptTokens = *usage.PromptTokens + } + completionTokens := float64(0) + if usage.CompletionTokens != nil { + completionTokens = *usage.CompletionTokens + } + t.openrouterUse.SetNumber("promptTokens", promptTokens) + if usage.PromptTokensDetails != nil { + cached := float64(0) + if usage.PromptTokensDetails.CachedTokens != nil { + cached = *usage.PromptTokensDetails.CachedTokens + } + inner := NewObject() + inner.SetNumber("cachedTokens", cached) + t.openrouterUse.SetObject("promptTokensDetails", inner) + } + t.openrouterUse.SetNumber("completionTokens", completionTokens) + if usage.CompletionTokensDetails != nil { + reasoning := float64(0) + if usage.CompletionTokensDetails.ReasoningTokens != nil { + reasoning = *usage.CompletionTokensDetails.ReasoningTokens + } + inner := NewObject() + inner.SetNumber("reasoningTokens", reasoning) + t.openrouterUse.SetObject("completionTokensDetails", inner) + } + extra := usageExtras(usage.Raw) + if extra.cost != nil { + t.openrouterUse.SetNumber("cost", *extra.cost) + } + // total_tokens is required by the usage schema, so it is always present. + if extra.totalTokens != nil { + t.openrouterUse.SetNumber("totalTokens", *extra.totalTokens) + } + if extra.upstreamInferenceCost != nil { + inner := NewObject() + inner.SetNumber("upstreamInferenceCost", *extra.upstreamInferenceCost) + t.openrouterUse.SetObject("costDetails", inner) + } + t.promptTokensSeen = &promptTokens + t.completionTokensSeen = &completionTokens +} + +type usageExtraFields struct { + cost *float64 + totalTokens *float64 + upstreamInferenceCost *float64 +} + +// usageExtras reads the three fields calc.OpenRouterUsage does not project: +// `cost`, `total_tokens` and `cost_details.upstream_inference_cost`. They only +// feed the openrouter usage metadata, which senior-dev does not read; they are +// carried so the metadata reflects the wire. +func usageExtras(raw json.RawMessage) usageExtraFields { + var out usageExtraFields + if len(raw) == 0 { + return out + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return out + } + if v, ok := obj["cost"]; ok && !isJSONNull(v) { + var n float64 + if err := json.Unmarshal(v, &n); err == nil { + out.cost = &n + } + } + if v, ok := obj["total_tokens"]; ok && !isJSONNull(v) { + var n float64 + if err := json.Unmarshal(v, &n); err == nil { + out.totalTokens = &n + } + } + if v, ok := obj["cost_details"]; ok && !isJSONNull(v) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(v, &fields); err == nil { + if u, ok := fields["upstream_inference_cost"]; ok && !isJSONNull(u) { + var n float64 + if err := json.Unmarshal(u, &n); err == nil { + out.upstreamInferenceCost = &n + } + } + } + } + return out +} + +// Flush ends the stream: it flushes unsent tool calls, closes open reasoning +// and text, and emits the finish part. +func (t *Translator) Flush() []StreamPart { + if t.flushed { + return nil + } + t.flushed = true + + var out []StreamPart + emit := func(p StreamPart) { out = append(out, p) } + + hasToolCalls := t.toolCalls.length > 0 + if t.streamError != nil { + t.finishReason = FinishReason{Unified: FinishError} + emit(ErrorPart{Error: t.streamError}) + } + + hasEncryptedReasoning := false + for _, d := range t.accumulated { + if d.Type == ReasoningDetailEncrypted && d.Data != "" { + hasEncryptedReasoning = true + break + } + } + // The two synthetic promotions to tool-calls. `raw` is preserved. + if hasToolCalls && hasEncryptedReasoning && t.finishReason.Unified == FinishStop { + t.finishReason = FinishReason{Unified: FinishToolCalls, Raw: t.finishReason.Raw} + } + if hasToolCalls && t.finishReason.Unified == FinishOther { + t.finishReason = FinishReason{Unified: FinishToolCalls, Raw: t.finishReason.Raw} + } + + // Unsent tool calls are flushed ONLY when the final unified reason is + // `tool-calls` — which is exactly what the promotions above buy. + if t.finishReason.Unified == FinishToolCalls { + t.toolCalls.iterate(func(slot *toolCallSlot) { + if slot == nil || slot.sent { + return + } + input := slot.arguments + if !isParsableJSON(input) { + input = "{}" + } + if !slot.inputStarted { + emit(ToolInputStartPart{ID: slot.id, ToolName: slot.name}) + emit(ToolInputDeltaPart{ID: slot.id, Delta: input}) + } + emit(ToolInputEndPart{ID: slot.id}) + part := ToolCallPart{ToolCallID: slot.id, ToolName: slot.name, Input: input} + if !t.detailsOnCall { + part.HasProviderMetadata = true + part.Details = t.detailsView() + } + t.detailsOnCall = true + emit(part) + slot.sent = true + }) + } + + if t.reasoningStarted { + id := t.reasoningID + if id == "" { + id = generateId() + } + emit(ReasoningEndPart{ID: id, Details: t.detailsView()}) + } + if t.textStarted { + id := t.textID + if id == "" { + id = generateId() + } + emit(TextEndPart{ID: id}) + } + + metadata := OpenRouterMetadata{ + Usage: t.openrouterUse, + Provider: t.provider, + ReasoningDetails: t.detailsView(), + } + if len(t.fileAnnotation) > 0 { + metadata.Annotations = t.fileAnnotation + } + + // Late fallbacks for a usage block that never reported totals. + usage := t.usage + if usage.InputTokens.Total == nil && t.promptTokensSeen != nil { + usage.InputTokens.Total = t.promptTokensSeen + } + if usage.OutputTokens.Total == nil && t.completionTokensSeen != nil { + usage.OutputTokens.Total = t.completionTokensSeen + } + usage.Raw = t.rawUsage + + emit(FinishPart{FinishReason: t.finishReason, Usage: usage, Metadata: metadata}) + return out +} + +// FinishReasonSnapshot exposes the running finish reason, so a caller can tell +// before flush whether a still-unsent tool call will ever arrive. +func (t *Translator) FinishReasonSnapshot() FinishReason { return t.finishReason } + +// isParsableJSON reports whether the accumulated arguments parse as JSON. It +// uses the same secure parser as tool validation, which also rejects +// `__proto__` / `constructor.prototype` keys. +func isParsableJSON(input string) bool { + if input == "" { + return false + } + _, err := parseSecureJSONValue(input) + return err == nil +} + +// base64FromDataURLLoose extracts the base64 payload of a data URL, returning +// the input unchanged when it is not one. +func base64FromDataURLLoose(dataURL string) string { + return base64FromDataURL(dataURL) +} diff --git a/internal/seniordev/engine/orclient/toolcall.go b/internal/seniordev/engine/orclient/toolcall.go new file mode 100644 index 0000000000..46438944e4 --- /dev/null +++ b/internal/seniordev/engine/orclient/toolcall.go @@ -0,0 +1,440 @@ +//go:build !windows + +package orclient + +// Tool-call validation, repair, and the `invalid` tool. +// +// A three-stage fallback: +// +// 1. VALIDATE. An unknown tool name raises NoSuchToolError; otherwise an +// EMPTY input string validates as `{}` (it is not an error) and anything +// else is parsed and validated. A validation failure raises +// InvalidToolInputError. +// 2. REPAIR. The repair callback runs for THOSE TWO ERROR TYPES ONLY. +// senior-dev's implementation lowercases a mis-cased tool name when a +// lowercase tool exists, and otherwise rewrites the call to +// `toolName:"invalid"` with `input: {tool, error}` as a JSON string. A +// repaired call is RE-VALIDATED (a second failure is NOT re-repaired); a +// nil result keeps the ORIGINAL error; a returned error is wrapped in +// ToolCallRepairError. +// 3. SAFETY NET. If repair is absent, returns nil, or the repaired call +// still fails, ParseToolCall DOES NOT return an error. It returns a +// synthetic `{type:"tool-call", …, dynamic:true, invalid:true, error}`, +// which the step loop persists as a tool part and immediately fails, +// WITHOUT executing the tool. +// +// Stage 3 is what keeps the loop's exit condition working: the assistant +// message still ends up with a tool part, so the turn counts as a tool-call +// turn and the loop iterates, giving the model a chance to correct itself. +// +// ActiveTools excludes "invalid" so the model can never CHOOSE it, but the +// tool map passed to the parser includes it so repair can TARGET it. +// +// ── the validator seam ─────────────────────────────────────────────────── +// +// ToolSpec.Validate is optional: nil means "accept anything that parses"; a +// tool with a schema installs a real validator. + +import ( + "encoding/json" + "errors" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// InvalidToolName is the tool repair rewrites an unrepairable call to. +const InvalidToolName = "invalid" + +// ToolSpec is one registered tool as the parser sees it. +type ToolSpec struct { + Name string + // Validate checks a parsed input against the tool's schema. Nil accepts + // any value that parsed as JSON. + Validate func(input json.RawMessage) error +} + +// ToolMap is the registered tool set in a fixed order. The order reaches the +// request body and the `invalid` tool's availableTools list, so it is part +// of what keeps prompt-cache keys stable. +type ToolMap struct { + order []string + specs map[string]ToolSpec +} + +// NewToolMap builds a map in the given order. +func NewToolMap(specs ...ToolSpec) *ToolMap { + m := &ToolMap{specs: map[string]ToolSpec{}} + for _, s := range specs { + if _, ok := m.specs[s.Name]; !ok { + m.order = append(m.order, s.Name) + } + m.specs[s.Name] = s + } + return m +} + +// SortedToolMap builds a ToolMap sorted by tool name. +func SortedToolMap(specs ...ToolSpec) *ToolMap { + m := NewToolMap(specs...) + sort.SliceStable(m.order, func(i, j int) bool { + return m.order[i] < m.order[j] + }) + return m +} + +// Names lists the tools in map order. +func (m *ToolMap) Names() []string { + if m == nil { + return nil + } + return append([]string(nil), m.order...) +} + +// ActiveTools is Names without the invalid tool: the set the model is offered. +func (m *ToolMap) ActiveTools() []string { + out := make([]string, 0, len(m.order)) + for _, name := range m.Names() { + if name == InvalidToolName { + continue + } + out = append(out, name) + } + return out +} + +// Get looks up a tool. +func (m *ToolMap) Get(name string) (ToolSpec, bool) { + if m == nil { + return ToolSpec{}, false + } + spec, ok := m.specs[name] + return spec, ok +} + +// ── errors ──────────────────────────────────────────────────────────────── + +// NoSuchToolError reports a call to an unregistered tool. The message text +// matters: the repair callback puts it into the `invalid` tool's input and the +// model reads it. +type NoSuchToolError struct { + ToolName string + AvailableTools []string + Message string +} + +func (e *NoSuchToolError) Error() string { return e.Message } + +func newNoSuchToolError(toolName string, available []string) *NoSuchToolError { + msg := "Model tried to call unavailable tool '" + toolName + "'. " + if len(available) == 0 { + msg += "No tools are available." + } else { + msg += "Available tools: " + strings.Join(available, ", ") + "." + } + return &NoSuchToolError{ToolName: toolName, AvailableTools: available, Message: msg} +} + +// InvalidToolInputError reports an input that failed to parse or validate. +type InvalidToolInputError struct { + ToolName string + ToolInput string + Cause error + Message string +} + +func (e *InvalidToolInputError) Error() string { return e.Message } + +func newInvalidToolInputError(toolName, toolInput string, cause error) *InvalidToolInputError { + msg := "Invalid input for tool " + toolName + ": " + if cause != nil { + msg += cause.Error() + } + return &InvalidToolInputError{ToolName: toolName, ToolInput: toolInput, Cause: cause, Message: msg} +} + +// TypeValidationError is what a Validate hook's rejection is wrapped in. Its +// message reaches the model verbatim through the `invalid` tool's input, in +// the format +// +// Type validation failed: Value: <value JSON>.\nError message: <cause> +// +// ToolSpec.Validate implementations should return one of these. +type TypeValidationError struct { + Value json.RawMessage + Cause error + Message string +} + +func (e *TypeValidationError) Error() string { return e.Message } + +// NewTypeValidationError builds one in the format above. +func NewTypeValidationError(value json.RawMessage, cause error) *TypeValidationError { + rendered := "undefined" + if len(value) > 0 { + rendered = string(value) + } + message := "" + if cause != nil { + message = cause.Error() + } + return &TypeValidationError{ + Value: value, + Cause: cause, + Message: "Type validation failed: Value: " + rendered + ".\nError message: " + message, + } +} + +// JSONParseError reports an input that is not JSON. Its message, in the format +// +// JSON parsing failed: Text: <text>.\nError message: <cause> +// +// reaches the model verbatim through the `invalid` tool's input. The cause +// text is whatever encoding/json reports. +type JSONParseError struct { + Text string + Cause error + Message string +} + +func (e *JSONParseError) Error() string { return e.Message } + +// NewJSONParseError builds one in the format above. +func NewJSONParseError(text string, cause error) *JSONParseError { + message := "" + if cause != nil { + message = cause.Error() + } + return &JSONParseError{ + Text: text, + Cause: cause, + Message: "JSON parsing failed: Text: " + text + ".\nError message: " + message, + } +} + +// ToolCallRepairError wraps an error returned by the repair callback. +type ToolCallRepairError struct { + Cause error + OriginalError error + Message string +} + +func (e *ToolCallRepairError) Error() string { return e.Message } + +// ── the call shapes ─────────────────────────────────────────────────────── + +// RawToolCall is a tool call as the stream delivered it: `input` is a raw +// JSON STRING, never a parsed value. +type RawToolCall struct { + ToolCallID string + ToolName string + Input string + ProviderExecuted bool + ProviderMetadata json.RawMessage +} + +// ParsedToolCall is ParseToolCall's result. `Invalid` marks the +// synthetic stage-3 result, which is emitted as a `tool-call` part and +// immediately as a `tool-error` part, and is NEVER executed. +type ParsedToolCall struct { + Type string + ToolCallID string + ToolName string + Input json.RawMessage + Dynamic bool + Invalid bool + Error error + ProviderExecuted bool + ProviderMetadata json.RawMessage +} + +// MarshalJSON writes the call with a fixed key order. +func (c ParsedToolCall) MarshalJSON() ([]byte, error) { + w := newObjectWriter() + w.str("type", "tool-call") + w.str("toolCallId", c.ToolCallID) + w.str("toolName", c.ToolName) + w.raw("input", c.Input) + if c.Invalid { + w.raw("dynamic", json.RawMessage("true")) + w.raw("invalid", json.RawMessage("true")) + if c.Error != nil { + w.str("error", c.Error.Error()) + } + } + if c.ProviderExecuted { + w.raw("providerExecuted", json.RawMessage("true")) + } + w.raw("providerMetadata", c.ProviderMetadata) + return w.done() +} + +// RepairFn tries to fix a call that failed validation. Returning (nil, nil) +// keeps the ORIGINAL error. +type RepairFn func(call RawToolCall, tools *ToolMap, failure error) (*RawToolCall, error) + +// ── senior-dev's repair callback ───────────────────────────────────────────── + +// SeniorDevRepairToolCall is senior-dev's RepairFn. +// +// Two steps, in order: +// +// 1. if the LOWERCASED name differs from the emitted one AND a tool with the +// lowercase name exists → return the call with the name lowercased. Note it +// keeps the ORIGINAL input, so a call that failed VALIDATION (not +// name-lookup) and happens to be mis-cased gets re-validated against the +// lowercase tool's schema and can fail a second time — which then lands in +// stage 3 rather than the `invalid` tool. +// 2. otherwise → rewrite to `toolName:"invalid"` with `input: {tool, error}` +// encoded as a raw JSON STRING, which is what RawToolCall.Input holds. +func SeniorDevRepairToolCall(call RawToolCall, tools *ToolMap, failure error) (*RawToolCall, error) { + lower := unicodeLower(call.ToolName) + if lower != call.ToolName { + if _, ok := tools.Get(lower); ok { + repaired := call + repaired.ToolName = lower + return &repaired, nil + } + } + payload := NewObject() + payload.SetString("tool", call.ToolName) + message := "" + if failure != nil { + message = failure.Error() + } + payload.SetString("error", message) + encoded, err := payload.MarshalJSON() + if err != nil { + return nil, err + } + repaired := call + repaired.Input = string(encoded) + repaired.ToolName = InvalidToolName + return &repaired, nil +} + +// ── parseToolCall ───────────────────────────────────────────────────────── + +// ParseToolCall validates and, if needed, repairs one call. It never returns +// an error: every failure path collapses into the synthetic invalid call, +// which is the whole point of stage 3. +func ParseToolCall(call RawToolCall, tools *ToolMap, repair RepairFn) ParsedToolCall { + parsed, err := doParseToolCall(call, tools) + if err == nil { + return parsed + } + + if repair != nil && isRepairable(err) { + repaired, repairErr := repair(call, tools, err) + if repairErr != nil { + err = &ToolCallRepairError{ + Cause: repairErr, + OriginalError: err, + Message: "Error repairing tool call: " + repairErr.Error(), + } + } else if repaired != nil { + // A second failure is NOT re-repaired. + parsed, secondErr := doParseToolCall(*repaired, tools) + if secondErr == nil { + return parsed + } + err = secondErr + } + // A nil repair result keeps the ORIGINAL error, which is already in + // `err`. + } + + return invalidToolCall(call, err) +} + +func isRepairable(err error) bool { + switch err.(type) { + case *NoSuchToolError, *InvalidToolInputError: + return true + } + return false +} + +// invalidToolCall builds the stage-3 result. `input` is the BEST-EFFORT parse of the raw +// string, falling back to the raw string itself when it is not JSON — so +// `input` can be either a parsed value or a bare string. +func invalidToolCall(call RawToolCall, err error) ParsedToolCall { + input := bestEffortParse(call.Input) + return ParsedToolCall{ + Type: "tool-call", + ToolCallID: call.ToolCallID, + ToolName: call.ToolName, + Input: input, + Dynamic: true, + Invalid: true, + Error: err, + ProviderExecuted: call.ProviderExecuted, + ProviderMetadata: call.ProviderMetadata, + } +} + +func bestEffortParse(raw string) json.RawMessage { + if normalized, err := parseSecureJSONValue(raw); err == nil { + if encoded, err := marshalJSONValue(normalized); err == nil { + return encoded + } + } + encoded, err := jsonutil.Marshal(raw) + if err != nil { + return json.RawMessage(`""`) + } + return encoded +} + +// doParseToolCall is stage 1: look the tool up, parse and validate the input. +func doParseToolCall(call RawToolCall, tools *ToolMap) (ParsedToolCall, error) { + spec, ok := tools.Get(call.ToolName) + if !ok { + return ParsedToolCall{}, newNoSuchToolError(call.ToolName, tools.Names()) + } + + // An empty input validates as `{}`; it is NOT an error. + var value json.RawMessage + if strings.TrimSpace(call.Input) == "" { + value = json.RawMessage("{}") + } else { + normalized, err := parseSecureJSONValue(call.Input) + if err != nil { + return ParsedToolCall{}, newInvalidToolInputError(call.ToolName, call.Input, NewJSONParseError(call.Input, err)) + } + encoded, err := marshalJSONValue(normalized) + if err != nil { + return ParsedToolCall{}, newInvalidToolInputError(call.ToolName, call.Input, NewJSONParseError(call.Input, err)) + } + value = encoded + } + if spec.Validate != nil { + if err := spec.Validate(value); err != nil { + return ParsedToolCall{}, newInvalidToolInputError(call.ToolName, call.Input, err) + } + } + return ParsedToolCall{ + Type: "tool-call", + ToolCallID: call.ToolCallID, + ToolName: call.ToolName, + Input: value, + ProviderExecuted: call.ProviderExecuted, + ProviderMetadata: call.ProviderMetadata, + }, nil +} + +// parseSecureJSONValue parses JSON and rejects `__proto__` keys and +// `constructor.prototype` pairs at any depth, so a tool input can never +// smuggle a prototype into a consumer that evaluates it. Both the streaming +// "is parsable" check and tool validation use this exact parser. +func parseSecureJSONValue(input string) (jsonValue, error) { + normalized, err := parseJSONValue([]byte(input)) + if err != nil { + return jsonValue{}, err + } + if hasForbiddenPrototypeJSONValue(normalized) { + return jsonValue{}, errors.New("Object contains forbidden prototype property") + } + return normalized, nil +} diff --git a/internal/seniordev/engine/orclient/transform.go b/internal/seniordev/engine/orclient/transform.go new file mode 100644 index 0000000000..4e7ed51210 --- /dev/null +++ b/internal/seniordev/engine/orclient/transform.go @@ -0,0 +1,433 @@ +//go:build !windows + +package orclient + +// Message normalisation before a request: surrogate sanitisation, +// unsupported-modality rewriting and the DeepSeek empty-reasoning stub; plus +// the OpenRouter-specific request options. + +import ( + "strings" + "unicode/utf16" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// ModelAPI is the `api` sub-object of a catalog model. +type ModelAPI struct { + Npm string `json:"npm"` + ID string `json:"id"` +} + +// ModelCapabilities is the slice of a catalog model's capabilities request +// assembly reads. `temperature` gates whether a temperature is sent at all; +// `input` gates UnsupportedParts. +type ModelCapabilities struct { + Temperature bool `json:"temperature"` + Reasoning bool `json:"reasoning"` + Attachment bool `json:"attachment"` + ToolCall bool `json:"toolcall"` + Input map[string]bool `json:"input"` + Output map[string]bool `json:"output"` +} + +// ModelLimit is a catalog model's limits. +type ModelLimit struct { + Context float64 `json:"context"` + Input *float64 `json:"input"` + Output float64 `json:"output"` +} + +// Model is the catalog-model projection this package needs. +type Model struct { + ProviderID string `json:"providerID"` + ID string `json:"id"` + API ModelAPI `json:"api"` + Capabilities ModelCapabilities `json:"capabilities"` + Limit ModelLimit `json:"limit"` +} + +// ── SanitizeSurrogates ─────────────────────────────────────────────────── + +// SanitizeSurrogates replaces every UNPAIRED UTF-16 surrogate with U+FFFD. +// +// This is a hand-rolled UTF-16 scan, and it MUST be UTF-16, not runes: the +// whole point is code units, and a surrogate pair must survive untouched while +// its halves individually do not. +// +// A lone surrogate is reachable: `encoding/json` maps a `\uD800` escape to +// U+FFFD on decode, but a Go string is a byte string and can carry the WTF-8 +// encoding of a surrogate code point (ED A0 80 … ED BF BF), which is what a +// non-strict decoder or a byte-level splice produces. Both forms are handled. +// +// The original string is returned BY VALUE when no replacement happens, so a +// CESU-8-encoded (surrogate-pair) input is not silently re-encoded to canonical +// UTF-8. +func SanitizeSurrogates(content string) string { + if !mayContainSurrogate(content) { + return content + } + units := utf16Units(content) + changed := false + for i := 0; i < len(units); i++ { + u := units[i] + switch { + case u >= 0xD800 && u <= 0xDBFF: + // High surrogate: paired only if followed by a low surrogate. + if i+1 < len(units) && units[i+1] >= 0xDC00 && units[i+1] <= 0xDFFF { + i++ + continue + } + units[i] = 0xFFFD + changed = true + case u >= 0xDC00 && u <= 0xDFFF: + // Low surrogate reached without having been consumed as the tail + // of a pair, i.e. not preceded by a high surrogate. + units[i] = 0xFFFD + changed = true + } + } + if !changed { + return content + } + return string(utf16.Decode(units)) +} + +// mayContainSurrogate is the cheap pre-test: a surrogate code unit can only +// appear in a Go string as the WTF-8 sequence ED A0..BF xx, or as a genuine +// astral character (F0..F4 lead byte) whose UTF-16 form is a well-formed pair. +// A well-formed pair is never rewritten, so only the WTF-8 form matters. +func mayContainSurrogate(s string) bool { + for i := 0; i+1 < len(s); i++ { + if s[i] == 0xED && s[i+1] >= 0xA0 && s[i+1] <= 0xBF { + return true + } + } + return false +} + +// utf16Units decodes a Go string to UTF-16 code units, accepting the WTF-8 +// encoding of an unpaired surrogate (which utf8.DecodeRuneInString rejects). +func utf16Units(s string) []uint16 { + out := make([]uint16, 0, len(s)) + for i := 0; i < len(s); { + if s[i] == 0xED && i+2 < len(s) && s[i+1] >= 0xA0 && s[i+1] <= 0xBF && s[i+2] >= 0x80 && s[i+2] <= 0xBF { + cp := rune(s[i]&0x0F)<<12 | rune(s[i+1]&0x3F)<<6 | rune(s[i+2]&0x3F) + out = append(out, uint16(cp)) + i += 3 + continue + } + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size <= 1 { + out = append(out, 0xFFFD) + i++ + continue + } + if r > 0xFFFF { + hi, lo := utf16.EncodeRune(r) + out = append(out, uint16(hi), uint16(lo)) + } else { + out = append(out, uint16(r)) + } + i += size + } + return out +} + +// ── UnsupportedParts ───────────────────────────────────────────────────── + +// mimeToModality maps a mime type to the capability key that gates it. +func mimeToModality(mime string) string { + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.HasPrefix(mime, "audio/"): + return "audio" + case strings.HasPrefix(mime, "video/"): + return "video" + case mime == "application/pdf": + return "pdf" + } + return "" +} + +// UnsupportedParts runs before NormalizeMessages. It only touches +// ARRAY-content user messages, and only their `file` parts: a part whose +// modality the model does not accept becomes a text part telling the model to +// inform the user. +// +// A nil capabilities.input map counts as "supports nothing", so a malformed +// catalog entry rewrites every file part rather than failing. +func UnsupportedParts(msgs []msgmodel.ModelMessage, model Model) []msgmodel.ModelMessage { + out := make([]msgmodel.ModelMessage, len(msgs)) + for i, msg := range msgs { + out[i] = msg + if msg.Role != "user" { + continue + } + parts, ok := msg.Content.([]any) + if !ok { + continue + } + out[i].Content = mapParts(parts, func(part any) any { + file, ok := part.(msgmodel.FileContent) + if !ok { + return part + } + modality := mimeToModality(file.MediaType) + if modality == "" { + return part + } + if model.Capabilities.Input[modality] { + return part + } + name := modality + if len(file.Filename) > 0 { + if decoded := textOf(rawJSONValue(file.Filename)); decoded != "" { + name = `"` + decoded + `"` + } + } + return msgmodel.TextContent{ + Type: "text", + Text: "ERROR: Cannot read " + name + " (this model does not support " + modality + " input). Inform the user.", + } + }) + } + return out +} + +// Message prepares a message list for an OpenRouter model: UnsupportedParts, +// then NormalizeMessages. It is called immediately before BuildRequestBody. +func Message(msgs []msgmodel.ModelMessage, model Model) []msgmodel.ModelMessage { + return NormalizeMessages(UnsupportedParts(msgs, model), model) +} + +// ── normalizeMessages, reachable branches only ──────────────────────────── + +// NormalizeMessages sanitises surrogates in every text-bearing part and, for +// a DeepSeek model, appends the empty reasoning stub. It returns a new slice +// and leaves the input alone. +func NormalizeMessages(msgs []msgmodel.ModelMessage, model Model) []msgmodel.ModelMessage { + out := make([]msgmodel.ModelMessage, len(msgs)) + for i, msg := range msgs { + out[i] = sanitizeMessage(msg) + } + if strings.Contains(strings.ToLower(model.API.ID), "deepseek") { + out = deepseekReasoningStub(out) + } + return out +} + +func sanitizeMessage(msg msgmodel.ModelMessage) msgmodel.ModelMessage { + switch msg.Role { + case "tool": + parts, ok := msg.Content.([]any) + if !ok { + // Non-array content is left alone. + return msg + } + msg.Content = mapParts(parts, func(part any) any { + if tr, ok := part.(msgmodel.ToolResultContent); ok { + return sanitizeToolResultOutput(tr) + } + return part + }) + return msg + + case "system": + if s, ok := msg.Content.(string); ok { + msg.Content = SanitizeSurrogates(s) + } + return msg + + case "user": + if s, ok := msg.Content.(string); ok { + msg.Content = SanitizeSurrogates(s) + return msg + } + parts, ok := msg.Content.([]any) + if !ok { + return msg + } + msg.Content = mapParts(parts, func(part any) any { + if t, ok := part.(msgmodel.TextContent); ok { + t.Text = SanitizeSurrogates(t.Text) + return t + } + return part + }) + return msg + + case "assistant": + if s, ok := msg.Content.(string); ok { + msg.Content = SanitizeSurrogates(s) + return msg + } + parts, ok := msg.Content.([]any) + if !ok { + return msg + } + msg.Content = mapParts(parts, func(part any) any { + switch p := part.(type) { + case msgmodel.TextContent: + p.Text = SanitizeSurrogates(p.Text) + return p + case msgmodel.ReasoningContent: + p.Text = SanitizeSurrogates(p.Text) + return p + case msgmodel.ToolResultContent: + return sanitizeToolResultOutput(p) + } + return part + }) + return msg + } + // An unknown role is passed through untouched. + return msg +} + +// sanitizeToolResultOutput sanitises the text of a tool result. `json` / +// `error-json` / `execution-denied` outputs are deliberately untouched. +func sanitizeToolResultOutput(tr msgmodel.ToolResultContent) msgmodel.ToolResultContent { + switch tr.Output.Type { + case "text", "error-text": + if s, ok := tr.Output.Value.(string); ok { + tr.Output.Value = SanitizeSurrogates(s) + } + case "content": + items, ok := tr.Output.Value.([]any) + if !ok { + return tr + } + tr.Output.Value = mapParts(items, func(item any) any { + if t, ok := item.(msgmodel.ToolOutputContentText); ok { + t.Text = SanitizeSurrogates(t.Text) + return t + } + return item + }) + } + return tr +} + +func mapParts(parts []any, f func(any) any) []any { + out := make([]any, len(parts)) + for i, p := range parts { + out[i] = f(p) + } + return out +} + +// deepseekReasoningStub: DeepSeek requires every assistant message to carry +// reasoning. +// +// Every assistant message gets `{type:"reasoning", text:""}` APPENDED AT THE +// END of its content (after any tool-calls), unless it already carries a +// reasoning part. String content becomes `[{type:"text", text}]` first, and an +// EMPTY string produces no text part at all. +// +// This fires for every DeepSeek API id. +func deepseekReasoningStub(msgs []msgmodel.ModelMessage) []msgmodel.ModelMessage { + out := make([]msgmodel.ModelMessage, len(msgs)) + for i, msg := range msgs { + if msg.Role != "assistant" { + out[i] = msg + continue + } + if parts, ok := msg.Content.([]any); ok { + hasReasoning := false + for _, p := range parts { + if _, is := p.(msgmodel.ReasoningContent); is { + hasReasoning = true + break + } + } + if hasReasoning { + out[i] = msg + continue + } + next := make([]any, 0, len(parts)+1) + next = append(next, parts...) + next = append(next, msgmodel.ReasoningContent{Type: "reasoning", Text: ""}) + msg.Content = next + out[i] = msg + continue + } + text, _ := msg.Content.(string) + next := make([]any, 0, 2) + if text != "" { + next = append(next, msgmodel.TextContent{Type: "text", Text: text}) + } + next = append(next, msgmodel.ReasoningContent{Type: "reasoning", Text: ""}) + msg.Content = next + out[i] = msg + } + return out +} + +// ── output cap ──────────────────────────────────────────────────────────── +// +// No generation parameter (temperature, top_p, ...) has a built-in default; +// the provider's own defaults apply unless the caller sets one. +// MaxOutputTokens is the one value with a built-in default, below. + +// MaxOutputTokens is `min(model.limit.output, OUTPUT_TOKEN_MAX)`, falling +// back to OUTPUT_TOKEN_MAX when the minimum is 0 or NaN. Delegated to +// internal/engine/calc so the SENIOR_DEV_OUTPUT_TOKEN_MAX read lives in exactly +// one place. +func MaxOutputTokens(model Model) float64 { + return calc.MaxOutputTokens(calc.Model{Limit: calc.ModelLimit{ + Context: model.Limit.Context, + Input: model.Limit.Input, + Output: model.Limit.Output, + }}) +} + +// ── Options / ProviderOptions, OpenRouter cases only ───────────────────── + +// OptionsInput is what Options needs. +type OptionsInput struct { + Model Model `json:"model"` + SessionID string `json:"sessionID"` +} + +// Options is the OpenRouter-specific request option bag, in the order the +// keys reach the wire after the top-level spread: +// +// usage (npm === "@openrouter/ai-sdk-provider") +// prompt_cache_key (providerID === "openrouter") +// +// No reasoning effort is set here; it is sent only when a variant or config +// sets it. +func Options(input OptionsInput) *Object { + result := NewObject() + if input.Model.API.Npm == "@openrouter/ai-sdk-provider" || input.Model.API.Npm == "@llmgateway/ai-sdk-provider" { + usage := NewObject() + usage.SetBool("include", true) + result.SetObject("usage", usage) + } + if input.Model.ProviderID == Service { + result.SetString("prompt_cache_key", input.SessionID) + } + return result +} + +// ProviderOptions wraps the merged option bag under the provider's SDK key, +// which for OpenRouter is "openrouter": exactly the namespace BuildRequestBody +// unwraps and spreads over the body. Callers that go straight to +// BuildRequestBody can skip the round-trip; this exists so the wrapping is +// testable on its own. +func ProviderOptions(model Model, options *Object) *Object { + out := NewObject() + out.SetObject(sdkKeyFor(model.API.Npm), options) + return out +} + +// sdkKeyFor is the key a model's options are kept under. Every model this +// client serves speaks OpenRouter's wire, whatever package name it came with, +// so the answer is always the one service identity. +func sdkKeyFor(npm string) string { + return Service +} diff --git a/internal/seniordev/engine/orclient/wire.go b/internal/seniordev/engine/orclient/wire.go new file mode 100644 index 0000000000..4dd3dd7432 --- /dev/null +++ b/internal/seniordev/engine/orclient/wire.go @@ -0,0 +1,943 @@ +//go:build !windows + +package orclient + +// Chunk validation: the streaming chunk shape and the error-response shape, +// tried in that order. +// +// Three properties of the validation drive behaviour and are easy to lose: +// +// 1. `choices` is a REQUIRED array on the chunk shape. A payload carrying +// only `usage` fails BOTH shapes and surfaces as a parse failure, i.e. an +// `error` part and `finishReason = error`, not a silently ignored chunk. +// 2. Unknown fields survive validation. The one place that is observable is +// the `error` field: an object carrying both `choices` and `error` +// validates as a CHUNK, and the emitted error is the RAW object. When it +// validates as the ERROR shape instead, `code`/`type`/`param` are filled +// with null and reordered to shape order. +// 3. `finish_reason` is any string, not an enum, so arbitrary provider +// strings reach `raw` and map to `other`. +// +// The `error` value of a PARSE-FAILURE part is an envelope +// (`{"name":…,"cause":…,"value":…}` / `{"name":…,"cause":…,"text":…}`) whose +// discriminating `name` is what a consumer keys on; the router classifiers +// substring-match on `message`. + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" +) + +// Chunk is one parsed SSE payload. +type Chunk struct { + // Success reports whether the payload validated. + Success bool + // ParseError is the serialised error object emitted when Success is false. + ParseError json.RawMessage + // Value is the validated chunk. + Value *ChunkValue +} + +// ChunkValue is the validated payload. +type ChunkValue struct { + ID *string + Model *string + Provider *string + Usage *calc.OpenRouterUsage + Choices []Choice + + // ErrorField is set when the payload carries an `error` key, for BOTH + // shapes. + ErrorField json.RawMessage +} + +// Choice is `value.choices[i]`. +type Choice struct { + Delta *Delta + FinishReason *string +} + +// Delta is `choices[i].delta`. +type Delta struct { + Content *string + Reasoning *string + ReasoningDetails []ReasoningDetail + HasReasoningDeta bool + Images []ImageResponse + ToolCalls []ToolCallDelta + HasToolCalls bool + Annotations []Annotation + HasAnnotations bool +} + +// ImageResponse is one recognised `delta.images[]` entry. +type ImageResponse struct{ URL string } + +// ToolCallDelta is one `delta.tool_calls[]` entry. Every field may be absent; +// a missing `type` on a FIRST delta is an InvalidResponseDataError. +type ToolCallDelta struct { + Index *float64 + ID *string + Type *string + HasFunction bool + Name *string + Arguments *string + // Raw is the original entry, carried because InvalidResponseDataError + // reports it as `data`. + Raw json.RawMessage +} + +// Annotation is one `delta.annotations[]` entry. +type Annotation struct { + Type string + // url_citation fields. + URL string + Title *string + StartIndex *float64 + EndIndex *float64 + Content *string + // Raw carries the normalized annotation object; the old-format + // `file_annotation` is validated and then IGNORED ENTIRELY. + Raw json.RawMessage +} + +// ParseChunk parses and validates one SSE payload. +func ParseChunk(text string) Chunk { + parsed, err := parseJSONValue([]byte(text)) + if err != nil { + return Chunk{Success: false, ParseError: jsonParseErrorValue(text)} + } + if hasForbiddenPrototypeJSONValue(parsed) { + return Chunk{Success: false, ParseError: jsonParseErrorValue(text)} + } + raw := json.RawMessage(text) + + value, chunkErr := validateChunk(raw) + if chunkErr == nil { + return Chunk{Success: true, Value: value} + } + value, errErr := validateErrorResponse(raw) + if errErr == nil { + return Chunk{Success: true, Value: value} + } + return Chunk{ + Success: false, + ParseError: typeValidationErrorValue(raw, chunkErr, errErr), + } +} + +func hasForbiddenPrototypeJSONValue(value jsonValue) bool { + switch value.Kind { + case kindArray: + for _, child := range value.Array { + if hasForbiddenPrototypeJSONValue(child) { + return true + } + } + case kindObject: + for _, member := range value.Object { + if member.Key == "__proto__" { + return true + } + if member.Key == "constructor" && member.Value.Kind == kindObject { + for _, child := range member.Value.Object { + if child.Key == "prototype" { + return true + } + } + } + if hasForbiddenPrototypeJSONValue(member.Value) { + return true + } + } + } + return false +} + +func jsonParseErrorValue(text string) json.RawMessage { + w := newObjectWriter() + w.str("name", "JSONParseError") + w.raw("cause", json.RawMessage("{}")) + w.str("text", text) + out, err := w.done() + if err != nil { + return json.RawMessage(`{"name":"JSONParseError"}`) + } + return out +} + +func typeValidationErrorValue(raw json.RawMessage, chunkErr, errErr error) json.RawMessage { + cause := newObjectWriter() + cause.str("name", "ValidationError") + cause.str("message", chunkErr.Error()+"\n"+errErr.Error()) + causeRaw, err := cause.done() + if err != nil { + causeRaw = json.RawMessage("{}") + } + w := newObjectWriter() + w.str("name", "TypeValidationError") + w.raw("cause", causeRaw) + w.raw("value", raw) + out, err := w.done() + if err != nil { + return json.RawMessage(`{"name":"TypeValidationError"}`) + } + return out +} + +// ── the chunk shape ────────────────────────────────────────────────────── + +func validateChunk(raw json.RawMessage) (*ChunkValue, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`expected object`) + } + out := &ChunkValue{} + + var err error + if out.ID, err = optionalString(obj, "id"); err != nil { + return nil, err + } + if out.Model, err = optionalString(obj, "model"); err != nil { + return nil, err + } + if out.Provider, err = optionalString(obj, "provider"); err != nil { + return nil, err + } + if out.Usage, err = validateUsage(obj["usage"]); err != nil { + return nil, err + } + + choicesRaw, present := obj["choices"] + if !present || bytes.Equal(bytes.TrimSpace(choicesRaw), []byte("null")) { + return nil, errors.New(`Invalid input: expected array, received undefined at "choices"`) + } + var choices []json.RawMessage + if err := json.Unmarshal(choicesRaw, &choices); err != nil { + return nil, errors.New(`Invalid input: expected array at "choices"`) + } + out.Choices = make([]Choice, 0, len(choices)) + for _, c := range choices { + choice, err := validateChoice(c) + if err != nil { + return nil, err + } + out.Choices = append(out.Choices, choice) + } + if e, ok := obj["error"]; ok { + out.ErrorField = e + } + return out, nil +} + +func validateChoice(raw json.RawMessage) (Choice, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return Choice{}, errors.New(`Invalid input: expected object at "choices[]"`) + } + out := Choice{} + if _, err := nullishNumber(obj, "index"); err != nil { + return Choice{}, err + } + if logprobs, ok := obj["logprobs"]; ok && !isJSONNull(logprobs) { + if err := validateLogprobs(logprobs); err != nil { + return Choice{}, err + } + } + fr, err := nullableOptionalString(obj, "finish_reason") + if err != nil { + return Choice{}, err + } + out.FinishReason = fr + + deltaRaw, present := obj["delta"] + if !present || bytes.Equal(bytes.TrimSpace(deltaRaw), []byte("null")) { + return out, nil + } + delta, err := validateDelta(deltaRaw) + if err != nil { + return Choice{}, err + } + out.Delta = delta + return out, nil +} + +func validateDelta(raw json.RawMessage) (*Delta, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`Invalid input: expected object at "delta"`) + } + out := &Delta{} + + if roleRaw, ok := obj["role"]; ok { + var role string + if err := json.Unmarshal(roleRaw, &role); err != nil || role != "assistant" { + return nil, errors.New(`Invalid input: expected "assistant" at "delta.role"`) + } + } + var err error + if out.Content, err = nullishString(obj, "content"); err != nil { + return nil, err + } + if out.Reasoning, err = nullishString(obj, "reasoning"); err != nil { + return nil, err + } + + if rd, ok := obj["reasoning_details"]; ok && !isJSONNull(rd) { + var entries []json.RawMessage + if err := json.Unmarshal(rd, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.reasoning_details"`) + } + details := make([]ReasoningDetail, 0, len(entries)) + for _, e := range entries { + d, ok := parseReasoningDetail(e) + if !ok { + continue // mapped to null, then filtered + } + details = append(details, d) + } + out.ReasoningDetails = details + out.HasReasoningDeta = true + } + + if imgs, ok := obj["images"]; ok && !isJSONNull(imgs) { + var entries []json.RawMessage + if err := json.Unmarshal(imgs, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.images"`) + } + for _, e := range entries { + img, ok := parseImageResponse(e) + if !ok { + continue // ImageResponseWithUnknownSchema → null → filtered + } + out.Images = append(out.Images, img) + } + } + + if tc, ok := obj["tool_calls"]; ok && !isJSONNull(tc) { + var entries []json.RawMessage + if err := json.Unmarshal(tc, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.tool_calls"`) + } + out.HasToolCalls = true + for _, e := range entries { + d, err := validateToolCallDelta(e) + if err != nil { + return nil, err + } + out.ToolCalls = append(out.ToolCalls, d) + } + } + + if ann, ok := obj["annotations"]; ok && !isJSONNull(ann) { + var entries []json.RawMessage + if err := json.Unmarshal(ann, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "delta.annotations"`) + } + out.HasAnnotations = true + for _, e := range entries { + a, err := validateAnnotation(e) + if err != nil { + return nil, err + } + out.Annotations = append(out.Annotations, a) + } + } + return out, nil +} + +func validateToolCallDelta(raw json.RawMessage) (ToolCallDelta, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return ToolCallDelta{}, errors.New(`Invalid input: expected object at "delta.tool_calls[]"`) + } + out := ToolCallDelta{Raw: raw} + var err error + if out.Index, err = nullishNumber(obj, "index"); err != nil { + return ToolCallDelta{}, err + } + if out.ID, err = nullishString(obj, "id"); err != nil { + return ToolCallDelta{}, err + } + if t, ok := obj["type"]; ok { + var typ string + if err := json.Unmarshal(t, &typ); err != nil || typ != "function" { + return ToolCallDelta{}, errors.New(`Invalid input: expected "function" at "delta.tool_calls[].type"`) + } + out.Type = &typ + } + // `function` is REQUIRED on the delta entry. + fnRaw, ok := obj["function"] + if !ok { + return ToolCallDelta{}, errors.New(`Invalid input: expected object, received undefined at "delta.tool_calls[].function"`) + } + if isJSONNull(fnRaw) { + return ToolCallDelta{}, errors.New(`Invalid input: expected object at "delta.tool_calls[].function"`) + } + var fn map[string]json.RawMessage + if err := json.Unmarshal(fnRaw, &fn); err != nil { + return ToolCallDelta{}, errors.New(`Invalid input: expected object at "delta.tool_calls[].function"`) + } + out.HasFunction = true + if out.Name, err = nullishString(fn, "name"); err != nil { + return ToolCallDelta{}, err + } + if out.Arguments, err = nullishString(fn, "arguments"); err != nil { + return ToolCallDelta{}, err + } + return out, nil +} + +func validateAnnotation(raw json.RawMessage) (Annotation, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "delta.annotations[]"`) + } + typeRaw, ok := obj["type"] + if !ok { + return Annotation{}, errors.New(`Invalid input: expected a discriminated annotation`) + } + var typ string + if err := json.Unmarshal(typeRaw, &typ); err != nil { + return Annotation{}, errors.New(`Invalid input: expected string at "delta.annotations[].type"`) + } + switch typ { + case "url_citation": + inner, ok := obj["url_citation"] + if !ok { + return Annotation{}, errors.New(`Invalid input: expected object at "url_citation"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "url_citation"`) + } + url, ok := requiredString(fields, "url") + if !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "url_citation.url"`) + } + a := Annotation{Type: typ, URL: url, Raw: raw} + var err error + if a.Title, err = optionalString(fields, "title"); err != nil { + return Annotation{}, err + } + if a.StartIndex, err = optionalNumber(fields, "start_index"); err != nil { + return Annotation{}, err + } + if a.EndIndex, err = optionalNumber(fields, "end_index"); err != nil { + return Annotation{}, err + } + if a.Content, err = optionalString(fields, "content"); err != nil { + return Annotation{}, err + } + normalizedInner, err := normalizePassthroughObject(inner, + []string{"url", "title", "start_index", "end_index", "content"}, nil) + if err != nil { + return Annotation{}, err + } + normalizedOuter, err := normalizePassthroughObject(raw, + []string{"type", "url_citation"}, + map[string]json.RawMessage{"url_citation": normalizedInner}) + if err != nil { + return Annotation{}, err + } + a.Raw = normalizedOuter + return a, nil + case "file_annotation": + inner, ok := obj["file_annotation"] + if !ok || isJSONNull(inner) { + return Annotation{}, errors.New(`Invalid input: expected object at "file_annotation"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "file_annotation"`) + } + if _, ok := requiredString(fields, "file_id"); !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "file_annotation.file_id"`) + } + if _, err := optionalString(fields, "quote"); err != nil { + return Annotation{}, err + } + normalizedInner, err := normalizePassthroughObject(inner, + []string{"file_id", "quote"}, nil) + if err != nil { + return Annotation{}, err + } + normalizedOuter, err := normalizePassthroughObject(raw, + []string{"type", "file_annotation"}, + map[string]json.RawMessage{"file_annotation": normalizedInner}) + if err != nil { + return Annotation{}, err + } + return Annotation{Type: typ, Raw: normalizedOuter}, nil + case "file": + inner, ok := obj["file"] + if !ok || isJSONNull(inner) { + return Annotation{}, errors.New(`Invalid input: expected object at "file"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return Annotation{}, errors.New(`Invalid input: expected object at "file"`) + } + if _, ok := requiredString(fields, "hash"); !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "file.hash"`) + } + if _, ok := requiredString(fields, "name"); !ok { + return Annotation{}, errors.New(`Invalid input: expected string at "file.name"`) + } + overrides := map[string]json.RawMessage{} + if content, present := fields["content"]; present { + normalized, err := normalizeFileAnnotationContent(content) + if err != nil { + return Annotation{}, err + } + overrides["content"] = normalized + } + normalizedInner, err := normalizePassthroughObject(inner, + []string{"hash", "name", "content"}, overrides) + if err != nil { + return Annotation{}, err + } + normalizedOuter, err := normalizePassthroughObject(raw, + []string{"type", "file"}, + map[string]json.RawMessage{"file": normalizedInner}) + if err != nil { + return Annotation{}, err + } + return Annotation{Type: typ, Raw: normalizedOuter}, nil + } + // The annotation union has NO unknown fallback, so an unrecognised entry + // fails the whole chunk. + return Annotation{}, errors.New(`Invalid input: unrecognised annotation type ` + typ) +} + +func parseImageResponse(raw json.RawMessage) (ImageResponse, bool) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return ImageResponse{}, false + } + typ, ok := requiredString(obj, "type") + if !ok || typ != "image_url" { + return ImageResponse{}, false + } + inner, ok := obj["image_url"] + if !ok { + return ImageResponse{}, false + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(inner, &fields); err != nil { + return ImageResponse{}, false + } + url, ok := requiredString(fields, "url") + if !ok { + return ImageResponse{}, false + } + return ImageResponse{URL: url}, true +} + +// validateUsage checks the `usage` object: prompt_tokens, +// completion_tokens and total_tokens are all REQUIRED numbers, so a partial +// usage object fails the whole chunk. +func validateUsage(raw json.RawMessage) (*calc.OpenRouterUsage, error) { + if raw == nil || isJSONNull(raw) { + return nil, nil + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage"`) + } + for _, required := range []string{"prompt_tokens", "completion_tokens", "total_tokens"} { + v, ok := obj[required] + if !ok { + return nil, errors.New(`Invalid input: expected number, received undefined at "usage.` + required + `"`) + } + if !isJSONNumber(v) { + return nil, errors.New(`Invalid input: expected number at "usage.` + required + `"`) + } + } + overrides := map[string]json.RawMessage{} + if d, ok := obj["prompt_tokens_details"]; ok && !isJSONNull(d) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(d, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage.prompt_tokens_details"`) + } + cached, ok := fields["cached_tokens"] + if !ok { + return nil, errors.New(`Invalid input: expected number, received undefined at "usage.prompt_tokens_details.cached_tokens"`) + } + if !isJSONNumber(cached) { + return nil, errors.New(`Invalid input: expected number at "usage.prompt_tokens_details.cached_tokens"`) + } + if cacheWrite, present := fields["cache_write_tokens"]; present && + !isJSONNull(cacheWrite) && !isJSONNumber(cacheWrite) { + return nil, errors.New(`Invalid input: expected number at "usage.prompt_tokens_details.cache_write_tokens"`) + } + normalized, err := normalizePassthroughObject(d, + []string{"cached_tokens", "cache_write_tokens"}, nil) + if err != nil { + return nil, err + } + overrides["prompt_tokens_details"] = normalized + } + if d, ok := obj["completion_tokens_details"]; ok && !isJSONNull(d) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(d, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage.completion_tokens_details"`) + } + reasoning, ok := fields["reasoning_tokens"] + if !ok { + return nil, errors.New(`Invalid input: expected number, received undefined at "usage.completion_tokens_details.reasoning_tokens"`) + } + if !isJSONNumber(reasoning) { + return nil, errors.New(`Invalid input: expected number at "usage.completion_tokens_details.reasoning_tokens"`) + } + normalized, err := normalizePassthroughObject(d, + []string{"reasoning_tokens"}, nil) + if err != nil { + return nil, err + } + overrides["completion_tokens_details"] = normalized + } + if cost, present := obj["cost"]; present && !isJSONNumber(cost) { + return nil, errors.New(`Invalid input: expected number at "usage.cost"`) + } + if details, present := obj["cost_details"]; present && !isJSONNull(details) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(details, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage.cost_details"`) + } + if upstream, ok := fields["upstream_inference_cost"]; ok && + !isJSONNull(upstream) && !isJSONNumber(upstream) { + return nil, errors.New(`Invalid input: expected number at "usage.cost_details.upstream_inference_cost"`) + } + normalized, err := normalizePassthroughObject(details, + []string{"upstream_inference_cost"}, nil) + if err != nil { + return nil, err + } + overrides["cost_details"] = normalized + } + normalized, err := normalizePassthroughObject(raw, []string{ + "prompt_tokens", + "prompt_tokens_details", + "completion_tokens", + "completion_tokens_details", + "total_tokens", + "cost", + "cost_details", + }, overrides) + if err != nil { + return nil, err + } + var usage calc.OpenRouterUsage + if err := json.Unmarshal(normalized, &usage); err != nil { + return nil, errors.New(`Invalid input: expected object at "usage"`) + } + return &usage, nil +} + +// ── the error shape ────────────────────────────────────────────────────── + +// validateErrorResponse checks the error-response shape: `error.message` is +// required, and `code`/`type`/`param` default to explicit nulls in the +// normalized object, in SHAPE order, with extra keys after. +func validateErrorResponse(raw json.RawMessage) (*ChunkValue, error) { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return nil, errors.New(`expected object`) + } + errRaw, ok := obj["error"] + if !ok { + return nil, errors.New(`Invalid input: expected object, received undefined at "error"`) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(errRaw, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "error"`) + } + if _, ok := requiredString(fields, "message"); !ok { + return nil, errors.New(`Invalid input: expected string at "error.message"`) + } + if code, present := fields["code"]; present && !isJSONNull(code) && + !isJSONString(code) && !isJSONNumber(code) { + return nil, errors.New(`Invalid input: expected string or number at "error.code"`) + } + if typ, present := fields["type"]; present && !isJSONNull(typ) && !isJSONString(typ) { + return nil, errors.New(`Invalid input: expected string at "error.type"`) + } + + ordered, err := ParseObject(errRaw) + if err != nil { + return nil, err + } + normalized := NewObject() + for _, key := range []string{"code", "message", "type", "param"} { + if v, ok := ordered.Get(key); ok { + if err := normalized.Set(key, v); err != nil { + return nil, err + } + continue + } + if key == "message" { + continue + } + normalized.set(key, jsonValue{Kind: kindNull}) + } + for _, key := range ordered.Keys() { + if normalized.Has(key) { + continue + } + v, _ := ordered.Get(key) + if err := normalized.Set(key, v); err != nil { + return nil, err + } + } + encoded, err := normalized.MarshalJSON() + if err != nil { + return nil, err + } + return &ChunkValue{ErrorField: encoded}, nil +} + +// ── small field helpers ────────────────────────────────────────────────── + +func isJSONNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), []byte("null")) +} + +// optionalString reads an optional string: absent is fine, null is NOT. +func optionalString(obj map[string]json.RawMessage, key string) (*string, error) { + raw, ok := obj[key] + if !ok { + return nil, nil + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, errors.New(`Invalid input: expected string at "` + key + `"`) + } + return &s, nil +} + +// nullishString reads a string that may be absent or null; both give nil. +func nullishString(obj map[string]json.RawMessage, key string) (*string, error) { + raw, ok := obj[key] + if !ok || isJSONNull(raw) { + return nil, nil + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, errors.New(`Invalid input: expected string at "` + key + `"`) + } + return &s, nil +} + +// nullableOptionalString is nullishString under the name the wire field +// declares. +func nullableOptionalString(obj map[string]json.RawMessage, key string) (*string, error) { + return nullishString(obj, key) +} + +func nullishNumber(obj map[string]json.RawMessage, key string) (*float64, error) { + raw, ok := obj[key] + if !ok || isJSONNull(raw) { + return nil, nil + } + var n float64 + if err := json.Unmarshal(raw, &n); err != nil { + return nil, errors.New(`Invalid input: expected number at "` + key + `"`) + } + return &n, nil +} + +func optionalNumber(obj map[string]json.RawMessage, key string) (*float64, error) { + raw, ok := obj[key] + if !ok { + return nil, nil + } + var n float64 + if err := json.Unmarshal(raw, &n); err != nil { + return nil, errors.New(`Invalid input: expected number at "` + key + `"`) + } + return &n, nil +} + +// validateLogprobs validates choice.logprobs, which nothing reads afterwards: +// a malformed block still turns the whole SSE chunk into an error part. +func validateLogprobs(raw json.RawMessage) error { + var obj map[string]json.RawMessage + if err := json.Unmarshal(raw, &obj); err != nil { + return errors.New(`Invalid input: expected object at "choices[].logprobs"`) + } + content, ok := obj["content"] + if !ok { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content"`) + } + // content may be an explicit null. + if isJSONNull(content) { + return nil + } + var entries []json.RawMessage + if err := json.Unmarshal(content, &entries); err != nil { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content"`) + } + for _, entry := range entries { + var token map[string]json.RawMessage + if err := json.Unmarshal(entry, &token); err != nil { + return errors.New(`Invalid input: expected object at "choices[].logprobs.content[]"`) + } + if _, ok := requiredString(token, "token"); !ok { + return errors.New(`Invalid input: expected string at "choices[].logprobs.content[].token"`) + } + if value, ok := token["logprob"]; !ok || !isJSONNumber(value) { + return errors.New(`Invalid input: expected number at "choices[].logprobs.content[].logprob"`) + } + top, ok := token["top_logprobs"] + if !ok || isJSONNull(top) { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content[].top_logprobs"`) + } + var alternatives []json.RawMessage + if err := json.Unmarshal(top, &alternatives); err != nil { + return errors.New(`Invalid input: expected array at "choices[].logprobs.content[].top_logprobs"`) + } + for _, alternative := range alternatives { + var fields map[string]json.RawMessage + if err := json.Unmarshal(alternative, &fields); err != nil { + return errors.New(`Invalid input: expected object at "choices[].logprobs.content[].top_logprobs[]"`) + } + if _, ok := requiredString(fields, "token"); !ok { + return errors.New(`Invalid input: expected string at "choices[].logprobs.content[].top_logprobs[].token"`) + } + if value, ok := fields["logprob"]; !ok || !isJSONNumber(value) { + return errors.New(`Invalid input: expected number at "choices[].logprobs.content[].top_logprobs[].logprob"`) + } + } + } + return nil +} + +func normalizeFileAnnotationContent(raw json.RawMessage) (json.RawMessage, error) { + if isJSONNull(raw) { + return nil, errors.New(`Invalid input: expected array at "file.content"`) + } + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, errors.New(`Invalid input: expected array at "file.content"`) + } + values := make([]jsonValue, 0, len(entries)) + for _, entry := range entries { + var fields map[string]json.RawMessage + if err := json.Unmarshal(entry, &fields); err != nil { + return nil, errors.New(`Invalid input: expected object at "file.content[]"`) + } + if _, ok := requiredString(fields, "type"); !ok { + return nil, errors.New(`Invalid input: expected string at "file.content[].type"`) + } + if _, err := optionalString(fields, "text"); err != nil { + return nil, err + } + normalized, err := normalizePassthroughObject(entry, []string{"type", "text"}, nil) + if err != nil { + return nil, err + } + value, err := parseJSONValue(normalized) + if err != nil { + return nil, err + } + values = append(values, value) + } + return marshalJSONValue(jsonValue{Kind: kindArray, Array: values}) +} + +// normalizePassthroughObject re-emits an object with the declared shape keys +// first, in shape order, followed by the remaining keys in source order. +// Overrides contain already-normalized nested objects. +func normalizePassthroughObject( + raw json.RawMessage, + shape []string, + overrides map[string]json.RawMessage, +) (json.RawMessage, error) { + source, err := ParseObject(raw) + if err != nil { + return nil, err + } + out := NewObject() + for _, key := range shape { + value, present := source.Get(key) + if !present { + continue + } + if override, ok := overrides[key]; ok { + value = override + } + if err := out.Set(key, value); err != nil { + return nil, err + } + } + for _, key := range source.Keys() { + if out.Has(key) { + continue + } + value, _ := source.Get(key) + if err := out.Set(key, value); err != nil { + return nil, err + } + } + return out.MarshalJSON() +} + +func isJSONNumber(raw json.RawMessage) bool { + var number json.Number + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil { + return false + } + number, ok := token.(json.Number) + if !ok { + return false + } + var n float64 + return json.Unmarshal([]byte(number.String()), &n) == nil +} + +func isJSONString(raw json.RawMessage) bool { + var value string + return json.Unmarshal(raw, &value) == nil +} + +// getMediaType reads the media type of a data URL, else the default. +func getMediaType(dataURL, defaultMediaType string) string { + if !strings.HasPrefix(dataURL, "data:") { + return defaultMediaType + } + rest := dataURL[len("data:"):] + end := strings.IndexByte(rest, ';') + if end < 0 { + end = len(rest) + } + if end == 0 { + return defaultMediaType + } + return rest[:end] +} + +// InvalidResponseDataError is returned by the tool-call accumulator for a +// malformed first delta. It tears the whole stream down; it is not an `error` +// part. +type InvalidResponseDataError struct { + Message string + Data json.RawMessage +} + +func (e *InvalidResponseDataError) Error() string { return e.Message } + +func newInvalidResponseDataError(message string, data any) *InvalidResponseDataError { + encoded, err := json.Marshal(data) + if err != nil { + encoded = nil + } + return &InvalidResponseDataError{Message: message, Data: encoded} +} diff --git a/internal/seniordev/engine/retrysched/contextoverflow.go b/internal/seniordev/engine/retrysched/contextoverflow.go new file mode 100644 index 0000000000..6babf27bc2 --- /dev/null +++ b/internal/seniordev/engine/retrysched/contextoverflow.go @@ -0,0 +1,70 @@ +//go:build !windows + +package retrysched + +import ( + "encoding/json" + "regexp" + "strings" +) + +var contextOverflowPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)prompt is too long`), + regexp.MustCompile(`(?i)input is too long for requested model`), + regexp.MustCompile(`(?i)exceeds the context window`), + regexp.MustCompile(`(?i)input token count.*exceeds the maximum`), + regexp.MustCompile(`(?i)maximum prompt length is \d+`), + regexp.MustCompile(`(?i)reduce the length of the messages`), + regexp.MustCompile(`(?i)maximum context length is \d+ tokens`), + regexp.MustCompile(`(?i)exceeds the limit of \d+`), + regexp.MustCompile(`(?i)exceeds the available context size`), + regexp.MustCompile(`(?i)greater than the context length`), + regexp.MustCompile(`(?i)context window exceeds limit`), + regexp.MustCompile(`(?i)exceeded model token limit`), + regexp.MustCompile(`(?i)context[_ ]length[_ ]exceeded`), + regexp.MustCompile(`(?i)request entity too large`), + regexp.MustCompile(`(?i)context length is only \d+ tokens`), + regexp.MustCompile(`(?i)input length.*exceeds.*context length`), + regexp.MustCompile(`(?i)prompt too long; exceeded (max )?context length`), + regexp.MustCompile(`(?i)too large for model with \d+ maximum context length`), + regexp.MustCompile(`(?i)model_context_window_exceeded`), +} + +var contextOverflowNoBody = regexp.MustCompile(`(?i)^4(00|13)\s*(status code)?\s*\(no body\)`) + +// IsContextOverflow reports whether a classified error says the prompt did +// not fit the model's context: a 413, a known overflow message, or a +// context_length_exceeded code in the response body. +func IsContextOverflow(err Err) bool { + if err.Name == "ContextOverflowError" { + return true + } + if !err.IsAPIError() { + return false + } + if err.Data.StatusCode != nil && *err.Data.StatusCode == 413 { + return true + } + message := "" + if err.Data.Message != nil { + message = strings.TrimSpace(*err.Data.Message) + } + for _, pattern := range contextOverflowPatterns { + if pattern.MatchString(message) { + return true + } + } + if contextOverflowNoBody.MatchString(message) { + return true + } + if err.Data.ResponseBody == nil { + return false + } + var body struct { + Error *struct { + Code string `json:"code"` + } `json:"error"` + } + return json.Unmarshal([]byte(*err.Data.ResponseBody), &body) == nil && + body.Error != nil && body.Error.Code == "context_length_exceeded" +} diff --git a/internal/seniordev/engine/retrysched/contextoverflow_test.go b/internal/seniordev/engine/retrysched/contextoverflow_test.go new file mode 100644 index 0000000000..d40b0d8e50 --- /dev/null +++ b/internal/seniordev/engine/retrysched/contextoverflow_test.go @@ -0,0 +1,37 @@ +//go:build !windows + +package retrysched + +import "testing" + +func TestContextOverflowProviderPredicate(t *testing.T) { + // A hard provider failure is an overflow by message, by status, or by + // the response-body error code. + status400 := float64(400) + status413 := float64(413) + for _, test := range []struct { + name string + message string + status *float64 + body *string + want bool + }{ + {name: "message signature", message: "maximum context length is 128000 tokens", status: &status400, want: true}, + {name: "entity too large status", message: "payload rejected", status: &status413, want: true}, + {name: "response code", message: "bad request", status: &status400, body: stringAddress(`{"error":{"code":"context_length_exceeded"}}`), want: true}, + {name: "ordinary bad request", message: "invalid schema", status: &status400, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + retryable := false + err := Err{Name: "APIError", Data: ErrData{ + Message: &test.message, StatusCode: test.status, + IsRetryable: &retryable, ResponseBody: test.body, + }} + if got := IsContextOverflow(err); got != test.want { + t.Fatalf("IsContextOverflow(%+v) = %v, want %v", err, got, test.want) + } + }) + } +} + +func stringAddress(value string) *string { return &value } diff --git a/internal/seniordev/engine/retrysched/errors.go b/internal/seniordev/engine/retrysched/errors.go new file mode 100644 index 0000000000..a594bc6f6e --- /dev/null +++ b/internal/seniordev/engine/retrysched/errors.go @@ -0,0 +1,127 @@ +//go:build !windows + +package retrysched + +import ( + "encoding/json" + "errors" + "regexp" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// timeoutMessageRE matches the timeout-shaped messages a transport or a +// provider produces. +var timeoutMessageRE = regexp.MustCompile(`(?i)timeout|timed out|deadline exceeded`) + +// StatusError is the error the OpenRouter path returns for a failed HTTP +// response: a message plus the status, headers and body the response carried. +// It exposes the status, name, body and cause through the small interfaces +// the adaptive router's classifiers probe. +type StatusError struct { + Message string + Name string + Status *float64 + Cause error + ResponseHeaders map[string]string + ResponseBody *string +} + +func (e *StatusError) Error() string { return e.Message } + +// ErrorName is the symbolic error name; it defaults to "Error". +func (e *StatusError) ErrorName() string { + if e.Name == "" { + return "Error" + } + return e.Name +} + +// ErrorStatusCode reports the HTTP status, when the error carries one. +func (e *StatusError) ErrorStatusCode() (float64, bool) { + if e.Status == nil { + return 0, false + } + return *e.Status, true +} + +// ErrorDetail is the response body, so classifiers can match provider +// messages that only appear there. +func (e *StatusError) ErrorDetail() string { + if e.ResponseBody == nil { + return "" + } + return *e.ResponseBody +} + +func (e *StatusError) Unwrap() error { return e.Cause } + +// NewProviderError builds the StatusError for a failed HTTP response. +func NewProviderError(message string, status float64, headers map[string]string, body *string) *StatusError { + return &StatusError{ + Message: message, Status: &status, + ResponseHeaders: headers, ResponseBody: body, + } +} + +// RetryError projects the failure into the classified Err shape: an APIError, +// or a ContextOverflowError when the message or body says the prompt did not +// fit. +func (e *StatusError) RetryError() Err { + if e.Status == nil { + // Without an HTTP status this is not a provider API error; keep the + // symbolic name (AbortError, for one) the step loop classifies by. + return Err{Name: e.ErrorName(), Data: ErrData{Message: &e.Message, ResponseBody: e.ResponseBody}} + } + status := *e.Status + retryable := status == 408 || status == 409 || status == 429 || status >= 500 + result := Err{Name: "APIError", Data: ErrData{ + Message: &e.Message, StatusCode: e.Status, IsRetryable: &retryable, + ResponseHeaders: e.ResponseHeaders, ResponseBody: e.ResponseBody, + }} + if IsContextOverflow(result) { + return Err{Name: "ContextOverflowError", Data: ErrData{ + Message: &e.Message, ResponseBody: e.ResponseBody, + }} + } + return result +} + +// FromError classifies a Go error into the Err shape. +func FromError(err error) Err { + if err == nil { + return Err{} + } + var classified interface{ RetryError() Err } + if errors.As(err, &classified) { + return classified.RetryError() + } + name := "UnknownError" + var named interface{ ErrorName() string } + if errors.As(err, &named) && named.ErrorName() != "" { + name = named.ErrorName() + } + message := err.Error() + return Err{Name: name, Data: ErrData{Message: &message}} +} + +// FromStreamError classifies an in-band error payload from a model stream. +func FromStreamError(raw json.RawMessage) Err { + parsed := msgmodel.FromError(raw) + var data ErrData + _ = json.Unmarshal(parsed.Data, &data) + return Err{Name: parsed.Name, Data: data} +} + +// HeaderPairs flattens response headers into a lowercase-keyed map. +func HeaderPairs(headers map[string][]string) map[string]string { + if headers == nil { + return nil + } + out := make(map[string]string, len(headers)) + for key, values := range headers { + out[strings.ToLower(key)] = strings.Join(values, ", ") + } + return out +} diff --git a/internal/seniordev/engine/retrysched/errors_test.go b/internal/seniordev/engine/retrysched/errors_test.go new file mode 100644 index 0000000000..ce7431c62d --- /dev/null +++ b/internal/seniordev/engine/retrysched/errors_test.go @@ -0,0 +1,60 @@ +//go:build !windows + +package retrysched + +import ( + "errors" + "fmt" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestStatusErrorClassifiesThroughTheRouter(t *testing.T) { + body := `{"error":{"message":"Provider returned error","error_type":"unmapped"}}` + err := NewProviderError("429 Too Many Requests", 429, HeaderPairs(map[string][]string{"Retry-After": {"7"}}), &body) + if !adaptive.IsLikelyRateLimit(err) { + t.Error("a 429 must classify as a rate limit") + } + if !adaptive.IsLikelyTransientProviderError(err) { + t.Error("the response body must be searched by the classifiers") + } + wrapped := fmt.Errorf("stream failed: %w", err) + if !adaptive.IsLikelyRateLimit(wrapped) { + t.Error("wrapping must not hide the status") + } + if err.ResponseHeaders["retry-after"] != "7" { + t.Errorf("headers = %v", err.ResponseHeaders) + } +} + +func TestFromErrorProjectsStatusAndOverflow(t *testing.T) { + classified := FromError(NewProviderError("Bad Gateway", 502, nil, nil)) + if !classified.IsAPIError() || classified.Data.StatusCode == nil || *classified.Data.StatusCode != 502 { + t.Fatalf("classified = %+v", classified) + } + if classified.Data.IsRetryable == nil || !*classified.Data.IsRetryable { + t.Fatalf("a 502 is retryable: %+v", classified) + } + overflow := FromError(NewProviderError("prompt is too long: 300000 tokens", 400, nil, nil)) + if overflow.Name != "ContextOverflowError" { + t.Fatalf("overflow = %+v", overflow) + } + plain := FromError(errors.New("dial tcp: connection refused")) + if plain.Name != "UnknownError" || plain.Data.Message == nil || *plain.Data.Message != "dial tcp: connection refused" { + t.Fatalf("plain = %+v", plain) + } + if zero := FromError(nil); zero.Name != "" || zero.Data.Message != nil { + t.Fatalf("nil classifies to the zero Err, got %+v", zero) + } +} + +func TestNamedErrorsKeepTheirName(t *testing.T) { + abort := &StatusError{Message: "aborted", Name: "AbortError"} + if !IsTimeoutError(FromError(abort)) { + t.Error("an AbortError is a timeout for the step loop") + } + if !adaptive.IsLikelyTimeout(abort) { + t.Error("an AbortError is a timeout for the router") + } +} diff --git a/internal/seniordev/engine/retrysched/retrysched.go b/internal/seniordev/engine/retrysched/retrysched.go new file mode 100644 index 0000000000..74ea4aa725 --- /dev/null +++ b/internal/seniordev/engine/retrysched/retrysched.go @@ -0,0 +1,41 @@ +//go:build !windows + +// Package retrysched classifies the errors a model call can end in: the +// parsed Err shape, the timeout and context-overflow predicates the step loop +// consults, and the StatusError a failed provider response becomes. senior-dev +// issues one request per model call; nothing here schedules or re-issues a +// request. +package retrysched + +// Err is a classified error: a name plus the payload fields the predicates +// probe. +type Err struct { + Name string `json:"name"` + Data ErrData `json:"data"` +} + +// ErrData models the error payload for the keys the predicates probe. Every +// field is a pointer because a provider error can omit any of them, including +// the message. +type ErrData struct { + Message *string `json:"message,omitempty"` + StatusCode *float64 `json:"statusCode,omitempty"` + IsRetryable *bool `json:"isRetryable,omitempty"` + ResponseHeaders map[string]string `json:"responseHeaders,omitempty"` + ResponseBody *string `json:"responseBody,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// IsAPIError reports whether the classified error is a provider API error. +func (e Err) IsAPIError() bool { return e.Name == "APIError" } + +// IsTimeoutError reports whether the classified error is timeout-shaped: an +// AbortError by name, or a timeout-shaped message. A missing message is not a +// match. +func IsTimeoutError(err Err) bool { + if err.Name == "AbortError" { + return true + } + msg := err.Data.Message + return msg != nil && timeoutMessageRE.MatchString(*msg) +} diff --git a/internal/seniordev/engine/retrysched/retrysched_test.go b/internal/seniordev/engine/retrysched/retrysched_test.go new file mode 100644 index 0000000000..48c100dfc1 --- /dev/null +++ b/internal/seniordev/engine/retrysched/retrysched_test.go @@ -0,0 +1,60 @@ +//go:build !windows + +package retrysched + +import "testing" + +// apiError builds the wire-shape APIError (name + data) a provider failure +// classifies to. +func apiError(message string) Err { + isRetryable := false + return Err{Name: "APIError", Data: ErrData{Message: &message, IsRetryable: &isRetryable}} +} + +func TestIsTimeoutError(t *testing.T) { + t.Run("matches AbortError by name", func(t *testing.T) { + if got := IsTimeoutError(Err{Name: "AbortError"}); got != true { + t.Errorf("IsTimeoutError = %v, want true", got) + } + }) + + t.Run("matches timeout-shaped messages", func(t *testing.T) { + for _, message := range []string{ + "openrouter first-content timeout after 45000ms", + "openrouter content-idle timeout: no data: chunks", + "request timed out", + "context deadline exceeded", + } { + if got := IsTimeoutError(apiError(message)); got != true { + t.Errorf("IsTimeoutError(%q) = %v, want true", message, got) + } + } + }) + + t.Run("does not match rate-limit or generic errors", func(t *testing.T) { + if got := IsTimeoutError(apiError("rate limit exceeded")); got != false { + t.Errorf("IsTimeoutError(rate limit) = %v, want false", got) + } + if got := IsTimeoutError(apiError("Internal Server Error")); got != false { + t.Errorf("IsTimeoutError(500) = %v, want false", got) + } + }) + + t.Run("an absent message is not a timeout", func(t *testing.T) { + retryable := true + if IsTimeoutError(Err{Name: "APIError", Data: ErrData{IsRetryable: &retryable}}) { + t.Error("IsTimeoutError with absent message = true, want false") + } + }) +} + +func TestFromStreamErrorReadsTheInBandPayload(t *testing.T) { + classified := FromStreamError([]byte( + `{"code":502,"message":"Network connection lost.","metadata":{"error_type":"provider_unavailable"}}`)) + if classified.Data.Message == nil || *classified.Data.Message == "" { + t.Fatalf("in-band error lost its message: %#v", classified) + } + if IsContextOverflow(classified) { + t.Fatalf("a 502 classified as a context overflow: %#v", classified) + } +} diff --git a/internal/seniordev/engine/steploop/compacted_after_test.go b/internal/seniordev/engine/steploop/compacted_after_test.go new file mode 100644 index 0000000000..7b9cb9c557 --- /dev/null +++ b/internal/seniordev/engine/steploop/compacted_after_test.go @@ -0,0 +1,49 @@ +//go:build !windows + +package steploop + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +func finishedAssistant(id string, summary bool, failed bool) msgmodel.WithParts { + finish := "stop" + info := msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses"}, + Finish: &finish, + } + if summary { + flag := true + info.Summary = &flag + } + if failed { + converted := msgmodel.NewUnknownError("boom") + info.Error = &converted + } + return msgmodel.WithParts{Info: info} +} + +// The verbatim tail keeps the assistant whose token count triggered the +// compaction, and the projection places it after the summary. Its count must +// not trigger a second boundary. +func TestCompactedAfterSeesANewerCompletedSummary(t *testing.T) { + tail := finishedAssistant("msg_0001", false, false) + summary := finishedAssistant("msg_0002", true, false) + failedSummary := finishedAssistant("msg_0003", true, true) + projection := []msgmodel.WithParts{summary, tail} + if !CompactedAfter(projection, tail.Info.(msgmodel.Assistant)) { + t.Fatal("a newer completed summary was not detected") + } + if CompactedAfter([]msgmodel.WithParts{tail}, tail.Info.(msgmodel.Assistant)) { + t.Fatal("no summary at all was reported as compacted-after") + } + if CompactedAfter([]msgmodel.WithParts{failedSummary, tail}, tail.Info.(msgmodel.Assistant)) { + t.Fatal("an errored summary attempt counted as a boundary") + } + older := finishedAssistant("msg_0000", true, false) + if CompactedAfter([]msgmodel.WithParts{older, tail}, tail.Info.(msgmodel.Assistant)) { + t.Fatal("an older summary counted as newer") + } +} diff --git a/internal/seniordev/engine/steploop/doc.go b/internal/seniordev/engine/steploop/doc.go new file mode 100644 index 0000000000..e933fa4da2 --- /dev/null +++ b/internal/seniordev/engine/steploop/doc.go @@ -0,0 +1,32 @@ +//go:build !windows + +// Package steploop drives one turn of the agent. Run loads the persisted +// transcript, issues one model request per step, settles the tool calls the +// model made, and repeats until the model stops calling tools, the step cap is +// reached, or the processor asks to stop. When the transcript records a +// pending compaction, the step is handed to the TaskController instead. +// +// Seams: +// +// - Store is the message persistence service. Messages must return fresh, +// chronological values; the loop rebuilds its view from them every step. +// - LLMClient/PartStream are the narrow model-client seam; scripted tests +// return an in-memory stream. +// - ToolExecutor executes one already-resolved tool call. Tool discovery, +// permission checks and the concrete tools stay outside this package and +// arrive in RunOptions.Tools. +// - ModelResolver names the model for the latest user message. +// - TaskController owns compaction: overflow detection, boundary creation, +// processing and pruning. +// - The clock and the ascending ID factories are package seams with +// SetNowForTesting and SetIDFactoryForTesting restore closures. +// +// Behaviour worth knowing: +// +// - The natural exit is: a non-empty finish other than "tool-calls", no +// non-provider-executed tool part on the matching persisted assistant, and +// the last user message older than the last assistant. +// - Tool cleanup snapshots all outstanding calls, waits for each with an +// independent short timeout, then force-writes every survivor as +// "Tool execution aborted". +package steploop diff --git a/internal/seniordev/engine/steploop/helpers.go b/internal/seniordev/engine/steploop/helpers.go new file mode 100644 index 0000000000..2831c4bac3 --- /dev/null +++ b/internal/seniordev/engine/steploop/helpers.go @@ -0,0 +1,164 @@ +//go:build !windows + +package steploop + +import ( + "strings" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// BackScanResult is what a newest-first scan of the transcript finds. +type BackScanResult struct { + LastUser *msgmodel.User + LastAssistant *msgmodel.Assistant + LastFinished *msgmodel.Assistant + Tasks []msgmodel.Part +} + +// BackScan scans chronological filtered messages from newest to oldest. +func BackScan(msgs []msgmodel.WithParts) BackScanResult { + var out BackScanResult + for i := len(msgs) - 1; i >= 0; i-- { + msg := msgs[i] + switch info := msg.Info.(type) { + case msgmodel.User: + if out.LastUser == nil { + copy := info + out.LastUser = © + } + case msgmodel.Assistant: + if out.LastAssistant == nil { + copy := info + out.LastAssistant = © + } + if out.LastFinished == nil && info.Finish != nil && *info.Finish != "" { + copy := info + out.LastFinished = © + } + } + if out.LastUser != nil && out.LastFinished != nil { + break + } + if out.LastFinished == nil { + for _, part := range msg.Parts { + switch part.(type) { + case msgmodel.CompactionPart: + out.Tasks = append(out.Tasks, part) + } + } + } + } + return out +} + +// CompactedAfter reports whether a completed compaction summary is newer (by +// ascending message ID) than the given assistant. The token count recorded on +// that assistant is then stale: the boundary has already replaced the context +// it measured. Re-checking it would create a boundary loop, because the +// verbatim tail keeps that very message -- and its count -- after every +// compaction, and the projection places the tail after the summary, where a +// newest-first scan finds it first. +func CompactedAfter(msgs []msgmodel.WithParts, assistant msgmodel.Assistant) bool { + for _, msg := range msgs { + candidate, ok := msg.Info.(msgmodel.Assistant) + if !ok || !boolValue(candidate.Summary) || candidate.Error != nil || + candidate.Finish == nil || *candidate.Finish == "" { + continue + } + if idLess(assistant.ID, candidate.ID) { + return true + } + } + return false +} + +// ShouldExit reports the natural exit: the last assistant finished for a +// reason other than tool calls, none of its persisted tool parts is pending +// on this side (provider-executed tool parts do not count), and the last user +// message is older than it. +func ShouldExit(lastUser *msgmodel.User, lastAssistant *msgmodel.Assistant, msgs []msgmodel.WithParts) bool { + if lastUser == nil || lastAssistant == nil || lastAssistant.Finish == nil || *lastAssistant.Finish == "" { + return false + } + if *lastAssistant.Finish == orFinishToolCalls { + return false + } + + var persisted *msgmodel.WithParts + for i := len(msgs) - 1; i >= 0; i-- { + assistant, ok := msgs[i].Info.(msgmodel.Assistant) + if ok && assistant.ID == lastAssistant.ID { + persisted = &msgs[i] + break + } + } + if persisted != nil { + for _, raw := range persisted.Parts { + part, ok := raw.(msgmodel.ToolPart) + if ok && !part.ProviderExecuted() { + return false + } + } + } + return idLess(lastUser.ID, lastAssistant.ID) +} + +const orFinishToolCalls = "tool-calls" + +// idLess orders two message IDs lexicographically by UTF-16 code unit. IDs +// are ASCII in practice, so this is plain lexicographic order; the UTF-16 +// form keeps the comparison well-defined for any string. +func idLess(left, right string) bool { + a := utf16.Encode([]rune(left)) + b := utf16.Encode([]rune(right)) + n := len(a) + if len(b) < n { + n = len(b) + } + for i := 0; i < n; i++ { + if a[i] != b[i] { + return a[i] < b[i] + } + } + return len(a) < len(b) +} + +// WrapLateUserText wraps the text of any user message that arrived after the +// last finished assistant in a system reminder, so the model treats it as an +// interjection. The input must be a fresh store load because this mutates +// text-part values in place. +func WrapLateUserText(msgs []msgmodel.WithParts, lastFinished msgmodel.Assistant) { + for mi := range msgs { + user, ok := msgs[mi].Info.(msgmodel.User) + if !ok || !idLess(lastFinished.ID, user.ID) { + continue + } + for partIdx, raw := range msgs[mi].Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolValue(part.Ignored) || boolValue(part.Synthetic) || strings.TrimSpace(part.Text) == "" { + continue + } + part.Text = strings.Join([]string{ + "<system-reminder>", + "The user sent the following message:", + part.Text, + "", + "Please address this message and continue with your tasks.", + "</system-reminder>", + }, "\n") + msgs[mi].Parts[partIdx] = part + } + } +} + +func boolValue(v *bool) bool { return v != nil && *v } + +func newestFirst(msgs []msgmodel.WithParts) []msgmodel.WithParts { + out := append([]msgmodel.WithParts(nil), msgs...) + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} diff --git a/internal/seniordev/engine/steploop/helpers_test.go b/internal/seniordev/engine/steploop/helpers_test.go new file mode 100644 index 0000000000..c94ee20640 --- /dev/null +++ b/internal/seniordev/engine/steploop/helpers_test.go @@ -0,0 +1,20 @@ +//go:build !windows + +package steploop + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// Shared helpers for the tests in this package. + +func jsonString(t *testing.T, value any) string { + t.Helper() + raw, err := jsonutil.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} diff --git a/internal/seniordev/engine/steploop/invalid_tool_test.go b/internal/seniordev/engine/steploop/invalid_tool_test.go new file mode 100644 index 0000000000..e698ed8874 --- /dev/null +++ b/internal/seniordev/engine/steploop/invalid_tool_test.go @@ -0,0 +1,118 @@ +//go:build !windows + +package steploop + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" +) + +type releasedTool struct { + started chan<- struct{} + release <-chan struct{} +} + +func (tool releasedTool) Execute(_ context.Context, _ ToolCall) (ToolResult, error) { + close(tool.started) + <-tool.release + return ToolResult{Title: "done", Output: "answered", Metadata: msgmodel.RawObject("{}")}, nil +} + +func TestWaitForResultDefinitionKeepsProcessorStreamOpen(t *testing.T) { + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "coder", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + started := make(chan struct{}) + release := make(chan struct{}) + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, + Tools: []ToolDefinition{{ + Provider: orclient.Tool{Type: "function", Name: "question"}, + WaitForResult: true, + }}, + Executor: releasedTool{started: started, release: release}, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "call_1", ToolName: "question"}, + orclient.ToolCallPart{ToolCallID: "call_1", ToolName: "question", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + done := make(chan error, 1) + go func() { + _, err := processor.Process(context.Background(), stream) + done <- err + }() + <-started + select { + case err := <-done: + t.Fatalf("processor finished while result-blocking tool was pending: %v", err) + case <-time.After(20 * time.Millisecond): + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + parts := store.rawSnapshot()[0].Parts + toolPart := parts[1].(msgmodel.ToolPart) + if toolPart.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("tool state = %s", jsonString(t, toolPart.State)) + } +} + +func TestUnknownToolCompletesAsInvalidAndContinues(t *testing.T) { + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "coder", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + processor := NewProcessor(ProcessorOptions{ + Store: store, + Assistant: assistant, + Tools: []ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "read", + }}}, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "call_1", ToolName: "missing"}, + orclient.ToolCallPart{ToolCallID: "call_1", ToolName: "missing", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + + result, err := processor.Process(context.Background(), stream) + if err != nil { + t.Fatal(err) + } + if result != ResultContinue { + t.Fatalf("Process result = %q, want %q", result, ResultContinue) + } + + parts := store.rawSnapshot()[0].Parts + assertPartTypes(t, parts, "step-start,tool,step-finish") + toolPart := parts[1].(msgmodel.ToolPart) + if toolPart.Tool != orclient.InvalidToolName || toolPart.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("unknown tool state = %#v", toolPart) + } + state := jsonString(t, toolPart.State) + wantOutput := "The arguments provided to the tool are invalid: " + + "Model tried to call unavailable tool 'missing'. Available tools: invalid, read." + for _, fragment := range []string{ + `"title":"Invalid Tool"`, + `"metadata":{}`, + `"output":` + jsonString(t, wantOutput), + } { + if !strings.Contains(state, fragment) { + t.Fatalf("invalid state %s missing %s", state, fragment) + } + } +} diff --git a/internal/seniordev/engine/steploop/loop.go b/internal/seniordev/engine/steploop/loop.go new file mode 100644 index 0000000000..7e8c5a3dae --- /dev/null +++ b/internal/seniordev/engine/steploop/loop.go @@ -0,0 +1,232 @@ +//go:build !windows + +package steploop + +import ( + "context" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// Loop wires the outer state machine's service seams. +type Loop struct { + Store Store + Client LLMClient + Models ModelResolver + Executor ToolExecutor + Tasks TaskController + + // ProcessorWaitTimeout bounds the tool drain after an aborted stream. + // Zero means 250ms. + ProcessorWaitTimeout time.Duration +} + +// Run drives the step loop until its natural exit or a processor stop. +func (l *Loop) Run(ctx context.Context, opts RunOptions) (msgmodel.Assistant, error) { + if l == nil || l.Store == nil { + return msgmodel.Assistant{}, errors.New("steploop: Store is required") + } + if l.Client == nil { + return msgmodel.Assistant{}, errors.New("steploop: Client is required") + } + if l.Models == nil { + return msgmodel.Assistant{}, errors.New("steploop: Models is required") + } + models := l.Models + + step := 0 +loop: + for { + chronological, err := l.Store.Messages(ctx, opts.SessionID) + if err != nil { + return msgmodel.Assistant{}, err + } + msgs := msgmodel.FilterCompacted(newestFirst(chronological)) + scan := BackScan(msgs) + if scan.LastUser == nil { + return msgmodel.Assistant{}, errors.New("No user message found in stream. This should never happen.") + } + + if ShouldExit(scan.LastUser, scan.LastAssistant, msgs) { + break + } + + step++ + model, err := models.Resolve(ctx, *scan.LastUser) + if err != nil { + return msgmodel.Assistant{}, err + } + taskInput := TaskInput{SessionID: opts.SessionID, Messages: msgs, User: *scan.LastUser, Model: model} + if len(scan.Tasks) > 0 { + task := scan.Tasks[len(scan.Tasks)-1] // tasks.pop() + switch value := task.(type) { + case msgmodel.CompactionPart: + if l.Tasks == nil { + return msgmodel.Assistant{}, errors.New("steploop: TaskController is required for compaction") + } + result, err := l.Tasks.ProcessCompaction(ctx, taskInput, value) + if err != nil { + return msgmodel.Assistant{}, err + } + if result == ResultStop { + break loop + } + continue + } + } + if scan.LastFinished != nil && !boolValue(scan.LastFinished.Summary) && + !CompactedAfter(msgs, *scan.LastFinished) && l.Tasks != nil { + overflow, err := l.Tasks.IsOverflow(ctx, *scan.LastFinished, model) + if err != nil { + return msgmodel.Assistant{}, err + } + if overflow { + if err := l.Tasks.CreateCompaction(ctx, opts.SessionID, *scan.LastUser, false); err != nil { + return msgmodel.Assistant{}, err + } + continue + } + } + if opts.InjectReminders != nil { + msgs, err = opts.InjectReminders(ctx, msgs, *scan.LastUser) + if err != nil { + return msgmodel.Assistant{}, err + } + } + if step > 1 && scan.LastFinished != nil { + WrapLateUserText(msgs, *scan.LastFinished) + } + + assistant := newAssistant(opts, *scan.LastUser, model) + if err := l.Store.UpdateMessage(ctx, assistant); err != nil { + return msgmodel.Assistant{}, err + } + + prompt, err := msgmodel.ToModelMessages(msgs, model.Message, nil) + if err != nil { + return msgmodel.Assistant{}, err + } + if float64(step) >= opts.maxSteps() { + prompt = append(prompt, msgmodel.ModelMessage{Role: "assistant", Content: MaxStepsPrompt}) + } + + params := model.Request + params.ModelID = model.Message.ID + params.Prompt = prompt + if params.MaxOutputTokens == nil { + maxOutput := calc.MaxOutputTokens(model.Calc) + params.MaxOutputTokens = &maxOutput + } + params.Tools = nil + for _, tool := range opts.Tools { + params.Tools = append(params.Tools, tool.Provider) + } + + processor := NewProcessor(ProcessorOptions{ + Store: l.Store, + Assistant: assistant, + Model: model, + Tools: opts.Tools, + Executor: l.Executor, + WaitTimeout: l.ProcessorWaitTimeout, + }) + outcome, processErr := func() (Result, error) { + if opts.AfterAssistant != nil { + defer opts.AfterAssistant(ctx, assistant.ID) + } + stream, streamErr := l.Client.Stream(ctx, params) + if streamErr != nil { + stream = &SliceStream{Failure: streamErr} + } + return processor.Process(ctx, stream) + }() + if opts.AfterTurn != nil { + parts, partsErr := assistantParts(ctx, l.Store, opts.SessionID, assistant.ID) + if partsErr != nil { + return msgmodel.Assistant{}, partsErr + } + if hookErr := opts.AfterTurn(ctx, processor.Message(), parts); hookErr != nil { + return msgmodel.Assistant{}, hookErr + } + } + if processErr != nil { + return msgmodel.Assistant{}, processErr + } + if outcome == ResultStop { + break + } + if outcome == ResultContinue && l.Tasks != nil { + current := processor.Message() + if current.Finish != nil && !boolValue(current.Summary) { + overflow, overflowErr := l.Tasks.IsOverflow(ctx, current, model) + if overflowErr != nil { + return msgmodel.Assistant{}, overflowErr + } + if overflow { + outcome = ResultCompact + } + } + } + if outcome == ResultCompact && l.Tasks != nil { + current := processor.Message() + if err := l.Tasks.CreateCompaction(ctx, opts.SessionID, *scan.LastUser, current.Finish == nil); err != nil { + return msgmodel.Assistant{}, err + } + } + } + + if l.Tasks != nil { + // Pruning runs in the background; its result is not awaited. + go func(tasks TaskController) { + _ = tasks.Prune(ctx, opts.SessionID) + }(l.Tasks) + } + chronological, err := l.Store.Messages(ctx, opts.SessionID) + if err != nil { + return msgmodel.Assistant{}, err + } + for i := len(chronological) - 1; i >= 0; i-- { + if assistant, ok := chronological[i].Info.(msgmodel.Assistant); ok { + return assistant, nil + } + } + return msgmodel.Assistant{}, errors.New("Impossible") +} + +func assistantParts( + ctx context.Context, store Store, sessionID, messageID string, +) (msgmodel.Parts, error) { + messages, err := store.Messages(ctx, sessionID) + if err != nil { + return nil, err + } + for index := len(messages) - 1; index >= 0; index-- { + assistant, ok := messages[index].Info.(msgmodel.Assistant) + if ok && assistant.ID == messageID { + return messages[index].Parts, nil + } + } + return nil, nil +} + +func newAssistant(opts RunOptions, user msgmodel.User, model Model) msgmodel.Assistant { + return msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: nextID("msg"), SessionID: opts.SessionID}, + Time: msgmodel.AssistantTime{Created: currentNow()}, + ParentID: user.ID, + ModelID: model.Message.ID, + ProviderID: model.Message.ProviderID, + Mode: user.Agent, + Agent: user.Agent, + Path: msgmodel.AssistantPath{Cwd: opts.Workspace, Root: opts.Worktree}, + Cost: float64(0), + Tokens: msgmodel.Tokens{ + Input: 0, Output: 0, Reasoning: 0, + Cache: msgmodel.TokenCache{Read: 0, Write: 0}, + }, + Variant: user.Model.Variant, + } +} diff --git a/internal/seniordev/engine/steploop/processor.go b/internal/seniordev/engine/steploop/processor.go new file mode 100644 index 0000000000..f158fd2416 --- /dev/null +++ b/internal/seniordev/engine/steploop/processor.go @@ -0,0 +1,804 @@ +//go:build !windows + +package steploop + +import ( + "context" + "encoding/json" + "errors" + "io" + "math" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/retrysched" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// ProcessorOptions configure the processor for one assistant turn. +type ProcessorOptions struct { + Store Store + Assistant msgmodel.Assistant + Model Model + Tools []ToolDefinition + Executor ToolExecutor + WaitTimeout time.Duration +} + +type toolCallState struct { + part msgmodel.ToolPart + done chan struct{} + once sync.Once + // dispatched marks that an executor was actually launched for this call. + // A registration whose tool-call part never arrived (length-truncated or + // errored stream) has no executor and can never settle itself, so cleanup + // must keep the bounded drain for those. + dispatched bool +} + +// Processor persists one assistant turn and owns its in-flight tool registry. +type Processor struct { + store Store + message msgmodel.Assistant + model Model + tools []ToolDefinition + executor ToolExecutor + wait time.Duration + sessionID string + + mu sync.Mutex + toolCalls map[string]*toolCallState + toolOrder []string + current *msgmodel.TextPart + reasoning map[string]msgmodel.ReasoningPart + reasoningOrder []string + streamAborted bool +} + +func NewProcessor(opts ProcessorOptions) *Processor { + wait := opts.WaitTimeout + if wait <= 0 { + wait = 250 * time.Millisecond + } + return &Processor{ + store: opts.Store, + message: opts.Assistant, + model: opts.Model, + tools: append([]ToolDefinition(nil), opts.Tools...), + executor: opts.Executor, + wait: wait, + sessionID: opts.Assistant.SessionID, + toolCalls: map[string]*toolCallState{}, + reasoning: map[string]msgmodel.ReasoningPart{}, + } +} + +// Message returns a copy of the current assistant message. +func (p *Processor) Message() msgmodel.Assistant { + p.mu.Lock() + defer p.mu.Unlock() + return p.message +} + +// UpdateToolCall rewrites and persists one in-flight tool part. The callback +// runs while the processor lock is held. +func (p *Processor) UpdateToolCall(ctx context.Context, toolCallID string, update func(msgmodel.ToolPart) msgmodel.ToolPart) (*msgmodel.ToolPart, error) { + p.mu.Lock() + defer p.mu.Unlock() + call := p.toolCalls[toolCallID] + if call == nil { + return nil, nil + } + part := update(call.part) + if err := p.store.UpdatePart(ctx, part); err != nil { + return nil, err + } + call.part = part + copy := part + return ©, nil +} + +// CompleteToolCall persists a tool result and settles the call. A +// non-running or missing call is a no-op and is not settled. +func (p *Processor) CompleteToolCall(ctx context.Context, toolCallID string, output ToolResult) error { + p.mu.Lock() + defer p.mu.Unlock() + call := p.toolCalls[toolCallID] + if call == nil || call.part.State == nil || call.part.State.ToolStatus() != msgmodel.ToolStatusRunning { + return nil + } + // Settle on every exit past the guard: execute() discards these errors, + // and an unsettled dispatched call would hang cleanup's unbounded wait. + defer p.settleLocked(toolCallID) + end := currentNow() + start, _ := call.part.State.StartTime() + metadata := output.Metadata + if !msgmodel.IsRecord(metadata) { + metadata = msgmodel.RawObject("{}") + } + state, err := completedState(call.part.State.ToolInput(), output, metadata, start, end) + if err != nil { + return err + } + call.part.State = state + return p.store.UpdatePart(ctx, call.part) +} + +// FailToolCall persists a tool failure and settles the call. A non-running or +// missing call is a no-op. +func (p *Processor) FailToolCall(ctx context.Context, toolCallID string, failure error) (bool, error) { + p.mu.Lock() + defer p.mu.Unlock() + call := p.toolCalls[toolCallID] + if call == nil || call.part.State == nil || call.part.State.ToolStatus() != msgmodel.ToolStatusRunning { + return false, nil + } + // Settle on every exit past the guard — see CompleteToolCall. + defer p.settleLocked(toolCallID) + message := "null" + if failure != nil { + message = failure.Error() + } + end := currentNow() + start, _ := call.part.State.StartTime() + state, err := errorState(call.part.State.ToolInput(), message, start, end) + if err != nil { + return false, err + } + call.part.State = state + if err := p.store.UpdatePart(ctx, call.part); err != nil { + return false, err + } + return true, nil +} + +func (p *Processor) settleLocked(toolCallID string) { + call := p.toolCalls[toolCallID] + delete(p.toolCalls, toolCallID) // unregister before waking the waiters. + if call != nil { + call.once.Do(func() { close(call.done) }) + } +} + +// Process drains one provider stream, synthesizing start-step/finish-step and +// tool-result/error events around orclient's lower-level parts. +func (p *Processor) Process(ctx context.Context, stream PartStream) (result Result, err error) { + result = ResultContinue + p.mu.Lock() + p.streamAborted = true + p.mu.Unlock() + if stream == nil { + return ResultStop, errors.New("steploop: nil stream") + } + defer stream.Close() + defer func() { + cleanupErr := p.cleanup(ctx) + if err == nil && cleanupErr != nil { + err = cleanupErr + } + }() + + if err = p.persistPart(ctx, msgmodel.StepStartPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + }); err != nil { + return ResultStop, err + } + + for { + var part orclient.StreamPart + part, err = stream.Next() + if err == io.EOF { + p.mu.Lock() + p.streamAborted = false + p.mu.Unlock() + return result, nil + } + if err != nil { + classified := retrysched.FromError(err) + if retrysched.IsContextOverflow(classified) { + return ResultCompact, nil + } + p.setAssistantError(classified, err.Error()) + return ResultStop, nil + } + switch value := part.(type) { + case orclient.ReasoningStartPart: + err = p.reasoningStart(ctx, value) + case orclient.ReasoningDeltaPart: + err = p.reasoningDelta(ctx, value) + case orclient.ReasoningEndPart: + err = p.reasoningEnd(ctx, value) + case orclient.TextStartPart: + err = p.textStart(ctx) + case orclient.TextDeltaPart: + err = p.textDelta(ctx, value) + case orclient.TextEndPart: + err = p.textEnd(ctx) + case orclient.ToolInputStartPart: + err = p.toolInputStart(ctx, value) + case orclient.ToolCallPart: + err = p.toolCall(ctx, value) + case orclient.ErrorPart: + classified := retrysched.FromStreamError(value.Error) + if retrysched.IsContextOverflow(classified) { + return ResultCompact, nil + } + p.setAssistantError(classified, errorPartMessage(value.Error)) + return ResultStop, nil + case orclient.AbortPart: + message := "Aborted" + if value.HasReason && value.Reason != "" { + message = value.Reason + } + p.mu.Lock() + abort := msgmodel.NewMessageAbortedError(message) + p.message.Error = &abort + p.mu.Unlock() + return ResultStop, nil + case orclient.FinishPart: + err = p.finish(ctx, value) + } + if err != nil { + return ResultStop, err + } + } +} + +func (p *Processor) persistPart(ctx context.Context, part msgmodel.Part) error { + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) reasoningStart(ctx context.Context, value orclient.ReasoningStartPart) error { + p.mu.Lock() + defer p.mu.Unlock() + if _, exists := p.reasoning[value.ID]; exists { + return nil + } + part := msgmodel.ReasoningPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + Text: "", + Time: msgmodel.TimeStartEnd{Start: currentNow()}, + } + p.reasoning[value.ID] = part + p.reasoningOrder = append(p.reasoningOrder, value.ID) + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) reasoningDelta(ctx context.Context, value orclient.ReasoningDeltaPart) error { + p.mu.Lock() + defer p.mu.Unlock() + part, exists := p.reasoning[value.ID] + if !exists { + return nil + } + part.Text += value.Delta + p.reasoning[value.ID] = part + p.publishPartDelta(ctx, part.PartBase, value.Delta) + return nil +} + +func (p *Processor) reasoningEnd(ctx context.Context, value orclient.ReasoningEndPart) error { + p.mu.Lock() + defer p.mu.Unlock() + part, exists := p.reasoning[value.ID] + if !exists { + return nil + } + end := currentNow() + part.Time.End = &end + part.Metadata = reasoningMetadata(value.Details) + delete(p.reasoning, value.ID) + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) textStart(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + part := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + Text: "", + Time: &msgmodel.TimeStartEnd{Start: currentNow()}, + } + p.current = &part + return p.store.UpdatePart(ctx, part) +} + +func (p *Processor) textDelta(ctx context.Context, value orclient.TextDeltaPart) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.current == nil { + return nil + } + p.current.Text += value.Delta + p.publishPartDelta(ctx, p.current.PartBase, value.Delta) + return nil +} + +func (p *Processor) publishPartDelta(ctx context.Context, base msgmodel.PartBase, delta string) { + store, ok := p.store.(interface { + UpdatePartDelta(context.Context, msgmodel.PartDeltaEvent) + }) + if !ok { + return + } + store.UpdatePartDelta(ctx, msgmodel.PartDeltaEvent{ + SessionID: base.SessionID, + MessageID: base.MessageID, + PartID: base.ID, + Field: "text", + Delta: delta, + }) +} + +func (p *Processor) textEnd(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.current == nil { + return nil + } + end := currentNow() + if p.current.Time == nil { + p.current.Time = &msgmodel.TimeStartEnd{Start: end} + } + p.current.Time.End = &end + err := p.store.UpdatePart(ctx, *p.current) + p.current = nil + return err +} + +func (p *Processor) toolInputStart(ctx context.Context, value orclient.ToolInputStartPart) error { + p.mu.Lock() + defer p.mu.Unlock() + partID := nextID("prt") + if previous := p.toolCalls[value.ID]; previous != nil { + partID = previous.part.ID + } + part := msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: partID, SessionID: p.sessionID, MessageID: p.message.ID}, + CallID: value.ID, + Tool: value.ToolName, + State: msgmodel.PendingToolState(), + } + if err := p.store.UpdatePart(ctx, part); err != nil { + return err + } + if _, exists := p.toolCalls[value.ID]; !exists { + p.toolOrder = append(p.toolOrder, value.ID) + } + p.toolCalls[value.ID] = &toolCallState{part: part, done: make(chan struct{})} + return nil +} + +func (p *Processor) toolCall(ctx context.Context, value orclient.ToolCallPart) error { + specs := make([]orclient.ToolSpec, 0, len(p.tools)+1) + for _, tool := range p.tools { + specs = append(specs, orclient.ToolSpec{Name: tool.Provider.Name, Validate: tool.Validate}) + } + specs = append(specs, orclient.ToolSpec{Name: orclient.InvalidToolName}) + // The full map, invalid included, is sorted before the call is parsed; + // the provider never sees invalid, but it stays the fallback target for a + // call that names an unknown tool. + parsed := orclient.ParseToolCall(orclient.RawToolCall{ + ToolCallID: value.ToolCallID, + ToolName: value.ToolName, + Input: value.Input, + }, orclient.SortedToolMap(specs...), orclient.SeniorDevRepairToolCall) + + _, err := p.UpdateToolCall(ctx, value.ToolCallID, func(part msgmodel.ToolPart) msgmodel.ToolPart { + start := currentNow() + state, stateErr := runningState(part.State, msgmodel.RawObject(parsed.Input), start) + if stateErr == nil { + part.State = state + } + part.Tool = parsed.ToolName + if value.HasProviderMetadata { + part.Metadata = reasoningMetadata(value.Details) + } + return part + }) + if err != nil { + return err + } + if parsed.Invalid { + _, err = p.FailToolCall(ctx, value.ToolCallID, parsed.Error) + return err + } + if parsed.ToolName == orclient.InvalidToolName { + return p.CompleteToolCall(ctx, value.ToolCallID, ToolResult{ + Title: "Invalid Tool", + Output: "The arguments provided to the tool are invalid: " + + invalidToolMessage(parsed.Input), + Metadata: msgmodel.RawObject("{}"), + }) + } + if p.executor == nil { + // Without an executor the call stays incomplete; cleanup settles it + // as aborted. + return nil + } + modelID := p.model.Message.API.ID + if modelID == "" { + modelID = p.model.Message.ID + } + call := ToolCall{ + ID: parsed.ToolCallID, + Name: parsed.ToolName, + Input: append(json.RawMessage(nil), parsed.Input...), + SessionID: p.sessionID, + MessageID: p.message.ID, + Agent: p.message.Agent, + ModelID: modelID, + } + execute := func() { + messages, messagesErr := p.store.Messages(ctx, p.sessionID) + if messagesErr != nil { + _, _ = p.FailToolCall(ctx, call.ID, messagesErr) + return + } + executeCtx := WithToolMessages(ctx, msgmodel.FilterCompacted(newestFirst(messages))) + output, executeErr := p.executor.Execute(executeCtx, call) + if executeErr != nil { + _, _ = p.FailToolCall(ctx, call.ID, executeErr) + return + } + _ = p.CompleteToolCall(ctx, call.ID, output) + } + p.mu.Lock() + if state := p.toolCalls[call.ID]; state != nil { + state.dispatched = true + } + p.mu.Unlock() + for _, definition := range p.tools { + if definition.Provider.Name == call.Name && definition.WaitForResult { + execute() + return nil + } + } + go execute() + return nil +} + +func (p *Processor) finish(ctx context.Context, value orclient.FinishPart) error { + usage := calc.GetUsage(calc.GetUsageInput{ + Model: p.model.Calc, + Usage: calc.AsLanguageModelUsage(value.Usage), + }) + // THE SERVICE'S OWN PRICE WINS OVER THE CATALOG'S. A step's cost is the + // catalog's rates times its tokens only when the service put no price on + // it; when it did, that is what was charged. The catalog's reading ran + // 1.2 to 10 times under codeaf's meter on the runs of 2026-09-23, and the + // run's agent-summary, which adds these up, told a reader of + // delegate-stderr.log that a $2.30 run had cost $0.57. It is an account + // and nothing more: the run's budget already reads the service's figure. + cost := usage.Cost + if reported, ok := reportedCost(value); ok { + cost = reported + } + finish := value.FinishReason.Unified + tokens := messageTokens(usage.Tokens) + part := msgmodel.StepFinishPart{ + PartBase: msgmodel.PartBase{ID: nextID("prt"), SessionID: p.sessionID, MessageID: p.message.ID}, + Reason: finish, + Cost: float64(cost), + Tokens: tokens, + } + if value.Metadata.Provider != nil { + part.Upstream = *value.Metadata.Provider + } + if err := p.store.UpdatePart(ctx, part); err != nil { + return err + } + p.mu.Lock() + p.message.Finish = &finish + p.message.Cost = float64(float64(p.message.Cost) + cost) + p.message.Tokens = tokens + p.message.Upstream = part.Upstream + message := p.message + p.mu.Unlock() + return p.store.UpdateMessage(ctx, message) +} + +// reportedCost is the price the model's service put on one step, read off the +// finish part's usage block, and whether it named one. +func reportedCost(value orclient.FinishPart) (float64, bool) { + raw, ok := value.Metadata.Usage.Get("cost") + if !ok { + return 0, false + } + var cost float64 + if json.Unmarshal(raw, &cost) != nil { + return 0, false + } + return cost, true +} + +func (p *Processor) setAssistantError(classified retrysched.Err, fallback string) { + message := fallback + if classified.Data.Message != nil && *classified.Data.Message != "" { + message = *classified.Data.Message + } + value := msgmodel.NewUnknownError(message) + if classified.Name == msgmodel.ErrNameAPI { + api := msgmodel.APIError{ + Message: message, ResponseBody: classified.Data.ResponseBody, + } + if classified.Data.IsRetryable != nil { + api.IsRetryable = *classified.Data.IsRetryable + } + if code := classified.Data.StatusCode; code != nil && + *code >= 100 && *code <= 999 && math.Trunc(*code) == *code { + status := uint64(*code) + api.StatusCode = &status + } + value = msgmodel.NewAPIError(api) + } + p.mu.Lock() + p.message.Error = &value + p.mu.Unlock() +} + +func (p *Processor) cleanup(ctx context.Context) error { + p.mu.Lock() + streamAborted := p.streamAborted + if p.current != nil { + end := currentNow() + if p.current.Time == nil { + p.current.Time = &msgmodel.TimeStartEnd{Start: end} + } + p.current.Time.End = &end + if err := p.store.UpdatePart(ctx, *p.current); err != nil { + p.mu.Unlock() + return err + } + p.current = nil + } + for _, id := range p.reasoningOrder { + part, exists := p.reasoning[id] + if !exists { + continue + } + end := currentNow() + part.Time.End = &end + if err := p.store.UpdatePart(ctx, part); err != nil { + p.mu.Unlock() + return err + } + delete(p.reasoning, id) + } + p.reasoningOrder = nil + type pendingSettle struct { + done <-chan struct{} + dispatched bool + } + waiting := make([]pendingSettle, 0, len(p.toolCalls)) + for _, id := range p.toolOrder { + if call := p.toolCalls[id]; call != nil { + waiting = append(waiting, pendingSettle{done: call.done, dispatched: call.dispatched}) + } + } + p.mu.Unlock() + + // A dispatched call on a normally drained turn is waited for without a + // bound; the grace window applies only once ctx is cancelled. After an + // abnormal stream end every call uses the bounded drain. A registration + // that never got its tool-call part has no executor to settle it and + // always uses the bounded drain. + var group sync.WaitGroup + for _, wait := range waiting { + wait := wait + group.Add(1) + go func() { + defer group.Done() + if wait.dispatched && !streamAborted { + select { + case <-wait.done: + return + case <-ctx.Done(): + } + } + timer := time.NewTimer(p.wait) + defer timer.Stop() + select { + case <-wait.done: + case <-timer.C: + } + }() + } + group.Wait() + + p.mu.Lock() + for _, id := range p.toolOrder { + call := p.toolCalls[id] + if call == nil { + continue + } + end := currentNow() + raw, spreadErr := msgmodel.SpreadAbortedToolState(call.part.State, end) + if spreadErr != nil { + p.mu.Unlock() + return spreadErr + } + call.part.State = newRawToolState(raw) + if updateErr := p.store.UpdatePart(ctx, call.part); updateErr != nil { + p.mu.Unlock() + return updateErr + } + } + p.toolCalls = map[string]*toolCallState{} + p.toolOrder = nil + completed := currentNow() + p.message.Time.Completed = &completed + message := p.message + p.mu.Unlock() + return p.store.UpdateMessage(ctx, message) +} + +func messageTokens(tokens calc.UsageTokens) msgmodel.Tokens { + var total *uint64 + if tokens.Total != nil { + value := safeUint(*tokens.Total) + total = &value + } + return msgmodel.Tokens{ + Total: total, + Input: safeUint(tokens.Input), + Output: safeUint(tokens.Output), + Reasoning: safeUint(tokens.Reasoning), + Cache: msgmodel.TokenCache{ + Read: safeUint(tokens.Cache.Read), + Write: safeUint(tokens.Cache.Write), + }, + } +} + +func safeUint(value float64) uint64 { + if math.IsNaN(value) || value <= 0 { + return 0 + } + if math.IsInf(value, 1) || value >= math.MaxUint64 { + return math.MaxUint64 + } + return uint64(value) +} + +// rawToolState keeps a tool state as the JSON object it was assembled from, +// so fields carried over from the previous state survive a status transition +// verbatim, which the typed msgmodel variants cannot represent. +type rawToolState struct { + raw json.RawMessage + status string + input msgmodel.RawObject + metadata msgmodel.RawObject + start uint64 + hasStart bool +} + +func newRawToolState(raw json.RawMessage) rawToolState { + state := rawToolState{raw: append(json.RawMessage(nil), raw...)} + var probe struct { + Status string `json:"status"` + Input msgmodel.RawObject `json:"input"` + Metadata msgmodel.RawObject `json:"metadata"` + Time *struct { + Start uint64 `json:"start"` + } `json:"time"` + } + _ = json.Unmarshal(raw, &probe) + state.status = probe.Status + state.input = probe.Input + state.metadata = probe.Metadata + if probe.Time != nil { + state.start = probe.Time.Start + state.hasStart = true + } + return state +} + +func (s rawToolState) ToolStatus() string { return s.status } +func (s rawToolState) ToolInput() msgmodel.RawObject { return s.input } +func (s rawToolState) ToolMetadata() msgmodel.RawObject { return s.metadata } +func (s rawToolState) StartTime() (uint64, bool) { return s.start, s.hasStart } +func (s rawToolState) MarshalJSON() ([]byte, error) { return append([]byte(nil), s.raw...), nil } + +func runningState(previous msgmodel.ToolState, input msgmodel.RawObject, start uint64) (rawToolState, error) { + status, _ := jsonutil.Marshal(msgmodel.ToolStatusRunning) + timeRaw, err := jsonutil.Marshal(msgmodel.ToolTimeStart{Start: start}) + if err != nil { + return rawToolState{}, err + } + raw, err := msgmodel.SpreadToolState(previous, + msgmodel.RawField{Key: "status", Value: status}, + msgmodel.RawField{Key: "input", Value: json.RawMessage(input)}, + msgmodel.RawField{Key: "time", Value: timeRaw}, + ) + if err != nil { + return rawToolState{}, err + } + return newRawToolState(raw), nil +} + +func completedState(input msgmodel.RawObject, output ToolResult, metadata msgmodel.RawObject, start, end uint64) (rawToolState, error) { + status, _ := jsonutil.Marshal(msgmodel.ToolStatusCompleted) + outputRaw, _ := jsonutil.Marshal(output.Output) + titleRaw, _ := jsonutil.Marshal(output.Title) + timeRaw, err := jsonutil.Marshal(msgmodel.ToolTimeCompleted{Start: start, End: end}) + if err != nil { + return rawToolState{}, err + } + fields := []msgmodel.RawField{ + {Key: "status", Value: status}, + {Key: "input", Value: json.RawMessage(input)}, + {Key: "output", Value: outputRaw}, + {Key: "metadata", Value: json.RawMessage(metadata)}, + {Key: "title", Value: titleRaw}, + {Key: "time", Value: timeRaw}, + } + if output.Attachments != nil { + attachments, marshalErr := jsonutil.Marshal(*output.Attachments) + if marshalErr != nil { + return rawToolState{}, marshalErr + } + fields = append(fields, msgmodel.RawField{Key: "attachments", Value: attachments}) + } + return newRawToolState(msgmodel.SpreadObject(nil, fields...)), nil +} + +func errorState(input msgmodel.RawObject, message string, start, end uint64) (rawToolState, error) { + status, _ := jsonutil.Marshal(msgmodel.ToolStatusError) + messageRaw, _ := jsonutil.Marshal(message) + timeRaw, err := jsonutil.Marshal(msgmodel.ToolTimeSpan{Start: start, End: end}) + if err != nil { + return rawToolState{}, err + } + return newRawToolState(msgmodel.SpreadObject(nil, + msgmodel.RawField{Key: "status", Value: status}, + msgmodel.RawField{Key: "input", Value: json.RawMessage(input)}, + msgmodel.RawField{Key: "error", Value: messageRaw}, + msgmodel.RawField{Key: "time", Value: timeRaw}, + )), nil +} + +func reasoningMetadata(details orclient.ReasoningDetailsView) msgmodel.RawObject { + type envelope struct { + ReasoningDetails orclient.ReasoningDetailsView `json:"reasoning_details"` + } + type metadata struct { + Openrouter envelope `json:"openrouter"` + } + raw, _ := jsonutil.Marshal(metadata{Openrouter: envelope{ReasoningDetails: details}}) + return msgmodel.RawObject(raw) +} + +func errorPartMessage(raw json.RawMessage) string { + var value struct { + Message string `json:"message"` + Data *struct { + Message string `json:"message"` + } `json:"data"` + } + if json.Unmarshal(raw, &value) == nil { + if value.Message != "" { + return value.Message + } + if value.Data != nil && value.Data.Message != "" { + return value.Data.Message + } + } + if len(raw) == 0 { + return "unknown error" + } + return string(raw) +} + +func invalidToolMessage(input json.RawMessage) string { + var value struct { + Error string `json:"error"` + } + if json.Unmarshal(input, &value) == nil && value.Error != "" { + return value.Error + } + return "Invalid tool call" +} diff --git a/internal/seniordev/engine/steploop/reminders.go b/internal/seniordev/engine/steploop/reminders.go new file mode 100644 index 0000000000..489e5dcd8d --- /dev/null +++ b/internal/seniordev/engine/steploop/reminders.go @@ -0,0 +1,91 @@ +//go:build !windows + +package steploop + +import ( + "crypto/rand" + "fmt" + "sync" + "time" +) + +var ( + seamMu sync.Mutex + nowMS = func() uint64 { return uint64(time.Now().UnixMilli()) } + idFunc = defaultID + + idMu sync.Mutex + idLastMS uint64 + idCounter uint64 +) + +// SetNowForTesting swaps the millisecond clock and returns a restore closure. +func SetNowForTesting(f func() uint64) func() { + seamMu.Lock() + prev := nowMS + nowMS = f + seamMu.Unlock() + return func() { + seamMu.Lock() + nowMS = prev + seamMu.Unlock() + } +} + +// SetIDFactoryForTesting swaps MessageID/PartID ascending generation. Prefix +// is "msg" or "prt". It returns a restore closure. +func SetIDFactoryForTesting(f func(prefix string) string) func() { + seamMu.Lock() + prev := idFunc + idFunc = f + seamMu.Unlock() + return func() { + seamMu.Lock() + idFunc = prev + seamMu.Unlock() + } +} + +func currentNow() uint64 { + seamMu.Lock() + f := nowMS + seamMu.Unlock() + return f() +} + +func nextID(prefix string) string { + seamMu.Lock() + f := idFunc + seamMu.Unlock() + return f(prefix) +} + +// NewAscendingID lets production adapters mint session-layer records from the +// same ordered sequence as loop-owned messages and parts. +func NewAscendingID(prefix string) string { + return nextID(prefix) +} + +// defaultID mints an ascending ID: the prefix, six bytes of packed +// millisecond time and per-millisecond counter, then 14 random base-62 +// characters (one crypto byte modulo 62 each). +func defaultID(prefix string) string { + idMu.Lock() + defer idMu.Unlock() + ms := currentNow() + if ms != idLastMS { + idLastMS = ms + idCounter = 0 + } + idCounter++ + packed := ms*0x1000 + idCounter + random := make([]byte, 14) + if _, err := rand.Read(random); err != nil { + panic(err) + } + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + for index := range random { + random[index] = alphabet[int(random[index])%len(alphabet)] + } + return fmt.Sprintf("%s_%012x%s", prefix, packed&0xffffffffffff, string(random)) +} diff --git a/internal/seniordev/engine/steploop/steploop_test.go b/internal/seniordev/engine/steploop/steploop_test.go new file mode 100644 index 0000000000..76ec2bee3e --- /dev/null +++ b/internal/seniordev/engine/steploop/steploop_test.go @@ -0,0 +1,809 @@ +//go:build !windows + +package steploop + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" + "github.com/Agent-Field/codeaf/internal/seniordev/session/instruction" +) + +func stringPtr(value string) *string { return &value } + +func baseUser(id, agent string, parts msgmodel.Parts) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.TimeCreated{Created: 1}, + Agent: agent, + Model: msgmodel.UserModel{ProviderID: "openrouter", ModelID: "model-1"}, + }, + Parts: parts, + } +} + +func baseAssistant(id, parent, agent, finish string, parts msgmodel.Parts) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.AssistantTime{Created: 2}, + ParentID: parent, + ModelID: "model-1", + ProviderID: "openrouter", + Mode: agent, + Agent: agent, + Path: msgmodel.AssistantPath{Cwd: "/work", Root: "/work"}, + Tokens: msgmodel.Tokens{Cache: msgmodel.TokenCache{}}, + Finish: stringPtr(finish), + }, + Parts: parts, + } +} + +func TestShouldExitTruthTable(t *testing.T) { + finishes := []*string{ + nil, + stringPtr(""), + stringPtr(orclient.FinishStop), + stringPtr(orclient.FinishLength), + stringPtr(orclient.FinishContentFilter), + stringPtr(orclient.FinishToolCalls), + stringPtr(orclient.FinishError), + stringPtr(orclient.FinishOther), + stringPtr("unknown"), + } + for _, finish := range finishes { + label := "unset" + if finish != nil { + label = *finish + if label == "" { + label = "empty" + } + } + for _, pending := range []bool{false, true} { + for _, ordering := range []string{"lt", "eq", "gt"} { + t.Run(label+"/tool="+strconv.FormatFloat(boolNumber(pending), 'f', -1, 64)+"/"+ordering, func(t *testing.T) { + userID := "msg_2" + switch ordering { + case "lt": + userID = "msg_1" + case "gt": + userID = "msg_3" + } + user := msgmodel.User{MessageBase: msgmodel.MessageBase{ID: userID}} + assistant := msgmodel.Assistant{MessageBase: msgmodel.MessageBase{ID: "msg_2"}, Finish: finish} + parts := msgmodel.Parts{} + if pending { + parts = append(parts, msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "prt_1", MessageID: assistant.ID}, + CallID: "call_1", Tool: "fake", State: msgmodel.PendingToolState(), + }) + } + msgs := []msgmodel.WithParts{{Info: assistant, Parts: parts}} + got := ShouldExit(&user, &assistant, msgs) + want := finish != nil && *finish != "" && *finish != orclient.FinishToolCalls && !pending && ordering == "lt" + if got != want { + t.Fatalf("ShouldExit=%v want %v", got, want) + } + }) + } + } + } +} + +func boolNumber(value bool) float64 { + if value { + return 1 + } + return 0 +} + +type memoryStore struct { + mu sync.Mutex + messages []msgmodel.WithParts + events []string +} + +func (s *memoryStore) Messages(_ context.Context, sessionID string) ([]msgmodel.WithParts, error) { + s.mu.Lock() + defer s.mu.Unlock() + raw, err := jsonutil.Marshal(s.messages) + if err != nil { + return nil, err + } + var copied []msgmodel.WithParts + if err := json.Unmarshal(raw, &copied); err != nil { + return nil, err + } + return copied, nil +} + +func (s *memoryStore) UpdateMessage(_ context.Context, info msgmodel.Info) error { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, "message:"+info.MessageRole()+":"+info.MessageID()) + for index := range s.messages { + if s.messages[index].Info.MessageID() == info.MessageID() { + s.messages[index].Info = info + return nil + } + } + s.messages = append(s.messages, msgmodel.WithParts{Info: info, Parts: msgmodel.Parts{}}) + return nil +} + +func (s *memoryStore) UpdatePart(_ context.Context, part msgmodel.Part) error { + s.mu.Lock() + defer s.mu.Unlock() + base := part.Base() + s.events = append(s.events, "part:"+part.PartType()+":"+base.ID) + for mi := range s.messages { + if s.messages[mi].Info.MessageID() != base.MessageID { + continue + } + for partIdx := range s.messages[mi].Parts { + if s.messages[mi].Parts[partIdx].Base().ID == base.ID { + s.messages[mi].Parts[partIdx] = part + return nil + } + } + s.messages[mi].Parts = append(s.messages[mi].Parts, part) + return nil + } + return errors.New("part message not found: " + base.MessageID) +} + +func (s *memoryStore) rawSnapshot() []msgmodel.WithParts { + s.mu.Lock() + defer s.mu.Unlock() + return append([]msgmodel.WithParts(nil), s.messages...) +} + +type scriptedClient struct { + mu sync.Mutex + scripts [][]orclient.StreamPart + requests []orclient.RequestParams +} + +func (c *scriptedClient) Stream(_ context.Context, params orclient.RequestParams) (PartStream, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.requests = append(c.requests, params) + if len(c.scripts) == 0 { + return nil, errors.New("unexpected extra LLM turn") + } + parts := c.scripts[0] + c.scripts = c.scripts[1:] + return &SliceStream{Parts: parts}, nil +} + +func finishPart(reason string) orclient.FinishPart { + return orclient.FinishPart{ + FinishReason: orclient.FinishReason{Unified: reason}, + Usage: calc.LanguageModelV3Usage{}, + } +} + +type immediateTool struct { + mu sync.Mutex + calls []ToolCall +} + +type historyTool struct { + loaded chan []string +} + +func (tool *historyTool) Execute(ctx context.Context, _ ToolCall) (ToolResult, error) { + paths := instruction.Loaded(ToolMessagesFromContext(ctx)).Values() + tool.loaded <- paths + return ToolResult{Output: "nested reminder re-injected", Metadata: msgmodel.RawObject(`{}`)}, nil +} + +type taskControllerSpy struct { + mu sync.Mutex + overflowChecks int + creates int + pruned chan string +} + +func (*taskControllerSpy) ProcessCompaction( + context.Context, TaskInput, msgmodel.CompactionPart, +) (Result, error) { + return ResultContinue, nil +} + +func (s *taskControllerSpy) IsOverflow( + _ context.Context, _ msgmodel.Assistant, _ Model, +) (bool, error) { + s.mu.Lock() + s.overflowChecks++ + s.mu.Unlock() + return false, nil +} + +func (s *taskControllerSpy) CreateCompaction( + context.Context, string, msgmodel.User, bool, +) error { + s.mu.Lock() + s.creates++ + s.mu.Unlock() + return nil +} + +func (s *taskControllerSpy) Prune(_ context.Context, sessionID string) error { + s.pruned <- sessionID + return nil +} + +func (e *immediateTool) Execute(_ context.Context, call ToolCall) (ToolResult, error) { + e.mu.Lock() + e.calls = append(e.calls, call) + e.mu.Unlock() + return ToolResult{Title: "ok", Metadata: msgmodel.RawObject(`{"source":"fake"}`), Output: "value=2"}, nil +} + +func fixedSeams(t *testing.T) { + t.Helper() + restoreNow := SetNowForTesting(func() uint64 { return 1000 }) + sequence := 0 + restoreID := SetIDFactoryForTesting(func(prefix string) string { + sequence++ + return prefix + "_1" + leftPad(sequence, 4) + }) + t.Cleanup(restoreID) + t.Cleanup(restoreNow) +} + +func leftPad(value, width int) string { + text := strconv.Itoa(value) + for len(text) < width { + text = "0" + text + } + return text +} + +func testResolver() ModelResolver { + limits := calc.Model{Limit: calc.ModelLimit{Context: 128000, Output: 32000}} + return ModelResolverFunc(func(_ context.Context, user msgmodel.User) (Model, error) { + return Model{ + Message: msgmodel.Model{ + ProviderID: user.Model.ProviderID, + ID: user.Model.ModelID, + API: msgmodel.ModelAPI{Npm: "@openrouter/ai-sdk-provider", ID: user.Model.ModelID}, + }, + Calc: limits, + Request: orclient.RequestParams{ModelID: user.Model.ModelID}, + }, nil + }) +} + +func TestScriptedMultiTurnToolSequence(t *testing.T) { + fixedSeams(t) + store := &memoryStore{messages: []msgmodel.WithParts{baseUser("msg_0000", "build", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0000", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "double one"}, + })}} + client := &scriptedClient{scripts: [][]orclient.StreamPart{ + { + orclient.ToolInputStartPart{ID: "call_1", ToolName: "double"}, + orclient.ToolCallPart{ToolCallID: "call_1", ToolName: "double", Input: `{"x":1}`}, + finishPart(orclient.FinishToolCalls), + }, + { + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + finishPart(orclient.FinishStop), + }, + }} + executor := &immediateTool{} + cleared := []string{} + + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: executor} + final, err := loop.Run(context.Background(), RunOptions{ + SessionID: "ses_1", Workspace: "/work", Worktree: "/work", + Tools: []ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "double", Description: "double", InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + AfterAssistant: func(_ context.Context, messageID string) { + cleared = append(cleared, messageID) + }, + }) + if err != nil { + t.Fatal(err) + } + if final.Finish == nil || *final.Finish != orclient.FinishStop { + t.Fatalf("final finish = %#v", final.Finish) + } + if len(client.requests) != 2 { + t.Fatalf("LLM turns = %d, want 2", len(client.requests)) + } + if got := modelRoles(client.requests[0].Prompt); strings.Join(got, ",") != "user" { + t.Fatalf("first prompt roles = %v", got) + } + if got := modelRoles(client.requests[1].Prompt); strings.Join(got, ",") != "user,assistant,tool" { + t.Fatalf("second prompt roles = %v", got) + } + messages := store.rawSnapshot() + if len(messages) != 3 { + t.Fatalf("persisted messages = %d, want user + two assistants", len(messages)) + } + assertPartTypes(t, messages[1].Parts, "step-start,tool,step-finish") + assertPartTypes(t, messages[2].Parts, "step-start,text,step-finish") + tool := messages[1].Parts[1].(msgmodel.ToolPart) + if tool.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("tool status = %s", tool.State.ToolStatus()) + } + if len(executor.calls) != 1 || string(executor.calls[0].Input) != `{"x":1}` { + t.Fatalf("tool calls = %#v", executor.calls) + } + if len(cleared) != 2 || cleared[0] == cleared[1] { + t.Fatalf("assistant instruction claims cleared = %v", cleared) + } +} + +func TestToolExecutionUsesPostCompactionHistory(t *testing.T) { + // Read metadata from before a completed compaction is absent from the + // next read's tool context, so its nested instruction reminder can be + // injected again. + fixedSeams(t) + summary := true + compactionUser := baseUser("msg_0002", "coder", msgmodel.Parts{msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "compact", SessionID: "ses_1", MessageID: "msg_0002"}, + Auto: true, + }}) + summaryAssistant := baseAssistant("msg_0003", "msg_0002", "compaction", orclient.FinishStop, msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "summary", SessionID: "ses_1", MessageID: "msg_0003"}, Text: "summary"}, + }) + summaryInfo := summaryAssistant.Info.(msgmodel.Assistant) + summaryInfo.Summary = &summary + summaryAssistant.Info = summaryInfo + oldRead := msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "old-read", SessionID: "ses_1", MessageID: "msg_0001"}, + CallID: "old", Tool: "read", + State: msgmodel.CompletedToolState( + msgmodel.RawObject(`{}`), "old reminder", "read", msgmodel.RawObject(`{"loaded":["/work/src/AGENTS.md"]}`), + 1, 2, nil, + ), + } + store := &memoryStore{messages: []msgmodel.WithParts{ + baseUser("msg_0000", "coder", msgmodel.Parts{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "initial", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "first read", + }}), + baseAssistant("msg_0001", "msg_0000", "coder", orclient.FinishToolCalls, msgmodel.Parts{oldRead}), + compactionUser, summaryAssistant, + baseUser("msg_0004", "coder", msgmodel.Parts{msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "again", SessionID: "ses_1", MessageID: "msg_0004"}, Text: "read again", + }}), + }} + client := &scriptedClient{scripts: [][]orclient.StreamPart{ + { + orclient.ToolInputStartPart{ID: "call_2", ToolName: "read"}, + orclient.ToolCallPart{ToolCallID: "call_2", ToolName: "read", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }, + {finishPart(orclient.FinishStop)}, + }} + executor := &historyTool{loaded: make(chan []string, 1)} + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: executor} + if _, err := loop.Run(context.Background(), RunOptions{ + SessionID: "ses_1", Workspace: "/work", Worktree: "/work", + Tools: []ToolDefinition{{Provider: orclient.Tool{ + Type: "function", Name: "read", InputSchema: json.RawMessage(`{"type":"object"}`), + }}}, + }); err != nil { + t.Fatal(err) + } + if loaded := <-executor.loaded; len(loaded) != 0 { + t.Fatalf("post-compaction read saw stale loaded paths: %v", loaded) + } +} + +func TestSummaryTurnSkipsRecursiveCompactionAndPrunesOnExit(t *testing.T) { + // A finished summary bypasses the pre-turn overflow check, and a wired + // controller is pruned after the natural exit. + fixedSeams(t) + summary := true + store := &memoryStore{messages: []msgmodel.WithParts{ + baseUser("msg_0001", "coder", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0001", SessionID: "ses_1", MessageID: "msg_0001"}, Text: "original"}, + }), + baseAssistant("msg_0002", "msg_0001", "compaction", orclient.FinishStop, msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0002", SessionID: "ses_1", MessageID: "msg_0002"}, Text: "summary"}, + }), + baseUser("msg_0003", "coder", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0003", SessionID: "ses_1", MessageID: "msg_0003"}, Text: "continue"}, + }), + }} + assistant := store.messages[1].Info.(msgmodel.Assistant) + assistant.Summary = &summary + store.messages[1].Info = assistant + client := &scriptedClient{scripts: [][]orclient.StreamPart{{ + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + finishPart(orclient.FinishStop), + }}} + tasks := &taskControllerSpy{pruned: make(chan string, 1)} + loop := Loop{Store: store, Client: client, Models: testResolver(), Tasks: tasks} + if _, err := loop.Run(context.Background(), RunOptions{ + SessionID: "ses_1", Workspace: "/work", Worktree: "/work", + }); err != nil { + t.Fatal(err) + } + select { + case sessionID := <-tasks.pruned: + if sessionID != "ses_1" { + t.Fatalf("pruned session = %q", sessionID) + } + case <-time.After(time.Second): + t.Fatal("prune fork did not run") + } + tasks.mu.Lock() + defer tasks.mu.Unlock() + if tasks.overflowChecks != 1 { + t.Fatalf("overflow checks = %d, want only the post-response check", tasks.overflowChecks) + } + if tasks.creates != 0 { + t.Fatalf("recursive compactions = %d", tasks.creates) + } +} + +func modelRoles(messages []msgmodel.ModelMessage) []string { + out := make([]string, len(messages)) + for i, message := range messages { + out[i] = message.Role + } + return out +} + +func assertPartTypes(t *testing.T, parts msgmodel.Parts, want string) { + t.Helper() + got := make([]string, len(parts)) + for index, part := range parts { + got[index] = part.PartType() + } + if strings.Join(got, ",") != want { + t.Fatalf("part types = %v, want %s", got, want) + } +} + +type blockingTool struct { + release <-chan struct{} +} + +func (e blockingTool) Execute(_ context.Context, _ ToolCall) (ToolResult, error) { + <-e.release + return ToolResult{Metadata: msgmodel.RawObject("{}")}, nil +} + +func TestCleanupWaitsOutstandingToolsConcurrentlyThenAbortsSpreadStates(t *testing.T) { + // A cancelled ctx grants each outstanding tool the grace window, in + // parallel, then force-writes the aborted state over what was there. + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + release := make(chan struct{}) + defer close(release) + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "a"}}, + {Provider: orclient.Tool{Type: "function", Name: "b"}}, + }, + Executor: blockingTool{release: release}, WaitTimeout: 40 * time.Millisecond, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "a"}, + orclient.ToolCallPart{ToolCallID: "a", ToolName: "a", Input: `{}`}, + orclient.ToolInputStartPart{ID: "b", ToolName: "b"}, + orclient.ToolCallPart{ToolCallID: "b", ToolName: "b", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + start := time.Now() + if _, err := processor.Process(ctx, stream); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed >= 75*time.Millisecond { + t.Fatalf("two 40ms waits were serial: %s", elapsed) + } + messages := store.rawSnapshot() + parts := messages[0].Parts + assertPartTypes(t, parts, "step-start,tool,tool,step-finish") + for _, raw := range parts { + tool, ok := raw.(msgmodel.ToolPart) + if !ok { + continue + } + if tool.State.ToolStatus() != msgmodel.ToolStatusError { + t.Fatalf("%s status = %s", tool.CallID, tool.State.ToolStatus()) + } + state := jsonString(t, tool.State) + for _, fragment := range []string{`"raw":""`, `"error":"Tool execution aborted"`, `"interrupted":true`} { + if !strings.Contains(state, fragment) { + t.Fatalf("%s state %s missing %s", tool.CallID, state, fragment) + } + } + } +} + +func TestCleanupBoundsDispatchedToolAfterErrorPart(t *testing.T) { + testCleanupBoundsDispatchedToolAfterAbnormalPart(t, orclient.ErrorPart{ + Error: json.RawMessage(`{"message":"provider failed"}`), + }) +} + +func TestCleanupBoundsDispatchedToolAfterAbortPart(t *testing.T) { + testCleanupBoundsDispatchedToolAfterAbnormalPart(t, orclient.AbortPart{ + Reason: "user stopped the stream", HasReason: true, + }) +} + +func testCleanupBoundsDispatchedToolAfterAbnormalPart(t *testing.T, terminal orclient.StreamPart) { + t.Helper() + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + release := make(chan struct{}) + var releaseOnce sync.Once + releaseTool := func() { releaseOnce.Do(func() { close(release) }) } + defer releaseTool() + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "a"}}, + }, + Executor: blockingTool{release: release}, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "a"}, + orclient.ToolCallPart{ToolCallID: "a", ToolName: "a", Input: `{}`}, + terminal, + }} + type processResult struct { + result Result + err error + } + completed := make(chan processResult, 1) + start := time.Now() + go func() { + result, err := processor.Process(context.Background(), stream) + completed <- processResult{result: result, err: err} + }() + var got processResult + select { + case got = <-completed: + case <-time.After(time.Second): + releaseTool() + <-completed + t.Fatal("Process did not bound cleanup after an abnormal stream end") + } + if got.err != nil { + t.Fatal(got.err) + } + if got.result != ResultStop { + t.Fatalf("Process result = %v, want ResultStop", got.result) + } + if elapsed := time.Since(start); elapsed >= time.Second { + t.Fatalf("Process took %s, want bounded abnormal cleanup", elapsed) + } + messages := store.rawSnapshot() + var tool *msgmodel.ToolPart + for _, part := range messages[0].Parts { + if value, ok := part.(msgmodel.ToolPart); ok { + value := value + tool = &value + break + } + } + if tool == nil { + t.Fatal("tool part was not persisted") + } + state := jsonString(t, tool.State) + for _, fragment := range []string{`"error":"Tool execution aborted"`, `"interrupted":true`} { + if !strings.Contains(state, fragment) { + t.Fatalf("state %s missing %s", state, fragment) + } + } +} + +func TestCleanupBoundsUndispatchedToolRegistrationOnHealthyTurn(t *testing.T) { + // A tool-input-start whose tool-call part never arrives (length-truncated + // or errored stream) has no executor and can never settle itself. On a + // healthy turn cleanup must settle it via the bounded drain, not wait + // unbounded, which would deadlock the loop. + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "write"}}, + }, + Executor: &immediateTool{}, + WaitTimeout: 40 * time.Millisecond, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "write"}, + finishPart(orclient.FinishLength), + }} + completed := make(chan error, 1) + go func() { + _, err := processor.Process(context.Background(), stream) + completed <- err + }() + select { + case err := <-completed: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("cleanup hung on an undispatched tool registration (healthy ctx)") + } + messages := store.rawSnapshot() + parts := messages[0].Parts + assertPartTypes(t, parts, "step-start,tool,step-finish") + tool := parts[1].(msgmodel.ToolPart) + if tool.State.ToolStatus() != msgmodel.ToolStatusError { + t.Fatalf("undispatched registration status = %s, want aborted error", tool.State.ToolStatus()) + } + state := jsonString(t, tool.State) + for _, fragment := range []string{`"error":"Tool execution aborted"`, `"interrupted":true`} { + if !strings.Contains(state, fragment) { + t.Fatalf("state %s missing %s", state, fragment) + } + } +} + +type slowTool struct { + delay time.Duration + output string +} + +func (e slowTool) Execute(_ context.Context, _ ToolCall) (ToolResult, error) { + time.Sleep(e.delay) + return ToolResult{Output: e.output, Metadata: msgmodel.RawObject("{}")}, nil +} + +func TestCleanupWaitsForSlowToolOnHealthyTurnInsteadOfAborting(t *testing.T) { + // A tool whose execution outlives the provider stream still completes + // with its real output on a healthy (non-cancelled) turn, no matter how + // far past WaitTimeout it runs. Aborting it instead hands the model a + // spurious "Tool execution aborted" error and costs a retry. + fixedSeams(t) + store := &memoryStore{} + assistant := baseAssistant("msg_0001", "msg_0000", "build", "", nil).Info.(msgmodel.Assistant) + assistant.Finish = nil + if err := store.UpdateMessage(context.Background(), assistant); err != nil { + t.Fatal(err) + } + processor := NewProcessor(ProcessorOptions{ + Store: store, Assistant: assistant, Model: Model{}, + Tools: []ToolDefinition{ + {Provider: orclient.Tool{Type: "function", Name: "a"}}, + }, + Executor: slowTool{delay: 120 * time.Millisecond, output: "slow but real"}, + WaitTimeout: 5 * time.Millisecond, + }) + stream := &SliceStream{Parts: []orclient.StreamPart{ + orclient.ToolInputStartPart{ID: "a", ToolName: "a"}, + orclient.ToolCallPart{ToolCallID: "a", ToolName: "a", Input: `{}`}, + finishPart(orclient.FinishToolCalls), + }} + if _, err := processor.Process(context.Background(), stream); err != nil { + t.Fatal(err) + } + messages := store.rawSnapshot() + parts := messages[0].Parts + assertPartTypes(t, parts, "step-start,tool,step-finish") + tool := parts[1].(msgmodel.ToolPart) + if tool.State.ToolStatus() != msgmodel.ToolStatusCompleted { + t.Fatalf("slow tool status = %s, want completed; state = %s", + tool.State.ToolStatus(), jsonString(t, tool.State)) + } + if !strings.Contains(jsonString(t, tool.State), "slow but real") { + t.Fatalf("slow tool output missing: %s", jsonString(t, tool.State)) + } +} + +// The endpoint OpenRouter reports for a call lands on the step-finish part and +// on the assistant message, so a cache miss can be attributed to an endpoint +// switch afterwards. A stream that never reports one leaves both empty. +func TestFinishRecordsReportedUpstream(t *testing.T) { + fixedSeams(t) + store := &memoryStore{messages: []msgmodel.WithParts{baseUser("msg_0000", "build", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0000", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "hello"}, + })}} + served := finishPart(orclient.FinishStop) + served.Metadata.Provider = stringPtr("provider-b") + client := &scriptedClient{scripts: [][]orclient.StreamPart{{ + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + served, + }}} + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: &immediateTool{}} + final, err := loop.Run(context.Background(), RunOptions{SessionID: "ses_1", Workspace: "/work", Worktree: "/work"}) + if err != nil { + t.Fatal(err) + } + if final.Upstream != "provider-b" { + t.Fatalf("assistant upstream = %q, want provider-b", final.Upstream) + } + messages := store.rawSnapshot() + assertPartTypes(t, messages[1].Parts, "step-start,text,step-finish") + finish := messages[1].Parts[2].(msgmodel.StepFinishPart) + if finish.Upstream != "provider-b" { + t.Fatalf("step-finish upstream = %q, want provider-b", finish.Upstream) + } +} + +// A step's cost is the price its service put on it when it named one, and the +// catalog's rates times its tokens only when it did not: the catalog read far +// under what codeaf metered, and the run's agent-summary adds these up. +func TestFinishCostsWhatTheServiceChargedWhenItSaid(t *testing.T) { + for _, row := range []struct { + name string + reported *float64 + want float64 + }{ + {name: "the service named a price", reported: floatPtr(0.0412), want: 0.0412}, + {name: "the service named none", want: 0}, + } { + t.Run(row.name, func(t *testing.T) { + fixedSeams(t) + store := &memoryStore{messages: []msgmodel.WithParts{baseUser("msg_0000", "build", msgmodel.Parts{ + msgmodel.TextPart{PartBase: msgmodel.PartBase{ID: "prt_0000", SessionID: "ses_1", MessageID: "msg_0000"}, Text: "hello"}, + })}} + served := finishPart(orclient.FinishStop) + if row.reported != nil { + served.Metadata.Usage = orclient.NewObject() + served.Metadata.Usage.SetNumber("cost", *row.reported) + } + client := &scriptedClient{scripts: [][]orclient.StreamPart{{ + orclient.TextStartPart{ID: "text_1"}, + orclient.TextDeltaPart{ID: "text_1", Delta: "done"}, + orclient.TextEndPart{ID: "text_1"}, + served, + }}} + loop := Loop{Store: store, Client: client, Models: testResolver(), Executor: &immediateTool{}} + final, err := loop.Run(context.Background(), RunOptions{SessionID: "ses_1", Workspace: "/work", Worktree: "/work"}) + if err != nil { + t.Fatal(err) + } + if final.Cost != row.want { + t.Fatalf("assistant cost = %v, want %v", final.Cost, row.want) + } + finish := store.rawSnapshot()[1].Parts[2].(msgmodel.StepFinishPart) + if finish.Cost != row.want { + t.Fatalf("step-finish cost = %v, want %v", finish.Cost, row.want) + } + }) + } +} + +func floatPtr(value float64) *float64 { return &value } diff --git a/internal/seniordev/engine/steploop/types.go b/internal/seniordev/engine/steploop/types.go new file mode 100644 index 0000000000..6dca0dafb7 --- /dev/null +++ b/internal/seniordev/engine/steploop/types.go @@ -0,0 +1,208 @@ +//go:build !windows + +package steploop + +import ( + "context" + "encoding/json" + "io" + "math" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" +) + +// Store is the MessageV2 persistence slice used by the loop and processor. +// Messages returns chronological order and fresh part values. +type Store interface { + Messages(ctx context.Context, sessionID string) ([]msgmodel.WithParts, error) + UpdateMessage(ctx context.Context, info msgmodel.Info) error + UpdatePart(ctx context.Context, part msgmodel.Part) error +} + +// PartStream is one one-request OpenRouter response. +type PartStream interface { + Next() (orclient.StreamPart, error) + Close() error +} + +// LLMClient starts one request. Multi-turn behavior belongs to Loop.Run. +type LLMClient interface { + Stream(ctx context.Context, params orclient.RequestParams) (PartStream, error) +} + +// SliceStream is a deterministic in-memory PartStream useful to embedders and +// tests. Failure is returned after all Parts; Close is idempotent. +type SliceStream struct { + Parts []orclient.StreamPart + Failure error + next int + closed bool +} + +func (s *SliceStream) Next() (orclient.StreamPart, error) { + if s.closed { + return nil, io.EOF + } + if s.next < len(s.Parts) { + part := s.Parts[s.next] + s.next++ + return part, nil + } + if s.Failure != nil { + err := s.Failure + s.Failure = nil + return nil, err + } + return nil, io.EOF +} + +func (s *SliceStream) Close() error { + s.closed = true + return nil +} + +// ToolDefinition is a provider declaration plus the optional validation seam +// consumed by orclient.ParseToolCall. +type ToolDefinition struct { + Provider orclient.Tool + Validate func(input json.RawMessage) error + WaitForResult bool +} + +// ToolCall is the already-repaired, already-validated call handed to a real +// tool implementation. +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` + SessionID string `json:"sessionID"` + MessageID string `json:"messageID"` + Agent string `json:"agent,omitempty"` + ModelID string `json:"modelID,omitempty"` +} + +// ToolResult is what a tool returns when it completes. +type ToolResult struct { + Title string + Metadata msgmodel.RawObject + Output string + Attachments *[]msgmodel.FilePart +} + +// ToolExecutor is intentionally the minimal real-tool seam. Resolution and +// declarations are RunOptions.Tools; this interface only performs a call. +type ToolExecutor interface { + Execute(ctx context.Context, call ToolCall) (ToolResult, error) +} + +type toolMessagesContextKey struct{} + +// WithToolMessages gives a tool the persisted session history that was current +// when execution began. Read uses it to deduplicate nested instruction files. +func WithToolMessages(ctx context.Context, messages []msgmodel.WithParts) context.Context { + return context.WithValue(ctx, toolMessagesContextKey{}, messages) +} + +// ToolMessagesFromContext returns the history attached by the live executor. +func ToolMessagesFromContext(ctx context.Context) []msgmodel.WithParts { + messages, _ := ctx.Value(toolMessagesContextKey{}).([]msgmodel.WithParts) + return messages +} + +// Model is the resolved model in the three projections the loop needs: the +// message-conversion view, the budget view and the request parameters. +type Model struct { + Message msgmodel.Model + Calc calc.Model + Request orclient.RequestParams +} + +// ModelResolver resolves the model named by the latest user message. +type ModelResolver interface { + Resolve(ctx context.Context, user msgmodel.User) (Model, error) +} + +// ModelResolverFunc adapts a function to ModelResolver. +type ModelResolverFunc func(context.Context, msgmodel.User) (Model, error) + +func (f ModelResolverFunc) Resolve(ctx context.Context, user msgmodel.User) (Model, error) { + return f(ctx, user) +} + +// TaskInput is the context handed to the compaction branch of the loop. +type TaskInput struct { + SessionID string + Messages []msgmodel.WithParts + User msgmodel.User + Model Model +} + +// TaskController is the narrow seam to the compaction service. The step loop +// owns when each operation runs; the controller owns the operations. +type TaskController interface { + ProcessCompaction(ctx context.Context, input TaskInput, task msgmodel.CompactionPart) (Result, error) + IsOverflow(ctx context.Context, assistant msgmodel.Assistant, model Model) (bool, error) + CreateCompaction(ctx context.Context, sessionID string, user msgmodel.User, overflow bool) error + Prune(ctx context.Context, sessionID string) error +} + +// RunOptions are the session and agent values one Run needs. +type RunOptions struct { + SessionID string + ParentID string + Workspace string + Worktree string + + // MaxSteps is agent.steps. Nil means Infinity. + MaxSteps *float64 + Tools []ToolDefinition + + // InjectReminders may add in-memory-only reminder parts to the prompt + // before each request. It must return fresh values. + InjectReminders func(context.Context, []msgmodel.WithParts, msgmodel.User) ([]msgmodel.WithParts, error) + + // AfterAssistant runs after each processor turn, including stop and + // error turns, with the assistant message ID; the instruction tracker + // uses it to release the claims made for that message. + AfterAssistant func(context.Context, string) + + // AfterTurn observes a fully persisted assistant turn and may stop the loop + // before another provider call. + AfterTurn func(context.Context, msgmodel.Assistant, msgmodel.Parts) error +} + +func (o RunOptions) maxSteps() float64 { + if o.MaxSteps == nil { + return math.Inf(1) + } + return *o.MaxSteps +} + +// Result is what a processed turn asks the loop to do next. +type Result string + +const ( + ResultCompact Result = "compact" + ResultStop Result = "stop" + ResultContinue Result = "continue" +) + +// MaxStepsPrompt is appended to the prompt once the step cap is reached. +const MaxStepsPrompt = `CRITICAL - MAXIMUM STEPS REACHED + +The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. + +STRICT REQUIREMENTS: +1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools) +2. MUST provide a text response summarizing work done so far +3. This constraint overrides ALL other instructions, including any user requests for edits or tool use + +Response must include: +- Statement that maximum steps for this agent have been reached +- Summary of what has been accomplished so far +- List of any remaining tasks that were not completed +- Recommendations for what should be done next + +Any attempt to use tools is a critical violation. Respond with text ONLY.` diff --git a/internal/seniordev/format/format.go b/internal/seniordev/format/format.go new file mode 100644 index 0000000000..f6eb8a589d --- /dev/null +++ b/internal/seniordev/format/format.go @@ -0,0 +1,256 @@ +//go:build !windows + +// Formatter service +package format + +import ( + "context" + "errors" + "path/filepath" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type Status struct { + Name string `json:"name"` + Extensions []string `json:"extensions"` + Enabled bool `json:"enabled"` +} + +type FormatterOverride struct { + Key string + Disabled bool + Extensions *[]string + Command *[]string + Environment map[string]string +} + +type Configuration struct { + // Enabled=false (no formatter config) disables every formatter. + Enabled bool + Overrides []FormatterOverride +} + +type CommandRunner func(context.Context, []string, string, map[string]string) (int, error) + +type Service struct { + context Context + deps Dependencies + runner CommandRunner + + mu sync.Mutex + formatters orderedFormatters + commands map[string]cachedCommand +} + +type cachedCommand struct { + command []string + enabled bool + set bool +} + +type orderedFormatters struct { + order []string + items map[string]Info +} + +func (o *orderedFormatters) set(key string, value Info) { + if _, exists := o.items[key]; !exists { + o.order = append(o.order, key) + } + o.items[key] = value +} + +func (o *orderedFormatters) delete(key string) { + if _, exists := o.items[key]; !exists { + return + } + delete(o.items, key) + for i, item := range o.order { + if item == key { + o.order = append(o.order[:i], o.order[i+1:]...) + return + } + } +} + +func (o *orderedFormatters) values() []Info { + out := make([]Info, 0, len(o.order)) + for _, key := range o.order { + out = append(out, o.items[key]) + } + return out +} + +func NewService(instance Context, configuration Configuration, dependencies Dependencies, runner CommandRunner) *Service { + if runner == nil { + runner = func(ctx context.Context, command []string, cwd string, environment map[string]string) (int, error) { + if len(command) == 0 { + return 1, errors.New("Command is required") + } + result, err := util.RunProcess(ctx, command, util.RunOptions{ + ProcessOptions: util.ProcessOptions{Cwd: cwd, Env: environment}, + NoThrow: true, + }) + return result.Code, err + } + } + service := &Service{ + context: instance, + deps: dependencies, + runner: runner, + formatters: orderedFormatters{items: make(map[string]Info)}, + commands: make(map[string]cachedCommand), + } + if !configuration.Enabled { + return service + } + builtins := Builtins(dependencies) + byExportKey := map[string]Info{} + for _, item := range builtins { + byExportKey[item.Key] = item + service.formatters.set(item.Name, item) + } + + linkedDisabled := false + for _, override := range configuration.Overrides { + if (override.Key == "ruff" || override.Key == "uv") && override.Disabled { + linkedDisabled = true + } + } + for _, override := range configuration.Overrides { + name := override.Key + if (name == "ruff" || name == "uv") && linkedDisabled { + service.formatters.delete("ruff") + service.formatters.delete("uv") + continue + } + if override.Disabled { + service.formatters.delete(name) + continue + } + builtIn, exists := byExportKey[name] + info := Info{Key: name, Name: name, Extensions: []string{}} + if exists { + info = builtIn + info.Name = name + } + if override.Extensions != nil { + info.Extensions = append([]string(nil), (*override.Extensions)...) + } + if override.Environment != nil { + environment := map[string]string{} + for key, value := range info.Environment { + environment[key] = value + } + for key, value := range override.Environment { + environment[key] = value + } + info.Environment = environment + } + if !exists || override.Command != nil { + command := override.Command + info.Enabled = func(_ context.Context, _ Context) ([]string, bool, error) { + if command == nil { + return nil, false, nil + } + return append([]string(nil), (*command)...), true, nil + } + } + service.formatters.set(name, info) + } + return service +} + +func (s *Service) getCommand(ctx context.Context, item Info) ([]string, bool, error) { + s.mu.Lock() + cached := s.commands[item.Name] + if cached.set && cached.enabled { + command := append([]string(nil), cached.command...) + s.mu.Unlock() + return command, true, nil + } + s.mu.Unlock() + + command, enabled, err := item.Enabled(ctx, s.context) + if err != nil { + return nil, false, err + } + s.mu.Lock() + s.commands[item.Name] = cachedCommand{ + command: append([]string(nil), command...), enabled: enabled, set: true, + } + s.mu.Unlock() + return command, enabled, nil +} + +func (s *Service) Status(ctx context.Context) ([]Status, error) { + out := []Status{} + for _, formatter := range s.formatters.values() { + _, enabled, err := s.getCommand(ctx, formatter) + if err != nil { + return nil, err + } + out = append(out, Status{ + Name: formatter.Name, Extensions: append([]string(nil), formatter.Extensions...), Enabled: enabled, + }) + } + return out, nil +} + +func (s *Service) File(ctx context.Context, path string) (bool, error) { + extension := fileExtension(path) + type match struct { + item Info + command []string + } + matches := []match{} + for _, formatter := range s.formatters.values() { + if !containsExtension(formatter.Extensions, extension) { + continue + } + command, enabled, err := s.getCommand(ctx, formatter) + if err != nil { + return false, err + } + if enabled { + matches = append(matches, match{item: formatter, command: command}) + } + } + if len(matches) == 0 { + return false, nil + } + for _, match := range matches { + replaced := make([]string, len(match.command)) + for i, argument := range match.command { + replaced[i] = strings.Replace(argument, "$FILE", path, 1) + } + _, _ = s.runner(ctx, replaced, s.context.Directory, match.item.Environment) + } + return true, nil +} + +func containsExtension(extensions []string, target string) bool { + for _, extension := range extensions { + if extension == target { + return true + } + } + return false +} + +// fileExtension returns the final dotted suffix of path's base name; a +// leading dot alone (".bashrc") is not an extension. +func fileExtension(path string) string { + base := filepath.Base(path) + lastDot := strings.LastIndexByte(base, '.') + if lastDot <= 0 { + return "" + } + if base == ".." { + return "" + } + return base[lastDot:] +} diff --git a/internal/seniordev/format/format_test.go b/internal/seniordev/format/format_test.go new file mode 100644 index 0000000000..5566a4eb5e --- /dev/null +++ b/internal/seniordev/format/format_test.go @@ -0,0 +1,261 @@ +//go:build !windows + +package format + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" +) + +type runCall struct { + command []string + cwd string + environment map[string]string +} + +func TestDisabledConfiguration(t *testing.T) { + service := NewService(Context{}, Configuration{}, Dependencies{}, nil) + status, err := service.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(status) != 0 { + t.Fatalf("status = %#v", status) + } + formatted, err := service.File(context.Background(), "file.go") + if err != nil || formatted { + t.Fatalf("File = %v, %v", formatted, err) + } +} + +func TestCustomFormatterRunsAndReplacesFirstPlaceholder(t *testing.T) { + extensions := []string{".x"} + command := []string{"fmt", "--file=$FILE", "$FILE-$FILE"} + var calls []runCall + service := NewService( + Context{Directory: "/repo"}, + Configuration{Enabled: true, Overrides: []FormatterOverride{{ + Key: "custom", Extensions: &extensions, Command: &command, + Environment: map[string]string{"MODE": "fix"}, + }}}, + Dependencies{}, + func(_ context.Context, command []string, cwd string, environment map[string]string) (int, error) { + calls = append(calls, runCall{command, cwd, environment}) + return 0, nil + }, + ) + formatted, err := service.File(context.Background(), "/repo/a.x") + if err != nil { + t.Fatal(err) + } + if !formatted { + t.Fatal("custom formatter did not match") + } + want := []runCall{{ + command: []string{"fmt", "--file=/repo/a.x", "/repo/a.x-$FILE"}, + cwd: "/repo", environment: map[string]string{"MODE": "fix"}, + }} + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %#v, want %#v", calls, want) + } +} + +func TestAllMatchingFormattersRunAndFailuresAreIgnored(t *testing.T) { + extensions := []string{".x"} + first := []string{"first", "$FILE"} + second := []string{"second", "$FILE"} + var commands [][]string + service := NewService( + Context{Directory: "/repo"}, + Configuration{Enabled: true, Overrides: []FormatterOverride{ + {Key: "first", Extensions: &extensions, Command: &first}, + {Key: "second", Extensions: &extensions, Command: &second}, + }}, + Dependencies{}, + func(_ context.Context, command []string, _ string, _ map[string]string) (int, error) { + commands = append(commands, command) + if command[0] == "first" { + return 1, errors.New("spawn failed") + } + return 9, nil + }, + ) + formatted, err := service.File(context.Background(), "/repo/a.x") + if err != nil || !formatted { + t.Fatalf("File = %v, %v", formatted, err) + } + want := [][]string{{"first", "/repo/a.x"}, {"second", "/repo/a.x"}} + if !reflect.DeepEqual(commands, want) { + t.Fatalf("commands = %#v, want %#v", commands, want) + } +} + +func TestEnabledCommandsCacheOnlySuccessfulProbe(t *testing.T) { + available := false + checks := 0 + dependencies := Dependencies{Which: func(name string) (string, bool) { + if name != "gofmt" { + return "", false + } + checks++ + return "/bin/gofmt", available + }} + service := NewService(Context{}, Configuration{Enabled: true}, dependencies, nil) + for range 2 { + if _, err := service.Status(context.Background()); err != nil { + t.Fatal(err) + } + } + if checks != 2 { + t.Fatalf("false probe checks = %d, want 2", checks) + } + available = true + for range 2 { + if _, err := service.Status(context.Background()); err != nil { + t.Fatal(err) + } + } + if checks != 3 { + t.Fatalf("enabled probe checks = %d, want 3", checks) + } +} + +func TestPrettierDiscoveryAndExecution(t *testing.T) { + root := t.TempDir() + directory := filepath.Join(root, "src") + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(root, "package.json"), + []byte(`{"devDependencies":{"prettier":"3.0.0"}}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + var calls []runCall + service := NewService( + Context{Directory: directory, Worktree: root}, + Configuration{Enabled: true}, + Dependencies{NpmWhich: func(_ context.Context, name string) (string, bool) { + if name == "prettier" { + return "/npm/prettier", true + } + return "", false + }}, + func(_ context.Context, command []string, cwd string, environment map[string]string) (int, error) { + calls = append(calls, runCall{command, cwd, environment}) + return 0, nil + }, + ) + formatted, err := service.File(context.Background(), filepath.Join(directory, "a.ts")) + if err != nil || !formatted { + t.Fatalf("File = %v, %v", formatted, err) + } + want := []runCall{{ + command: []string{"/npm/prettier", "--write", filepath.Join(directory, "a.ts")}, + cwd: directory, environment: map[string]string{"BUN_BE_BUN": "1"}, + }} + if !reflect.DeepEqual(calls, want) { + t.Fatalf("calls = %#v, want %#v", calls, want) + } +} + +func TestLinkedRuffAndUVDisable(t *testing.T) { + service := NewService( + Context{}, + Configuration{Enabled: true, Overrides: []FormatterOverride{{Key: "uv", Disabled: true}}}, + Dependencies{}, + nil, + ) + status, err := service.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, item := range status { + if item.Name == "ruff" || item.Name == "uv" { + t.Fatalf("linked formatter survived: %#v", item) + } + } +} + +// Every built-in registers under the same string a config override names, so +// disabling one by the name it is known by removes it. These three are the +// cases that used to carry a separate config key and silently survive. +func TestDisablingABuiltinByNameRemovesIt(t *testing.T) { + service := NewService( + Context{}, + Configuration{Enabled: true, Overrides: []FormatterOverride{ + {Key: "clang-format", Disabled: true}, + {Key: "air", Disabled: true}, + {Key: "uv", Disabled: true}, + }}, + Dependencies{}, + nil, + ) + status, err := service.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, item := range status { + switch item.Name { + case "clang-format", "air", "uv": + t.Errorf("%s survived being disabled", item.Name) + } + } +} + +// Every built-in's key and name agree, so an override reaches the formatter +// it names instead of registering a second entry beside it. +func TestEveryBuiltinKeyMatchesItsName(t *testing.T) { + for _, item := range Builtins(Dependencies{}) { + if item.Key != item.Name { + t.Errorf("builtin key %q does not match name %q", item.Key, item.Name) + } + } +} + +func TestEmptyCommandStillCountsAsFormatter(t *testing.T) { + extensions := []string{".x"} + command := []string{} + var calls int + service := NewService( + Context{}, + Configuration{Enabled: true, Overrides: []FormatterOverride{{ + Key: "empty", Extensions: &extensions, Command: &command, + }}}, + Dependencies{}, + func(_ context.Context, command []string, _ string, _ map[string]string) (int, error) { + calls++ + if len(command) != 0 { + t.Fatalf("command = %#v", command) + } + return 1, errors.New("missing command") + }, + ) + formatted, err := service.File(context.Background(), "a.x") + if err != nil || !formatted || calls != 1 { + t.Fatalf("File = %v, %v; calls=%d", formatted, err, calls) + } +} + +func TestFileExtension(t *testing.T) { + cases := map[string]string{ + ".bashrc": "", + "a.ts": ".ts", + "a.": ".", + "..": "", + "...": ".", + "..foo": ".foo", + ".foo.bar": ".bar", + } + for path, want := range cases { + if got := fileExtension(path); got != want { + t.Errorf("fileExtension(%q) = %q, want %q", path, got, want) + } + } +} diff --git a/internal/seniordev/format/formatter.go b/internal/seniordev/format/formatter.go new file mode 100644 index 0000000000..6407d8d30d --- /dev/null +++ b/internal/seniordev/format/formatter.go @@ -0,0 +1,300 @@ +//go:build !windows + +// Built-in formatter registry +package format + +import ( + "context" + "encoding/json" + "os" + "slices" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type Context struct { + Directory string + Worktree string +} + +type EnabledFunc func(context.Context, Context) ([]string, bool, error) + +type Info struct { + Key string + Name string + Environment map[string]string + Extensions []string + Enabled EnabledFunc +} + +// Dependencies isolates PATH, npm, filesystem, flags, and process probes. +type Dependencies struct { + Which func(string) (string, bool) + NpmWhich func(context.Context, string) (string, bool) + ExperimentalOxfmt bool +} + +func (d Dependencies) which(command string) (string, bool) { + if d.Which == nil { + return "", false + } + return d.Which(command) +} + +func (d Dependencies) npmWhich(ctx context.Context, pkg string) (string, bool) { + if d.NpmWhich == nil { + return "", false + } + return d.NpmWhich(ctx, pkg) +} + +// Builtins returns the built-in formatters. Every entry's Key, which is what +// a config override names, is also its Name, which is what it registers +// under, so an override reaches the formatter it names. The order is the +// order a file's candidates are collected in. +func Builtins(dependencies Dependencies) []Info { + pathFormatter := func(key, name string, extensions []string, args ...string) Info { + return Info{ + Key: key, Name: name, Extensions: extensions, + Enabled: func(_ context.Context, _ Context) ([]string, bool, error) { + match, ok := dependencies.which(name) + if !ok { + return nil, false, nil + } + command := []string{match} + command = append(command, args...) + command = append(command, "$FILE") + return command, true, nil + }, + } + } + bunEnvironment := map[string]string{"BUN_BE_BUN": "1"} + out := []Info{} + out = append(out, pathFormatter("gofmt", "gofmt", []string{".go"}, "-w")) + out = append(out, pathFormatter("mix", "mix", []string{".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"}, "format")) + out = append(out, Info{ + Key: "prettier", Name: "prettier", Environment: bunEnvironment, + Extensions: prettierExtensions(), + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + items := util.FindUp([]string{"package.json"}, instance.Directory, instance.Worktree) + for _, item := range items { + pkg, err := readPackageJSON(item) + if err != nil { + return nil, false, err + } + if dependencyPresent(pkg, "prettier") { + if bin, ok := dependencies.npmWhich(ctx, "prettier"); ok { + return []string{bin, "--write", "$FILE"}, true, nil + } + } + } + return nil, false, nil + }, + }) + out = append(out, Info{ + Key: "oxfmt", Name: "oxfmt", Environment: bunEnvironment, + Extensions: []string{".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"}, + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + if !dependencies.ExperimentalOxfmt { + return nil, false, nil + } + items := util.FindUp([]string{"package.json"}, instance.Directory, instance.Worktree) + for _, item := range items { + pkg, err := readPackageJSON(item) + if err != nil { + return nil, false, err + } + if dependencyPresent(pkg, "oxfmt") { + if bin, ok := dependencies.npmWhich(ctx, "oxfmt"); ok { + return []string{bin, "$FILE"}, true, nil + } + } + } + return nil, false, nil + }, + }) + out = append(out, Info{ + Key: "biome", Name: "biome", Environment: bunEnvironment, + Extensions: prettierExtensions(), + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + for _, config := range []string{"biome.json", "biome.jsonc"} { + if len(util.FindUp([]string{config}, instance.Directory, instance.Worktree)) > 0 { + if bin, ok := dependencies.npmWhich(ctx, "@biomejs/biome"); ok { + return []string{bin, "format", "--write", "$FILE"}, true, nil + } + } + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("zig", "zig", []string{".zig", ".zon"}, "fmt")) + out = append(out, Info{ + Key: "clang-format", Name: "clang-format", + Extensions: []string{".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + if len(util.FindUp([]string{".clang-format"}, instance.Directory, instance.Worktree)) == 0 { + return nil, false, nil + } + match, ok := dependencies.which("clang-format") + if !ok { + return nil, false, nil + } + return []string{match, "-i", "$FILE"}, true, nil + }, + }) + out = append(out, pathFormatter("ktlint", "ktlint", []string{".kt", ".kts"}, "-F")) + ruff := Info{ + Key: "ruff", Name: "ruff", Extensions: []string{".py", ".pyi"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + if _, ok := dependencies.which("ruff"); !ok { + return nil, false, nil + } + for _, config := range []string{"pyproject.toml", "ruff.toml", ".ruff.toml"} { + found := util.FindUp([]string{config}, instance.Directory, instance.Worktree) + if len(found) == 0 { + continue + } + if config == "pyproject.toml" { + content, err := util.ReadText(found[0]) + if err != nil { + return nil, false, err + } + if strings.Contains(content, "[tool.ruff]") { + return []string{"ruff", "format", "$FILE"}, true, nil + } + } else { + return []string{"ruff", "format", "$FILE"}, true, nil + } + } + for _, dependency := range []string{"requirements.txt", "pyproject.toml", "Pipfile"} { + found := util.FindUp([]string{dependency}, instance.Directory, instance.Worktree) + if len(found) == 0 { + continue + } + content, err := util.ReadText(found[0]) + if err != nil { + return nil, false, err + } + if strings.Contains(content, "ruff") { + return []string{"ruff", "format", "$FILE"}, true, nil + } + } + return nil, false, nil + }, + } + out = append(out, ruff) + out = append(out, Info{ + Key: "air", Name: "air", Extensions: []string{".R"}, + Enabled: func(ctx context.Context, _ Context) ([]string, bool, error) { + air, ok := dependencies.which("air") + if !ok { + return nil, false, nil + } + result, _ := util.TextProcess(ctx, []string{air, "--help"}, util.RunOptions{NoThrow: true}) + firstLine := strings.Split(result.Text, "\n")[0] + if result.Code == 0 && strings.Contains(firstLine, "R language") && strings.Contains(firstLine, "formatter") { + return []string{air, "format", "$FILE"}, true, nil + } + return nil, false, nil + }, + }) + out = append(out, Info{ + Key: "uv", Name: "uv", Extensions: []string{".py", ".pyi"}, + Enabled: func(ctx context.Context, instance Context) ([]string, bool, error) { + if _, enabled, err := ruff.Enabled(ctx, instance); err != nil || enabled { + return nil, false, err + } + uv, ok := dependencies.which("uv") + if !ok { + return nil, false, nil + } + result, _ := util.RunProcess(ctx, []string{uv, "format", "--help"}, util.RunOptions{NoThrow: true}) + if result.Code == 0 { + return []string{uv, "format", "--", "$FILE"}, true, nil + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("rubocop", "rubocop", []string{".rb", ".rake", ".gemspec", ".ru"}, "--autocorrect")) + out = append(out, pathFormatter("standardrb", "standardrb", []string{".rb", ".rake", ".gemspec", ".ru"}, "--fix")) + out = append(out, pathFormatter("htmlbeautifier", "htmlbeautifier", []string{".erb", ".html.erb"})) + out = append(out, pathFormatter("dart", "dart", []string{".dart"}, "format")) + out = append(out, Info{ + Key: "ocamlformat", Name: "ocamlformat", Extensions: []string{".ml", ".mli"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + if _, ok := dependencies.which("ocamlformat"); !ok { + return nil, false, nil + } + if len(util.FindUp([]string{".ocamlformat"}, instance.Directory, instance.Worktree)) > 0 { + return []string{"ocamlformat", "-i", "$FILE"}, true, nil + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("terraform", "terraform", []string{".tf", ".tfvars"}, "fmt")) + out = append(out, pathFormatter("latexindent", "latexindent", []string{".tex"}, "-w", "-s")) + out = append(out, pathFormatter("gleam", "gleam", []string{".gleam"}, "format")) + out = append(out, pathFormatter("shfmt", "shfmt", []string{".sh", ".bash"}, "-w")) + out = append(out, pathFormatter("nixfmt", "nixfmt", []string{".nix"})) + out = append(out, pathFormatter("rustfmt", "rustfmt", []string{".rs"})) + out = append(out, Info{ + Key: "pint", Name: "pint", Extensions: []string{".php"}, + Enabled: func(_ context.Context, instance Context) ([]string, bool, error) { + items := util.FindUp([]string{"composer.json"}, instance.Directory, instance.Worktree) + for _, item := range items { + data, err := os.ReadFile(item) + if err != nil { + return nil, false, err + } + var composer struct { + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` + } + if err := json.Unmarshal(data, &composer); err != nil { + return nil, false, err + } + if composer.Require["laravel/pint"] != "" || composer.RequireDev["laravel/pint"] != "" { + return []string{"./vendor/bin/pint", "$FILE"}, true, nil + } + } + return nil, false, nil + }, + }) + out = append(out, pathFormatter("ormolu", "ormolu", []string{".hs"}, "-i")) + out = append(out, pathFormatter("cljfmt", "cljfmt", []string{".clj", ".cljs", ".cljc", ".edn"}, "fix", "--quiet")) + out = append(out, pathFormatter("dfmt", "dfmt", []string{".d"}, "-i")) + // Sorted by key so the registration order is deterministic. + slices.SortFunc(out, func(left, right Info) int { + return strings.Compare(left.Key, right.Key) + }) + return out +} + +func prettierExtensions() []string { + return []string{ + ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", + ".html", ".htm", ".css", ".scss", ".sass", ".less", ".vue", ".svelte", + ".json", ".jsonc", ".yaml", ".yml", ".toml", ".xml", ".md", ".mdx", + ".graphql", ".gql", + } +} + +type packageJSON struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +func readPackageJSON(path string) (packageJSON, error) { + data, err := os.ReadFile(path) + if err != nil { + return packageJSON{}, err + } + var pkg packageJSON + err = json.Unmarshal(data, &pkg) + return pkg, err +} + +func dependencyPresent(pkg packageJSON, name string) bool { + return pkg.Dependencies[name] != "" || pkg.DevDependencies[name] != "" +} diff --git a/internal/seniordev/guide_test.go b/internal/seniordev/guide_test.go new file mode 100644 index 0000000000..3cf4a826b8 --- /dev/null +++ b/internal/seniordev/guide_test.go @@ -0,0 +1,41 @@ +//go:build !windows + +package seniordev + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// SENIOR-DEV'S GUIDE CLAIMS THE HARD CODING WORK, IN FEWER BYTES THAN ITS CAP. +// The guide is what the chat's model reads before it reaches for senior-dev, +// and the paragraph it is printed under tells that model to prefer a program +// for whatever its guide claims (internal/session/delegate_door.go). So the +// claim is pinned here: an issue in a mature codebase, the work senior-dev +// was going unused on while the guide priced it at "an hour", and the brief +// carrying the issue in full. And it rides every request of every turn, so it +// is held under delegate.GuideMax. +func TestTheGuideClaimsComplexCodingWorkWithinItsBytes(t *testing.T) { + guide := strings.TrimSpace(Program.Guide) + if len(guide) > delegate.GuideMax { + t.Fatalf("the guide is %d bytes, over delegate.GuideMax's %d: %q", len(guide), delegate.GuideMax, guide) + } + for _, want := range []string{ + "complex, multi-part coding work", + "fixing an issue in a mature codebase whose cause spans files", + "the issue or ask in full", + "what done means and how to check it", + "what must not change", + } { + if !strings.Contains(guide, want) { + t.Errorf("the guide does not say %q: %q", want, guide) + } + } + // AND IT SETS NO PRICE THAT KEEPS IT ON THE SHELF. "worth an hour" read as + // a bar the chat's model almost never judged a piece of work to clear. + if strings.Contains(guide, "worth an hour") { + t.Errorf("the guide still prices senior-dev at an hour: %q", guide) + } +} diff --git a/internal/seniordev/id/id.go b/internal/seniordev/id/id.go new file mode 100644 index 0000000000..4651512813 --- /dev/null +++ b/internal/seniordev/id/id.go @@ -0,0 +1,197 @@ +//go:build !windows + +// Package id generates prefixed, time-ordered identifiers. IDs encode a +// millisecond timestamp and per-timestamp counter in six big-endian bytes, +// followed by 14 modulo-biased base62 characters. +package id + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "strings" + "sync" + "time" +) + +const ( + encodedLength = 26 + randomLength = encodedLength - 12 + base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +var prefixes = map[string]string{ + "event": "evt", + "session": "ses", + "message": "msg", + "permission": "per", + "question": "que", + "user": "usr", + "part": "prt", + "pty": "pty", + "tool": "tool", + "workspace": "wrk", + "entry": "ent", + "account": "act", +} + +// Direction selects the timestamp byte ordering used by Create. +type Direction string + +const ( + AscendingDirection Direction = "ascending" + DescendingDirection Direction = "descending" +) + +// Generator owns the monotonic counter and its injectable side effects. +type Generator struct { + mu sync.Mutex + lastTimestamp int64 + counter uint64 + now func() int64 + random io.Reader +} + +// NewGenerator constructs an independent ID generator. Nil inputs use the +// wall clock and crypto/rand. +func NewGenerator(now func() int64, random io.Reader) *Generator { + if now == nil { + now = func() int64 { return time.Now().UnixMilli() } + } + if random == nil { + random = rand.Reader + } + return &Generator{now: now, random: random} +} + +var defaultGenerator = NewGenerator(nil, nil) + +// Prefix returns the short prefix for an entity kind. +func Prefix(kind string) (string, bool) { + value, ok := prefixes[kind] + return value, ok +} + +// SchemaAccepts reports whether value carries the prefix for kind. +func SchemaAccepts(kind, value string) bool { + prefix, ok := Prefix(kind) + return ok && strings.HasPrefix(value, prefix) +} + +// Ascending returns an ascending ID for kind. An absent or empty given value +// generates a new ID; any other value is prefix-checked and returned verbatim. +func Ascending(kind string, given ...string) (string, error) { + return defaultGenerator.generateKind(kind, AscendingDirection, given...) +} + +// Descending returns a descending ID for kind; given is handled as in Ascending. +func Descending(kind string, given ...string) (string, error) { + return defaultGenerator.generateKind(kind, DescendingDirection, given...) +} + +// Ascending generates with this Generator. +func (g *Generator) Ascending(kind string, given ...string) (string, error) { + return g.generateKind(kind, AscendingDirection, given...) +} + +// Descending generates with this Generator. +func (g *Generator) Descending(kind string, given ...string) (string, error) { + return g.generateKind(kind, DescendingDirection, given...) +} + +func (g *Generator) generateKind(kind string, direction Direction, given ...string) (string, error) { + prefix, ok := Prefix(kind) + if !ok { + return "", fmt.Errorf("unknown ID prefix %s", kind) + } + if len(given) == 0 || given[0] == "" { + return g.Create(prefix, direction) + } + if !strings.HasPrefix(given[0], prefix) { + return "", fmt.Errorf("ID %s does not start with %s", given[0], prefix) + } + return given[0], nil +} + +// Create uses the process-global generator and an optional explicit timestamp. +func Create(prefix string, direction Direction, timestamp ...int64) (string, error) { + return defaultGenerator.Create(prefix, direction, timestamp...) +} + +// Create emits an ID from this Generator. State resets only when the current +// timestamp differs from the previous one; moving the clock backwards is +// accepted. +func (g *Generator) Create(prefix string, direction Direction, timestamp ...int64) (string, error) { + current := g.now() + if len(timestamp) > 0 { + current = timestamp[0] + } + + g.mu.Lock() + defer g.mu.Unlock() + if current != g.lastTimestamp { + g.lastTimestamp = current + g.counter = 0 + } + g.counter++ + + // Only the low 48 bits are encoded. Converting to uint64 before the shifts + // gives well-defined two's-complement bits for negative timestamps and for + // the inverted descending value. + now := uint64(current)*0x1000 + g.counter + if direction == DescendingDirection { + now = ^now + } + var timeBytes [6]byte + for i := range 6 { + timeBytes[i] = byte(now >> uint(40-8*i)) + } + random, err := g.randomBase62(randomLength) + if err != nil { + return "", err + } + return prefix + "_" + hex.EncodeToString(timeBytes[:]) + random, nil +} + +func (g *Generator) randomBase62(length int) (string, error) { + bytes := make([]byte, length) + if _, err := io.ReadFull(g.random, bytes); err != nil { + return "", err + } + out := make([]byte, length) + for i, value := range bytes { + out[i] = base62[int(value)%len(base62)] + } + return string(out), nil +} + +// Timestamp extracts the millisecond timestamp from an ascending ID. It does +// not invert descending IDs. +func Timestamp(value string) (int64, error) { + prefix := strings.Split(value, "_")[0] + start := len(prefix) + 1 + end := start + 12 + if start > len(value) { + start = len(value) + } + if end > len(value) { + end = len(value) + } + hexPart := value[start:end] + if hexPart == "" { + return 0, fmt.Errorf("invalid ID %q", value) + } + bytes, err := hex.DecodeString(hexPart) + if err != nil || len(bytes) == 0 { + if err == nil { + err = fmt.Errorf("empty timestamp") + } + return 0, fmt.Errorf("invalid ID %q: %w", value, err) + } + var encoded uint64 + for _, value := range bytes { + encoded = encoded<<8 | uint64(value) + } + return int64(encoded / 0x1000), nil +} diff --git a/internal/seniordev/id/id_test.go b/internal/seniordev/id/id_test.go new file mode 100644 index 0000000000..90573f80ad --- /dev/null +++ b/internal/seniordev/id/id_test.go @@ -0,0 +1,42 @@ +//go:build !windows + +package id + +import ( + "bytes" + "errors" + "testing" +) + +func TestCounterSpillsIntoTimestampBits(t *testing.T) { + entropy := bytes.NewReader(make([]byte, randomLength*4097)) + generator := NewGenerator(func() int64 { return 10 }, entropy) + var value string + for range 4097 { + var err error + value, err = generator.Create("evt", AscendingDirection, 10) + if err != nil { + t.Fatal(err) + } + } + got, err := Timestamp(value) + if err != nil { + t.Fatal(err) + } + if got != 11 { + t.Fatalf("timestamp after counter spill = %d, want 11", got) + } +} + +func TestRandomFailurePropagates(t *testing.T) { + generator := NewGenerator(func() int64 { return 1 }, failingReader{}) + if _, err := generator.Create("evt", AscendingDirection); !errors.Is(err, errEntropy) { + t.Fatalf("error = %v", err) + } +} + +var errEntropy = errors.New("entropy failed") + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errEntropy } diff --git a/internal/seniordev/jsonutil/jsonutil.go b/internal/seniordev/jsonutil/jsonutil.go new file mode 100644 index 0000000000..5085b3aba4 --- /dev/null +++ b/internal/seniordev/jsonutil/jsonutil.go @@ -0,0 +1,35 @@ +//go:build !windows + +// Package jsonutil wraps encoding/json for the two encodings senior-dev uses +// everywhere: compact and two-space indented, both without HTML escaping so +// that `<`, `>` and `&` inside model-visible text stay readable. +package jsonutil + +import ( + "bytes" + "encoding/json" +) + +// Marshal encodes v as compact JSON without escaping HTML characters. +func Marshal(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} + +// MarshalIndent encodes v as two-space indented JSON without escaping HTML +// characters. +func MarshalIndent(v any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return nil, err + } + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} diff --git a/internal/seniordev/modelsdev/models.go b/internal/seniordev/modelsdev/models.go new file mode 100644 index 0000000000..d85501a075 --- /dev/null +++ b/internal/seniordev/modelsdev/models.go @@ -0,0 +1,444 @@ +//go:build !windows + +// Package modelsdev loads the models.dev provider/model catalog used to price +// and describe models. The catalog is runtime data: this package does not +// carry a baked model or pricing table. +package modelsdev + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" +) + +const ( + DefaultSource = "https://models.dev" + cacheTTL = 5 * time.Minute + refreshEvery = 60 * time.Minute + fetchTimeout = 10 * time.Second + lockTimeout = 5 * time.Minute + lockPoll = 100 * time.Millisecond +) + +// Cost is the models.dev cost block. Pointer fields preserve the distinction +// between an omitted optional rate and an explicit zero. +type Cost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead *float64 `json:"cache_read,omitempty"` + CacheWrite *float64 `json:"cache_write,omitempty"` + ContextOver200K *Cost `json:"context_over_200k,omitempty"` +} + +type Limit struct { + Context float64 `json:"context"` + Input *float64 `json:"input,omitempty"` + Output float64 `json:"output"` +} + +type Modalities struct { + Input []string `json:"input"` + Output []string `json:"output"` +} + +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Attachment *bool `json:"attachment,omitempty"` + Reasoning *bool `json:"reasoning,omitempty"` + Temperature *bool `json:"temperature,omitempty"` + ToolCall *bool `json:"tool_call,omitempty"` + Modalities *Modalities `json:"modalities,omitempty"` + Cost *Cost `json:"cost,omitempty"` + Limit Limit `json:"limit"` +} + +type Provider struct { + ID string `json:"id"` + Name string `json:"name"` + Models map[string]Model `json:"models"` +} + +type Catalog map[string]Provider + +// Resolve addresses the provider and model maps by their catalog keys. +// Model.id is API metadata, not a lookup alias. +func (catalog Catalog) Resolve(providerID, modelID string) (calc.Model, error) { + provider, ok := catalog[providerID] + if !ok { + return calc.Model{}, fmt.Errorf("models.dev: provider %q not found", providerID) + } + model, ok := provider.Models[modelID] + if !ok { + return calc.Model{}, fmt.Errorf("models.dev: model %q not found for provider %q", modelID, providerID) + } + return calc.Model{Cost: projectCost(model.Cost), Limit: calc.ModelLimit{ + Context: model.Limit.Context, + Input: model.Limit.Input, + Output: model.Limit.Output, + }, Capabilities: projectCapabilities(model)}, nil +} + +func projectCapabilities(model Model) calc.ModelCapabilities { + capabilities := calc.ModelCapabilities{ + Attachment: boolOr(model.Attachment, false), + Reasoning: boolOr(model.Reasoning, false), + // An absent temperature flag is support, not refusal: the request-side + // gate drops a configured temperature only on a declared false. + Temperature: boolOr(model.Temperature, true), + ToolCall: boolOr(model.ToolCall, true), + Input: modalityMap(nil), + Output: modalityMap(nil), + } + if model.Modalities != nil { + capabilities.Input = modalityMap(model.Modalities.Input) + capabilities.Output = modalityMap(model.Modalities.Output) + } + return capabilities +} + +func boolOr(value *bool, fallback bool) bool { + if value == nil { + return fallback + } + return *value +} + +func modalityMap(values []string) map[string]bool { + result := map[string]bool{ + "text": false, "audio": false, "image": false, "video": false, "pdf": false, + } + for _, value := range values { + if _, ok := result[value]; ok { + result[value] = true + } + } + return result +} + +func projectCost(cost *Cost) *calc.ModelCost { + result := &calc.ModelCost{Cache: &calc.CacheCost{}} + if cost == nil { + return result + } + result.Input = cost.Input + result.Output = cost.Output + if cost.CacheRead != nil { + result.Cache.Read = *cost.CacheRead + } + if cost.CacheWrite != nil { + result.Cache.Write = *cost.CacheWrite + } + if cost.ContextOver200K != nil { + over := cost.ContextOver200K + result.ExperimentalOver200K = &calc.Over200KCost{ + Cache: &calc.CacheCost{}, Input: over.Input, Output: over.Output, + } + if over.CacheRead != nil { + result.ExperimentalOver200K.Cache.Read = *over.CacheRead + } + if over.CacheWrite != nil { + result.ExperimentalOver200K.Cache.Write = *over.CacheWrite + } + } + return result +} + +type Options struct { + Source string + CatalogPath string + CacheDir string + DisableFetch bool + HTTPClient *http.Client + Version string + Now func() time.Time + Sleep func(context.Context, time.Duration) error + LockTimeout time.Duration + LockPoll time.Duration +} + +type Client struct { + options Options + cachePath string + + mu sync.Mutex + loaded bool + catalog Catalog + loadErr error +} + +// New constructs a catalog client. Empty options select models.dev and the +// user cache directory. +func New(options Options) (*Client, error) { + if options.Source == "" { + options.Source = DefaultSource + } + options.Source = strings.TrimRight(options.Source, "/") + if options.CacheDir == "" { + base, err := os.UserCacheDir() + if err != nil { + return nil, fmt.Errorf("models.dev cache directory: %w", err) + } + options.CacheDir = filepath.Join(base, "senior-dev") + } + if options.HTTPClient == nil { + options.HTTPClient = http.DefaultClient + } + if options.Now == nil { + options.Now = time.Now + } + if options.Sleep == nil { + options.Sleep = sleepContext + } + if options.LockTimeout <= 0 { + options.LockTimeout = lockTimeout + } + if options.LockPoll <= 0 { + options.LockPoll = lockPoll + } + name := "models.json" + if options.Source != DefaultSource { + digest := sha1.Sum([]byte(options.Source)) + name = "models-" + hex.EncodeToString(digest[:]) + ".json" + } + return &Client{options: options, cachePath: filepath.Join(options.CacheDir, name)}, nil +} + +// NewFromEnv reads the catalog settings from the environment. Only "true" and +// "1" (case-insensitive) enable SENIOR_DEV_DISABLE_MODELS_FETCH. +func NewFromEnv(version string) (*Client, error) { + return New(Options{ + Source: os.Getenv("SENIOR_DEV_MODELS_URL"), + CatalogPath: os.Getenv("SENIOR_DEV_MODELS_PATH"), + DisableFetch: truthy(os.Getenv("SENIOR_DEV_DISABLE_MODELS_FETCH")), + Version: version, + }) +} + +func truthy(value string) bool { + value = strings.ToLower(value) + return value == "true" || value == "1" +} + +// CachePath is exposed for diagnostics and tests. +func (client *Client) CachePath() string { return client.cachePath } + +// Get is memoized for the process lifetime. Population is disk -> (no bundled +// snapshot) -> disabled-fetch empty catalog -> network. +func (client *Client) Get(ctx context.Context) (Catalog, error) { + client.mu.Lock() + defer client.mu.Unlock() + if client.loaded { + return client.catalog, client.loadErr + } + client.catalog, client.loadErr = client.populate(ctx) + client.loaded = true + return client.catalog, client.loadErr +} + +func (client *Client) populate(ctx context.Context) (Catalog, error) { + path := client.cachePath + if client.options.CatalogPath != "" { + path = client.options.CatalogPath + } + if catalog, err := readCatalog(path); err == nil && catalog != nil { + return catalog, nil + } + // No catalog is bundled: with fetching disabled the catalog is empty. + if client.options.DisableFetch { + return Catalog{}, nil + } + var catalog Catalog + err := client.withFileLock(ctx, func() error { + raw, err := client.fetch(ctx) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &catalog); err != nil { + return fmt.Errorf("models.dev decode: %w", err) + } + return writeCache(client.cachePath, raw) + }) + return catalog, err +} + +// Refresh refetches a stale catalog: freshness is checked on the generated +// cache path (not SENIOR_DEV_MODELS_PATH), checked again under the cross-process +// lock, and fetch errors leave the memoized catalog untouched. +func (client *Client) Refresh(ctx context.Context, force bool) error { + if !force && client.fresh() { + return nil + } + var catalog Catalog + err := client.withFileLock(ctx, func() error { + if !force && client.fresh() { + return nil + } + raw, err := client.fetch(ctx) + if err != nil { + return err + } + if err := json.Unmarshal(raw, &catalog); err != nil { + return fmt.Errorf("models.dev decode: %w", err) + } + return writeCache(client.cachePath, raw) + }) + if err != nil || catalog == nil { + return err + } + client.mu.Lock() + client.catalog = catalog + client.loadErr = nil + client.loaded = true + client.mu.Unlock() + return nil +} + +// StartRefresh performs the startup refresh and repeats one hour after each +// completion. +func (client *Client) StartRefresh(ctx context.Context, report func(error)) { + if client.options.DisableFetch { + return + } + go func() { + for { + if err := client.Refresh(ctx, false); err != nil && report != nil { + report(err) + } + timer := time.NewTimer(refreshEvery) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } + }() +} + +func (client *Client) fresh() bool { + info, err := os.Stat(client.cachePath) + if err != nil { + return false + } + return client.options.Now().Sub(info.ModTime()) < cacheTTL +} + +func readCatalog(path string) (Catalog, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var catalog Catalog + if err := json.Unmarshal(raw, &catalog); err != nil { + return nil, err + } + return catalog, nil +} + +func writeCache(path string, raw []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, raw, 0o644) +} + +func (client *Client) fetch(ctx context.Context) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + var last error + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + if err := client.options.Sleep(ctx, time.Duration(1<<(attempt-1))*200*time.Millisecond); err != nil { + return nil, err + } + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, client.options.Source+"/api.json", nil) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", "senior-dev/"+client.options.Version) + response, err := client.options.HTTPClient.Do(request) + if err != nil { + last = err + continue + } + raw, readErr := io.ReadAll(response.Body) + closeErr := response.Body.Close() + if readErr != nil { + last = readErr + continue + } + if closeErr != nil { + last = closeErr + continue + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + last = fmt.Errorf("models.dev: GET %s: %s", request.URL, response.Status) + if response.StatusCode != http.StatusRequestTimeout && response.StatusCode != http.StatusTooManyRequests && response.StatusCode < 500 { + return nil, last + } + continue + } + return raw, nil + } + if last == nil { + last = errors.New("models.dev: fetch failed") + } + return nil, last +} + +func (client *Client) withFileLock(ctx context.Context, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(client.cachePath), 0o755); err != nil { + return err + } + lock, err := os.OpenFile(client.cachePath+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer lock.Close() + timer := time.NewTimer(client.options.LockTimeout) + defer timer.Stop() + for { + err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, syscall.EWOULDBLOCK) && !errors.Is(err, syscall.EAGAIN) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return fmt.Errorf("Timed out waiting for lock: models-dev:%s", client.cachePath) + case <-time.After(client.options.LockPoll): + } + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck + return fn() +} + +func sleepContext(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/seniordev/modelsdev/models_test.go b/internal/seniordev/modelsdev/models_test.go new file mode 100644 index 0000000000..aa6f23fe50 --- /dev/null +++ b/internal/seniordev/modelsdev/models_test.go @@ -0,0 +1,297 @@ +//go:build !windows + +package modelsdev + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (function roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func fixture(t *testing.T) []byte { + t.Helper() + raw, err := os.ReadFile("testdata/catalog.json") + if err != nil { + t.Fatal(err) + } + return raw +} + +func response(request *http.Request, status int, body []byte) *http.Response { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Body: io.NopCloser(strings.NewReader(string(body))), + Request: request, + Header: make(http.Header), + } +} + +func TestCatalogProjectsCostsAndLimits(t *testing.T) { + client, err := New(Options{ + CatalogPath: "testdata/catalog.json", CacheDir: t.TempDir(), DisableFetch: true, + }) + if err != nil { + t.Fatal(err) + } + catalog, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + model, err := catalog.Resolve("openrouter", "fixture/vendor-model") + if err != nil { + t.Fatal(err) + } + if model.Cost == nil || model.Cost.Input != 1.25 || model.Cost.Output != 4.5 || + model.Cost.Cache == nil || model.Cost.Cache.Read != 0.125 || model.Cost.Cache.Write != 1.5 || + model.Cost.ExperimentalOver200K == nil || model.Cost.ExperimentalOver200K.Input != 2.5 || + model.Limit.Context != 240_000 || model.Limit.Input == nil || *model.Limit.Input != 220_000 || + model.Limit.Output != 12_000 { + t.Fatalf("projected model = %#v", model) + } + if !model.Capabilities.Attachment || !model.Capabilities.Reasoning || + !model.Capabilities.Temperature || !model.Capabilities.ToolCall || + !model.Capabilities.Input["text"] || !model.Capabilities.Input["image"] || + model.Capabilities.Input["audio"] || !model.Capabilities.Output["text"] || + model.Capabilities.Output["image"] { + t.Fatalf("projected capabilities = %#v", model.Capabilities) + } + withoutCost, err := catalog.Resolve("openrouter", "fixture/no-cost") + if err != nil { + t.Fatal(err) + } + if withoutCost.Cost == nil || withoutCost.Cost.Input != 0 || withoutCost.Cost.Output != 0 { + t.Fatalf("missing cost projection = %#v", withoutCost.Cost) + } + if withoutCost.Capabilities.Attachment || withoutCost.Capabilities.Reasoning || + !withoutCost.Capabilities.Temperature || !withoutCost.Capabilities.ToolCall || + withoutCost.Capabilities.Input["text"] || withoutCost.Capabilities.Output["text"] { + t.Fatalf("missing modalities/default projection = %#v", withoutCost.Capabilities) + } + if _, err := catalog.Resolve("openrouter", "fixture/unknown"); err == nil { + t.Fatal("unknown model unexpectedly resolved") + } + // An absent temperature flag must read as support: the request-side gate + // drops a configured temperature on false, and many models.dev entries + // omit the flag entirely. + defaults := projectCapabilities(Model{}) + if defaults.Attachment || defaults.Reasoning || !defaults.Temperature || !defaults.ToolCall || + defaults.Input["text"] || defaults.Output["text"] { + t.Fatalf("absent capability defaults = %#v", defaults) + } +} + +func TestGetFetchesOnceAndReusesFreshDiskCache(t *testing.T) { + var mu sync.Mutex + requests := 0 + transport := roundTripFunc(func(request *http.Request) (*http.Response, error) { + mu.Lock() + requests++ + mu.Unlock() + if request.URL.String() != "https://catalog.example/api.json" { + t.Fatalf("request URL = %s", request.URL) + } + if request.Header.Get("User-Agent") != "senior-dev/test-version" { + t.Fatalf("User-Agent = %q", request.Header.Get("User-Agent")) + } + return response(request, http.StatusOK, fixture(t)), nil + }) + options := Options{ + Source: "https://catalog.example", CacheDir: t.TempDir(), + HTTPClient: &http.Client{Transport: transport}, Version: "test-version", + Sleep: func(context.Context, time.Duration) error { return nil }, + } + first, err := New(options) + if err != nil { + t.Fatal(err) + } + if first.options.LockTimeout != 5*time.Minute { + t.Fatalf("default lock timeout = %v, want 5m", first.options.LockTimeout) + } + if _, err := first.Get(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := first.Get(context.Background()); err != nil { + t.Fatal(err) + } + second, err := New(options) + if err != nil { + t.Fatal(err) + } + if _, err := second.Get(context.Background()); err != nil { + t.Fatal(err) + } + if err := second.Refresh(context.Background(), false); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if requests != 1 { + t.Fatalf("catalog requests = %d, want 1", requests) + } + if filepath.Base(first.CachePath()) != "models-871608c17971dbac7e10503763c16cb91f1a52f7.json" { + t.Fatalf("custom-source cache path = %s", first.CachePath()) + } +} + +func TestColdUnreachableCatalogReturnsErrorAndDisabledFetchReturnsEmpty(t *testing.T) { + requests := 0 + client, err := New(Options{ + Source: "https://unreachable.example", CacheDir: t.TempDir(), + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + requests++ + return nil, errors.New("offline") + })}, + Sleep: func(context.Context, time.Duration) error { return nil }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := client.Get(context.Background()); err == nil || !strings.Contains(err.Error(), "offline") { + t.Fatalf("cold unreachable error = %v", err) + } + if requests != 3 { + t.Fatalf("transient fetch attempts = %d, want 3", requests) + } + + disabled, err := New(Options{ + CacheDir: t.TempDir(), DisableFetch: true, + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("disabled catalog performed network I/O") + return nil, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + catalog, err := disabled.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(catalog) != 0 { + t.Fatalf("disabled catalog = %#v", catalog) + } + if _, err := catalog.Resolve("openrouter", "fixture/vendor-model"); err == nil { + t.Fatal("model resolved from disabled empty catalog") + } +} + +func TestStaleDiskCatalogWinsOverUnreachableNetwork(t *testing.T) { + cacheDir := t.TempDir() + cachePath := filepath.Join(cacheDir, "models.json") + if err := os.WriteFile(cachePath, fixture(t), 0o644); err != nil { + t.Fatal(err) + } + stale := time.Now().Add(-24 * time.Hour) + if err := os.Chtimes(cachePath, stale, stale); err != nil { + t.Fatal(err) + } + client, err := New(Options{ + CacheDir: cacheDir, + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("initial Get fetched instead of using the stale disk catalog") + return nil, errors.New("offline") + })}, + }) + if err != nil { + t.Fatal(err) + } + catalog, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, err := catalog.Resolve("openrouter", "fixture/vendor-model"); err != nil { + t.Fatal(err) + } +} + +func TestCatalogLockHonorsCancellationAndTimeout(t *testing.T) { + cacheDir := t.TempDir() + client, err := New(Options{ + CacheDir: cacheDir, LockTimeout: 30 * time.Millisecond, LockPoll: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(client.CachePath()), 0o755); err != nil { + t.Fatal(err) + } + lock, err := os.OpenFile(client.CachePath()+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatal(err) + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := client.withFileLock(ctx, func() error { return nil }); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled lock error = %v", err) + } + started := time.Now() + err = client.withFileLock(context.Background(), func() error { return nil }) + if err == nil || !strings.Contains(err.Error(), "Timed out waiting for lock: models-dev:") { + t.Fatalf("timeout error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("short test timeout took %v", elapsed) + } +} + +func TestRefreshLockTimeoutKeepsMemoizedCatalog(t *testing.T) { + client, err := New(Options{ + CatalogPath: "testdata/catalog.json", CacheDir: t.TempDir(), + LockTimeout: 20 * time.Millisecond, LockPoll: time.Millisecond, + HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("timed-out refresh reached the network") + return nil, nil + })}, + }) + if err != nil { + t.Fatal(err) + } + before, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + lock, err := os.OpenFile(client.CachePath()+".lock", os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatal(err) + } + defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck + if err := client.Refresh(context.Background(), true); err == nil || + !strings.Contains(err.Error(), "Timed out waiting for lock") { + t.Fatalf("refresh timeout error = %v", err) + } + after, err := client.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(after) != len(before) { + t.Fatalf("catalog size after timeout = %d, want %d", len(after), len(before)) + } + if _, err := after.Resolve("openrouter", "fixture/vendor-model"); err != nil { + t.Fatalf("memoized catalog lost after refresh timeout: %v", err) + } +} diff --git a/internal/seniordev/modelsdev/testdata/catalog.json b/internal/seniordev/modelsdev/testdata/catalog.json new file mode 100644 index 0000000000..2ac056dba3 --- /dev/null +++ b/internal/seniordev/modelsdev/testdata/catalog.json @@ -0,0 +1,53 @@ +{ + "openrouter": { + "id": "openrouter", + "name": "OpenRouter", + "env": ["OPENROUTER_API_KEY"], + "models": { + "fixture/vendor-model": { + "id": "fixture/vendor-model", + "name": "Fixture Vendor Model", + "family": "fixture", + "release_date": "2026-01-01", + "attachment": true, + "reasoning": true, + "temperature": true, + "tool_call": true, + "cost": { + "input": 1.25, + "output": 4.5, + "cache_read": 0.125, + "cache_write": 1.5, + "context_over_200k": { + "input": 2.5, + "output": 9, + "cache_read": 0.25, + "cache_write": 3 + } + }, + "limit": { + "context": 240000, + "input": 220000, + "output": 12000 + }, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + }, + "fixture/no-cost": { + "id": "fixture/no-cost", + "name": "Fixture Model Without Cost", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": false, + "temperature": true, + "tool_call": true, + "limit": { + "context": 64000, + "output": 4096 + } + } + } + } +} diff --git a/internal/seniordev/netpolicy/blackhole.go b/internal/seniordev/netpolicy/blackhole.go new file mode 100644 index 0000000000..947e177e10 --- /dev/null +++ b/internal/seniordev/netpolicy/blackhole.go @@ -0,0 +1,128 @@ +//go:build !windows + +package netpolicy + +import ( + "errors" + "fmt" + "net" + "sync" + "time" +) + +const noProxyHosts = "localhost,127.0.0.1,::1" + +// ShellProxyEnv returns the environment entries that gate proxy-honoring +// network clients (curl, wget, pip, npm, git-over-HTTPS) in shell children, +// or nil when the policy leaves bash open. Entries are meant to be appended +// AFTER os.Environ(): exec dedup is last-entry-wins, so they override any +// proxy the parent environment carries. +// +// The proxy address is a local black-hole listener that answers every +// connection with an explicit 403 naming the policy, so a blocked command +// fails with a legible, non-retryable error instead of a hang. If the +// listener cannot be (re)established the entries point at 127.0.0.1:1 +// instead — connection refused, still fail-closed. +func ShellProxyEnv(p Policy) []string { + if !p.Restricted() { + return nil + } + address, err := BlackholeAddr() + if err != nil { + address = "127.0.0.1:1" + } + proxy := "http://" + address + return []string{ + "HTTP_PROXY=" + proxy, + "HTTPS_PROXY=" + proxy, + "http_proxy=" + proxy, + "https_proxy=" + proxy, + "NO_PROXY=" + noProxyHosts, + "no_proxy=" + noProxyHosts, + } +} + +var blackhole struct { + mu sync.Mutex + address string + listener net.Listener +} + +// BlackholeAddr returns the address of the per-process black-hole listener, +// starting or restarting it as needed. Callers invoke this once per shell +// spawn, so a listener that died costs at most one command of ECONNREFUSED +// (still fail-closed) before the next spawn restores the legible 403. +func BlackholeAddr() (string, error) { + blackhole.mu.Lock() + defer blackhole.mu.Unlock() + if blackhole.address != "" && blackholeAlive(blackhole.address) { + return blackhole.address, nil + } + // Close a listener that failed its health check before replacing it, so + // a spurious dial timeout cannot leak sockets and serving goroutines. + if blackhole.listener != nil { + _ = blackhole.listener.Close() + blackhole.listener = nil + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + blackhole.address = "" + return "", err + } + blackhole.address = listener.Addr().String() + blackhole.listener = listener + go serveBlackhole(listener) + return blackhole.address, nil +} + +func blackholeAlive(address string) bool { + connection, err := net.DialTimeout("tcp", address, 250*time.Millisecond) + if err != nil { + return false + } + _ = connection.Close() + return true +} + +func serveBlackhole(listener net.Listener) { + body := "[network-policy] network access is restricted for this run (" + EnvMode + "). " + + "This proxy rejects all traffic. Do not retry, and do not attempt the same " + + "access through other commands - work from local repository content instead.\n" + response := fmt.Sprintf( + "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", + len(body), body, + ) + // Closing the listener on exit is load-bearing: the dial-based health + // check in blackholeAlive succeeds against any open listen socket (the + // kernel completes handshakes from the backlog even with no accept loop), + // so a listener abandoned open would pass health checks forever while + // hanging every proxied client instead of serving the fail-fast 403. + defer listener.Close() + backoff := 5 * time.Millisecond + for { + connection, err := listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + // Transient pressure (EMFILE and friends): back off and keep + // serving rather than dying under exactly the load that most + // needs the legible refusal. Mirrors net/http Server.Serve. + time.Sleep(backoff) + if backoff < time.Second { + backoff *= 2 + } + continue + } + backoff = 5 * time.Millisecond + go func(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(2 * time.Second)) + // Absorb the request (or CONNECT) line so clients that wait to + // finish writing before reading do not see a send error. + buffer := make([]byte, 1024) + _, _ = c.Read(buffer) + _, _ = c.Write([]byte(response)) + }(connection) + } +} diff --git a/internal/seniordev/netpolicy/blackhole_test.go b/internal/seniordev/netpolicy/blackhole_test.go new file mode 100644 index 0000000000..4798e7d49d --- /dev/null +++ b/internal/seniordev/netpolicy/blackhole_test.go @@ -0,0 +1,79 @@ +//go:build !windows + +package netpolicy + +import ( + "bufio" + "net" + "net/http" + "strings" + "testing" + "time" +) + +func TestBlackholeAnswersWithPolicy403(t *testing.T) { + address, err := BlackholeAddr() + if err != nil { + t.Skipf("sandbox blocks loopback listeners: %v", err) + } + connection, err := net.DialTimeout("tcp", address, time.Second) + if err != nil { + t.Fatalf("dial blackhole: %v", err) + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := connection.Write([]byte("GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")); err != nil { + t.Fatalf("write request: %v", err) + } + response, err := http.ReadResponse(bufio.NewReader(connection), nil) + if err != nil { + t.Fatalf("read response: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("status = %d, want 403", response.StatusCode) + } + body := make([]byte, 512) + n, _ := response.Body.Read(body) + if !strings.Contains(string(body[:n]), "[network-policy]") { + t.Fatalf("body %q missing policy marker", body[:n]) + } +} + +func TestBlackholeAddrIsStableWhileAlive(t *testing.T) { + first, err := BlackholeAddr() + if err != nil { + t.Skipf("sandbox blocks loopback listeners: %v", err) + } + second, err := BlackholeAddr() + if err != nil { + t.Fatalf("second BlackholeAddr: %v", err) + } + if first != second { + t.Fatalf("address changed while alive: %q then %q", first, second) + } +} + +func TestShellProxyEnv(t *testing.T) { + if entries := ShellProxyEnv(Policy{Mode: ModeAllow}); entries != nil { + t.Fatalf("allow mode should inject nothing, got %v", entries) + } + + entries := ShellProxyEnv(Policy{Mode: ModeOff}) + if len(entries) != 6 { + t.Fatalf("off mode entries = %v, want 6", entries) + } + for _, want := range []string{"HTTP_PROXY=http://127.0.0.1:", "HTTPS_PROXY=http://127.0.0.1:", "http_proxy=", "https_proxy=", "NO_PROXY=localhost,127.0.0.1,::1", "no_proxy="} { + found := false + for _, entry := range entries { + if strings.HasPrefix(entry, strings.SplitN(want, "=", 2)[0]+"=") && strings.Contains(entry, strings.SplitN(want, "=", 2)[1]) { + found = true + break + } + } + if !found { + t.Fatalf("entries %v missing %q", entries, want) + } + } + +} diff --git a/internal/seniordev/netpolicy/netpolicy.go b/internal/seniordev/netpolicy/netpolicy.go new file mode 100644 index 0000000000..854d99831e --- /dev/null +++ b/internal/seniordev/netpolicy/netpolicy.go @@ -0,0 +1,166 @@ +//go:build !windows + +// Package netpolicy makes senior-dev aware of runs where agent-initiated network +// access is unavailable, so agents stop wasting cycles attempting it. It +// governs the builtin web tools (webfetch, websearch) and the environment +// handed to bash children. The model plane (the model API codeaf serves the +// run) is deliberately outside its scope: that traffic is senior-dev's own +// road to a model, not agent-initiated, and a run cannot function without it. +// +// The policy is read from the environment, following the pipeline's existing +// SENIOR_DEV_* precedent: +// +// SENIOR_DEV_NET=allow current behavior (default when unset) +// SENIOR_DEV_NET=off agent-initiated egress is unavailable for this run +// +// A value of SENIOR_DEV_NET that parses to neither fails CLOSED to off: a typo in +// a flag that exists to forbid network access must not silently grant it. The +// parse problem is preserved on the Policy so callers can surface it; a run +// refuses to start on it, so a run is never silently degraded by a typo either. +// +// Containment is not this package's job - that belongs to the environment the +// run executes in (for example a sandbox that only lets the model API +// through). What this package delivers under off is legibility +// and economy: +// the web tools disappear from the model's tool list, in-process HTTP fails +// instantly with an explicit no-retry policy error instead of a sandbox +// timeout, and proxy-honoring bash clients (curl, wget, pip, npm, +// git-over-HTTPS) get a millisecond 403 from a local black-hole listener +// rather than a DNS or connect stall. Clients that ignore proxy variables +// simply fail against the outer sandbox instead - slower, but still contained. +package netpolicy + +import ( + "fmt" + "net" + "net/http" + "os" + "strings" +) + +// Mode is the egress posture. +type Mode string + +const ( + // ModeAllow leaves every egress path open. + ModeAllow Mode = "allow" + // ModeOff declares agent-initiated egress unavailable. + ModeOff Mode = "off" +) + +// EnvMode is the environment variable the policy is read from. +const EnvMode = "SENIOR_DEV_NET" + +// Policy is an immutable snapshot of the egress policy. +type Policy struct { + Mode Mode + // Warning is non-empty when the environment held an unrecognized value + // and the policy failed closed because of it. + Warning string +} + +// Current reads the policy from the process environment. +func Current() Policy { + return FromLookup(os.Getenv) +} + +// FromLookup parses a policy from an environment accessor, for tests and +// embedders that do not own the process environment. +func FromLookup(getenv func(string) string) Policy { + raw := strings.TrimSpace(strings.ToLower(getenv(EnvMode))) + switch raw { + case "", string(ModeAllow): + return Policy{Mode: ModeAllow} + case string(ModeOff): + return Policy{Mode: ModeOff} + default: + return Policy{ + Mode: ModeOff, + Warning: fmt.Sprintf( + "%s=%q is not one of allow/off", + EnvMode, getenv(EnvMode), + ), + } + } +} + +// Restricted reports whether the policy restricts egress at all. Callers on +// hot paths use it to skip wrapping entirely under the default policy. +func (p Policy) Restricted() bool { + return p.Mode == ModeOff +} + +func hostOnly(host string) string { + if trimmed, _, err := net.SplitHostPort(host); err == nil { + return trimmed + } + // A bare IPv6 literal without a port fails SplitHostPort; unwrap brackets. + return strings.TrimSuffix(strings.TrimPrefix(host, "["), "]") +} + +// BlockedError is the model-facing refusal for one blocked host. It is a +// distinct type so callers can recover it with errors.As after net/http wraps +// it (a refusal on a redirect hop comes back from Client.Do inside a +// *url.Error) and surface the policy text instead of a generic transport +// failure. +type BlockedError struct { + Host string + message string +} + +func (err *BlockedError) Error() string { return err.message } + +// HostError builds the refusal for one blocked host. The [network-policy] +// prefix and the no-retry framing follow the [environment-signal] convention +// in internal/tool/shell_env_signal.go: the point is to stop an agent from +// burning turns retrying a request that policy, not transient failure, +// rejected. +func (p Policy) HostError(host string) error { + name := hostOnly(host) + return &BlockedError{Host: name, message: fmt.Sprintf( + "[network-policy] request to %q blocked: this run has network access disabled (%s=off). "+ + "This is policy, not a transient failure - do not retry and do not attempt "+ + "the same access through bash or other tools; "+ + "work from local repository content instead", + name, EnvMode, + )} +} + +// EnvironmentNotice is the per-turn system-prompt paragraph that tells agents +// up front that the network is unavailable, so the first fetch attempt never +// happens instead of merely failing fast. Empty under the default policy. +func (p Policy) EnvironmentNotice() string { + if !p.Restricted() { + return "" + } + return "Network access is disabled for this run: external fetches, package installs, " + + "and any other network commands will fail. Do not attempt them or retry them; " + + "work only from content already available in the repository and this environment." +} + +// Transport wraps base so every request is refused before it dials while the +// policy is restricted. Redirect hops re-enter the transport, so each hop is +// covered. A nil base means http.DefaultTransport, mirroring net/http. +func (p Policy) Transport(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + if !p.Restricted() { + return base + } + return policyTransport{policy: p, base: base} +} + +type policyTransport struct { + policy Policy + base http.RoundTripper +} + +func (t policyTransport) RoundTrip(request *http.Request) (*http.Response, error) { + // The RoundTripper contract makes the transport responsible for closing + // the body once it has been handed the request. + if request.Body != nil { + _ = request.Body.Close() + } + return nil, t.policy.HostError(request.URL.Host) +} diff --git a/internal/seniordev/netpolicy/netpolicy_test.go b/internal/seniordev/netpolicy/netpolicy_test.go new file mode 100644 index 0000000000..6e9f688fbd --- /dev/null +++ b/internal/seniordev/netpolicy/netpolicy_test.go @@ -0,0 +1,100 @@ +//go:build !windows + +package netpolicy + +import ( + "net/http" + "strings" + "testing" +) + +func lookup(values map[string]string) func(string) string { + return func(name string) string { return values[name] } +} + +func TestFromLookupModes(t *testing.T) { + cases := []struct { + name string + env map[string]string + mode Mode + warning bool + }{ + {name: "unset defaults to allow", env: nil, mode: ModeAllow}, + {name: "explicit allow", env: map[string]string{EnvMode: "allow"}, mode: ModeAllow}, + {name: "off", env: map[string]string{EnvMode: "off"}, mode: ModeOff}, + {name: "case and space folded", env: map[string]string{EnvMode: " OFF "}, mode: ModeOff}, + { + name: "typo fails closed", + env: map[string]string{EnvMode: "on"}, + mode: ModeOff, warning: true, + }, + { + name: "unknown mode name fails closed", + env: map[string]string{EnvMode: "allowlist"}, + mode: ModeOff, warning: true, + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + policy := FromLookup(lookup(test.env)) + if policy.Mode != test.mode { + t.Fatalf("mode = %q, want %q", policy.Mode, test.mode) + } + if (policy.Warning != "") != test.warning { + t.Fatalf("warning = %q, want present=%v", policy.Warning, test.warning) + } + }) + } +} + +type recordingTransport struct{ dialed bool } + +func (t *recordingTransport) RoundTrip(*http.Request) (*http.Response, error) { + t.dialed = true + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestTransportEnforcesPolicy(t *testing.T) { + base := &recordingTransport{} + + client := &http.Client{Transport: Policy{Mode: ModeOff}.Transport(base)} + _, err := client.Get("http://example.com/") + if err == nil { + t.Fatal("off-mode request unexpectedly succeeded") + } + if !strings.Contains(err.Error(), "[network-policy]") { + t.Fatalf("off-mode error missing policy marker: %v", err) + } + if base.dialed { + t.Fatal("off-mode request reached the base transport") + } + + // Unrestricted policy must return the base transport untouched. + if (Policy{Mode: ModeAllow}).Transport(base) != http.RoundTripper(base) { + t.Fatal("allow-mode Transport(base) should be the base transport") + } + if (Policy{Mode: ModeAllow}).Transport(nil) != http.RoundTripper(http.DefaultTransport) { + t.Fatal("allow-mode Transport(nil) should be http.DefaultTransport") + } +} + +func TestHostErrorSteersAwayFromRetry(t *testing.T) { + err := Policy{Mode: ModeOff}.HostError("mcp.exa.ai:443") + for _, want := range []string{"[network-policy]", "mcp.exa.ai", "do not retry", "SENIOR_DEV_NET"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error %q missing %q", err, want) + } + } +} + +func TestEnvironmentNotice(t *testing.T) { + if notice := (Policy{Mode: ModeAllow}).EnvironmentNotice(); notice != "" { + t.Fatalf("allow mode should carry no notice, got %q", notice) + } + notice := Policy{Mode: ModeOff}.EnvironmentNotice() + for _, want := range []string{"Network access is disabled", "Do not attempt"} { + if !strings.Contains(notice, want) { + t.Fatalf("notice %q missing %q", notice, want) + } + } +} diff --git a/internal/seniordev/notes_test.go b/internal/seniordev/notes_test.go new file mode 100644 index 0000000000..0c1bf83515 --- /dev/null +++ b/internal/seniordev/notes_test.go @@ -0,0 +1,14 @@ +//go:build !windows + +package seniordev + +import "testing" + +// THE FOLDER CODEAF MOVES OUT OF A PLAIN FOLDER IS THE ONE SENIOR-DEV WRITES +// ITS RECORDS TO: its brief, checklist, session database and conversation all +// live under app's seniorDevDataDirectory, which is this name. +func TestSeniorDevNamesTheFolderItKeepsItsRecordsIn(t *testing.T) { + if Program.Notes != ".senior-dev" { + t.Fatalf("senior-dev's notes folder = %q, want .senior-dev", Program.Notes) + } +} diff --git a/internal/seniordev/patch/diff_contract_test.go b/internal/seniordev/patch/diff_contract_test.go new file mode 100644 index 0000000000..15539d8dfb --- /dev/null +++ b/internal/seniordev/patch/diff_contract_test.go @@ -0,0 +1,46 @@ +//go:build !windows + +package patch + +import ( + "fmt" + "strings" + "testing" +) + +func TestUnifiedDiffUsesRealLineAlignment(t *testing.T) { + // An insertion must not masquerade as a replacement. + got := GenerateTwoFilesPatch("x", "a\nb\nc\nd\ne\nf\ng\n", "a\nb\nX\nc\nd\ne\nf\ng\n") + want := "Index: x\n===================================================================\n--- x\n+++ x\n" + + "@@ -1,6 +1,7 @@\n a\n b\n+X\n c\n d\n e\n f\n" + if got != want { + t.Fatalf("insertion diff:\n%s\nwant:\n%s", got, want) + } + if got := GenerateTwoFilesPatch("x", "a\n", ""); got != "Index: x\n===================================================================\n--- x\n+++ x\n@@ -1,1 +0,0 @@\n-a\n" { + t.Fatalf("deletion diff: %q", got) + } +} + +func TestTwoFilesPatchUnchangedEmptyFileIsHeaderOnly(t *testing.T) { + // Two identical empty versions still yield a header-only patch. + want := "Index: empty\n===================================================================\n--- empty\n+++ empty\n" + if got := GenerateTwoFilesPatch("empty", "", ""); got != want { + t.Fatalf("unchanged empty patch = %q, want %q", got, want) + } +} + +func TestLineDiffLargePathologicalInputIsBounded(t *testing.T) { + // A 10k-line replacement must not allocate an old-by-new matrix; the + // bounded linear-space path still returns a patch. + var oldContent, newContent strings.Builder + for index := 0; index < 10_000; index++ { + fmt.Fprintf(&oldContent, "old-%05d\n", index) + fmt.Fprintf(&newContent, "new-%05d\n", index) + } + got := GenerateTwoFilesPatch("large", oldContent.String(), newContent.String()) + if !strings.HasPrefix(got, "Index: large\n") || + !strings.Contains(got, "-old-00000") || + !strings.Contains(got, "+new-09999") { + t.Fatalf("large replacement patch was not generated: prefix=%q len=%d", got[:min(len(got), 80)], len(got)) + } +} diff --git a/internal/seniordev/patch/io_test.go b/internal/seniordev/patch/io_test.go new file mode 100644 index 0000000000..8a0e4b40b1 --- /dev/null +++ b/internal/seniordev/patch/io_test.go @@ -0,0 +1,72 @@ +//go:build !windows + +package patch + +import ( + "os" + "path/filepath" + "testing" +) + +func TestApplyPatchAndAffectedPaths(t *testing.T) { + root := t.TempDir() + add := filepath.Join(root, "nested", "add.txt") + update := filepath.Join(root, "update.txt") + deletePath := filepath.Join(root, "delete.txt") + if err := os.WriteFile(update, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(deletePath, []byte("gone\n"), 0o644); err != nil { + t.Fatal(err) + } + patchText := "*** Begin Patch\n" + + "*** Add File: " + add + "\n+x\n" + + "*** Update File: " + update + "\n@@\n-old\n+new\n" + + "*** Delete File: " + deletePath + "\n" + + "*** End Patch" + affected, err := ApplyPatch(patchText) + if err != nil { + t.Fatalf("ApplyPatch: %v", err) + } + if len(affected.Added) != 1 || affected.Added[0] != add || + len(affected.Modified) != 1 || affected.Modified[0] != update || + len(affected.Deleted) != 1 || affected.Deleted[0] != deletePath { + t.Fatalf("affected = %#v", affected) + } + data, err := os.ReadFile(add) + if err != nil || string(data) != "x" { + t.Fatalf("add content=%q err=%v", data, err) + } + data, err = os.ReadFile(update) + if err != nil || string(data) != "new\n" { + t.Fatalf("update content=%q err=%v", data, err) + } +} + +func TestMaybeParseApplyPatchVerified(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "file.txt") + if err := os.WriteFile(path, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + patchText := "*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n+new\n*** End Patch" + result := MaybeParseApplyPatchVerified([]string{"apply_patch", patchText}, root) + if result.Type != VerifiedBody || result.Action == nil { + t.Fatalf("result = %#v", result) + } + change, ok := result.Action.Changes[path] + if !ok || change.Type != "update" || change.NewContent != "new\n" { + t.Fatalf("change = %#v, ok=%v", change, ok) + } + implicit := MaybeParseApplyPatchVerified([]string{patchText}, root) + if implicit.Type != VerifiedCorrectnessError || implicit.Err == nil || implicit.Err.Error() != ErrorImplicitInvocation { + t.Fatalf("implicit = %#v", implicit) + } +} + +func TestApplyHunksRejectsEmpty(t *testing.T) { + _, err := ApplyHunksToFiles(nil) + if err == nil || err.Error() != "No files were modified." { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/seniordev/patch/patch.go b/internal/seniordev/patch/patch.go new file mode 100644 index 0000000000..33a1ab1bd1 --- /dev/null +++ b/internal/seniordev/patch/patch.go @@ -0,0 +1,1038 @@ +//go:build !windows + +// Package patch implements the apply_patch format: the patch parser, fuzzy +// chunk matching, direct filesystem application, and detection of apply_patch +// invocations inside shell commands. +package patch + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + MaybeBody = "Body" + MaybeShellParseError = "ShellParseError" + MaybePatchParseError = "PatchParseError" + MaybeNotApplyPatch = "NotApplyPatch" + + VerifiedBody = "Body" + VerifiedShellParseError = "ShellParseError" + VerifiedCorrectnessError = "CorrectnessError" + VerifiedNotApplyPatch = "NotApplyPatch" + + ErrorParseError = "ParseError" + ErrorIOError = "IoError" + ErrorComputeReplacements = "ComputeReplacements" + ErrorImplicitInvocation = "ImplicitInvocation" +) + +// UpdateFileChunk is the parser's line-oriented update block. +type UpdateFileChunk struct { + OldLines []string `json:"old_lines"` + NewLines []string `json:"new_lines"` + ChangeContext string `json:"change_context,omitempty"` +} + +// Hunk is one add, delete, or update section. +type Hunk struct { + Type string + Path string + Contents string + MovePath string + Chunks []UpdateFileChunk +} + +func (h Hunk) MarshalJSON() ([]byte, error) { + switch h.Type { + case "add": + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + Contents string `json:"contents"` + }{h.Type, h.Path, h.Contents}) + case "delete": + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + }{h.Type, h.Path}) + case "update": + if h.MovePath != "" { + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + MovePath string `json:"move_path"` + Chunks []UpdateFileChunk `json:"chunks"` + }{h.Type, h.Path, h.MovePath, nonNilChunks(h.Chunks)}) + } + return jsonutil.Marshal(struct { + Type string `json:"type"` + Path string `json:"path"` + Chunks []UpdateFileChunk `json:"chunks"` + }{h.Type, h.Path, nonNilChunks(h.Chunks)}) + default: + return nil, fmt.Errorf("unknown hunk type %q", h.Type) + } +} + +func nonNilChunks(chunks []UpdateFileChunk) []UpdateFileChunk { + if chunks == nil { + return []UpdateFileChunk{} + } + return chunks +} + +// ParseResult is the parsed hunk list. +type ParseResult struct { + Hunks []Hunk `json:"hunks"` +} + +// ApplyPatchArgs is a detected apply_patch invocation. +type ApplyPatchArgs struct { + Patch string `json:"patch"` + Hunks []Hunk `json:"hunks"` + Workdir string `json:"workdir,omitempty"` +} + +// MaybeResult is the discriminated result of MaybeParseApplyPatch. +type MaybeResult struct { + Type string + Args *ApplyPatchArgs + Err error +} + +// ApplyPatchFileUpdate is DeriveNewContentsFromChunks' result. +type ApplyPatchFileUpdate struct { + UnifiedDiff string `json:"unified_diff"` + Content string `json:"content"` + BOM bool `json:"bom"` +} + +// AffectedPaths records filesystem application results. +type AffectedPaths struct { + Added []string `json:"added"` + Modified []string `json:"modified"` + Deleted []string `json:"deleted"` +} + +// ApplyPatchFileChange is one previewed file change. +type ApplyPatchFileChange struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + UnifiedDiff string `json:"unified_diff,omitempty"` + MovePath string `json:"move_path,omitempty"` + NewContent string `json:"new_content,omitempty"` +} + +// ApplyPatchAction is the verified patch preview. +type ApplyPatchAction struct { + Changes map[string]ApplyPatchFileChange + Patch string + CWD string +} + +// VerifiedResult is the discriminated result of MaybeParseApplyPatchVerified. +type VerifiedResult struct { + Type string + Action *ApplyPatchAction + Err error +} + +// ParsePatch parses the stripped patch envelope. +func ParsePatch(patchText string) (ParseResult, error) { + cleaned := stripHeredoc(strings.TrimSpace(patchText)) + lines := strings.Split(cleaned, "\n") + beginIndex := -1 + endIndex := -1 + for i, line := range lines { + if beginIndex == -1 && strings.TrimSpace(line) == "*** Begin Patch" { + beginIndex = i + } + if endIndex == -1 && strings.TrimSpace(line) == "*** End Patch" { + endIndex = i + } + } + if beginIndex == -1 || endIndex == -1 || beginIndex >= endIndex { + return ParseResult{}, errors.New("Invalid patch format: missing Begin/End markers") + } + + hunks := []Hunk{} + for i := beginIndex + 1; i < endIndex; { + path, movePath, next, kind, ok := parsePatchHeader(lines, i) + if !ok { + i++ + continue + } + switch kind { + case "add": + content, after := parseAddFileContent(lines, next) + hunks = append(hunks, Hunk{Type: "add", Path: path, Contents: content}) + i = after + case "delete": + hunks = append(hunks, Hunk{Type: "delete", Path: path}) + i = next + case "update": + chunks, after := parseUpdateFileChunks(lines, next) + hunks = append(hunks, Hunk{ + Type: "update", + Path: path, + MovePath: movePath, + Chunks: chunks, + }) + i = after + default: + i++ + } + } + return ParseResult{Hunks: hunks}, nil +} + +func parsePatchHeader(lines []string, index int) (path string, movePath string, next int, kind string, ok bool) { + line := lines[index] + for _, item := range []struct { + prefix string + kind string + }{ + {"*** Add File:", "add"}, + {"*** Delete File:", "delete"}, + {"*** Update File:", "update"}, + } { + if !strings.HasPrefix(line, item.prefix) { + continue + } + path = strings.TrimSpace(line[len(item.prefix):]) + if path == "" { + return "", "", 0, "", false + } + next = index + 1 + if item.kind == "update" && next < len(lines) && strings.HasPrefix(lines[next], "*** Move to:") { + movePath = strings.TrimSpace(lines[next][len("*** Move to:"):]) + next++ + } + return path, movePath, next, item.kind, true + } + return "", "", 0, "", false +} + +func parseUpdateFileChunks(lines []string, start int) ([]UpdateFileChunk, int) { + chunks := []UpdateFileChunk{} + i := start + for i < len(lines) && !strings.HasPrefix(lines[i], "***") { + if !strings.HasPrefix(lines[i], "@@") { + i++ + continue + } + contextLine := strings.TrimSpace(lines[i][2:]) + i++ + oldLines := []string{} + newLines := []string{} + for i < len(lines) && !strings.HasPrefix(lines[i], "@@") && !strings.HasPrefix(lines[i], "***") { + changeLine := lines[i] + switch { + case strings.HasPrefix(changeLine, " "): + oldLines = append(oldLines, changeLine[1:]) + newLines = append(newLines, changeLine[1:]) + case strings.HasPrefix(changeLine, "-"): + oldLines = append(oldLines, changeLine[1:]) + case strings.HasPrefix(changeLine, "+"): + newLines = append(newLines, changeLine[1:]) + } + i++ + } + chunks = append(chunks, UpdateFileChunk{ + OldLines: oldLines, + NewLines: newLines, + ChangeContext: contextLine, + }) + } + return chunks, i +} + +func parseAddFileContent(lines []string, start int) (string, int) { + var content strings.Builder + i := start + for i < len(lines) && !strings.HasPrefix(lines[i], "***") { + if strings.HasPrefix(lines[i], "+") { + content.WriteString(lines[i][1:]) + content.WriteByte('\n') + } + i++ + } + out := content.String() + out = strings.TrimSuffix(out, "\n") + return out, i +} + +func stripHeredoc(input string) string { + headerEnd := strings.IndexByte(input, '\n') + if headerEnd < 0 { + return input + } + header := input[:headerEnd] + rest := input[headerEnd+1:] + if strings.HasPrefix(header, "cat") { + after := header[len("cat"):] + if after == "" || !isSpaceRune(firstRune(after)) { + return input + } + header = strings.TrimLeftFunc(after, isSpaceRune) + } + if !strings.HasPrefix(header, "<<") { + return input + } + header = header[2:] + if len(header) > 0 && (header[0] == '\'' || header[0] == '"') { + header = header[1:] + } + end := 0 + for end < len(header) && isASCIIWord(header[end]) { + end++ + } + if end == 0 { + return input + } + delimiter := header[:end] + header = header[end:] + if len(header) > 0 && (header[0] == '\'' || header[0] == '"') { + header = header[1:] + } + if strings.TrimSpace(header) != "" { + return input + } + suffix := "\n" + delimiter + position := strings.Index(rest, suffix) + for position >= 0 { + after := rest[position+len(suffix):] + if strings.TrimSpace(after) == "" { + return rest[:position] + } + next := strings.Index(rest[position+1:], suffix) + if next < 0 { + break + } + position += next + 1 + } + return input +} + +func firstRune(value string) rune { + for _, r := range value { + return r + } + return 0 +} + +func isASCIIWord(value byte) bool { + return value >= 'a' && value <= 'z' || + value >= 'A' && value <= 'Z' || + value >= '0' && value <= '9' || + value == '_' +} + +// MaybeParseApplyPatch detects direct and bash-heredoc invocations. +func MaybeParseApplyPatch(argv []string) MaybeResult { + if len(argv) == 2 && (argv[0] == "apply_patch" || argv[0] == "applypatch") { + parsed, err := ParsePatch(argv[1]) + if err != nil { + return MaybeResult{Type: MaybePatchParseError, Err: err} + } + return MaybeResult{ + Type: MaybeBody, + Args: &ApplyPatchArgs{Patch: argv[1], Hunks: parsed.Hunks}, + } + } + if len(argv) == 3 && argv[0] == "bash" && argv[1] == "-lc" { + if content, ok := extractApplyPatchHeredoc(argv[2]); ok { + parsed, err := ParsePatch(content) + if err != nil { + return MaybeResult{Type: MaybePatchParseError, Err: err} + } + return MaybeResult{ + Type: MaybeBody, + Args: &ApplyPatchArgs{Patch: content, Hunks: parsed.Hunks}, + } + } + } + return MaybeResult{Type: MaybeNotApplyPatch} +} + +func extractApplyPatchHeredoc(script string) (string, bool) { + for start := 0; start < len(script); { + index := strings.Index(script[start:], "apply_patch") + if index < 0 { + return "", false + } + index += start + len("apply_patch") + for index < len(script) && isSpaceRune(firstRune(script[index:])) { + _, size := runeAt(script[index:]) + index += size + } + if !strings.HasPrefix(script[index:], "<<") { + start = index + continue + } + index += 2 + if index >= len(script) || (script[index] != '\'' && script[index] != '"') { + return "", false + } + index++ + delimiterStart := index + for index < len(script) && isASCIIWord(script[index]) { + index++ + } + if delimiterStart == index { + return "", false + } + delimiter := script[delimiterStart:index] + if index >= len(script) || (script[index] != '\'' && script[index] != '"') { + return "", false + } + index++ + for index < len(script) && script[index] != '\n' { + if !isSpaceRune(firstRune(script[index:])) { + return "", false + } + _, size := runeAt(script[index:]) + index += size + } + if index >= len(script) { + return "", false + } + bodyStart := index + 1 + endMarker := "\n" + delimiter + bodyEnd := strings.Index(script[bodyStart:], endMarker) + if bodyEnd < 0 { + return "", false + } + return script[bodyStart : bodyStart+bodyEnd], true + } + return "", false +} + +func runeAt(value string) (rune, int) { + for _, r := range value { + return r, len(string(r)) + } + return 0, 0 +} + +// DeriveNewContentsFromChunks reads filePath and applies update chunks. +func DeriveNewContentsFromChunks(filePath string, chunks []UpdateFileChunk) (ApplyPatchFileUpdate, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return ApplyPatchFileUpdate{}, fmt.Errorf("Failed to read file %s: %v", filePath, err) + } + bom, originalText := splitBOM(strings.ToValidUTF8(string(data), "\uFFFD")) + originalLines := strings.Split(originalText, "\n") + if len(originalLines) > 0 && originalLines[len(originalLines)-1] == "" { + originalLines = originalLines[:len(originalLines)-1] + } + replacements, err := computeReplacements(originalLines, filePath, chunks) + if err != nil { + return ApplyPatchFileUpdate{}, err + } + newLines := applyReplacements(originalLines, replacements) + if len(newLines) == 0 || newLines[len(newLines)-1] != "" { + newLines = append(newLines, "") + } + nextBOM, newContent := splitBOM(strings.Join(newLines, "\n")) + return ApplyPatchFileUpdate{ + UnifiedDiff: generateUnifiedDiff(originalText, newContent), + Content: newContent, + BOM: bom || nextBOM, + }, nil +} + +type replacement struct { + start int + oldLength int + newSegment []string +} + +func computeReplacements(originalLines []string, filePath string, chunks []UpdateFileChunk) ([]replacement, error) { + replacements := []replacement{} + lineIndex := 0 + for _, chunk := range chunks { + if chunk.ChangeContext != "" { + contextIndex := seekSequence(originalLines, []string{chunk.ChangeContext}, lineIndex) + if contextIndex == -1 { + return nil, fmt.Errorf("Failed to find context '%s' in %s", chunk.ChangeContext, filePath) + } + lineIndex = contextIndex + 1 + } + if len(chunk.OldLines) == 0 { + insertionIndex := len(originalLines) + if len(originalLines) > 0 && originalLines[len(originalLines)-1] == "" { + insertionIndex-- + } + replacements = append(replacements, replacement{ + start: insertionIndex, oldLength: 0, newSegment: append([]string(nil), chunk.NewLines...), + }) + continue + } + pattern := append([]string(nil), chunk.OldLines...) + newSlice := append([]string(nil), chunk.NewLines...) + found := seekSequence(originalLines, pattern, lineIndex) + if found == -1 && len(pattern) > 0 && pattern[len(pattern)-1] == "" { + pattern = pattern[:len(pattern)-1] + if len(newSlice) > 0 && newSlice[len(newSlice)-1] == "" { + newSlice = newSlice[:len(newSlice)-1] + } + found = seekSequence(originalLines, pattern, lineIndex) + } + if found == -1 { + return nil, fmt.Errorf( + "Failed to find expected lines in %s:\n%s", + filePath, + strings.Join(chunk.OldLines, "\n"), + ) + } + replacements = append(replacements, replacement{ + start: found, oldLength: len(pattern), newSegment: newSlice, + }) + lineIndex = found + len(pattern) + } + sort.SliceStable(replacements, func(i, j int) bool { + return replacements[i].start < replacements[j].start + }) + return replacements, nil +} + +func applyReplacements(lines []string, replacements []replacement) []string { + result := append([]string(nil), lines...) + for i := len(replacements) - 1; i >= 0; i-- { + item := replacements[i] + before := append([]string(nil), result[:item.start]...) + after := append([]string(nil), result[item.start+item.oldLength:]...) + result = append(before, item.newSegment...) + result = append(result, after...) + } + return result +} + +type comparator func(string, string) bool + +func tryMatch(lines []string, pattern []string, start int, compare comparator) int { + for i := start; i <= len(lines)-len(pattern); i++ { + if sequenceMatches(lines, pattern, i, compare) { + return i + } + } + return -1 +} + +func sequenceMatches(lines []string, pattern []string, start int, compare comparator) bool { + for j := range pattern { + if !compare(lines[start+j], pattern[j]) { + return false + } + } + return true +} + +func seekSequence(lines []string, pattern []string, start int) int { + if len(pattern) == 0 { + return -1 + } + if found := tryMatch(lines, pattern, start, func(a, b string) bool { return a == b }); found != -1 { + return found + } + if found := tryMatch(lines, pattern, start, func(a, b string) bool { + return trimEnd(a) == trimEnd(b) + }); found != -1 { + return found + } + if found := tryMatch(lines, pattern, start, func(a, b string) bool { + return strings.TrimSpace(a) == strings.TrimSpace(b) + }); found != -1 { + return found + } + return tryMatch(lines, pattern, start, func(a, b string) bool { + return normalizeUnicode(strings.TrimSpace(a)) == normalizeUnicode(strings.TrimSpace(b)) + }) +} + +func trimEnd(value string) string { + return strings.TrimRightFunc(value, isSpaceRune) +} + +func normalizeUnicode(value string) string { + var out strings.Builder + for _, r := range value { + switch { + case r >= 0x2018 && r <= 0x201b: + out.WriteByte('\'') + case r >= 0x201c && r <= 0x201f: + out.WriteByte('"') + case r >= 0x2010 && r <= 0x2015: + out.WriteByte('-') + case r == 0x2026: + out.WriteString("...") + case r == 0x00a0: + out.WriteByte(' ') + default: + out.WriteRune(r) + } + } + return out.String() +} + +type diffLine struct { + text string + newline bool +} + +type diffOperation struct { + kind byte + line diffLine +} + +type lineMatch struct { + old int + new int +} + +const maxLineDiffCells int64 = 8_000_000 + +func contentLines(content string) []diffLine { + if content == "" { + return nil + } + parts := strings.SplitAfter(content, "\n") + lines := make([]diffLine, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + newline := strings.HasSuffix(part, "\n") + lines = append(lines, diffLine{text: strings.TrimSuffix(part, "\n"), newline: newline}) + } + return lines +} + +func equalDiffLine(left, right diffLine) bool { + return left.text == right.text && left.newline == right.newline +} + +func lineOperations(oldContent, newContent string) []diffOperation { + oldLines, newLines := contentLines(oldContent), contentLines(newContent) + prefix := 0 + for prefix < len(oldLines) && prefix < len(newLines) && + equalDiffLine(oldLines[prefix], newLines[prefix]) { + prefix++ + } + suffix := 0 + for suffix < len(oldLines)-prefix && suffix < len(newLines)-prefix && + equalDiffLine(oldLines[len(oldLines)-suffix-1], newLines[len(newLines)-suffix-1]) { + suffix++ + } + operations := make([]diffOperation, 0, len(oldLines)+len(newLines)) + for _, line := range oldLines[:prefix] { + operations = append(operations, diffOperation{kind: ' ', line: line}) + } + oldMiddle := oldLines[prefix : len(oldLines)-suffix] + newMiddle := newLines[prefix : len(newLines)-suffix] + matches, ok := linearLCSMatches(oldMiddle, newMiddle) + if !ok { + for _, line := range oldMiddle { + operations = append(operations, diffOperation{kind: '-', line: line}) + } + for _, line := range newMiddle { + operations = append(operations, diffOperation{kind: '+', line: line}) + } + } else { + oldCursor, newCursor := 0, 0 + for _, match := range matches { + for oldCursor < match.old { + operations = append(operations, diffOperation{kind: '-', line: oldMiddle[oldCursor]}) + oldCursor++ + } + for newCursor < match.new { + operations = append(operations, diffOperation{kind: '+', line: newMiddle[newCursor]}) + newCursor++ + } + operations = append(operations, diffOperation{kind: ' ', line: oldMiddle[match.old]}) + oldCursor, newCursor = match.old+1, match.new+1 + } + for oldCursor < len(oldMiddle) { + operations = append(operations, diffOperation{kind: '-', line: oldMiddle[oldCursor]}) + oldCursor++ + } + for newCursor < len(newMiddle) { + operations = append(operations, diffOperation{kind: '+', line: newMiddle[newCursor]}) + newCursor++ + } + } + for _, line := range oldLines[len(oldLines)-suffix:] { + operations = append(operations, diffOperation{kind: ' ', line: line}) + } + return operations +} + +func linearLCSMatches(oldLines, newLines []diffLine) ([]lineMatch, bool) { + budget := maxLineDiffCells + matches := make([]lineMatch, 0) + if len(newLines) <= len(oldLines) { + if !appendLCSMatches(oldLines, newLines, 0, 0, false, &budget, &matches) { + return nil, false + } + } else if !appendLCSMatches(newLines, oldLines, 0, 0, true, &budget, &matches) { + return nil, false + } + return matches, true +} + +// appendLCSMatches is Hirschberg's linear-space LCS. The work budget falls +// back to a whole-range replacement before pathological inputs consume +// unbounded CPU. +func appendLCSMatches( + left, right []diffLine, + leftOffset, rightOffset int, + swapped bool, + budget *int64, + matches *[]lineMatch, +) bool { + if len(left) == 0 || len(right) == 0 { + return true + } + if len(left) == 1 { + if int64(len(right)) > *budget { + return false + } + *budget -= int64(len(right)) + for index := range right { + if !equalDiffLine(left[0], right[index]) { + continue + } + if swapped { + *matches = append(*matches, lineMatch{old: rightOffset + index, new: leftOffset}) + } else { + *matches = append(*matches, lineMatch{old: leftOffset, new: rightOffset + index}) + } + return true + } + return true + } + cells := int64(len(left)) * int64(len(right)) + if cells > *budget/2 { + return false + } + middle := len(left) / 2 + forward := lcsPrefixLengths(left[:middle], right) + backward := lcsSuffixLengths(left[middle:], right) + *budget -= cells * 2 + split := 0 + best := -1 + for index := 0; index <= len(right); index++ { + value := forward[index] + backward[index] + if value > best { + best = value + split = index + } + } + return appendLCSMatches( + left[:middle], right[:split], leftOffset, rightOffset, swapped, budget, matches, + ) && appendLCSMatches( + left[middle:], right[split:], leftOffset+middle, rightOffset+split, swapped, budget, matches, + ) +} + +func lcsPrefixLengths(left, right []diffLine) []int { + previous, current := make([]int, len(right)+1), make([]int, len(right)+1) + for _, leftLine := range left { + for index, rightLine := range right { + if equalDiffLine(leftLine, rightLine) { + current[index+1] = previous[index] + 1 + } else if previous[index+1] >= current[index] { + current[index+1] = previous[index+1] + } else { + current[index+1] = current[index] + } + } + previous, current = current, previous + clear(current) + } + return previous +} + +func lcsSuffixLengths(left, right []diffLine) []int { + previous, current := make([]int, len(right)+1), make([]int, len(right)+1) + for leftIndex := len(left) - 1; leftIndex >= 0; leftIndex-- { + for rightIndex := len(right) - 1; rightIndex >= 0; rightIndex-- { + if equalDiffLine(left[leftIndex], right[rightIndex]) { + current[rightIndex] = previous[rightIndex+1] + 1 + } else if previous[rightIndex] >= current[rightIndex+1] { + current[rightIndex] = previous[rightIndex] + } else { + current[rightIndex] = current[rightIndex+1] + } + } + previous, current = current, previous + clear(current) + } + return previous +} + +func generateUnifiedDiff(oldContent string, newContent string) string { + if oldContent == newContent { + return "" + } + operations := lineOperations(oldContent, newContent) + oldBefore, newBefore := make([]int, len(operations)+1), make([]int, len(operations)+1) + for i, operation := range operations { + oldBefore[i+1], newBefore[i+1] = oldBefore[i], newBefore[i] + if operation.kind != '+' { + oldBefore[i+1]++ + } + if operation.kind != '-' { + newBefore[i+1]++ + } + } + var diff strings.Builder + for cursor := 0; cursor < len(operations); { + first := cursor + for first < len(operations) && operations[first].kind == ' ' { + first++ + } + if first == len(operations) { + break + } + start := first + for count := 0; start > 0 && count < 4; count++ { + start-- + } + lastChange := first + for scan := first + 1; scan < len(operations); { + next := scan + for next < len(operations) && operations[next].kind == ' ' { + next++ + } + if next == len(operations) || next-lastChange-1 > 8 { + break + } + lastChange = next + scan = next + 1 + } + end := lastChange + 1 + for count := 0; end < len(operations) && operations[end].kind == ' ' && count < 4; count++ { + end++ + } + oldCount, newCount := oldBefore[end]-oldBefore[start], newBefore[end]-newBefore[start] + oldStart, newStart := oldBefore[start]+1, newBefore[start]+1 + if oldCount == 0 { + oldStart = oldBefore[start] + } + if newCount == 0 { + newStart = newBefore[start] + } + fmt.Fprintf(&diff, "@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount) + for _, operation := range operations[start:end] { + diff.WriteByte(operation.kind) + diff.WriteString(operation.line.text) + diff.WriteByte('\n') + if !operation.line.newline { + diff.WriteString("\\ No newline at end of file\n") + } + } + cursor = end + } + return diff.String() +} + +// GenerateTwoFilesPatch wraps the line diff in an Index/---/+++ header. +func GenerateTwoFilesPatch(filePath, oldContent, newContent string) string { + diff := generateUnifiedDiff(oldContent, newContent) + return "Index: " + filePath + "\n" + + "===================================================================\n" + + "--- " + filePath + "\n" + + "+++ " + filePath + "\n" + diff +} + +// GenerateUnifiedDiff exposes the patch package's mutation preview to live +// edit/write tools. +func GenerateUnifiedDiff(oldContent string, newContent string) string { + return generateUnifiedDiff(oldContent, newContent) +} + +// ApplyHunksToFiles applies already-parsed hunks to their literal paths. +func ApplyHunksToFiles(hunks []Hunk) (AffectedPaths, error) { + if len(hunks) == 0 { + return AffectedPaths{}, errors.New("No files were modified.") + } + out := AffectedPaths{Added: []string{}, Modified: []string{}, Deleted: []string{}} + for _, hunk := range hunks { + switch hunk.Type { + case "add": + dir := filepath.Dir(hunk.Path) + if dir != "." && dir != string(filepath.Separator) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return AffectedPaths{}, err + } + } + if err := os.WriteFile(hunk.Path, []byte(hunk.Contents), 0o644); err != nil { + return AffectedPaths{}, err + } + out.Added = append(out.Added, hunk.Path) + case "delete": + if err := os.Remove(hunk.Path); err != nil { + return AffectedPaths{}, err + } + out.Deleted = append(out.Deleted, hunk.Path) + case "update": + update, err := DeriveNewContentsFromChunks(hunk.Path, hunk.Chunks) + if err != nil { + return AffectedPaths{}, err + } + target := hunk.Path + if hunk.MovePath != "" { + target = hunk.MovePath + dir := filepath.Dir(target) + if dir != "." && dir != string(filepath.Separator) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return AffectedPaths{}, err + } + } + } + if err := os.WriteFile(target, []byte(joinBOM(update.Content, update.BOM)), 0o644); err != nil { + return AffectedPaths{}, err + } + if hunk.MovePath != "" { + if err := os.Remove(hunk.Path); err != nil { + return AffectedPaths{}, err + } + } + out.Modified = append(out.Modified, target) + } + } + return out, nil +} + +// ApplyPatch parses and applies patchText. +func ApplyPatch(patchText string) (AffectedPaths, error) { + parsed, err := ParsePatch(patchText) + if err != nil { + return AffectedPaths{}, err + } + return ApplyHunksToFiles(parsed.Hunks) +} + +// MaybeParseApplyPatchVerified previews a detected patch against cwd. +func MaybeParseApplyPatchVerified(argv []string, cwd string) VerifiedResult { + if len(argv) == 1 { + if _, err := ParsePatch(argv[0]); err == nil { + return VerifiedResult{Type: VerifiedCorrectnessError, Err: errors.New(ErrorImplicitInvocation)} + } + } + result := MaybeParseApplyPatch(argv) + switch result.Type { + case MaybeBody: + effectiveCWD := cwd + if result.Args.Workdir != "" { + effectiveCWD = filepath.Join(cwd, result.Args.Workdir) + } + effectiveCWD = filepath.Clean(effectiveCWD) + changes := map[string]ApplyPatchFileChange{} + for _, hunk := range result.Args.Hunks { + targetPath := hunk.Path + if hunk.Type == "update" && hunk.MovePath != "" { + targetPath = hunk.MovePath + } + resolvedPath := resolve(effectiveCWD, targetPath) + switch hunk.Type { + case "add": + changes[resolvedPath] = ApplyPatchFileChange{Type: "add", Content: hunk.Contents} + case "delete": + deletePath := resolve(effectiveCWD, hunk.Path) + content, err := os.ReadFile(deletePath) + if err != nil { + return VerifiedResult{ + Type: VerifiedCorrectnessError, + Err: fmt.Errorf("Failed to read file for deletion: %s", deletePath), + } + } + changes[resolvedPath] = ApplyPatchFileChange{Type: "delete", Content: string(content)} + case "update": + updatePath := resolve(effectiveCWD, hunk.Path) + update, err := DeriveNewContentsFromChunks(updatePath, hunk.Chunks) + if err != nil { + return VerifiedResult{Type: VerifiedCorrectnessError, Err: err} + } + changes[resolvedPath] = ApplyPatchFileChange{ + Type: "update", + UnifiedDiff: update.UnifiedDiff, + MovePath: optionalResolved(effectiveCWD, hunk.MovePath), + NewContent: update.Content, + } + } + } + return VerifiedResult{ + Type: VerifiedBody, + Action: &ApplyPatchAction{ + Changes: changes, + Patch: result.Args.Patch, + CWD: effectiveCWD, + }, + } + case MaybePatchParseError: + return VerifiedResult{Type: VerifiedCorrectnessError, Err: result.Err} + default: + return VerifiedResult{Type: VerifiedNotApplyPatch} + } +} + +func resolve(cwd string, path string) string { + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + return filepath.Clean(filepath.Join(cwd, path)) +} + +func optionalResolved(cwd string, path string) string { + if path == "" { + return "" + } + return resolve(cwd, path) +} + +func splitBOM(value string) (bool, string) { + if strings.HasPrefix(value, "\ufeff") { + return true, value[len("\ufeff"):] + } + return false, value +} + +func joinBOM(value string, bom bool) string { + _, value = splitBOM(value) + if bom { + return "\ufeff" + value + } + return value +} + +// isSpaceRune is the whitespace set used when trimming patch lines: ASCII +// controls, the Unicode space separators, the line/paragraph separators and +// the BOM. +func isSpaceRune(r rune) bool { + if r >= 0x0009 && r <= 0x000d { + return true + } + if r >= 0x2000 && r <= 0x200a { + return true + } + switch r { + case 0x0020, 0x00a0, 0x1680, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff: + return true + default: + return false + } +} + +// Ensure the custom Hunk marshaler satisfies encoding/json. +var _ json.Marshaler = Hunk{} diff --git a/internal/seniordev/permission/permission.go b/internal/seniordev/permission/permission.go new file mode 100644 index 0000000000..6ffad4d8c5 --- /dev/null +++ b/internal/seniordev/permission/permission.go @@ -0,0 +1,619 @@ +//go:build !windows + +// Package permission evaluates tool permission rules. Rule and YAML-object +// order are preserved because the last matching rule wins. +package permission + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + ActionAllow = "allow" + ActionDeny = "deny" + ActionAsk = "ask" +) + +type ruleOrder uint8 + +const ( + orderPermissionPatternAction ruleOrder = iota + orderPermissionActionPattern + orderActionPermissionPattern +) + +// Rule is one permission/pattern/action entry. +type Rule struct { + Permission string + Pattern string + Action string + order ruleOrder +} + +func (r Rule) MarshalJSON() ([]byte, error) { + switch r.order { + case orderPermissionActionPattern: + return jsonutil.Marshal(struct { + Permission string `json:"permission"` + Action string `json:"action"` + Pattern string `json:"pattern"` + }{r.Permission, r.Action, r.Pattern}) + case orderActionPermissionPattern: + return jsonutil.Marshal(struct { + Action string `json:"action"` + Permission string `json:"permission"` + Pattern string `json:"pattern"` + }{r.Action, r.Permission, r.Pattern}) + default: + return jsonutil.Marshal(struct { + Permission string `json:"permission"` + Pattern string `json:"pattern"` + Action string `json:"action"` + }{r.Permission, r.Pattern, r.Action}) + } +} + +func (r *Rule) UnmarshalJSON(data []byte) error { + fields, err := orderedJSONFields(data) + if err != nil { + return err + } + order := make([]string, 0, len(fields)) + for _, field := range fields { + order = append(order, field.key) + switch field.key { + case "permission": + if err := json.Unmarshal(field.value, &r.Permission); err != nil { + return err + } + case "pattern": + if err := json.Unmarshal(field.value, &r.Pattern); err != nil { + return err + } + case "action": + if err := json.Unmarshal(field.value, &r.Action); err != nil { + return err + } + } + } + switch strings.Join(order, ",") { + case "permission,action,pattern": + r.order = orderPermissionActionPattern + case "action,permission,pattern": + r.order = orderActionPermissionPattern + default: + r.order = orderPermissionPatternAction + } + return nil +} + +// Ruleset is evaluated in slice order; the last matching rule wins. +type Ruleset []Rule + +// PatternAction is one nested config entry. +type PatternAction struct { + Pattern string + Action string +} + +// ConfigEntry is one permission entry in config source order. +type ConfigEntry struct { + Permission string + Action *string + Patterns []PatternAction +} + +// Config keeps permission entries in the order the config listed them. +type Config struct { + Entries []ConfigEntry +} + +// ParseConfigJSON decodes a permission config object without losing key +// order. Integer-like keys are ordered first, numerically. +func ParseConfigJSON(data []byte) (Config, error) { + trimmed := bytes.TrimSpace(data) + if len(trimmed) > 0 && trimmed[0] == '"' { + var action string + if err := json.Unmarshal(trimmed, &action); err != nil { + return Config{}, err + } + return Config{Entries: []ConfigEntry{{ + Permission: "*", + Action: stringPointer(action), + }}}, nil + } + fields, err := orderedJSONFields(trimmed) + if err != nil { + return Config{}, err + } + fields = jsObjectFieldOrder(fields) + config := Config{Entries: make([]ConfigEntry, 0, len(fields))} + for _, field := range fields { + entry := ConfigEntry{Permission: field.key} + var action string + if err := json.Unmarshal(field.value, &action); err == nil { + entry.Action = &action + config.Entries = append(config.Entries, entry) + continue + } + nested, err := orderedJSONFields(field.value) + if err != nil { + return Config{}, fmt.Errorf("permission %q: %w", field.key, err) + } + nested = jsObjectFieldOrder(nested) + entry.Patterns = make([]PatternAction, 0, len(nested)) + for _, pattern := range nested { + if err := json.Unmarshal(pattern.value, &action); err != nil { + return Config{}, fmt.Errorf("permission %q pattern %q: %w", field.key, pattern.key, err) + } + entry.Patterns = append(entry.Patterns, PatternAction{Pattern: pattern.key, Action: action}) + } + config.Entries = append(config.Entries, entry) + } + return config, nil +} + +func (c *Config) UnmarshalJSON(data []byte) error { + value, err := ParseConfigJSON(data) + if err != nil { + return err + } + *c = value + return nil +} + +type jsonField struct { + key string + value json.RawMessage + index int +} + +func orderedJSONFields(data []byte) ([]jsonField, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + token, err := decoder.Token() + if err != nil { + return nil, err + } + if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { + return nil, errors.New("expected JSON object") + } + fields := []jsonField{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return nil, err + } + key, ok := keyToken.(string) + if !ok { + return nil, errors.New("expected object key") + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return nil, err + } + fields = append(fields, jsonField{key: key, value: value, index: len(fields)}) + } + if _, err := decoder.Token(); err != nil { + return nil, err + } + if _, err := decoder.Token(); err != io.EOF { + if err == nil { + return nil, errors.New("trailing JSON") + } + return nil, err + } + return fields, nil +} + +func jsObjectFieldOrder(fields []jsonField) []jsonField { + out := append([]jsonField(nil), fields...) + sort.SliceStable(out, func(i, j int) bool { + left, leftOK := arrayIndex(out[i].key) + right, rightOK := arrayIndex(out[j].key) + switch { + case leftOK && rightOK: + return left < right + case leftOK: + return true + case rightOK: + return false + default: + return out[i].index < out[j].index + } + }) + return out +} + +func arrayIndex(value string) (uint64, bool) { + if value == "" || (len(value) > 1 && value[0] == '0') { + return 0, false + } + number, err := strconv.ParseUint(value, 10, 32) + if err != nil || number == 1<<32-1 { + return 0, false + } + if strconv.FormatUint(number, 10) != value { + return 0, false + } + return number, true +} + +func stringPointer(value string) *string { + return &value +} + +// FromConfig flattens an ordered config object. +func FromConfig(config Config) Ruleset { + out := Ruleset{} + for _, entry := range config.Entries { + if entry.Action != nil { + out = append(out, Rule{ + Permission: entry.Permission, + Action: *entry.Action, + Pattern: "*", + order: orderPermissionActionPattern, + }) + continue + } + for _, pattern := range entry.Patterns { + out = append(out, Rule{ + Permission: entry.Permission, + Pattern: expand(pattern.Pattern), + Action: pattern.Action, + order: orderPermissionPatternAction, + }) + } + } + return out +} + +// Evaluate returns the last matching rule across the flattened rulesets. +func Evaluate(permission string, pattern string, rulesets ...Ruleset) Rule { + for i := len(rulesets) - 1; i >= 0; i-- { + rules := rulesets[i] + for j := len(rules) - 1; j >= 0; j-- { + rule := rules[j] + if WildcardMatch(permission, rule.Permission) && WildcardMatch(pattern, rule.Pattern) { + return rule + } + } + } + return Rule{ + Action: ActionAllow, + Permission: permission, + Pattern: "*", + order: orderActionPermissionPattern, + } +} + +// Merge concatenates rulesets without deduplication. +func Merge(rulesets ...Ruleset) Ruleset { + out := Ruleset{} + for _, rules := range rulesets { + out = append(out, rules...) + } + return out +} + +// StringSet is an insertion-ordered string set. +type StringSet struct { + order []string + values map[string]struct{} +} + +func newStringSet() StringSet { + return StringSet{order: []string{}, values: map[string]struct{}{}} +} + +func (s *StringSet) add(value string) { + if _, ok := s.values[value]; ok { + return + } + s.values[value] = struct{}{} + s.order = append(s.order, value) +} + +// Has reports set membership. +func (s StringSet) Has(value string) bool { + _, ok := s.values[value] + return ok +} + +// Values returns the members in insertion order. +func (s StringSet) Values() []string { + out := make([]string, len(s.order)) + copy(out, s.order) + return out +} + +// Disabled returns tools disabled by a final whole-permission deny. +func Disabled(tools []string, rules Ruleset) StringSet { + out := newStringSet() + for _, tool := range tools { + permission := tool + if tool == "edit" || tool == "write" || tool == "apply_patch" { + permission = "edit" + } + var match *Rule + for i := len(rules) - 1; i >= 0; i-- { + if WildcardMatch(permission, rules[i].Permission) { + copy := rules[i] + match = © + break + } + } + if match != nil && match.Pattern == "*" && match.Action == ActionDeny { + out.add(tool) + } + } + return out +} + +// WildcardMatch matches value against pattern: * spans any number of UTF-16 +// code units, ? spans one, and a trailing " *" is optional as a unit. +func WildcardMatch(value string, pattern string) bool { + value = strings.ReplaceAll(value, `\`, "/") + pattern = strings.ReplaceAll(pattern, `\`, "/") + if strings.HasSuffix(pattern, " *") { + if wildcardUnits(value, pattern[:len(pattern)-2]) { + return true + } + } + return wildcardUnits(value, pattern) +} + +func wildcardUnits(value string, pattern string) bool { + input := utf16.Encode([]rune(value)) + glob := utf16.Encode([]rune(pattern)) + table := make([][]bool, len(glob)+1) + for i := range table { + table[i] = make([]bool, len(input)+1) + } + table[0][0] = true + for i := 1; i <= len(glob); i++ { + if glob[i-1] == '*' { + table[i][0] = table[i-1][0] + } + for j := 1; j <= len(input); j++ { + switch glob[i-1] { + case '*': + table[i][j] = table[i-1][j] || table[i][j-1] + case '?': + table[i][j] = table[i-1][j-1] + default: + table[i][j] = table[i-1][j-1] && glob[i-1] == input[j-1] + } + } + } + return table[len(glob)][len(input)] +} + +func expand(pattern string) string { + home, _ := os.UserHomeDir() + switch { + case strings.HasPrefix(pattern, "~/"): + return home + pattern[1:] + case pattern == "~": + return home + case strings.HasPrefix(pattern, "$HOME/"): + return home + pattern[5:] + case strings.HasPrefix(pattern, "$HOME"): + return home + pattern[5:] + default: + return pattern + } +} + +// DeniedError is returned when Ask finds a deny rule. +type DeniedError struct { + Ruleset Ruleset +} + +func (e DeniedError) Error() string { + data, err := jsonutil.Marshal(e.Ruleset) + if err != nil { + panic(err) + } + return "The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules " + string(data) +} + +// Service is the autonomous permission evaluator. Literal ask and allow both +// proceed; only deny returns an error. +type Service struct { + Approved Ruleset +} + +// AskInput is the tool-context request shape evaluated by the live registry. +type AskInput struct { + Request + Ruleset Ruleset `json:"ruleset"` +} + +// Evaluate preserves the full request metadata while applying autonomous Ask. +func (s *Service) Evaluate(input AskInput) error { + return s.Ask(input.Permission, input.Patterns, input.Ruleset) +} + +// Ask evaluates every pattern against request rules followed by approvals. +func (s *Service) Ask(permission string, patterns []string, rules Ruleset) error { + for _, pattern := range patterns { + rule := Evaluate(permission, pattern, rules, s.Approved) + if rule.Action != ActionDeny { + continue + } + relevant := Ruleset{} + for _, candidate := range rules { + if WildcardMatch(permission, candidate.Permission) { + relevant = append(relevant, candidate) + } + } + return DeniedError{Ruleset: relevant} + } + return nil +} + +// Pending returns no requests: the autonomous evaluator never queues one. +func (s *Service) Pending() []Request { + return []Request{} +} + +// Request is the value-level pending request shape. +type Request struct { + ID string `json:"id"` + SessionID string `json:"sessionID"` + Permission string `json:"permission"` + Patterns []string `json:"patterns"` + Metadata map[string]any `json:"metadata"` + Always []string `json:"always"` +} + +// RulesetFromFrontmatter parses the permission mapping of an agent Markdown +// document while preserving YAML source order. +func RulesetFromFrontmatter(markdown string) (Ruleset, error) { + config, err := ConfigFromFrontmatter(markdown) + if err != nil { + return nil, err + } + return FromConfig(config), nil +} + +// ConfigFromFrontmatter parses the subset of YAML used by agent permission +// frontmatter: ordered scalar actions and one nested pattern/action mapping. +func ConfigFromFrontmatter(markdown string) (Config, error) { + lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n") + if len(lines) == 0 || lines[0] != "---" { + return Config{}, errors.New("missing YAML frontmatter") + } + end := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + end = i + break + } + } + if end == -1 { + return Config{}, errors.New("unterminated YAML frontmatter") + } + config := Config{Entries: []ConfigEntry{}} + permissionIndex := -1 + for i := 1; i < end; i++ { + indent := leadingSpaces(lines[i]) + key, value, ok := yamlKeyValue(strings.TrimSpace(lines[i])) + if !ok { + continue + } + if indent == 0 && key == "permission" { + if value != "" { + action := yamlScalar(value) + config.Entries = append(config.Entries, ConfigEntry{ + Permission: "*", + Action: stringPointer(action), + }) + return config, nil + } + permissionIndex = i + break + } + } + if permissionIndex == -1 { + return config, nil + } + + var current *ConfigEntry + for i := permissionIndex + 1; i < end; i++ { + line := lines[i] + if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + indent := leadingSpaces(line) + if indent < 2 { + break + } + key, value, ok := yamlKeyValue(strings.TrimSpace(line)) + if !ok { + continue + } + switch indent { + case 2: + entry := ConfigEntry{Permission: yamlScalar(key)} + if value != "" { + action := yamlScalar(value) + entry.Action = &action + } else { + entry.Patterns = []PatternAction{} + } + config.Entries = append(config.Entries, entry) + current = &config.Entries[len(config.Entries)-1] + default: + if indent >= 4 && current != nil && current.Action == nil { + current.Patterns = append(current.Patterns, PatternAction{ + Pattern: yamlScalar(key), + Action: yamlScalar(value), + }) + } + } + } + return config, nil +} + +func leadingSpaces(value string) int { + count := 0 + for count < len(value) && value[count] == ' ' { + count++ + } + return count +} + +func yamlKeyValue(value string) (string, string, bool) { + quoted := byte(0) + escaped := false + for i := 0; i < len(value); i++ { + char := value[i] + if quoted != 0 { + if quoted == '"' && char == '\\' && !escaped { + escaped = true + continue + } + if char == quoted && !escaped { + quoted = 0 + } + escaped = false + continue + } + if char == '\'' || char == '"' { + quoted = char + continue + } + if char == ':' { + return strings.TrimSpace(value[:i]), strings.TrimSpace(value[i+1:]), true + } + } + return "", "", false +} + +func yamlScalar(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' { + if decoded, err := strconv.Unquote(value); err == nil { + return decoded + } + } + if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + return strings.ReplaceAll(value[1:len(value)-1], "''", "'") + } + if index := strings.Index(value, " #"); index >= 0 { + value = value[:index] + } + return strings.TrimSpace(value) +} diff --git a/internal/seniordev/permission/permission_test.go b/internal/seniordev/permission/permission_test.go new file mode 100644 index 0000000000..7acb33cf1f --- /dev/null +++ b/internal/seniordev/permission/permission_test.go @@ -0,0 +1,75 @@ +//go:build !windows + +package permission + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" +) + +func TestWildcardUTF16AndTrailingOptional(t *testing.T) { + cases := []struct { + value string + pattern string + want bool + }{ + {"git", "git *", true}, + {"git status", "git *", true}, + {"gitstatus", "git *", false}, + {"a/b", `a\b`, true}, + {"💩", "?", false}, + {"💩", "??", true}, + {"a\nb", "a*b", true}, + } + for _, test := range cases { + if got := WildcardMatch(test.value, test.pattern); got != test.want { + t.Errorf("WildcardMatch(%q, %q) = %v, want %v", test.value, test.pattern, got, test.want) + } + } +} + +func TestAutonomousAskTreatsAskAsAllowAndDenyAsError(t *testing.T) { + config, err := ParseConfigJSON([]byte(`{"read":{"*":"ask","secret":"deny"}}`)) + if err != nil { + t.Fatal(err) + } + rules := FromConfig(config) + service := &Service{} + if err := service.Ask("read", []string{"public"}, rules); err != nil { + t.Fatalf("ask should proceed: %v", err) + } + err = service.Ask("read", []string{"secret"}, rules) + var denied DeniedError + if !errors.As(err, &denied) { + t.Fatalf("error = %T %v", err, err) + } + want := "The user has specified a rule which prevents you from using this specific tool call. " + + "Here are some of the relevant rules " + + `[{"permission":"read","pattern":"*","action":"ask"},{"permission":"read","pattern":"secret","action":"deny"}]` + if err.Error() != want { + t.Fatalf("error = %q", err) + } + if len(service.Pending()) != 0 { + t.Fatalf("autonomous service has pending requests") + } +} + +func TestEveryBakedAgentPermissionFrontmatterParses(t *testing.T) { + for _, name := range baked.ListBakedAgents() { + t.Run(name, func(t *testing.T) { + markdown, ok := baked.GetBakedAgentMarkdown(name) + if !ok { + t.Fatal("missing markdown") + } + rules, err := RulesetFromFrontmatter(markdown) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(rules) == 0 { + t.Fatal("permission rules unexpectedly empty") + } + }) + } +} diff --git a/internal/seniordev/project/context.go b/internal/seniordev/project/context.go new file mode 100644 index 0000000000..79f96ddc23 --- /dev/null +++ b/internal/seniordev/project/context.go @@ -0,0 +1,114 @@ +//go:build !windows + +// Package project resolves the directory and project a tool call runs +// against. The instance travels on context.Context so concurrent scheduler +// leaves cannot bleed working directories into one another. +package project + +import ( + "context" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/seniordev/core" +) + +type ID string + +const GlobalID ID = "global" + +type Icon struct { + URL *string `json:"url,omitempty"` + Override *string `json:"override,omitempty"` + Color *string `json:"color,omitempty"` +} + +type Commands struct { + Start *string `json:"start,omitempty"` +} + +type Time struct { + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Initialized *int64 `json:"initialized,omitempty"` +} + +// Info is the public project record. +type Info struct { + ID ID `json:"id"` + Worktree string `json:"worktree"` + VCS *string `json:"vcs,omitempty"` + Name *string `json:"name,omitempty"` + Icon *Icon `json:"icon,omitempty"` + Commands *Commands `json:"commands,omitempty"` + Time Time `json:"time"` + Sandboxes []string `json:"sandboxes"` +} + +// InstanceContext is the execution boundary a tool call resolves paths +// against: which directory it runs in, and which project that is. +type InstanceContext struct { + Directory string `json:"directory"` + Worktree string `json:"worktree"` + Project Info `json:"project"` +} + +type instanceContextKey struct{} + +// WithContext binds an instance to ctx. The value is immutable by convention. +func WithContext(ctx context.Context, instance InstanceContext) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, instanceContextKey{}, instance) +} + +// FromContext returns the bound instance. +func FromContext(ctx context.Context) (InstanceContext, bool) { + if ctx == nil { + return InstanceContext{}, false + } + instance, ok := ctx.Value(instanceContextKey{}).(InstanceContext) + return instance, ok +} + +// Directory returns the active tool cwd, falling back when no instance is +// bound. +func Directory(ctx context.Context, fallback string) string { + if instance, ok := FromContext(ctx); ok && instance.Directory != "" { + return instance.Directory + } + return fallback +} + +// ContainsPath reports whether path is inside the instance directory or its +// worktree; a non-git worktree of "/" does not count as containing anything. +func ContainsPath(path string, instance InstanceContext) bool { + if core.Contains(instance.Directory, path) { + return true + } + if instance.Worktree == "/" { + return false + } + return core.Contains(instance.Worktree, path) +} + +// RedirectIntoDirectory remaps an absolute original-worktree path into the +// active isolated directory. +func RedirectIntoDirectory(path string, instance InstanceContext) string { + if instance.Directory == instance.Worktree || instance.Worktree == "/" { + return path + } + if core.Contains(instance.Directory, path) || !core.Contains(instance.Worktree, path) { + return path + } + relative, err := filepath.Rel(instance.Worktree, path) + if err != nil { + return path + } + return filepath.Join(instance.Directory, relative) +} + +// Provide invokes fn with an instance-bound context. +func Provide[T any](ctx context.Context, instance InstanceContext, fn func(context.Context) (T, error)) (T, error) { + return fn(WithContext(ctx, instance)) +} diff --git a/internal/seniordev/project/store.go b/internal/seniordev/project/store.go new file mode 100644 index 0000000000..d28ec65100 --- /dev/null +++ b/internal/seniordev/project/store.go @@ -0,0 +1,270 @@ +//go:build !windows + +package project + +// Instance store lifecycle and project discovery. The bootstrap step is +// injected. + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type LoadInput struct { + Directory string + Worktree string + Project *Info +} + +type Bootstrap interface { + Run(ctx context.Context, instance InstanceContext) error +} + +type BootstrapFunc func(context.Context, InstanceContext) error + +func (f BootstrapFunc) Run(ctx context.Context, instance InstanceContext) error { + return f(ctx, instance) +} + +type Discoverer interface { + FromDirectory(ctx context.Context, directory string) (Info, string, error) +} + +type DiscovererFunc func(context.Context, string) (Info, string, error) + +func (f DiscovererFunc) FromDirectory(ctx context.Context, directory string) (Info, string, error) { + return f(ctx, directory) +} + +type storeEntry struct { + ready chan struct{} + value InstanceContext + err error +} + +// Store caches one booted instance per resolved active directory. +type Store struct { + discover Discoverer + bootstrap Bootstrap + + mu sync.Mutex + cache map[string]*storeEntry +} + +func NewStore(discover Discoverer, bootstrap Bootstrap) *Store { + if discover == nil { + discover = DiscovererFunc(Discover) + } + return &Store{discover: discover, bootstrap: bootstrap, cache: map[string]*storeEntry{}} +} + +func resolvedDirectory(path string) string { + absolute, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(absolute) +} + +func (s *Store) boot(ctx context.Context, input LoadInput) (InstanceContext, error) { + instance := InstanceContext{Directory: input.Directory} + if input.Project != nil && input.Worktree != "" { + instance.Worktree = input.Worktree + instance.Project = *input.Project + } else { + project, sandbox, err := s.discover.FromDirectory(ctx, input.Directory) + if err != nil { + return InstanceContext{}, err + } + instance.Project, instance.Worktree = project, sandbox + } + if s.bootstrap != nil { + if err := s.bootstrap.Run(WithContext(ctx, instance), instance); err != nil { + return InstanceContext{}, err + } + } + return instance, nil +} + +func (s *Store) Load(ctx context.Context, input LoadInput) (InstanceContext, error) { + input.Directory = resolvedDirectory(input.Directory) + s.mu.Lock() + if entry := s.cache[input.Directory]; entry != nil { + s.mu.Unlock() + select { + case <-ctx.Done(): + return InstanceContext{}, ctx.Err() + case <-entry.ready: + return entry.value, entry.err + } + } + entry := &storeEntry{ready: make(chan struct{})} + s.cache[input.Directory] = entry + s.mu.Unlock() + + entry.value, entry.err = s.boot(ctx, input) + if entry.err != nil { + s.mu.Lock() + if s.cache[input.Directory] == entry { + delete(s.cache, input.Directory) + } + s.mu.Unlock() + } + close(entry.ready) + return entry.value, entry.err +} + +func (s *Store) Reload(ctx context.Context, input LoadInput) (InstanceContext, error) { + input.Directory = resolvedDirectory(input.Directory) + entry := &storeEntry{ready: make(chan struct{})} + s.mu.Lock() + s.cache[input.Directory] = entry + s.mu.Unlock() + entry.value, entry.err = s.boot(ctx, input) + if entry.err != nil { + s.mu.Lock() + if s.cache[input.Directory] == entry { + delete(s.cache, input.Directory) + } + s.mu.Unlock() + } + close(entry.ready) + return entry.value, entry.err +} + +func (s *Store) Dispose(instance InstanceContext) { + s.mu.Lock() + delete(s.cache, filepath.Clean(instance.Directory)) + s.mu.Unlock() +} + +func (s *Store) DisposeAll() { + s.mu.Lock() + s.cache = map[string]*storeEntry{} + s.mu.Unlock() +} + +func (s *Store) Provide(ctx context.Context, input LoadInput, fn func(context.Context) error) error { + instance, err := s.Load(ctx, input) + if err != nil { + return err + } + return fn(WithContext(ctx, instance)) +} + +func runGit(ctx context.Context, cwd string, args ...string) (string, bool) { + command := exec.CommandContext(ctx, "git", args...) + command.Dir = cwd + var stdout bytes.Buffer + command.Stdout = &stdout + command.Stderr = &bytes.Buffer{} + if err := command.Run(); err != nil { + return "", false + } + return strings.TrimSpace(stdout.String()), true +} + +func findDotGit(directory string) string { + current := directory + for { + if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { + return filepath.Join(current, ".git") + } + parent := filepath.Dir(current) + if parent == current { + return "" + } + current = parent + } +} + +func readCachedProjectID(dotGit string) ID { + data, err := os.ReadFile(filepath.Join(dotGit, "senior-dev")) + if err != nil { + return "" + } + return ID(strings.TrimSpace(string(data))) +} + +// Discover resolves the project for directory: inside a git repository it +// yields the worktree and a stable project id, elsewhere the global project. +func Discover(ctx context.Context, directory string) (Info, string, error) { + directory = resolvedDirectory(directory) + now := time.Now().UnixMilli() + dotGit := findDotGit(directory) + if dotGit == "" { + return Info{ + ID: GlobalID, Worktree: "/", Time: Time{Created: now, Updated: now}, + Sandboxes: []string{"/"}, + }, "/", nil + } + sandbox := filepath.Dir(dotGit) + projectID := readCachedProjectID(dotGit) + commonRaw, gitOK := runGit(ctx, sandbox, "rev-parse", "--git-common-dir") + if !gitOK { + if projectID == "" { + projectID = GlobalID + } + vcs := "git" + return Info{ + ID: projectID, Worktree: sandbox, VCS: &vcs, + Time: Time{Created: now, Updated: now}, Sandboxes: []string{sandbox}, + }, sandbox, nil + } + common := commonRaw + if !filepath.IsAbs(common) { + common = filepath.Join(sandbox, common) + } + common = filepath.Clean(common) + bareRaw, bareOK := runGit(ctx, sandbox, "config", "--bool", "core.bare") + isBare := bareOK && bareRaw == "true" + worktree := filepath.Dir(common) + if common == sandbox { + worktree = sandbox + } else if isBare { + worktree = common + } + if projectID == "" { + projectID = readCachedProjectID(common) + } + if projectID == "" { + rootsRaw, _ := runGit(ctx, sandbox, "rev-list", "--max-parents=0", "HEAD") + roots := []string{} + for _, root := range strings.Split(rootsRaw, "\n") { + if root = strings.TrimSpace(root); root != "" { + roots = append(roots, root) + } + } + sort.Strings(roots) + if len(roots) > 0 { + projectID = ID(roots[0]) + _ = os.WriteFile(filepath.Join(common, "senior-dev"), []byte(projectID), 0o644) + } + } + if projectID == "" { + projectID = GlobalID + } + vcs := "git" + return Info{ + ID: projectID, Worktree: worktree, VCS: &vcs, + Time: Time{Created: now, Updated: now}, Sandboxes: []string{sandbox}, + }, sandbox, nil +} + +var ErrNoInstance = errors.New("project: no instance in context") + +func Require(ctx context.Context) (InstanceContext, error) { + instance, ok := FromContext(ctx) + if !ok { + return InstanceContext{}, ErrNoInstance + } + return instance, nil +} diff --git a/internal/seniordev/project/store_test.go b/internal/seniordev/project/store_test.go new file mode 100644 index 0000000000..311251dfee --- /dev/null +++ b/internal/seniordev/project/store_test.go @@ -0,0 +1,57 @@ +//go:build !windows + +package project + +import ( + "context" + "sync" + "sync/atomic" + "testing" +) + +func TestStoreCoalescesConcurrentLoadsAndBindsBootstrapContext(t *testing.T) { + directory := t.TempDir() + var discovers atomic.Int32 + var bootstraps atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + discover := DiscovererFunc(func(_ context.Context, directory string) (Info, string, error) { + if discovers.Add(1) == 1 { + close(started) + } + <-release + return Info{ID: "p", Worktree: directory, Sandboxes: []string{directory}}, directory, nil + }) + bootstrap := BootstrapFunc(func(ctx context.Context, instance InstanceContext) error { + bootstraps.Add(1) + got, ok := FromContext(ctx) + if !ok || got.Directory != instance.Directory { + t.Errorf("bootstrap context = %#v, %v", got, ok) + } + return nil + }) + store := NewStore(discover, bootstrap) + var wait sync.WaitGroup + wait.Add(2) + results := make(chan InstanceContext, 2) + for index := range 2 { + if index == 1 { + <-started + } + go func() { + defer wait.Done() + value, err := store.Load(context.Background(), LoadInput{Directory: directory}) + if err != nil { + t.Errorf("Load: %v", err) + return + } + results <- value + }() + } + close(release) + wait.Wait() + close(results) + if discovers.Load() != 1 || bootstraps.Load() != 1 { + t.Fatalf("duplicate work: discover=%d bootstrap=%d", discovers.Load(), bootstraps.Load()) + } +} diff --git a/internal/seniordev/question/question.go b/internal/seniordev/question/question.go new file mode 100644 index 0000000000..07ba7f4c12 --- /dev/null +++ b/internal/seniordev/question/question.go @@ -0,0 +1,254 @@ +//go:build !windows + +// Question lifecycle service +package question + +import ( + "context" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" +) + +// Event contains the three question bus definitions. +var Event = struct { + Asked bus.Definition + Replied bus.Definition + Rejected bus.Definition +}{ + Asked: bus.Define("question.asked", "QuestionRequest"), + Replied: bus.Define("question.replied", "QuestionReplied"), + Rejected: bus.Define("question.rejected", "QuestionRejected"), +} + +// RejectedError is returned when a question is dismissed or the service is +// finalized. +type RejectedError struct{} + +// Error is the model-visible dismissal message. +func (*RejectedError) Error() string { return "The user dismissed this question" } + +// Publisher is the narrow bus surface used by Service. +type Publisher interface { + Publish(bus.Definition, any, ...bus.PublishOptions) +} + +// IDGenerator creates a new ascending question identifier. +type IDGenerator func() (QuestionID, error) + +// AskInput is the input accepted by Service.Ask. +type AskInput struct { + SessionID string + Questions []Info + Tool *Tool +} + +// ReplyInput is the input accepted by Service.Reply. +type ReplyInput struct { + RequestID QuestionID + Answers []Answer +} + +type pendingResult struct { + answers []Answer + err error +} + +type pendingEntry struct { + info Request + deferred chan pendingResult +} + +// Service owns the insertion-ordered pending-question map. +type Service struct { + mu sync.Mutex + pending map[QuestionID]*pendingEntry + order []QuestionID + publisher Publisher + createID IDGenerator + closed bool +} + +// NewService constructs a question service. Nil dependencies use the +// package-level bus and QuestionID generator. +func NewService(publisher Publisher, createID IDGenerator) *Service { + if publisher == nil { + publisher = bus.Default + } + if createID == nil { + createID = func() (QuestionID, error) { return AscendingQuestionID() } + } + return &Service{ + pending: make(map[QuestionID]*pendingEntry), + publisher: publisher, + createID: createID, + } +} + +// Ask registers and publishes a request, then waits for a reply, rejection, or +// context cancellation. The pending entry is removed on every exit path. +func (s *Service) Ask(ctx context.Context, input AskInput) ([]Answer, error) { + s.mu.Lock() + closed := s.closed + s.mu.Unlock() + if closed { + return nil, &RejectedError{} + } + requestID, err := s.createID() + if err != nil { + return nil, err + } + request := Request{ + ID: requestID, + SessionID: input.SessionID, + Questions: cloneInfos(input.Questions), + Tool: cloneTool(input.Tool), + } + entry := &pendingEntry{info: request, deferred: make(chan pendingResult, 1)} + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return nil, &RejectedError{} + } + if _, exists := s.pending[requestID]; !exists { + s.order = append(s.order, requestID) + } + s.pending[requestID] = entry + s.mu.Unlock() + + defer s.delete(requestID) + s.publisher.Publish(Event.Asked, request) + + select { + case result := <-entry.deferred: + return result.answers, result.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// Reply publishes the reply and resolves its pending Ask. Unknown request IDs +// are ignored. +func (s *Service) Reply(input ReplyInput) { + entry := s.take(input.RequestID) + if entry == nil { + return + } + s.publisher.Publish(Event.Replied, Replied{ + SessionID: entry.info.SessionID, + RequestID: entry.info.ID, + Answers: cloneAnswers(input.Answers), + }) + entry.deferred <- pendingResult{answers: input.Answers} +} + +// Reject publishes the rejection and fails its pending Ask. Unknown request +// IDs are ignored. +func (s *Service) Reject(requestID QuestionID) { + entry := s.take(requestID) + if entry == nil { + return + } + s.publisher.Publish(Event.Rejected, Rejected{ + SessionID: entry.info.SessionID, + RequestID: entry.info.ID, + }) + entry.deferred <- pendingResult{err: &RejectedError{}} +} + +// List returns pending requests in insertion order. +func (s *Service) List() []Request { + s.mu.Lock() + defer s.mu.Unlock() + result := make([]Request, 0, len(s.order)) + for _, requestID := range s.order { + if entry := s.pending[requestID]; entry != nil { + result = append(result, entry.info) + } + } + return result +} + +// Close rejects every waiter, clears the pending map, and makes subsequent +// asks fail as dismissed. +func (s *Service) Close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + entries := make([]*pendingEntry, 0, len(s.order)) + for _, requestID := range s.order { + if entry := s.pending[requestID]; entry != nil { + entries = append(entries, entry) + } + } + s.pending = make(map[QuestionID]*pendingEntry) + s.order = nil + s.mu.Unlock() + + for _, entry := range entries { + entry.deferred <- pendingResult{err: &RejectedError{}} + } +} + +func (s *Service) take(requestID QuestionID) *pendingEntry { + s.mu.Lock() + defer s.mu.Unlock() + entry := s.pending[requestID] + if entry == nil { + return nil + } + delete(s.pending, requestID) + s.removeOrder(requestID) + return entry +} + +func (s *Service) delete(requestID QuestionID) { + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.pending[requestID]; !exists { + return + } + delete(s.pending, requestID) + s.removeOrder(requestID) +} + +func (s *Service) removeOrder(requestID QuestionID) { + for index, item := range s.order { + if item == requestID { + s.order = append(s.order[:index], s.order[index+1:]...) + return + } + } +} + +func cloneTool(tool *Tool) *Tool { + if tool == nil { + return nil + } + result := *tool + return &result +} + +func cloneInfos(questions []Info) []Info { + result := make([]Info, len(questions)) + for index, question := range questions { + result[index] = question + result[index].Options = append([]Option{}, question.Options...) + } + return result +} + +func cloneAnswers(answers []Answer) []Answer { + result := make([]Answer, len(answers)) + for index, answer := range answers { + result[index] = append(Answer{}, answer...) + } + return result +} + +// Default is the package-level service backed by bus.Default. +var Default = NewService(nil, nil) diff --git a/internal/seniordev/question/question_test.go b/internal/seniordev/question/question_test.go new file mode 100644 index 0000000000..e4739f61fc --- /dev/null +++ b/internal/seniordev/question/question_test.go @@ -0,0 +1,264 @@ +//go:build !windows + +package question + +import ( + "context" + "errors" + "reflect" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" +) + +type askResult struct { + answers []Answer + err error +} + +func isolatedService(ids ...QuestionID) (*Service, *bus.Bus, <-chan bus.Payload) { + instanceBus := bus.New( + bus.Context{}, + bus.WithIDGenerator(func() string { return "evt_local" }), + ) + events := make(chan bus.Payload, 32) + instanceBus.SubscribeAllCallback(func(payload bus.Payload) { events <- payload }) + index := 0 + var idMutex sync.Mutex + service := NewService(instanceBus, func() (QuestionID, error) { + idMutex.Lock() + defer idMutex.Unlock() + value := ids[index] + index++ + return value, nil + }) + return service, instanceBus, events +} + +func askAsync(service *Service, input AskInput) <-chan askResult { + result := make(chan askResult, 1) + go func() { + answers, err := service.Ask(context.Background(), input) + result <- askResult{answers: answers, err: err} + }() + return result +} + +func receiveEvent(t *testing.T, events <-chan bus.Payload) bus.Payload { + t.Helper() + select { + case event := <-events: + return event + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for event") + return bus.Payload{} + } +} + +func receiveAsk(t *testing.T, result <-chan askResult) askResult { + t.Helper() + select { + case value := <-result: + return value + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Ask") + return askResult{} + } +} + +func TestAskReplyLifecycleAndEvents(t *testing.T) { + service, _, events := isolatedService("que_1") + question := Info{ + Question: "Continue?", + Header: "Confirm", + Options: []Option{{Label: "Yes", Description: "Continue"}}, + } + result := askAsync(service, AskInput{ + SessionID: "ses_1", + Questions: []Info{question}, + Tool: &Tool{MessageID: "msg_1", CallID: "call-1"}, + }) + + asked := receiveEvent(t, events) + if asked.Type != Event.Asked.Type { + t.Fatalf("event type = %q", asked.Type) + } + request, ok := asked.Properties.(Request) + if !ok || request.ID != "que_1" || request.SessionID != "ses_1" || + request.Tool == nil || request.Tool.CallID != "call-1" { + t.Fatalf("asked payload = %#v", asked.Properties) + } + listed := service.List() + if len(listed) != 1 || listed[0].ID != "que_1" { + t.Fatalf("List = %#v", listed) + } + + answers := []Answer{{"Yes"}, {"custom"}} + service.Reply(ReplyInput{RequestID: "que_1", Answers: answers}) + replied := receiveEvent(t, events) + if replied.Type != Event.Replied.Type { + t.Fatalf("event type = %q", replied.Type) + } + properties, ok := replied.Properties.(Replied) + if !ok || properties.SessionID != "ses_1" || properties.RequestID != "que_1" || + !reflect.DeepEqual(properties.Answers, answers) { + t.Fatalf("replied payload = %#v", replied.Properties) + } + answers[0][0] = "mutated" + if properties.Answers[0][0] != "Yes" { + t.Fatal("published answers alias Reply input") + } + + got := receiveAsk(t, result) + if got.err != nil || got.answers[0][0] != "mutated" { + t.Fatalf("Ask = %#v", got) + } + if len(service.List()) != 0 { + t.Fatalf("pending after reply = %#v", service.List()) + } +} + +func TestRejectLifecycleAndMessage(t *testing.T) { + service, _, events := isolatedService("que_2") + result := askAsync(service, AskInput{SessionID: "ses_2", Questions: []Info{}}) + _ = receiveEvent(t, events) + + service.Reject("que_2") + rejected := receiveEvent(t, events) + properties, ok := rejected.Properties.(Rejected) + if rejected.Type != Event.Rejected.Type || !ok || + properties.SessionID != "ses_2" || properties.RequestID != "que_2" { + t.Fatalf("rejected event = %#v", rejected) + } + got := receiveAsk(t, result) + var rejectedError *RejectedError + if !errors.As(got.err, &rejectedError) { + t.Fatalf("Ask error = %v", got.err) + } + if got.err.Error() != "The user dismissed this question" { + t.Fatalf("message = %q", got.err) + } +} + +func TestUnknownReplyAndRejectAreNoOps(t *testing.T) { + service, _, events := isolatedService("que_unused") + service.Reply(ReplyInput{RequestID: "que_unknown", Answers: []Answer{{"x"}}}) + service.Reject("que_unknown") + select { + case event := <-events: + t.Fatalf("unexpected event %#v", event) + default: + } +} + +func TestListPreservesInsertionOrder(t *testing.T) { + service, _, events := isolatedService("que_1", "que_2", "que_3") + var results []<-chan askResult + for _, sessionID := range []string{"ses_1", "ses_2", "ses_3"} { + results = append(results, askAsync(service, AskInput{ + SessionID: sessionID, Questions: []Info{}, + })) + _ = receiveEvent(t, events) + } + listed := service.List() + var ids []QuestionID + for _, request := range listed { + ids = append(ids, request.ID) + } + if want := []QuestionID{"que_1", "que_2", "que_3"}; !reflect.DeepEqual(ids, want) { + t.Fatalf("ids = %#v, want %#v", ids, want) + } + for index, requestID := range ids { + service.Reply(ReplyInput{RequestID: requestID, Answers: []Answer{}}) + _ = receiveEvent(t, events) + if got := receiveAsk(t, results[index]); got.err != nil { + t.Fatal(got.err) + } + } +} + +func TestSynchronousAskedSubscriberCanReply(t *testing.T) { + instanceBus := bus.New( + bus.Context{}, + bus.WithIDGenerator(func() string { return "evt_local" }), + ) + var service *Service + instanceBus.SubscribeCallback(Event.Asked, func(payload bus.Payload) { + request := payload.Properties.(Request) + service.Reply(ReplyInput{RequestID: request.ID, Answers: []Answer{{"Immediately"}}}) + }) + service = NewService(instanceBus, func() (QuestionID, error) { return "que_sync", nil }) + answers, err := service.Ask(context.Background(), AskInput{ + SessionID: "ses_1", Questions: []Info{}, + }) + if err != nil || !reflect.DeepEqual(answers, []Answer{{"Immediately"}}) { + t.Fatalf("Ask = %#v, %v", answers, err) + } +} + +func TestContextCancellationRemovesPending(t *testing.T) { + service, _, events := isolatedService("que_cancel") + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan askResult, 1) + go func() { + answers, err := service.Ask(ctx, AskInput{SessionID: "ses_1", Questions: []Info{}}) + result <- askResult{answers: answers, err: err} + }() + _ = receiveEvent(t, events) + cancel() + got := receiveAsk(t, result) + if !errors.Is(got.err, context.Canceled) { + t.Fatalf("Ask error = %v", got.err) + } + if len(service.List()) != 0 { + t.Fatalf("pending after cancel = %#v", service.List()) + } +} + +func TestCloseRejectsAllWithoutPublishingRejectedEvents(t *testing.T) { + service, _, events := isolatedService("que_1", "que_2") + first := askAsync(service, AskInput{SessionID: "ses_1", Questions: []Info{}}) + _ = receiveEvent(t, events) + second := askAsync(service, AskInput{SessionID: "ses_2", Questions: []Info{}}) + _ = receiveEvent(t, events) + + service.Close() + for _, result := range []<-chan askResult{first, second} { + got := receiveAsk(t, result) + var rejected *RejectedError + if !errors.As(got.err, &rejected) { + t.Fatalf("Ask error = %v", got.err) + } + } + if len(service.List()) != 0 { + t.Fatalf("pending after Close = %#v", service.List()) + } + select { + case event := <-events: + t.Fatalf("Close published event %#v", event) + default: + } + _, err := service.Ask(context.Background(), AskInput{}) + var rejected *RejectedError + if !errors.As(err, &rejected) { + t.Fatalf("Ask after Close error = %v", err) + } +} + +func TestConcurrentUnknownOperations(t *testing.T) { + service, _, _ := isolatedService("que_unused") + var wait sync.WaitGroup + for index := range 100 { + wait.Add(1) + go func() { + defer wait.Done() + requestID := QuestionID("que_" + string(rune(index))) + service.Reply(ReplyInput{RequestID: requestID}) + service.Reject(requestID) + _ = service.List() + }() + } + wait.Wait() +} diff --git a/internal/seniordev/question/schema.go b/internal/seniordev/question/schema.go new file mode 100644 index 0000000000..5226e7382f --- /dev/null +++ b/internal/seniordev/question/schema.go @@ -0,0 +1,281 @@ +//go:build !windows + +// Question payload types and their JSON acceptance checks. +package question + +import ( + "bytes" + "encoding/json" + + idpkg "github.com/Agent-Field/codeaf/internal/seniordev/id" +) + +// QuestionID is the branded string used to identify a pending question. +type QuestionID string + +// Option is one selectable response. +type Option struct { + Label string `json:"label"` + Description string `json:"description"` +} + +// Info describes a question presented to a user. +type Info struct { + Question string `json:"question"` + Header string `json:"header"` + Options []Option `json:"options"` + Multiple *bool `json:"multiple,omitempty"` + Custom *bool `json:"custom,omitempty"` +} + +// Prompt is the tool-facing question shape before the custom-answer flag is +// added. +type Prompt struct { + Question string `json:"question"` + Header string `json:"header"` + Options []Option `json:"options"` + Multiple *bool `json:"multiple,omitempty"` +} + +// Tool links a question request to its originating tool call. +type Tool struct { + MessageID string `json:"messageID"` + CallID string `json:"callID"` +} + +// Request is the payload of question.asked. +type Request struct { + ID QuestionID `json:"id"` + SessionID string `json:"sessionID"` + Questions []Info `json:"questions"` + Tool *Tool `json:"tool,omitempty"` +} + +// Answer contains the selected labels for one question. +type Answer []string + +// Reply is the HTTP/tool reply body. +type Reply struct { + Answers []Answer `json:"answers"` +} + +// Replied is the payload of question.replied. +type Replied struct { + SessionID string `json:"sessionID"` + RequestID QuestionID `json:"requestID"` + Answers []Answer `json:"answers"` +} + +// Rejected is the payload of question.rejected. +type Rejected struct { + SessionID string `json:"sessionID"` + RequestID QuestionID `json:"requestID"` +} + +// AscendingQuestionID returns a new ascending question ID, or validates and +// returns given. +func AscendingQuestionID(given ...string) (QuestionID, error) { + value, err := idpkg.Ascending("question", given...) + return QuestionID(value), err +} + +// SchemaAccepts reports whether raw is a valid JSON encoding of the named +// question type. Mode "strict" additionally requires branded ID strings to carry +// their prefix; mode "basic" checks structure only. +func SchemaAccepts(kind, mode string, raw json.RawMessage) bool { + if mode != "basic" && mode != "strict" { + return false + } + strict := mode == "strict" + switch kind { + case "option": + return acceptsOption(raw) + case "info": + return acceptsInfo(raw) + case "prompt": + return acceptsPrompt(raw) + case "tool": + return acceptsTool(raw, strict) + case "request": + return acceptsRequest(raw, strict) + case "answer": + return acceptsAnswer(raw) + case "reply": + return acceptsReply(raw) + case "questionID": + value, ok := rawString(raw) + return ok && (!strict || idpkg.SchemaAccepts("question", value)) + default: + return false + } +} + +func acceptsOption(raw json.RawMessage) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + _, labelOK := requiredString(object, "label") + _, descriptionOK := requiredString(object, "description") + return labelOK && descriptionOK +} + +func acceptsInfo(raw json.RawMessage) bool { + object, ok := rawObject(raw) + if !ok || !acceptsBase(object) { + return false + } + return optionalBool(object, "custom") +} + +func acceptsPrompt(raw json.RawMessage) bool { + object, ok := rawObject(raw) + return ok && acceptsBase(object) +} + +func acceptsBase(object map[string]json.RawMessage) bool { + if _, ok := requiredString(object, "question"); !ok { + return false + } + if _, ok := requiredString(object, "header"); !ok { + return false + } + options, ok := rawArray(object["options"]) + if !ok { + return false + } + for _, option := range options { + if !acceptsOption(option) { + return false + } + } + return optionalBool(object, "multiple") +} + +func acceptsTool(raw json.RawMessage, strict bool) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + messageID, ok := requiredString(object, "messageID") + if !ok || (strict && !idpkg.SchemaAccepts("message", messageID)) { + return false + } + _, ok = requiredString(object, "callID") + return ok +} + +func acceptsRequest(raw json.RawMessage, strict bool) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + requestID, ok := requiredString(object, "id") + if !ok || (strict && !idpkg.SchemaAccepts("question", requestID)) { + return false + } + sessionID, ok := requiredString(object, "sessionID") + if !ok || (strict && !idpkg.SchemaAccepts("session", sessionID)) { + return false + } + questions, ok := rawArray(object["questions"]) + if !ok { + return false + } + for _, question := range questions { + if !acceptsInfo(question) { + return false + } + } + if tool, exists := object["tool"]; exists && !acceptsTool(tool, strict) { + return false + } + return true +} + +func acceptsAnswer(raw json.RawMessage) bool { + answers, ok := rawArray(raw) + if !ok { + return false + } + for _, answer := range answers { + if _, ok := rawString(answer); !ok { + return false + } + } + return true +} + +func acceptsReply(raw json.RawMessage) bool { + object, ok := rawObject(raw) + if !ok { + return false + } + answers, ok := rawArray(object["answers"]) + if !ok { + return false + } + for _, answer := range answers { + if !acceptsAnswer(answer) { + return false + } + } + return true +} + +func rawObject(raw json.RawMessage) (map[string]json.RawMessage, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil, false + } + var value map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &value); err != nil || value == nil { + return nil, false + } + return value, true +} + +func rawArray(raw json.RawMessage) ([]json.RawMessage, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '[' { + return nil, false + } + var value []json.RawMessage + if err := json.Unmarshal(trimmed, &value); err != nil { + return nil, false + } + return value, true +} + +func rawString(raw json.RawMessage) (string, bool) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '"' { + return "", false + } + var value string + if json.Unmarshal(trimmed, &value) != nil { + return "", false + } + return value, true +} + +func requiredString(object map[string]json.RawMessage, key string) (string, bool) { + raw, exists := object[key] + if !exists { + return "", false + } + return rawString(raw) +} + +func optionalBool(object map[string]json.RawMessage, key string) bool { + raw, exists := object[key] + if !exists { + return true + } + trimmed := bytes.TrimSpace(raw) + if !bytes.Equal(trimmed, []byte("true")) && !bytes.Equal(trimmed, []byte("false")) { + return false + } + var value bool + return json.Unmarshal(trimmed, &value) == nil +} diff --git a/internal/seniordev/router/adaptive/adaptive.go b/internal/seniordev/router/adaptive/adaptive.go new file mode 100644 index 0000000000..706357b13a --- /dev/null +++ b/internal/seniordev/router/adaptive/adaptive.go @@ -0,0 +1,1111 @@ +//go:build !windows + +// Package adaptive is the adaptive model router: it picks a model for each +// call from the configured pool for the caller's tier (high, low, frontier) +// by sampling a score from each candidate's recorded reliability, speed, +// price and current load, and it learns from every registered outcome +// (latency, throughput, rate limits and other failures, each with its own +// cooldown). +// +// A tier whose pool is empty resolves to the high pool, so a run configured +// with nothing but a high pool routes every tier on it. +// +// A single router-wide mutex serialises every public method; Pick takes and +// releases it around each TryPick and never holds it across a sleep. +package adaptive + +import ( + "context" + "errors" + "fmt" + "math" + "math/rand" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" +) + +// ── injectable ambient dependencies ────────────────────────────────────── + +// nowMillis is the wall clock in milliseconds. +var nowMillis = func() float64 { return float64(time.Now().UnixMilli()) } + +// SetClockForTesting pins the clock. Returns a restore func. +func SetClockForTesting(f func() float64) func() { + prev := nowMillis + nowMillis = f + return func() { nowMillis = prev } +} + +// randomFloat is the unseeded random source used when no RandomSeed is +// configured. +var randomFloat = func() float64 { return rand.Float64() } + +// SetRandomForTesting pins the unseeded random source. Returns a restore func. +func SetRandomForTesting(f func() float64) func() { + prev := randomFloat + randomFloat = f + return func() { randomFloat = prev } +} + +// sleepMillis is the backoff sleep inside Pick, interruptible by the signal. +var sleepMillis = defaultSleep + +// SetSleeperForTesting pins the pick() backoff sleep. Returns a restore func. +func SetSleeperForTesting(f func(ms float64, signal *AbortSignal)) func() { + prev := sleepMillis + sleepMillis = f + return func() { sleepMillis = prev } +} + +func defaultSleep(ms float64, signal *AbortSignal) { + d := time.Duration(0) + if !math.IsNaN(ms) && ms > 0 { + if ms > 1e15 { + ms = 1e15 + } + d = time.Duration(ms * float64(time.Millisecond)) + } + timer := time.NewTimer(d) + defer timer.Stop() + if signal == nil { + <-timer.C + return + } + select { + case <-timer.C: + case <-signal.Done(): + } +} + +// AbortSignal lets a caller interrupt a blocking Pick. +type AbortSignal struct { + mu sync.Mutex + aborted bool + done chan struct{} +} + +func NewAbortSignal() *AbortSignal { return &AbortSignal{done: make(chan struct{})} } + +func (s *AbortSignal) Abort() { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if !s.aborted { + s.aborted = true + close(s.done) + } +} + +func (s *AbortSignal) Aborted() bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.aborted +} + +func (s *AbortSignal) Done() <-chan struct{} { + if s == nil { + return nil + } + return s.done +} + +// ── ModelTier ──────────────────────────────────────────────────────────── + +// ModelTier names one of the three configurable model pools. A caller asks +// for a tier; EffectiveTier turns that request into the tier actually routed +// on, which is the high tier whenever the requested pool is empty. +type ModelTier string + +const ( + ModelTierHigh ModelTier = "high" + ModelTierLow ModelTier = "low" + ModelTierFrontier ModelTier = "frontier" +) + +// ── ModelCandidate ─────────────────────────────────────────────────────── + +// ModelCandidate is one routable model. +type ModelCandidate struct { + // ID is the full model id, e.g. "openrouter/qwen/qwen3.6-plus". + ID string `json:"id"` + // Tier is the pool the candidate was configured into. + Tier ModelTier `json:"tier"` + PromptUSDPerMtok float64 `json:"prompt_usd_per_mtok"` + CompletionUSDPerMtok float64 `json:"completion_usd_per_mtok"` + // Priority is the config index: lower = preferred when stats are absent. + Priority float64 `json:"priority"` +} + +// ── event / config / choice shapes ─────────────────────────────────────── + +// AdaptiveRouteEvent describes one registered outcome or cancellation. +type AdaptiveRouteEvent struct { + Slot string `json:"slot"` + Tier ModelTier `json:"tier"` + Model string `json:"model"` + PreviousModel string `json:"previous_model"` + Switched bool `json:"switched"` + Reason string `json:"reason"` + Score float64 `json:"score"` + ElapsedS float64 `json:"elapsed_s"` + Attempts float64 `json:"attempts"` + Successes float64 `json:"successes"` + Failures float64 `json:"failures"` + RateLimits float64 `json:"rate_limits"` + LatencyEwma float64 `json:"latency_ewma"` + ToksecEwma float64 `json:"toksec_ewma"` + Error string `json:"error"` +} + +// AdaptiveRouterConfig configures a router. A nil pointer field takes its +// default, and so does an empty high pool; an empty low or frontier pool +// stays empty and degrades to high instead. +type AdaptiveRouterConfig struct { + HighModels []ModelCandidate `json:"high_models"` + // LowModels and FrontierModels have no default: an unset pool stays empty + // and every caller asking for it routes on HIGH instead. + LowModels []ModelCandidate `json:"low_models"` + FrontierModels []ModelCandidate `json:"frontier_models"` + MaxAttempts *float64 `json:"max_attempts"` + // RandomSeed makes the router's sampling reproducible. Nil uses the + // process-wide random source. + RandomSeed *float64 `json:"random_seed"` + OnEvent func(AdaptiveRouteEvent) `json:"-"` +} + +type modelStats struct { + ModelID string + Slot string + Tier ModelTier + Attempts float64 + Successes float64 + Failures float64 + RateLimits float64 + Inflight float64 + CooldownUntilMs float64 + LatencyEwma float64 + ToksecEwma float64 + QualityEwma float64 + RewardCount float64 + LastError string +} + +// RouteChoice is a picked candidate together with why it was picked. +type RouteChoice struct { + Slot string `json:"slot"` + // Tier is the tier actually routed on, after degradation. + Tier ModelTier `json:"tier"` + Candidate ModelCandidate `json:"candidate"` + Score float64 `json:"score"` + PreviousModel string `json:"previous_model"` + Switched bool `json:"switched"` + Reason string `json:"reason"` +} + +// ── defaults ───────────────────────────────────────────────────────────── + +func defaultPool(tier ModelTier, rows [][3]any) []ModelCandidate { + out := make([]ModelCandidate, 0, len(rows)) + for _, row := range rows { + out = append(out, ModelCandidate{ + ID: row[0].(string), + Tier: tier, + PromptUSDPerMtok: row[1].(float64), + CompletionUSDPerMtok: row[2].(float64), + }) + } + return out +} + +func DefaultHighModels() []ModelCandidate { + return defaultPool(ModelTierHigh, [][3]any{ + {"openrouter/deepseek/deepseek-v4-flash-0731", 0.09, 0.18}, + {"openrouter/qwen/qwen3.6-plus", 0.325, 1.95}, + {"openrouter/qwen/qwen3.5-plus-20260420", 0.4, 2.4}, + {"openrouter/deepseek/deepseek-v4-pro", 0.435, 0.87}, + {"openrouter/moonshotai/kimi-k2.6", 0.74, 3.49}, + {"openrouter/z-ai/glm-5.1", 0.98, 3.08}, + {"openrouter/minimax/minimax-m2.7", 0.2, 1.2}, + {"openrouter/qwen/qwen3-coder-next", 0.14, 0.8}, + }) +} + +// ── normalization ──────────────────────────────────────────────────────── + +// NormalizeCandidateModel trims a model id; the "openrouter/" prefix stays. +func NormalizeCandidateModel(model string) string { + return strings.TrimSpace(model) +} + +// statsKey identifies a stats row. A leading "openrouter/" is stripped so +// the prefixed and bare spellings of one model share their history. +func statsKey(slot string, candidate ModelCandidate) string { + id := strings.TrimSpace(candidate.ID) + id = strings.TrimPrefix(id, "openrouter/") + return strings.TrimSpace(slot) + ":" + id +} + +type normalizedConfig struct { + HighModels []ModelCandidate + LowModels []ModelCandidate + FrontierModels []ModelCandidate + MaxAttempts float64 + OnEvent func(AdaptiveRouteEvent) +} + +// normalizePool trims ids, drops blank entries, stamps the tier and numbers +// priorities by position. +func normalizePool(in []ModelCandidate, tier ModelTier) []ModelCandidate { + out := make([]ModelCandidate, 0, len(in)) + for _, c := range in { + id := NormalizeCandidateModel(c.ID) + if id == "" { + continue + } + normalized := c + normalized.ID = id + normalized.Tier = tier + normalized.Priority = float64(len(out)) + normalized.PromptUSDPerMtok = finiteOrZero(c.PromptUSDPerMtok) + normalized.CompletionUSDPerMtok = finiteOrZero(c.CompletionUSDPerMtok) + out = append(out, normalized) + } + return out +} + +func normalizeConfig(cfg AdaptiveRouterConfig) normalizedConfig { + high := cfg.HighModels + if len(high) == 0 { + high = DefaultHighModels() + } + maxAttempts := 3.0 + if cfg.MaxAttempts != nil && *cfg.MaxAttempts > 0 { + maxAttempts = *cfg.MaxAttempts + } + onEvent := cfg.OnEvent + if onEvent == nil { + onEvent = func(AdaptiveRouteEvent) {} + } + return normalizedConfig{ + HighModels: normalizePool(high, ModelTierHigh), + LowModels: normalizePool(cfg.LowModels, ModelTierLow), + FrontierModels: normalizePool(cfg.FrontierModels, ModelTierFrontier), + MaxAttempts: maxAttempts, + OnEvent: onEvent, + } +} + +// ── error classification ───────────────────────────────────────────────── + +// StatusCoder is implemented by errors that carry an HTTP status. +type StatusCoder interface { + ErrorStatusCode() (float64, bool) +} + +// Namer is implemented by errors with a symbolic name; "AbortError" marks a +// timeout regardless of message text. +type Namer interface { + ErrorName() string +} + +// Detailer is implemented by errors that carry extra text the classifiers +// should search, such as a provider's raw error body. +type Detailer interface { + ErrorDetail() string +} + +// errorText is the text the classifiers match against: the error message +// plus any detail an error exposes. +func errorText(err error) string { + if err == nil { + return "" + } + text := err.Error() + var detailer Detailer + if errors.As(err, &detailer) { + if detail := detailer.ErrorDetail(); detail != "" && !strings.Contains(text, detail) { + text += " " + detail + } + } + return text +} + +func statusCodeOf(err error) (float64, bool) { + var coder StatusCoder + if err != nil && errors.As(err, &coder) { + return coder.ErrorStatusCode() + } + return 0, false +} + +func IsLikelyRateLimit(err error) bool { + if code, ok := statusCodeOf(err); ok && code == 429 { + return true + } + text := strings.ToLower(errorText(err)) + return containsAny(text, "429", "rate limit", "rate_limit", "too many requests") +} + +func IsLikelyProviderIncompatible(err error) bool { + text := strings.ToLower(errorText(err)) + return containsAny(text, + "no endpoints found", + "can handle the requested parameters", + "unsupported parameter", + "unsupported parameters", + "does not support tools", + "doesn't support tools", + "does not support structured", + "doesn't support structured", + ) +} + +func IsLikelyTimeout(err error) bool { + var namer Namer + if err != nil && errors.As(err, &namer) && namer.ErrorName() == "AbortError" { + return true + } + text := strings.ToLower(errorText(err)) + return containsAny(text, "timeout", "timed out", "deadline exceeded") +} + +func IsLikelyStructuredFailure(err error) bool { + text := strings.ToLower(errorText(err)) + if containsAny(text, "structured output failed", "response_format", "json_schema", "invalid json") { + return true + } + if strings.Contains(text, "json") && containsAny(text, "unmarshal", "decode", "parse") { + return true + } + if strings.Contains(text, "schema") && containsAny(text, "validation", "parse", "400") { + return true + } + return false +} + +// IsLikelyTransientProviderError covers HTTP 5xx and generic upstream +// provider errors that are neither rate limits nor schema issues. +func IsLikelyTransientProviderError(err error) bool { + if code, ok := statusCodeOf(err); ok && code >= 500 && code <= 599 { + return true + } + text := strings.ToLower(errorText(err)) + return containsAny(text, + "provider returned error", + `error_type":"unmapped"`, + "upstream", + "bad gateway", + "service unavailable", + "gateway timeout", + ) +} + +func IsRetryableRouteError(err error) bool { + return IsLikelyRateLimit(err) || + IsLikelyProviderIncompatible(err) || + IsLikelyTimeout(err) || + IsLikelyStructuredFailure(err) || + IsLikelyTransientProviderError(err) +} + +func containsAny(text string, needles ...string) bool { + for _, needle := range needles { + if strings.Contains(text, needle) { + return true + } + } + return false +} + +// ── math helpers ───────────────────────────────────────────────────────── + +func updateEwma(prev float64, next float64, alpha float64) float64 { + if prev <= 0 { + return next + } + return alpha*next + (1-alpha)*prev +} + +func updateRewardEwma(prev float64, next float64, count float64, alpha float64) float64 { + if count <= 0 { + return next + } + return alpha*next + (1-alpha)*prev +} + +func rateLimitCooldownSeconds(count float64) float64 { + if count <= 0 { + return 10.0 + } + return 10 * math.Pow(2, math.Min(count, 5)-1) +} + +// immediateFailureReward maps a failure to a quality reward, or reports +// that the failure carries no signal about the model's quality. +func immediateFailureReward(err error) (float64, bool) { + if IsLikelyRateLimit(err) || IsLikelyProviderIncompatible(err) { + return -1.0, true + } + if IsLikelyTimeout(err) { + return -0.95, true + } + if IsLikelyStructuredFailure(err) { + return -0.9, true + } + if IsLikelyTransientProviderError(err) { + return -0.85, true + } + if strings.Contains(strings.ToLower(errorText(err)), "schema") { + return -0.9, true + } + return 0, false +} + +// ── sampling ───────────────────────────────────────────────────────────── + +// makeRng returns the router's random source: a seeded generator when a +// seed is configured, otherwise the package source. +func makeRng(seed *float64) func() float64 { + if seed == nil { + return func() float64 { return randomFloat() } + } + return rand.New(rand.NewSource(int64(*seed))).Float64 +} + +// gauss is a Box-Muller standard normal draw. +func gauss(rng func() float64) float64 { + u, v := 0.0, 0.0 + for u == 0 { + u = rng() + } + for v == 0 { + v = rng() + } + return math.Sqrt(-2.0*math.Log(u)) * math.Cos(2.0*math.Pi*v) +} + +// sampleGamma is Marsaglia & Tsang, recursive for shape < 1. +func sampleGamma(rng func() float64, shape float64) float64 { + if shape <= 0 { + return 0 + } + if shape < 1 { + u := math.Max(rng(), 1e-12) + return sampleGamma(rng, shape+1) * math.Pow(u, 1/shape) + } + d := shape - 1.0/3.0 + c := 1.0 / math.Sqrt(9*d) + for { + x := gauss(rng) + v := 1 + c*x + if v <= 0 { + continue + } + v = v * v * v + u := rng() + if u < 1-0.0331*x*x*x*x { + return d * v + } + if math.Log(u) < 0.5*x*x+d*(1-v+math.Log(v)) { + return d * v + } + } +} + +func sampleBeta(rng func() float64, alpha float64, beta float64) float64 { + x := sampleGamma(rng, alpha) + y := sampleGamma(rng, beta) + if x <= 0 && y <= 0 { + return 0.5 + } + return x / (x + y) +} + +// ── TryPickResult ──────────────────────────────────────────────────────── + +// TryPickResult is the non-blocking pick outcome. Ok==false means every +// candidate in the pool is cooling. +type TryPickResult struct { + Ok bool `json:"ok"` + Choice RouteChoice `json:"choice,omitzero"` + Reason string `json:"reason,omitempty"` + RetryAfterMs float64 `json:"retryAfterMs,omitempty"` +} + +// ── the router ─────────────────────────────────────────────────────────── + +type AdaptiveModelRouter struct { + mu sync.Mutex + cfg normalizedConfig + rng func() float64 + // stats is keyed by statsKey(slot, candidate). + stats map[string]*modelStats + last map[string]string +} + +func NewAdaptiveModelRouter(cfg AdaptiveRouterConfig) *AdaptiveModelRouter { + return &AdaptiveModelRouter{ + cfg: normalizeConfig(cfg), + rng: makeRng(cfg.RandomSeed), + stats: map[string]*modelStats{}, + last: map[string]string{}, + } +} + +// MaxAttempts is the configured attempt budget per call. +func (r *AdaptiveModelRouter) MaxAttempts() float64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.cfg.MaxAttempts +} + +// CandidatesForTier is the pool the tier routes on, after degradation. +func (r *AdaptiveModelRouter) CandidatesForTier(tier ModelTier) []ModelCandidate { + r.mu.Lock() + defer r.mu.Unlock() + return r.candidatesForTier(r.effectiveTierLocked(tier)) +} + +// candidatesForTier takes an already-degraded tier. +func (r *AdaptiveModelRouter) candidatesForTier(tier ModelTier) []ModelCandidate { + switch tier { + case ModelTierLow: + return r.cfg.LowModels + case ModelTierFrontier: + return r.cfg.FrontierModels + default: + return r.cfg.HighModels + } +} + +// EffectiveTier resolves the tier a caller should actually route on: a tier +// whose pool is empty degrades to HIGH, so no run depends on LOW or FRONTIER +// being configured, and an unrecognised tier routes on HIGH. +func (r *AdaptiveModelRouter) EffectiveTier(tier ModelTier) ModelTier { + r.mu.Lock() + defer r.mu.Unlock() + return r.effectiveTierLocked(tier) +} + +func (r *AdaptiveModelRouter) effectiveTierLocked(tier ModelTier) ModelTier { + switch tier { + case ModelTierLow: + if len(r.cfg.LowModels) > 0 { + return ModelTierLow + } + case ModelTierFrontier: + if len(r.cfg.FrontierModels) > 0 { + return ModelTierFrontier + } + } + return ModelTierHigh +} + +// TryPick is the non-blocking pick. Pass at most one nowMs to override the +// clock. +func (r *AdaptiveModelRouter) TryPick(slot string, tier ModelTier, nowMs ...float64) TryPickResult { + r.mu.Lock() + defer r.mu.Unlock() + return r.tryPickLocked(slot, tier, nowMs...) +} + +func (r *AdaptiveModelRouter) tryPickLocked( + slot string, tier ModelTier, nowMsOpt ...float64, +) TryPickResult { + nowMs := nowMillis() + if len(nowMsOpt) > 0 { + nowMs = nowMsOpt[0] + } + // Degrade once, here: the pool, the score weighting and the tier on the + // choice all read the tier actually routed on. + tier = r.effectiveTierLocked(tier) + + candidates := r.candidatesForTier(tier) + if len(candidates) == 0 { + // No candidates configured at all: choose installs the hard-coded + // fallback. Treat as a successful pick. + return TryPickResult{Ok: true, Choice: r.lease(slot, tier, nowMs, nil, false)} + } + + anyAvailable := false + for _, c := range candidates { + if r.statsFor(slot, c).CooldownUntilMs <= nowMs { + anyAvailable = true + break + } + } + if !anyAvailable { + earliestMs := math.Inf(1) + for _, c := range candidates { + st := r.statsFor(slot, c) + if st.CooldownUntilMs > nowMs && st.CooldownUntilMs < earliestMs { + earliestMs = st.CooldownUntilMs + } + } + retryAfterMs := 1000.0 + if !math.IsInf(earliestMs, 0) && !math.IsNaN(earliestMs) { + retryAfterMs = math.Max(100, earliestMs-nowMs) + } + return TryPickResult{ + Ok: false, + Reason: "all-busy", + RetryAfterMs: retryAfterMs, + } + } + + return TryPickResult{Ok: true, Choice: r.lease(slot, tier, nowMs, candidates, false)} +} + +// lease chooses a candidate and takes an in-flight slot on it. +func (r *AdaptiveModelRouter) lease( + slot string, tier ModelTier, nowMs float64, candidates []ModelCandidate, relaxed bool, +) RouteChoice { + choice := r.choose(slot, tier, nowMs, candidates, relaxed) + st := r.statsFor(slot, choice.Candidate) + st.Inflight += 1 + return choice +} + +// PickOptions bounds a blocking Pick. +type PickOptions struct { + TimeoutMs *float64 + Signal *AbortSignal +} + +// PickContext binds waiting for a route to the caller's actual lifetime. +// A cancellation before a lease is returned must not leak an in-flight slot. +func (r *AdaptiveModelRouter) PickContext( + ctx context.Context, slot string, tier ModelTier, +) (RouteChoice, error) { + signal := NewAbortSignal() + stop := context.AfterFunc(ctx, signal.Abort) + defer stop() + if ctx.Err() != nil { + signal.Abort() + } + choice, err := r.Pick(slot, tier, PickOptions{Signal: signal}) + if ctx.Err() != nil { + if err == nil { + r.RegisterCanceled(choice) + } else { + // The effective tier, so every emitted tier means the tier + // routed on and never the one asked for. + r.emitCancellation(AdaptiveRouteEvent{ + Slot: slot, Tier: r.EffectiveTier(tier), + Reason: "caller-canceled-pick", + }) + } + return RouteChoice{}, context.Cause(ctx) + } + return choice, err +} + +// RegisterCanceled releases a caller-canceled lease without attributing a +// success, failure, latency sample or cooldown to the provider. The owner must +// serialize this with Register: exactly one terminal accounting action per pick. +func (r *AdaptiveModelRouter) RegisterCanceled(choice RouteChoice) { + r.mu.Lock() + st := r.statsFor(choice.Slot, choice.Candidate) + if st.Inflight > 0 { + st.Inflight-- + } + event := AdaptiveRouteEvent{ + Slot: choice.Slot, Tier: choice.Tier, Model: choice.Candidate.ID, + Reason: "caller-canceled-request", Score: choice.Score, + Attempts: st.Attempts, Successes: st.Successes, + Failures: st.Failures, RateLimits: st.RateLimits, + LatencyEwma: st.LatencyEwma, ToksecEwma: st.ToksecEwma, + } + r.mu.Unlock() + r.emitCancellation(event) +} + +func (r *AdaptiveModelRouter) emitCancellation(event AdaptiveRouteEvent) { + defer func() { _ = recover() }() + r.cfg.OnEvent(event) +} + +// Pick blocks (polling TryPick with bounded exponential backoff) until a +// candidate is available or `timeoutMs` (default 5 minutes) elapses. +// +// The caller must settle the returned lease exactly once, with Register or +// RegisterCanceled. +func (r *AdaptiveModelRouter) Pick( + slot string, tier ModelTier, opts ...PickOptions, +) (RouteChoice, error) { + var opt PickOptions + if len(opts) > 0 { + opt = opts[0] + } + timeoutMs := 5 * 60 * 1000.0 + if opt.TimeoutMs != nil { + timeoutMs = *opt.TimeoutMs + } + deadline := nowMillis() + timeoutMs + waitMs := 100.0 + maxWaitMs := 2000.0 + + for { + if opt.Signal.Aborted() { + return RouteChoice{}, fmt.Errorf("router.pick aborted for slot=%s tier=%s", slot, tier) + } + r.mu.Lock() + result := r.tryPickLocked(slot, tier) + r.mu.Unlock() + if result.Ok { + return result.Choice, nil + } + now := nowMillis() + if now >= deadline { + return RouteChoice{}, fmt.Errorf( + "router.pick timeout after %sms (%s) — slot=%s tier=%s", + strconv.FormatFloat(timeoutMs, 'f', -1, 64), result.Reason, slot, tier) + } + sleepMs := math.Min(math.Min(waitMs, math.Max(50, deadline-now)), result.RetryAfterMs) + sleepMillis(sleepMs, opt.Signal) + waitMs = math.Min(waitMs*2, maxWaitMs) + } +} + +// PickSync always returns a choice: when every candidate is cooling it takes +// the least bad one from the pool and says so. +func (r *AdaptiveModelRouter) PickSync(slot string, tier ModelTier) RouteChoice { + r.mu.Lock() + defer r.mu.Unlock() + tier = r.effectiveTierLocked(tier) + result := r.tryPickLocked(slot, tier) + if result.Ok { + return result.Choice + } + return r.lease(slot, tier, nowMillis(), r.candidatesForTier(tier), true) +} + +// Register records the outcome of a call started by Pick. Pass err == nil on +// success. +func (r *AdaptiveModelRouter) Register(choice RouteChoice, elapsedSeconds float64, completionTokens float64, err error) AdaptiveRouteEvent { + event, onEvent := r.registerLocked(choice, elapsedSeconds, completionTokens, err) + // The listener runs after the lock is dropped, so one that calls back + // into the router re-enters instead of deadlocking. + func() { + defer func() { _ = recover() }() + onEvent(event) + }() + return event +} + +func (r *AdaptiveModelRouter) registerLocked(choice RouteChoice, elapsedSeconds float64, completionTokens float64, err error) (AdaptiveRouteEvent, func(AdaptiveRouteEvent)) { + r.mu.Lock() + defer r.mu.Unlock() + + st := r.statsFor(choice.Slot, choice.Candidate) + if st.Inflight > 0 { + st.Inflight -= 1 + } + st.Attempts += 1 + if err == nil { + st.Successes += 1 + } else { + st.Failures += 1 + st.LastError = truncateChars(errorText(err), 400) + now := nowMillis() + switch { + case IsLikelyRateLimit(err): + st.RateLimits += 1 + st.CooldownUntilMs = now + rateLimitCooldownSeconds(st.RateLimits)*1000 + case IsLikelyProviderIncompatible(err): + st.CooldownUntilMs = now + 3600*1000 + case IsLikelyTimeout(err): + st.CooldownUntilMs = now + 120*1000 + case IsLikelyTransientProviderError(err): + // Short cooldown: encourage a different model next call, but let + // this one back quickly since 5xx is usually genuinely transient. + st.CooldownUntilMs = now + 60*1000 + } + if reward, ok := immediateFailureReward(err); ok { + st.QualityEwma = updateRewardEwma(st.QualityEwma, reward, st.RewardCount, 0.08) + st.RewardCount += 1 + } + } + st.LatencyEwma = updateEwma(st.LatencyEwma, elapsedSeconds, 0.35) + if completionTokens > 0 && elapsedSeconds > 0 { + st.ToksecEwma = updateEwma(st.ToksecEwma, completionTokens/elapsedSeconds, 0.35) + } + + errText := "" + if err != nil { + errText = truncateChars(errorText(err), 300) + } + event := AdaptiveRouteEvent{ + Slot: choice.Slot, + Tier: choice.Tier, + Model: choice.Candidate.ID, + PreviousModel: choice.PreviousModel, + Switched: choice.Switched, + Reason: choice.Reason, + Score: choice.Score, + ElapsedS: elapsedSeconds, + Attempts: st.Attempts, + Successes: st.Successes, + Failures: st.Failures, + RateLimits: st.RateLimits, + LatencyEwma: st.LatencyEwma, + ToksecEwma: st.ToksecEwma, + Error: errText, + } + return event, r.cfg.OnEvent +} + +// truncateChars keeps the first n characters of s without splitting a +// multi-byte character. +func truncateChars(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + runes := []rune(s) + return string(runes[:n]) +} + +// ── internals ──────────────────────────────────────────────────────────── + +// choose scores and selects from a pre-filtered candidate list. `relaxed` +// prefixes the reason with `constraint-relaxed:` so telemetry can tell a clean +// pick from a relaxed one. +func (r *AdaptiveModelRouter) choose( + slot string, + tier ModelTier, + nowMs float64, + preFilteredCandidates []ModelCandidate, + relaxed bool, +) RouteChoice { + candidates := preFilteredCandidates + if len(candidates) == 0 { + candidates = r.candidatesForTier(tier) + } + if len(candidates) == 0 { + candidates = []ModelCandidate{{ID: "openrouter/openai/gpt-oss-120b", Tier: tier}} + } + previous := r.last[slot] + previousScore := math.Inf(-1) + previousCooling := false + previousInflight := 0.0 + previousRateLimits := 0.0 + best := RouteChoice{ + Slot: slot, + Tier: tier, + Candidate: candidates[0], + Score: math.Inf(-1), + PreviousModel: previous, + } + for _, cand := range candidates { + st := r.statsFor(slot, cand) + if st.CooldownUntilMs > nowMs { + if cand.ID == previous { + previousCooling = true + previousInflight = st.Inflight + previousRateLimits = st.RateLimits + } + continue + } + score := r.score(tier, cand, st) + if cand.ID == previous { + previousScore = score + previousInflight = st.Inflight + previousRateLimits = st.RateLimits + } + if score > best.Score { + best = RouteChoice{ + Slot: slot, Tier: tier, Candidate: cand, + Score: score, PreviousModel: previous, + } + } + } + // Forced fallback: every candidate is cooling. Pick the least bad with a + // -2.0 penalty. + if math.IsInf(best.Score, -1) { + for _, cand := range candidates { + st := r.statsFor(slot, cand) + score := r.score(tier, cand, st) - 2.0 + if score > best.Score { + best = RouteChoice{Slot: slot, Tier: tier, Candidate: cand, Score: score, + PreviousModel: previous, Reason: "forced"} + } + } + } + if math.IsInf(best.Score, -1) { + // Nothing produced a finite score; keep the first candidate rather + // than reporting an unrepresentable score. + best.Score = -2.0 + best.Reason = "forced" + } + best.PreviousModel = previous + best.Switched = previous != "" && previous != best.Candidate.ID + best.Reason = routeReason(best, previousScore, previousCooling, previousInflight, previousRateLimits) + if relaxed { + best.Reason = "constraint-relaxed:" + best.Reason + } + r.last[slot] = best.Candidate.ID + return best +} + +func (r *AdaptiveModelRouter) score( + tier ModelTier, cand ModelCandidate, st *modelStats, +) float64 { + // Cold-model optimism: an unattempted model gets a uniform draw in + // [0.72, 0.90) instead of a Beta posterior. + var reliability float64 + if st.Attempts == 0 { + reliability = 0.72 + r.rng()*0.18 + } else { + reliability = sampleBeta(r.rng, st.Successes+2, st.Failures+1) + } + latency := 15.0 + cand.Priority + if st.LatencyEwma > 0 { + latency = st.LatencyEwma + } + toksec := 35.0 + if st.ToksecEwma > 0 { + toksec = st.ToksecEwma + } + price := cand.PromptUSDPerMtok + cand.CompletionUSDPerMtok + if !(price > 0) { + price = 1.0 + } + cost := 1.0 / (1.0 + price) + speed := toksec / (toksec + 80.0) + latencyPen := latency / (latency + 45.0) + pressurePen := st.Inflight * 0.12 + rlRisk := 0.0 + if st.Attempts > 0 { + rlRisk = st.RateLimits / st.Attempts + } + quality := 0.0 + if st.RewardCount > 0 { + quality = math.Max(-1.0, math.Min(1.0, st.QualityEwma)) + } + var score float64 + if tier == ModelTierLow { + // The low tier buys throughput and price: speed and cost carry more + // than twice the weight they do on the other tiers. + score = 0.38*reliability + 0.3*speed + 0.16*cost + + 0.06*quality - 0.1*latencyPen - pressurePen - 0.45*rlRisk + } else { + // HIGH and FRONTIER: reliability dominates, cost and speed matter + // little. + score = 0.68*reliability + 0.14*speed + 0.08*cost + + 0.08*quality - 0.08*latencyPen - pressurePen - 0.5*rlRisk + } + if math.IsNaN(score) { + return math.Inf(-1) + } + return score +} + +func (r *AdaptiveModelRouter) statsFor(slot string, cand ModelCandidate) *modelStats { + key := statsKey(slot, cand) + st, ok := r.stats[key] + if !ok { + st = &modelStats{} + r.stats[key] = st + } + st.ModelID = cand.ID + st.Slot = slot + st.Tier = cand.Tier + return st +} + +func routeReason( + best RouteChoice, + previousScore float64, + previousCooling bool, + previousInflight float64, + previousRateLimits float64, +) string { + if best.Reason == "forced" { + return "all-cooling" + } + if best.PreviousModel == "" { + return "initial" + } + if best.PreviousModel == best.Candidate.ID { + return "stay" + } + if previousCooling { + return "previous-cooling" + } + if previousRateLimits > 0 { + return "previous-rate-limited" + } + if previousInflight > 0 { + return "previous-busy" + } + if !math.IsInf(previousScore, 0) && !math.IsNaN(previousScore) && best.Score > previousScore { + return "better-score" + } + return "switch" +} + +// ── CLI parsing ────────────────────────────────────────────────────────── + +// ParseModelList parses a comma-separated `--high`, `--low` or `--frontier` +// flag value into the named tier's pool. Each entry is either +// "openrouter/qwen/qwen3.6-plus" or +// "openrouter/qwen/qwen3.6-plus@0.325/1.95" (id + prompt$/completion$ per Mtok). +// Cost defaults to zero, which degenerates the cost term to a neutral 0.5. +func ParseModelList(raw *string, tier ModelTier) []ModelCandidate { + if raw == nil || *raw == "" { + return []ModelCandidate{} + } + var entries []string + for _, s := range strings.Split(*raw, ",") { + if trimmed := strings.TrimSpace(s); trimmed != "" { + entries = append(entries, trimmed) + } + } + out := make([]ModelCandidate, 0, len(entries)) + for i, entry := range entries { + id := entry + prompt := 0.0 + completion := 0.0 + if at := strings.LastIndex(entry, "@"); at > 0 { + id = entry[:at] + if p, c, ok := strings.Cut(entry[at+1:], "/"); ok { + prompt = parsePrice(p) + completion = parsePrice(c) + } + } + out = append(out, ModelCandidate{ + ID: NormalizeCandidateModel(id), + Tier: tier, + PromptUSDPerMtok: prompt, + CompletionUSDPerMtok: completion, + Priority: float64(i), + }) + } + return out +} + +// parsePrice reads a $/Mtok figure; anything unparsable or non-finite is 0. +func parsePrice(s string) float64 { + f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return 0 + } + return finiteOrZero(f) +} + +// finiteOrZero maps a non-finite price to 0 so every candidate stays +// encodable and the cost term degenerates to neutral. +func finiteOrZero(f float64) float64 { + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0 + } + return f +} diff --git a/internal/seniordev/router/adaptive/adaptive_contract_test.go b/internal/seniordev/router/adaptive/adaptive_contract_test.go new file mode 100644 index 0000000000..37f5338761 --- /dev/null +++ b/internal/seniordev/router/adaptive/adaptive_contract_test.go @@ -0,0 +1,181 @@ +//go:build !windows + +package adaptive + +import ( + "encoding/json" + "errors" + "math" + "strings" + "testing" +) + +type statusErr struct { + msg string + status float64 +} + +func (e statusErr) Error() string { return e.msg } +func (e statusErr) ErrorStatusCode() (float64, bool) { return e.status, true } + +func TestRegisterOutcomesDriveCooldownsAndStats(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/only")}, + RandomSeed: seed(5), + }) + first := router.TryPick("s", ModelTierHigh) + if !first.Ok { + t.Fatal("first pick must succeed") + } + event := router.Register(first.Choice, 2, 100, nil) + if event.Attempts != 1 || event.Successes != 1 || event.Failures != 0 || event.Error != "" { + t.Fatalf("success event = %+v", event) + } + if event.LatencyEwma != 2 || event.ToksecEwma != 50 { + t.Fatalf("ewma after one sample = %+v", event) + } + + second := router.TryPick("s", ModelTierHigh) + event = router.Register(second.Choice, 1, 0, statusErr{msg: "Too Many Requests", status: 429}) + if event.Failures != 1 || event.RateLimits != 1 || !strings.Contains(event.Error, "Too Many Requests") { + t.Fatalf("rate-limit event = %+v", event) + } + // The only candidate is now cooling, so a non-blocking pick reports busy. + third := router.TryPick("s", ModelTierHigh) + if third.Ok || third.Reason != "all-busy" || third.RetryAfterMs <= 0 { + t.Fatalf("pick during cooldown = %+v", third) + } + // PickSync forces a choice anyway and says so. + forced := router.PickSync("s", ModelTierHigh) + if !strings.Contains(forced.Reason, "all-cooling") { + t.Fatalf("forced reason = %q", forced.Reason) + } + router.Register(forced, 1, 1, nil) + if st := router.statsFor("s", forced.Candidate); st.Inflight != 0 { + t.Fatalf("inflight after settling every lease = %v", st.Inflight) + } +} + +func TestClassifiersReadStatusNameAndDetail(t *testing.T) { + if !IsLikelyRateLimit(statusErr{msg: "nope", status: 429}) { + t.Error("a 429 status is a rate limit") + } + if !IsLikelyTransientProviderError(statusErr{msg: "nope", status: 503}) { + t.Error("a 5xx status is transient") + } + if !IsLikelyTimeout(errors.New("SSE read timed out")) { + t.Error("timed out is a timeout") + } + if !IsLikelyProviderIncompatible(errors.New("No endpoints found for this model")) { + t.Error("no endpoints is an incompatibility") + } + if IsRetryableRouteError(errors.New("the model refused")) { + t.Error("an ordinary failure is not retryable") + } + if IsRetryableRouteError(nil) { + t.Error("nil is not an error") + } +} + +func TestBlankCandidateIDsAreDropped(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand(" "), cand("openrouter/qwen/real"), cand("")}, + }) + pool := router.CandidatesForTier(ModelTierHigh) + if len(pool) != 1 || pool[0].ID != "openrouter/qwen/real" || pool[0].Priority != 0 { + t.Fatalf("pool = %+v", pool) + } +} + +func TestNaNPriceStillYieldsAFiniteScore(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{{ID: "openrouter/qwen/nanprice", PromptUSDPerMtok: math.NaN()}}, + RandomSeed: seed(37), + }) + result := router.TryPick("s", ModelTierHigh) + if !result.Ok || math.IsNaN(result.Choice.Score) || math.IsInf(result.Choice.Score, 0) { + t.Fatalf("pick = %+v", result) + } + if _, err := json.Marshal(result); err != nil { + t.Fatalf("a pick result must always be encodable: %v", err) + } +} + +func TestEmptyPoolInstallsTheFallbackCandidate(t *testing.T) { + pinRuntime(t) + // A pool whose every entry is blank normalizes to nothing, which is the + // only way to reach the router with no candidates at all: an unset pool + // takes the defaults instead. + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{HighModels: []ModelCandidate{cand(" ")}}) + result := router.TryPick("coder", ModelTierHigh) + if !result.Ok || result.Choice.Candidate.ID != "openrouter/openai/gpt-oss-120b" { + t.Fatalf("fallback pick = %+v", result) + } +} + +func TestSeededRoutersAreReproducible(t *testing.T) { + pinRuntime(t) + build := func() *AdaptiveModelRouter { + return NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/a"), cand("openrouter/deepseek/b"), cand("openrouter/moonshotai/c")}, + RandomSeed: seed(99), + }) + } + a, b := build(), build() + for i := 0; i < 5; i++ { + ca := a.PickSync("s", ModelTierHigh) + cb := b.PickSync("s", ModelTierHigh) + if ca.Candidate.ID != cb.Candidate.ID || ca.Score != cb.Score { + t.Fatalf("pick %d diverged: %+v vs %+v", i, ca, cb) + } + a.Register(ca, 1, 10, nil) + b.Register(cb, 1, 10, nil) + } +} + +func TestRouteChoiceRoundTripsThroughJSON(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/a@x")}, + }) + choice := router.PickSync("s", ModelTierHigh) + encoded, err := json.Marshal(choice) + if err != nil { + t.Fatal(err) + } + var decoded RouteChoice + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded != choice { + t.Fatalf("round trip changed the choice:\n %+v\n %+v", choice, decoded) + } + busy := TryPickResult{Reason: "all-busy", RetryAfterMs: 100} + encoded, err = json.Marshal(busy) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), `"choice"`) { + t.Fatalf("a busy result must not carry an empty choice: %s", encoded) + } +} + +func TestParseModelListPricesAreOptional(t *testing.T) { + raw := "openrouter/qwen/a@0.325/1.95,openrouter/deepseek/b@bogus/2,openrouter/moonshotai/c" + got := ParseModelList(&raw, ModelTierHigh) + if len(got) != 3 { + t.Fatalf("len = %d", len(got)) + } + if got[0].PromptUSDPerMtok != 0.325 || got[0].CompletionUSDPerMtok != 1.95 { + t.Fatalf("priced entry = %+v", got[0]) + } + if got[1].PromptUSDPerMtok != 0 || got[1].CompletionUSDPerMtok != 2 || got[1].ID != "openrouter/deepseek/b" { + t.Fatalf("partially priced entry = %+v", got[1]) + } + if got[2].Priority != 2 || got[2].ID != "openrouter/moonshotai/c" { + t.Fatalf("unpriced entry = %+v", got[2]) + } +} diff --git a/internal/seniordev/router/adaptive/adaptive_test.go b/internal/seniordev/router/adaptive/adaptive_test.go new file mode 100644 index 0000000000..53dcc2eebb --- /dev/null +++ b/internal/seniordev/router/adaptive/adaptive_test.go @@ -0,0 +1,107 @@ +//go:build !windows + +package adaptive + +// These tests install a fake sleeper that advances a fake clock, so no test +// can wall-clock sleep: a regression that made Pick block fails fast instead +// of hanging the suite. + +import ( + "errors" + "testing" +) + +func cand(id string) ModelCandidate { + return ModelCandidate{ + ID: id, + PromptUSDPerMtok: 1, + CompletionUSDPerMtok: 1, + Priority: 0, + } +} + +func seed(v float64) *float64 { return &v } + +// pinRuntime freezes the clock and turns the Pick backoff into a clock advance. +func pinRuntime(t *testing.T) { + t.Helper() + now := 1_700_000_000_000.0 + t.Cleanup(SetClockForTesting(func() float64 { return now })) + t.Cleanup(SetSleeperForTesting(func(ms float64, _ *AbortSignal) { now += ms })) +} + +func mustPick( + t *testing.T, r *AdaptiveModelRouter, slot string, tier ModelTier, + opts ...PickOptions, +) RouteChoice { + t.Helper() + choice, err := r.Pick(slot, tier, opts...) + if err != nil { + t.Fatalf("pick(%s): %v", slot, err) + } + return choice +} + +func TestPickTimeoutAndAbort(t *testing.T) { + t.Run("timeout names the reason and the slot", func(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/qwen-a"), cand("openrouter/deepseek/deepseek-a")}, + RandomSeed: seed(22), + }) + // Cool BOTH models for the slot for an hour. + for i := 0; i < 2; i++ { + r := router.TryPick("coder", ModelTierHigh) + router.Register(r.Choice, 1, 1, errors.New("No endpoints found for this model")) + } + + timeout := 5000.0 + _, err := router.Pick("coder", ModelTierHigh, PickOptions{TimeoutMs: &timeout}) + if err == nil { + t.Fatalf("expected a timeout error") + } + want := "router.pick timeout after 5000ms (all-busy) — slot=coder tier=high" + if err.Error() != want { + t.Errorf("error = %q,\n want %q", err.Error(), want) + } + }) + + t.Run("abort before the first tryPick throws immediately", func(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("openrouter/qwen/qwen-a")}, + RandomSeed: seed(23), + }) + signal := NewAbortSignal() + signal.Abort() + _, err := router.Pick("s", ModelTierHigh, PickOptions{Signal: signal}) + if err == nil || err.Error() != "router.pick aborted for slot=s tier=high" { + t.Errorf("error = %v", err) + } + }) +} + +func TestParseModelListShapes(t *testing.T) { + raw := "openrouter/qwen/a@0.325/1.95, ,openrouter/deepseek/b" + got := ParseModelList(&raw, ModelTierHigh) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].PromptUSDPerMtok != 0.325 || got[0].CompletionUSDPerMtok != 1.95 || got[0].Priority != 0 { + t.Errorf("first = %+v", got[0]) + } + if got[1].ID != "openrouter/deepseek/b" || got[1].Priority != 1 { + t.Errorf("second = %+v", got[1]) + } + if got[1].PromptUSDPerMtok != 0 || got[1].CompletionUSDPerMtok != 0 { + t.Errorf("unpriced entry should default to 0/0, got %+v", got[1]) + } + // An empty string yields no candidates, the same as nil. + empty := "" + if n := len(ParseModelList(&empty, ModelTierHigh)); n != 0 { + t.Errorf("empty string produced %d candidates", n) + } + if n := len(ParseModelList(nil, ModelTierHigh)); n != 0 { + t.Errorf("nil produced %d candidates", n) + } +} diff --git a/internal/seniordev/router/adaptive/cancellation_test.go b/internal/seniordev/router/adaptive/cancellation_test.go new file mode 100644 index 0000000000..bb1280143e --- /dev/null +++ b/internal/seniordev/router/adaptive/cancellation_test.go @@ -0,0 +1,53 @@ +//go:build !windows + +package adaptive + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestCanceledRouteReleasesWithoutHealthCredit(t *testing.T) { + pinRuntime(t) + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{HighModels: []ModelCandidate{cand("p/model")}}) + choice := mustPick(t, router, "coder", ModelTierHigh) + st := router.statsFor("coder", choice.Candidate) + before := *st + router.RegisterCanceled(choice) + before.Inflight-- + if *st != before { + t.Fatalf("cancellation changed health: before=%+v after=%+v", before, *st) + } + result := router.TryPick("coder", ModelTierHigh) + if !result.Ok { + t.Fatalf("canceled call created cooldown: %+v", result) + } + router.RegisterCanceled(result.Choice) +} + +func TestPickContextStopsDuringRealProviderCooldown(t *testing.T) { + var events []AdaptiveRouteEvent + router := NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{cand("p/model")}, + OnEvent: func(e AdaptiveRouteEvent) { events = append(events, e) }, + }) + choice := mustPick(t, router, "coder", ModelTierHigh) + router.Register(choice, 1, 0, errors.New("SSE read timed out")) + before := *router.statsFor("coder", choice.Candidate) + cause := errors.New("wall-clock budget exhausted") + ctx, cancel := context.WithTimeoutCause(context.Background(), 20*time.Millisecond, cause) + defer cancel() + started := time.Now() + _, err := router.PickContext(ctx, "coder", ModelTierHigh) + if !errors.Is(err, cause) || time.Since(started) > time.Second { + t.Fatalf("pick cancellation err=%v elapsed=%s", err, time.Since(started)) + } + if got := *router.statsFor("coder", choice.Candidate); got != before { + t.Fatalf("canceled wait modified prior provider health: %+v -> %+v", before, got) + } + if len(events) != 2 || events[1].Reason != "caller-canceled-pick" { + t.Fatalf("events=%+v", events) + } +} diff --git a/internal/seniordev/router/adaptive/tier_test.go b/internal/seniordev/router/adaptive/tier_test.go new file mode 100644 index 0000000000..3b794c54c2 --- /dev/null +++ b/internal/seniordev/router/adaptive/tier_test.go @@ -0,0 +1,130 @@ +//go:build !windows + +package adaptive + +import "testing" + +// tierRouter builds a router whose pools are exactly what is passed: the +// helper never falls back to the shipped defaults, so an empty pool here +// really is empty. +func tierRouter(t *testing.T, high, low, frontier []string) *AdaptiveModelRouter { + t.Helper() + pool := func(ids []string) []ModelCandidate { + out := make([]ModelCandidate, 0, len(ids)) + for _, id := range ids { + out = append(out, cand(id)) + } + return out + } + return NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: pool(high), + LowModels: pool(low), + FrontierModels: pool(frontier), + RandomSeed: seed(5), + }) +} + +func TestEmptyTierPoolsResolveToHigh(t *testing.T) { + pinRuntime(t) + router := tierRouter(t, []string{"openrouter/qwen/high-a"}, nil, nil) + for _, tier := range []ModelTier{ModelTierLow, ModelTierFrontier, ModelTierHigh} { + if got := router.EffectiveTier(tier); got != ModelTierHigh { + t.Errorf("EffectiveTier(%q) = %q, want %q", tier, got, ModelTierHigh) + } + pool := router.CandidatesForTier(tier) + if len(pool) != 1 || pool[0].ID != "openrouter/qwen/high-a" { + t.Errorf("CandidatesForTier(%q) = %+v, want the high pool", tier, pool) + } + } + if got := router.EffectiveTier("platinum"); got != ModelTierHigh { + t.Errorf("EffectiveTier(unknown) = %q, want %q", got, ModelTierHigh) + } +} + +func TestAConfiguredTierPoolStandsOnItsOwn(t *testing.T) { + pinRuntime(t) + router := tierRouter(t, + []string{"openrouter/qwen/high-a"}, + []string{"openrouter/qwen/low-a"}, + []string{"openrouter/anthropic/frontier-a"}, + ) + for tier, want := range map[ModelTier]string{ + ModelTierHigh: "openrouter/qwen/high-a", + ModelTierLow: "openrouter/qwen/low-a", + ModelTierFrontier: "openrouter/anthropic/frontier-a", + } { + if got := router.EffectiveTier(tier); got != tier { + t.Errorf("EffectiveTier(%q) = %q, want it unchanged", tier, got) + } + choice := router.PickSync("slot", tier) + if choice.Candidate.ID != want { + t.Errorf("PickSync(%q) = %q, want %q", tier, choice.Candidate.ID, want) + } + if choice.Tier != tier { + t.Errorf("choice tier = %q, want %q", choice.Tier, tier) + } + router.Register(choice, 1, 10, nil) + } +} + +func TestOneModelHighPoolServesEveryTier(t *testing.T) { + pinRuntime(t) + // The trivial configuration: `--high m` and nothing else. + router := tierRouter(t, []string{"openrouter/qwen/only"}, nil, nil) + for _, tier := range []ModelTier{ModelTierHigh, ModelTierLow, ModelTierFrontier} { + choice := router.PickSync("slot", tier) + if choice.Candidate.ID != "openrouter/qwen/only" { + t.Fatalf("PickSync(%q) = %q", tier, choice.Candidate.ID) + } + // The degradation happens before anything reads the tier, so the + // choice and its event report the tier actually routed on. + if choice.Tier != ModelTierHigh { + t.Fatalf("choice tier = %q, want %q", choice.Tier, ModelTierHigh) + } + event := router.Register(choice, 1, 10, nil) + if event.Tier != ModelTierHigh || event.Model != "openrouter/qwen/only" { + t.Fatalf("event = %+v", event) + } + } +} + +func TestDegradedTierIsScoredAsHigh(t *testing.T) { + pinRuntime(t) + // Two models that the two weightings rank differently: the cheap, fast + // one wins on LOW, the expensive, reliable one wins on HIGH. With no low + // pool configured, a LOW request must score exactly as a HIGH request + // does, down to the value. + slow := ModelCandidate{ID: "openrouter/qwen/slow", PromptUSDPerMtok: 9, CompletionUSDPerMtok: 9} + fast := ModelCandidate{ID: "openrouter/qwen/fast", PromptUSDPerMtok: 0.01, CompletionUSDPerMtok: 0.01} + build := func(low []ModelCandidate) *AdaptiveModelRouter { + return NewAdaptiveModelRouter(AdaptiveRouterConfig{ + HighModels: []ModelCandidate{slow, fast}, + LowModels: low, + RandomSeed: seed(11), + }) + } + degraded := build(nil).PickSync("slot", ModelTierLow) + asHigh := build(nil).PickSync("slot", ModelTierHigh) + if degraded.Candidate.ID != asHigh.Candidate.ID || degraded.Score != asHigh.Score { + t.Fatalf("degraded low pick %+v differs from the high pick %+v", degraded, asHigh) + } + // With a pool of its own, LOW scores on its own weighting. + configured := build([]ModelCandidate{slow, fast}).PickSync("slot", ModelTierLow) + if configured.Tier != ModelTierLow { + t.Fatalf("choice tier = %q, want %q", configured.Tier, ModelTierLow) + } + if configured.Score == asHigh.Score { + t.Fatalf("the low weighting produced the high score %v", configured.Score) + } +} + +func TestParseModelListStampsTheTier(t *testing.T) { + raw := "openrouter/qwen/a,openrouter/qwen/b" + for _, tier := range []ModelTier{ModelTierHigh, ModelTierLow, ModelTierFrontier} { + for _, candidate := range ParseModelList(&raw, tier) { + if candidate.Tier != tier { + t.Errorf("%q parsed with tier %q, want %q", candidate.ID, candidate.Tier, tier) + } + } + } +} diff --git a/internal/seniordev/router/state/adaptivewire.go b/internal/seniordev/router/state/adaptivewire.go new file mode 100644 index 0000000000..e4b737e801 --- /dev/null +++ b/internal/seniordev/router/state/adaptivewire.go @@ -0,0 +1,82 @@ +//go:build !windows + +package state + +// The adaptive router wiring: GetRouter hands back a real +// *adaptive.AdaptiveModelRouter whose event hook is bridged onto +// EmitRouteEvent, so every Register lands on the `[router]` NDJSON line. + +import ( + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// ToRouteEvent converts an adaptive event into the emitted record. +func ToRouteEvent(ev adaptive.AdaptiveRouteEvent) RouteEvent { + return RouteEvent{ + Slot: ev.Slot, + Tier: string(ev.Tier), + Model: ev.Model, + PreviousModel: ev.PreviousModel, + Switched: ev.Switched, + Reason: ev.Reason, + Score: ev.Score, + ElapsedS: ev.ElapsedS, + Attempts: ev.Attempts, + Successes: ev.Successes, + Failures: ev.Failures, + RateLimits: ev.RateLimits, + LatencyEwma: ev.LatencyEwma, + ToksecEwma: ev.ToksecEwma, + Error: ev.Error, + } +} + +// AdaptiveConfig coerces the opaque RouterConfig into adaptive's struct. Both +// a typed struct and a decoded JSON map work; anything else yields the zero +// config, which the router fills with its own defaults. +func AdaptiveConfig(cfg RouterConfig) adaptive.AdaptiveRouterConfig { + switch typed := cfg.(type) { + case adaptive.AdaptiveRouterConfig: + return typed + case *adaptive.AdaptiveRouterConfig: + if typed != nil { + return *typed + } + return adaptive.AdaptiveRouterConfig{} + case nil: + return adaptive.AdaptiveRouterConfig{} + } + encoded, err := json.Marshal(cfg) + if err != nil { + return adaptive.AdaptiveRouterConfig{} + } + var out adaptive.AdaptiveRouterConfig + if err := json.Unmarshal(encoded, &out); err != nil { + return adaptive.AdaptiveRouterConfig{} + } + return out +} + +// NewAdaptiveRouter builds the real router and wires its event hook to +// EmitRouteEvent unless the config already carries one. +func NewAdaptiveRouter(cfg RouterConfig) Router { + resolved := AdaptiveConfig(cfg) + if resolved.OnEvent == nil { + resolved.OnEvent = func(ev adaptive.AdaptiveRouteEvent) { EmitRouteEvent(ToRouteEvent(ev)) } + } + return adaptive.NewAdaptiveModelRouter(resolved) +} + +// AdaptiveRouter narrows GetRouter()'s opaque handle. It returns false when a +// test has installed a different factory through SetRouterFactory, which is the +// only way the singleton can be anything else. +func AdaptiveRouter(r Router) (*adaptive.AdaptiveModelRouter, bool) { + router, ok := r.(*adaptive.AdaptiveModelRouter) + return router, ok +} + +func init() { + newRouter = NewAdaptiveRouter +} diff --git a/internal/seniordev/router/state/adaptivewire_test.go b/internal/seniordev/router/state/adaptivewire_test.go new file mode 100644 index 0000000000..c7e67d3e54 --- /dev/null +++ b/internal/seniordev/router/state/adaptivewire_test.go @@ -0,0 +1,141 @@ +//go:build !windows + +package state + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestGetRouterReturnsAnAdaptiveRouter(t *testing.T) { + ResetRouterForTesting() + defer ResetRouterForTesting() + + router, ok := AdaptiveRouter(GetRouter()) + if !ok { + t.Fatalf("GetRouter must hand back a *adaptive.AdaptiveModelRouter, got %T", GetRouter()) + } + // The `{}` fallback yields adaptive's own default pool. + if len(router.CandidatesForTier(adaptive.ModelTierHigh)) == 0 { + t.Error("the default config must still populate the pool") + } +} + +func TestInitRouterDecodesAJSONConfig(t *testing.T) { + ResetRouterForTesting() + defer ResetRouterForTesting() + + built := InitRouter(map[string]any{"max_attempts": 5}) + router, ok := AdaptiveRouter(built) + if !ok { + t.Fatalf("InitRouter must build an adaptive router, got %T", built) + } + if router.MaxAttempts() != 5 { + t.Errorf("max_attempts must survive the JSON round trip, got %v", router.MaxAttempts()) + } +} + +func TestAdaptiveConfigAcceptsATypedStruct(t *testing.T) { + attempts := float64(9) + cfg := adaptive.AdaptiveRouterConfig{MaxAttempts: &attempts} + got := AdaptiveConfig(cfg) + if got.MaxAttempts == nil || *got.MaxAttempts != 9 { + t.Fatalf("a typed config must pass through untouched, got %+v", got.MaxAttempts) + } + if got := AdaptiveConfig(nil); got.MaxAttempts != nil { + t.Error("a nil config must yield the zero config") + } + if got := AdaptiveConfig("not a config"); got.MaxAttempts != nil { + t.Error("an undecodable config must yield the zero config, not panic") + } +} + +// Register invokes the configured OnEvent hook and nothing else emits, so the +// bridge must be installed as cfg.OnEvent and must reach EmitRouteEvent's +// stderr NDJSON line and its listeners. +func TestRegisterBridgesOntoEmitRouteEvent(t *testing.T) { + ResetRouterForTesting() + ResetListenersForTesting() + defer ResetRouterForTesting() + defer ResetListenersForTesting() + + var buf strings.Builder + prevStderr := Stderr + Stderr = &buf + defer func() { Stderr = prevStderr }() + + var seen []RouteEvent + unsubscribe := OnRouteEvent(func(ev RouteEvent) { seen = append(seen, ev) }) + defer unsubscribe() + + router, ok := AdaptiveRouter(GetRouter()) + if !ok { + t.Fatal("expected an adaptive router") + } + choice := router.PickSync("coder", adaptive.ModelTierHigh) + router.Register(choice, 1.5, 42, nil) + + if len(seen) != 1 { + t.Fatalf("register must fan out exactly one RouteEvent, got %d", len(seen)) + } + if seen[0].Slot != "coder" { + t.Errorf("slot: got %q", seen[0].Slot) + } + if seen[0].ElapsedS != float64(1.5) { + t.Errorf("elapsed_s: got %v", seen[0].ElapsedS) + } + line := buf.String() + if !strings.HasPrefix(line, "[router] ") || !strings.HasSuffix(line, "\n") { + t.Fatalf("stderr line must be `[router] <json>\\n`, got %q", line) + } + var decoded RouteEvent + if err := json.Unmarshal([]byte(strings.TrimSuffix(strings.TrimPrefix(line, "[router] "), "\n")), &decoded); err != nil { + t.Fatalf("the NDJSON line must be valid JSON: %v", err) + } +} + +// An explicit OnEvent in the config wins — the bridge only fills a nil hook. +func TestExplicitOnEventIsNotOverwritten(t *testing.T) { + ResetRouterForTesting() + ResetListenersForTesting() + defer ResetRouterForTesting() + defer ResetListenersForTesting() + + var buf strings.Builder + prevStderr := Stderr + Stderr = &buf + defer func() { Stderr = prevStderr }() + + custom := 0 + cfg := adaptive.AdaptiveRouterConfig{OnEvent: func(adaptive.AdaptiveRouteEvent) { custom++ }} + router, ok := AdaptiveRouter(InitRouter(cfg)) + if !ok { + t.Fatal("expected an adaptive router") + } + choice := router.PickSync("coder", adaptive.ModelTierHigh) + router.Register(choice, 1, 1, nil) + + if custom != 1 { + t.Errorf("the caller's own OnEvent must be preserved, fired %d times", custom) + } + if buf.Len() != 0 { + t.Errorf("the bridge must not also emit, wrote %q", buf.String()) + } +} + +// A test may still install the inert factory. +func TestSetRouterFactoryStillOverridesTheDefault(t *testing.T) { + ResetRouterForTesting() + defer ResetRouterForTesting() + + type fake struct{ Router } + restore := SetRouterFactory(func(cfg RouterConfig) Router { return &fake{} }) + defer restore() + + if _, ok := AdaptiveRouter(GetRouter()); ok { + t.Error("an injected factory must win over the adaptive default") + } +} diff --git a/internal/seniordev/router/state/state.go b/internal/seniordev/router/state/state.go new file mode 100644 index 0000000000..ce2a2616de --- /dev/null +++ b/internal/seniordev/router/state/state.go @@ -0,0 +1,198 @@ +//go:build !windows + +// Package state holds the process-wide adaptive router singleton and the +// route-event fan-out: every route decision is written to stderr as one +// `[router] <json>` NDJSON line and delivered to the process-local +// subscribers. A panicking subscriber never breaks telemetry. +package state + +import ( + "io" + "os" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// ── the process singleton ───────────────────────────────────────────────── + +// Router is the opaque router handle the singleton stores; adaptivewire.go +// narrows it to *adaptive.AdaptiveModelRouter. +type Router any + +// RouterConfig is the router configuration, opaque at this boundary. +type RouterConfig any + +// DefaultRouterConfig is the empty config GetRouter falls back to; the router +// fills in its default pools. +var DefaultRouterConfig RouterConfig = map[string]any{} + +// placeholderRouter keeps GetRouter()'s never-nil contract until +// adaptivewire.go's init installs the adaptive constructor. It is inert on +// purpose — anything that tries to route through it should fail loudly at the +// call site rather than silently no-op. +type placeholderRouter struct{ Cfg RouterConfig } + +var newRouter = func(cfg RouterConfig) Router { return &placeholderRouter{Cfg: cfg} } + +// SetRouterFactory replaces the router constructor. Returns a restore func. +func SetRouterFactory(f func(cfg RouterConfig) Router) func() { + routerMu.Lock() + prev := newRouter + newRouter = f + routerMu.Unlock() + return func() { + routerMu.Lock() + newRouter = prev + routerMu.Unlock() + } +} + +var ( + routerMu sync.Mutex + router Router +) + +// InitRouter unconditionally replaces the singleton. +func InitRouter(cfg RouterConfig) Router { + routerMu.Lock() + defer routerMu.Unlock() + router = newRouter(cfg) + return router +} + +// GetRouter returns the singleton, building a default-config one for code +// paths that run before the CLI bootstraps the router (tests, eager imports). +func GetRouter() Router { + routerMu.Lock() + defer routerMu.Unlock() + if router == nil { + router = newRouter(DefaultRouterConfig) + } + return router +} + +// ResetRouterForTesting drops the singleton so the next GetRouter() rebuilds +// it. +func ResetRouterForTesting() { + routerMu.Lock() + router = nil + routerMu.Unlock() +} + +// ── route events ────────────────────────────────────────────────────────── + +// RouteEvent is the route decision record written to the `[router] ` NDJSON +// line and handed to subscribers. +type RouteEvent struct { + Slot string `json:"slot"` + Tier string `json:"tier"` + Model string `json:"model"` + PreviousModel string `json:"previous_model"` + Switched bool `json:"switched"` + Reason string `json:"reason"` + Score float64 `json:"score"` + ElapsedS float64 `json:"elapsed_s"` + Attempts float64 `json:"attempts"` + Successes float64 `json:"successes"` + Failures float64 `json:"failures"` + RateLimits float64 `json:"rate_limits"` + LatencyEwma float64 `json:"latency_ewma"` + ToksecEwma float64 `json:"toksec_ewma"` + Error string `json:"error"` +} + +// RouteEventListener receives every emitted route event. +type RouteEventListener func(event RouteEvent) + +// Stderr receives the NDJSON lines; it is a variable so tests can capture +// them. They go to stderr so they never mix with the event stream on stdout. +var Stderr io.Writer = os.Stderr + +// routerTag prefixes every NDJSON record. +const routerTag = "[router] " + +var stderrMu sync.Mutex + +var ( + listenerMu sync.Mutex + listenerSeq uint64 + listenerKeys []uint64 + listenerFns map[uint64]RouteEventListener +) + +// OnRouteEvent registers a process-local subscriber and returns its +// unsubscribe. +func OnRouteEvent(listener RouteEventListener) func() { + listenerMu.Lock() + if listenerFns == nil { + listenerFns = map[uint64]RouteEventListener{} + } + listenerSeq++ + key := listenerSeq + listenerKeys = append(listenerKeys, key) + listenerFns[key] = listener + listenerMu.Unlock() + return func() { + listenerMu.Lock() + if _, ok := listenerFns[key]; ok { + delete(listenerFns, key) + for i, k := range listenerKeys { + if k == key { + listenerKeys = append(listenerKeys[:i], listenerKeys[i+1:]...) + break + } + } + } + listenerMu.Unlock() + } +} + +// ResetListenersForTesting drops every subscriber. +func ResetListenersForTesting() { + listenerMu.Lock() + listenerKeys = nil + listenerFns = nil + listenerMu.Unlock() +} + +// EmitRouteEvent writes one NDJSON line on stderr, then fans the event out. +// Listener panics are swallowed so they cannot break router telemetry. +func EmitRouteEvent(event RouteEvent) { + encoded, err := jsonutil.Marshal(event) + if err != nil { + // Keep the stream line-oriented even if encoding somehow fails. + encoded = []byte("null") + } + // One Write of the whole record, serialized, so two concurrent emits + // cannot tear a line in half. + line := make([]byte, 0, len(routerTag)+len(encoded)+1) + line = append(line, routerTag...) + line = append(line, encoded...) + line = append(line, '\n') + stderrMu.Lock() + _, _ = Stderr.Write(line) + stderrMu.Unlock() + + listenerMu.Lock() + keys := make([]uint64, len(listenerKeys)) + copy(keys, listenerKeys) + listenerMu.Unlock() + + for _, key := range keys { + listenerMu.Lock() + listener, live := listenerFns[key] + listenerMu.Unlock() + if !live { + // Unsubscribed by an earlier listener in this same emit. + continue + } + callListener(listener, event) + } +} + +// callListener invokes one listener, swallowing any panic. +func callListener(listener RouteEventListener, event RouteEvent) { + defer func() { _ = recover() }() + listener(event) +} diff --git a/internal/seniordev/router/state/state_test.go b/internal/seniordev/router/state/state_test.go new file mode 100644 index 0000000000..ecb1ae3f48 --- /dev/null +++ b/internal/seniordev/router/state/state_test.go @@ -0,0 +1,245 @@ +//go:build !windows + +package state + +import ( + "bytes" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// These cover the process singleton and the listener registry. + +func TestGetRouterBuildsADefaultSingleton(t *testing.T) { + ResetRouterForTesting() + t.Cleanup(ResetRouterForTesting) + + first := GetRouter() + if first == nil { + t.Fatalf("GetRouter must never return nil — it is the safety net for code paths that run before the CLI bootstraps the router") + } + if second := GetRouter(); second != first { + t.Errorf("GetRouter must return the same process singleton on every call") + } +} + +func TestInitRouterReplacesTheSingleton(t *testing.T) { + ResetRouterForTesting() + t.Cleanup(ResetRouterForTesting) + + before := GetRouter() + built := InitRouter(map[string]any{"max_attempts": 5}) + if built == before { + t.Fatalf("InitRouter must construct a fresh router, not reuse the default") + } + if got := GetRouter(); got != built { + t.Errorf("GetRouter must hand back what InitRouter stored") + } + // Unconditional: every InitRouter call builds a fresh router. + if again := InitRouter(map[string]any{}); again == built { + t.Errorf("a second InitRouter must replace the singleton again") + } +} + +func TestSetRouterFactoryReplacesTheConstructor(t *testing.T) { + ResetRouterForTesting() + t.Cleanup(ResetRouterForTesting) + + type stub struct{ cfg RouterConfig } + var seen []RouterConfig + restore := SetRouterFactory(func(cfg RouterConfig) Router { + seen = append(seen, cfg) + return &stub{cfg: cfg} + }) + defer restore() + + built := GetRouter() + if _, ok := built.(*stub); !ok { + t.Fatalf("GetRouter must build through the injected factory, got %T", built) + } + if len(seen) != 1 { + t.Fatalf("factory called %d times, want 1", len(seen)) + } + // GetRouter's fallback is the empty config; the router fills in its own + // defaults. + if cfg, ok := seen[0].(map[string]any); !ok || len(cfg) != 0 { + t.Errorf("default config = %#v, want the empty object literal", seen[0]) + } + + InitRouter(map[string]any{"random_seed": 7}) + if len(seen) != 2 { + t.Fatalf("InitRouter must also go through the factory") + } +} + +func TestOnRouteEventReturnsAWorkingUnsubscribe(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + count := 0 + off := OnRouteEvent(func(RouteEvent) { count++ }) + EmitRouteEvent(RouteEvent{Model: "a"}) + off() + EmitRouteEvent(RouteEvent{Model: "b"}) + + if count != 1 { + t.Errorf("listener called %d times, want 1", count) + } + // Telemetry keeps flowing regardless of subscribers. + if got := strings.Count(sink.String(), "[router] "); got != 2 { + t.Errorf("stderr lines = %d, want 2", got) + } +} + +func TestEmitRouteEventLineIsExactlyOneNDJSONRecord(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + event := RouteEvent{ + Slot: "coder", + Model: "openrouter/z-ai/glm-5.1", + PreviousModel: "", + Switched: false, + Reason: "sticky", + Score: float64(0.5), + ElapsedS: float64(1), + Attempts: float64(1), + Successes: float64(1), + Failures: float64(0), + RateLimits: float64(0), + LatencyEwma: float64(1), + ToksecEwma: float64(0), + Error: "", + } + EmitRouteEvent(event) + + line := sink.String() + if !strings.HasPrefix(line, "[router] ") { + t.Fatalf("line %q must start with the [router] tag", line) + } + if !strings.HasSuffix(line, "\n") || strings.Count(line, "\n") != 1 { + t.Fatalf("line %q must be exactly one newline-terminated record", line) + } + // The payload must be the event verbatim — the wire format downstream + // tooling parses. + want, err := jsonutil.Marshal(event) + if err != nil { + t.Fatalf("stringify: %v", err) + } + if got := strings.TrimSuffix(strings.TrimPrefix(line, "[router] "), "\n"); got != string(want) { + t.Errorf("payload = %s, want %s", got, want) + } +} + +func TestPanickingListenerDoesNotBreakTelemetry(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + reached := false + OnRouteEvent(func(RouteEvent) { panic("listener exploded") }) + OnRouteEvent(func(RouteEvent) { reached = true }) + + EmitRouteEvent(RouteEvent{Model: "m"}) + + if !reached { + t.Errorf("a panicking listener must not stop the ones registered after it") + } + if !strings.Contains(sink.String(), "[router] ") { + t.Errorf("the NDJSON line must still be written") + } +} + +// TestListenerRegistrySemantics pins how the listener registry treats +// duplicate registrations and registrations made during an emit. +func TestListenerRegistrySemantics(t *testing.T) { + t.Run("registering the same func twice yields two entries", func(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + count := 0 + listener := func(RouteEvent) { count++ } + OnRouteEvent(listener) + OnRouteEvent(listener) + EmitRouteEvent(RouteEvent{}) + + if count != 2 { + t.Errorf("listener calls = %d, want 2", count) + } + }) + + t.Run("a listener added during an emit is not visited by that emit", func(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + lateCalls := 0 + OnRouteEvent(func(RouteEvent) { + OnRouteEvent(func(RouteEvent) { lateCalls++ }) + }) + EmitRouteEvent(RouteEvent{}) + + if lateCalls != 0 { + t.Errorf("late listener calls = %d, want 0", lateCalls) + } + // It is registered for the next emit, though. + EmitRouteEvent(RouteEvent{}) + if lateCalls == 0 { + t.Errorf("the listener added mid-emit must fire on the following emit") + } + }) +} + +func TestConcurrentEmitIsSafe(t *testing.T) { + ResetListenersForTesting() + t.Cleanup(ResetListenersForTesting) + var sink bytes.Buffer + previous := Stderr + Stderr = &sink + t.Cleanup(func() { Stderr = previous }) + + var mu sync.Mutex + seen := 0 + OnRouteEvent(func(RouteEvent) { + mu.Lock() + seen++ + mu.Unlock() + }) + + var wait sync.WaitGroup + for i := 0; i < 50; i++ { + wait.Add(1) + go func() { + defer wait.Done() + EmitRouteEvent(RouteEvent{Model: "m"}) + }() + } + wait.Wait() + + mu.Lock() + defer mu.Unlock() + if seen != 50 { + t.Errorf("listener calls = %d, want 50", seen) + } +} diff --git a/internal/seniordev/seniordev.go b/internal/seniordev/seniordev.go new file mode 100644 index 0000000000..27875812da --- /dev/null +++ b/internal/seniordev/seniordev.go @@ -0,0 +1,210 @@ +//go:build !windows + +// Package seniordev is senior-dev: an autonomous coding agent codeaf carries +// and runs, and nothing else can. It takes one brief, works in the folder it is +// handed — on the branch codeaf cut for it there when the folder is a git +// repository (internal/session's programfolder.go) — under a model it reaches +// only through codeaf, submits a frozen candidate, +// checks it with the project's own build and tests, and ends with one record +// that keeps what its model claimed apart from what it saw +// (internal/seniordev/app; its own account of the run is ARCHITECTURE.md in +// the repository it came from, swe-pro-go at the tag codeaf-absorb). +// +// IT HAS NO ENTRY POINT OF ITS OWN. What codeaf needs of it is a +// delegate.Delegate value, and its one command's body takes a delegate.Host, +// which only codeaf makes: `/senior-dev <brief>` in the chat, and +// `codeaf senior-dev <brief>` at a shell. There is no binary, no key it reads +// and no stdout it writes to but the host's records. +// +// ON WINDOWS IT IS ABSENT. Its engine leans on process groups, file locks and +// a bash shell it has never had a Windows form of, so every file under this +// tree carries a !windows constraint and the build's list carries nothing +// there (internal/delegate/builtin/carried_windows.go). +package seniordev + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "runtime/debug" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// Program is senior-dev as codeaf carries it. +// stageWords is senior-dev's stage names (app.Stages) in a person's words. +// +// `landing` IS ITS LAST CHECKS. senior-dev reports it once as it starts, and +// `implement` replaces that within milliseconds; every other time it is the +// end-of-run turn that brings its work to a state that stands and the build +// and tests it runs on the tree it leaves. Read as `starting`, the row went +// back to `starting` for the last minutes of a run. +var stageWords = map[string]string{ + "bootstrap": "starting", + "run-contract": "starting", + "intake": "reading the brief", + "landing": "checking its work", + "implement": "working", + "agent-runtime": "working", + "compaction-capacity": "working", + "compaction": "working", + "router-cancellation": "working", + "model-switch": "working", + "submit": "handing in its work", + "verification": "checking its work", + "ship": "finishing", + "patch-summary": "finishing", + "agent-summary": "finishing", +} + +var Program = delegate.Delegate{ + Name: "senior-dev", + Summary: "an autonomous agent for one large, well-specified code change", + // What the chat's model reads before it names senior-dev in `via`. The + // brief is copied word for word into .senior-dev/spec.md and is all it ever + // knows of the work, so the guide says what that brief must settle. Its + // folder is codeaf's to choose and to read: a plain one is handed over + // with PlainFolder on the line, so the guide says nothing about git. + // + // IT CLAIMS THE HARD CODING WORK, BECAUSE THAT IS WHAT IT IS FOR. It said + // "one large code change worth an hour", a price no piece of work seems to + // clear before it is opened: an issue in a mature project whose cause runs + // through several files reads, from its report, as a few edits the chat + // could make itself. The owner asked on 2026-09-24 that codeaf reach for + // senior-dev by itself on complicated, many-sided coding work, the kind a + // hard software-engineering benchmark is made of, so the guide names that + // work, and the paragraph it is printed under says codeaf prefers a program + // for whatever its guide claims (internal/session/delegate_door.go). THE + // BRIEF CARRIES THE ISSUE IN FULL: a summary of a bug report is the one + // thing senior-dev cannot check against, since there is nobody it can ask + // what the report said. + Guide: "For complex, multi-part coding work: fixing an issue in a mature codebase whose cause " + + "spans files, a feature with its tests, a rewrite across a package, a migration. Its brief " + + "carries the issue or ask in full, what done means and how to check it, and what must not change.", + Lands: delegate.LandsTree, + // Its recorder is git unless it is told --in-place, which keeps its + // checkpoints outside the folder and commits nothing. codeaf passes it for + // every folder it works in without git — no history, or a repository at + // the home folder — because the recorder's own reading climbs to any + // repository around the folder. + PlainFolder: []string{"--in-place"}, + // Where it keeps its records in the folder it works in: the brief, the + // checklist, the pinned command, its session database and its model + // conversation (app's seniorDevDataDirectory, which git never sees). + Notes: ".senior-dev", + CrewFlags: crewFlags, + // What a person reads for its stages, one plain word per phase: getting + // ready, doing the work (every inner stage of a model turn included), + // handing it in, checking it, wrapping up. Its task's row reads them only + // until a record has named a step of its process — which its first, + // `bootstrap`, already does — so they are the words for a run whose records + // say no step. A test holds every stage in app.Stages to a word. + StageWords: stageWords, + // What each line of its action log reads as on its task's page, under the + // step of its process it served, and the step's word its task's row reads + // while it is in it (actions.go). + Present: presentActions, + Default: "run", + Page: "senior-dev", + Commands: []delegate.Command{runCommand}, +} + +// crewFlags is the conversation's crew as senior-dev's own flags: the working +// seat is the pool the coder routes on (--high), and the light seat the +// history summaries (--low). --crew says the pools came from a crew, so a +// model senior-dev's catalog cannot size is left out rather than failing the +// run. A seat the crew leaves unset keeps senior-dev's own default for it. +// +// THE PLANNING SEAT IS NOT HANDED OVER, BECAUSE NOTHING WOULD USE IT. It went +// to senior-dev's frontier tier, and senior-dev routes exactly two kinds of +// call: the coder's turns on the high pool and the history summary on the low +// one (baked/tier.go). No call rides the frontier tier, so the flag changed +// nothing, while the manual told the person their mastermind model handled +// senior-dev's hardest calls. +// +// MODELS THE PERSON ASKED FOR ARE THE WORKING POOL, AND ARE KEPT AS ASKED. +// They go on --high in place of the crew's working seat, with --asked, which +// takes that pool out of the crew's leniency: a model the person named that +// senior-dev cannot size ends the run before its first call, naming it, +// rather than being quietly swapped for its own list. +func crewFlags(crew delegate.Crew) []string { + flags := []string{"--crew"} + high := app.CrewModel(crew.Hands) + if len(crew.Asked) > 0 { + asked := make([]string, 0, len(crew.Asked)) + for _, model := range crew.Asked { + if model = app.CrewModel(model); model != "" { + asked = append(asked, model) + } + } + high = strings.Join(asked, ",") + flags = append(flags, "--asked") + } + for _, seat := range []struct{ flag, model string }{ + {"--high", high}, {"--low", app.CrewModel(crew.Light)}, + } { + if seat.model != "" { + flags = append(flags, seat.flag, seat.model) + } + } + return flags +} + +// runCommand is senior-dev's one verb: the whole run, from the brief to the +// terminal record. codeaf owns --dir, --max-cost, --max-hours and --json; the +// flags here are senior-dev's own. +var runCommand = delegate.Command{ + Name: "run", + Usage: "[flags] -- <brief>", + Summary: "does one change start to finish: works, submits, checks its work", + Bind: bindRun, +} + +// bindRun declares the run's own flags and answers the body that reads them. +func bindRun(fs *flag.FlagSet) delegate.Body { + variant := fs.String("variant", "", "reasoning effort per call: low, medium, high or xhigh") + inPlace := fs.Bool("in-place", false, "work without git: no commits; checkpoints kept outside") + high := fs.String("high", app.DefaultHighModels, "models the coder routes among, comma-separated") + low := fs.String("low", "", "models for the history summary (default: --high)") + frontier := fs.String("frontier", "", "models for the frontier tier (no call uses it)") + crew := fs.Bool("crew", false, "the models came from codeaf's crew: skip any it cannot size") + asked := fs.Bool("asked", false, "the --high models were asked for by name; none is skipped") + return func(ctx context.Context, host delegate.Host, args []string) error { + run(ctx, host, app.Options{ + Goal: strings.Join(args, " "), + High: *high, + Low: *low, + Frontier: *frontier, + Variant: *variant, + InPlace: *inPlace, + Crew: *crew, + Asked: *asked, + }, os.Stderr) + return nil + } +} + +// run is the body: hello first, the run, and exactly one terminal. +// +// A PANIC IS AN ENDING TOO. The host reads a missing terminal as work that did +// not finish and can say nothing more; a panic in the run's own goroutine is +// caught here and written as the crash it is, with its stack on stderr for +// whoever opens the task. (A panic on another of the run's goroutines ends the +// process, and the missing terminal says so.) +func run(ctx context.Context, host delegate.Host, options app.Options, notes io.Writer) { + host.Hello(app.Stages) + defer func() { + if recovered := recover(); recovered != nil { + _, _ = fmt.Fprintf(notes, "[senior-dev] panic: %v\n%s", recovered, debug.Stack()) + host.Terminal(delegate.Ending{ + Status: delegate.StatusCrashed, + Message: fmt.Sprintf("senior-dev broke: %v", recovered), + }) + } + }() + host.Terminal(app.Run(ctx, host, options, notes)) +} diff --git a/internal/seniordev/seniordev_test.go b/internal/seniordev/seniordev_test.go new file mode 100644 index 0000000000..f90d914b8b --- /dev/null +++ b/internal/seniordev/seniordev_test.go @@ -0,0 +1,460 @@ +//go:build !windows + +package seniordev + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/provider/modelapi" + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// hostRecord is one record a run wrote, in the order it wrote them. +type hostRecord struct { + kind string + stages []string + stage string + step string + // record is the step record whole: its tool, its step and its exit. + record delegate.StepRecord + ending delegate.Ending +} + +// recordingHost is codeaf as a run meets it: a workspace, ceilings, a model +// API, and the four records, kept in order. +type recordingHost struct { + mu sync.Mutex + workspace string + ceilings delegate.Ceilings + api delegate.ModelAPI + records []hostRecord +} + +func (h *recordingHost) Workspace() string { return h.workspace } +func (h *recordingHost) Ceilings() delegate.Ceilings { return h.ceilings } +func (h *recordingHost) Models() delegate.ModelAPI { return h.api } +func (h *recordingHost) Hello(stages []string) { h.add(hostRecord{kind: "hello", stages: stages}) } +func (h *recordingHost) Stage(stage delegate.StageRecord) { + h.add(hostRecord{kind: "stage", stage: stage.Stage + "/" + stage.Status}) +} +func (h *recordingHost) Step(step delegate.StepRecord) { + h.add(hostRecord{kind: "step", step: step.Command, record: step}) +} +func (h *recordingHost) Terminal(end delegate.Ending) { + h.add(hostRecord{kind: "terminal", ending: end}) +} + +func (h *recordingHost) add(r hostRecord) { + h.mu.Lock() + defer h.mu.Unlock() + h.records = append(h.records, r) +} + +func (h *recordingHost) snapshot() []hostRecord { + h.mu.Lock() + defer h.mu.Unlock() + return append([]hostRecord(nil), h.records...) +} + +// A key and an address senior-dev must never read. Before codeaf carried it, +// senior-dev took both from these two variables; a run that still did would +// send the one to the other. +const ( + keyNobodyMayRead = "sk-or-v1-a-key-senior-dev-must-never-read" + runToken = "codeaf-run-token-for-this-run-only" +) + +// modelAPIServer answers like codeaf's model API: OpenRouter's streamed +// chat-completions shape, a keepalive comment before the first chunk, and +// usage.cost in the last. It plays one scripted conversation — run the tests, +// write the feature, write the checklist, submit, and stop — and keeps every request it +// was sent. +type modelAPIServer struct { + mu sync.Mutex + requests []seenRequest + // hold, when set, blocks each request until the caller gives up, and says + // so on the channel first. + hold chan struct{} +} + +type seenRequest struct { + path string + authorization string + affinity string + referer string + title string + body map[string]any + raw string +} + +func (s *modelAPIServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + s.mu.Lock() + s.requests = append(s.requests, seenRequest{ + path: r.URL.Path, + authorization: r.Header.Get("Authorization"), + affinity: r.Header.Get("x-session-affinity"), + referer: r.Header.Get("HTTP-Referer"), + title: r.Header.Get("X-Title"), + body: body, + raw: string(raw) + fmt.Sprint(r.Header), + }) + call := len(s.requests) + hold := s.hold + s.mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + // The model API keeps a waiting stream alive with SSE comments. + _, _ = io.WriteString(w, ": keepalive\n\n") + if flusher != nil { + flusher.Flush() + } + if hold != nil { + select { + case hold <- struct{}{}: + default: + } + <-r.Context().Done() + return + } + _, _ = io.WriteString(w, scriptedReply(call)) +} + +func (s *modelAPIServer) seen() []seenRequest { + s.mu.Lock() + defer s.mu.Unlock() + return append([]seenRequest(nil), s.requests...) +} + +// scriptedReply is the model's side of the conversation, one reply per call: +// run the tests first, write the feature, write the checklist, submit, stop. +func scriptedReply(call int) string { + switch call { + case 1: + return toolCall(call, "bash", map[string]any{"command": "make test"}) + case 2: + return toolCall(call, "write", map[string]any{"filePath": "feature.txt", "content": "implemented\n"}) + case 3: + return toolCall(call, "write", map[string]any{"filePath": ".senior-dev/checklist.md", "content": "- [x] the feature is implemented\n"}) + case 4: + return toolCall(call, "submit", map[string]any{ + "reason": "feature.txt now holds the feature", "evidence": "make test exits 0", + "checklist_satisfied": true, + }) + default: + return `data: {"id":"gen-text","choices":[{"delta":{"content":"Done."}}]}` + "\n\n" + + `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"cost":0.001,"prompt_tokens":40,"completion_tokens":3,"total_tokens":43}}` + "\n\n" + + "data: [DONE]\n\n" + } +} + +func toolCall(call int, name string, arguments map[string]any) string { + encodedArguments, _ := json.Marshal(arguments) + chunk := map[string]any{ + "id": fmt.Sprintf("gen-%d", call), + "choices": []any{map[string]any{ + "delta": map[string]any{"tool_calls": []any{map[string]any{ + "index": 0, "id": fmt.Sprintf("call-%d", call), "type": "function", + "function": map[string]any{"name": name, "arguments": string(encodedArguments)}, + }}}, + "finish_reason": "tool_calls", + }}, + "usage": map[string]any{"cost": 0.002, "prompt_tokens": 30, "completion_tokens": 10, "total_tokens": 40}, + } + encoded, _ := json.Marshal(chunk) + return "data: " + string(encoded) + "\n\ndata: [DONE]\n\n" +} + +// hermeticRun is the environment a run meets in these tests: nothing of the +// machine's own configuration, a catalog on disk, no fetch, and the two old +// provider variables set to values senior-dev must not read. It answers the +// workspace, a git repository with a build and a test that pass. +func hermeticRun(t *testing.T) (workspace string, trap *atomic.Bool) { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) + t.Setenv("SENIOR_DEV_CONFIG_DIR", t.TempDir()) + t.Setenv("SENIOR_DEV_CONFIG", "") + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", "") + t.Setenv("SENIOR_DEV_PERMISSION", "") + t.Setenv("SENIOR_DEV_NET", "allow") + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + t.Setenv("SENIOR_DEV_DISABLE_MODELS_FETCH", "1") + catalog, err := filepath.Abs(filepath.Join("modelsdev", "testdata", "catalog.json")) + if err != nil { + t.Fatal(err) + } + t.Setenv("SENIOR_DEV_MODELS_PATH", catalog) + hit := &atomic.Bool{} + trapServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hit.Store(true) + w.WriteHeader(http.StatusTeapot) + })) + t.Cleanup(trapServer.Close) + t.Setenv("OPENROUTER_API_KEY", keyNobodyMayRead) + t.Setenv("OPENROUTER_BASE_URL", trapServer.URL) + + workspace = t.TempDir() + files := map[string]string{ + "README.md": "base\n", + "Makefile": "build:\n\t@true\n\ntest:\n\t@true\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + for _, args := range [][]string{ + {"init", "-q", "-b", "main"}, + {"add", "README.md", "Makefile"}, + {"-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", "commit", "-q", "-m", "base"}, + } { + command := exec.Command("git", args...) + command.Dir = workspace + if out, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + return workspace, hit +} + +// runBody runs the run command's body the way codeaf's command line does: +// bound on a fresh flag set, handed the line after the flags as the brief. +func runBody(t *testing.T, ctx context.Context, host delegate.Host, line ...string) error { + t.Helper() + command, ok := Program.Command(Program.Default) + if !ok { + t.Fatalf("senior-dev has no %q command", Program.Default) + } + fs := flag.NewFlagSet("senior-dev run", flag.ContinueOnError) + body := command.Bind(fs) + if err := fs.Parse(line); err != nil { + t.Fatal(err) + } + return body(ctx, host, fs.Args()) +} + +// THE WHOLE ROAD, AGAINST A MODEL API THAT ANSWERS LIKE CODEAF'S. The run is +// given a host whose model API is a local server, works a scripted task in a +// real repository, and is held to the protocol: hello first with its stages, +// a step per finished tool call, exactly one terminal and nothing after it; +// every model call on the route codeaf spells, carrying the run's token; and +// no read of the key or the address senior-dev used to take from its +// environment. +func TestTheRunCommandWorksATaskThroughTheModelAPIItIsGiven(t *testing.T) { + workspace, trapHit := hermeticRun(t) + server := &modelAPIServer{} + api := httptest.NewServer(server) + t.Cleanup(api.Close) + host := &recordingHost{ + workspace: workspace, + api: delegate.ModelAPI{BaseURL: api.URL + "/v1", Token: runToken}, + } + + err := runBody(t, context.Background(), host, + "--high", "openrouter/fixture/vendor-model", "--", "Add", "the", "feature.") + if err != nil { + t.Fatalf("the body answered an error: %v", err) + } + + records := host.snapshot() + if len(records) == 0 || records[0].kind != "hello" { + t.Fatalf("the first record is not hello: %+v", records) + } + if !slices.Equal(records[0].stages, app.Stages) || len(records[0].stages) != 15 { + t.Fatalf("hello names %v, want the run's fifteen stages %v", records[0].stages, app.Stages) + } + var steps, terminals int + named := map[string]bool{} + for at, record := range records { + switch record.kind { + case "hello": + if at != 0 { + t.Fatalf("a second hello at record %d", at) + } + case "step": + steps++ + // EVERY STEP NAMES ITS TOOL AND THE STEP OF THE PROCESS IT SERVED. + if record.record.Tool == "" || !slices.Contains(app.Steps, record.record.Step) { + t.Fatalf("step %q says tool %q and step %q, want a tool and one of %v", record.step, record.record.Tool, record.record.Step, app.Steps) + } + named[record.record.Step] = true + // A COMMAND SAYS HOW IT EXITED, from the shell tool's own record of it. + if record.record.Tool == "bash" && record.record.Exit == nil { + t.Fatalf("the command step %q carries no exit code", record.step) + } + case "terminal": + terminals++ + if at != len(records)-1 { + t.Fatalf("records follow the terminal: %+v", records[at:]) + } + } + } + if steps < 1 { + t.Fatalf("no step records: %+v", records) + } + // The scripted model runs the tests, writes the feature, then its checklist, + // then submits; senior-dev then runs the project's own build and tests itself. + for _, want := range []string{app.StepExplore, app.StepImplement, app.StepChecklist, app.StepSubmit, app.StepVerify} { + if !named[want] { + t.Errorf("no step was named %q: %v", want, named) + } + } + if terminals != 1 { + t.Fatalf("%d terminal records, want exactly one", terminals) + } + stages := map[string]bool{} + for _, record := range records { + if record.kind == "stage" { + stages[strings.SplitN(record.stage, "/", 2)[0]] = true + if !slices.Contains(app.Stages, strings.SplitN(record.stage, "/", 2)[0]) { + t.Fatalf("stage %q is not one the hello named", record.stage) + } + } + } + for _, want := range []string{"bootstrap", "intake", "implement", "submit", "verification", "ship"} { + if !stages[want] { + t.Errorf("no %s stage was reported: %v", want, stages) + } + } + + ending := records[len(records)-1].ending + if ending.Status != delegate.StatusPass { + t.Fatalf("ending = %+v, want pass", ending) + } + if ending.Claim != "feature.txt now holds the feature" { + t.Errorf("claim = %q, want the model's submission reason", ending.Claim) + } + if !strings.Contains(ending.Observed, "passed") { + t.Errorf("observed = %q, want what senior-dev saw its build and tests do", ending.Observed) + } + if ending.CostUSD <= 0 { + t.Errorf("cost = %v, want the calls' own usage.cost summed", ending.CostUSD) + } + for _, banned := range []string{"verified", "verdict", "auditor", "refuted"} { + for _, said := range []string{ending.Message, ending.Claim, ending.Observed, ending.Reason} { + if strings.Contains(said, banned) { + t.Errorf("the ending says %q, a word no person reads from codeaf: %q", banned, said) + } + } + } + if content, err := os.ReadFile(filepath.Join(workspace, "feature.txt")); err != nil || string(content) != "implemented\n" { + t.Fatalf("the work is not in the tree: %q, %v", content, err) + } + + requests := server.seen() + if len(requests) < 5 { + t.Fatalf("%d model requests, want the scripted five", len(requests)) + } + route, err := url.Parse(modelapi.ChatURL(host.api.BaseURL)) + if err != nil { + t.Fatal(err) + } + for at, request := range requests { + if request.path != route.Path { + t.Errorf("request %d went to %q, want the model API's route %q", at, request.path, route.Path) + } + if request.authorization != "Bearer "+runToken { + t.Errorf("request %d carried Authorization %q, want the run's token", at, request.authorization) + } + if strings.Contains(request.raw, keyNobodyMayRead) { + t.Errorf("request %d carried the provider key from the environment", at) + } + if request.affinity == "" { + t.Errorf("request %d lost its x-session-affinity header", at) + } + if request.referer != "" || request.title != "" { + t.Errorf("request %d carried attribution headers: %q %q", at, request.referer, request.title) + } + if request.body["prompt_cache_key"] == nil { + t.Errorf("request %d lost its prompt_cache_key", at) + } + if usage, _ := request.body["usage"].(map[string]any); usage["include"] != true { + t.Errorf("request %d did not ask for usage: %v", at, request.body["usage"]) + } + if _, routed := request.body["provider"]; routed { + t.Errorf("request %d carried a provider-routing block", at) + } + if len(request.body["tools"].([]any)) == 0 { + t.Errorf("request %d carried no tools", at) + } + } + if trapHit.Load() { + t.Fatal("a request went to OPENROUTER_BASE_URL") + } +} + +// SIGTERM IS codeaf's STOP. The run's context ends while a model call is in +// flight; the run starts nothing new, ships what it has and writes its one +// terminal quickly, saying the work did not finish — not that it crashed. +func TestAStoppedRunEndsWithItsOneTerminalAndTheTruth(t *testing.T) { + workspace, _ := hermeticRun(t) + server := &modelAPIServer{hold: make(chan struct{}, 1)} + api := httptest.NewServer(server) + t.Cleanup(api.Close) + host := &recordingHost{ + workspace: workspace, + api: delegate.ModelAPI{BaseURL: api.URL + "/v1", Token: runToken}, + } + ctx, stop := context.WithCancel(context.Background()) + defer stop() + done := make(chan error, 1) + go func() { + done <- runBody(t, ctx, host, "--high", "openrouter/fixture/vendor-model", "--", "Add the feature.") + }() + select { + case <-server.hold: + case err := <-done: + t.Fatalf("the run ended before its first model call: %v, %+v", err, host.snapshot()) + case <-time.After(30 * time.Second): + t.Fatal("the run never made a model call") + } + stopped := time.Now() + stop() + select { + case <-done: + case <-time.After(delegate.DefaultGrace): + t.Fatalf("the run outlived the grace a stop gives it") + } + if took := time.Since(stopped); took > 10*time.Second { + t.Errorf("the run took %s to end after the stop", took) + } + records := host.snapshot() + var terminals []delegate.Ending + for _, record := range records { + if record.kind == "terminal" { + terminals = append(terminals, record.ending) + } + } + if len(terminals) != 1 || records[len(records)-1].kind != "terminal" { + t.Fatalf("terminals = %+v, want exactly one, last", terminals) + } + if terminals[0].Status != delegate.StatusFail || !strings.HasPrefix(terminals[0].Message, "stopped before it finished") { + t.Fatalf("ending = %+v, want the stop said as unfinished work", terminals[0]) + } + if n := len(server.seen()); n != 1 { + t.Fatalf("%d model requests, want none after the stop", n) + } +} diff --git a/internal/seniordev/session/compaction/additive_test.go b/internal/seniordev/session/compaction/additive_test.go new file mode 100644 index 0000000000..88946e01e0 --- /dev/null +++ b/internal/seniordev/session/compaction/additive_test.go @@ -0,0 +1,376 @@ +//go:build !windows + +package compaction + +import ( + "context" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +// scriptedProcessor drives the fake summary processor with one outcome. +type scriptedProcessor struct { + text string // written as the summary text when non-empty + result steploop.Result + failWith string // sets an assistant error when non-empty + calls int + requests []SummaryRequest +} + +func (sc *scriptedProcessor) factory(store *memoryStore) ProcessorFactory { + return ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, request SummaryRequest) (steploop.Result, error) { + sc.calls++ + sc.requests = append(sc.requests, request) + if sc.failWith != "" { + converted := msgmodel.NewUnknownError(sc.failWith) + assistant.Error = &converted + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultStop, nil + } + finish := "stop" + assistant.Finish = &finish + assistant.Tokens = msgmodel.Tokens{Input: 1234, Output: 56} + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + if sc.text != "" { + if err := store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: "sp_" + assistant.ID, SessionID: "ses_1", MessageID: assistant.ID, + }, + Text: sc.text, + }); err != nil { + return steploop.ResultStop, err + } + } + result := sc.result + if result == "" { + result = steploop.ResultContinue + } + return result, nil + }, + }, nil + }) +} + +func additiveDeps(store *memoryStore, script *scriptedProcessor) (Dependencies, *[]CompactionDecision) { + deps := baseDeps(store) + deps.Provider = &fakeProvider{model: serviceModel(), provider: ProviderInfo{Source: "env"}} + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + decisions := &[]CompactionDecision{} + deps.Decisions = DecisionSinkFunc(func(d CompactionDecision) { + *decisions = append(*decisions, d) + }) + deps.Processors = script.factory(store) + deps.ChangedFiles = func(context.Context) []string { + return []string{" src/a.ts | 4 ++--"} + } + return deps, decisions +} + +// sessionWithPriorBoundary is a session that already compacted once: the +// goal, a completed boundary (uc0 + its valid summary), the tail of that +// boundary, more work, and the new compaction parent uc. +func sessionWithPriorBoundary() []msgmodel.WithParts { + finish := "stop" + flag := true + previous := testAssistant("as0", "uc0", textPart("as0", testValidSummary("Implemented parse() in src/a.ts"))) + info := previous.Info.(msgmodel.Assistant) + info.Summary = &flag + info.Finish = &finish + previous.Info = info + big := strings.Repeat("test output line\n", 600) + return []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), + testUser("uc0", msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc0", SessionID: "ses_1", MessageID: "uc0"}, + Auto: true, + }), + previous, + // Long enough (~1,700 tokens) that a 1,500-token tail budget cannot + // hold it once the truncated a2 is in. + testAssistant("a1", "u0", textPart("a1", strings.Repeat("Continuing with the tests. ", 250))), + testAssistant("a2", "u0", toolPartCompleted("a2", "c2", "bash", `{"cmd":"npm test"}`, big)), + testAssistant("a3", "u0", textPart("a3", "Two tests still fail.")), + testUser("uc", msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + }), + } +} + +func selectableBoundaries(t *testing.T, store *memoryStore, parent string) int { + t.Helper() + msgs, _ := store.Messages(context.Background(), "ses_1") + count := 0 + for _, m := range msgs { + a, ok := m.Info.(msgmodel.Assistant) + if !ok || !boolPointer(a.Summary) || a.ParentID != parent { + continue + } + if a.Finish != nil && *a.Finish != "" && a.Error == nil { + count++ + } + } + return count +} + +func TestRejectedSummaryInstallsDeterministicRecordCarryingThePreviousSummary(t *testing.T) { + messages := sessionWithPriorBoundary() + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{text: "Let me keep going and read src/b.ts next."} + deps, decisions := additiveDeps(store, script) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if script.calls != 1 { + t.Fatalf("summary calls = %d, want exactly 1 (no retries)", script.calls) + } + d := (*decisions)[0] + if d.SummaryStatus != "fallback" || d.SummaryClass != SummaryClassFormat || + !d.PreviousSummaryCarried || d.SummaryPromptTokens != 1234 || d.SummaryOutputTokens != 56 { + t.Fatalf("decision = %#v", d) + } + if selectableBoundaries(t, store, "uc") != 1 { + t.Fatal("the rejected attempt did not become the boundary") + } + fresh, _ := store.Messages(context.Background(), "ses_1") + prior := completedCompactions(fresh) + record := *prior[len(prior)-1].Summary + if !stringsContainsAll(record, + "carried forward verbatim", + "> - Implemented parse() in src/a.ts", // the previous summary, quoted as data + "> \\### Completed", // its headings escaped + "See the CHANGED FILES record", + ) { + t.Fatalf("deterministic record:\n%s", record) + } + if strings.Contains(record, "read src/b.ts next") { + t.Fatalf("the rejected continuation leaked into the record:\n%s", record) + } + // The prompt sent was the flattened head only: the newest message is the + // verbatim tail and must not have been summarized. + prompt := promptOf(t, script.requests[0]) + if !strings.Contains(prompt, "Continuing with the tests.") || + strings.Contains(prompt, "Two tests still fail.") { + t.Fatalf("head/tail split is wrong in prompt:\n%s", prompt) + } + if !strings.Contains(prompt, "<previous-summary>") { + t.Fatalf("previous summary was not offered to the summarizer:\n%s", prompt) + } +} + +func TestTailIsKeptAndOlderToolOutputsAreTruncatedInTheStore(t *testing.T) { + messages := sessionWithPriorBoundary() + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{text: testValidSummary("tests running")} + deps, decisions := additiveDeps(store, script) + // Enough budget for the truncated a2 but not for an untruncated one: + // a2's output is ~10,200 chars (~2,550 tokens); truncated it is ~4,100. + budget := float64(1_500) + deps.Config = ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{ + PreserveRecentTokens: &budget, + }}, nil + }) + + if _, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }); err != nil { + t.Fatal(err) + } + d := (*decisions)[0] + if d.SummaryStatus != "valid" || d.TailMessages != 2 || d.TailTruncatedOutputs != 1 { + t.Fatalf("decision = %#v", d) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + var tailStart *string + var truncatedOutput string + for _, m := range fresh { + if m.Info.MessageID() == "uc" { + tailStart = m.Parts[0].(msgmodel.CompactionPart).TailStartID + } + if m.Info.MessageID() == "a2" { + truncatedOutput = m.Parts[0].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted).Output + } + } + if tailStart == nil || *tailStart != "a2" { + t.Fatalf("tail start = %v, want a2", tailStart) + } + if !strings.Contains(truncatedOutput, "truncated at a context compaction") || + len(truncatedOutput) > 4_400 { + t.Fatalf("old tool output was not truncated in the store: %d chars", len(truncatedOutput)) + } + // And the projection places the tail after the summary: compaction user, + // summary, then a2 and a3 verbatim, then the auto-continue user. + projected := msgmodel.FilterCompacted(newestFirstMessages(fresh)) + order := []string{} + for _, m := range projected { + order = append(order, m.Info.MessageID()) + } + summaryIndex := indexOfPrefix(order, "message_") + if indexOf(order, "uc") != 0 || summaryIndex != 1 || + indexOf(order, "a2") != 2 || indexOf(order, "a3") != 3 || len(order) != 5 { + t.Fatalf("projection order = %v", order) + } +} + +func TestSummaryCallErrorInstallsDeterministicRecordAndContinues(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{failWith: "unexpected EOF"} + deps, decisions := additiveDeps(store, script) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + d := (*decisions)[0] + if d.SummaryStatus != "summary-error" || !strings.Contains(d.SummaryError, "unexpected EOF") { + t.Fatalf("decision = %#v", d) + } + if selectableBoundaries(t, store, "uc") != 1 { + t.Fatal("an errored summary call left no usable boundary") + } + fresh, _ := store.Messages(context.Background(), "ses_1") + if prior := completedCompactions(fresh); len(prior) != 1 || + !strings.Contains(*prior[0].Summary, "unexpected EOF") { + t.Fatalf("record does not name the cause: %#v", prior) + } +} + +func TestNothingToSummarizeSkipsTheModelCall(t *testing.T) { + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), + testUser("uc", msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + }), + } + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + script := &scriptedProcessor{text: testValidSummary("never used")} + deps, decisions := additiveDeps(store, script) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if script.calls != 0 { + t.Fatalf("summary calls = %d, want 0", script.calls) + } + if d := (*decisions)[0]; d.SummaryStatus != "no-head" || d.TranscriptMessages != 0 || d.TailMessages != 1 { + t.Fatalf("decision = %#v", d) + } + if selectableBoundaries(t, store, "uc") != 1 { + t.Fatal("no boundary was installed") + } + // The whole history is the tail, and the projection must still carry it: + // compaction user, record, then u0 verbatim, then the auto-continue. + fresh, _ := store.Messages(context.Background(), "ses_1") + tailStart := fresh[1].Parts[0].(msgmodel.CompactionPart).TailStartID + if tailStart == nil || *tailStart != "u0" { + t.Fatalf("no-head boundary did not name the tail: %v", tailStart) + } + order := []string{} + for _, m := range msgmodel.FilterCompacted(newestFirstMessages(fresh)) { + order = append(order, m.Info.MessageID()) + } + if len(order) != 4 || order[0] != "uc" || order[2] != "u0" { + t.Fatalf("projection after a no-head boundary = %v", order) + } +} + +func TestChangedFilesPinIsInstalledBesideTheSummary(t *testing.T) { + for name, script := range map[string]*scriptedProcessor{ + "valid": {text: testValidSummary("x")}, + "rejected": {text: "not a record"}, + } { + t.Run(name, func(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + deps, _ := additiveDeps(store, script) + if _, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: false, + }); err != nil { + t.Fatal(err) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + prior := completedCompactions(fresh) + if len(prior) != 1 { + t.Fatalf("completed compactions = %#v", prior) + } + pinned := false + for _, raw := range fresh[prior[0].AssistantIndex].Parts { + part, ok := raw.(msgmodel.TextPart) + if ok && strings.Contains(string(part.Metadata), `"changed_files"`) && + strings.Contains(part.Text, "src/a.ts | 4 ++--") && boolPointer(part.Synthetic) { + pinned = true + } + } + if !pinned { + t.Fatal("changed-files pin missing") + } + }) + } +} + +func TestFallbackRecordAlwaysValidates(t *testing.T) { + previous := "## Working State\n### Completed\n- did things\n### Current\n- x\n### Verification\n- y\n### Next\n- z\n### Files\n- f" + for name, record := range map[string]string{ + "with previous": fallbackRecord(&previous, "task", errString("boom"), true), + "bare": fallbackRecord(nil, "", nil, false), + } { + if err := ValidateSummaryText(record); err != nil { + t.Fatalf("%s: %v\n%s", name, err, record) + } + } +} + +type errString string + +func (e errString) Error() string { return string(e) } + +func newestFirstMessages(messages []msgmodel.WithParts) []msgmodel.WithParts { + out := make([]msgmodel.WithParts, len(messages)) + for i := range messages { + out[len(messages)-1-i] = messages[i] + } + return out +} + +func indexOf(values []string, want string) int { + for i, value := range values { + if value == want { + return i + } + } + return -1 +} + +func indexOfPrefix(values []string, prefix string) int { + for i, value := range values { + if strings.HasPrefix(value, prefix) { + return i + } + } + return -1 +} diff --git a/internal/seniordev/session/compaction/controller.go b/internal/seniordev/session/compaction/controller.go new file mode 100644 index 0000000000..490670e829 --- /dev/null +++ b/internal/seniordev/session/compaction/controller.go @@ -0,0 +1,70 @@ +//go:build !windows + +// Controller adapts the compaction service to the step-loop task seam. +package compaction + +import ( + "context" + "errors" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +type Controller struct { + Compaction *Service +} + +func (c Controller) ProcessCompaction( + ctx context.Context, + input steploop.TaskInput, + task msgmodel.CompactionPart, +) (steploop.Result, error) { + if c.Compaction == nil { + return steploop.ResultStop, errors.New("compaction: nil service") + } + return c.Compaction.Process(ctx, ProcessInput{ + ParentID: task.MessageID, Messages: input.Messages, + SessionID: input.SessionID, Auto: task.Auto, Overflow: task.Overflow, + }) +} + +func (c Controller) IsOverflow( + ctx context.Context, + assistant msgmodel.Assistant, + model steploop.Model, +) (bool, error) { + if c.Compaction == nil { + return false, errors.New("compaction: nil service") + } + return c.Compaction.IsOverflow( + ctx, assistant.Tokens, Model{Message: model.Message, Overflow: model.Calc}, + ) +} + +func (c Controller) CreateCompaction( + ctx context.Context, + sessionID string, + user msgmodel.User, + overflowed bool, +) error { + if c.Compaction == nil { + return errors.New("compaction: nil service") + } + return c.Compaction.Create(ctx, CreateInput{ + SessionID: sessionID, Agent: user.Agent, + Model: ModelRef{ + ProviderID: user.Model.ProviderID, ModelID: user.Model.ModelID, + }, + Auto: true, Overflow: &overflowed, + }) +} + +func (c Controller) Prune(ctx context.Context, sessionID string) error { + if c.Compaction == nil { + return errors.New("compaction: nil service") + } + return c.Compaction.Prune(ctx, sessionID) +} + +var _ steploop.TaskController = Controller{} diff --git a/internal/seniordev/session/compaction/controller_test.go b/internal/seniordev/session/compaction/controller_test.go new file mode 100644 index 0000000000..d5d2f55019 --- /dev/null +++ b/internal/seniordev/session/compaction/controller_test.go @@ -0,0 +1,54 @@ +//go:build !windows + +package compaction + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +// The controller hands the assistant's token count and the session's config +// to the overflow arithmetic; the trigger is the high watermark of the +// capacity, and nothing else fires it. +func TestControllerIsOverflowTriggersAtTheHighWatermark(t *testing.T) { + capacity := 100_000.0 + service := NewService(Dependencies{ + Config: ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{ + CapacityTokens: &capacity, + }}, nil + }), + }) + controller := Controller{Compaction: service} + model := steploop.Model{Calc: calc.Model{ + Limit: calc.ModelLimit{Context: 200_000, Output: 32_768}, + }} + + tests := []struct { + name string + tokens uint64 + want bool + }{ + {name: "below the high watermark", tokens: 59_999}, + {name: "at the high watermark", tokens: 60_000, want: true}, + {name: "far above", tokens: 150_000, want: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := controller.IsOverflow(context.Background(), msgmodel.Assistant{ + Tokens: msgmodel.Tokens{Input: test.tokens, Cache: msgmodel.TokenCache{}}, + }, model) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("IsOverflow() = %v, want %v", got, test.want) + } + }) + } +} diff --git a/internal/seniordev/session/compaction/core.go b/internal/seniordev/session/compaction/core.go new file mode 100644 index 0000000000..94c42e5974 --- /dev/null +++ b/internal/seniordev/session/compaction/core.go @@ -0,0 +1,424 @@ +//go:build !windows + +// Package compaction summarizes a session's older history into a state record +// when the context window fills, keeping the newest messages verbatim. +package compaction + +import ( + "fmt" + "math" + "strconv" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +const ( + PruneMinimum = 20_000 + PruneProtect = 40_000 + // ToolOutputMaxChars caps each tool output inside the flattened transcript + // handed to the summarizer. + ToolOutputMaxChars = 2_000 + // SummaryTranscriptMaxChars bounds the whole flattened transcript. A head + // that outgrows it is cut in the middle (a quarter from the start, the rest + // from the end) so the summarizer sees how the work began and, mostly, its + // latest state. ~60K tokens, well inside any current model's window. + SummaryTranscriptMaxChars = 240_000 + EvidenceToolOutputMaxChars = 2_000 + EventCompacted = "session.compacted" +) + +var PruneProtectedTools = []string{"skill"} + +const SummarySystemPrompt = `You are a context serializer. The text inside <conversation> tags is a +finished transcript given to you as DATA, not a conversation to continue. +Do NOT continue the task, call tools, or emit tool-call markup as text. Do NOT +write code or address the user. Read the transcript and return ONLY the +Markdown state record in the exact format requested after it.` + +// The task itself is pinned verbatim beside every summary, so the record does +// not repeat it. The contract is deliberately small: current work, exact +// verification evidence, and the next concrete action. +const SummaryTemplate = `Return exactly this Markdown structure, with terse bullets and no text outside it: +## Working State +### Completed +- completed work, or (none) +### Current +- work in progress, blockers, and decisions, or (none) +### Verification +- exact commands, exit codes, and error strings, or (none) +### Next +- the next concrete action, or (none) +### Files +- relevant file paths and why, or (none) + +Preserve exact paths, commands, errors, and identifiers. Do not restate the task; it is pinned verbatim beside this record.` + +var summaryHeadings = []string{ + "## Working State", + "### Completed", + "### Current", + "### Verification", + "### Next", + "### Files", +} + +type Turn struct { + Start int `json:"start"` + End int `json:"end"` + ID string `json:"id"` +} + +type CompletedCompaction struct { + UserIndex int + AssistantIndex int + Summary *string +} + +func summaryText(message msgmodel.WithParts) *string { + parts := []string{} + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) { + continue + } + text := strings.TrimSpace(part.Text) + if text != "" { + parts = append(parts, text) + } + } + text := strings.TrimSpace(strings.Join(parts, "\n\n")) + if text == "" { + return nil + } + return &text +} + +func generatedSummaryText(message msgmodel.WithParts) *string { + parts := []string{} + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) || boolPointer(part.Synthetic) { + continue + } + text := strings.TrimSpace(part.Text) + if text != "" { + parts = append(parts, text) + } + } + text := strings.TrimSpace(strings.Join(parts, "\n\n")) + if text == "" { + return nil + } + return &text +} + +// Summary failure classes, emitted on the compaction decision event so every +// rejected summary can be attributed from the event stream. +const ( + SummaryClassToolCall = "tool-call" + SummaryClassDSMLText = "dsml-text" + SummaryClassEmpty = "empty" + SummaryClassFormat = "contract-format" +) + +// ClassifySummaryFailure labels a rejected summary attempt. tool-call is a +// structural ToolPart; dsml-text is provider tool-call markup leaked as TEXT; +// empty is no usable text; contract-format is any other heading/structure +// violation. +func ClassifySummaryFailure(message msgmodel.WithParts) string { + for _, part := range message.Parts { + if _, ok := part.(msgmodel.ToolPart); ok { + return SummaryClassToolCall + } + } + text := generatedSummaryText(message) + if text == nil { + return SummaryClassEmpty + } + if containsToolMarkup(*text) { + return SummaryClassDSMLText + } + return SummaryClassFormat +} + +// containsToolMarkup detects tool-call markup emitted as plain text — DeepSeek +// DSML (the "|" full-width bar and the DSML token) plus the common +// XML-ish tool-call shapes other providers leak. +func containsToolMarkup(text string) bool { + if strings.Contains(text, "|") || strings.Contains(text, "DSML") { + return true + } + lower := strings.ToLower(text) + for _, marker := range []string{ + "<tool_call", "<invoke", "invoke name=", "<function_call", "<|tool", + } { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// ValidateSummary rejects a response before it can become a compaction +// boundary. Structural step/reasoning parts are harmless provider metadata, +// but a tool-shaped response is never a serialized session state. +func ValidateSummary(message msgmodel.WithParts) error { + for _, part := range message.Parts { + if _, ok := part.(msgmodel.ToolPart); ok { + return fmt.Errorf("compaction summary contains a tool call") + } + } + text := generatedSummaryText(message) + if text == nil { + return fmt.Errorf("compaction summary is empty") + } + return ValidateSummaryText(*text) +} + +func ValidateSummaryText(text string) error { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return fmt.Errorf("compaction summary is empty") + } + lines := strings.Split(strings.ReplaceAll(trimmed, "\r\n", "\n"), "\n") + if strings.TrimSpace(lines[0]) != summaryHeadings[0] { + return fmt.Errorf("compaction summary must begin with %q", summaryHeadings[0]) + } + found := make([]string, 0, len(summaryHeadings)) + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "## ") || strings.HasPrefix(line, "### ") { + found = append(found, line) + } + } + if len(found) != len(summaryHeadings) { + return fmt.Errorf( + "compaction summary has %d contract headings; want %d", + len(found), len(summaryHeadings), + ) + } + for index, heading := range summaryHeadings { + if found[index] != heading { + return fmt.Errorf( + "compaction summary heading %d is %q; want %q", + index+1, found[index], heading, + ) + } + } + return nil +} + +// NormalizeOffFormatSummary recovers a stateful answer (headed "Summary of +// Changes" or similar) that would otherwise be discarded solely because it +// missed the report template. Coding continuations and tool calls still fail +// closed; only an explicitly summary/state-shaped text is wrapped. +func NormalizeOffFormatSummary(message msgmodel.WithParts) (string, bool) { + for _, part := range message.Parts { + if _, ok := part.(msgmodel.ToolPart); ok { + return "", false + } + } + text := generatedSummaryText(message) + if text == nil { + return "", false + } + first, _, _ := strings.Cut(strings.TrimSpace(*text), "\n") + first = strings.ToLower(strings.TrimSpace(strings.TrimLeft(first, "#"))) + if !strings.Contains(first, "summary") && !strings.Contains(first, "state") && + !strings.Contains(first, "progress") && !strings.Contains(first, "changes") { + return "", false + } + recovered := HeadTailTruncate(strings.TrimSpace(*text), 8_000) + return strings.Join([]string{ + "## Working State", + "### Completed", quoteAsData(recovered), + "### Current", "- Reconcile the recovered state above with the current repository before editing.", + "### Verification", "- Verification claims in the recovered state are untrusted unless they include an exact command and outcome.", + "### Next", "- Inspect the current diff and resume from the last concrete unfinished item.", + "### Files", "- Use the exact paths preserved in the recovered state above.", + }, "\n"), true +} + +func completedCompactions(messages []msgmodel.WithParts) []CompletedCompaction { + users := map[string]int{} + for i, message := range messages { + user, ok := message.Info.(msgmodel.User) + if !ok || !hasCompaction(message.Parts) { + continue + } + users[user.ID] = i + } + out := []CompletedCompaction{} + for assistantIndex, message := range messages { + assistant, ok := message.Info.(msgmodel.Assistant) + if !ok || !boolPointer(assistant.Summary) || + assistant.Finish == nil || *assistant.Finish == "" || assistant.Error != nil { + continue + } + userIndex, ok := users[assistant.ParentID] + if !ok { + continue + } + if err := ValidateSummary(message); err != nil { + continue + } + out = append(out, CompletedCompaction{ + UserIndex: userIndex, AssistantIndex: assistantIndex, + Summary: generatedSummaryText(message), + }) + } + return out +} + +func BuildPrompt(previousSummary *string, context []string) string { + anchor := "Create a new anchored summary from the conversation history above." + if previousSummary != nil && *previousSummary != "" { + anchor = strings.Join([]string{ + "Update the anchored summary below using the conversation history above.", + "Preserve still-true details, remove stale details, and merge in the new facts.", + "<previous-summary>", + *previousSummary, + "</previous-summary>", + }, "\n") + } + parts := []string{anchor, SummaryTemplate} + parts = append(parts, context...) + return strings.Join(parts, "\n\n") +} + +// tailBudget is the token budget for the truncated older messages of the +// verbatim tail. An explicit preserve_recent_tokens always wins. Otherwise +// the tail is preserve_recent_fraction (default DefaultPreserveRecentFraction) +// of the high watermark, so a 300K trigger keeps a 60K tail rather than a +// fixed one. +func TailBudget(cfg overflow.Config, marks overflow.CompactionWatermarks) float64 { + return tailBudget(cfg, marks) +} + +func tailBudget(cfg overflow.Config, marks overflow.CompactionWatermarks) float64 { + fraction := DefaultPreserveRecentFraction + if cfg.Compaction != nil { + if cfg.Compaction.PreserveRecentTokens != nil { + return *cfg.Compaction.PreserveRecentTokens + } + if f := cfg.Compaction.PreserveRecentFraction; f != nil && *f > 0 && *f < 1 { + fraction = *f + } + } + return math.Floor(marks.High * fraction) +} + +func IsSyntheticUser(message msgmodel.WithParts) bool { + if _, ok := message.Info.(msgmodel.User); !ok { + return false + } + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || part.Synthetic == nil || !*part.Synthetic { + return false + } + } + return true +} + +func Turns(messages []msgmodel.WithParts) []Turn { + result := []Turn{} + for i, message := range messages { + user, ok := message.Info.(msgmodel.User) + if !ok || hasCompaction(message.Parts) || IsSyntheticUser(message) { + continue + } + result = append(result, Turn{ + Start: i, End: len(messages), ID: user.ID, + }) + } + for i := 0; i < len(result)-1; i++ { + result[i].End = result[i+1].Start + } + return result +} + +func HeadTailTruncate(text string, maxChars float64) string { + length := charCount(text) + if maxChars <= 0 || float64(length) <= maxChars { + return text + } + headChars := int(maxChars / 4) + tailChars := int(maxChars) - headChars + head := sliceChars(text, 0, headChars) + tail := sliceChars(text, length-tailChars, length) + omitted := length - headChars - tailChars + return head + "\n[Tool output truncated for evidence: omitted " + + strconv.Itoa(omitted) + " chars]\n" + tail +} + +func EvidenceBlocksFromMessages( + messages []msgmodel.WithParts, maxToolChars ...float64, +) []string { + maxChars := float64(EvidenceToolOutputMaxChars) + if len(maxToolChars) > 0 { + maxChars = maxToolChars[0] + } + blocks := []string{} + for _, message := range messages { + parts := []string{} + for _, raw := range message.Parts { + switch part := raw.(type) { + case msgmodel.TextPart: + if strings.TrimSpace(part.Text) != "" { + parts = append(parts, part.Text) + } + case msgmodel.ToolPart: + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if ok && completed.Output != "" && strings.TrimSpace(completed.Output) != "" { + parts = append(parts, HeadTailTruncate(completed.Output, maxChars)) + } + } + } + text := strings.Join(parts, "\n") + if strings.TrimSpace(text) != "" { + blocks = append(blocks, text) + } + } + return blocks +} + +type Model struct { + Message msgmodel.Model + Overflow overflow.Model +} + +type EstimateFunc func(messages []msgmodel.WithParts, model Model) (float64, error) + +// estimateTokens is the rough four-characters-per-token estimate used when a +// message has no recorded usage. +func estimateTokens(input string) float64 { + return math.Round(float64(charCount(input)) / 4) +} + +func hasCompaction(parts msgmodel.Parts) bool { + for _, part := range parts { + if _, ok := part.(msgmodel.CompactionPart); ok { + return true + } + } + return false +} + +func boolPointer(value *bool) bool { return value != nil && *value } + +// charCount is the length of value in characters (runes), the unit every +// character budget in this package is expressed in. +func charCount(value string) int { return utf8.RuneCountInString(value) } + +// sliceChars returns the characters of value in [start, end), clamped to the +// string, so a cut never splits a multi-byte character. +func sliceChars(value string, start, end int) string { + runes := []rune(value) + start = max(0, min(start, len(runes))) + end = max(start, min(end, len(runes))) + return string(runes[start:end]) +} diff --git a/internal/seniordev/session/compaction/core_test.go b/internal/seniordev/session/compaction/core_test.go new file mode 100644 index 0000000000..70aca0e530 --- /dev/null +++ b/internal/seniordev/session/compaction/core_test.go @@ -0,0 +1,278 @@ +//go:build !windows + +package compaction + +import ( + "fmt" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +func testUser(id string, parts ...msgmodel.Part) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.TimeCreated{Created: 1}, + Agent: "build", + Model: msgmodel.UserModel{ProviderID: "openrouter", ModelID: "m"}, + }, + Parts: parts, + } +} + +func testAssistant(id, parent string, parts ...msgmodel.Part) msgmodel.WithParts { + return msgmodel.WithParts{ + Info: msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ID: id, SessionID: "ses_1"}, + Time: msgmodel.AssistantTime{Created: 2}, + ParentID: parent, ModelID: "m", ProviderID: "openrouter", + Mode: "build", Agent: "build", + Path: msgmodel.AssistantPath{Cwd: "/work", Root: "/work"}, + Tokens: msgmodel.Tokens{Cache: msgmodel.TokenCache{}}, + }, + Parts: parts, + } +} + +func textPart(messageID, text string) msgmodel.TextPart { + return msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: "p_" + messageID, SessionID: "ses_1", MessageID: messageID, + }, + Text: text, + } +} + +func testValidSummary(goal string) string { + return strings.Join([]string{ + "## Working State", + "### Completed", "- " + goal, + "### Current", "- inspect the implementation", + "### Verification", "- (none)", + "### Next", "- continue", + "### Files", "- (none)", + }, "\n") +} + +func testOverflowModel(context, output float64) overflow.Model { + return overflow.Model{ + Limit: calc.ModelLimit{Context: context, Output: output}, + } +} + +func TestTailBudgetDefaultsAndExplicitValueWins(t *testing.T) { + marks := overflow.CompactionWatermarks{Capacity: 500_000, High: 300_000, Low: 200_000} + if got := tailBudget(overflow.Config{}, marks); got != 60_000 { + t.Fatalf("default budget = %v, want 60000 (0.2 of high)", got) + } + explicit := float64(0) + got := tailBudget(overflow.Config{Compaction: &overflow.CompactionConfig{ + PreserveRecentTokens: &explicit, + }}, marks) + if got != 0 { + t.Fatalf("explicit zero budget = %v", got) + } + _ = testOverflowModel +} + +func TestTailBudgetIsAFractionOfHigh(t *testing.T) { + marks := overflow.CompactionWatermarks{Capacity: 500_000, High: 300_000, Low: 200_000} + window := func(mutate func(*overflow.CompactionConfig)) overflow.Config { + c := &overflow.CompactionConfig{Policy: overflow.PolicyWindow} + if mutate != nil { + mutate(c) + } + return overflow.Config{Compaction: c} + } + if got := tailBudget(window(nil), marks); got != 60_000 { + t.Fatalf("default fraction budget = %v, want 60000 (0.2 of high)", got) + } + tenth := 0.1 + if got := tailBudget(window(func(c *overflow.CompactionConfig) { c.PreserveRecentFraction = &tenth }), marks); got != 30_000 { + t.Fatalf("explicit fraction budget = %v, want 30000", got) + } + // Out-of-range fractions fall back to the default rather than producing + // an empty or whole-context tail. + for _, bad := range []float64{0, 1, 1.5, -0.2} { + f := bad + if got := tailBudget(window(func(c *overflow.CompactionConfig) { c.PreserveRecentFraction = &f }), marks); got != 60_000 { + t.Fatalf("fraction %v budget = %v, want the 60000 default", bad, got) + } + } + // An explicit token budget still wins over the fraction. + tokens := 45_000.0 + if got := tailBudget(window(func(c *overflow.CompactionConfig) { + c.PreserveRecentTokens = &tokens + c.PreserveRecentFraction = &tenth + }), marks); got != 45_000 { + t.Fatalf("tokens should win over fraction: %v", got) + } +} + +func TestEstimateTokensCountsCharacters(t *testing.T) { + cases := map[string]float64{ + "": 0, "a": 0, "ab": 1, "abcde": 1, + "abcdef": 2, "😀": 0, "😀a": 1, "😀😀": 1, + } + for input, want := range cases { + if got := estimateTokens(input); got != want { + t.Errorf("estimateTokens(%q) = %v, want %v", input, got, want) + } + } +} + +func TestCompletedCompactionsRequireSuccessfulFinishedSummary(t *testing.T) { + finish := "stop" + summary := true + compaction := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + } + user := testUser("uc", compaction) + valid := testValidSummary("Fix src/a.ts") + ok := testAssistant("ac", "uc", textPart("ac", " "+valid+" ")) + assistant := ok.Info.(msgmodel.Assistant) + assistant.Summary = &summary + assistant.Finish = &finish + ok.Info = assistant + failed := ok + failedAssistant := failed.Info.(msgmodel.Assistant) + failedAssistant.ID = "af" + converted := msgmodel.NewUnknownError("boom") + failedAssistant.Error = &converted + failed.Info = failedAssistant + got := completedCompactions([]msgmodel.WithParts{user, ok, failed}) + if len(got) != 1 || got[0].Summary == nil || *got[0].Summary != valid { + t.Fatalf("completed compactions = %#v", got) + } +} + +func TestValidateSummaryRejectsEmptyMalformedAndToolShapedOutput(t *testing.T) { + validText := testValidSummary("Fix src/a.ts") + valid := testAssistant("valid", "uc", textPart("valid", validText)) + if err := ValidateSummary(valid); err != nil { + t.Fatalf("valid summary rejected: %v", err) + } + + cases := map[string]msgmodel.WithParts{ + "empty": testAssistant("empty", "uc"), + "malformed": testAssistant("malformed", "uc", textPart("malformed", "I will inspect the code next.")), + "missing section": testAssistant( + "partial", "uc", textPart("partial", "## Goal\n- Fix src/a.ts"), + ), + "tool shaped": testAssistant( + "tool", "uc", + textPart("tool", validText), + msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ + ID: "p_tool", SessionID: "ses_1", MessageID: "tool", + }, + CallID: "call_1", Tool: "bash", State: msgmodel.PendingToolState(), + }, + ), + } + for name, message := range cases { + t.Run(name, func(t *testing.T) { + if err := ValidateSummary(message); err == nil { + t.Fatal("invalid summary was accepted") + } + }) + } +} + +func TestNormalizeOffFormatSummaryKeepsStateButRejectsContinuation(t *testing.T) { + report := testAssistant("report", "uc", textPart( + "report", + "## Summary of Changes\n### Current\n- Added SortBy in src/cli.rs\n- cargo build: error: could not compile", + )) + normalized, ok := NormalizeOffFormatSummary(report) + if !ok || ValidateSummaryText(normalized) != nil { + t.Fatalf("off-format state was not normalized:\n%s", normalized) + } + if !strings.Contains(normalized, "src/cli.rs") || + !strings.Contains(normalized, "error: could not compile") { + t.Fatalf("normalization lost working state:\n%s", normalized) + } + if !strings.Contains(normalized, `> \### Current`) { + t.Fatalf("recovered headings can collide with the summary envelope:\n%s", normalized) + } + + continuation := testAssistant( + "continue", "uc", textPart("continue", "Let me verify the edit by reading src/cli.rs."), + ) + if _, ok := NormalizeOffFormatSummary(continuation); ok { + t.Fatal("a coding continuation was mistaken for serialized state") + } +} + +func TestOverflowHistoryAndReplayPartSurgery(t *testing.T) { + imageName := "photo.png" + image := msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "pi", SessionID: "ses_1", MessageID: "u1"}, + Mime: "image/png", Filename: &imageName, URL: "data:image/png;base64,AA", + } + plain := msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "pt", SessionID: "ses_1", MessageID: "u1"}, + Mime: "text/plain", URL: "file:///a.txt", + } + compaction := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + } + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", textPart("a0", "reply")), + testUser("u1", image, plain), + testUser("uc", compaction), + } + selected := selectOverflowHistory(messages, "uc", true) + if selected.Replay == nil || selected.Replay.Info.ID != "u1" || + len(selected.Messages) != 2 { + t.Fatalf("overflow history = %#v", selected) + } + id := 0 + parts := buildReplayParts(*selected.Replay, "ses_new", "msg_new", func(prefix string) string { + id++ + return fmt.Sprintf("%s_%d", prefix, id) + }) + if len(parts) != 2 { + t.Fatalf("replay parts = %#v", parts) + } + placeholder, ok := parts[0].(msgmodel.TextPart) + if !ok || placeholder.Text != "[Attached image/png: photo.png]" || + placeholder.SessionID != "ses_new" || placeholder.MessageID != "msg_new" { + t.Fatalf("placeholder = %#v", parts[0]) + } + replayedFile, ok := parts[1].(msgmodel.FilePart) + if !ok || replayedFile.ID != "part_2" || replayedFile.Mime != "text/plain" { + t.Fatalf("plain replay = %#v", parts[1]) + } + + noHead := selectOverflowHistory(messages[2:], "uc", true) + if noHead.Replay != nil || len(noHead.Messages) != 2 { + t.Fatalf("fallback history = %#v", noHead) + } +} + +func TestAutoContinueStrings(t *testing.T) { + if got := autoContinueText(false); got != + "The conversation was compacted: the state record above replaces the older transcript, and the most recent messages are retained verbatim. Continue from the current state." { + t.Fatalf("normal continue = %q", got) + } + if got := autoContinueText(true); !stringsContainsAll( + got, "exceeded the provider's size limit", "\n\nThe conversation was compacted", + ) { + t.Fatalf("overflow continue = %q", got) + } +} + +func stringsContainsAll(value string, needles ...string) bool { + for _, needle := range needles { + if !strings.Contains(value, needle) { + return false + } + } + return true +} diff --git a/internal/seniordev/session/compaction/evidence.go b/internal/seniordev/session/compaction/evidence.go new file mode 100644 index 0000000000..da2563b897 --- /dev/null +++ b/internal/seniordev/session/compaction/evidence.go @@ -0,0 +1,24 @@ +//go:build !windows + +package compaction + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/evidenceharvest" +) + +// FallbackEvidenceSelector exposes the deterministic evidence harvest through +// the compaction service's evidence seam. +type FallbackEvidenceSelector struct { + MaxChars float64 +} + +func (selector FallbackEvidenceSelector) SelectEvidence( + _ context.Context, blocks []string, +) (*string, error) { + if selector.MaxChars > 0 { + return evidenceharvest.HarvestEvidence(blocks, selector.MaxChars), nil + } + return evidenceharvest.HarvestEvidence(blocks), nil +} diff --git a/internal/seniordev/session/compaction/service.go b/internal/seniordev/session/compaction/service.go new file mode 100644 index 0000000000..0238ce1bb6 --- /dev/null +++ b/internal/seniordev/session/compaction/service.go @@ -0,0 +1,1356 @@ +//go:build !windows + +// The compaction service runs one compaction boundary end to end. Concrete +// config/provider/plugin/session/processor services are represented by narrow +// interfaces; the state transitions and model-visible strings live here. +package compaction + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +type ConfigProvider interface { + GetConfig(ctx context.Context) (overflow.Config, error) +} + +type ConfigProviderFunc func(ctx context.Context) (overflow.Config, error) + +func (f ConfigProviderFunc) GetConfig(ctx context.Context) (overflow.Config, error) { + return f(ctx) +} + +type ModelRef struct { + ProviderID string + ModelID string +} + +type Agent struct { + Name string + Model *ModelRef +} + +type AgentProvider interface { + GetAgent(ctx context.Context, name string) (Agent, error) +} + +type AgentProviderFunc func(ctx context.Context, name string) (Agent, error) + +func (f AgentProviderFunc) GetAgent(ctx context.Context, name string) (Agent, error) { + return f(ctx, name) +} + +type ProviderInfo struct { + Source string + Options any +} + +type ModelProvider interface { + GetModel(ctx context.Context, providerID, modelID string) (Model, error) + GetProvider(ctx context.Context, providerID string) (ProviderInfo, error) +} + +type CompactingResult struct { + Context []string + Prompt *string +} + +type AutoContinueInput struct { + SessionID string + Agent string + Model Model + Provider ProviderInfo + Message msgmodel.User + Overflow bool +} + +type Plugin interface { + Compacting(ctx context.Context, sessionID string) (CompactingResult, error) + TransformMessages(ctx context.Context, messages []msgmodel.WithParts) error + AutoContinue(ctx context.Context, input AutoContinueInput) (bool, error) +} + +type SummaryRequest struct { + User msgmodel.User + Agent Agent + SessionID string + Messages []msgmodel.ModelMessage + Model Model +} + +type SummaryProcessor interface { + Process(ctx context.Context, request SummaryRequest) (steploop.Result, error) + Message() msgmodel.Assistant +} + +type ProcessorFactory interface { + Create( + ctx context.Context, + assistant *msgmodel.Assistant, + sessionID string, + model Model, + ) (SummaryProcessor, error) +} + +type ProcessorFactoryFunc func( + ctx context.Context, + assistant *msgmodel.Assistant, + sessionID string, + model Model, +) (SummaryProcessor, error) + +func (f ProcessorFactoryFunc) Create( + ctx context.Context, + assistant *msgmodel.Assistant, + sessionID string, + model Model, +) (SummaryProcessor, error) { + return f(ctx, assistant, sessionID, model) +} + +type EvidenceSelector interface { + SelectEvidence(ctx context.Context, blocks []string) (*string, error) +} + +type EvidenceSelectorFunc func(ctx context.Context, blocks []string) (*string, error) + +func (f EvidenceSelectorFunc) SelectEvidence(ctx context.Context, blocks []string) (*string, error) { + return f(ctx, blocks) +} + +// ContextSizer measures the complete model-visible request represented by a +// projected message list. The senior-dev adapter includes its system prompt and +// tool schemas; the default service implementation measures messages alone. +type ContextSizer interface { + EstimateContext(ctx context.Context, messages []msgmodel.WithParts, model Model) (float64, error) +} + +type ContextSizerFunc func( + ctx context.Context, messages []msgmodel.WithParts, model Model, +) (float64, error) + +func (f ContextSizerFunc) EstimateContext( + ctx context.Context, messages []msgmodel.WithParts, model Model, +) (float64, error) { + return f(ctx, messages, model) +} + +// CompactionDecision is the one record a boundary leaves behind: what the +// summarizer was handed (transcript and prompt sizes), what the provider +// reported back (prompt and output tokens), how the summary was judged, and +// how much verbatim tail was kept. +type CompactionDecision struct { + SessionID string `json:"sessionID"` + Status string `json:"status"` + Before float64 `json:"beforeTokens"` + After float64 `json:"afterTokens"` + Capacity float64 `json:"capacityTokens"` + Low float64 `json:"lowTokens"` + High float64 `json:"highTokens"` + // DroppedTail: the watermark rebuild removed the verbatim tail. + // StubbedSummary: it also had to replace the summary with the capacity + // stub because the summary block alone did not fit. + DroppedTail bool `json:"droppedTail"` + StubbedSummary bool `json:"stubbedSummary,omitempty"` + // SummaryStatus is valid, normalized, fallback, no-head (nothing to + // summarize, no call made), overflow (the summary request itself exceeded + // the model), summary-error (the call failed), summary-stopped, or + // capacity-fallback (the watermark rebuild replaced the record). Every + // status except valid and normalized installs the deterministic record. + SummaryStatus string `json:"summaryStatus"` + SummaryClass string `json:"summaryClass,omitempty"` + SummaryError string `json:"summaryError,omitempty"` + + TranscriptMessages int `json:"transcriptMessages"` + TranscriptChars int `json:"transcriptChars"` + TranscriptCutChars float64 `json:"transcriptCutChars,omitempty"` + PromptChars int `json:"promptChars"` + SummaryPromptTokens uint64 `json:"summaryPromptTokens"` + SummaryOutputTokens uint64 `json:"summaryOutputTokens"` + SummaryWallMs int64 `json:"summaryWallMs"` + + TailBudget float64 `json:"tailBudget"` + TailMessages int `json:"tailMessages"` + TailTokens float64 `json:"tailTokens"` + TailTruncatedOutputs int `json:"tailTruncatedOutputs"` + TailTruncatedReasoning int `json:"tailTruncatedReasoning,omitempty"` + // PreviousSummaryCarried is set when a deterministic record carried the + // previous boundary's summary forward verbatim. + PreviousSummaryCarried bool `json:"previousSummaryCarried,omitempty"` +} + +type DecisionSink interface { + CompactionDecision(decision CompactionDecision) +} + +type DecisionSinkFunc func(decision CompactionDecision) + +func (f DecisionSinkFunc) CompactionDecision(decision CompactionDecision) { f(decision) } + +type InstanceContext struct { + Directory string + Worktree string +} + +type EventSink interface { + CompactionStarted(sessionID string, timestamp uint64, reason string) + CompactionEnded(sessionID string, timestamp uint64, text string, include *string) + PublishCompacted(ctx context.Context, sessionID string) error +} + +type Dependencies struct { + Store steploop.Store + Config ConfigProvider + Agents AgentProvider + Provider ModelProvider + Plugin Plugin + Processors ProcessorFactory + Evidence EvidenceSelector + Sizer ContextSizer + Decisions DecisionSink + Events EventSink + Instance InstanceContext + // ChangedFiles reports the workspace's changed files as preformatted + // lines, computed by code (a diffstat against the starting tree plus the + // status). It is pinned beside every summary as a record the model cannot + // misremember; nil disables the pin. + ChangedFiles func(ctx context.Context) []string + + NewID func(prefix string) string + Now func() uint64 +} + +type Service struct { + deps Dependencies +} + +var ErrContextCapacityExhausted = errors.New("compaction: context capacity exhausted") + +type ContextCapacityError struct { + After float64 + High float64 +} + +func (err ContextCapacityError) Error() string { + return fmt.Sprintf( + "%s: deterministic rebuild is %.0f tokens; high watermark is %.0f", + ErrContextCapacityExhausted, err.After, err.High, + ) +} + +func (ContextCapacityError) Unwrap() error { return ErrContextCapacityExhausted } + +func NewService(deps Dependencies) *Service { + if deps.NewID == nil { + deps.NewID = defaultID + } + if deps.Now == nil { + deps.Now = func() uint64 { return uint64(time.Now().UnixMilli()) } + } + return &Service{deps: deps} +} + +func (s *Service) IsOverflow( + ctx context.Context, tokens msgmodel.Tokens, model Model, +) (bool, error) { + cfg, err := s.config(ctx) + if err != nil { + return false, err + } + return overflow.IsOverflow(overflow.OverflowInput{ + Cfg: cfg, Tokens: overflowTokens(tokens), Model: model.Overflow, + }), nil +} + +func (s *Service) Estimate( + messages []msgmodel.WithParts, model Model, +) (float64, error) { + modelMessages, err := msgmodel.ToModelMessages(messages, model.Message, nil) + if err != nil { + return 0, err + } + raw, err := jsonutil.Marshal(modelMessages) + if err != nil { + return 0, err + } + return estimateTokens(string(raw)), nil +} + +func (s *Service) estimateContext( + ctx context.Context, messages []msgmodel.WithParts, model Model, +) (float64, error) { + if s.deps.Sizer != nil { + return s.deps.Sizer.EstimateContext(ctx, messages, model) + } + return s.Estimate(messages, model) +} + +func (s *Service) projectedContextTokens( + ctx context.Context, sessionID string, model Model, +) (float64, error) { + messages, err := s.deps.Store.Messages(ctx, sessionID) + if err != nil { + return 0, err + } + newest := make([]msgmodel.WithParts, len(messages)) + for index := range messages { + newest[len(messages)-1-index] = messages[index] + } + return s.estimateContext(ctx, msgmodel.FilterCompacted(newest), model) +} + +func (s *Service) Prune(ctx context.Context, sessionID string) error { + if s.deps.Store == nil { + return errors.New("compaction: nil store") + } + cfg, err := s.config(ctx) + if err != nil { + return err + } + if cfg.Compaction != nil && cfg.Compaction.Prune != nil && !*cfg.Compaction.Prune { + return nil + } + messages, err := s.deps.Store.Messages(ctx, sessionID) + if errors.Is(err, msgmodel.ErrNotFound) { + return nil + } + if err != nil { + return err + } + total := float64(0) + pruned := float64(0) + toPrune := []msgmodel.ToolPart{} + turnCount := 0 + stop := false + for messageIndex := len(messages) - 1; messageIndex >= 0 && !stop; messageIndex-- { + message := messages[messageIndex] + if _, ok := message.Info.(msgmodel.User); ok { + turnCount++ + } + if turnCount < 2 { + continue + } + if assistant, ok := message.Info.(msgmodel.Assistant); ok && boolPointer(assistant.Summary) { + break + } + for partIndex := len(message.Parts) - 1; partIndex >= 0; partIndex-- { + part, ok := message.Parts[partIndex].(msgmodel.ToolPart) + if !ok { + continue + } + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if !ok || protectedTool(part.Tool) { + continue + } + if completed.Time.Compacted != nil && *completed.Time.Compacted != 0 { + stop = true + break + } + estimate := estimateTokens(completed.Output) + total += estimate + if total <= PruneProtect { + continue + } + pruned += estimate + toPrune = append(toPrune, part) + } + } + if pruned > PruneMinimum { + for _, part := range toPrune { + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if !ok { + continue + } + now := s.deps.Now() + completed.Time.Compacted = &now + part.State = completed + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + } + } + return nil +} + +type ProcessInput struct { + ParentID string + Messages []msgmodel.WithParts + SessionID string + Auto bool + Overflow *bool +} + +func (s *Service) Process(ctx context.Context, input ProcessInput) (steploop.Result, error) { + if s.deps.Store == nil || s.deps.Agents == nil || + s.deps.Provider == nil || s.deps.Processors == nil { + return steploop.ResultStop, errors.New("compaction: incomplete dependencies") + } + var parent *msgmodel.WithParts + for i := len(input.Messages) - 1; i >= 0; i-- { + if input.Messages[i].Info.MessageID() == input.ParentID { + value := input.Messages[i] + parent = &value + break + } + } + if parent == nil { + return steploop.ResultStop, fmt.Errorf( + "Compaction parent must be a user message: %s", input.ParentID, + ) + } + userMessage, ok := parent.Info.(msgmodel.User) + if !ok { + return steploop.ResultStop, fmt.Errorf( + "Compaction parent must be a user message: %s", input.ParentID, + ) + } + compactionPart := findCompaction(parent.Parts) + overflowed := input.Overflow != nil && *input.Overflow + historyChoice := selectOverflowHistory(input.Messages, input.ParentID, overflowed) + messages := historyChoice.Messages + replay := historyChoice.Replay + + agent, err := s.deps.Agents.GetAgent(ctx, "compaction") + if err != nil { + return steploop.ResultStop, err + } + ref := ModelRef{ + ProviderID: userMessage.Model.ProviderID, ModelID: userMessage.Model.ModelID, + } + if agent.Model != nil { + ref = *agent.Model + } + model, err := s.deps.Provider.GetModel(ctx, ref.ProviderID, ref.ModelID) + if err != nil { + return steploop.ResultStop, err + } + originalModel := model + if ref.ProviderID != userMessage.Model.ProviderID || ref.ModelID != userMessage.Model.ModelID { + originalModel, err = s.deps.Provider.GetModel( + ctx, userMessage.Model.ProviderID, userMessage.Model.ModelID, + ) + if err != nil { + return steploop.ResultStop, err + } + } + cfg, err := s.config(ctx) + if err != nil { + return steploop.ResultStop, err + } + history := messages + if compactionPart != nil && len(messages) > 0 && + messages[len(messages)-1].Info.MessageID() == input.ParentID { + history = messages[:len(messages)-1] + } + beforeTokens, err := s.estimateContext(ctx, history, originalModel) + if err != nil { + return steploop.ResultStop, fmt.Errorf("compaction: measure input context: %w", err) + } + beforeTokens = math.Max(beforeTokens, observedContextTokens(history)) + prior := completedCompactions(history) + hidden := map[int]bool{} + for _, item := range prior { + hidden[item.UserIndex] = true + hidden[item.AssistantIndex] = true + } + var previousSummary *string + if len(prior) > 0 { + previousSummary = prior[len(prior)-1].Summary + } + visible := make([]msgmodel.WithParts, 0, len(history)-len(hidden)) + for i, message := range history { + if !hidden[i] { + visible = append(visible, message) + } + } + // The tail first: the newest messages are kept verbatim (older tool + // outputs truncated in the store so the tail always fits), and only what + // precedes them is summarized. + // The tail budget follows the same watermarks the trigger used: it is a + // fraction of the high watermark, and it is recorded on the decision + // beside the tail actually kept. + budget := tailBudget(cfg, overflow.Watermarks( + overflow.UsableInput{Cfg: cfg, Model: originalModel.Overflow}, + )) + selected, err := selectTail(visible, budget, model, s.Estimate, TailToolOutputMaxChars) + if err != nil { + return steploop.ResultStop, err + } + compacting := CompactingResult{Context: []string{}} + if s.deps.Plugin != nil { + compacting, err = s.deps.Plugin.Compacting(ctx, input.SessionID) + if err != nil { + return steploop.ResultStop, err + } + } + nextPrompt := BuildPrompt(previousSummary, compacting.Context) + if compacting.Prompt != nil { + nextPrompt = *compacting.Prompt + } + cloned, err := cloneMessages(selected.Head) + if err != nil { + return steploop.ResultStop, err + } + if s.deps.Plugin != nil { + if err := s.deps.Plugin.TransformMessages(ctx, cloned); err != nil { + return steploop.ResultStop, err + } + } + pinnedPrompt := nextPrompt + // The head is flattened into ONE user text block (SerializeTranscript): + // data to read, with no open turn for the model to continue. + transcript, transcriptCut := CapTranscript( + SerializeTranscript(cloned, ToolOutputMaxChars), SummaryTranscriptMaxChars, + ) + summaryPrompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + pinnedPrompt + authoritativeTask, taskSource := s.authoritativeTask(messages) + changedFiles := s.changedFiles(ctx) + + decision := CompactionDecision{ + SessionID: input.SessionID, + TranscriptMessages: len(selected.Head), + TranscriptChars: charCount(transcript), + TranscriptCutChars: transcriptCut, + PromptChars: charCount(summaryPrompt), + TailBudget: budget, + TailMessages: selected.Messages, + TailTokens: selected.Tokens, + TailTruncatedOutputs: selected.TruncatedOutputs, TailTruncatedReasoning: selected.TruncatedReasoning, + } + + // ONE summary call. Whatever comes back, this boundary completes and the + // tail is kept: a valid record is installed as generated, a summary-shaped + // one is normalized, and anything else -- rejected, errored, overflowed, + // or nothing to summarize -- gets the deterministic record, which carries + // the previous summary forward verbatim. There are no retries and no + // second attempt message, so there is nothing for FilterCompacted to + // pick wrongly and no path on which the boundary destroys progress. + assistant := msgmodel.Assistant{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: input.SessionID, + }, + Time: msgmodel.AssistantTime{Created: s.deps.Now()}, + ParentID: input.ParentID, + ModelID: model.Message.ID, + ProviderID: model.Message.ProviderID, + Mode: "compaction", + Agent: "compaction", + Path: msgmodel.AssistantPath{ + Cwd: s.deps.Instance.Directory, Root: s.deps.Instance.Worktree, + }, + Summary: boolAddress(true), + Cost: 0, + Tokens: msgmodel.Tokens{ + Cache: msgmodel.TokenCache{}, + }, + Variant: userMessage.Model.Variant, + } + if err := s.deps.Store.UpdateMessage(ctx, assistant); err != nil { + return steploop.ResultStop, err + } + var ( + accepted msgmodel.WithParts + cause error + ) + decision.SummaryStatus = "valid" + if strings.TrimSpace(transcript) == "" { + decision.SummaryStatus = "no-head" + } else { + processor, err := s.deps.Processors.Create(ctx, &assistant, input.SessionID, model) + if err != nil { + return steploop.ResultStop, err + } + started := time.Now() + attemptResult, err := processor.Process(ctx, SummaryRequest{ + User: userMessage, Agent: agent, SessionID: input.SessionID, + Messages: []msgmodel.ModelMessage{msgmodel.UserText(summaryPrompt)}, + Model: model, + }) + if err != nil { + return steploop.ResultStop, err + } + decision.SummaryWallMs = time.Since(started).Milliseconds() + processorMessage := processor.Message() + decision.SummaryPromptTokens = processorMessage.Tokens.Input + + processorMessage.Tokens.Cache.Read + processorMessage.Tokens.Cache.Write + decision.SummaryOutputTokens = processorMessage.Tokens.Output + candidate, err := s.summaryMessage(ctx, input.SessionID, assistant.ID) + if err != nil { + return steploop.ResultStop, err + } + switch { + case attemptResult == steploop.ResultCompact: + decision.SummaryStatus = "overflow" + cause = errors.New("the summary request itself exceeded the model context") + case processorMessage.Error != nil: + decision.SummaryStatus = "summary-error" + cause = errors.New(assistantErrorText(processorMessage.Error)) + case attemptResult != steploop.ResultContinue: + decision.SummaryStatus = "summary-stopped" + cause = fmt.Errorf("summary call ended with %s", attemptResult) + default: + validationErr := ValidateSummary(candidate) + if validationErr == nil { + accepted = candidate + } else if normalized, ok := NormalizeOffFormatSummary(candidate); ok { + decision.SummaryStatus = "normalized" + decision.SummaryError = validationErr.Error() + accepted, err = s.installSummaryText(ctx, input.SessionID, candidate, normalized) + if err != nil { + return steploop.ResultStop, err + } + } else { + decision.SummaryStatus = "fallback" + decision.SummaryClass = ClassifySummaryFailure(candidate) + cause = validationErr + } + } + } + if accepted.Info == nil { + // The deterministic record. The message becomes the boundary: no + // error, a finish reason, and a record that validates. + if cause != nil { + decision.SummaryError = cause.Error() + } + candidate, err := s.summaryMessage(ctx, input.SessionID, assistant.ID) + if err != nil { + return steploop.ResultStop, err + } + info, _ := candidate.Info.(msgmodel.Assistant) + info.Error = nil + finish := "stop" + info.Finish = &finish + if err := s.deps.Store.UpdateMessage(ctx, info); err != nil { + return steploop.ResultStop, err + } + candidate.Info = info + decision.PreviousSummaryCarried = previousSummary != nil && *previousSummary != "" + accepted, err = s.installSummaryText(ctx, input.SessionID, candidate, + fallbackRecord(previousSummary, authoritativeTask, cause, len(changedFiles) > 0)) + if err != nil { + return steploop.ResultStop, err + } + } + result := steploop.ResultContinue + if authoritativeTask != "" { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: accepted.Info.MessageID(), + SessionID: input.SessionID, + }, + Text: BuildAuthoritativeTaskPin(authoritativeTask, taskSource), + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"authoritative_task"}`), + }); err != nil { + return steploop.ResultStop, err + } + } + + if pin := BuildChangedFilesPin(changedFiles); pin != "" { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: accepted.Info.MessageID(), + SessionID: input.SessionID, + }, + Text: pin, + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"changed_files"}`), + }); err != nil { + return steploop.ResultStop, err + } + } + + if s.deps.Evidence != nil { + evidence, evidenceErr := s.deps.Evidence.SelectEvidence( + ctx, EvidenceBlocksFromMessages(selected.Head), + ) + if evidenceErr == nil && evidence != nil && *evidence != "" { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: accepted.Info.MessageID(), + SessionID: input.SessionID, + }, + Text: *evidence, Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"evidence"}`), + }); err != nil { + return steploop.ResultStop, err + } + } + } + + // The kept tail was measured at its truncated size; make the store agree + // before the tail is projected, so what the model sees costs what was + // counted. + for _, part := range selected.Truncated { + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return steploop.ResultStop, err + } + } + if compactionPart != nil && selected.StartID != nil && + (compactionPart.TailStartID == nil || *compactionPart.TailStartID != *selected.StartID) { + part := *compactionPart + part.TailStartID = selected.StartID + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return steploop.ResultStop, err + } + } + + if result == steploop.ResultContinue && input.Auto { + if replay != nil { + if err := s.persistReplay(ctx, input.SessionID, *replay); err != nil { + return steploop.ResultStop, err + } + } else { + enabled := true + if s.deps.Plugin != nil { + info, err := s.deps.Provider.GetProvider(ctx, userMessage.Model.ProviderID) + if err != nil { + return steploop.ResultStop, err + } + enabled, err = s.deps.Plugin.AutoContinue(ctx, AutoContinueInput{ + SessionID: input.SessionID, Agent: userMessage.Agent, + Model: originalModel, Provider: info, + Message: userMessage, Overflow: overflowed, + }) + if err != nil { + return steploop.ResultStop, err + } + } + if enabled { + if err := s.persistAutoContinue( + ctx, input.SessionID, userMessage, overflowed, + ); err != nil { + return steploop.ResultStop, err + } + } + } + } + + capacity, capacityErr := s.enforceWatermarks( + ctx, input.SessionID, beforeTokens, originalModel, cfg, + compactionPart, taskSource, + ) + decision.Status = capacity.Status + decision.Before, decision.After = capacity.Before, capacity.After + decision.Capacity, decision.Low, decision.High = capacity.Capacity, capacity.Low, capacity.High + decision.DroppedTail = capacity.DroppedTail + decision.StubbedSummary = capacity.StubbedSummary + if capacity.StubbedSummary { + decision.SummaryStatus = "capacity-fallback" + } + if s.deps.Decisions != nil { + s.deps.Decisions.CompactionDecision(decision) + } + if capacityErr != nil { + return steploop.ResultStop, capacityErr + } + + if result == steploop.ResultContinue { + var summary *string + fresh, err := s.deps.Store.Messages(ctx, input.SessionID) + if err != nil { + return steploop.ResultStop, err + } + for _, item := range fresh { + if item.Info.MessageID() == accepted.Info.MessageID() { + summary = summaryText(item) + break + } + } + if s.deps.Events != nil { + text := "" + if summary != nil { + text = *summary + } + s.deps.Events.CompactionEnded( + input.SessionID, s.deps.Now(), text, selected.StartID, + ) + if err := s.deps.Events.PublishCompacted(ctx, input.SessionID); err != nil { + return steploop.ResultStop, err + } + } + } + return result, nil +} + +func (s *Service) summaryMessage( + ctx context.Context, sessionID, messageID string, +) (msgmodel.WithParts, error) { + messages, err := s.deps.Store.Messages(ctx, sessionID) + if err != nil { + return msgmodel.WithParts{}, err + } + for _, message := range messages { + if message.Info.MessageID() == messageID { + return message, nil + } + } + return msgmodel.WithParts{}, fmt.Errorf("compaction summary message not found: %s", messageID) +} + +const ( + compactionStatusTarget = "target" + compactionStatusDegraded = "degraded" + compactionStatusRebuilt = "rebuilt" + compactionStatusExhausted = "context_capacity_exhausted" + compactionStatusUnbounded = "unbounded" +) + +func minimumContinuationHeadroom(marks overflow.CompactionWatermarks) float64 { + gap := math.Max(0, marks.High-marks.Low) + wanted := math.Max(2_048, math.Floor(marks.High*0.10)) + return math.Max(1, math.Min(gap, wanted)) +} + +func (s *Service) enforceWatermarks( + ctx context.Context, + sessionID string, + before float64, + model Model, + cfg overflow.Config, + compactionPart *msgmodel.CompactionPart, + source string, +) (CompactionDecision, error) { + marks := overflow.Watermarks( + overflow.UsableInput{Cfg: cfg, Model: model.Overflow}, + ) + decision := CompactionDecision{ + SessionID: sessionID, Before: before, + Capacity: marks.Capacity, Low: marks.Low, High: marks.High, + } + if marks.High <= 0 || math.IsInf(marks.High, 1) { + decision.Status = compactionStatusUnbounded + return decision, nil + } + after, err := s.projectedContextTokens(ctx, sessionID, model) + if err != nil { + return decision, fmt.Errorf("compaction: measure reconstructed context: %w", err) + } + decision.After = after + headroom := minimumContinuationHeadroom(marks) + if after <= marks.Low { + decision.Status = compactionStatusTarget + return decision, nil + } + if after < marks.High && before-after >= headroom && marks.High-after >= headroom { + decision.Status = compactionStatusDegraded + return decision, nil + } + + // Stage one: drop the verbatim tail and keep the summary. The tail is + // almost always what does not fit (typically one giant newest message), + // and the summary is the progress record, so it is kept as long as it + // fits on its own. + if compactionPart != nil && compactionPart.TailStartID != nil { + part := *compactionPart + part.TailStartID = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return decision, err + } + decision.DroppedTail = true + after, err = s.projectedContextTokens(ctx, sessionID, model) + if err != nil { + return decision, fmt.Errorf("compaction: measure tail-dropped rebuild: %w", err) + } + decision.After = after + if after < marks.High && marks.High-after >= headroom { + decision.Status = compactionStatusRebuilt + return decision, nil + } + } + + // Stage two: the summary block itself does not fit. Replace it with the + // deterministic capacity stub. + if err := s.installCapacityFallback( + ctx, sessionID, compactionPart, source, after, marks, + ); err != nil { + return decision, err + } + decision.DroppedTail = true + decision.StubbedSummary = true + after, err = s.projectedContextTokens(ctx, sessionID, model) + if err != nil { + return decision, fmt.Errorf("compaction: measure deterministic rebuild: %w", err) + } + decision.After = after + if after < marks.High && marks.High-after >= headroom { + decision.Status = compactionStatusRebuilt + return decision, nil + } + decision.Status = compactionStatusExhausted + return decision, ContextCapacityError{After: after, High: marks.High} +} + +func compactFallbackSummary(source string, observed float64, marks overflow.CompactionWatermarks) string { + current := "- Continue the authoritative task pinned verbatim beside this state record." + files := "- (none)" + if source == ".senior-dev/spec.md" { + files = "- .senior-dev/spec.md: authoritative task specification" + } else if source == "" { + current = "- Recover the original request from durable session state before editing." + } + context := fmt.Sprintf( + "- Prior projection was %.0f tokens (target %.0f; high watermark %.0f); retained history did not fit.", + observed, marks.Low, marks.High, + ) + return strings.Join([]string{ + "## Working State", + "### Completed", "- No generated completion claim survived the capacity rebuild.", + "### Current", current, + "### Verification", context, + "### Next", "- Inspect the current diff and latest exact failure before editing.", + "### Files", files, + }, "\n") +} + +func compactionRole(part msgmodel.TextPart) string { + if len(part.Metadata) == 0 { + return "" + } + var value struct { + Role string `json:"compaction_role"` + } + if json.Unmarshal(part.Metadata, &value) != nil { + return "" + } + return value.Role +} + +func (s *Service) installCapacityFallback( + ctx context.Context, + sessionID string, + compactionPart *msgmodel.CompactionPart, + source string, + observed float64, + marks overflow.CompactionWatermarks, +) error { + if compactionPart != nil { + part := *compactionPart + part.TailStartID = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + } + + messages, err := s.deps.Store.Messages(ctx, sessionID) + if err != nil { + return err + } + prior := completedCompactions(messages) + if len(prior) == 0 { + return errors.New("compaction: completed summary missing during deterministic rebuild") + } + message := messages[prior[len(prior)-1].AssistantIndex] + fallback := compactFallbackSummary(source, observed, marks) + written := false + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if ok && !boolPointer(part.Synthetic) && !written { + part.Text = fallback + part.Ignored = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + written = true + continue + } + if ok && compactionRole(part) == "authoritative_task" { + continue + } + switch raw.(type) { + case msgmodel.StepStartPart, msgmodel.StepFinishPart: + continue + } + ignored := true + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: raw.Base(), Text: "", Ignored: &ignored, + }); err != nil { + return err + } + } + if !written { + return errors.New("compaction: generated summary text missing during deterministic rebuild") + } + accepted, err := s.summaryMessage(ctx, sessionID, message.Info.MessageID()) + if err != nil { + return err + } + if err := ValidateSummary(accepted); err != nil { + return fmt.Errorf("compaction deterministic rebuild validation failed: %w", err) + } + return nil +} + +func (s *Service) authoritativeTask(messages []msgmodel.WithParts) (string, string) { + if s.deps.Instance.Directory != "" { + path := filepath.Join(s.deps.Instance.Directory, ".senior-dev", "spec.md") + if raw, err := os.ReadFile(path); err == nil && strings.TrimSpace(string(raw)) != "" { + return string(raw), ".senior-dev/spec.md" + } + } + for _, message := range messages { + if IsSyntheticUser(message) || hasCompaction(message.Parts) { + continue + } + if _, ok := message.Info.(msgmodel.User); !ok { + continue + } + parts := []string{} + for _, raw := range message.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) || boolPointer(part.Synthetic) || + strings.TrimSpace(part.Text) == "" { + continue + } + parts = append(parts, part.Text) + } + if task := strings.TrimSpace(strings.Join(parts, "\n\n")); task != "" { + return task, "original user request" + } + } + return "", "" +} + +func BuildAuthoritativeTaskPin(task, source string) string { + return strings.Join([]string{ + "# AUTHORITATIVE TASK (verbatim — durable, not generated)", + "Source: " + source, + "The text below is the task. It overrides any conflicting claim in the generated summary.", + "<authoritative-task>", + task, + "</authoritative-task>", + }, "\n") +} + +// fallbackRecord is the deterministic state record installed when no generated +// summary is available. It never claims progress it cannot know, and it never +// loses progress either: the previous boundary's summary is carried forward +// verbatim (quoted as data), the task is pinned beside it by the caller, the +// changed files are computed by code, and the verbatim tail follows. +func fallbackRecord(previous *string, task string, cause error, filesPinned bool) string { + completed := "- (none: no state record could be generated at this boundary)" + if previous != nil && strings.TrimSpace(*previous) != "" { + completed = "- No summary was generated at this boundary; the previous state record is carried forward verbatim:\n" + + quoteAsData(HeadTailTruncate(strings.TrimSpace(*previous), 8_000)) + } + current := "- Continue the authoritative task from the current repository state; the most recent messages are retained verbatim after this record." + if strings.TrimSpace(task) == "" { + current = "- Recover the original request from durable session state before editing; the most recent messages are retained verbatim after this record." + } + verification := "- No verification claim was retained at this boundary." + if cause != nil { + verification += " Cause: " + cause.Error() + } + files := "- (none recorded)" + if filesPinned { + files = "- See the CHANGED FILES record pinned beside this state." + } + return strings.Join([]string{ + "## Working State", + "### Completed", completed, + "### Current", current, + "### Verification", verification, + "### Next", "- Inspect the current diff and the retained recent messages before editing.", + "### Files", files, + }, "\n") +} + +// quoteAsData prefixes every line with "> " and escapes leading heading +// markers, so quoted text can never collide with the record's own contract +// headings. +func quoteAsData(text string) string { + quoted := make([]string, 0, strings.Count(text, "\n")+1) + for _, line := range strings.Split(text, "\n") { + if trimmed := strings.TrimLeft(line, " \t"); strings.HasPrefix(trimmed, "#") { + line = strings.Replace(line, "#", `\#`, 1) + } + quoted = append(quoted, "> "+line) + } + return strings.Join(quoted, "\n") +} + +// BuildChangedFilesPin renders the code-computed changed-files record that is +// pinned beside every summary: a generated summary can forget a file, a +// diffstat cannot. +func BuildChangedFilesPin(lines []string) string { + lines = nonEmptyStrings(lines) + if len(lines) == 0 { + return "" + } + return strings.Join(append([]string{ + "# CHANGED FILES (computed by senior-dev at this compaction, not generated)", + }, lines...), "\n") +} + +func (s *Service) changedFiles(ctx context.Context) []string { + if s.deps.ChangedFiles == nil { + return nil + } + return s.deps.ChangedFiles(ctx) +} + +// assistantErrorText renders a stored assistant error for the decision event, +// bounded so a provider's HTML error page cannot flood the record. +func assistantErrorText(err *msgmodel.AssistantError) string { + if err == nil { + return "" + } + text := err.Name + if len(err.Data) > 0 { + text += ": " + string(err.Data) + } + return HeadTailTruncate(text, 600) +} + +func (s *Service) installSummaryText( + ctx context.Context, + sessionID string, + message msgmodel.WithParts, + text string, +) (msgmodel.WithParts, error) { + written := false + for _, raw := range message.Parts { + if part, ok := raw.(msgmodel.TextPart); ok && !written && + !boolPointer(part.Synthetic) { + part.Text = text + part.Ignored = nil + part.Synthetic = nil + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return msgmodel.WithParts{}, err + } + written = true + continue + } + switch raw.(type) { + case msgmodel.StepStartPart, msgmodel.StepFinishPart: + continue + } + ignored := true + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: raw.Base(), Text: "", Ignored: &ignored, + }); err != nil { + return msgmodel.WithParts{}, err + } + } + if !written { + if err := s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: message.Info.MessageID(), + SessionID: sessionID, + }, + Text: text, + }); err != nil { + return msgmodel.WithParts{}, err + } + } + accepted, err := s.summaryMessage(ctx, sessionID, message.Info.MessageID()) + if err != nil { + return msgmodel.WithParts{}, err + } + if err := ValidateSummary(accepted); err != nil { + return msgmodel.WithParts{}, fmt.Errorf("compaction installed summary validation failed: %w", err) + } + return accepted, nil +} + +func nonEmptyStrings(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} + +type CreateInput struct { + SessionID string + Agent string + Model ModelRef + Auto bool + Overflow *bool +} + +func (s *Service) Create(ctx context.Context, input CreateInput) error { + if s.deps.Store == nil { + return errors.New("compaction: nil store") + } + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: input.SessionID, + }, + Time: msgmodel.TimeCreated{Created: s.deps.Now()}, + Agent: input.Agent, + Model: msgmodel.UserModel{ + ProviderID: input.Model.ProviderID, ModelID: input.Model.ModelID, + }, + } + if err := s.deps.Store.UpdateMessage(ctx, message); err != nil { + return err + } + part := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ + ID: s.deps.NewID("part"), MessageID: message.ID, + SessionID: input.SessionID, + }, + Auto: input.Auto, Overflow: input.Overflow, + } + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + if s.deps.Events != nil { + reason := "manual" + if input.Auto { + reason = "auto" + } + s.deps.Events.CompactionStarted(input.SessionID, s.deps.Now(), reason) + } + return nil +} + +func (s *Service) persistReplay( + ctx context.Context, sessionID string, replay Replay, +) error { + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: sessionID, + }, + Time: msgmodel.TimeCreated{Created: s.deps.Now()}, + Format: replay.Info.Format, + Agent: replay.Info.Agent, + Model: replay.Info.Model, + System: replay.Info.System, + Tools: replay.Info.Tools, + } + if err := s.deps.Store.UpdateMessage(ctx, message); err != nil { + return err + } + for _, part := range buildReplayParts( + replay, sessionID, message.ID, s.deps.NewID, + ) { + if err := s.deps.Store.UpdatePart(ctx, part); err != nil { + return err + } + } + return nil +} + +func (s *Service) persistAutoContinue( + ctx context.Context, + sessionID string, + user msgmodel.User, + overflowed bool, +) error { + message := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ + ID: s.deps.NewID("message"), SessionID: sessionID, + }, + Time: msgmodel.TimeCreated{Created: s.deps.Now()}, + Agent: user.Agent, Model: user.Model, + } + if err := s.deps.Store.UpdateMessage(ctx, message); err != nil { + return err + } + partID := s.deps.NewID("part") + start := s.deps.Now() + end := s.deps.Now() + return s.deps.Store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: partID, MessageID: message.ID, SessionID: sessionID, + }, + Text: autoContinueText(overflowed), + Metadata: msgmodel.RawObject(`{"compaction_continue":true}`), + Synthetic: boolAddress(true), + Time: &msgmodel.TimeStartEnd{Start: start, End: &end}, + }) +} + +func (s *Service) config(ctx context.Context) (overflow.Config, error) { + if s.deps.Config == nil { + return overflow.Config{}, errors.New("compaction: nil config provider") + } + return s.deps.Config.GetConfig(ctx) +} + +func cloneMessages(input []msgmodel.WithParts) ([]msgmodel.WithParts, error) { + raw, err := jsonutil.Marshal(input) + if err != nil { + return nil, err + } + var out []msgmodel.WithParts + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} + +func findCompaction(parts msgmodel.Parts) *msgmodel.CompactionPart { + for _, raw := range parts { + if part, ok := raw.(msgmodel.CompactionPart); ok { + return &part + } + } + return nil +} + +func overflowTokens(tokens msgmodel.Tokens) overflow.Tokens { + var total *float64 + if tokens.Total != nil { + value := float64(*tokens.Total) + total = &value + } + return overflow.Tokens{ + Total: total, Input: float64(tokens.Input), Output: float64(tokens.Output), + Reasoning: float64(tokens.Reasoning), + Cache: overflow.TokenCache{ + Read: float64(tokens.Cache.Read), Write: float64(tokens.Cache.Write), + }, + } +} + +func observedContextTokens(messages []msgmodel.WithParts) float64 { + observed := float64(0) + for _, message := range messages { + assistant, ok := message.Info.(msgmodel.Assistant) + if !ok { + continue + } + tokens := float64(assistant.Tokens.Input + assistant.Tokens.Output + + assistant.Tokens.Cache.Read + assistant.Tokens.Cache.Write) + if assistant.Tokens.Total != nil && *assistant.Tokens.Total != 0 { + tokens = float64(*assistant.Tokens.Total) + } + observed = math.Max(observed, tokens) + } + return observed +} + +func protectedTool(name string) bool { + for _, protected := range PruneProtectedTools { + if name == protected { + return true + } + } + return false +} + +func boolAddress(value bool) *bool { return &value } + +var serviceID atomic.Uint64 + +func defaultID(prefix string) string { + return fmt.Sprintf("%s_%016x", prefix, serviceID.Add(1)) +} diff --git a/internal/seniordev/session/compaction/service_test.go b/internal/seniordev/session/compaction/service_test.go new file mode 100644 index 0000000000..453a412859 --- /dev/null +++ b/internal/seniordev/session/compaction/service_test.go @@ -0,0 +1,957 @@ +//go:build !windows + +package compaction + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/overflow" +) + +type memoryStore struct { + mu sync.Mutex + messages []msgmodel.WithParts + updates []string + err error +} + +func (s *memoryStore) Messages(_ context.Context, _ string) ([]msgmodel.WithParts, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return nil, s.err + } + return append([]msgmodel.WithParts(nil), s.messages...), nil +} + +func (s *memoryStore) UpdateMessage(_ context.Context, info msgmodel.Info) error { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.messages { + if s.messages[i].Info.MessageID() == info.MessageID() { + s.messages[i].Info = info + s.updates = append(s.updates, "message:"+info.MessageID()) + return nil + } + } + s.messages = append(s.messages, msgmodel.WithParts{Info: info, Parts: msgmodel.Parts{}}) + s.updates = append(s.updates, "message:"+info.MessageID()) + return nil +} + +func (s *memoryStore) UpdatePart(_ context.Context, part msgmodel.Part) error { + s.mu.Lock() + defer s.mu.Unlock() + base := part.Base() + for messageIndex := range s.messages { + if s.messages[messageIndex].Info.MessageID() != base.MessageID { + continue + } + for partIndex, existing := range s.messages[messageIndex].Parts { + if existing.Base().ID == base.ID { + s.messages[messageIndex].Parts[partIndex] = part + s.updates = append(s.updates, "part:"+base.ID) + return nil + } + } + s.messages[messageIndex].Parts = append(s.messages[messageIndex].Parts, part) + s.updates = append(s.updates, "part:"+base.ID) + return nil + } + return fmt.Errorf("message not found for part %s", base.ID) +} + +func (s *memoryStore) find(id string) *msgmodel.WithParts { + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.messages { + if s.messages[i].Info.MessageID() == id { + value := s.messages[i] + return &value + } + } + return nil +} + +type fakeProvider struct { + model Model + provider ProviderInfo + providerCalls int +} + +func (p *fakeProvider) GetModel(_ context.Context, _, _ string) (Model, error) { + return p.model, nil +} + +func (p *fakeProvider) GetProvider(_ context.Context, _ string) (ProviderInfo, error) { + p.providerCalls++ + return p.provider, nil +} + +type fakePlugin struct { + compacting CompactingResult + transformed bool + auto bool + autoCalls int + autoInput AutoContinueInput +} + +func (p *fakePlugin) Compacting(context.Context, string) (CompactingResult, error) { + return p.compacting, nil +} + +func (p *fakePlugin) TransformMessages(_ context.Context, _ []msgmodel.WithParts) error { + p.transformed = true + return nil +} + +func (p *fakePlugin) AutoContinue(_ context.Context, input AutoContinueInput) (bool, error) { + p.autoCalls++ + p.autoInput = input + return p.auto, nil +} + +type fakeProcessor struct { + message *msgmodel.Assistant + process func(context.Context, SummaryRequest) (steploop.Result, error) +} + +func (p *fakeProcessor) Process( + ctx context.Context, request SummaryRequest, +) (steploop.Result, error) { + return p.process(ctx, request) +} + +func (p *fakeProcessor) Message() msgmodel.Assistant { return *p.message } + +type fakeEvents struct { + started []string + ended []string + published []string +} + +func (e *fakeEvents) CompactionStarted(sessionID string, _ uint64, reason string) { + e.started = append(e.started, sessionID+":"+reason) +} + +func (e *fakeEvents) CompactionEnded( + sessionID string, _ uint64, text string, include *string, +) { + tail := "" + if include != nil { + tail = *include + } + e.ended = append(e.ended, sessionID+":"+tail+":"+text) +} + +func (e *fakeEvents) PublishCompacted(_ context.Context, sessionID string) error { + e.published = append(e.published, sessionID) + return nil +} + +func serviceModel() Model { + return Model{ + Message: msgmodel.Model{ + ProviderID: "openrouter", ID: "vendor/model", + API: msgmodel.ModelAPI{ + Npm: "@openrouter/ai-sdk-provider", ID: "vendor/model", + }, + }, + Overflow: overflow.Model{ + Limit: calc.ModelLimit{Context: 131_072, Output: 8_192}, + }, + } +} + +func deterministicRuntime() (func(string) string, func() uint64) { + id := 0 + now := uint64(1000) + return func(prefix string) string { + id++ + return fmt.Sprintf("%s_%d", prefix, id) + }, func() uint64 { + now++ + return now + } +} + +func baseDeps(store *memoryStore) Dependencies { + newID, now := deterministicRuntime() + // A zero tail budget keeps only the newest message verbatim, so every + // older message lands in the summarized head and the tests can see the + // summary path with a one-message tail. + tail := float64(0) + return Dependencies{ + Store: store, + Config: ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{ + PreserveRecentTokens: &tail, + }}, nil + }), + Agents: AgentProviderFunc(func(_ context.Context, name string) (Agent, error) { + return Agent{Name: name}, nil + }), + NewID: newID, Now: now, + } +} + +func compactionConversation(agent string) []msgmodel.WithParts { + parentPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ + ID: "pc", SessionID: "ses_1", MessageID: "uc", + }, + Auto: true, + } + goal := testUser("u0", textPart("u0", "Fix src/a.ts")) + // Two assistant messages: the newest one is always the verbatim tail, so + // the goal and the first reply form the head that gets summarized. + first := testAssistant("a0", "u0", textPart("a0", "Reading src/a.ts first.")) + newest := testAssistant("a1", "u0", textPart("a1", "The edit is in place.")) + parent := testUser("uc", parentPart) + parentUser := parent.Info.(msgmodel.User) + parentUser.Agent = agent + parent.Info = parentUser + return []msgmodel.WithParts{goal, first, newest, parent} +} + +// promptOf returns the single user text block of a summary request, after +// proving it is the one shape the wire converter accepts. +func promptOf(t *testing.T, request SummaryRequest) string { + t.Helper() + if len(request.Messages) != 1 || request.Messages[0].Role != "user" { + t.Fatalf("summary request shape = %#v", request.Messages) + } + parts, ok := request.Messages[0].Content.([]any) + if !ok || len(parts) != 1 { + t.Fatalf("summary content is not a canonical part list: %#v", request.Messages[0].Content) + } + text, ok := parts[0].(msgmodel.TextContent) + if !ok { + t.Fatalf("summary content part = %#v", parts[0]) + } + body, err := orclient.BuildRequestBody(orclient.RequestParams{ + ModelID: "vendor/model", Prompt: request.Messages, + }) + if err != nil { + t.Fatalf("summary request does not build a request body: %v", err) + } + if !strings.Contains(string(body), `"content":"<conversation>\n`) { + t.Fatalf("wire body lost the transcript: %s", body) + } + return text.Text +} + +func TestProcessContinueInjectsSummaryEvidenceAutoContinueAndEvents(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + provider := &fakeProvider{ + model: serviceModel(), provider: ProviderInfo{Source: "env", Options: "opts"}, + } + plugin := &fakePlugin{ + compacting: CompactingResult{Context: []string{"PLUGIN CONTEXT"}}, + auto: true, + } + events := &fakeEvents{} + decisions := []CompactionDecision{} + deps := baseDeps(store) + deps.Provider = provider + deps.Plugin = plugin + deps.Events = events + deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) { + decisions = append(decisions, decision) + }) + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + deps.Evidence = EvidenceSelectorFunc(func(_ context.Context, blocks []string) (*string, error) { + // Evidence is harvested from the summarized head only: the goal and + // the first reply, never the verbatim tail. + if len(blocks) != 2 || blocks[0] != "Fix src/a.ts" || blocks[1] != "Reading src/a.ts first." { + t.Fatalf("evidence blocks = %#v", blocks) + } + value := "EVIDENCE" + return &value, nil + }) + deps.ChangedFiles = func(context.Context) []string { + return []string{" src/a.ts | 2 +-", "?? notes.txt"} + } + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, request SummaryRequest) (steploop.Result, error) { + if !plugin.transformed { + t.Fatal("message transform did not run before processor") + } + prompt := promptOf(t, request) + if !stringsContainsAll( + prompt, SummaryTemplate, "PLUGIN CONTEXT", + "[User]: Fix src/a.ts", "[Assistant]: Reading src/a.ts first.", + ) { + t.Fatalf("summary prompt = %q", prompt) + } + if strings.Contains(prompt, "The edit is in place.") { + t.Fatalf("the verbatim tail was summarized too: %q", prompt) + } + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + if err := store.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ + ID: "summary_part", SessionID: "ses_1", MessageID: assistant.ID, + }, + Text: testValidSummary("Fix src/a.ts"), + }); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultContinue, nil + }, + }, nil + }) + service := NewService(deps) + result, err := service.Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: true, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if plugin.autoCalls != 1 || provider.providerCalls != 1 || + plugin.autoInput.Agent != "coder" { + t.Fatalf("auto plugin/provider calls = %d/%d input=%#v", plugin.autoCalls, provider.providerCalls, plugin.autoInput) + } + if len(events.ended) != 1 || + !stringsContainsAll( + events.ended[0], + "ses_1:a1:", // the tail starts at the newest message + testValidSummary("Fix src/a.ts"), + "# AUTHORITATIVE TASK (verbatim — durable, not generated)", + "Fix src/a.ts", "EVIDENCE", + "# CHANGED FILES (computed by senior-dev at this compaction, not generated)", + "src/a.ts | 2 +-", "?? notes.txt", + ) || len(events.published) != 1 { + t.Fatalf("events = %#v %#v", events.ended, events.published) + } + if len(decisions) != 1 || decisions[0].Status != compactionStatusTarget || + decisions[0].After > decisions[0].Low || decisions[0].DroppedTail || + decisions[0].SummaryStatus != "valid" || decisions[0].SummaryError != "" { + t.Fatalf("compaction decisions = %#v", decisions) + } + if d := decisions[0]; d.TranscriptMessages != 2 || d.TailMessages != 1 || + d.TranscriptChars == 0 || d.PromptChars <= d.TranscriptChars || d.TailTokens == 0 { + t.Fatalf("decision sizes = %#v", d) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + tailPart := fresh[3].Parts[0].(msgmodel.CompactionPart) + if tailPart.TailStartID == nil || *tailPart.TailStartID != "a1" { + t.Fatalf("tail start = %#v", tailPart) + } + last := fresh[len(fresh)-1] + autoUser, ok := last.Info.(msgmodel.User) + if !ok || autoUser.Agent != "coder" || len(last.Parts) != 1 { + t.Fatalf("auto continuation = %#v", last) + } + autoPart := last.Parts[0].(msgmodel.TextPart) + if autoPart.Text != autoContinueText(false) || + string(autoPart.Metadata) != `{"compaction_continue":true}` || + autoPart.Synthetic == nil || !*autoPart.Synthetic { + t.Fatalf("auto part = %#v", autoPart) + } +} + +func TestEnforceWatermarksRebuildsWhenFirstProjectionLacksHeadroom(t *testing.T) { + tail := "u0" + compactionPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, TailStartID: &tail, + } + parent := testUser("uc", compactionPart) + finish := "stop" + summaryFlag := true + summary := testAssistant( + "as", "uc", + textPart("as", testValidSummary("Fix src/a.ts")), + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "pin", SessionID: "ses_1", MessageID: "as"}, + Text: BuildAuthoritativeTaskPin("Fix src/a.ts", "original user request"), + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"authoritative_task"}`), + }, + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "evidence", SessionID: "ses_1", MessageID: "as"}, + Text: "large non-authoritative evidence", Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"evidence"}`), + }, + ) + assistant := summary.Info.(msgmodel.Assistant) + assistant.Summary = &summaryFlag + assistant.Finish = &finish + summary.Info = assistant + store := &memoryStore{messages: []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), parent, summary, + }} + // Stage one only: dropping the tail brings the projection under the + // watermark, so the VALID summary and its evidence are kept. + sizes := []float64{58_000, 20_000} + deps := baseDeps(store) + deps.Sizer = ContextSizerFunc(func( + context.Context, []msgmodel.WithParts, Model, + ) (float64, error) { + value := sizes[0] + sizes = sizes[1:] + return value, nil + }) + decision, err := NewService(deps).enforceWatermarks( + context.Background(), "ses_1", 70_000, serviceModel(), watermarkTestConfig(), + &compactionPart, "original user request", + ) + if err != nil { + t.Fatal(err) + } + if decision.Status != compactionStatusRebuilt || !decision.DroppedTail || decision.StubbedSummary || + decision.After != 20_000 || len(sizes) != 0 { + t.Fatalf("decision = %#v; remaining sizes = %#v", decision, sizes) + } + fresh, err := store.Messages(context.Background(), "ses_1") + if err != nil { + t.Fatal(err) + } + updatedParent := fresh[1].Parts[0].(msgmodel.CompactionPart) + if updatedParent.TailStartID != nil { + t.Fatalf("retained tail survived the rebuild: %#v", updatedParent) + } + generated := generatedSummaryText(fresh[2]) + if generated == nil || !strings.Contains(*generated, "Fix src/a.ts") || + strings.Contains(*generated, "retained history did not fit") { + t.Fatalf("the valid summary did not survive a tail-only rebuild: %v", generated) + } +} + +// watermarkTestConfig caps the capacity at 100K so the watermarks the +// enforcement tests reason about are high 60,000 / low 40,000. +func watermarkTestConfig() overflow.Config { + capacity := 100_000.0 + return overflow.Config{Compaction: &overflow.CompactionConfig{CapacityTokens: &capacity}} +} + +// Stage two: when the summary block alone still does not fit, it is replaced +// by the capacity stub and the evidence is tombstoned. +func TestEnforceWatermarksStubsTheSummaryOnlyWhenItAloneDoesNotFit(t *testing.T) { + tail := "u0" + compactionPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, TailStartID: &tail, + } + parent := testUser("uc", compactionPart) + finish := "stop" + summaryFlag := true + summary := testAssistant( + "as", "uc", + textPart("as", testValidSummary("Fix src/a.ts")), + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "pin", SessionID: "ses_1", MessageID: "as"}, + Text: BuildAuthoritativeTaskPin("Fix src/a.ts", "original user request"), + Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"authoritative_task"}`), + }, + msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "evidence", SessionID: "ses_1", MessageID: "as"}, + Text: "large non-authoritative evidence", Synthetic: boolAddress(true), + Metadata: msgmodel.RawObject(`{"compaction_role":"evidence"}`), + }, + ) + assistant := summary.Info.(msgmodel.Assistant) + assistant.Summary = &summaryFlag + assistant.Finish = &finish + summary.Info = assistant + store := &memoryStore{messages: []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), parent, summary, + }} + sizes := []float64{90_000, 70_000, 20_000} + deps := baseDeps(store) + deps.Sizer = ContextSizerFunc(func( + context.Context, []msgmodel.WithParts, Model, + ) (float64, error) { + value := sizes[0] + sizes = sizes[1:] + return value, nil + }) + decision, err := NewService(deps).enforceWatermarks( + context.Background(), "ses_1", 95_000, serviceModel(), watermarkTestConfig(), + &compactionPart, "original user request", + ) + if err != nil { + t.Fatal(err) + } + if decision.Status != compactionStatusRebuilt || !decision.DroppedTail || !decision.StubbedSummary || + decision.After != 20_000 || len(sizes) != 0 { + t.Fatalf("decision = %#v; remaining sizes = %#v", decision, sizes) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + generated := generatedSummaryText(fresh[2]) + if generated == nil || !strings.Contains(*generated, "retained history did not fit") { + t.Fatalf("generated fallback = %v", generated) + } + evidenceRemoved := false + for _, raw := range fresh[2].Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || part.ID != "evidence" { + continue + } + if part.Ignored == nil || !*part.Ignored || part.Text != "" { + t.Fatalf("evidence survived deterministic rebuild: %#v", part) + } + evidenceRemoved = true + } + if !evidenceRemoved { + t.Fatal("deterministic rebuild did not retain an ignored evidence tombstone") + } +} + +func TestProcessReplacesInvalidSummaryBeforeActivatingBoundary(t *testing.T) { + cases := []struct { + name string + wantStatus string + wantText string + parts func(assistant *msgmodel.Assistant) msgmodel.Parts + }{ + { + name: "empty", + wantStatus: "fallback", + parts: func(*msgmodel.Assistant) msgmodel.Parts { return nil }, + }, + { + name: "malformed text", + wantStatus: "fallback", + parts: func(assistant *msgmodel.Assistant) msgmodel.Parts { + return msgmodel.Parts{textPart(assistant.ID, "I will inspect src/a.ts next.")} + }, + }, + { + name: "tool shaped", + wantStatus: "fallback", + parts: func(assistant *msgmodel.Assistant) msgmodel.Parts { + return msgmodel.Parts{msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ + ID: "bad_tool", SessionID: "ses_1", MessageID: assistant.ID, + }, + CallID: "call_1", Tool: "bash", State: msgmodel.PendingToolState(), + }} + }, + }, + { + name: "off-format state", + wantStatus: "normalized", + wantText: "cargo build: error: could not compile", + parts: func(assistant *msgmodel.Assistant) msgmodel.Parts { + return msgmodel.Parts{textPart( + assistant.ID, "## Summary of Changes\n- cargo build: error: could not compile", + )} + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + messages := compactionConversation("coder") + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + workspace := t.TempDir() + if err := os.MkdirAll(filepath.Join(workspace, ".senior-dev"), 0o755); err != nil { + t.Fatal(err) + } + const spec = "Fix src/a.ts exactly as requested.\nPreserve public behavior." + if err := os.WriteFile( + filepath.Join(workspace, ".senior-dev", "spec.md"), []byte(spec), 0o600, + ); err != nil { + t.Fatal(err) + } + deps := baseDeps(store) + var decisions []CompactionDecision + deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) { + decisions = append(decisions, decision) + }) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Instance = InstanceContext{Directory: workspace, Worktree: workspace} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, _ SummaryRequest) (steploop.Result, error) { + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + for _, part := range tc.parts(assistant) { + if err := store.UpdatePart(ctx, part); err != nil { + return steploop.ResultStop, err + } + } + return steploop.ResultContinue, nil + }, + }, nil + }) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: false, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + fresh, err := store.Messages(context.Background(), "ses_1") + if err != nil { + t.Fatal(err) + } + prior := completedCompactions(fresh) + if len(prior) != 1 || prior[0].Summary == nil { + t.Fatalf("completed compactions = %#v", prior) + } + if err := ValidateSummaryText(*prior[0].Summary); err != nil { + t.Fatalf("fallback summary is invalid: %v\n%s", err, *prior[0].Summary) + } + accepted := fresh[prior[0].AssistantIndex] + for _, part := range accepted.Parts { + if _, ok := part.(msgmodel.ToolPart); ok { + t.Fatalf("tool-shaped summary part survived fallback: %#v", accepted.Parts) + } + } + full := summaryText(accepted) + if len(decisions) != 1 || decisions[0].SummaryStatus != tc.wantStatus { + t.Fatalf("compaction decisions = %#v", decisions) + } + if full == nil || !stringsContainsAll(*full, spec, "# AUTHORITATIVE TASK") { + t.Fatalf("authoritative fallback = %v", full) + } + if tc.wantText != "" { + if !strings.Contains(*full, tc.wantText) { + t.Fatalf("normalized state was lost: %s", *full) + } + } else if !strings.Contains(*full, "no state record could be generated at this boundary") { + t.Fatalf("deterministic record missing: %s", *full) + } + }) + } +} + +// A summary request that itself overflows the model is one more way of having +// no generated summary: the deterministic record is installed and the run goes +// on with its tail intact. +func TestProcessSummaryOverflowInstallsDeterministicRecordAndContinues(t *testing.T) { + imageName := "large.png" + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", textPart("a0", "reply")), + testUser("u1", msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "img", SessionID: "ses_1", MessageID: "u1"}, + Mime: "image/png", Filename: &imageName, URL: "data:image/png;base64,AA", + }), + } + parentPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + } + parent := testUser("uc", parentPart) + messages = append(messages, parent) + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + deps := baseDeps(store) + var decisions []CompactionDecision + deps.Decisions = DecisionSinkFunc(func(decision CompactionDecision) { + decisions = append(decisions, decision) + }) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(context.Context, SummaryRequest) (steploop.Result, error) { + return steploop.ResultCompact, nil + }, + }, nil + }) + overflowed := true + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", + Auto: true, Overflow: &overflowed, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + prior := completedCompactions(fresh) + if len(prior) != 1 || prior[0].Summary == nil || + !strings.Contains(*prior[0].Summary, "no state record could be generated") { + t.Fatalf("completed compactions = %#v", prior) + } + if len(decisions) != 1 || decisions[0].SummaryStatus != "overflow" || + !strings.Contains(decisions[0].SummaryError, "exceeded the model context") { + t.Fatalf("decisions = %#v", decisions) + } +} + +func TestProcessOverflowReplayReplacesMediaAndSkipsAutoContinue(t *testing.T) { + name := "big.pdf" + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", textPart("a0", "reply")), + testUser("u1", + textPart("u1", "inspect"), + msgmodel.FilePart{ + PartBase: msgmodel.PartBase{ID: "pdf", SessionID: "ses_1", MessageID: "u1"}, + Mime: "application/pdf", Filename: &name, URL: "data:application/pdf;base64,AA", + }, + ), + } + parentPart := msgmodel.CompactionPart{ + PartBase: msgmodel.PartBase{ID: "pc", SessionID: "ses_1", MessageID: "uc"}, + Auto: true, + } + messages = append(messages, testUser("uc", parentPart)) + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + plugin := &fakePlugin{auto: true} + deps := baseDeps(store) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Plugin = plugin + deps.Instance = InstanceContext{Directory: "/repo", Worktree: "/repo"} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, _ SummaryRequest) (steploop.Result, error) { + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultContinue, nil + }, + }, nil + }) + overflowed := true + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", + Auto: true, Overflow: &overflowed, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + if plugin.autoCalls != 0 { + t.Fatalf("autocontinue plugin called for replay: %d", plugin.autoCalls) + } + fresh, _ := store.Messages(context.Background(), "ses_1") + last := fresh[len(fresh)-1] + if _, ok := last.Info.(msgmodel.User); !ok || len(last.Parts) != 2 { + t.Fatalf("replay = %#v", last) + } + if got := last.Parts[1].(msgmodel.TextPart).Text; got != "[Attached application/pdf: big.pdf]" { + t.Fatalf("media placeholder = %q", got) + } +} + +func TestPruneThresholdProtectedToolAndConfigDisable(t *testing.T) { + big := strings.Repeat("x", 260_000) // 65k estimated tokens + bash := completedToolPart("bash_part", "a0", "bash", big) + skill := completedToolPart("skill_part", "a0", "skill", big) + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "old")), + testAssistant("a0", "u0", bash, skill), + testUser("u1", textPart("u1", "next")), + testAssistant("a1", "u1", textPart("a1", "reply")), + testUser("u2", textPart("u2", "latest")), + } + store := &memoryStore{messages: messages} + deps := baseDeps(store) + service := NewService(deps) + if err := service.Prune(context.Background(), "ses_1"); err != nil { + t.Fatal(err) + } + old := store.find("a0") + bashState := old.Parts[0].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted) + skillState := old.Parts[1].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted) + if bashState.Time.Compacted == nil || skillState.Time.Compacted != nil { + t.Fatalf("prune states bash=%#v skill=%#v", bashState.Time, skillState.Time) + } + + disabled := false + deps.Config = ConfigProviderFunc(func(context.Context) (overflow.Config, error) { + return overflow.Config{Compaction: &overflow.CompactionConfig{Prune: &disabled}}, nil + }) + store.updates = nil + if err := NewService(deps).Prune(context.Background(), "ses_1"); err != nil { + t.Fatal(err) + } + if len(store.updates) != 0 { + t.Fatalf("disabled prune updates = %#v", store.updates) + } +} + +func TestPruneNotFoundDegradesOnlyThatError(t *testing.T) { + store := &memoryStore{err: fmt.Errorf("%w: missing", msgmodel.ErrNotFound)} + deps := baseDeps(store) + if err := NewService(deps).Prune(context.Background(), "missing"); err != nil { + t.Fatalf("not-found prune = %v", err) + } + store.err = errors.New("database failed") + if err := NewService(deps).Prune(context.Background(), "ses"); err == nil || + err.Error() != "database failed" { + t.Fatalf("other error = %v", err) + } +} + +func TestCreatePersistsCompactionAndStartedEvent(t *testing.T) { + store := &memoryStore{} + events := &fakeEvents{} + deps := baseDeps(store) + deps.Events = events + overflowed := true + err := NewService(deps).Create(context.Background(), CreateInput{ + SessionID: "ses_1", Agent: "coder", + Model: ModelRef{ProviderID: "openrouter", ModelID: "m"}, + Auto: true, Overflow: &overflowed, + }) + if err != nil { + t.Fatal(err) + } + if len(store.messages) != 1 || len(store.messages[0].Parts) != 1 { + t.Fatalf("created state = %#v", store.messages) + } + part := store.messages[0].Parts[0].(msgmodel.CompactionPart) + if !part.Auto || part.Overflow == nil || !*part.Overflow { + t.Fatalf("part = %#v", part) + } + if len(events.started) != 1 || events.started[0] != "ses_1:auto" { + t.Fatalf("started events = %#v", events.started) + } +} + +func completedToolPart(id, messageID, tool, output string) msgmodel.ToolPart { + return msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: id, SessionID: "ses_1", MessageID: messageID}, + CallID: "call_" + id, Tool: tool, + State: msgmodel.ToolStateCompleted{ + Input: msgmodel.RawObject("{}"), Output: output, Title: tool, + Metadata: msgmodel.RawObject("{}"), + Time: msgmodel.ToolTimeCompleted{Start: 1, End: 2}, + }, + } +} + +// A run that loses the specification at a compaction boundary will later +// report that no task was given. The spec must therefore be pinned beside +// every summary, not only beside a rejected one. +// +// TestProcessContinueInjectsSummaryEvidenceAutoContinueAndEvents already covers +// the pin on a valid summary, and TestProcessReplacesInvalidSummaryBefore- +// ActivatingBoundary covers .senior-dev/spec.md as its source. Neither covers the +// combination that actually occurs in a long run: a summary that VALIDATES, a +// spec on disk, and an original request that is no longer recoverable from +// the message list. Without this, moving the pin into the invalid-summary +// branch would leave every test green and silently restore the defect. +func TestProcessPinsSpecOnValidSummaryWhenTheRequestIsUnrecoverable(t *testing.T) { + messages := compactionConversation("coder") + // The surviving user message says something the spec does not, standing in + // for a first message compaction has already rewritten past recognition. + // If the pin ever sources from here instead of the file, the assertions + // below say so by name rather than by a missing substring. + stale := "Fix src/a.ts" + store := &memoryStore{messages: append([]msgmodel.WithParts(nil), messages...)} + + workspace := t.TempDir() + if err := os.MkdirAll(filepath.Join(workspace, ".senior-dev"), 0o755); err != nil { + t.Fatal(err) + } + const spec = "Emit a sorted manifest of every record, ordered by source path.\nInclude derived records in the output." + if err := os.WriteFile( + filepath.Join(workspace, ".senior-dev", "spec.md"), []byte(spec), 0o600, + ); err != nil { + t.Fatal(err) + } + + deps := baseDeps(store) + deps.Provider = &fakeProvider{model: serviceModel()} + deps.Instance = InstanceContext{Directory: workspace, Worktree: workspace} + deps.Processors = ProcessorFactoryFunc(func( + _ context.Context, assistant *msgmodel.Assistant, _ string, _ Model, + ) (SummaryProcessor, error) { + return &fakeProcessor{ + message: assistant, + process: func(ctx context.Context, _ SummaryRequest) (steploop.Result, error) { + finish := "stop" + assistant.Finish = &finish + if err := store.UpdateMessage(ctx, *assistant); err != nil { + return steploop.ResultStop, err + } + // A summary that passes ValidateSummary, so the fallback path + // is not what installs the pin. + if err := store.UpdatePart( + ctx, textPart(assistant.ID, testValidSummary("continue the refactor")), + ); err != nil { + return steploop.ResultStop, err + } + return steploop.ResultContinue, nil + }, + }, nil + }) + + result, err := NewService(deps).Process(context.Background(), ProcessInput{ + ParentID: "uc", Messages: messages, SessionID: "ses_1", Auto: false, + }) + if err != nil || result != steploop.ResultContinue { + t.Fatalf("result=%s err=%v", result, err) + } + + fresh, err := store.Messages(context.Background(), "ses_1") + if err != nil { + t.Fatal(err) + } + prior := completedCompactions(fresh) + if len(prior) != 1 || prior[0].Summary == nil { + t.Fatalf("completed compactions = %#v", prior) + } + // The generated summary was accepted on its own merits: if this run had + // gone down the fallback path the pin would prove nothing about the path + // a real run takes. + if strings.Contains(*prior[0].Summary, "generated summary failed validation") { + t.Fatalf("valid summary was replaced by the fallback:\n%s", *prior[0].Summary) + } + if !strings.Contains(*prior[0].Summary, "continue the refactor") { + t.Fatalf("generated summary was not preserved:\n%s", *prior[0].Summary) + } + + accepted := fresh[prior[0].AssistantIndex] + var pin *msgmodel.TextPart + for _, raw := range accepted.Parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || !strings.Contains(string(part.Metadata), `"authoritative_task"`) { + continue + } + pin = &part + } + if pin == nil { + t.Fatal("no authoritative-task pin was installed beside a VALID summary") + } + if !stringsContainsAll( + pin.Text, spec, "# AUTHORITATIVE TASK", "Source: .senior-dev/spec.md", + ) { + t.Fatalf("pin did not carry the spec verbatim from disk:\n%s", pin.Text) + } + if strings.Contains(pin.Text, stale) { + t.Fatalf("pin sourced the stale user message instead of the spec:\n%s", pin.Text) + } + if pin.Synthetic == nil || !*pin.Synthetic { + t.Fatalf("pin must be synthetic so it is not mistaken for the summary: %#v", pin) + } +} diff --git a/internal/seniordev/session/compaction/surgery.go b/internal/seniordev/session/compaction/surgery.go new file mode 100644 index 0000000000..23101c412b --- /dev/null +++ b/internal/seniordev/session/compaction/surgery.go @@ -0,0 +1,124 @@ +//go:build !windows + +// Post-compaction message surgery: the overflow history selection, the replay +// of the message that overflowed, and the auto-continue text. These helpers +// are pure apart from the injected ID factory. +package compaction + +import ( + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +type Replay struct { + Info msgmodel.User + Parts msgmodel.Parts +} + +type OverflowHistory struct { + Messages []msgmodel.WithParts + Replay *Replay +} + +func selectOverflowHistory( + messages []msgmodel.WithParts, parentID string, overflow bool, +) OverflowHistory { + if !overflow { + return OverflowHistory{Messages: messages} + } + index := -1 + for i, message := range messages { + if message.Info.MessageID() == parentID { + index = i + break + } + } + var replay *Replay + selected := messages + for i := index - 1; i >= 0; i-- { + user, ok := messages[i].Info.(msgmodel.User) + if ok && !hasCompaction(messages[i].Parts) { + replay = &Replay{Info: user, Parts: messages[i].Parts} + selected = messages[:i] + break + } + } + hasContent := false + if replay != nil { + for _, message := range selected { + if _, ok := message.Info.(msgmodel.User); ok && !hasCompaction(message.Parts) { + hasContent = true + break + } + } + } + if !hasContent { + return OverflowHistory{Messages: messages} + } + return OverflowHistory{Messages: selected, Replay: replay} +} + +func buildReplayParts( + replay Replay, sessionID, messageID string, newID func(prefix string) string, +) msgmodel.Parts { + out := msgmodel.Parts{} + for _, raw := range replay.Parts { + if _, ok := raw.(msgmodel.CompactionPart); ok { + continue + } + base := msgmodel.PartBase{ + ID: newID("part"), MessageID: messageID, SessionID: sessionID, + } + if file, ok := raw.(msgmodel.FilePart); ok && msgmodel.IsMedia(file.Mime) { + filename := "file" + if file.Filename != nil { + filename = *file.Filename + } + out = append(out, msgmodel.TextPart{ + PartBase: base, + Text: "[Attached " + file.Mime + ": " + filename + "]", + }) + continue + } + out = append(out, rebasePart(raw, base)) + } + return out +} + +func autoContinueText(overflow bool) string { + prefix := "" + if overflow { + prefix = "The previous request exceeded the provider's size limit due to large media attachments. " + + "The conversation was compacted and media files were removed from context. If the user was asking " + + "about attached images or files, explain that the attachments were too large to process and suggest " + + "they try again with smaller or fewer files.\n\n" + } + return prefix + "The conversation was compacted: the state record above replaces the older transcript, and the most recent messages are retained verbatim. Continue from the current state." +} + +func rebasePart(raw msgmodel.Part, base msgmodel.PartBase) msgmodel.Part { + switch part := raw.(type) { + case msgmodel.TextPart: + part.PartBase = base + return part + case msgmodel.ReasoningPart: + part.PartBase = base + return part + case msgmodel.FilePart: + part.PartBase = base + return part + case msgmodel.ToolPart: + part.PartBase = base + return part + case msgmodel.StepStartPart: + part.PartBase = base + return part + case msgmodel.StepFinishPart: + part.PartBase = base + return part + case msgmodel.CompactionPart: + part.PartBase = base + return part + default: + panic("compaction: unknown part type " + raw.PartType()) + } +} diff --git a/internal/seniordev/session/compaction/tail.go b/internal/seniordev/session/compaction/tail.go new file mode 100644 index 0000000000..96747081db --- /dev/null +++ b/internal/seniordev/session/compaction/tail.go @@ -0,0 +1,198 @@ +//go:build !windows + +package compaction + +import ( + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// The verbatim tail a compaction keeps ahead of the summary. +// +// The newest messages are kept verbatim at every boundary and only what lies +// before them is summarized. Old tool outputs INSIDE the tail are truncated in +// the store, so a tail always fits: measured on untruncated multi-kilobyte +// command outputs, one assistant message could cost more than the whole budget +// and no split point would ever be found. +// +// Reasoning is cut too, in every kept message including the newest. The +// same-model projection re-sends every reasoning part with its provider +// metadata, and a single message can carry far more reasoning than tool +// traffic. Reasoning is the model's scratch, not state; signed reasoning +// (Anthropic) is left alone because the provider validates it byte for byte. +const ( + // DefaultPreserveRecentFraction sizes the token budget for the truncated + // older messages of the tail as a fraction of the high watermark, so the + // tail grows with the budget. The newest message is kept on top of it. + DefaultPreserveRecentFraction = 0.2 + // TailToolOutputMaxChars caps each completed tool output of an OLDER tail + // message. Head/tail truncation keeps the start and, mostly, the end, so + // an error at the bottom of a test run survives. + TailToolOutputMaxChars = 4_000 + // TailReasoningMaxChars caps each unsigned reasoning part of EVERY tail + // message to its head. + TailReasoningMaxChars = 1_000 +) + +// tailSelection is the outcome of selectTail: where the tail starts, what it +// costs, and the parts that must be rewritten in the store so the kept +// messages really are the size they were measured at. +type tailSelection struct { + // StartID names the first kept message; nil only when there are no + // messages at all. When everything fits it is the first message, and the + // head is empty. + StartID *string + // Head is everything before the tail: the messages to summarize. + Head []msgmodel.WithParts + // Messages counts kept messages and Tokens is their estimated size after + // truncation. Truncated lists the rewritten parts; TruncatedOutputs and + // TruncatedReasoning count them by kind. + Messages int + Tokens float64 + Truncated []msgmodel.Part + TruncatedOutputs int + TruncatedReasoning int +} + +// selectTail walks backwards from the newest message. The newest message is +// always kept: it is the observation the model has not yet acted on (the +// trigger fires when the turn that produced it finishes), so its tool outputs +// stay whole and only its reasoning is cut. Older messages are kept, oldest +// cut first, while their truncated size fits `budget`. +func selectTail( + messages []msgmodel.WithParts, + budget float64, + model Model, + estimate EstimateFunc, + maxToolChars float64, +) (tailSelection, error) { + if len(messages) == 0 { + return tailSelection{}, nil + } + newest := len(messages) - 1 + newestCopy, cuts := truncateForTail(messages[newest], 0, TailReasoningMaxChars) + total, err := estimate([]msgmodel.WithParts{newestCopy}, model) + if err != nil { + return tailSelection{}, err + } + selection := tailSelection{Messages: 1, Tokens: total} + selection.absorb(cuts) + start := newest + older := float64(0) + var olderCuts []msgmodel.Part + for i := newest - 1; i >= 0; i-- { + copy, cuts := truncateForTail(messages[i], maxToolChars, TailReasoningMaxChars) + size, err := estimate([]msgmodel.WithParts{copy}, model) + if err != nil { + return tailSelection{}, err + } + if older+size > budget { + break + } + older += size + start = i + olderCuts = append(cuts, olderCuts...) + } + selection.absorb(olderCuts) + // Everything fitting (start == 0) means there is no head to summarize, + // but the tail must STILL be named: FilterCompacted keeps only what a + // compaction's tail_start_id points at, and a boundary without one keeps + // nothing before it: a "no-head" boundary would otherwise project only the + // summary and lose the tail it meant to keep. + id := messages[start].Info.MessageID() + selection.StartID = &id + selection.Head = messages[:start] + selection.Messages = len(messages) - start + selection.Tokens = total + older + return selection, nil +} + +func (selection *tailSelection) absorb(parts []msgmodel.Part) { + for _, part := range parts { + switch part.(type) { + case msgmodel.ToolPart: + selection.TruncatedOutputs++ + case msgmodel.ReasoningPart: + selection.TruncatedReasoning++ + } + } + selection.Truncated = append(selection.Truncated, parts...) +} + +// truncateForTail returns a copy of the message whose completed tool outputs +// longer than maxToolChars (0 = leave them) and whose unsigned reasoning parts +// longer than maxReasoningChars are cut, plus the rewritten parts. The copy is +// what the tail will cost; the parts are what the store must be told. +func truncateForTail( + message msgmodel.WithParts, maxToolChars, maxReasoningChars float64, +) (msgmodel.WithParts, []msgmodel.Part) { + out := message + out.Parts = append(msgmodel.Parts(nil), message.Parts...) + var cuts []msgmodel.Part + for index, raw := range out.Parts { + switch part := raw.(type) { + case msgmodel.ToolPart: + completed, ok := part.State.(msgmodel.ToolStateCompleted) + if !ok || maxToolChars <= 0 || float64(charCount(completed.Output)) <= maxToolChars { + continue + } + completed.Output = truncateTailOutput(completed.Output, maxToolChars) + part.State = completed + out.Parts[index] = part + cuts = append(cuts, part) + case msgmodel.ReasoningPart: + if maxReasoningChars <= 0 || signedReasoning(part) || + float64(charCount(part.Text)) <= maxReasoningChars { + continue + } + part.Text = truncateTailReasoning(part.Text, maxReasoningChars) + out.Parts[index] = part + cuts = append(cuts, part) + } + } + return out, cuts +} + +// signedReasoning reports provider-signed reasoning (Anthropic's signature +// field), which must reach the provider unchanged. +func signedReasoning(part msgmodel.ReasoningPart) bool { + anthropic, ok := part.Metadata.Field("anthropic") + if !ok { + return false + } + signature, ok := msgmodel.RawObject(anthropic).Field("signature") + return ok && strings.TrimSpace(string(signature)) != "null" && len(signature) > 0 +} + +// truncateTailOutput keeps a quarter of the cap from the start and the rest +// from the end, and says so in words the model can act on. +func truncateTailOutput(text string, maxChars float64) string { + length := float64(charCount(text)) + if maxChars <= 0 || length <= maxChars { + return text + } + headChars := int(maxChars / 4) + tailChars := int(maxChars) - headChars + head := sliceChars(text, 0, headChars) + tail := sliceChars(text, charCount(text)-tailChars, charCount(text)) + omitted := length - float64(headChars) - float64(tailChars) + return strings.Join([]string{ + head, + "[Tool output truncated at a context compaction: omitted " + + strconv.FormatFloat(omitted, 'f', -1, 64) + " chars. Re-run the command if you need the full output.]", + tail, + }, "\n") +} + +// truncateTailReasoning keeps the head of a reasoning part. +func truncateTailReasoning(text string, maxChars float64) string { + length := float64(charCount(text)) + if maxChars <= 0 || length <= maxChars { + return text + } + head := sliceChars(text, 0, int(maxChars)) + return head + "\n[Reasoning truncated at a context compaction: omitted " + + strconv.FormatFloat(length-maxChars, 'f', -1, 64) + " chars.]" +} diff --git a/internal/seniordev/session/compaction/tail_test.go b/internal/seniordev/session/compaction/tail_test.go new file mode 100644 index 0000000000..ed79c0e984 --- /dev/null +++ b/internal/seniordev/session/compaction/tail_test.go @@ -0,0 +1,180 @@ +//go:build !windows + +package compaction + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// charEstimate sizes messages by their text and tool output characters so the +// tests can reason in exact numbers. +func charEstimate(messages []msgmodel.WithParts, _ Model) (float64, error) { + total := 0 + for _, message := range messages { + for _, raw := range message.Parts { + switch part := raw.(type) { + case msgmodel.TextPart: + total += len(part.Text) + case msgmodel.ToolPart: + if completed, ok := part.State.(msgmodel.ToolStateCompleted); ok { + total += len(completed.Output) + } + } + } + } + return float64(total), nil +} + +func TestSelectTailKeepsNewestVerbatimEvenOverBudget(t *testing.T) { + huge := strings.Repeat("x", 50_000) + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "goal")), + testAssistant("a0", "u0", textPart("a0", "older reply")), + testAssistant("a1", "u0", toolPartCompleted("a1", "c1", "bash", `{"cmd":"cat"}`, huge)), + } + // A budget too small for even "older reply": only the newest message is + // kept, and it is kept whole despite being 500x the budget. + selected, err := selectTail(messages, 5, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + if selected.StartID == nil || *selected.StartID != "a1" || len(selected.Head) != 2 { + t.Fatalf("selection = %#v", selected) + } + if selected.Messages != 1 || selected.Tokens != 50_000 || len(selected.Truncated) != 0 { + t.Fatalf("the newest message must be kept whole and uncut: %#v", selected) + } +} + +func TestSelectTailTruncatesOlderToolOutputsAndMeasuresAfterwards(t *testing.T) { + big := strings.Repeat("y", 10_000) + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "goal")), + testAssistant("a0", "u0", textPart("a0", strings.Repeat("f", 2_000))), + testAssistant("a1", "u0", toolPartCompleted("a1", "c1", "bash", `{}`, big)), + testAssistant("a2", "u0", toolPartCompleted("a2", "c2", "bash", `{}`, big)), + testAssistant("a3", "u0", textPart("a3", "newest")), + } + // Each truncated output costs 4,000 chars plus a marker; a budget of + // 9,000 fits both older tool messages after truncation but neither + // before it, and stops short of the 2,000-char reply before them. + selected, err := selectTail(messages, 9_000, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + if selected.StartID == nil || *selected.StartID != "a1" || len(selected.Head) != 2 { + t.Fatalf("selection = %#v", selected) + } + if selected.Messages != 3 || len(selected.Truncated) != 2 || selected.TruncatedOutputs != 2 { + t.Fatalf("tail accounting = %#v", selected) + } + if selected.Tokens >= 20_000 || selected.Tokens < 8_000 { + t.Fatalf("tail measured before truncation: %v", selected.Tokens) + } + for _, raw := range selected.Truncated { + part := raw.(msgmodel.ToolPart) + output := part.State.(msgmodel.ToolStateCompleted).Output + if !strings.Contains(output, "truncated at a context compaction") || + !strings.HasPrefix(output, strings.Repeat("y", 1_000)) || + !strings.HasSuffix(output, strings.Repeat("y", 3_000)) { + t.Fatalf("truncated output = %q", output[:80]) + } + } + // The caller's messages are untouched: truncation is reported, not + // applied in place. + original := messages[2].Parts[0].(msgmodel.ToolPart).State.(msgmodel.ToolStateCompleted).Output + if original != big { + t.Fatal("selectTail mutated the input messages") + } +} + +func TestSelectTailWithEverythingFittingHasNoHead(t *testing.T) { + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "goal")), + testAssistant("a0", "u0", textPart("a0", "reply")), + } + selected, err := selectTail(messages, 1_000, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + // No head, but the tail is still NAMED from the first message: a boundary + // whose compaction part carries no tail_start_id keeps nothing before it. + if selected.StartID == nil || *selected.StartID != "u0" || + len(selected.Head) != 0 || selected.Messages != 2 { + t.Fatalf("selection = %#v", selected) + } + empty, err := selectTail(nil, 1_000, Model{}, charEstimate, 4_000) + if err != nil || empty.StartID != nil || empty.Messages != 0 { + t.Fatalf("empty selection = %#v %v", empty, err) + } +} + +func reasoningPart(messageID, id, text string, metadata string) msgmodel.ReasoningPart { + part := msgmodel.ReasoningPart{ + PartBase: msgmodel.PartBase{ID: id, SessionID: "ses_1", MessageID: messageID}, + Text: text, + } + if metadata != "" { + part.Metadata = msgmodel.RawObject(metadata) + } + return part +} + +// Reasoning is cut in every kept message, the newest included: a newest +// message dominated by reasoning would otherwise cost the whole tail. Signed +// reasoning is left alone. +func TestSelectTailTruncatesUnsignedReasoningEverywhereIncludingNewest(t *testing.T) { + long := strings.Repeat("thinking ", 2_000) // 18,000 chars + messages := []msgmodel.WithParts{ + // A 5,000-char request that cannot fit the 3,000 budget, so it forms + // the head and a0 is the first kept message. + testUser("u0", textPart("u0", strings.Repeat("g", 5_000))), + testAssistant("a0", "u0", reasoningPart("a0", "r0", long, ""), textPart("a0", "older")), + testAssistant("a1", "u0", + reasoningPart("a1", "r1", long, `{"anthropic":{"signature":"sig"}}`), + reasoningPart("a1", "r2", long, ""), + toolPartCompleted("a1", "c1", "bash", `{}`, strings.Repeat("o", 9_000)), + ), + } + selected, err := selectTail(messages, 3_000, Model{}, charEstimate, 4_000) + if err != nil { + t.Fatal(err) + } + if selected.StartID == nil || *selected.StartID != "a0" || selected.TruncatedReasoning != 2 || + selected.TruncatedOutputs != 0 { + t.Fatalf("selection = %#v", selected) + } + // newest: unsigned r2 cut, signed r1 untouched, tool output whole. + // older: r0 cut; its size after the cut fits the 3,000 budget. + ids := map[string]bool{} + for _, raw := range selected.Truncated { + part := raw.(msgmodel.ReasoningPart) + ids[part.ID] = true + if !strings.HasPrefix(part.Text, "thinking ") || !strings.Contains(part.Text, "Reasoning truncated") || + len(part.Text) > 1_200 { + t.Fatalf("reasoning cut = %q", part.Text[:80]) + } + } + if !ids["r0"] || !ids["r2"] || ids["r1"] { + t.Fatalf("truncated reasoning ids = %v", ids) + } + if selected.Tokens > 1_000+18_000+9_000+200 || selected.Tokens < 9_000 { + t.Fatalf("tail tokens = %v", selected.Tokens) + } +} + +func TestTruncateTailOutputKeepsHeadAndMostlyTail(t *testing.T) { + text := strings.Repeat("h", 500) + strings.Repeat("t", 500) + out := truncateTailOutput(text, 200) + if !strings.HasPrefix(out, strings.Repeat("h", 50)+"\n") || + !strings.HasSuffix(out, strings.Repeat("t", 150)) || + !strings.Contains(out, "omitted 800 chars") { + t.Fatalf("truncated = %q", out) + } + if got := truncateTailOutput("short", 200); got != "short" { + t.Fatalf("short output altered: %q", got) + } +} diff --git a/internal/seniordev/session/compaction/transcript.go b/internal/seniordev/session/compaction/transcript.go new file mode 100644 index 0000000000..2212702a12 --- /dev/null +++ b/internal/seniordev/session/compaction/transcript.go @@ -0,0 +1,123 @@ +//go:build !windows + +package compaction + +import ( + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +// SerializeTranscript flattens the conversation being summarized into ONE +// plain-text block for the summary model call (the caller wraps it in +// <conversation> tags). Handing the summarizer the session as a live message +// array invites the model to keep coding — leaking tool-call markup as text or +// narrating its next action — instead of serializing state. A flattened +// transcript cannot be "continued": there is no open tool call, no assistant +// turn to extend, only data to read. +// +// Each tool output is head/tail truncated (HeadTailTruncate) so the serialized +// block is bounded and, in practice, smaller than the message array it +// replaces. +func SerializeTranscript(messages []msgmodel.WithParts, maxToolChars float64) string { + lines := make([]string, 0, len(messages)) + for _, message := range messages { + switch info := message.Info.(type) { + case msgmodel.User: + if text := plainText(message.Parts); text != "" { + lines = append(lines, "[User]: "+text) + } + case msgmodel.Assistant: + if text := plainText(message.Parts); text != "" { + lines = append(lines, "[Assistant]: "+text) + } + if calls := toolCallLines(message.Parts); calls != "" { + lines = append(lines, "[Assistant tool calls]: "+calls) + } + for _, result := range toolResultLines(message.Parts, maxToolChars) { + lines = append(lines, result) + } + _ = info + } + } + return strings.Join(lines, "\n") +} + +// plainText joins the non-synthetic, non-ignored text parts of a message. +func plainText(parts msgmodel.Parts) string { + collected := make([]string, 0, len(parts)) + for _, raw := range parts { + part, ok := raw.(msgmodel.TextPart) + if !ok || boolPointer(part.Ignored) || boolPointer(part.Synthetic) { + continue + } + if text := strings.TrimSpace(part.Text); text != "" { + collected = append(collected, text) + } + } + return strings.TrimSpace(strings.Join(collected, "\n")) +} + +// toolCallLines renders "name(input); name(input)" for the tool calls in a +// message. The raw input JSON is included but capped, so a large write/edit +// payload cannot dominate the serialized transcript. +func toolCallLines(parts msgmodel.Parts) string { + calls := make([]string, 0) + for _, raw := range parts { + part, ok := raw.(msgmodel.ToolPart) + if !ok { + continue + } + input := "" + if raw := part.State.ToolInput(); len(raw) > 0 { + input = HeadTailTruncate(string(raw), toolCallInputMaxChars) + } + calls = append(calls, part.Tool+"("+input+")") + } + return strings.Join(calls, "; ") +} + +// toolResultLines renders one "[Tool result]" / "[Tool error]" line per tool +// part, truncated to the observation cap. +func toolResultLines(parts msgmodel.Parts, maxToolChars float64) []string { + lines := make([]string, 0) + for _, raw := range parts { + part, ok := raw.(msgmodel.ToolPart) + if !ok { + continue + } + switch state := part.State.(type) { + case msgmodel.ToolStateCompleted: + if output := strings.TrimSpace(state.Output); output != "" { + lines = append(lines, + "[Tool result]: "+HeadTailTruncate(state.Output, maxToolChars)) + } + case msgmodel.ToolStateError: + if errText := strings.TrimSpace(state.Error); errText != "" { + lines = append(lines, + "[Tool error]: "+HeadTailTruncate(state.Error, maxToolChars)) + } + } + } + return lines +} + +const toolCallInputMaxChars = 500 + +// CapTranscript bounds a flattened transcript to maxChars, cutting the middle +// so the beginning and the latest state both survive. It returns the number +// of characters removed so the boundary can report it. +func CapTranscript(transcript string, maxChars float64) (string, float64) { + length := float64(charCount(transcript)) + if maxChars <= 0 || length <= maxChars { + return transcript, 0 + } + headChars := int(maxChars / 4) + tailChars := int(maxChars) - headChars + head := sliceChars(transcript, 0, headChars) + tail := sliceChars(transcript, charCount(transcript)-tailChars, charCount(transcript)) + omitted := length - float64(headChars) - float64(tailChars) + return head + "\n[... transcript cut here: " + strconv.FormatFloat(omitted, 'f', -1, 64) + + " chars of the middle omitted ...]\n" + tail, omitted +} diff --git a/internal/seniordev/session/compaction/transcript_test.go b/internal/seniordev/session/compaction/transcript_test.go new file mode 100644 index 0000000000..5865655283 --- /dev/null +++ b/internal/seniordev/session/compaction/transcript_test.go @@ -0,0 +1,138 @@ +//go:build !windows + +package compaction + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +func toolPartCompleted(messageID, id, tool, input, output string) msgmodel.ToolPart { + return msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: id, SessionID: "ses_1", MessageID: messageID}, + CallID: id, + Tool: tool, + State: msgmodel.ToolStateCompleted{ + Input: msgmodel.RawObject(input), + Output: output, + }, + } +} + +func TestSerializeTranscriptFlattensToOneBlockWithoutMessageArray(t *testing.T) { + messages := []msgmodel.WithParts{ + testUser("u0", textPart("u0", "Fix src/a.ts")), + testAssistant("a0", "u0", + textPart("a0", "Reading the file first."), + toolPartCompleted("a0", "c1", "read", `{"file":"src/a.ts"}`, "line one\nline two"), + ), + } + out := SerializeTranscript(messages, 2000) + + for _, want := range []string{ + "[User]: Fix src/a.ts", + "[Assistant]: Reading the file first.", + "[Assistant tool calls]: read(", + "[Tool result]: line one", + } { + if !strings.Contains(out, want) { + t.Fatalf("serialized transcript missing %q:\n%s", want, out) + } + } + // The whole point: a flattened string, not a replayable conversation. + if strings.Contains(out, "\"role\"") { + t.Fatalf("transcript leaked a message-array shape:\n%s", out) + } +} + +func TestSerializeTranscriptCapsToolOutput(t *testing.T) { + big := strings.Repeat("x", 10_000) + messages := []msgmodel.WithParts{ + testAssistant("a0", "u0", toolPartCompleted("a0", "c1", "bash", `{"cmd":"cat big"}`, big)), + } + out := SerializeTranscript(messages, 2000) + if len(out) > 4000 { + t.Fatalf("tool output not capped: serialized length %d", len(out)) + } + if !strings.Contains(out, "truncated") { + t.Fatalf("expected truncation marker in:\n%s", out[:200]) + } +} + +func TestSerializeTranscriptRendersToolErrors(t *testing.T) { + messages := []msgmodel.WithParts{ + testAssistant("a0", "u0", msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "c1", SessionID: "ses_1", MessageID: "a0"}, + CallID: "c1", Tool: "bash", + State: msgmodel.ToolStateError{ + Input: msgmodel.RawObject(`{"cmd":"false"}`), + Error: "exit status 1", + }, + }), + } + out := SerializeTranscript(messages, 2000) + if !strings.Contains(out, "[Tool error]: exit status 1") { + t.Fatalf("tool error not rendered:\n%s", out) + } +} + +func TestClassifySummaryFailure(t *testing.T) { + cases := []struct { + name string + msg msgmodel.WithParts + want string + }{ + { + name: "dsml markup as text", + msg: testAssistant("a", "u", textPart("a", "<|DSML|invoke name=\"bash\">")), + want: SummaryClassDSMLText, + }, + { + name: "xml tool call as text", + msg: testAssistant("a", "u", textPart("a", "<tool_call>read</tool_call>")), + want: SummaryClassDSMLText, + }, + { + name: "empty", + msg: testAssistant("a", "u", textPart("a", " ")), + want: SummaryClassEmpty, + }, + { + name: "off format prose", + msg: testAssistant("a", "u", textPart("a", "Let me continue reading the code.")), + want: SummaryClassFormat, + }, + { + name: "structural tool part", + msg: testAssistant("a", "u", + toolPartCompleted("a", "c1", "read", "{}", "ok")), + want: SummaryClassToolCall, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ClassifySummaryFailure(tc.msg); got != tc.want { + t.Fatalf("class = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCapTranscriptCutsTheMiddleAndReportsIt(t *testing.T) { + transcript := strings.Repeat("a", 1000) + strings.Repeat("z", 1000) + capped, omitted := CapTranscript(transcript, 400) + if omitted != 1600 { + t.Fatalf("omitted = %v, want 1600", omitted) + } + if !strings.HasPrefix(capped, strings.Repeat("a", 100)) || + !strings.HasSuffix(capped, strings.Repeat("z", 300)) || + !strings.Contains(capped, "transcript cut here: 1600 chars") { + t.Fatalf("capped transcript = %q", capped) + } + same, omitted := CapTranscript("short", 400) + if same != "short" || omitted != 0 { + t.Fatalf("short transcript was altered: %q %v", same, omitted) + } +} diff --git a/internal/seniordev/session/evidenceharvest/evidenceharvest.go b/internal/seniordev/session/evidenceharvest/evidenceharvest.go new file mode 100644 index 0000000000..d3766500d3 --- /dev/null +++ b/internal/seniordev/session/evidenceharvest/evidenceharvest.go @@ -0,0 +1,423 @@ +//go:build !windows + +// Package evidenceharvest extracts the lines worth preserving from tool +// output before a compaction: command outcomes, exact error signatures and +// paths referenced across messages. A low-tier judge may pick the lines; the +// deterministic harvester is the fallback. All budgets count characters. +package evidenceharvest + +import ( + "regexp" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +const ( + maxLineChars = 300 + defaultMaxChars = 4000 + maxCorpusChars = 60000 +) + +var outcomePatterns = []*regexp.Regexp{ + regexp.MustCompile(`exit(ed)?( with)?( code)? [0-9]+`), + regexp.MustCompile(`\b[0-9]+ pass(ed|ing)?\b[^\n\r\x{2028}\x{2029}]*\b[0-9]+ fail`), + regexp.MustCompile(`\b[0-9]+ fail(ed|ing|ures?)\b`), + regexp.MustCompile(`\btests? (passed|failed)\b`), +} + +var errorPatterns = []struct { + re *regexp.Regexp + fold bool +}{ + {regexp.MustCompile(`\b[A-Z][a-zA-Z]*Error\b:?`), false}, + {regexp.MustCompile(`(^|[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}])Error:[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]`), false}, + {regexp.MustCompile(`\bTraceback \(most recent call last\)`), false}, + {regexp.MustCompile(`\bpanic:[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}]`), false}, + {regexp.MustCompile(`\bFAILED\b`), false}, + {regexp.MustCompile(`\bassertionerror\b|\bassert(ion)? failed\b`), true}, + {regexp.MustCompile(`\bENOENT\b|\bEACCES\b|\bECONNREFUSED\b|\bETIMEDOUT\b`), false}, + {regexp.MustCompile(`\berror TS[0-9]+\b`), false}, + {regexp.MustCompile(`\bnpm error\b|\bnpm ERR!`), false}, + // Suite-abort signatures that are lower-case or toolchain-specific, so + // the generic Error patterns above miss them. + {regexp.MustCompile(`error: could not compile|\[build failed\]`), true}, + {regexp.MustCompile(`error during collection|ERROR collecting|ImportError while loading`), false}, + {regexp.MustCompile(`Transform failed with [0-9]+ error|Exception during run`), false}, +} + +// Captures are: 1 boundary, 2 path, 3 optional relative prefix, 4 repeated +// directory segment, 5 optional line suffix. Group 2 is PATH_RE's match[1]. +var pathRE = regexp.MustCompile( + `(^|[\x09-\x0d \x{00a0}\x{1680}\x{2000}-\x{200a}\x{2028}\x{2029}\x{202f}\x{205f}\x{3000}\x{feff}"'` + "`" + `(=])` + + `((\.{0,2}/)?([A-Za-z0-9_.@-]+/)+[A-Za-z0-9_.@-]+\.[a-z]{1,10})(:[0-9]+)?`, +) + +// Message is the minimal model-message surface textBlocksOf reads. +type Message struct { + Content any `json:"content"` +} + +// EvidenceSource reports whether the low-tier judgment or deterministic +// fallback supplied SelectEvidence's content. +type EvidenceSource string + +const ( + SourceLLM EvidenceSource = "llm" + SourceFallback EvidenceSource = "fallback" +) + +// EvidenceJudgment is the narrow result SelectEvidence needs from a judge. +type EvidenceJudgment struct { + Lines []string + Source EvidenceSource +} + +// EvidenceJudge is the seam for a model-backed evidence selector. +type EvidenceJudge interface { + JudgeEvidence(prompt string, language any) EvidenceJudgment +} + +// EvidenceJudgeFunc adapts a function to EvidenceJudge. +type EvidenceJudgeFunc func(prompt string, language any) EvidenceJudgment + +// JudgeEvidence implements EvidenceJudge. +func (f EvidenceJudgeFunc) JudgeEvidence(prompt string, language any) EvidenceJudgment { + return f(prompt, language) +} + +// SelectEvidenceOptions configures SelectEvidence. Nil MaxChars selects 4000. +type SelectEvidenceOptions struct { + MaxChars *float64 + Judge EvidenceJudge +} + +// SelectedEvidence is SelectEvidence's result. Text is nil when nothing was +// selected. +type SelectedEvidence struct { + Text *string `json:"text"` + Source EvidenceSource `json:"source"` +} + +// charCount is the length of s in characters (runes). +func charCount(s string) int { return utf8.RuneCountInString(s) } + +// sliceChars returns the characters of s in [start, end), clamped to s. +func sliceChars(s string, start, end int) string { + runes := []rune(s) + start = max(0, min(start, len(runes))) + end = max(start, min(end, len(runes))) + return string(runes[start:end]) +} + +func truncate(line string) string { + trimmed := strings.TrimSpace(line) + if charCount(trimmed) > maxLineChars { + return sliceChars(trimmed, 0, maxLineChars) + "…" + } + return trimmed +} + +func asciiLower(s string) string { + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +func isOutcome(line string) bool { + folded := asciiLower(line) + for _, re := range outcomePatterns { + if re.MatchString(folded) { + return true + } + } + return false +} + +func isError(line string) bool { + for _, pattern := range errorPatterns { + scan := line + if pattern.fold { + scan = asciiLower(scan) + } + if pattern.re.MatchString(scan) { + return true + } + } + return false +} + +type scoredLine struct { + line string + score int + block int + seq int +} + +type pathRefs struct { + path string + blocks map[int]struct{} +} + +// HarvestEvidence extracts deterministic, verbatim evidence. Nil means nothing +// qualified. +func HarvestEvidence(blocks []string, maxChars ...float64) *string { + budget := float64(defaultMaxChars) + if len(maxChars) > 0 { + budget = maxChars[0] + } + if len(blocks) == 0 || budget <= 0 { + return nil + } + + scored := []scoredLine{} + seen := map[string]int{} + pathIndex := map[string]int{} + paths := []pathRefs{} + seq := 0 + + for blockIndex, block := range blocks { + for _, raw := range strings.Split(block, "\n") { + line := truncate(raw) + if charCount(line) < 4 { + continue + } + for _, match := range pathRE.FindAllStringSubmatch(raw, -1) { + path := match[2] + index, ok := pathIndex[path] + if !ok { + index = len(paths) + pathIndex[path] = index + paths = append(paths, pathRefs{path: path, blocks: map[int]struct{}{}}) + } + paths[index].blocks[blockIndex] = struct{}{} + } + score := 0 + if isOutcome(line) { + score += 3 + } + if isError(line) { + score += 2 + } + if score == 0 { + continue + } + if index, exists := seen[line]; exists { + // Attribute repeated evidence to its newest occurrence. + scored[index].block, scored[index].seq = blockIndex, seq + seq++ + continue + } + seen[line] = len(scored) + scored = append(scored, scoredLine{ + line: line, score: score, block: blockIndex, seq: seq, + }) + seq++ + } + } + + crossFiles := []pathRefs{} + for _, item := range paths { + if len(item.blocks) >= 2 { + crossFiles = append(crossFiles, item) + } + } + sort.SliceStable(crossFiles, func(i, j int) bool { + if len(crossFiles[i].blocks) != len(crossFiles[j].blocks) { + return len(crossFiles[i].blocks) > len(crossFiles[j].blocks) + } + return crossFiles[i].path < crossFiles[j].path + }) + if len(crossFiles) > 20 { + crossFiles = crossFiles[:20] + } + + if len(scored) == 0 && len(crossFiles) == 0 { + return nil + } + + sort.SliceStable(scored, func(i, j int) bool { + if scored[i].score != scored[j].score { + return scored[i].score > scored[j].score + } + // Prefer the newest command, but retain line order within that command. + if scored[i].block != scored[j].block { + return scored[i].block > scored[j].block + } + return scored[i].seq < scored[j].seq + }) + + fileBlock := []string{} + if len(crossFiles) > 0 { + fileBlock = append(fileBlock, "### Files referenced across multiple steps") + for _, file := range crossFiles { + fileBlock = append(fileBlock, + "- "+file.path+" ("+strconv.Itoa(len(file.blocks))+" messages)") + } + } + fileChars := 0 + for _, line := range fileBlock { + fileChars += charCount(line) + 1 + } + + lines := []string{} + used := 0 + evidenceBudget := budget - float64(fileChars) + for _, entry := range scored { + if float64(used+charCount(entry.line)+3) > evidenceBudget { + continue + } + lines = append(lines, "- "+entry.line) + used += charCount(entry.line) + 3 + } + + sections := []string{"## Preserved evidence (verbatim, extracted by senior-dev)"} + if len(lines) > 0 { + sections = append(sections, "### Command outcomes & errors") + sections = append(sections, lines...) + } + sections = append(sections, fileBlock...) + if len(sections) == 1 { + return nil + } + text := strings.Join(sections, "\n") + return &text +} + +func optionMaxChars(opts *SelectEvidenceOptions) float64 { + if opts == nil || opts.MaxChars == nil { + return defaultMaxChars + } + return *opts.MaxChars +} + +func languageTruthy(language any) bool { + switch value := language.(type) { + case nil: + return false + case bool: + return value + case string: + return value != "" + case float64: + return value != 0 + case float32: + return value != 0 + case int: + return value != 0 + default: + return true + } +} + +func evidencePrompt(corpus string) string { + return strings.Join([]string{ + "The transcript region below is about to be replaced by a summary.", + "Select the LOAD-BEARING lines that must survive VERBATIM because a", + "paraphrase would lose their value: commands with their outcomes/exit", + "codes, exact error messages and signatures, and file paths central to", + "the work. Copy each selected line EXACTLY as it appears (you may", + "truncate a line after 300 characters). Skip conversational prose,", + "reasoning, and anything a summary can safely restate. Max 25 lines;", + "return an empty list if nothing qualifies.", + "", + "--- TRANSCRIPT REGION ---", + corpus, + }, "\n") +} + +// SelectEvidence renders low-tier-selected evidence or falls back to the +// deterministic regex harvester when the judge is unavailable/fails. +func SelectEvidence(blocks []string, language any, opts *SelectEvidenceOptions) SelectedEvidence { + maxChars := optionMaxChars(opts) + if len(blocks) == 0 { + return SelectedEvidence{Text: nil, Source: SourceFallback} + } + + corpus := strings.Join(blocks, "\n---\n") + if charCount(corpus) > maxCorpusChars { + corpus = sliceChars(corpus, charCount(corpus)-maxCorpusChars, charCount(corpus)) + } + + if opts == nil || opts.Judge == nil || !languageTruthy(language) { + return SelectedEvidence{Text: HarvestEvidence(blocks, maxChars), Source: SourceFallback} + } + judged := opts.Judge.JudgeEvidence(evidencePrompt(corpus), language) + if judged.Source != SourceLLM { + return SelectedEvidence{Text: HarvestEvidence(blocks, maxChars), Source: SourceFallback} + } + + lines := []string{} + used := 0 + for _, raw := range judged.Lines { + line := truncate(raw) + if float64(used+charCount(line)+3) > maxChars { + break + } + lines = append(lines, "- "+line) + used += charCount(line) + 3 + } + if len(lines) == 0 { + return SelectedEvidence{Text: nil, Source: SourceLLM} + } + text := strings.Join( + append([]string{"## Preserved evidence (verbatim, low-tier selected)"}, lines...), + "\n", + ) + return SelectedEvidence{Text: &text, Source: SourceLLM} +} + +// TextBlocksOf flattens string content and typed text/output parts. +func TextBlocksOf(messages []Message) []string { + blocks := []string{} + for _, message := range messages { + if content, ok := message.Content.(string); ok { + if strings.TrimSpace(content) != "" { + blocks = append(blocks, content) + } + continue + } + parts, ok := message.Content.([]any) + if !ok { + continue + } + texts := []string{} + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok { + continue + } + if text, ok := part["text"].(string); ok { + if text != "" { + texts = append(texts, text) + } + continue + } + output, exists := part["output"] + if !exists { + continue + } + if text, ok := output.(string); ok { + if text != "" { + texts = append(texts, text) + } + continue + } + if object, ok := output.(map[string]any); ok { + if text, ok := object["value"].(string); ok && text != "" { + texts = append(texts, text) + } + } + } + text := strings.Join(texts, "\n") + if strings.TrimSpace(text) != "" { + blocks = append(blocks, text) + } + } + return blocks +} diff --git a/internal/seniordev/session/evidenceharvest/evidenceharvest_test.go b/internal/seniordev/session/evidenceharvest/evidenceharvest_test.go new file mode 100644 index 0000000000..8bcabeac67 --- /dev/null +++ b/internal/seniordev/session/evidenceharvest/evidenceharvest_test.go @@ -0,0 +1,85 @@ +//go:build !windows + +package evidenceharvest + +import ( + "strings" + "testing" +) + +func TestBackslashPathsAreNotHarvested(t *testing.T) { + got := HarvestEvidence([]string{ + `opened C:\repo\src\a.ts`, + `changed C:\repo\src\a.ts`, + }) + if got != nil { + t.Fatalf("Windows-only path unexpectedly harvested: %q", *got) + } +} + +func TestFileSectionIsKeptEvenOverBudget(t *testing.T) { + got := HarvestEvidence([]string{ + "src/really-long-name.ts", + "src/really-long-name.ts", + }, 1) + if got == nil || len(*got) <= 1 { + t.Fatalf("expected over-budget structural file block, got %v", got) + } +} + +func TestSelectEvidencePromptEndsWithTheCorpus(t *testing.T) { + var prompt string + opts := &SelectEvidenceOptions{ + Judge: EvidenceJudgeFunc(func(got string, _ any) EvidenceJudgment { + prompt = got + return EvidenceJudgment{Source: SourceLLM} + }), + } + result := SelectEvidence([]string{"alpha", "beta"}, struct{}{}, opts) + if result.Source != SourceLLM || result.Text != nil { + t.Fatalf("unexpected result: %+v", result) + } + if !strings.HasSuffix(prompt, "--- TRANSCRIPT REGION ---\nalpha\n---\nbeta") { + t.Fatalf("prompt corpus mismatch:\n%s", prompt) + } +} + +func TestHarvestEvidenceKeepsCurrentSuiteAbortAheadOfOldProbeNoise(t *testing.T) { + got := HarvestEvidence([]string{ + "Error: obsolete scratch probe failed", + "error[E0063]: missing fields in Config\nerror: could not compile `mycrate`", + }) + if got == nil || !strings.Contains(*got, "error: could not compile `mycrate`") { + t.Fatalf("current compiler failure was not preserved: %v", got) + } + if strings.Index(*got, "could not compile") > strings.Index(*got, "obsolete scratch") { + t.Fatalf("old equal-strength noise outranked the current failure:\n%s", *got) + } +} + +func TestHarvestEvidenceKeepsLineOrderInsideCurrentFailure(t *testing.T) { + got := HarvestEvidence([]string{ + "Error: stale probe", + "Exception during run: loader abort\nTransform failed with 1 error", + }) + if got == nil || strings.Index(*got, "Exception during run") > + strings.Index(*got, "Transform failed with 1 error") { + t.Fatalf("current failure lines were reversed:\n%v", got) + } +} + +func TestSemanticEvidenceJudgeSeesTheRecentEndOfALargeTranscript(t *testing.T) { + const latest = "LATEST failure: error: could not compile mycrate" + var prompt string + SelectEvidence([]string{ + "STALE-BEGIN " + strings.Repeat("x", maxCorpusChars), latest, + }, struct{}{}, &SelectEvidenceOptions{Judge: EvidenceJudgeFunc( + func(got string, _ any) EvidenceJudgment { + prompt = got + return EvidenceJudgment{Source: SourceLLM} + }, + )}) + if !strings.Contains(prompt, latest) || strings.Contains(prompt, "STALE-BEGIN") { + t.Fatalf("judge did not receive the recent transcript tail") + } +} diff --git a/internal/seniordev/session/fullverification/discovery.go b/internal/seniordev/session/fullverification/discovery.go new file mode 100644 index 0000000000..c1441e78d9 --- /dev/null +++ b/internal/seniordev/session/fullverification/discovery.go @@ -0,0 +1,884 @@ +//go:build !windows + +// Package fullverification discovers the project-wide build and test +// entrypoints that form the session-end machine verification floor. +package fullverification + +import ( + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type EntrypointKind string + +const ( + KindBuild EntrypointKind = "build" + KindTest EntrypointKind = "test" +) + +type Entrypoint struct { + Kind EntrypointKind `json:"kind"` + Command string `json:"command"` + Workdir string `json:"workdir,omitempty"` + Source string `json:"source"` +} + +type Plan struct { + Entrypoints []Entrypoint `json:"entrypoints"` + // BuildExpected reports whether this project is required to have a + // build/typecheck step. True for any accountable workspace — including one + // whose ecosystem we do not recognize — so the gate stays fail-closed; a + // plain Python package is the one case we can positively identify as + // having nothing to compile. + BuildExpected bool `json:"buildExpected"` + // TestExpected reports whether this project is required to have a test + // step. True for any accountable workspace: unlike a build, no ecosystem + // is exempt from having tests. Both flags are false only for a workspace + // that is not accountable at all (see accountableWorkspace). + TestExpected bool `json:"testExpected"` +} + +var ( + testCommandPattern = regexp.MustCompile(`(?i)(?:^|(?:&&|\|\||[;&|])[[:space:]]*)(?:env[[:space:]]+)?(?:[A-Z_][A-Z0-9_]*=[^[:space:]]+[[:space:]]+)*(?:go[[:space:]]+test|cargo[[:space:]]+test|bun[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|npm[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|pnpm[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|yarn[[:space:]]+(?:run[[:space:]]+)?(?:test(?::unit)?|unit|verify|check)|python(?:3)?[[:space:]]+-m[[:space:]]+(?:pytest|unittest)|pytest|tox|nox|make[[:space:]]+(?:test|check|verify)|just[[:space:]]+(?:test|check|verify)|(?:\./)?mvnw?[[:space:]].*(?:test|verify)|\./gradlew[[:space:]].*(?:test|check)|dotnet[[:space:]]+test|ctest(?:[[:space:]]|$))`) + buildCommandPattern = regexp.MustCompile(`(?i)(?:^|(?:&&|\|\||[;&|])[[:space:]]*)(?:env[[:space:]]+)?(?:[A-Z_][A-Z0-9_]*=[^[:space:]]+[[:space:]]+)*(?:go[[:space:]]+build|cargo[[:space:]]+build|npm[[:space:]]+run[[:space:]]+(?:build|compile|typecheck)|pnpm[[:space:]]+(?:run[[:space:]]+)?(?:build|compile|typecheck)|yarn[[:space:]]+(?:run[[:space:]]+)?(?:build|compile|typecheck)|bun[[:space:]]+run[[:space:]]+(?:build|compile|typecheck)|make[[:space:]]+(?:build|all)|just[[:space:]]+(?:build|all)|(?:\./)?mvnw?[[:space:]].*(?:package|compile)|\./gradlew[[:space:]].*(?:build|assemble)|dotnet[[:space:]]+build|cmake[[:space:]]+--build|python(?:3)?[[:space:]]+-m[[:space:]]+(?:build|compileall|mypy|pyright|ruff[[:space:]]+check)|(?:\./)?(?:mypy|pyright)(?:[[:space:]]|$)|(?:\./)?ruff[[:space:]]+check|tsc(?:[[:space:]]|$))`) + makeTargetPattern = regexp.MustCompile(`(?m)^([A-Za-z0-9_.-]+)[[:space:]]*:(?:[^=]|$)`) + inlineCodePattern = regexp.MustCompile("`([^`\n]+)`") + leadingCDPattern = regexp.MustCompile(`^cd[[:space:]]+((?:'[^']*'|"[^"]*"|[^;&|[:space:]]+))[[:space:]]*&&[[:space:]]*(.+)$`) + standaloneCDPattern = regexp.MustCompile(`^cd[[:space:]]+((?:'[^']*'|"[^"]*"|[^;&|[:space:]]+))[[:space:]]*$`) + interactiveRunnerPattern = regexp.MustCompile(`(?i)(?:^|(?:&&|\|\||[;&|])[[:space:]]*)cypress[[:space:]]+open(?:[[:space:]]|$)`) + heredocPattern = regexp.MustCompile(`<<-?[[:space:]]*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?`) + numericFlagPattern = regexp.MustCompile(`^[0-9]+$`) + + // Any package-manager invocation disqualifies a script from being chosen + // as the build entrypoint. `npm run x` hides another script's effects, and + // `npm publish` / `npm version` are outright destructive — a body like + // "tsc -p tsconfig.json && npm publish" reads as a compile right up to the + // point where satisfying the verification gate ships a release. A false + // negative here costs a discovered entrypoint; a false positive publishes a + // package. npx is excluded from the ban: it is a runner, not a lifecycle + // manager, so `npx tsc` stays selectable. + packageScriptDelegationPattern = regexp.MustCompile( + `(?i)(?:^|[^[:alnum:]_./-])(?:npm|pnpm|yarn|bun)(?:[[:space:]]|$)`) +) + +// Discover prefers CI, then repository instructions, declared +// scripts/manifests, and finally ecosystem defaults. Each kind gets one +// project-wide entrypoint. +func Discover(workspace string) Plan { + selected := map[EntrypointKind]Entrypoint{} + add := func(kind EntrypointKind, command, workdir, source string) { + command = normalizeCommand(command) + if command == "" { + return + } + if _, exists := selected[kind]; !exists { + selected[kind] = Entrypoint{ + Kind: kind, Command: command, Workdir: workdir, Source: source, + } + } + } + addCandidates := func(candidates []commandCandidate) { + for _, candidate := range candidates { + if candidate.kind != "" { + add(candidate.kind, candidate.command, candidate.workdir, candidate.source) + continue + } + if isBuildCommand(candidate.command) { + add(KindBuild, candidate.command, candidate.workdir, candidate.source) + } + if isTestCommand(candidate.command) && !isInteractiveTestCommand(candidate.command) { + add(KindTest, candidate.command, candidate.workdir, candidate.source) + } + } + } + + addCandidates(ciCandidates(workspace)) + addCandidates(documentCandidates(workspace, []string{"AGENTS.md"})) + addCandidates(scriptCandidates(workspace)) + addCandidates(documentCandidates(workspace, []string{ + "README.md", "README", "CONTRIBUTING.md", "CONTRIBUTING", + })) + + defaults := ecosystemDefaults(workspace) + for _, entrypoint := range defaults { + add(entrypoint.Kind, entrypoint.Command, entrypoint.Workdir, entrypoint.Source) + } + + _, hasBuild := selected[KindBuild] + _, hasTest := selected[KindTest] + // Both demands hang off one question: is there a project here to hold to a + // standard? Discovering any command answers it outright — someone wrote + // that command down — and otherwise the ecosystem markers decide. + accountable := hasBuild || hasTest || accountableWorkspace(workspace) + plan := Plan{ + Entrypoints: []Entrypoint{}, + BuildExpected: hasBuild || (accountable && !ecosystemLacksBuild(workspace)), + TestExpected: accountable, + } + for _, kind := range []EntrypointKind{KindBuild, KindTest} { + if entrypoint, ok := selected[kind]; ok { + plan.Entrypoints = append(plan.Entrypoints, entrypoint) + } + } + return plan +} + +type commandCandidate struct { + command string + workdir string + source string + // kind pins the classification when the caller already established it from + // something other than the command text. `npm run check:type:js` is a + // typecheck entrypoint, but only its script BODY says so — the invocation + // itself is indistinguishable from any other named script. + kind EntrypointKind +} + +func ciCandidates(workspace string) []commandCandidate { + paths := []string{ + ".gitlab-ci.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", + filepath.Join(".circleci", "config.yml"), + } + workflows, _ := filepath.Glob(filepath.Join(workspace, ".github", "workflows", "*.y*ml")) + for _, path := range workflows { + relative, err := filepath.Rel(workspace, path) + if err == nil { + paths = append(paths, relative) + } + } + sort.Strings(paths) + var candidates []commandCandidate + for _, relative := range paths { + body, ok := readSmallFile(filepath.Join(workspace, relative)) + if !ok { + continue + } + var document yaml.Node + if yaml.Unmarshal([]byte(body), &document) != nil { + continue + } + candidates = appendYAMLCommandCandidates(candidates, &document, "", relative) + } + return candidates +} + +func appendYAMLCommandCandidates(out []commandCandidate, node *yaml.Node, inheritedWorkdir, source string) []commandCandidate { + workdir := inheritedWorkdir + if node.Kind == yaml.MappingNode { + if defaults := yamlMappingValue(node, "defaults"); defaults != nil { + if run := yamlMappingValue(defaults, "run"); run != nil { + if value := yamlMappingValue(run, "working-directory"); value != nil && value.Kind == yaml.ScalarNode { + workdir = strings.TrimSpace(value.Value) + } + } + } + if value := yamlMappingValue(node, "working-directory"); value != nil && value.Kind == yaml.ScalarNode { + workdir = strings.TrimSpace(value.Value) + } + for index := 0; index+1 < len(node.Content); index += 2 { + key, value := node.Content[index].Value, node.Content[index+1] + if key == "run" || key == "script" { + out = appendYAMLCommandValue(out, value, workdir, source) + } + } + } + for _, child := range node.Content { + out = appendYAMLCommandCandidates(out, child, workdir, source) + } + return out +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + for index := 0; index+1 < len(node.Content); index += 2 { + if node.Content[index].Value == key { + return node.Content[index+1] + } + } + return nil +} + +func appendYAMLCommandValue(out []commandCandidate, node *yaml.Node, workdir, source string) []commandCandidate { + switch node.Kind { + case yaml.ScalarNode: + return appendCIShellCandidates(out, node.Value, workdir, source) + case yaml.SequenceNode: + for _, child := range node.Content { + if child.Kind == yaml.ScalarNode { + out = appendCIShellCandidates(out, child.Value, workdir, source) + } + } + } + return out +} + +func appendCIShellCandidates(out []commandCandidate, raw, workdir, source string) []commandCandidate { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + if !strings.Contains(raw, "\n") { + return appendShellCandidatesFrom(out, raw, workdir, source) + } + activeWorkdir := workdir + pending := "" + heredocEnd := "" + for _, line := range strings.Split(raw, "\n") { + if heredocEnd != "" { + if strings.TrimSpace(line) == heredocEnd { + heredocEnd = "" + } + continue + } + logical := line + if pending != "" { + logical = pending + strings.TrimSpace(line) + } + if shellLineContinues(logical) { + pending = strings.TrimSpace(strings.TrimSuffix(strings.TrimRight(logical, " \t"), "\\")) + " " + continue + } + pending = "" + command := normalizeCommand(logical) + if command == "" { + continue + } + if match := standaloneCDPattern.FindStringSubmatch(command); match != nil { + activeWorkdir = combineWorkingDirectories(activeWorkdir, strings.Trim(match[1], "\"'")) + continue + } + out = appendShellCandidatesFrom(out, command, activeWorkdir, source) + if match := heredocPattern.FindStringSubmatch(logical); match != nil { + heredocEnd = match[1] + } + } + return out +} + +func shellLineContinues(line string) bool { + line = strings.TrimRight(line, " \t") + backslashes := 0 + for index := len(line) - 1; index >= 0 && line[index] == '\\'; index-- { + backslashes++ + } + return backslashes%2 == 1 +} + +func documentCandidates(workspace string, names []string) []commandCandidate { + var candidates []commandCandidate + for _, relative := range names { + body, ok := readSmallFile(filepath.Join(workspace, relative)) + if !ok { + continue + } + inFence := false + for _, line := range strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") { + inFence = !inFence + continue + } + for _, match := range inlineCodePattern.FindAllStringSubmatch(line, -1) { + candidates = appendShellCandidates(candidates, match[1], relative) + } + if inFence || strings.HasPrefix(trimmed, "$") { + candidates = appendShellCandidates(candidates, strings.TrimSpace(strings.TrimPrefix(trimmed, "$")), relative) + } + } + } + return candidates +} + +func scriptCandidates(workspace string) []commandCandidate { + var candidates []commandCandidate + if body, ok := readSmallFile(filepath.Join(workspace, "package.json")); ok { + var manifest struct { + Scripts map[string]string `json:"scripts"` + } + if json.Unmarshal([]byte(body), &manifest) == nil { + manager := packageManager(workspace) + foundBuild := false + for _, script := range []string{"build", "compile", "typecheck"} { + if _, ok := manifest.Scripts[script]; !ok { + continue + } + candidates = append(candidates, commandCandidate{ + command: managerRun(manager, script), source: "package.json#scripts." + script, + }) + foundBuild = true + break + } + if !foundBuild { + // A project may name its compile/typecheck script anything + // (`check:type` -> `tsc -p tsconfig.json`, say). Matching only + // on the three blessed NAMES misses it, the gate reports that no + // build/typecheck entrypoint was discoverable, and the agent's + // rational way out is to invent a no-op `build` script that + // upstream would never merge. isBuildCommand already knows what + // a build/typecheck command looks like, so apply it to the + // script BODY rather than requiring a blessed name. + // + // Leaf scripts only. A body that shells out to other package + // scripts can carry a publish or deploy step alongside the + // compile, and running that to satisfy a verification gate would + // be far worse than failing the gate. Names are sorted so the + // choice is deterministic across runs. + for _, name := range sortedScriptNames(manifest.Scripts) { + script := manifest.Scripts[name] + if scriptRunsOtherScripts(script) || !isBuildCommand(script) { + continue + } + if isTestCommand(script) || isInteractiveTestCommand(script) { + continue + } + candidates = append(candidates, commandCandidate{ + command: managerRun(manager, name), + source: "package.json#scripts." + name, + kind: KindBuild, + }) + break + } + } + for _, script := range []string{"test", "unit", "test:unit", "verify", "check"} { + body, ok := manifest.Scripts[script] + if !ok || isInteractiveTestCommand(body) { + continue + } + command := managerRun(manager, script) + if script == "test" { + command = managerTest(manager) + } + candidates = append(candidates, commandCandidate{command: command, source: "package.json#scripts." + script}) + break + } + } + } + for _, file := range []string{"Makefile", "makefile", "GNUmakefile", "Justfile", "justfile"} { + body, ok := readSmallFile(filepath.Join(workspace, file)) + if !ok { + continue + } + targets := map[string]bool{} + for _, match := range makeTargetPattern.FindAllStringSubmatch(body, -1) { + targets[strings.ToLower(match[1])] = true + } + command := "make " + if strings.EqualFold(file, "Justfile") { + command = "just " + } + for _, target := range []string{"build", "all"} { + if targets[target] { + candidates = append(candidates, commandCandidate{command: command + target, source: file + "#" + target}) + break + } + } + for _, target := range []string{"test", "check", "verify"} { + if targets[target] { + candidates = append(candidates, commandCandidate{command: command + target, source: file + "#" + target}) + break + } + } + } + return candidates +} + +// accountableWorkspace reports whether the workspace looks like a software +// project at all: a language manifest, a build system, or a test suite. It is +// the precondition for BOTH verification demands. +// +// A workspace with none of these — a fresh `git init` carrying a README, a +// directory of loose data files — cannot satisfy either demand no matter what +// an agent does to it. There is nothing to compile and nothing to test, so +// "no build entrypoint was discoverable" and "no test entrypoint was +// discoverable" are not defects to repair; they are descriptions of an empty +// room. Reporting them as verification failures sends the run chasing a +// target that does not exist until the cost ceiling stops it, with the +// requested deliverable already sitting on disk. +// +// Recognizing a project is deliberately generous: anything here means the +// full fail-closed floor applies, so a real repository whose test command is +// merely undiscoverable still fails, which is the point of the floor. +func accountableWorkspace(workspace string) bool { + for _, marker := range []string{ + // Language and dependency manifests. + "go.mod", "Cargo.toml", "package.json", "deno.json", "deno.jsonc", + "tsconfig.json", "pom.xml", "build.gradle", "build.gradle.kts", + "gradlew", "Gemfile", "composer.json", "mix.exs", "pubspec.yaml", + // Build systems that stand in for a manifest. + "Makefile", "makefile", "GNUmakefile", "justfile", "Justfile", + "CMakeLists.txt", "meson.build", "BUILD", "BUILD.bazel", + // Test configuration implies a suite even with no manifest at all. + "pytest.ini", "tox.ini", "noxfile.py", "conftest.py", + "phpunit.xml", "phpunit.xml.dist", ".rspec", + } { + if fileExists(filepath.Join(workspace, marker)) { + return true + } + } + for _, runner := range []string{"jest", "vitest", "playwright", "karma", "cypress"} { + for _, ext := range []string{".js", ".ts", ".mjs", ".cjs", ".json"} { + if fileExists(filepath.Join(workspace, runner+".config"+ext)) { + return true + } + } + } + if hasSuffixFile(workspace, ".sln") || hasSuffixFile(workspace, ".csproj") { + return true + } + // Covers packaging metadata, Python test config, and test_*.py layouts. + if isPythonProject(workspace) { + return true + } + return hasTestDirectory(workspace) +} + +// hasTestDirectory reports a conventional test directory at the project root — +// the last signal that a suite is expected when no manifest names one. +func hasTestDirectory(workspace string) bool { + for _, dir := range []string{"tests", "test", "spec", "specs", "__tests__"} { + if info, err := os.Stat(filepath.Join(workspace, dir)); err == nil && info.IsDir() { + return true + } + } + return false +} + +// ecosystemLacksBuild reports the one ecosystem we can positively identify as +// having no build or typecheck step: a plain Python package, which has tests to +// run but nothing to compile. Every other project — including one whose +// ecosystem we do not recognize — is still required to produce a build +// entrypoint, so the gate stays fail-closed by default. +func ecosystemLacksBuild(workspace string) bool { + if isPythonProject(workspace) { + lacks := true + for _, marker := range []string{ + "go.mod", "Cargo.toml", "pom.xml", "gradlew", "package.json", "tsconfig.json", + } { + if fileExists(filepath.Join(workspace, marker)) { + lacks = false + break + } + } + if lacks { + return true + } + } + // The same carve-out the plain-Python case gets. A package.json project + // with no TypeScript config compiles nothing, so demanding a build + // entrypoint from it is unsatisfiable by construction — and an agent facing + // an unsatisfiable gate fabricates a no-op `build` script to get past it. + // Discover still overrides this the moment any build/typecheck step is + // found, from a script, CI, or the repository instructions. + if fileExists(filepath.Join(workspace, "package.json")) && + !fileExists(filepath.Join(workspace, "tsconfig.json")) && + !hasTypeScriptSources(workspace) { + for _, marker := range []string{"go.mod", "Cargo.toml", "pom.xml", "gradlew"} { + if fileExists(filepath.Join(workspace, marker)) { + return false + } + } + return true + } + return false +} + +// hasTypeScriptSources reports whether the project ships TypeScript that a +// typecheck step would be expected to cover, without walking the whole tree. +func hasTypeScriptSources(workspace string) bool { + for _, dir := range []string{".", "src", "lib", "types"} { + for _, pattern := range []string{"*.ts", "*.tsx", "*.mts", "*.cts"} { + matches, _ := filepath.Glob(filepath.Join(workspace, dir, pattern)) + if len(matches) > 0 { + return true + } + } + } + return false +} + +// sortedScriptNames gives package-script iteration a stable order; Go map +// ranging is randomized and the chosen entrypoint must not vary between runs. +func sortedScriptNames(scripts map[string]string) []string { + names := make([]string, 0, len(scripts)) + for name := range scripts { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// scriptRunsOtherScripts reports whether a package script delegates to other +// package scripts, which makes its full effect unknowable from its own body. +func scriptRunsOtherScripts(body string) bool { + return packageScriptDelegationPattern.MatchString(body) +} + +func ecosystemDefaults(workspace string) []Entrypoint { + entries := []Entrypoint{} + add := func(kind EntrypointKind, command, source string) { + entries = append(entries, Entrypoint{Kind: kind, Command: command, Source: source}) + } + switch { + case fileExists(filepath.Join(workspace, "go.mod")): + add(KindBuild, "go build ./...", "go.mod") + add(KindTest, "go test ./...", "go.mod") + case fileExists(filepath.Join(workspace, "Cargo.toml")): + add(KindBuild, "cargo build --workspace", "Cargo.toml") + add(KindTest, "cargo test --workspace", "Cargo.toml") + case fileExists(filepath.Join(workspace, "pom.xml")): + binary := "mvn" + if fileExists(filepath.Join(workspace, "mvnw")) { + binary = "./mvnw" + } + add(KindBuild, binary+" -DskipTests package", "pom.xml") + add(KindTest, binary+" test", "pom.xml") + case fileExists(filepath.Join(workspace, "gradlew")): + add(KindBuild, "./gradlew assemble", "gradlew") + add(KindTest, "./gradlew test", "gradlew") + case hasSuffixFile(workspace, ".sln") || hasSuffixFile(workspace, ".csproj"): + add(KindBuild, "dotnet build", "dotnet project") + add(KindTest, "dotnet test", "dotnet project") + case isPythonProject(workspace): + if hasPythonTests(workspace) { + add(KindTest, "python3 -m pytest", "Python test files") + } + } + return entries +} + +func appendShellCandidates(out []commandCandidate, raw, source string) []commandCandidate { + return appendShellCandidatesFrom(out, raw, "", source) +} + +func appendShellCandidatesFrom(out []commandCandidate, raw, baseWorkdir, source string) []commandCandidate { + raw = strings.TrimSpace(strings.Trim(raw, "`")) + if raw == "" || strings.Contains(raw, "${{") { + return out + } + command, inlineWorkdir := preserveLeadingWorkingDirectory(raw) + workdir := combineWorkingDirectories(baseWorkdir, inlineWorkdir) + if isBuildCommand(command) || isTestCommand(command) { + out = append(out, commandCandidate{ + command: command, workdir: workdir, source: source, + }) + } + return out +} + +func preserveLeadingWorkingDirectory(raw string) (string, string) { + command := normalizeCommand(raw) + match := leadingCDPattern.FindStringSubmatch(command) + if match == nil { + return command, "" + } + workdir := strings.Trim(match[1], "\"'") + return normalizeCommand(match[2]), workdir +} + +func combineWorkingDirectories(base, nested string) string { + if nested == "" { + return base + } + if base == "" || filepath.IsAbs(nested) { + return filepath.Clean(nested) + } + // A CI step starts in working-directory before its shell runs, so a relative + // inline cd is appended to that directory; an absolute cd replaces it, the + // same way a fresh shell process would resolve it. + return filepath.Clean(filepath.Join(base, nested)) +} + +func normalizeCommand(command string) string { + command = executableShellText(strings.TrimSpace(strings.Trim(command, "`"))) + return strings.Join(strings.Fields(command), " ") +} + +// executableShellText removes shell comments before command-name matching. +// Only commands that would actually execute count, not command-shaped prose, +// so quoted hashes remain data while an unquoted # at a shell word boundary +// hides everything through the newline. +func executableShellText(command string) string { + var out strings.Builder + singleQuoted := false + doubleQuoted := false + escaped := false + for index := 0; index < len(command); index++ { + character := command[index] + if escaped { + out.WriteByte(character) + escaped = false + continue + } + if character == '\\' && !singleQuoted { + out.WriteByte(character) + escaped = true + continue + } + if character == '\'' && !doubleQuoted { + singleQuoted = !singleQuoted + out.WriteByte(character) + continue + } + if character == '"' && !singleQuoted { + doubleQuoted = !doubleQuoted + out.WriteByte(character) + continue + } + if character == '#' && !singleQuoted && !doubleQuoted && shellCommentBoundary(command, index) { + for index < len(command) && command[index] != '\n' { + index++ + } + if index < len(command) { + out.WriteString(" ; ") + } + continue + } + if character == '\n' && !singleQuoted && !doubleQuoted { + out.WriteString(" ; ") + continue + } + out.WriteByte(character) + } + return out.String() +} + +func shellCommentBoundary(command string, index int) bool { + if index == 0 { + return true + } + previous := command[index-1] + return previous == ' ' || previous == '\t' || previous == '\r' || previous == '\n' || + strings.ContainsRune(";&|()", rune(previous)) +} + +func isBuildCommand(command string) bool { + return safeShellControlFlow(command) && buildCommandPattern.MatchString(classifiableShellText(command)) +} + +func isTestCommand(command string) bool { + return safeShellControlFlow(command) && testCommandPattern.MatchString(classifiableShellText(command)) +} + +// safeShellControlFlow admits only structures whose exit status the +// verification run can trust: simple commands, && chains, and output capture +// through tee. Alternative/sequence/background clauses can skip a classified +// tool or replace its status, so discovery fails closed on them. +func safeShellControlFlow(command string) bool { + text := classifiableShellText(command) + if strings.Contains(text, "||") || strings.Contains(text, ";") { + return false + } + for index := 0; index < len(text); index++ { + switch text[index] { + case '&': + if index+1 < len(text) && text[index+1] == '&' { + index++ + continue + } + if index > 0 && (text[index-1] == '>' || text[index-1] == '<') { + continue + } + return false + case '|': + if index+1 < len(text) && text[index+1] == '|' { + return false + } + remainder := strings.TrimSpace(text[index+1:]) + if remainder != "tee" && !strings.HasPrefix(remainder, "tee ") { + return false + } + } + } + return true +} + +// classifiableShellText keeps shell structure and unquoted command words but +// masks quoted arguments. Without this, `echo "x; go build"` looks like a +// second command even though the semicolon and build words are only echo data. +func classifiableShellText(command string) string { + command = normalizeCommand(command) + var out strings.Builder + singleQuoted := false + doubleQuoted := false + escaped := false + for index := 0; index < len(command); index++ { + character := command[index] + if escaped { + if !singleQuoted && !doubleQuoted { + out.WriteByte(character) + } + escaped = false + continue + } + if character == '\\' && !singleQuoted { + escaped = true + continue + } + if character == '\'' && !doubleQuoted { + if !singleQuoted { + out.WriteByte('Q') + } + singleQuoted = !singleQuoted + continue + } + if character == '"' && !singleQuoted { + if !doubleQuoted { + out.WriteByte('Q') + } + doubleQuoted = !doubleQuoted + continue + } + if !singleQuoted && !doubleQuoted { + out.WriteByte(character) + } + } + return out.String() +} + +func isInteractiveTestCommand(command string) bool { + command = normalizeCommand(command) + if interactiveRunnerPattern.MatchString(classifiableShellText(command)) { + return true + } + words := strings.Fields(command) + for index := 0; index < len(words); index++ { + word := strings.ToLower(strings.Trim(words[index], ";&|")) + name, value, assigned := strings.Cut(word, "=") + switch name { + case "-w": + if assigned { + if !numericFlagPattern.MatchString(value) { + return true + } + continue + } + if index+1 < len(words) && numericFlagPattern.MatchString(strings.Trim(words[index+1], ";&|")) { + index++ + continue + } + return true + case "--watch", "--watch-all", "--watchall", "--ui", "--interactive": + if assigned { + if !falseFlagValue(value) { + return true + } + continue + } + if index+1 < len(words) && falseFlagValue(strings.Trim(words[index+1], ";&|")) { + index++ + continue + } + return true + } + } + return false +} + +func falseFlagValue(value string) bool { + switch strings.ToLower(value) { + case "false", "0", "no", "off": + return true + default: + return false + } +} + +func packageManager(workspace string) string { + for _, candidate := range []struct { + file string + manager string + }{ + {"bun.lock", "bun"}, {"bun.lockb", "bun"}, + {"pnpm-lock.yaml", "pnpm"}, {"yarn.lock", "yarn"}, + } { + if fileExists(filepath.Join(workspace, candidate.file)) { + return candidate.manager + } + } + return "npm" +} + +func managerRun(manager, script string) string { + if manager == "npm" || manager == "bun" { + return manager + " run " + script + } + return manager + " " + script +} + +func managerTest(manager string) string { + if manager == "bun" { + return "bun run test" + } + return manager + " test" +} + +func readSmallFile(path string) (string, bool) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() || info.Size() > 1_000_000 { + return "", false + } + body, err := os.ReadFile(path) + return string(body), err == nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func isPythonProject(workspace string) bool { + for _, marker := range []string{ + "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "pytest.ini", "tox.ini", "noxfile.py", + } { + if fileExists(filepath.Join(workspace, marker)) { + return true + } + } + return hasPythonTests(workspace) +} + +func hasSuffixFile(workspace, suffix string) bool { + entries, err := os.ReadDir(workspace) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(strings.ToLower(entry.Name()), suffix) { + return true + } + } + return false +} + +func hasPythonTests(workspace string) bool { + for _, config := range []string{"pytest.ini", "tox.ini", "noxfile.py"} { + if fileExists(filepath.Join(workspace, config)) { + return true + } + } + found := false + _ = filepath.WalkDir(workspace, func(path string, entry os.DirEntry, err error) error { + if err != nil || found { + return filepath.SkipDir + } + if entry.IsDir() { + name := entry.Name() + if path != workspace && (strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor") { + return filepath.SkipDir + } + return nil + } + name := strings.ToLower(entry.Name()) + if strings.HasPrefix(name, "test_") && strings.HasSuffix(name, ".py") || + strings.HasSuffix(name, "_test.py") { + found = true + } + return nil + }) + return found +} diff --git a/internal/seniordev/session/fullverification/discovery_test.go b/internal/seniordev/session/fullverification/discovery_test.go new file mode 100644 index 0000000000..832b5b6f65 --- /dev/null +++ b/internal/seniordev/session/fullverification/discovery_test.go @@ -0,0 +1,598 @@ +//go:build !windows + +package fullverification + +import ( + "os" + "path/filepath" + "reflect" + "strconv" + "testing" +) + +func writeDiscoveryFile(t *testing.T, root, relative, body string) { + t.Helper() + path := filepath.Join(root, relative) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestDiscoverPrefersCIEntrypoints(t *testing.T) { + // Command discovery prefers CI over an ecosystem default. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "go.mod", "module example.test/ci\n") + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", ` +jobs: + verify: + steps: + - run: go build ./cmd/... + - run: | + go test -count=1 ./... +`) + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "go build ./cmd/...", Source: ".github/workflows/verify.yml"}, + {Kind: KindTest, Command: "go test -count=1 ./...", Source: ".github/workflows/verify.yml"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverHonorsAgentInstructionsBeforePackageScripts(t *testing.T) { + // Repository agent guidance can name the canonical commands and wins over + // generic package-script detection. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "AGENTS.md", "Run `npm run compile` and then `npm test -- --runInBand`.\n") + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"vite build","test":"vitest"}}`) + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "npm run compile", Source: "AGENTS.md"}, + {Kind: KindTest, Command: "npm test -- --runInBand", Source: "AGENTS.md"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverPackageManagerScripts(t *testing.T) { + // Manifest scripts retain the repository's package manager rather than + // assuming npm or a language-specific command. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"tsc","test":"vitest run"}}`) + writeDiscoveryFile(t, workspace, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "pnpm build", Source: "package.json#scripts.build"}, + {Kind: KindTest, Command: "pnpm test", Source: "package.json#scripts.test"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverREADMEEntrypoints(t *testing.T) { + // Documented commands remain discoverable when the repository has no CI + // or declared task-runner scripts. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "README.md", "# Development\n\n"+ + "Build with `cargo build --all-targets`.\n\n"+ + "Run the suite:\n\n```sh\ncargo test --all-targets\n```\n") + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "cargo build --all-targets", Source: "README.md"}, + {Kind: KindTest, Command: "cargo test --all-targets", Source: "README.md"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverGitLabScriptListAndUnitScript(t *testing.T) { + // List-form CI scripts and the standard `unit` script fallback are both + // recognized. + ciWorkspace := t.TempDir() + writeDiscoveryFile(t, ciWorkspace, ".gitlab-ci.yml", `verify: + script: + - cargo build --workspace + - cargo test --workspace +`) + wantCI := []Entrypoint{ + {Kind: KindBuild, Command: "cargo build --workspace", Source: ".gitlab-ci.yml"}, + {Kind: KindTest, Command: "cargo test --workspace", Source: ".gitlab-ci.yml"}, + } + if plan := Discover(ciWorkspace); !reflect.DeepEqual(plan.Entrypoints, wantCI) { + t.Fatalf("CI plan = %#v, want %#v", plan.Entrypoints, wantCI) + } + + packageWorkspace := t.TempDir() + writeDiscoveryFile(t, packageWorkspace, "package.json", `{"scripts":{"build":"tsc","unit":"vitest run"}}`) + wantPackage := []Entrypoint{ + {Kind: KindBuild, Command: "npm run build", Source: "package.json#scripts.build"}, + {Kind: KindTest, Command: "npm run unit", Source: "package.json#scripts.unit"}, + } + if plan := Discover(packageWorkspace); !reflect.DeepEqual(plan.Entrypoints, wantPackage) { + t.Fatalf("package plan = %#v, want %#v", plan.Entrypoints, wantPackage) + } +} + +func TestDiscoverGoFullEntrypointsAndEmptyFallback(t *testing.T) { + // Go is one ecosystem fallback among several, and a repository with no + // discoverable convention does not invent go test. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "go.mod", "module example.test/default\n") + plan := Discover(workspace) + want := []Entrypoint{ + {Kind: KindBuild, Command: "go build ./...", Source: "go.mod"}, + {Kind: KindTest, Command: "go test ./...", Source: "go.mod"}, + } + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("go plan = %#v, want %#v", plan.Entrypoints, want) + } + if empty := Discover(t.TempDir()); len(empty.Entrypoints) != 0 { + t.Fatalf("empty repository plan = %#v", empty.Entrypoints) + } +} + +func TestDiscoverRejectsWatcherScriptsAndFallsThrough(t *testing.T) { + // A manifest key is not usable evidence when its body starts an + // interactive runner. A later non-watcher candidate wins, while a + // watcher-only manifest yields no test entrypoint. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{ + "scripts": { + "build": "tsc", + "test": "vitest --watch", + "unit": "vitest run" + } +}`) + want := []Entrypoint{ + {Kind: KindBuild, Command: "npm run build", Source: "package.json#scripts.build"}, + {Kind: KindTest, Command: "npm run unit", Source: "package.json#scripts.unit"}, + } + if plan := Discover(workspace); !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("watcher fallback plan = %#v, want %#v", plan.Entrypoints, want) + } + + watcherOnly := t.TempDir() + writeDiscoveryFile(t, watcherOnly, "package.json", `{"scripts":{"build":"tsc","test":"vitest --ui"}}`) + want = []Entrypoint{ + {Kind: KindBuild, Command: "npm run build", Source: "package.json#scripts.build"}, + } + if plan := Discover(watcherOnly); !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("watcher-only plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverPreservesCIWorkingDirectoryAndCommandChain(t *testing.T) { + // Extracting a recognized command from a CI chain must retain its leading + // cd and every remaining shell step. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", ` +jobs: + verify: + steps: + - run: cd frontend && npm ci && npm run build && npm test +`) + want := []Entrypoint{ + { + Kind: KindBuild, Command: "npm ci && npm run build && npm test", + Workdir: "frontend", Source: ".github/workflows/verify.yml", + }, + { + Kind: KindTest, Command: "npm ci && npm run build && npm test", + Workdir: "frontend", Source: ".github/workflows/verify.yml", + }, + } + if plan := Discover(workspace); !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("CI chain plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +func TestCommandClassificationUsesExecutableShellText(t *testing.T) { + // Words in comments and echo arguments are not process evidence, while + // genuine build and test invocations remain discoverable. + tests := []struct { + name string + command string + build bool + test bool + }{ + {name: "true with test comment", command: "true # pytest", test: false}, + {name: "colon with test comment", command: ": # npm test", test: false}, + {name: "echo quoted build", command: `echo "go build ./..."`, build: false}, + {name: "echo quoted shell clause", command: `echo "ignored; go build ./..."`, build: false}, + {name: "echo unquoted test", command: "echo go test ./...", test: false}, + {name: "comment after real build", command: "go build ./... # pytest", build: true, test: false}, + {name: "quoted hash is argument", command: `pytest -k '# smoke'`, test: true}, + {name: "real test", command: "go test ./...", test: true}, + {name: "masked failing test", command: "go test ./... || true", test: false}, + {name: "skipped test after true", command: "true || go test ./...", test: false}, + {name: "skipped test after exit", command: "exit 0; go test ./...", test: false}, + {name: "preceding true clause", command: "true; go test ./...", test: false}, + {name: "unguarded pipe", command: "go test ./... | cat", test: false}, + {name: "backgrounded test", command: "go test ./... & true", test: false}, + {name: "tee pipeline", command: "go test ./... 2>&1 | tee test.log", test: true}, + {name: "leading cd chain", command: "cd frontend && go test ./...", test: true}, + {name: "build and test chain", command: "go build ./... && go test ./...", build: true, test: true}, + {name: "environment assignment", command: "CGO_ENABLED=0 go test ./...", test: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isBuildCommand(test.command); got != test.build { + t.Fatalf("isBuildCommand(%q) = %t, want %t", test.command, got, test.build) + } + if got := isTestCommand(test.command); got != test.test { + t.Fatalf("isTestCommand(%q) = %t, want %t", test.command, got, test.test) + } + }) + } +} + +func TestDiscoverPythonTypechecksAsBuildEntrypoints(t *testing.T) { + // Each explicit compile/typecheck command satisfies the mandatory build + // role. + commands := []string{ + "mypy src", + "pyright", + "ruff check .", + "tsc --noEmit", + "python3 -m compileall src", + } + for _, command := range commands { + t.Run(command, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "pyproject.toml", "[project]\nname = \"demo\"\n") + writeDiscoveryFile(t, workspace, "AGENTS.md", "Run `"+command+"`.\n") + plan := Discover(workspace) + want := Entrypoint{Kind: KindBuild, Command: command, Source: "AGENTS.md"} + if len(plan.Entrypoints) != 1 || plan.Entrypoints[0] != want { + t.Fatalf("plan = %#v, want build %#v", plan, want) + } + if !plan.BuildExpected { + t.Fatal("explicit Python typecheck did not make build evidence mandatory") + } + }) + } +} + +func TestPlainPythonBuildExemptionDependsOnDiscoveredTypecheck(t *testing.T) { + // The Python carve-out applies only when discovery found no explicit + // compile/typecheck step anywhere. + plain := t.TempDir() + writeDiscoveryFile(t, plain, "pyproject.toml", "[project]\nname = \"plain\"\n") + writeDiscoveryFile(t, plain, "test_demo.py", "def test_green():\n assert True\n") + if plan := Discover(plain); plan.BuildExpected || planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("plain Python plan = %#v, want test-only exemption", plan) + } + + typed := t.TempDir() + writeDiscoveryFile(t, typed, "pyproject.toml", "[project]\nname = \"typed\"\n") + writeDiscoveryFile(t, typed, "AGENTS.md", "Run `mypy src` and `python3 -m unittest`.\n") + if plan := Discover(typed); !plan.BuildExpected || !planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("typed Python plan = %#v, want required build", plan) + } +} + +func TestDiscoverPlainPythonWithoutPackagingMetadata(t *testing.T) { + // Test/config markers identify plain Python repositories even without + // packaging metadata, while the established metadata path remains. + for _, test := range []struct { + name string + marker string + }{ + {name: "requirements and pytest config", marker: "requirements.txt"}, + {name: "pytest config", marker: "pytest.ini"}, + {name: "tox config", marker: "tox.ini"}, + {name: "test layout only", marker: "tests/test_example.py"}, + {name: "packaging metadata", marker: "pyproject.toml"}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, test.marker, "# marker\n") + if test.marker != "tests/test_example.py" { + writeDiscoveryFile(t, workspace, "tests/test_example.py", "def test_green():\n assert True\n") + } + plan := Discover(workspace) + want := []Entrypoint{{Kind: KindTest, Command: "python3 -m pytest", Source: "Python test files"}} + if plan.BuildExpected || !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plain Python plan = %#v, want %#v with no build", plan, want) + } + }) + } +} + +func TestInteractiveTestFlagsRespectTheirMeaningAndValue(t *testing.T) { + // Jest's -w means workers, and an explicit false watch value is + // non-interactive; modes that actually wait for a user remain unusable. + tests := []struct { + command string + interactive bool + }{ + {command: "jest -w 1"}, + {command: "jest -w=2"}, + {command: "jest --maxWorkers=2"}, + {command: "vitest -w", interactive: true}, + {command: "jest -w", interactive: true}, + {command: "vitest --watch=false"}, + {command: "vitest --watch false"}, + {command: "vitest --watch", interactive: true}, + {command: "jest --watch", interactive: true}, + {command: "vitest --ui", interactive: true}, + {command: "cypress open", interactive: true}, + } + for _, test := range tests { + t.Run(test.command, func(t *testing.T) { + if got := isInteractiveTestCommand(test.command); got != test.interactive { + t.Fatalf("isInteractiveTestCommand(%q) = %t, want %t", test.command, got, test.interactive) + } + }) + } +} + +func TestDiscoverAcceptsNonInteractiveWatchLikeScripts(t *testing.T) { + // The package-script path, where watcher filtering happens before the + // generated npm command is classified. + for _, body := range []string{"jest -w 1", "jest -w=2", "jest --maxWorkers=2", "vitest --watch=false", "vitest --watch false"} { + t.Run(body, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"tsc","test":`+strconv.Quote(body)+`}}`) + if plan := Discover(workspace); !planHasEntrypointKind(plan, KindTest) { + t.Fatalf("plan = %#v, want usable test script", plan) + } + }) + } +} + +func TestDiscoverRejectsBareShortWatchFlagAndFallsThrough(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", `{"scripts":{"build":"tsc","test":"vitest -w","unit":"vitest run"}}`) + plan := Discover(workspace) + want := Entrypoint{Kind: KindTest, Command: "npm run unit", Source: "package.json#scripts.unit"} + if len(plan.Entrypoints) != 2 || plan.Entrypoints[1] != want { + t.Fatalf("short-watch plan = %#v, want fallback %#v", plan.Entrypoints, want) + } +} + +func TestDiscoverCIWorkingDirectoryForms(t *testing.T) { + tests := []struct { + name string + workflow string + workdir string + }{ + { + name: "step field", + workflow: `jobs: + verify: + steps: + - run: go test ./... + working-directory: frontend +`, + workdir: "frontend", + }, + { + name: "multiline leading cd", + workflow: `jobs: + verify: + steps: + - run: | + cd frontend + go test ./... +`, + workdir: "frontend", + }, + { + name: "step field then inline cd", + workflow: `jobs: + verify: + steps: + - working-directory: packages + run: cd frontend && go test ./... +`, + workdir: filepath.Join("packages", "frontend"), + }, + { + name: "workflow defaults", + workflow: `defaults: + run: + working-directory: frontend +jobs: + verify: + steps: + - run: go test ./... +`, + workdir: "frontend", + }, + { + name: "job defaults override workflow", + workflow: `defaults: + run: + working-directory: ignored +jobs: + verify: + defaults: + run: + working-directory: frontend + steps: + - run: go test ./... +`, + workdir: "frontend", + }, + { + name: "multiline cd after benign setup", + workflow: `jobs: + verify: + steps: + - run: | + set -e + export MODE=ci + # setup complete + cd frontend + go test ./... +`, + workdir: "frontend", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", test.workflow) + plan := Discover(workspace) + want := []Entrypoint{{ + Kind: KindTest, Command: "go test ./...", Workdir: test.workdir, + Source: ".github/workflows/verify.yml", + }} + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("plan = %#v, want %#v", plan.Entrypoints, want) + } + }) + } +} + +func TestDiscoverJoinsCIContinuationsAndSkipsHeredocBodies(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, ".github/workflows/verify.yml", `jobs: + verify: + steps: + - run: | + cmake \ + --build build + python3 <<'PY' + go test ./... + PY +`) + plan := Discover(workspace) + want := []Entrypoint{{ + Kind: KindBuild, Command: "cmake --build build", Source: ".github/workflows/verify.yml", + }} + if !reflect.DeepEqual(plan.Entrypoints, want) { + t.Fatalf("multiline CI plan = %#v, want %#v", plan.Entrypoints, want) + } +} + +// TestUnaccountableWorkspaceExpectsNothing: a workspace with no project in it +// demands neither role. Both demands are unsatisfiable there — nothing to +// compile, nothing to test — and reporting them as failures sends the run +// chasing a target that does not exist. +func TestUnaccountableWorkspaceExpectsNothing(t *testing.T) { + for _, test := range []struct { + name string + files map[string]string + }{ + {name: "empty", files: map[string]string{}}, + {name: "readme only", files: map[string]string{"README.md": "# demo\n"}}, + {name: "loose data files", files: map[string]string{ + "notes.txt": "hello\n", "data.csv": "a,b\n1,2\n", + }}, + // A shell script whose name starts with "test" is not a Python test + // file and not a suite. + {name: "loose shell script", files: map[string]string{ + "README.md": "# demo\n", "test_hello.sh": "#!/bin/sh\nexit 0\n", + }}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + for name, body := range test.files { + writeDiscoveryFile(t, workspace, name, body) + } + plan := Discover(workspace) + if plan.BuildExpected || plan.TestExpected || len(plan.Entrypoints) != 0 { + t.Fatalf("unaccountable plan = %#v, want no demands and no entrypoints", plan) + } + }) + } +} + +// TestAccountableWorkspaceExpectsBothRoles: the moment a workspace looks like a +// project, the fail-closed floor applies. A real repository whose test +// command is merely undiscoverable must still fail — that strictness is the +// point of the gate. +func TestAccountableWorkspaceExpectsBothRoles(t *testing.T) { + for _, test := range []struct { + name string + files map[string]string + wantBuildExpected bool + }{ + {name: "typescript config", files: map[string]string{ + "tsconfig.json": `{"compilerOptions":{"strict":true}}`, + }, wantBuildExpected: true}, + {name: "makefile without either target", files: map[string]string{ + "Makefile": "lint:\n\techo lint\n", + }, wantBuildExpected: true}, + {name: "test directory alone", files: map[string]string{ + "spec/example_spec.rb": "# spec\n", + }, wantBuildExpected: true}, + // A plain-Python project keeps its build carve-out but is still held to + // a test entrypoint, which pytest markers here do not supply. + {name: "python packaging without tests", files: map[string]string{ + "pyproject.toml": "[project]\nname = \"demo\"\n", + }, wantBuildExpected: false}, + } { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + for name, body := range test.files { + writeDiscoveryFile(t, workspace, name, body) + } + plan := Discover(workspace) + if !plan.TestExpected { + t.Errorf("plan = %#v, want TestExpected", plan) + } + if plan.BuildExpected != test.wantBuildExpected { + t.Errorf("plan = %#v, want BuildExpected=%v", plan, test.wantBuildExpected) + } + if planHasEntrypointKind(plan, KindTest) { + t.Errorf("plan = %#v, want no discoverable test entrypoint in this fixture", plan) + } + }) + } +} + +// TestDiscoveredEntrypointsMakeAWorkspaceAccountable: a documented command is +// itself proof that a project is here, whatever its shape. This is the path +// that keeps an unrecognized-ecosystem repository fail-closed. +func TestDiscoveredEntrypointsMakeAWorkspaceAccountable(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "checks/check_green.py", "# not a pytest layout\n") + writeDiscoveryFile(t, workspace, "AGENTS.md", + "Run `python3 -m unittest discover -s checks -p 'check_*.py'`.\n") + plan := Discover(workspace) + if !planHasEntrypointKind(plan, KindTest) { + t.Fatalf("plan = %#v, want the documented test command discovered", plan) + } + if !plan.BuildExpected || !plan.TestExpected { + t.Fatalf("plan = %#v, want both demands once a command was discovered", plan) + } +} + +// TestGoAndJSProjectsKeepTheirDiscoveredEntrypoints pins that the common +// ecosystems are both demanded and discovered. +func TestGoAndJSProjectsKeepTheirDiscoveredEntrypoints(t *testing.T) { + goWorkspace := t.TempDir() + writeDiscoveryFile(t, goWorkspace, "go.mod", "module example.test/demo\n") + writeDiscoveryFile(t, goWorkspace, "demo_test.go", "package demo\n") + plan := Discover(goWorkspace) + if !plan.BuildExpected || !plan.TestExpected || + !planHasEntrypointKind(plan, KindTest) || !planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("go plan = %#v, want both demanded and both discovered", plan) + } + + jsWorkspace := t.TempDir() + writeDiscoveryFile(t, jsWorkspace, "package.json", `{"scripts":{"test":"vitest run"}}`) + plan = Discover(jsWorkspace) + if !plan.TestExpected || !planHasEntrypointKind(plan, KindTest) { + t.Fatalf("js plan = %#v, want a discovered test entrypoint", plan) + } +} + +func planHasEntrypointKind(plan Plan, kind EntrypointKind) bool { + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind == kind { + return true + } + } + return false +} diff --git a/internal/seniordev/session/fullverification/noop_evidence_test.go b/internal/seniordev/session/fullverification/noop_evidence_test.go new file mode 100644 index 0000000000..e64ec165eb --- /dev/null +++ b/internal/seniordev/session/fullverification/noop_evidence_test.go @@ -0,0 +1,44 @@ +//go:build !windows + +package fullverification + +import "testing" + +// A command whose executed portion does nothing must never be accepted as +// build or test evidence, whatever a trailing comment claims: `true # pytest` +// exits 0 and runs no tests, and a substring classifier that saw "pytest" +// would record a green suite. +func TestNoOpCommandsAreNeverEvidence(t *testing.T) { + for _, command := range []string{ + "true # pytest", + "true # npm test", + ": # go test ./...", + `echo "go build"`, + "echo go test ./...", + "true # go build ./...", + " true # cargo test ", + } { + if isTestCommand(command) { + t.Errorf("no-op accepted as TEST evidence: %q", command) + } + if isBuildCommand(command) { + t.Errorf("no-op accepted as BUILD evidence: %q", command) + } + } + // Real commands must still classify, including with a trailing comment. + for _, command := range []string{ + "go test ./...", + "go test ./... # run the suite", + "pytest -q", + "npm test", + } { + if !isTestCommand(command) { + t.Errorf("real test command rejected: %q", command) + } + } + for _, command := range []string{"go build ./...", "npm run build", "tsc --noEmit"} { + if !isBuildCommand(command) { + t.Errorf("real build command rejected: %q", command) + } + } +} diff --git a/internal/seniordev/session/fullverification/script_body_discovery_test.go b/internal/seniordev/session/fullverification/script_body_discovery_test.go new file mode 100644 index 0000000000..090ec9cc3f --- /dev/null +++ b/internal/seniordev/session/fullverification/script_body_discovery_test.go @@ -0,0 +1,141 @@ +//go:build !windows + +package fullverification + +import "testing" + +// Build/typecheck discovery from script bodies. A project may declare real +// `tsc` typecheck scripts under its own names (check:type:ts, check:type:js); +// if discovery matched only the three blessed NAMES, the gate would report +// that no build/typecheck entrypoint was discoverable while `npm test` exited +// 0, and the agent's rational way out would be to fabricate a no-op `build` +// script that upstream would never merge. The contract: +// +// - a compile/typecheck script is discoverable under ANY name, judged by what +// its body runs; +// - a script that delegates to other package scripts is never chosen, since +// its full effect is unknowable from its own body (it may publish); +// - the blessed names still win when present, so existing plans do not move; +// - a project that genuinely compiles nothing is not asked for a build at all. +func TestBuildDiscoveryFromScriptBodies(t *testing.T) { + manifest := func(scripts string) string { + return "{\n \"name\": \"demo\",\n \"scripts\": {\n" + scripts + "\n }\n}\n" + } + + t.Run("finds a tsc typecheck declared under a project-specific name", func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "check:type": "npm run check:type:js && npm run check:type:ts", + "check:type:ts": "tsd && tsc -p tsconfig.ts.json", + "check:type:js": "tsc -p tsconfig.js.json", + "check:lint": "eslint .", + "test": "jest"`)) + writeDiscoveryFile(t, workspace, "tsconfig.json", "{}\n") + + plan := Discover(workspace) + if !planHasEntrypointKind(plan, KindBuild) { + t.Fatalf("no build entrypoint; gate would be unsatisfiable. plan = %#v", plan) + } + for _, entrypoint := range plan.Entrypoints { + if entrypoint.Kind != KindBuild { + continue + } + if entrypoint.Command != "npm run check:type:js" { + t.Errorf("build command = %q, want %q", entrypoint.Command, "npm run check:type:js") + } + if entrypoint.Source != "package.json#scripts.check:type:js" { + t.Errorf("build source = %q", entrypoint.Source) + } + } + }) + + t.Run("never picks a script that delegates to other package scripts", func(t *testing.T) { + // `release` bundles a compile with a publish. Running it to satisfy a + // verification gate would push a package to the registry. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "release": "tsc -p tsconfig.json && npm publish", + "test": "jest"`)) + writeDiscoveryFile(t, workspace, "tsconfig.json", "{}\n") + + for _, entrypoint := range Discover(workspace).Entrypoints { + if entrypoint.Kind == KindBuild { + t.Fatalf("chose a delegating script as the build entrypoint: %#v", entrypoint) + } + } + }) + + t.Run("blessed names still win so existing plans do not move", func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "build": "rollup -c", + "check:type:js": "tsc -p tsconfig.js.json", + "test": "jest"`)) + + for _, entrypoint := range Discover(workspace).Entrypoints { + if entrypoint.Kind != KindBuild { + continue + } + if entrypoint.Command != "npm run build" { + t.Errorf("build command = %q, want the blessed %q", entrypoint.Command, "npm run build") + } + } + }) + + t.Run("a script name is chosen deterministically", func(t *testing.T) { + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", manifest( + ` "zeta:types": "tsc -p tsconfig.zeta.json", + "alpha:types": "tsc -p tsconfig.alpha.json", + "test": "jest"`)) + writeDiscoveryFile(t, workspace, "tsconfig.json", "{}\n") + + first := "" + for run := 0; run < 8; run++ { + got := "" + for _, entrypoint := range Discover(workspace).Entrypoints { + if entrypoint.Kind == KindBuild { + got = entrypoint.Command + } + } + if run == 0 { + first = got + } else if got != first { + t.Fatalf("discovery is not deterministic: %q then %q", first, got) + } + } + if first != "npm run alpha:types" { + t.Errorf("build command = %q, want the name-sorted %q", first, "npm run alpha:types") + } + }) +} + +func TestBuildlessJavaScriptProjectIsNotAskedForABuild(t *testing.T) { + // Plain JS with nothing to compile is the JS twin of the plain-Python + // carve-out: no tsconfig, no TypeScript sources, no compile script. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", + "{\n \"name\": \"plain\",\n \"scripts\": { \"test\": \"jest\" }\n}\n") + writeDiscoveryFile(t, workspace, "index.js", "module.exports = 1\n") + + plan := Discover(workspace) + if plan.BuildExpected { + t.Errorf("BuildExpected = true for a project that compiles nothing; the gate "+ + "is unsatisfiable and invites a fabricated build script. plan = %#v", plan) + } + if !planHasEntrypointKind(plan, KindTest) { + t.Errorf("lost the test entrypoint: %#v", plan) + } +} + +func TestTypeScriptProjectStillOwesABuild(t *testing.T) { + // The carve-out must not swallow projects that really do compile. + workspace := t.TempDir() + writeDiscoveryFile(t, workspace, "package.json", + "{\n \"name\": \"typed\",\n \"scripts\": { \"test\": \"jest\" }\n}\n") + writeDiscoveryFile(t, workspace, "src/index.ts", "export const x = 1\n") + + if plan := Discover(workspace); !plan.BuildExpected { + t.Errorf("BuildExpected = false for a TypeScript project: %#v", plan) + } +} diff --git a/internal/seniordev/session/instruction/instruction.go b/internal/seniordev/session/instruction/instruction.go new file mode 100644 index 0000000000..16c1041844 --- /dev/null +++ b/internal/seniordev/session/instruction/instruction.go @@ -0,0 +1,500 @@ +//go:build !windows + +// Package instruction discovers the project's instruction files (AGENTS.md +// and the files named in config, local or remote) and tracks which ones a +// session has already loaded, so each is injected into the prompt once. +package instruction + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" +) + +type OrderedSet struct { + keys []string + set map[string]struct{} +} + +func NewOrderedSet() *OrderedSet { + return &OrderedSet{set: make(map[string]struct{})} +} + +func (set *OrderedSet) Add(value string) { + if _, exists := set.set[value]; exists { + return + } + set.set[value] = struct{}{} + set.keys = append(set.keys, value) +} + +func (set *OrderedSet) Has(value string) bool { + _, exists := set.set[value] + return exists +} + +func (set *OrderedSet) Values() []string { + return append([]string{}, set.keys...) +} + +func Loaded(messages []msgmodel.WithParts) *OrderedSet { + paths := NewOrderedSet() + for _, message := range messages { + for _, part := range message.Parts { + tool, ok := part.(msgmodel.ToolPart) + if !ok || tool.Tool != "read" { + continue + } + completed, ok := tool.State.(msgmodel.ToolStateCompleted) + if !ok || completed.Time.Compacted != nil && *completed.Time.Compacted != 0 { + continue + } + raw, ok := completed.Metadata.Field("loaded") + if !ok { + continue + } + var loaded []json.RawMessage + if json.Unmarshal(raw, &loaded) != nil || loaded == nil { + continue + } + for _, item := range loaded { + trimmed := strings.TrimSpace(string(item)) + if len(trimmed) == 0 || trimmed[0] != '"' { + continue + } + var path string + if json.Unmarshal(item, &path) == nil { + paths.Add(path) + } + } + } + } + return paths +} + +type Config struct { + Instructions []string +} + +type Global struct { + Config string + Home string +} + +type Instance struct { + Directory string + Worktree string +} + +type Flags struct { + DisableClaudeCodePrompt bool + DisableProjectConfig bool +} + +type FileSystem interface { + ExistsSafe(path string) bool + ReadFileString(path string) (string, error) + FindUp(target, start, stop string) ([]string, error) + GlobUp(pattern, start, stop string) ([]string, error) + Glob(pattern, cwd string) ([]string, error) +} + +type HTTPClient interface { + Fetch(ctx context.Context, url string) ([]byte, error) +} + +type Options struct { + Config Config + Global Global + Instance Instance + Flags Flags + FS FileSystem + HTTP HTTPClient +} + +type Service struct { + options Options + files []string + + mu sync.Mutex + claims map[string]*OrderedSet +} + +func New(options Options) *Service { + if options.FS == nil { + options.FS = OSFileSystem{} + } + if options.HTTP == nil { + options.HTTP = &DefaultHTTPClient{} + } + files := []string{"AGENTS.md"} + if !options.Flags.DisableClaudeCodePrompt { + files = append(files, "CLAUDE.md") + } + files = append(files, "CONTEXT.md") + return &Service{ + options: options, files: files, claims: make(map[string]*OrderedSet), + } +} + +func (service *Service) Clear(messageID string) { + service.mu.Lock() + delete(service.claims, messageID) + service.mu.Unlock() +} + +func (service *Service) SystemPaths() *OrderedSet { + paths := NewOrderedSet() + globalFiles := []string{filepath.Join(service.options.Global.Config, "AGENTS.md")} + if !service.options.Flags.DisableClaudeCodePrompt { + globalFiles = append(globalFiles, filepath.Join( + service.options.Global.Home, ".claude", "CLAUDE.md", + )) + } + for _, file := range globalFiles { + if service.options.FS.ExistsSafe(file) { + paths.Add(resolve(file)) + break + } + } + + if !service.options.Flags.DisableProjectConfig { + for _, file := range service.files { + matches, _ := service.options.FS.FindUp( + file, service.options.Instance.Directory, service.options.Instance.Worktree, + ) + if len(matches) > 0 { + for _, item := range matches { + paths.Add(resolve(item)) + } + break + } + } + } + + for _, raw := range service.options.Config.Instructions { + if isURL(raw) { + continue + } + instruction := raw + if strings.HasPrefix(raw, "~/") { + instruction = filepath.Join(service.options.Global.Home, raw[2:]) + } + var matches []string + if filepath.IsAbs(instruction) { + matches, _ = service.options.FS.Glob( + filepath.Base(instruction), filepath.Dir(instruction), + ) + } else if !service.options.Flags.DisableProjectConfig { + matches, _ = service.options.FS.GlobUp( + instruction, service.options.Instance.Directory, + service.options.Instance.Worktree, + ) + } else { + matches, _ = service.options.FS.GlobUp( + instruction, service.options.Global.Config, + service.options.Global.Config, + ) + } + for _, item := range matches { + paths.Add(resolve(item)) + } + } + return paths +} + +func (service *Service) System(ctx context.Context) []string { + paths := service.SystemPaths().Values() + urls := []string{} + for _, item := range service.options.Config.Instructions { + if isURL(item) { + urls = append(urls, item) + } + } + files := parallelStrings(len(paths), 8, func(index int) string { + content, err := service.options.FS.ReadFileString(paths[index]) + if err != nil { + return "" + } + return content + }) + remote := parallelStrings(len(urls), 4, func(index int) string { + callCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + body, err := service.options.HTTP.Fetch(callCtx, urls[index]) + if err != nil { + return "" + } + return strings.ToValidUTF8(string(body), "\uFFFD") + }) + out := []string{} + for index, path := range paths { + if files[index] != "" { + out = append(out, "Instructions from: "+path+"\n"+files[index]) + } + } + for index, url := range urls { + if remote[index] != "" { + out = append(out, "Instructions from: "+url+"\n"+remote[index]) + } + } + return out +} + +func (service *Service) Find(dir string) *string { + for _, file := range service.files { + path := resolve(filepath.Join(dir, file)) + if service.options.FS.ExistsSafe(path) { + return &path + } + } + return nil +} + +type Resolved struct { + Filepath string `json:"filepath"` + Content string `json:"content"` +} + +func (service *Service) Resolve( + messages []msgmodel.WithParts, file string, messageID string, +) []Resolved { + system := service.SystemPaths() + already := Loaded(messages) + results := []Resolved{} + root := resolve(service.options.Instance.Directory) + target := resolve(file) + current := filepath.Dir(target) + for strings.HasPrefix(current, root) && current != root { + found := service.Find(current) + if found == nil || *found == target || system.Has(*found) || already.Has(*found) { + current = filepath.Dir(current) + continue + } + + service.mu.Lock() + claimed := service.claims[messageID] + if claimed == nil { + claimed = NewOrderedSet() + service.claims[messageID] = claimed + } + if claimed.Has(*found) { + service.mu.Unlock() + current = filepath.Dir(current) + continue + } + claimed.Add(*found) + service.mu.Unlock() + + content, err := service.options.FS.ReadFileString(*found) + if err == nil && content != "" { + results = append(results, Resolved{ + Filepath: *found, + Content: "Instructions from: " + *found + "\n" + content, + }) + } + current = filepath.Dir(current) + } + return results +} + +func parallelStrings(count, limit int, work func(int) string) []string { + out := make([]string, count) + if count == 0 { + return out + } + semaphore := make(chan struct{}, limit) + var group sync.WaitGroup + for index := range count { + group.Add(1) + go func(index int) { + defer group.Done() + semaphore <- struct{}{} + out[index] = work(index) + <-semaphore + }(index) + } + group.Wait() + return out +} + +func isURL(value string) bool { + return strings.HasPrefix(value, "https://") || + strings.HasPrefix(value, "http://") +} + +func resolve(path string) string { + value, err := filepath.Abs(path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(value) +} + +type OSFileSystem struct{} + +func (OSFileSystem) ExistsSafe(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func (OSFileSystem) ReadFileString(path string) (string, error) { + data, err := os.ReadFile(path) + return strings.ToValidUTF8(string(data), "\uFFFD"), err +} + +func (filesystem OSFileSystem) FindUp( + target, start, stop string, +) ([]string, error) { + result := []string{} + current := start + for { + search := filepath.Join(current, target) + if filesystem.ExistsSafe(search) { + result = append(result, search) + } + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result, nil +} + +func (filesystem OSFileSystem) GlobUp( + pattern, start, stop string, +) ([]string, error) { + result := []string{} + current := start + for { + matches, _ := filesystem.Glob(pattern, current) + result = append(result, matches...) + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result, nil +} + +func (OSFileSystem) Glob(pattern, cwd string) ([]string, error) { + re, err := globRegexp(filepath.ToSlash(pattern)) + if err != nil { + return nil, err + } + result := []string{} + err = filepath.WalkDir(cwd, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(cwd, path) + if err != nil { + return err + } + if re.MatchString(filepath.ToSlash(relative)) { + result = append(result, resolve(path)) + } + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return result, err +} + +func globRegexp(pattern string) (*regexp.Regexp, error) { + var out strings.Builder + out.WriteString("^") + for index := 0; index < len(pattern); { + switch pattern[index] { + case '*': + if index+1 < len(pattern) && pattern[index+1] == '*' { + index += 2 + if index < len(pattern) && pattern[index] == '/' { + index++ + out.WriteString("(?:.*/)?") + } else { + out.WriteString(".*") + } + } else { + index++ + out.WriteString("[^/]*") + } + case '?': + index++ + out.WriteString("[^/]") + default: + out.WriteString(regexp.QuoteMeta(string(pattern[index]))) + index++ + } + } + out.WriteString("$") + return regexp.Compile(out.String()) +} + +type DefaultHTTPClient struct { + Client *http.Client +} + +func (client *DefaultHTTPClient) Fetch( + ctx context.Context, url string, +) ([]byte, error) { + httpClient := client.Client + if httpClient == nil { + httpClient = http.DefaultClient + } + var last error + for attempt := 0; attempt < 3; attempt++ { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + response, err := httpClient.Do(request) + if err == nil && response.StatusCode >= 200 && response.StatusCode < 300 { + data, readErr := io.ReadAll(response.Body) + _ = response.Body.Close() + return data, readErr + } + transient := err != nil + if response != nil { + _ = response.Body.Close() + err = errors.New(response.Status) + transient = response.StatusCode == http.StatusRequestTimeout || + response.StatusCode == http.StatusTooManyRequests || + response.StatusCode >= 500 + } + last = err + if !transient { + return nil, last + } + if attempt < 2 { + select { + case <-ctx.Done(): + return nil, context.Cause(ctx) + case <-time.After(time.Duration(200*(1<<attempt)) * time.Millisecond): + } + } + } + return nil, last +} diff --git a/internal/seniordev/session/llmcall/cancellation_test.go b/internal/seniordev/session/llmcall/cancellation_test.go new file mode 100644 index 0000000000..52f872eaf5 --- /dev/null +++ b/internal/seniordev/session/llmcall/cancellation_test.go @@ -0,0 +1,74 @@ +//go:build !windows + +package llmcall + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestWorkDeadlineLeavesLandingRouteImmediatelyUsable(t *testing.T) { + now := 1000000.0 + t.Cleanup(adaptive.SetClockForTesting(func() float64 { return now })) + sleeps := 0 + t.Cleanup(adaptive.SetSleeperForTesting(func(ms float64, signal *adaptive.AbortSignal) { sleeps++; now += ms })) + var events []adaptive.AdaptiveRouteEvent + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{{ID: "openrouter/moonshotai/kimi-k3"}}, + OnEvent: func(e adaptive.AdaptiveRouteEvent) { events = append(events, e) }, + }) + work, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + workCause := errors.New("work budget exhausted") + fetches := 0 + service := &Service{Router: router, Clients: ClientFactoryFunc(func(_ context.Context, _ Model, choice *adaptive.RouteChoice, r *adaptive.AdaptiveModelRouter) (StreamClient, error) { + return concreteClient{client: &orclient.Client{BaseURL: "http://model-api.invalid/v1", Router: r, RouteChoice: choice, + Fetcher: func(request *http.Request) (*http.Response, error) { + fetches++ + if fetches == 1 { + cancel(workCause) + return nil, request.Context().Err() + } + if request.Context().Err() != nil { + t.Fatal("landing inherited expired work context") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n"))}, nil + }}}, nil + })} + input := StreamInput{Model: Model{ProviderID: "openrouter", ID: "moonshotai/kimi-k3"}, Agent: Agent{Name: "coder"}} + if _, err := service.Stream(work, input); !errors.Is(err, context.Canceled) { + t.Fatalf("work err=%v", err) + } + if len(events) != 1 || events[0].Reason != "caller-canceled-request" || events[0].Attempts != 0 || events[0].Successes != 0 || events[0].Failures != 0 { + t.Fatalf("caller cancellation credited/penalized provider: %+v", events) + } + stream, err := service.Stream(context.Background(), input) + if err != nil { + t.Fatal(err) + } + for { + _, err = stream.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + } + if err = stream.Close(); err != nil { + t.Fatal(err) + } + if sleeps != 0 || fetches != 2 || now != 1000000 { + t.Fatalf("landing lost time: sleeps=%d fetches=%d elapsed=%g", sleeps, fetches, now-1000000) + } + if len(events) != 2 || events[1].Attempts != 1 || events[1].Successes != 1 || events[1].Failures != 0 { + t.Fatalf("normal accounting changed: %+v", events) + } +} diff --git a/internal/seniordev/session/llmcall/llmcall.go b/internal/seniordev/session/llmcall/llmcall.go new file mode 100644 index 0000000000..4f1bdc8471 --- /dev/null +++ b/internal/seniordev/session/llmcall/llmcall.go @@ -0,0 +1,312 @@ +//go:build !windows + +// Package llmcall resolves adaptive-router choices and assembles one +// OpenRouter call. Recovery and multi-turn behavior belong to the caller and +// steploop. +package llmcall + +import ( + "context" + "encoding/json" + "errors" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" + "github.com/Agent-Field/codeaf/internal/seniordev/router/state" +) + +const OutputTokenMax = 32_000 + +type Agent struct { + Name string + Mode string + // Tier is the model pool this agent routes on. Empty means high. + Tier adaptive.ModelTier +} + +type Model struct { + ProviderID string + ID string + APIID string + Params orclient.RequestParams +} + +type StreamInput struct { + UserID string + SessionID string + ParentSessionID string + Model Model + Agent Agent + System []string + Messages []msgmodel.ModelMessage + Tools []orclient.Tool + DisabledTools map[string]bool + UserTools map[string]bool + ToolChoice *orclient.ToolChoice +} + +type ModelResolver interface { + GetModel(ctx context.Context, providerID, modelID string) (Model, error) +} + +type ModelResolverFunc func(context.Context, string, string) (Model, error) + +func (f ModelResolverFunc) GetModel(ctx context.Context, providerID, modelID string) (Model, error) { + return f(ctx, providerID, modelID) +} + +type Stream interface { + Next() (orclient.StreamPart, error) + Close() error +} + +type StreamClient interface { + DoStream(ctx context.Context, params orclient.RequestParams) (Stream, error) +} + +type ClientFactory interface { + Client(ctx context.Context, model Model, choice *adaptive.RouteChoice, router *adaptive.AdaptiveModelRouter) (StreamClient, error) +} + +type ClientFactoryFunc func(context.Context, Model, *adaptive.RouteChoice, *adaptive.AdaptiveModelRouter) (StreamClient, error) + +func (f ClientFactoryFunc) Client(ctx context.Context, model Model, choice *adaptive.RouteChoice, router *adaptive.AdaptiveModelRouter) (StreamClient, error) { + return f(ctx, model, choice, router) +} + +type Service struct { + Models ModelResolver + Clients ClientFactory + Router *adaptive.AdaptiveModelRouter + DisableRouting bool +} + +type ResolvedCall struct { + Model Model + Choice *adaptive.RouteChoice + Candidates []adaptive.ModelCandidate + Params orclient.RequestParams +} + +func (s *Service) resolveRouter() *adaptive.AdaptiveModelRouter { + if s.DisableRouting { + return nil + } + if s.Router != nil { + return s.Router + } + if router, ok := state.GetRouter().(*adaptive.AdaptiveModelRouter); ok { + return router + } + return nil +} + +// ResolveAndAssemble picks the model (through the router when one is +// enabled) and assembles the request parameters. A failed routed-model lookup +// is registered immediately and the incoming model remains usable. +func (s *Service) ResolveAndAssemble(ctx context.Context, input StreamInput) (ResolvedCall, error) { + model := input.Model + router := s.resolveRouter() + var candidates []adaptive.ModelCandidate + var choice *adaptive.RouteChoice + if router != nil { + // An unset or unrecognised tier, and any tier with no pool of its + // own, resolves to high. + tier := router.EffectiveTier(input.Agent.Tier) + candidates = router.CandidatesForTier(tier) + if len(candidates) > 0 { + picked, err := router.PickContext(ctx, input.Agent.Name, tier) + if err != nil { + return ResolvedCall{}, err + } + choice = &picked + if picked.Candidate.ID != fullID(model) { + providerID, modelID := splitModelID(picked.Candidate.ID) + routed, resolveErr := s.getModel(ctx, providerID, modelID) + if resolveErr != nil { + if ctx.Err() != nil { + router.RegisterCanceled(picked) + return ResolvedCall{}, context.Cause(ctx) + } + router.Register(picked, 0, 0, errors.New("unresolvable model "+picked.Candidate.ID)) + choice = nil + } else { + model = routed + } + } + } + } + + // The language/client resolution fallback walks the complete pool in its + // existing order. The actual client is opened by Stream after assembly. + if s.Models != nil { + if _, err := s.Models.GetModel(ctx, model.ProviderID, model.ID); err != nil { + if ctx.Err() != nil { + if choice != nil { + router.RegisterCanceled(*choice) + } + return ResolvedCall{}, context.Cause(ctx) + } + found := false + for _, candidate := range candidates { + providerID, modelID := splitModelID(candidate.ID) + alternate, altErr := s.Models.GetModel(ctx, providerID, modelID) + if altErr == nil { + model = alternate + found = true + break + } + } + if !found { + return ResolvedCall{}, err + } + } + } + + params := model.Params + params.ModelID = model.ID + params.Prompt = make([]msgmodel.ModelMessage, 0, len(input.System)+len(input.Messages)) + for _, system := range input.System { + params.Prompt = append(params.Prompt, msgmodel.ModelMessage{Role: "system", Content: system}) + } + params.Prompt = append(params.Prompt, input.Messages...) + params.ToolChoice = input.ToolChoice + params.Tools = resolveTools(input) + + isLiteLLM := strings.Contains(strings.ToLower(model.ProviderID), "litellm") || + strings.Contains(strings.ToLower(model.APIID), "litellm") + if (isLiteLLM || strings.Contains(model.ProviderID, "github-copilot")) && + len(params.Tools) == 0 && HasToolCalls(input.Messages) { + params.Tools = []orclient.Tool{noopTool()} + } + sort.SliceStable(params.Tools, func(i, j int) bool { + return params.Tools[i].Name < params.Tools[j].Name + }) + return ResolvedCall{Model: model, Choice: choice, Candidates: candidates, Params: params}, nil +} + +func (s *Service) Stream(ctx context.Context, input StreamInput) (Stream, error) { + if s.Clients == nil { + return nil, errors.New("llmcall: ClientFactory is required") + } + call, err := s.ResolveAndAssemble(ctx, input) + if err != nil { + return nil, err + } + if ctx.Err() != nil { + if call.Choice != nil { + s.resolveRouter().RegisterCanceled(*call.Choice) + } + return nil, context.Cause(ctx) + } + client, err := s.Clients.Client(ctx, call.Model, call.Choice, s.resolveRouter()) + if err != nil { + if call.Choice != nil && ctx.Err() != nil && + (errors.Is(err, ctx.Err()) || errors.Is(err, context.Cause(ctx))) { + s.resolveRouter().RegisterCanceled(*call.Choice) + } + return nil, err + } + return client.DoStream(ctx, call.Params) +} + +func (s *Service) getModel(ctx context.Context, providerID, modelID string) (Model, error) { + if s.Models == nil { + return Model{}, errors.New("llmcall: ModelResolver is required") + } + return s.Models.GetModel(ctx, providerID, modelID) +} + +func resolveTools(input StreamInput) []orclient.Tool { + out := make([]orclient.Tool, 0, len(input.Tools)) + for _, tool := range input.Tools { + if input.DisabledTools[tool.Name] { + continue + } + if enabled, present := input.UserTools[tool.Name]; present && !enabled { + continue + } + out = append(out, tool) + } + return out +} + +// HasToolCalls reports whether any message carries a tool call or tool +// result. Only array content is inspected. +func HasToolCalls(messages []msgmodel.ModelMessage) bool { + for _, message := range messages { + parts, ok := message.Content.([]any) + if !ok { + switch typed := message.Content.(type) { + case []msgmodel.ToolCallContent: + if len(typed) > 0 { + return true + } + case []msgmodel.ToolResultContent: + if len(typed) > 0 { + return true + } + } + continue + } + for _, part := range parts { + switch item := part.(type) { + case msgmodel.ToolCallContent, msgmodel.ToolResultContent: + return true + case map[string]any: + if item["type"] == "tool-call" || item["type"] == "tool-result" { + return true + } + case json.RawMessage: + var probe struct { + Type string `json:"type"` + } + if json.Unmarshal(item, &probe) == nil && (probe.Type == "tool-call" || probe.Type == "tool-result") { + return true + } + } + } + } + return false +} + +func splitModelID(full string) (string, string) { + index := strings.IndexByte(full, '/') + if index <= 0 { + return full, "" + } + return full[:index], full[index+1:] +} + +func fullID(model Model) string { return model.ProviderID + "/" + model.ID } + +func noopTool() orclient.Tool { + return orclient.Tool{ + Type: "function", + Name: "_noop", + Description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", + InputSchema: json.RawMessage(`{"type":"object","properties":{"reason":{"type":"string","description":"Unused"}}}`), + } +} + +// OpenRouterClientFactory wires a configured endpoint builder to llmcall. +type OpenRouterClientFactory func(ctx context.Context, model Model) (*orclient.Client, error) + +func (f OpenRouterClientFactory) Client(ctx context.Context, model Model, choice *adaptive.RouteChoice, router *adaptive.AdaptiveModelRouter) (StreamClient, error) { + client, err := f(ctx, model) + if err != nil { + return nil, err + } + client.Router = router + client.RouteChoice = choice + return concreteClient{client}, nil +} + +type concreteClient struct{ client *orclient.Client } + +func (c concreteClient) DoStream(ctx context.Context, params orclient.RequestParams) (Stream, error) { + return c.client.DoStream(ctx, params) +} diff --git a/internal/seniordev/session/llmcall/llmcall_test.go b/internal/seniordev/session/llmcall/llmcall_test.go new file mode 100644 index 0000000000..6293153e32 --- /dev/null +++ b/internal/seniordev/session/llmcall/llmcall_test.go @@ -0,0 +1,107 @@ +//go:build !windows + +package llmcall + +import ( + "context" + "encoding/json" + "errors" + "io" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +func TestResolveAssemblySortsAndInjectsNoop(t *testing.T) { + service := &Service{DisableRouting: true} + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + SessionID: "s", + Model: Model{ProviderID: "litellm-proxy", ID: "m", APIID: "m"}, + Messages: []msgmodel.ModelMessage{{ + Role: "assistant", + Content: []any{map[string]any{"type": "tool-call"}}, + }}, + }) + if err != nil { + t.Fatal(err) + } + if len(call.Params.Tools) != 1 || call.Params.Tools[0].Name != "_noop" { + t.Fatalf("tools = %#v", call.Params.Tools) + } + if string(call.Params.Tools[0].InputSchema) != `{"type":"object","properties":{"reason":{"type":"string","description":"Unused"}}}` { + t.Fatalf("schema = %s", call.Params.Tools[0].InputSchema) + } +} + +func TestResolveUsesRouterChoiceAndFallback(t *testing.T) { + seed := float64(1) + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{{ID: "p/routed"}}, + RandomSeed: &seed, + }) + resolver := ModelResolverFunc(func(_ context.Context, provider, model string) (Model, error) { + if provider == "p" && model == "routed" { + return Model{ProviderID: provider, ID: model}, nil + } + if provider == "orig" { + return Model{ProviderID: provider, ID: model}, nil + } + return Model{}, errors.New("missing") + }) + service := &Service{Router: router, Models: resolver} + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + Model: Model{ProviderID: "orig", ID: "m"}, + Agent: Agent{Name: "coder"}, + }) + if err != nil { + t.Fatal(err) + } + if call.Model.ProviderID != "p" || call.Model.ID != "routed" || call.Choice == nil { + t.Fatalf("call = %#v", call) + } + // Release the pick for tests that share no process router state. + router.Register(*call.Choice, 0, 0, nil) +} + +func TestStreamPropagatesContextAndExactParams(t *testing.T) { + var got orclient.RequestParams + client := &fakeClient{run: func(ctx context.Context, params orclient.RequestParams) (Stream, error) { + if ctx.Value(contextKey{}) != "value" { + t.Fatal("context not propagated") + } + got = params + return &fakeStream{}, nil + }} + service := &Service{DisableRouting: true, Clients: ClientFactoryFunc(func(context.Context, Model, *adaptive.RouteChoice, *adaptive.AdaptiveModelRouter) (StreamClient, error) { + return client, nil + })} + ctx := context.WithValue(context.Background(), contextKey{}, "value") + stream, err := service.Stream(ctx, StreamInput{ + Model: Model{ProviderID: "p", ID: "m"}, + System: []string{"sys"}, + Tools: []orclient.Tool{{Name: "z", InputSchema: json.RawMessage(`{}`)}, {Name: "a", InputSchema: json.RawMessage(`{}`)}}, + }) + if err != nil { + t.Fatal(err) + } + _ = stream.Close() + if got.ModelID != "m" || len(got.Prompt) != 1 || got.Tools[0].Name != "a" { + t.Fatalf("params = %#v", got) + } +} + +type contextKey struct{} +type fakeClient struct { + run func(context.Context, orclient.RequestParams) (Stream, error) +} + +func (f *fakeClient) DoStream(ctx context.Context, params orclient.RequestParams) (Stream, error) { + return f.run(ctx, params) +} + +type fakeStream struct{} + +func (*fakeStream) Next() (orclient.StreamPart, error) { return nil, io.EOF } +func (*fakeStream) Close() error { return nil } diff --git a/internal/seniordev/session/llmcall/tier_test.go b/internal/seniordev/session/llmcall/tier_test.go new file mode 100644 index 0000000000..201742952b --- /dev/null +++ b/internal/seniordev/session/llmcall/tier_test.go @@ -0,0 +1,78 @@ +//go:build !windows + +package llmcall + +import ( + "context" + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/router/adaptive" +) + +// tierService wires a router with the given pools to a resolver that accepts +// every "p/<name>" model, so the model a call ends up on names the pool it +// was routed from. +func tierService(low, frontier []adaptive.ModelCandidate) (*Service, *adaptive.AdaptiveModelRouter) { + seed := float64(3) + router := adaptive.NewAdaptiveModelRouter(adaptive.AdaptiveRouterConfig{ + HighModels: []adaptive.ModelCandidate{{ID: "p/high"}}, + LowModels: low, + FrontierModels: frontier, + RandomSeed: &seed, + }) + resolver := ModelResolverFunc(func(_ context.Context, provider, model string) (Model, error) { + if provider != "p" { + return Model{}, errors.New("missing") + } + return Model{ProviderID: provider, ID: model}, nil + }) + return &Service{Router: router, Models: resolver}, router +} + +func TestAgentTierSelectsThePool(t *testing.T) { + service, router := tierService( + []adaptive.ModelCandidate{{ID: "p/low"}}, + []adaptive.ModelCandidate{{ID: "p/frontier"}}, + ) + for tier, want := range map[adaptive.ModelTier]string{ + adaptive.ModelTierHigh: "high", + adaptive.ModelTierLow: "low", + adaptive.ModelTierFrontier: "frontier", + "": "high", + } { + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + Model: Model{ProviderID: "p", ID: "requested"}, + Agent: Agent{Name: "agent", Tier: tier}, + }) + if err != nil { + t.Fatalf("tier %q: %v", tier, err) + } + if call.Model.ID != want { + t.Errorf("tier %q routed to %q, want %q", tier, call.Model.ID, want) + } + router.Register(*call.Choice, 0, 0, nil) + } +} + +func TestAgentTierWithNoPoolRoutesOnHigh(t *testing.T) { + service, router := tierService(nil, nil) + for _, tier := range []adaptive.ModelTier{ + adaptive.ModelTierLow, adaptive.ModelTierFrontier, + } { + call, err := service.ResolveAndAssemble(context.Background(), StreamInput{ + Model: Model{ProviderID: "p", ID: "requested"}, + Agent: Agent{Name: "agent", Tier: tier}, + }) + if err != nil { + t.Fatalf("tier %q: %v", tier, err) + } + if call.Model.ID != "high" { + t.Errorf("tier %q routed to %q, want the high pool", tier, call.Model.ID) + } + if call.Choice.Tier != adaptive.ModelTierHigh { + t.Errorf("tier %q recorded %q on the choice", tier, call.Choice.Tier) + } + router.Register(*call.Choice, 0, 0, nil) + } +} diff --git a/internal/seniordev/session/loopguard/loopguard.go b/internal/seniordev/session/loopguard/loopguard.go new file mode 100644 index 0000000000..a7601bc13d --- /dev/null +++ b/internal/seniordev/session/loopguard/loopguard.go @@ -0,0 +1,338 @@ +//go:build !windows + +// Package loopguard is a pure repetition and budget guard for agent action +// loops: it stops a run that repeats the same action, cycles through a short +// sequence of actions, or exceeds a cost or action budget, and warns once +// before a budget runs out. It has no I/O and no clock; the caller persists +// the snapshot it returns. +package loopguard + +import ( + "math" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + repeatCapDefault = 3 + maxCyclePeriodDefault = 4 + cycleMinOccurrencesDefault = 2 + warnFractionDefault = 0.8 + // maxCyclePeriodScan bounds the cycle scan regardless of configuration: + // a cycle longer than half the retained history cannot be observed twice. + maxCyclePeriodScan = 512 +) + +// LoopAction is one observed tool invocation. CostUsd is optional. +type LoopAction struct { + Tool string `json:"tool"` + ArgsKey string `json:"argsKey"` + CostUsd *float64 `json:"costUsd,omitempty"` +} + +// LoopStatus is the verdict severity. +type LoopStatus string + +const ( + LoopStatusOK LoopStatus = "ok" + LoopStatusWarn LoopStatus = "warn" + LoopStatusStop LoopStatus = "stop" +) + +// LoopVerdict is the result of observing one action. Reason is nil for an +// "ok" verdict. +type LoopVerdict struct { + Status LoopStatus `json:"status"` + Reason *string `json:"reason,omitempty"` +} + +// LoopGuardOptions configures a guard. A nil field selects the default. +type LoopGuardOptions struct { + // RepeatCap is the number of identical consecutive actions that terminates the loop. + RepeatCap *float64 `json:"repeatCap,omitempty"` + // MaxCyclePeriod is the largest cycle period to inspect. + MaxCyclePeriod *float64 `json:"maxCyclePeriod,omitempty"` + // CycleMinOccurrences is the number of repeated copies required to identify a cycle. + CycleMinOccurrences *float64 `json:"cycleMinOccurrences,omitempty"` + // MaxCostUsd is an optional cumulative USD budget. + MaxCostUsd *float64 `json:"maxCostUsd,omitempty"` + // MaxActions is an optional maximum number of observed actions. + MaxActions *float64 `json:"maxActions,omitempty"` + // WarnFraction is the fraction of a budget at which a one-shot warning is emitted. + WarnFraction *float64 `json:"warnFraction,omitempty"` +} + +// LoopGuardSnapshot is the guard's persistable state. +type LoopGuardSnapshot struct { + Version int `json:"version"` + Actions []string `json:"actions"` + ActionCount int `json:"actionCount"` + CumulativeCostUsd float64 `json:"cumulativeCostUsd"` + WarnedCost bool `json:"warnedCost"` + WarnedActions bool `json:"warnedActions"` + Stopped bool `json:"stopped"` +} + +// LoopGuard observes actions and costs and reports when the loop should stop. +type LoopGuard interface { + Observe(action LoopAction) LoopVerdict + // ObserveCost charges provider calls that have no tool action. It does + // not alter repetition or action counts. + ObserveCost(costUsd *float64) LoopVerdict + Snapshot() LoopGuardSnapshot + // Restore ignores a nil snapshot and one with an unknown version. + Restore(snapshot *LoopGuardSnapshot) +} + +type loopGuard struct { + repeatCap int + maxCyclePeriod int + cycleMinOccurrences int + maxCostUsd *float64 + maxActions *int + warnFraction float64 + historyLimit int + + actions []string + actionCount int + cumulativeCostUsd float64 + warnedCost bool + warnedActions bool + stopped bool +} + +// CreateLoopGuard creates a stateful but otherwise pure loop guard. +func CreateLoopGuard(options LoopGuardOptions) LoopGuard { + repeatCap := positiveInteger(options.RepeatCap, repeatCapDefault) + maxCyclePeriod := max(2, positiveInteger(options.MaxCyclePeriod, maxCyclePeriodDefault)) + cycleMinOccurrences := max(2, positiveInteger(options.CycleMinOccurrences, cycleMinOccurrencesDefault)) + var maxActions *int + if options.MaxActions != nil && isFinite(*options.MaxActions) { + n := int(math.Ceil(math.Max(0, *options.MaxActions))) + maxActions = &n + } + warnFraction := warnFractionDefault + if options.WarnFraction != nil { + warnFraction = math.Min(1, math.Max(0, *options.WarnFraction)) + } + return &loopGuard{ + repeatCap: repeatCap, + maxCyclePeriod: maxCyclePeriod, + cycleMinOccurrences: cycleMinOccurrences, + maxCostUsd: optionalNonNegative(options.MaxCostUsd), + maxActions: maxActions, + warnFraction: warnFraction, + historyLimit: max(repeatCap, maxCyclePeriod*cycleMinOccurrences), + actions: []string{}, + } +} + +func (g *loopGuard) Observe(action LoopAction) LoopVerdict { + if g.stopped { + return LoopVerdict{Status: LoopStatusStop, Reason: strptr("loop guard already stopped")} + } + + g.actions = append(g.actions, encodeAction(action)) + if len(g.actions) > g.historyLimit { + g.actions = sliceLast(g.actions, g.historyLimit) + } + g.actionCount++ + + if action.CostUsd != nil && isFinite(*action.CostUsd) && *action.CostUsd > 0 { + g.cumulativeCostUsd += *action.CostUsd + } + + // A terminal repetition finding takes precedence over a budget warning. + if reason := exactRepeatReason(g.actions, g.repeatCap); reason != nil { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: reason} + } + if reason := cycleDetectionReason(g.actions, g.maxCyclePeriod, g.cycleMinOccurrences); reason != nil { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: reason} + } + + var stopReasons, warnReasons []string + if g.maxCostUsd != nil { + maxCostUsd := *g.maxCostUsd + if g.cumulativeCostUsd >= maxCostUsd { + stopReasons = append(stopReasons, + "cost budget reached ("+formatNumber(g.cumulativeCostUsd)+"/"+formatNumber(maxCostUsd)+" USD)") + } else if !g.warnedCost && g.cumulativeCostUsd >= maxCostUsd*g.warnFraction { + g.warnedCost = true + warnReasons = append(warnReasons, "cost budget at "+formatPercent(g.cumulativeCostUsd/maxCostUsd)) + } + } + if g.maxActions != nil { + maxActions := *g.maxActions + if g.actionCount >= maxActions { + stopReasons = append(stopReasons, + "action budget reached ("+strconv.Itoa(g.actionCount)+"/"+strconv.Itoa(maxActions)+")") + } else if !g.warnedActions && float64(g.actionCount) >= float64(maxActions)*g.warnFraction { + g.warnedActions = true + warnReasons = append(warnReasons, + "action budget at "+formatPercent(float64(g.actionCount)/float64(maxActions))) + } + } + + if len(stopReasons) > 0 { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: strptr(strings.Join(stopReasons, "; "))} + } + if len(warnReasons) > 0 { + return LoopVerdict{Status: LoopStatusWarn, Reason: strptr(strings.Join(warnReasons, "; "))} + } + return LoopVerdict{Status: LoopStatusOK} +} + +func (g *loopGuard) ObserveCost(costUsd *float64) LoopVerdict { + if g.stopped { + return LoopVerdict{Status: LoopStatusStop, Reason: strptr("loop guard already stopped")} + } + if costUsd != nil && isFinite(*costUsd) && *costUsd > 0 { + g.cumulativeCostUsd += *costUsd + } + if g.maxCostUsd == nil { + return LoopVerdict{Status: LoopStatusOK} + } + maximum := *g.maxCostUsd + if g.cumulativeCostUsd >= maximum { + g.stopped = true + return LoopVerdict{Status: LoopStatusStop, Reason: strptr( + "cost budget reached (" + formatNumber(g.cumulativeCostUsd) + "/" + formatNumber(maximum) + " USD)", + )} + } + if !g.warnedCost && g.cumulativeCostUsd >= maximum*g.warnFraction { + g.warnedCost = true + return LoopVerdict{Status: LoopStatusWarn, Reason: strptr( + "cost budget at " + formatPercent(g.cumulativeCostUsd/maximum), + )} + } + return LoopVerdict{Status: LoopStatusOK} +} + +func (g *loopGuard) Snapshot() LoopGuardSnapshot { + actions := make([]string, len(g.actions)) + copy(actions, g.actions) + return LoopGuardSnapshot{ + Version: 1, + Actions: actions, + ActionCount: g.actionCount, + CumulativeCostUsd: g.cumulativeCostUsd, + WarnedCost: g.warnedCost, + WarnedActions: g.warnedActions, + Stopped: g.stopped, + } +} + +func (g *loopGuard) Restore(snapshot *LoopGuardSnapshot) { + if snapshot == nil || snapshot.Version != 1 { + return + } + if snapshot.Actions != nil { + g.actions = sliceLast(snapshot.Actions, g.historyLimit) + } + if snapshot.ActionCount >= 0 { + g.actionCount = snapshot.ActionCount + } + if isFinite(snapshot.CumulativeCostUsd) && snapshot.CumulativeCostUsd >= 0 { + g.cumulativeCostUsd = snapshot.CumulativeCostUsd + } + g.warnedCost = snapshot.WarnedCost + g.warnedActions = snapshot.WarnedActions + g.stopped = snapshot.Stopped +} + +// encodeAction is the history key for an action: the JSON array of its tool +// name and argument key. +func encodeAction(action LoopAction) string { + encoded, err := jsonutil.Marshal([2]string{action.Tool, action.ArgsKey}) + if err != nil { + return action.Tool + "\x00" + action.ArgsKey + } + return string(encoded) +} + +func exactRepeatReason(history []string, cap int) *string { + if len(history) < cap { + return nil + } + last := history[len(history)-1] + for i := len(history) - 2; i >= len(history)-cap; i-- { + if history[i] != last { + return nil + } + } + return strptr("exact action repeated " + strconv.Itoa(cap) + " times consecutively") +} + +func cycleDetectionReason(history []string, maxPeriod int, minOccurrences int) *string { + limit := min(maxPeriod, len(history)/2, maxCyclePeriodScan) + for period := 2; period <= limit; period++ { + required := period * minOccurrences + if len(history) < required { + continue + } + start := len(history) - required + matches := true + for offset := period; offset < required && matches; offset++ { + if history[start+offset] != history[start+offset%period] { + matches = false + } + } + if matches { + return strptr("cycle detected with period " + strconv.Itoa(period) + + " (" + strconv.Itoa(minOccurrences) + " occurrences)") + } + } + return nil +} + +func positiveInteger(value *float64, fallback int) int { + if value != nil && isFinite(*value) { + return int(math.Max(1, math.Min(math.Floor(*value), math.MaxInt32))) + } + return fallback +} + +func optionalNonNegative(value *float64) *float64 { + if value != nil && isFinite(*value) { + clamped := math.Max(0, *value) + return &clamped + } + return nil +} + +func strptr(value string) *string { return &value } + +// formatNumber prints a USD amount with at most four decimals. +func formatNumber(value float64) string { + text := strconv.FormatFloat(value, 'f', 4, 64) + if strings.Contains(text, ".") { + text = strings.TrimRight(text, "0") + text = strings.TrimSuffix(text, ".") + } + return text +} + +// formatPercent prints a ratio as a whole percentage. +func formatPercent(value float64) string { + return strconv.Itoa(int(math.Round(value*100))) + "%" +} + +// sliceLast returns a copy of the last limit values. +func sliceLast(values []string, limit int) []string { + start := 0 + if len(values) > limit { + start = len(values) - limit + } + out := make([]string, len(values)-start) + copy(out, values[start:]) + return out +} + +func isFinite(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/internal/seniordev/session/loopguard/loopguard_test.go b/internal/seniordev/session/loopguard/loopguard_test.go new file mode 100644 index 0000000000..6de2555acc --- /dev/null +++ b/internal/seniordev/session/loopguard/loopguard_test.go @@ -0,0 +1,231 @@ +//go:build !windows + +package loopguard + +import ( + "fmt" + "math" + "reflect" + "strings" + "testing" +) + +func action(tool string, argsKey string, costUsd ...float64) LoopAction { + act := LoopAction{Tool: tool, ArgsKey: argsKey} + if len(costUsd) > 0 { + value := costUsd[0] + act.CostUsd = &value + } + return act +} + +// expectVerdict asserts the status and the absence of a reason. +func expectVerdict(t *testing.T, got LoopVerdict, status LoopStatus) { + t.Helper() + if got.Status != status || got.Reason != nil { + t.Fatalf("expected {status: %q}, got %s", status, describeVerdict(got)) + } +} + +func expectStatus(t *testing.T, got LoopVerdict, status LoopStatus) { + t.Helper() + if got.Status != status { + t.Fatalf("expected status %q, got %s", status, describeVerdict(got)) + } +} + +func expectReasonContains(t *testing.T, got LoopVerdict, substring string) { + t.Helper() + if got.Reason == nil || !strings.Contains(*got.Reason, substring) { + t.Fatalf("expected reason containing %q, got %s", substring, describeVerdict(got)) + } +} + +func describeVerdict(verdict LoopVerdict) string { + if verdict.Reason == nil { + return fmt.Sprintf("{status: %q, reason: nil}", verdict.Status) + } + return fmt.Sprintf("{status: %q, reason: %q}", verdict.Status, *verdict.Reason) +} + +func floatptr(value float64) *float64 { return &value } + +func TestCreateLoopGuardRepetitionDetection(t *testing.T) { + t.Run("stops exactly at the consecutive-repeat cap", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + expectVerdict(t, guard.Observe(action("search", "same")), LoopStatusOK) + expectVerdict(t, guard.Observe(action("search", "same")), LoopStatusOK) + verdict := guard.Observe(action("search", "same")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "repeated 3 times") + }) + + t.Run("detects a period-two cycle after two occurrences", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Observe(action("read", "a")) + guard.Observe(action("write", "b")) + guard.Observe(action("read", "a")) + verdict := guard.Observe(action("write", "b")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "period 2") + }) + + t.Run("detects a period-three cycle after two occurrences", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + for _, item := range [][2]string{{"a", "1"}, {"b", "2"}, {"c", "3"}, {"a", "1"}, {"b", "2"}} { + expectStatus(t, guard.Observe(action(item[0], item[1])), LoopStatusOK) + } + verdict := guard.Observe(action("c", "3")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "period 3") + }) + + t.Run("does not flag progressing work with varied arguments", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + for i := 0; i < 20; i++ { + verdict := guard.Observe(action("search", fmt.Sprintf("query-%d", i))) + expectStatus(t, verdict, LoopStatusOK) + } + }) + + t.Run("a huge max cycle period is bounded by the history", func(t *testing.T) { + huge := 1e21 + guard := CreateLoopGuard(LoopGuardOptions{MaxCyclePeriod: &huge}) + guard.Observe(action("read", "a")) + guard.Observe(action("write", "b")) + guard.Observe(action("read", "a")) + verdict := guard.Observe(action("write", "b")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "period 2") + }) +} + +func TestCreateLoopGuardBudgets(t *testing.T) { + t.Run("warns before stopping on a cost budget", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(10)}) + expectStatus(t, guard.Observe(action("a", "1", 4)), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "2", 4)), LoopStatusWarn) + expectStatus(t, guard.Observe(action("a", "3", 2)), LoopStatusStop) + }) + + t.Run("warns before stopping on an action budget", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{MaxActions: floatptr(5)}) + expectStatus(t, guard.Observe(action("a", "1")), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "2")), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "3")), LoopStatusOK) + expectStatus(t, guard.Observe(action("a", "4")), LoopStatusWarn) + verdict := guard.Observe(action("a", "5")) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "action budget reached (5/5)") + }) + + t.Run("cost-only observations charge the budget", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(1)}) + expectStatus(t, guard.ObserveCost(floatptr(0.5)), LoopStatusOK) + expectStatus(t, guard.ObserveCost(floatptr(0.3)), LoopStatusWarn) + verdict := guard.ObserveCost(floatptr(0.25)) + expectStatus(t, verdict, LoopStatusStop) + expectReasonContains(t, verdict, "cost budget reached (1.05/1 USD)") + }) +} + +func TestCreateLoopGuardSnapshotAndRestore(t *testing.T) { + t.Run("round-trips repetition and budget state", func(t *testing.T) { + original := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(10)}) + original.Observe(action("read", "same", 4)) + expectStatus(t, original.Observe(action("read", "same", 4)), LoopStatusWarn) + + restored := CreateLoopGuard(LoopGuardOptions{MaxCostUsd: floatptr(10)}) + originalSnapshot := original.Snapshot() + restored.Restore(&originalSnapshot) + expectStatus(t, restored.Observe(action("read", "same", 2)), LoopStatusStop) + expectStatus(t, original.Observe(action("read", "same", 2)), LoopStatusStop) + if !reflect.DeepEqual(restored.Snapshot(), original.Snapshot()) { + t.Fatalf("snapshots differ:\nrestored: %+v\noriginal: %+v", restored.Snapshot(), original.Snapshot()) + } + }) + + t.Run("ignores malformed snapshots", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Restore(nil) + guard.Restore(&LoopGuardSnapshot{Version: 2, Actions: []string{}}) + expectStatus(t, guard.Observe(action("a", "1")), LoopStatusOK) + }) + + t.Run("rejects non-finite or negative numbers", func(t *testing.T) { + for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), -1} { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Observe(action("a", "1", 2.5)) + guard.Restore(&LoopGuardSnapshot{Version: 1, ActionCount: -1, CumulativeCostUsd: value}) + snapshot := guard.Snapshot() + if snapshot.ActionCount != 1 || snapshot.CumulativeCostUsd != 2.5 { + t.Errorf("restore(%v) overwrote state: %+v", value, snapshot) + } + } + }) + + t.Run("does not alias guard state", func(t *testing.T) { + guard := CreateLoopGuard(LoopGuardOptions{}) + guard.Observe(action("a", "1")) + snapshot := guard.Snapshot() + snapshot.Actions[0] = "tampered" + if guard.Snapshot().Actions[0] == "tampered" { + t.Error("Snapshot aliases the guard's action history") + } + + other := CreateLoopGuard(LoopGuardOptions{}) + restoreFrom := LoopGuardSnapshot{Version: 1, Actions: []string{`["a","1"]`}} + other.Restore(&restoreFrom) + other.Observe(action("b", "2")) + if len(restoreFrom.Actions) != 1 || restoreFrom.Actions[0] != `["a","1"]` { + t.Errorf("Restore aliases the caller's snapshot: %+v", restoreFrom.Actions) + } + }) +} + +func TestEncodeActionIsAJSONArray(t *testing.T) { + got := encodeAction(LoopAction{Tool: "bash", ArgsKey: "ls <dir> & echo"}) + want := `["bash","ls <dir> & echo"]` + if got != want { + t.Fatalf("encodeAction = %q, want %q", got, want) + } + if encodeAction(LoopAction{Tool: "a", ArgsKey: "b"}) == encodeAction(LoopAction{Tool: "ab", ArgsKey: ""}) { + t.Fatal("distinct actions must not collide") + } +} + +func TestFormatNumber(t *testing.T) { + cases := []struct { + value float64 + want string + }{ + {10, "10"}, + {0, "0"}, + {0.5, "0.5"}, + {12.345678, "12.3457"}, + {0.00001, "0"}, + {-10.5, "-10.5"}, + } + for _, testCase := range cases { + if got := formatNumber(testCase.value); got != testCase.want { + t.Errorf("formatNumber(%v) = %q, want %q", testCase.value, got, testCase.want) + } + } +} + +func TestFormatPercent(t *testing.T) { + cases := []struct { + value float64 + want string + }{ + {0.8, "80%"}, + {0.845, "85%"}, + {2.0 / 3.0, "67%"}, + {0, "0%"}, + } + for _, testCase := range cases { + if got := formatPercent(testCase.value); got != testCase.want { + t.Errorf("formatPercent(%v) = %q, want %q", testCase.value, got, testCase.want) + } + } +} diff --git a/internal/seniordev/session/outputoffload/outputoffload.go b/internal/seniordev/session/outputoffload/outputoffload.go new file mode 100644 index 0000000000..f1f90b3d68 --- /dev/null +++ b/internal/seniordev/session/outputoffload/outputoffload.go @@ -0,0 +1,347 @@ +//go:build !windows + +// Package outputoffload keeps large tool outputs out of the context window: +// the full text is written to a file under .senior-dev/tool-output and the model +// sees a bounded extract (head, tail and diagnostic-looking lines) plus the +// path. +package outputoffload + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + HEAD_LINES = 20 + TAIL_LINES = 40 + EXTRACT_MAX_CHARS = 6_000 + OFFLOAD_THRESHOLD_CHARS = 12_000 +) + +var unsafeCallIDRE = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +type DistillHook func(fullOutput string) (string, error) + +type ExtractRelevantOptions struct { + FullOutputPath *string `json:"fullOutputPath"` + Path *string `json:"path"` + MaxChars *float64 `json:"maxChars"` +} + +type OutputOffloadInput struct { + Output string `json:"output"` + Workspace string `json:"workspace"` + ToolName string `json:"toolName"` + CallID string `json:"callId"` + SessionID string `json:"sessionId,omitempty"` + EscalationWanted bool `json:"escalationWanted"` +} + +type OutputOffloadOptions struct { + Hook DistillHook `json:"-"` + EscalationWanted bool `json:"escalationWanted"` + Force bool `json:"-"` +} + +type OutputOffloadResult struct { + Inline string + OffloadPath *string + EscalationWanted bool +} + +// MarshalJSON emits inline first and the optional offloadPath and +// escalationWanted only when they are set. +func (r OutputOffloadResult) MarshalJSON() ([]byte, error) { + inline, err := jsonutil.Marshal(r.Inline) + if err != nil { + return nil, err + } + var b bytes.Buffer + b.WriteString(`{"inline":`) + b.Write(inline) + if r.OffloadPath != nil { + path, err := jsonutil.Marshal(*r.OffloadPath) + if err != nil { + return nil, err + } + b.WriteString(`,"offloadPath":`) + b.Write(path) + } + if r.EscalationWanted { + b.WriteString(`,"escalationWanted":true`) + } + b.WriteByte('}') + return b.Bytes(), nil +} + +// OutputSink is the filesystem side effect behind offloadLargeOutput. +type OutputSink interface { + WriteOutput(path string, output string) (string, error) +} + +type DiskSink struct{} + +func (DiskSink) WriteOutput(path string, output string) (string, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + ext := filepath.Ext(path) + stem := strings.TrimSuffix(path, ext) + // Bounded so a pathological collision space cannot livelock a + // synchronous tool call past the run deadline. + const maxCollisions = 100 + for collision := 1; collision <= maxCollisions; collision++ { + candidate := path + if collision > 1 { + candidate = stem + "-" + strconv.Itoa(collision) + ext + } + file, err := os.OpenFile(candidate, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return "", err + } + _, writeErr := file.WriteString(output) + closeErr := file.Close() + if writeErr != nil { + _ = os.Remove(candidate) + return "", writeErr + } + if closeErr != nil { + _ = os.Remove(candidate) + return "", closeErr + } + return candidate, nil + } + return "", fmt.Errorf("outputoffload: exhausted %d collision candidates for %s", maxCollisions, path) +} + +type Offloader struct { + Sink OutputSink +} + +var DefaultOffloader = Offloader{Sink: DiskSink{}} + +// ExtractRelevant keeps the head, tail, and diagnostic-looking lines. +func ExtractRelevant(output string, opts *ExtractRelevantOptions) string { + fullOutputPath := "<path>" + maxChars := float64(EXTRACT_MAX_CHARS) + if opts != nil { + if opts.FullOutputPath != nil { + fullOutputPath = *opts.FullOutputPath + } else if opts.Path != nil { + fullOutputPath = *opts.Path + } + if opts.MaxChars != nil { + maxChars = *opts.MaxChars + } + } + lines := splitCRLF(output) + selected := make([]bool, len(lines)) + for i := 0; i < min(HEAD_LINES, len(lines)); i++ { + selected[i] = true + } + for i := max(0, len(lines)-TAIL_LINES); i < len(lines); i++ { + selected[i] = true + } + for i, line := range lines { + if hasErrorText(line) { + selected[i] = true + } + } + + chunks := []string{} + seenLines := make(map[string]bool) + cursor := 0 + for index, keep := range selected { + if !keep { + continue + } + if index > cursor { + chunks = append(chunks, omission(index-cursor, fullOutputPath)) + } + line := lines[index] + if !seenLines[line] { + chunks = append(chunks, line) + seenLines[line] = true + } + cursor = index + 1 + } + if cursor < len(lines) { + chunks = append(chunks, omission(len(lines)-cursor, fullOutputPath)) + } + return capExtract(strings.Join(chunks, "\n"), maxChars, fullOutputPath) +} + +// OffloadLargeOutput uses the production filesystem sink. options accepts +// nil, OutputOffloadOptions (or a pointer), or a DistillHook. +func OffloadLargeOutput(input OutputOffloadInput, options any) OutputOffloadResult { + return DefaultOffloader.OffloadLargeOutput(input, options) +} + +// OffloadLargeOutput applies the offload policy with an injected sink. +func (o Offloader) OffloadLargeOutput(input OutputOffloadInput, options any) OutputOffloadResult { + escalationRequested := input.EscalationWanted + force := false + switch value := options.(type) { + case DistillHook: + escalationRequested = true + case func(string) (string, error): + escalationRequested = true + case OutputOffloadOptions: + escalationRequested = escalationRequested || value.EscalationWanted + force = value.Force + case *OutputOffloadOptions: + if value != nil { + escalationRequested = escalationRequested || value.EscalationWanted + force = value.Force + } + } + + if !force && charCount(input.Output) <= OFFLOAD_THRESHOLD_CHARS { + return resultWithEscalation(input.Output, nil, escalationRequested, ExtractRelevant(input.Output, nil)) + } + + offloadPath := outputPathFor(input) + sink := o.Sink + if sink == nil { + sink = DiskSink{} + } + actualPath, err := sink.WriteOutput(offloadPath, input.Output) + if err != nil { + return resultWithEscalation(plainTruncation(input.Output), nil, escalationRequested, ExtractRelevant(input.Output, nil)) + } + offloadPath = actualPath + + extract := ExtractRelevant(input.Output, &ExtractRelevantOptions{FullOutputPath: &offloadPath}) + handle := "Full output saved to " + offloadPath + " — read it only if the extract is insufficient." + return resultWithEscalation(extract+"\n\n"+handle, &offloadPath, escalationRequested, extract) +} + +func outputPathFor(input OutputOffloadInput) string { + safeCallID := unsafeCallIDRE.ReplaceAllString(input.CallID, "_") + if safeCallID == "" { + safeCallID = "unknown" + } + dir := filepath.Join(input.Workspace, ".senior-dev", "tool-output") + if input.SessionID != "" { + safeSessionID := unsafeCallIDRE.ReplaceAllString(input.SessionID, "_") + if safeSessionID == "" { + safeSessionID = "unknown" + } + dir = filepath.Join(dir, safeSessionID) + } + return filepath.Join(dir, safeCallID+".log") +} + +func resultWithEscalation(inline string, path *string, requested bool, relevant string) OutputOffloadResult { + return OutputOffloadResult{ + Inline: inline, + OffloadPath: path, + EscalationWanted: requested && !hasErrorLine(relevant), + } +} + +func hasErrorLine(output string) bool { + for _, line := range splitCRLF(output) { + if hasErrorText(line) { + return true + } + } + return false +} + +func hasErrorText(line string) bool { + lower := asciiLower(line) + return strings.Contains(lower, "error") || + strings.Contains(lower, "fail") || + strings.Contains(lower, "assert") || + strings.Contains(lower, "exception") || + strings.Contains(lower, "panic") || + strings.Contains(line, "✗") +} + +func omission(count int, fullOutputPath string) string { + return "[... " + strconv.Itoa(count) + " lines omitted — full output at " + fullOutputPath + "]" +} + +func capExtract(text string, maxChars float64, fullOutputPath string) string { + limit := int(maxChars) + if charCount(text) <= limit { + return text + } + marker := "\n[... extract capped at " + strconv.Itoa(limit) + " chars — full output at " + fullOutputPath + "]" + if charCount(marker) >= limit { + return firstChars(marker, limit) + } + return firstChars(text, limit-charCount(marker)) + marker +} + +func plainTruncation(output string) string { + note := "[... full output could not be saved; showing a plain truncation]" + if charCount(output) <= EXTRACT_MAX_CHARS { + return output + } + if charCount(note) >= EXTRACT_MAX_CHARS { + return firstChars(note, EXTRACT_MAX_CHARS) + } + return firstChars(output, EXTRACT_MAX_CHARS-charCount(note)-1) + "\n" + note +} + +func splitCRLF(s string) []string { + lines := []string{} + start := 0 + for i := 0; i < len(s); i++ { + if s[i] != '\n' { + continue + } + end := i + if end > start && s[end-1] == '\r' { + end-- + } + lines = append(lines, s[start:end]) + start = i + 1 + } + return append(lines, s[start:]) +} + +// charCount is the length of s in characters (runes). +func charCount(s string) int { return utf8.RuneCountInString(s) } + +// firstChars returns the first n characters of s without splitting a +// multi-byte character. +func firstChars(s string, n int) string { + if n <= 0 { + return "" + } + for i := range s { + if n == 0 { + return s[:i] + } + n-- + } + return s +} + +func asciiLower(s string) string { + var b strings.Builder + b.Grow(len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + b.WriteByte(c) + } + return b.String() +} diff --git a/internal/seniordev/session/overflow/overflow.go b/internal/seniordev/session/overflow/overflow.go new file mode 100644 index 0000000000..8b77b99ab9 --- /dev/null +++ b/internal/seniordev/session/overflow/overflow.go @@ -0,0 +1,37 @@ +//go:build !windows + +// Package overflow is the session-layer view of the compaction budget. +// +// The arithmetic lives in internal/engine/calc, where the engine already +// consumes this module's decisions. Type aliases keep both call sites on one +// implementation. +package overflow + +import "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + +type CompactionConfig = calc.CompactionConfig +type Config = calc.Config +type ModelLimit = calc.ModelLimit +type Model = calc.Model +type TokenCache = calc.TokenCache +type Tokens = calc.Tokens + +type UsableInput = calc.UsableInput +type OverflowInput = calc.OverflowInput +type CompactionWatermarks = calc.CompactionWatermarks + +const PolicyWindow = calc.PolicyWindow + +func ValidatePolicy(cfg Config) error { return calc.ValidatePolicy(cfg) } + +func EffectiveInputCapacity(input UsableInput) float64 { + return calc.EffectiveInputCapacity(input) +} + +func Watermarks(input UsableInput) CompactionWatermarks { + return calc.Watermarks(input) +} + +func IsOverflow(input OverflowInput) bool { + return calc.IsOverflow(input) +} diff --git a/internal/seniordev/session/projectors/busy_retry.go b/internal/seniordev/session/projectors/busy_retry.go new file mode 100644 index 0000000000..d032e0ffb1 --- /dev/null +++ b/internal/seniordev/session/projectors/busy_retry.go @@ -0,0 +1,165 @@ +//go:build !windows + +// The SQLite cold-start retry policy. It lives beside the projector database +// because the only retried operation is that database's first journal_mode +// pragma. +package projectors + +import ( + "fmt" + "io" + "math/rand/v2" + "os" + "strings" + "time" +) + +const ( + defaultMaxAttempts = 5 + defaultBaseDelayMS = 100 + defaultMaxDelayMS = 400 +) + +// BusyRetryOptions controls WithBusyRetry. Nil numeric fields select the +// defaults; pointers preserve the distinction between omitted and explicitly +// zero options. +type BusyRetryOptions struct { + MaxAttempts *int + BaseDelayMS *int + MaxDelayMS *int + DBPath string + Random func() float64 + Sleep func(time.Duration) + Log io.Writer +} + +type sqliteCodeError interface { + Code() int +} + +type namedCodeError interface { + CodeName() string +} + +// IsBusyError reports whether err or one of at most depth wrapped causes is a +// SQLite BUSY-class error. The default depth is 5, meaning the outer error +// plus five causes are inspected. +func IsBusyError(err error, depth ...int) bool { + limit := 5 + if len(depth) > 0 { + limit = depth[0] + } + for i := 0; i <= limit && err != nil; i++ { + if isBusyErrorShallow(err) { + return true + } + err = unwrapOnce(err) + } + return false +} + +func isBusyErrorShallow(err error) bool { + if named, ok := err.(namedCodeError); ok { + switch named.CodeName() { + case "SQLITE_BUSY", "SQLITE_BUSY_RECOVERY", "SQLITE_BUSY_SNAPSHOT", "SQLITE_BUSY_TIMEOUT": + return true + } + } + if coded, ok := err.(sqliteCodeError); ok { + code := coded.Code() + if code&0xff == 5 || code == 5 { + return true + } + } + return strings.Contains(strings.ToUpper(err.Error()), "SQLITE_BUSY") +} + +type unwrapper interface { + Unwrap() error +} + +func unwrapOnce(err error) error { + if wrapped, ok := err.(unwrapper); ok { + return wrapped.Unwrap() + } + return nil +} + +// BusyRetryError is returned after all BUSY-class attempts are exhausted. +// Unwrap preserves the last SQLite failure as the cause. +type BusyRetryError struct { + Message string + Cause error +} + +func (e *BusyRetryError) Error() string { return e.Message } +func (e *BusyRetryError) Unwrap() error { return e.Cause } + +// WithBusyRetry runs fn and retries only BUSY-class errors, sleeping a +// 100–400 ms (inclusive, by default) jitter between attempts. +func WithBusyRetry[T any](fn func() (T, error), opts BusyRetryOptions) (T, error) { + attempts := defaultMaxAttempts + if opts.MaxAttempts != nil { + attempts = max(1, *opts.MaxAttempts) + } + base := defaultBaseDelayMS + if opts.BaseDelayMS != nil { + base = *opts.BaseDelayMS + } + upper := defaultMaxDelayMS + if opts.MaxDelayMS != nil { + upper = *opts.MaxDelayMS + } + upper = max(base, upper) + random := opts.Random + if random == nil { + random = rand.Float64 + } + sleep := opts.Sleep + if sleep == nil { + sleep = time.Sleep + } + log := opts.Log + if log == nil { + log = os.Stderr + } + span := upper - base + 1 + + var zero T + var lastErr error + for attempt := 1; attempt <= attempts; attempt++ { + value, err := fn() + if err == nil { + return value, nil + } + if !IsBusyError(err) { + return zero, err + } + lastErr = err + if attempt >= attempts { + break + } + delay := base + int(random()*float64(span)) + where := "" + if opts.DBPath != "" { + where = " on " + opts.DBPath + } + fmt.Fprintf(log, "[busy-retry] SQLite busy%s (attempt %d/%d), backing off %dms\n", + where, attempt, attempts, delay) + sleep(time.Duration(delay) * time.Millisecond) + } + + where := "" + if opts.DBPath != "" { + where = " on database " + opts.DBPath + } + lastCode := "" + if named, ok := lastErr.(namedCodeError); ok && named.CodeName() != "" { + lastCode = " (last error: " + named.CodeName() + ")" + } + message := fmt.Sprintf( + "SQLite remained BUSY%s after %d attempts%s. This is a cold-start contention race between concurrent processes opening the same database — reduce launch concurrency or stagger process starts, then retry.", + where, attempts, lastCode, + ) + return zero, &BusyRetryError{Message: message, Cause: lastErr} +} diff --git a/internal/seniordev/session/projectors/busy_retry_test.go b/internal/seniordev/session/projectors/busy_retry_test.go new file mode 100644 index 0000000000..778cd3f088 --- /dev/null +++ b/internal/seniordev/session/projectors/busy_retry_test.go @@ -0,0 +1,144 @@ +//go:build !windows + +package projectors + +import ( + "bytes" + "errors" + "io" + "reflect" + "testing" + "time" +) + +type codedError struct { + name string + code int + msg string + cause error +} + +func (e *codedError) Error() string { + if e.msg != "" { + return e.msg + } + return e.name +} +func (e *codedError) Code() int { return e.code } +func (e *codedError) CodeName() string { return e.name } +func (e *codedError) Unwrap() error { return e.cause } + +func intPtr(value int) *int { return &value } + +func TestIsBusyError(t *testing.T) { + tests := []struct { + name string + err error + depth []int + want bool + }{ + {"named recovery", &codedError{name: "SQLITE_BUSY_RECOVERY"}, nil, true}, + {"primary numeric", &codedError{code: 5}, nil, true}, + {"extended numeric", &codedError{code: 261}, nil, true}, + {"message token", errors.New("Failed to run: SQLITE_BUSY: database is locked"), nil, true}, + {"constraint", &codedError{name: "SQLITE_CONSTRAINT", code: 19}, nil, false}, + {"ioerr", &codedError{code: 266}, nil, false}, + {"plain locked message", errors.New("database is locked"), nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsBusyError(tt.err, tt.depth...); got != tt.want { + t.Fatalf("IsBusyError() = %v, want %v", got, tt.want) + } + }) + } + + deep := &codedError{msg: "outer", cause: &codedError{msg: "mid", cause: &codedError{code: 261}}} + if !IsBusyError(deep) { + t.Fatal("two-level wrapped busy error was not recognized") + } + var chain error = &codedError{code: 261} + for i := 0; i < 6; i++ { + chain = &codedError{msg: "wrapper", cause: chain} + } + if IsBusyError(chain) { + t.Fatal("busy cause beyond the default depth was recognized") + } + if !IsBusyError(chain, 10) { + t.Fatal("explicitly deeper cause was not recognized") + } +} + +func TestWithBusyRetry(t *testing.T) { + calls := 0 + sleeps := []time.Duration{} + var logs bytes.Buffer + got, err := WithBusyRetry(func() (string, error) { + calls++ + if calls < 3 { + return "", &codedError{name: "SQLITE_BUSY_RECOVERY", code: 261} + } + return "ok", nil + }, BusyRetryOptions{ + Random: func() float64 { return 0.5 }, + Sleep: func(delay time.Duration) { sleeps = append(sleeps, delay) }, + Log: &logs, + }) + if err != nil { + t.Fatal(err) + } + if got != "ok" || calls != 3 { + t.Fatalf("got %q after %d calls", got, calls) + } + if want := []time.Duration{250 * time.Millisecond, 250 * time.Millisecond}; !reflect.DeepEqual(sleeps, want) { + t.Fatalf("sleeps = %v, want %v", sleeps, want) + } + wantLog := "" + + "[busy-retry] SQLite busy (attempt 1/5), backing off 250ms\n" + + "[busy-retry] SQLite busy (attempt 2/5), backing off 250ms\n" + if logs.String() != wantLog { + t.Fatalf("log:\n%q\nwant:\n%q", logs.String(), wantLog) + } +} + +func TestWithBusyRetryFailureModes(t *testing.T) { + calls := 0 + sleeps := 0 + wantErr := errors.New("real bug: not busy") + _, err := WithBusyRetry(func() (int, error) { + calls++ + return 0, wantErr + }, BusyRetryOptions{ + Sleep: func(time.Duration) { sleeps++ }, + Log: io.Discard, + }) + if !errors.Is(err, wantErr) || calls != 1 || sleeps != 0 { + t.Fatalf("non-busy result: err=%v calls=%d sleeps=%d", err, calls, sleeps) + } + + last := &codedError{name: "SQLITE_BUSY_RECOVERY", code: 261} + calls = 0 + _, err = WithBusyRetry(func() (int, error) { + calls++ + return 0, last + }, BusyRetryOptions{ + MaxAttempts: intPtr(2), + Random: func() float64 { return 0 }, + Sleep: func(time.Duration) {}, + DBPath: "/tmp/senior-dev.db", + Log: io.Discard, + }) + var exhausted *BusyRetryError + if !errors.As(err, &exhausted) { + t.Fatalf("error type = %T, want *BusyRetryError", err) + } + wantMessage := "SQLite remained BUSY on database /tmp/senior-dev.db after 2 attempts (last error: SQLITE_BUSY_RECOVERY). " + + "This is a cold-start contention race between concurrent processes opening the same database — " + + "reduce launch concurrency or stagger process starts, then retry." + if exhausted.Error() != wantMessage { + t.Fatalf("message:\n%s\nwant:\n%s", exhausted, wantMessage) + } + if !errors.Is(exhausted, last) || calls != 2 { + t.Fatalf("cause/calls: cause=%v calls=%d", errors.Unwrap(exhausted), calls) + } +} diff --git a/internal/seniordev/session/projectors/database.go b/internal/seniordev/session/projectors/database.go new file mode 100644 index 0000000000..cc13aac11f --- /dev/null +++ b/internal/seniordev/session/projectors/database.go @@ -0,0 +1,60 @@ +//go:build !windows + +// Projector database bootstrap. Migrations and the global path policy remain +// owned by the storage layer; this package owns the projector connection +// settings. +package projectors + +import ( + "context" + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +// SQLExecutor is the minimal database surface needed by Configure. +type SQLExecutor interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +// Configure applies the startup PRAGMA sequence. Only journal_mode is +// retried: it is the first file-touching statement, and so the point where +// concurrent processes opening the same database collide at cold start. +func Configure(ctx context.Context, db SQLExecutor, retry BusyRetryOptions) error { + _, err := WithBusyRetry(func() (sql.Result, error) { + return db.ExecContext(ctx, "PRAGMA journal_mode = WAL") + }, retry) + if err != nil { + return err + } + for _, statement := range []string{ + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 5000", + "PRAGMA cache_size = -64000", + "PRAGMA foreign_keys = ON", + "PRAGMA wal_checkpoint(PASSIVE)", + } { + if _, err := db.ExecContext(ctx, statement); err != nil { + return err + } + } + return nil +} + +// Open creates the cgo-free SQLite connection and configures it for projector +// use. A single physical connection keeps connection-local PRAGMAs effective. +func Open(ctx context.Context, path string, retry BusyRetryOptions) (*sql.DB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("projectors: open sqlite: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + retry.DBPath = path + if err := Configure(ctx, db, retry); err != nil { + db.Close() + return nil, err + } + return db, nil +} diff --git a/internal/seniordev/session/projectors/database_test.go b/internal/seniordev/session/projectors/database_test.go new file mode 100644 index 0000000000..351352d160 --- /dev/null +++ b/internal/seniordev/session/projectors/database_test.go @@ -0,0 +1,124 @@ +//go:build !windows + +package projectors + +import ( + "context" + "database/sql" + "database/sql/driver" + "io" + "path/filepath" + "reflect" + "testing" + "time" + + _ "modernc.org/sqlite" +) + +type recordingExecutor struct { + statements []string + journal int +} + +func (r *recordingExecutor) ExecContext(_ context.Context, statement string, _ ...any) (sql.Result, error) { + r.statements = append(r.statements, statement) + if statement == "PRAGMA journal_mode = WAL" { + r.journal++ + if r.journal == 1 { + return nil, &codedError{name: "SQLITE_BUSY_RECOVERY", code: 261} + } + } + return driver.RowsAffected(0), nil +} + +func TestConfigurePragmaOrderAndNarrowRetry(t *testing.T) { + exec := &recordingExecutor{} + if err := Configure(context.Background(), exec, BusyRetryOptions{ + Random: func() float64 { return 0 }, + Sleep: func(time.Duration) {}, + Log: io.Discard, + }); err != nil { + t.Fatal(err) + } + want := []string{ + "PRAGMA journal_mode = WAL", + "PRAGMA journal_mode = WAL", + "PRAGMA synchronous = NORMAL", + "PRAGMA busy_timeout = 5000", + "PRAGMA cache_size = -64000", + "PRAGMA foreign_keys = ON", + "PRAGMA wal_checkpoint(PASSIVE)", + } + if !reflect.DeepEqual(exec.statements, want) { + t.Fatalf("statements:\n%q\nwant:\n%q", exec.statements, want) + } +} + +func TestOpenConfiguresRealSQLite(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "working-memory.db") + db, err := Open(ctx, path, BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + checkPragma(t, db, "journal_mode", "wal") + checkPragma(t, db, "synchronous", int64(1)) + checkPragma(t, db, "busy_timeout", int64(5000)) + checkPragma(t, db, "cache_size", int64(-64000)) + checkPragma(t, db, "foreign_keys", int64(1)) +} + +func TestOpenRetriesRealSQLiteJournalContention(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "contended.db") + + holder, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + holder.SetMaxOpenConns(1) + if _, err := holder.ExecContext(ctx, "CREATE TABLE hold (id integer)"); err != nil { + t.Fatal(err) + } + if _, err := holder.ExecContext(ctx, "BEGIN EXCLUSIVE"); err != nil { + t.Fatal(err) + } + if _, err := holder.ExecContext(ctx, "INSERT INTO hold VALUES (1)"); err != nil { + t.Fatal(err) + } + + sleeps := 0 + db, err := Open(ctx, path, BusyRetryOptions{ + MaxAttempts: intPtr(3), + Random: func() float64 { return 0 }, + Sleep: func(time.Duration) { + sleeps++ + if _, rollbackErr := holder.ExecContext(ctx, "ROLLBACK"); rollbackErr != nil { + t.Fatalf("release holder: %v", rollbackErr) + } + }, + Log: io.Discard, + }) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if sleeps != 1 { + t.Fatalf("retry sleeps = %d, want 1", sleeps) + } + checkPragma(t, db, "journal_mode", "wal") +} + +func checkPragma(t *testing.T, db *sql.DB, name string, want any) { + t.Helper() + var got any + if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil { + t.Fatalf("PRAGMA %s: %v", name, err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("PRAGMA %s = %#v (%T), want %#v (%T)", name, got, got, want, want) + } +} diff --git a/internal/seniordev/session/projectors/projectors.go b/internal/seniordev/session/projectors/projectors.go new file mode 100644 index 0000000000..23eaabd745 --- /dev/null +++ b/internal/seniordev/session/projectors/projectors.go @@ -0,0 +1,583 @@ +//go:build !windows + +// Package projectors applies session, message and part events to the SQLite +// tables that mirror flat storage. Each Apply call is one transaction. +package projectors + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + EventSessionCreated = "session.created" + EventSessionUpdated = "session.updated" + EventSessionDeleted = "session.deleted" + EventMessageUpdated = "message.updated" + EventMessageRemoved = "message.removed" + EventMessagePartRemoved = "message.part.removed" + EventMessagePartUpdated = "message.part.updated" +) + +// Event is the serialized subset consumed by a projector. +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + Data json.RawMessage `json:"data"` +} + +// Warning is emitted for the two deliberately ignored late-write cases. +type Warning struct { + Message string + Fields PartialRow +} + +// StoreOptions supplies the clock and the warning sink. +type StoreOptions struct { + Now func() int64 + Warn func(Warning) +} + +// Store applies the ordered projector registry to a SQLite database. +type Store struct { + db *sql.DB + now func() int64 + warn func(Warning) +} + +// NewStore binds the projector chain to an initialized database. +func NewStore(db *sql.DB, options StoreOptions) *Store { + now := func() int64 { return time.Now().UnixMilli() } + if options.Now != nil { + now = options.Now + } + warn := func(Warning) {} + if options.Warn != nil { + warn = options.Warn + } + return &Store{db: db, now: now, warn: warn} +} + +// NotFoundError is returned when session.updated names a session that does +// not exist. The useful detail is in Message. +type NotFoundError struct { + Message string +} + +func (e *NotFoundError) Error() string { return "NotFoundError" } + +// PartialRow is a set of column values keyed by column name. +type PartialRow map[string]any + +// row is a decoded JSON object. +type row = map[string]any + +// ToPartialRow maps a JSON session patch onto the snake_case update columns. +// A nested field whose parent is null yields a null column. +func ToPartialRow(info json.RawMessage) (PartialRow, error) { + value, err := decodeObject(info) + if err != nil { + return nil, errors.New("projectors: session patch must be an object") + } + out := PartialRow{} + fields := []struct { + source string + column string + nested string + }{ + {"id", "id", ""}, + {"projectID", "project_id", ""}, + {"workspaceID", "workspace_id", ""}, + {"parentID", "parent_id", ""}, + {"slug", "slug", ""}, + {"directory", "directory", ""}, + {"path", "path", ""}, + {"title", "title", ""}, + {"version", "version", ""}, + {"share", "share_url", "url"}, + {"summary", "summary_additions", "additions"}, + {"summary", "summary_deletions", "deletions"}, + {"summary", "summary_files", "files"}, + {"summary", "summary_diffs", "diffs"}, + {"revert", "revert", ""}, + {"permission", "permission", ""}, + {"time", "time_created", "created"}, + {"time", "time_updated", "updated"}, + {"time", "time_compacting", "compacting"}, + {"time", "time_archived", "archived"}, + } + for _, field := range fields { + if item, ok := grab(value, field.source, field.nested); ok { + out[field.column] = item + } + } + return out, nil +} + +func grab(object row, field, nested string) (any, bool) { + value, ok := object[field] + if !ok { + return nil, false + } + if nested == "" { + return value, true + } + switch typed := value.(type) { + case row: + item, ok := typed[nested] + return item, ok + case []any: + return nil, false + } + return value, true +} + +// Apply projects one event inside a SQLite transaction. +func (s *Store) Apply(ctx context.Context, event Event) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if err := s.ApplyTx(ctx, tx, event); err != nil { + _ = tx.Rollback() + return err + } + return tx.Commit() +} + +// ApplyTx projects one event into an existing transaction. +func (s *Store) ApplyTx(ctx context.Context, tx *sql.Tx, event Event) error { + data, err := decodeObject(event.Data) + if err != nil { + return errors.New("projectors: event data must be an object") + } + switch event.Type { + case EventSessionCreated: + err = s.projectSessionCreated(ctx, tx, data) + case EventSessionUpdated: + err = s.projectSessionUpdated(ctx, tx, data) + case EventSessionDeleted: + err = s.projectSessionDeleted(ctx, tx, data) + case EventMessageUpdated: + err = s.projectMessageUpdated(ctx, tx, data) + case EventMessageRemoved: + err = s.projectMessageRemoved(ctx, tx, data) + case EventMessagePartRemoved: + err = s.projectPartRemoved(ctx, tx, data) + case EventMessagePartUpdated: + err = s.projectPartUpdated(ctx, tx, data) + default: + err = fmt.Errorf("Projector not found for event: %s", event.Type) + } + if err != nil { + return normalizeSQLiteError(err) + } + return nil +} + +// ApplyReconcileTx upserts every authoritative field from a flat-storage +// event. Startup reconciliation must repair stale key and timestamp columns as +// well as JSON payloads, so it does not go through the live projectors. +func (s *Store) ApplyReconcileTx(ctx context.Context, tx *sql.Tx, event Event) error { + data, err := decodeObject(event.Data) + if err != nil { + return errors.New("projectors: event data must be an object") + } + switch event.Type { + case EventSessionCreated: + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: session.created info must be an object") + } + values := sessionInsertRow(info) + for _, column := range []string{ + "workspace_id", "parent_id", "path", "share_url", + "summary_additions", "summary_deletions", "summary_files", "summary_diffs", + "revert", "permission", "agent", "model", "time_compacting", "time_archived", + } { + if _, exists := values[column]; !exists { + values[column] = nil + } + } + err = upsertRow(ctx, tx, "session", values, "id", sessionJSONColumns) + case EventMessageUpdated: + err = s.reconcileMessageUpdated(ctx, tx, data) + case EventMessagePartUpdated: + err = s.reconcilePartUpdated(ctx, tx, data) + default: + err = s.ApplyTx(ctx, tx, event) + } + if err != nil { + return normalizeSQLiteError(err) + } + return nil +} + +func (s *Store) reconcileMessageUpdated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: message.updated info must be an object") + } + restJSON, err := restJSON(info, "id", "sessionID") + if err != nil { + return err + } + values := row{ + "id": stringOf(info["id"]), + "session_id": stringOf(info["sessionID"]), + "time_created": nested(info, "time", "created"), + "time_updated": float64(s.now()), + "data": restJSON, + } + return upsertRow(ctx, tx, "message", values, "id", nil) +} + +func (s *Store) reconcilePartUpdated(ctx context.Context, tx *sql.Tx, data row) error { + part, ok := data["part"].(row) + if !ok { + return errors.New("projectors: message.part.updated part must be an object") + } + restJSON, err := restJSON(part, "id", "messageID", "sessionID") + if err != nil { + return err + } + values := row{ + "id": stringOf(part["id"]), + "message_id": stringOf(part["messageID"]), + "session_id": stringOf(part["sessionID"]), + "time_created": data["time"], + "time_updated": float64(s.now()), + "data": restJSON, + } + return upsertRow(ctx, tx, "part", values, "id", nil) +} + +func normalizeSQLiteError(err error) error { + coded, ok := err.(sqliteCodeError) + if !ok || coded.Code()&0xff != 19 { + return err + } + message := err.Error() + const prefix = "constraint failed: " + if strings.HasPrefix(message, prefix) { + message = strings.TrimPrefix(message, prefix) + if open := strings.LastIndex(message, " ("); open >= 0 && strings.HasSuffix(message, ")") { + message = message[:open] + } + return errors.New(message) + } + return err +} + +func (s *Store) projectSessionCreated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: session.created info must be an object") + } + return insertRow(ctx, tx, "session", sessionInsertRow(info), sessionJSONColumns) +} + +func sessionInsertRow(info row) row { + values := row{} + copyField(values, "id", info, "id") + copyField(values, "project_id", info, "projectID") + copyField(values, "workspace_id", info, "workspaceID") + copyField(values, "parent_id", info, "parentID") + copyField(values, "slug", info, "slug") + copyField(values, "directory", info, "directory") + copyField(values, "path", info, "path") + copyField(values, "title", info, "title") + copyField(values, "agent", info, "agent") + copyField(values, "model", info, "model") + copyField(values, "version", info, "version") + copyNestedField(values, "share_url", info, "share", "url") + copyNestedField(values, "summary_additions", info, "summary", "additions") + copyNestedField(values, "summary_deletions", info, "summary", "deletions") + copyNestedField(values, "summary_files", info, "summary", "files") + copyNestedField(values, "summary_diffs", info, "summary", "diffs") + values["revert"] = info["revert"] + copyField(values, "permission", info, "permission") + copyNestedField(values, "time_created", info, "time", "created") + copyNestedField(values, "time_updated", info, "time", "updated") + copyNestedField(values, "time_compacting", info, "time", "compacting") + copyNestedField(values, "time_archived", info, "time", "archived") + return values +} + +func copyField(values row, column string, object row, field string) { + if value, ok := object[field]; ok { + values[column] = value + } +} + +func copyNestedField(values row, column string, object row, parent, field string) { + if inner, ok := object[parent].(row); ok { + if value, ok := inner[field]; ok { + values[column] = value + } + } +} + +// nested returns object[parent][field], or nil when either level is absent. +func nested(object row, parent, field string) any { + if inner, ok := object[parent].(row); ok { + return inner[field] + } + return nil +} + +func stringOf(value any) string { + text, _ := value.(string) + return text +} + +var sessionJSONColumns = map[string]bool{ + "summary_diffs": true, + "revert": true, + "permission": true, + "model": true, +} + +func (s *Store) projectSessionUpdated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"] + if !ok { + return errors.New("projectors: session.updated info is required") + } + encoded, err := json.Marshal(info) + if err != nil { + return err + } + partial, err := ToPartialRow(encoded) + if err != nil { + return err + } + values := row(partial) + if len(values) == 0 { + return errors.New("No values to set") + } + if _, explicit := values["time_updated"]; !explicit { + values["time_updated"] = float64(s.now()) + } + sessionID := stringOf(data["sessionID"]) + affected, err := updateRow(ctx, tx, "session", values, "id", sessionID, sessionJSONColumns) + if err != nil { + return err + } + if affected == 0 { + return &NotFoundError{Message: "Session not found: " + sessionID} + } + return nil +} + +func (s *Store) projectSessionDeleted(ctx context.Context, tx *sql.Tx, data row) error { + _, err := tx.ExecContext(ctx, "DELETE FROM session WHERE id = ?", stringOf(data["sessionID"])) + return err +} + +func (s *Store) projectMessageUpdated(ctx context.Context, tx *sql.Tx, data row) error { + info, ok := data["info"].(row) + if !ok { + return errors.New("projectors: message.updated info must be an object") + } + id := stringOf(info["id"]) + sessionID := stringOf(info["sessionID"]) + restJSON, err := restJSON(info, "id", "sessionID") + if err != nil { + return err + } + created, err := sqlValue(nested(info, "time", "created"), false) + if err != nil { + return err + } + now := s.now() + _, err = tx.ExecContext(ctx, `INSERT INTO message + (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, time_updated = ?`, + id, sessionID, created, now, restJSON, now) + if err != nil && isForeignKeyError(err) { + s.warn(Warning{ + Message: "ignored late message update", + Fields: PartialRow{"messageID": id, "sessionID": sessionID}, + }) + return nil + } + return err +} + +func (s *Store) projectMessageRemoved(ctx context.Context, tx *sql.Tx, data row) error { + _, err := tx.ExecContext(ctx, "DELETE FROM message WHERE id = ? AND session_id = ?", + stringOf(data["messageID"]), stringOf(data["sessionID"])) + return err +} + +func (s *Store) projectPartRemoved(ctx context.Context, tx *sql.Tx, data row) error { + _, err := tx.ExecContext(ctx, "DELETE FROM part WHERE id = ? AND session_id = ?", + stringOf(data["partID"]), stringOf(data["sessionID"])) + return err +} + +func (s *Store) projectPartUpdated(ctx context.Context, tx *sql.Tx, data row) error { + part, ok := data["part"].(row) + if !ok { + return errors.New("projectors: message.part.updated part must be an object") + } + id := stringOf(part["id"]) + messageID := stringOf(part["messageID"]) + sessionID := stringOf(part["sessionID"]) + restJSON, err := restJSON(part, "id", "messageID", "sessionID") + if err != nil { + return err + } + created, err := sqlValue(data["time"], false) + if err != nil { + return err + } + now := s.now() + _, err = tx.ExecContext(ctx, `INSERT INTO part + (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, time_updated = ?`, + id, messageID, sessionID, created, now, restJSON, now) + if err != nil && isForeignKeyError(err) { + s.warn(Warning{ + Message: "ignored late part update", + Fields: PartialRow{"partID": id, "messageID": messageID, "sessionID": sessionID}, + }) + return nil + } + return err +} + +func isForeignKeyError(err error) bool { + if coded, ok := err.(sqliteCodeError); ok && coded.Code() == 787 { + return true + } + return strings.Contains(err.Error(), "FOREIGN KEY constraint failed") +} + +// decodeObject decodes a JSON object into a map; anything else is an error. +func decodeObject(data []byte) (row, error) { + var value any + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + object, ok := value.(row) + if !ok { + return nil, errors.New("projectors: expected a JSON object") + } + return object, nil +} + +// restJSON encodes object without the named keys, which live in their own +// columns. +func restJSON(object row, omit ...string) (string, error) { + rest := make(row, len(object)) + for key, value := range object { + rest[key] = value + } + for _, key := range omit { + delete(rest, key) + } + encoded, err := jsonutil.Marshal(rest) + return string(encoded), err +} + +// sqlValue converts a decoded JSON value into a SQLite parameter. Objects and +// arrays are stored as compact JSON; asJSON forces that for scalars too. +func sqlValue(value any, asJSON bool) (any, error) { + if value == nil { + return nil, nil + } + if !asJSON { + switch typed := value.(type) { + case bool, float64, string: + return typed, nil + } + } + encoded, err := jsonutil.Marshal(value) + return string(encoded), err +} + +func sortedColumns(values row) []string { + columns := make([]string, 0, len(values)) + for column := range values { + columns = append(columns, column) + } + sort.Strings(columns) + return columns +} + +func insertRow(ctx context.Context, tx *sql.Tx, table string, values row, jsonColumns map[string]bool) error { + columns := sortedColumns(values) + placeholders := make([]string, len(columns)) + args := make([]any, len(columns)) + for index, column := range columns { + placeholders[index] = "?" + value, err := sqlValue(values[column], jsonColumns[column]) + if err != nil { + return err + } + args[index] = value + } + statement := "INSERT INTO " + table + " (" + strings.Join(columns, ", ") + ") VALUES (" + + strings.Join(placeholders, ", ") + ")" + _, err := tx.ExecContext(ctx, statement, args...) + return err +} + +func upsertRow(ctx context.Context, tx *sql.Tx, table string, values row, conflictColumn string, jsonColumns map[string]bool) error { + columns := sortedColumns(values) + placeholders := make([]string, len(columns)) + updates := make([]string, 0, len(columns)) + args := make([]any, len(columns)) + for index, column := range columns { + placeholders[index] = "?" + value, err := sqlValue(values[column], jsonColumns[column]) + if err != nil { + return err + } + args[index] = value + if column != conflictColumn { + updates = append(updates, column+" = excluded."+column) + } + } + statement := "INSERT INTO " + table + " (" + strings.Join(columns, ", ") + ") VALUES (" + + strings.Join(placeholders, ", ") + ") ON CONFLICT(" + conflictColumn + ") " + if len(updates) == 0 { + statement += "DO NOTHING" + } else { + statement += "DO UPDATE SET " + strings.Join(updates, ", ") + } + _, err := tx.ExecContext(ctx, statement, args...) + return err +} + +func updateRow(ctx context.Context, tx *sql.Tx, table string, values row, whereColumn string, whereValue any, jsonColumns map[string]bool) (int64, error) { + columns := sortedColumns(values) + sets := make([]string, len(columns)) + args := make([]any, 0, len(columns)+1) + for index, column := range columns { + sets[index] = column + " = ?" + value, err := sqlValue(values[column], jsonColumns[column]) + if err != nil { + return 0, err + } + args = append(args, value) + } + args = append(args, whereValue) + result, err := tx.ExecContext(ctx, + "UPDATE "+table+" SET "+strings.Join(sets, ", ")+" WHERE "+whereColumn+" = ?", + args..., + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/seniordev/session/projectors/projectors_test.go b/internal/seniordev/session/projectors/projectors_test.go new file mode 100644 index 0000000000..d11e34a70f --- /dev/null +++ b/internal/seniordev/session/projectors/projectors_test.go @@ -0,0 +1,76 @@ +//go:build !windows + +package projectors + +import ( + "context" + "encoding/json" + "io" + "reflect" + "testing" +) + +func TestLateForeignWritesWarnAndOtherConstraintsFail(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, ":memory:", BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE project (id text PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + if err := ApplySchema(ctx, db); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO project VALUES ('p1')`); err != nil { + t.Fatal(err) + } + warnings := []Warning{} + store := NewStore(db, StoreOptions{ + Now: func() int64 { return 1000 }, + Warn: func(warning Warning) { warnings = append(warnings, warning) }, + }) + + for _, event := range []Event{ + { + ID: "e1", + Type: EventMessageUpdated, + Data: json.RawMessage(`{"sessionID":"gone","info":{"id":"m1","sessionID":"gone","time":{"created":1}}}`), + }, + { + ID: "e2", + Type: EventMessagePartUpdated, + Data: json.RawMessage(`{"sessionID":"gone","time":1,"part":{"id":"pt1","messageID":"gone","sessionID":"gone","type":"text"}}`), + }, + } { + if err := store.Apply(ctx, event); err != nil { + t.Fatal(err) + } + } + if len(warnings) != 2 { + t.Fatalf("warnings = %#v", warnings) + } + if warnings[0].Message != "ignored late message update" || + warnings[1].Message != "ignored late part update" { + t.Fatalf("warning messages = %#v", warnings) + } + if want := (PartialRow{"messageID": "m1", "sessionID": "gone"}); !reflect.DeepEqual(warnings[0].Fields, want) { + t.Fatalf("message warning fields = %#v", warnings[0].Fields) + } + if want := (PartialRow{"partID": "pt1", "messageID": "gone", "sessionID": "gone"}); !reflect.DeepEqual(warnings[1].Fields, want) { + t.Fatalf("part warning fields = %#v", warnings[1].Fields) + } + + err = store.Apply(ctx, Event{ + ID: "e3", + Type: EventMessageUpdated, + Data: json.RawMessage(`{"sessionID":"p1","info":{"id":"m2","sessionID":"p1","time":{}}}`), + }) + if err == nil || err.Error() != "NOT NULL constraint failed: message.time_created" { + t.Fatalf("non-foreign constraint error = %v", err) + } + if len(warnings) != 2 { + t.Fatalf("non-foreign constraint emitted warning: %#v", warnings) + } +} diff --git a/internal/seniordev/session/projectors/schema.go b/internal/seniordev/session/projectors/schema.go new file mode 100644 index 0000000000..71821e3a3c --- /dev/null +++ b/internal/seniordev/session/projectors/schema.go @@ -0,0 +1,115 @@ +//go:build !windows + +// The session SQLite schema. The project table that two foreign keys +// reference is owned by the caller. +package projectors + +import ( + "context" + "database/sql" + "fmt" +) + +// SchemaStatements creates the session-owned tables and indexes. +var SchemaStatements = []string{ + `CREATE TABLE IF NOT EXISTS session ( + id text PRIMARY KEY, + project_id text NOT NULL, + workspace_id text, + parent_id text, + slug text NOT NULL, + directory text NOT NULL, + path text, + title text NOT NULL, + version text NOT NULL, + share_url text, + summary_additions integer, + summary_deletions integer, + summary_files integer, + summary_diffs text, + revert text, + permission text, + agent text, + model text, + time_created integer NOT NULL, + time_updated integer NOT NULL, + time_compacting integer, + time_archived integer, + CONSTRAINT fk_session_project_id_project_id_fk + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS session_project_idx ON session (project_id)`, + `CREATE INDEX IF NOT EXISTS session_workspace_idx ON session (workspace_id)`, + `CREATE INDEX IF NOT EXISTS session_parent_idx ON session (parent_id)`, + `CREATE TABLE IF NOT EXISTS message ( + id text PRIMARY KEY, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_message_session_id_session_id_fk + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS message_session_time_created_id_idx + ON message (session_id, time_created, id)`, + `CREATE TABLE IF NOT EXISTS part ( + id text PRIMARY KEY, + message_id text NOT NULL, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_part_message_id_message_id_fk + FOREIGN KEY (message_id) REFERENCES message(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS part_message_id_id_idx ON part (message_id, id)`, + `CREATE INDEX IF NOT EXISTS part_session_idx ON part (session_id)`, + `CREATE TABLE IF NOT EXISTS todo ( + session_id text NOT NULL, + content text NOT NULL, + status text NOT NULL, + priority text NOT NULL, + position integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + CONSTRAINT todo_pk PRIMARY KEY (session_id, position), + CONSTRAINT fk_todo_session_id_session_id_fk + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS todo_session_idx ON todo (session_id)`, + `CREATE TABLE IF NOT EXISTS session_message ( + id text PRIMARY KEY, + session_id text NOT NULL, + type text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_session_message_session_id_session_id_fk + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS session_message_session_idx + ON session_message (session_id)`, + `CREATE INDEX IF NOT EXISTS session_message_session_type_idx + ON session_message (session_id, type)`, + `CREATE INDEX IF NOT EXISTS session_message_time_created_idx + ON session_message (time_created)`, + `CREATE TABLE IF NOT EXISTS permission ( + project_id text PRIMARY KEY, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL, + CONSTRAINT fk_permission_project_id_project_id_fk + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE + )`, +} + +// ApplySchema installs the session-owned tables and indexes. The project table +// referenced by two foreign keys must be installed by the caller. +func ApplySchema(ctx context.Context, db *sql.DB) error { + for _, statement := range SchemaStatements { + if _, err := db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("projectors: apply schema: %w", err) + } + } + return nil +} diff --git a/internal/seniordev/session/projectors/schema_test.go b/internal/seniordev/session/projectors/schema_test.go new file mode 100644 index 0000000000..f6f2584152 --- /dev/null +++ b/internal/seniordev/session/projectors/schema_test.go @@ -0,0 +1,157 @@ +//go:build !windows + +package projectors + +import ( + "context" + "database/sql" + "io" + "reflect" + "testing" +) + +func TestApplySchemaExactTablesColumnsAndIndexes(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, ":memory:", BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE project (id text PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + if err := ApplySchema(ctx, db); err != nil { + t.Fatal(err) + } + + wantColumns := map[string][]string{ + "session": { + "id", "project_id", "workspace_id", "parent_id", "slug", "directory", "path", + "title", "version", "share_url", "summary_additions", "summary_deletions", + "summary_files", "summary_diffs", "revert", "permission", "agent", "model", + "time_created", "time_updated", "time_compacting", "time_archived", + }, + "message": {"id", "session_id", "time_created", "time_updated", "data"}, + "part": {"id", "message_id", "session_id", "time_created", "time_updated", "data"}, + "todo": {"session_id", "content", "status", "priority", "position", "time_created", "time_updated"}, + "session_message": {"id", "session_id", "type", "time_created", "time_updated", "data"}, + "permission": {"project_id", "time_created", "time_updated", "data"}, + } + for table, want := range wantColumns { + if got := tableColumns(t, db, table); !reflect.DeepEqual(got, want) { + t.Errorf("%s columns:\n%v\nwant:\n%v", table, got, want) + } + } + + wantIndexes := []string{ + "message_session_time_created_id_idx", + "part_message_id_id_idx", + "part_session_idx", + "session_message_session_idx", + "session_message_session_type_idx", + "session_message_time_created_idx", + "session_parent_idx", + "session_project_idx", + "session_workspace_idx", + "todo_session_idx", + } + rows, err := db.Query(`SELECT name FROM sqlite_master + WHERE type = 'index' AND name NOT LIKE 'sqlite_autoindex_%' ORDER BY name`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + gotIndexes := []string{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatal(err) + } + gotIndexes = append(gotIndexes, name) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(gotIndexes, wantIndexes) { + t.Fatalf("indexes:\n%v\nwant:\n%v", gotIndexes, wantIndexes) + } +} + +func TestSchemaForeignKeyCascades(t *testing.T) { + ctx := context.Background() + db, err := Open(ctx, ":memory:", BusyRetryOptions{Log: io.Discard}) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(`CREATE TABLE project (id text PRIMARY KEY)`); err != nil { + t.Fatal(err) + } + if err := ApplySchema(ctx, db); err != nil { + t.Fatal(err) + } + statements := []string{ + `INSERT INTO project VALUES ('p1')`, + `INSERT INTO session + (id, project_id, slug, directory, title, version, time_created, time_updated) + VALUES ('s1', 'p1', 'slug', '/tmp', 'title', 'v', 1, 1)`, + `INSERT INTO message VALUES ('m1', 's1', 1, 1, '{}')`, + `INSERT INTO part VALUES ('pt1', 'm1', 's1', 1, 1, '{}')`, + `INSERT INTO todo VALUES ('s1', 'x', 'pending', 'high', 0, 1, 1)`, + `INSERT INTO session_message VALUES ('e1', 's1', 'user', 1, 1, '{}')`, + `INSERT INTO permission VALUES ('p1', 1, 1, '{}')`, + `DELETE FROM session WHERE id = 's1'`, + } + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("%s: %v", statement, err) + } + } + for _, table := range []string{"message", "part", "todo", "session_message"} { + if got := rowCount(t, db, table); got != 0 { + t.Errorf("%s rows after session delete = %d", table, got) + } + } + if got := rowCount(t, db, "permission"); got != 1 { + t.Fatalf("permission rows after session delete = %d", got) + } + if _, err := db.Exec(`DELETE FROM project WHERE id = 'p1'`); err != nil { + t.Fatal(err) + } + if got := rowCount(t, db, "permission"); got != 0 { + t.Fatalf("permission rows after project delete = %d", got) + } +} + +func tableColumns(t *testing.T, db *sql.DB, table string) []string { + t.Helper() + rows, err := db.Query("PRAGMA table_info(" + table + ")") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := []string{} + for rows.Next() { + var cid int + var name, typ string + var notNull, pk int + var defaultValue any + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + t.Fatal(err) + } + out = append(out, name) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func rowCount(t *testing.T, db *sql.DB, table string) int { + t.Helper() + var count int + if err := db.QueryRow("SELECT count(*) FROM " + table).Scan(&count); err != nil { + t.Fatal(err) + } + return count +} diff --git a/internal/seniordev/session/runbudget/runbudget.go b/internal/seniordev/session/runbudget/runbudget.go new file mode 100644 index 0000000000..8e6fd15450 --- /dev/null +++ b/internal/seniordev/session/runbudget/runbudget.go @@ -0,0 +1,151 @@ +//go:build !windows + +// Package runbudget resolves the optional run-level cost and wall-clock +// budget from flags and environment and tracks spend against it. +package runbudget + +import ( + "math" + "os" + "strconv" + "strings" + "time" +) + +// RunBudget is an explicitly supplied run-level budget. A nil field means +// that dimension is unbounded. +type RunBudget struct { + MaxCostUSD *float64 `json:"maxCostUsd,omitempty"` + MaxWallMS *float64 `json:"maxWallMs,omitempty"` +} + +// RunBudgetFlags mirrors the two CLI flag values. +type RunBudgetFlags struct { + MaxCost *float64 `json:"maxCost"` + MaxHours *float64 `json:"maxHours"` +} + +// BudgetExhaustion is the result of a tracker check. +type BudgetExhaustion struct { + Yes bool `json:"yes"` + Reason *string `json:"reason,omitempty"` +} + +func finite(n float64) bool { return !math.IsNaN(n) && !math.IsInf(n, 0) } + +func positiveNumber(n float64) (float64, bool) { + return n, finite(n) && n > 0 +} + +func positiveString(raw string, present bool) (float64, bool) { + if !present || strings.TrimSpace(raw) == "" { + return 0, false + } + parsed, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil { + return 0, false + } + return positiveNumber(parsed) +} + +// ResolveRunBudget applies flag-over-environment precedence. A non-nil env map +// is used exactly (including an explicitly empty map); nil reads process env. +func ResolveRunBudget(flags *RunBudgetFlags, env map[string]string) RunBudget { + lookup := func(name string) (string, bool) { + if env != nil { + value, ok := env[name] + return value, ok + } + return os.LookupEnv(name) + } + + var maxCost float64 + var hasMaxCost bool + if flags != nil && flags.MaxCost != nil { + maxCost, hasMaxCost = positiveNumber(*flags.MaxCost) + } + if !hasMaxCost { + raw, present := lookup("SENIOR_DEV_MAX_COST_USD") + maxCost, hasMaxCost = positiveString(raw, present) + } + + var maxHours float64 + var hasMaxHours bool + if flags != nil && flags.MaxHours != nil { + maxHours, hasMaxHours = positiveNumber(*flags.MaxHours) + } + if !hasMaxHours { + raw, present := lookup("SENIOR_DEV_MAX_WALL_H") + maxHours, hasMaxHours = positiveString(raw, present) + } + + budget := RunBudget{} + if hasMaxCost { + budget.MaxCostUSD = &maxCost + } + if hasMaxHours { + maxWall := maxHours * 3_600_000 + budget.MaxWallMS = &maxWall + } + return budget +} + +// IsBounded reports whether either property is present. +func IsBounded(budget RunBudget) bool { + return budget.MaxCostUSD != nil || budget.MaxWallMS != nil +} + +// BudgetTracker is the mutable accumulator returned by MakeBudgetTracker. +type BudgetTracker struct { + Budget RunBudget + startTS float64 + cost float64 + now func() float64 +} + +// MakeBudgetTracker creates a tracker. The optional prior cost defaults to 0. +func MakeBudgetTracker(budget RunBudget, startTS float64, priorCostUSD ...float64) *BudgetTracker { + prior := float64(0) + if len(priorCostUSD) > 0 && finite(priorCostUSD[0]) && priorCostUSD[0] > 0 { + prior = priorCostUSD[0] + } + return &BudgetTracker{ + Budget: budget, + startTS: startTS, + cost: prior, + now: func() float64 { return float64(time.Now().UnixMilli()) }, + } +} + +// AddCost accumulates a positive finite provider-cost delta. +func (t *BudgetTracker) AddCost(usd float64) { + if finite(usd) && usd > 0 { + t.cost += usd + } +} + +// CostUSD returns the accumulated provider cost. +func (t *BudgetTracker) CostUSD() float64 { return t.cost } + +// Exhausted checks cost first and wall time second. Omit nowMS to use the +// ambient wall clock. +func (t *BudgetTracker) Exhausted(nowMS ...float64) BudgetExhaustion { + now := t.now() + if len(nowMS) > 0 { + now = nowMS[0] + } + if t.Budget.MaxCostUSD != nil && t.cost >= *t.Budget.MaxCostUSD { + reason := "cost $" + strconv.FormatFloat(t.cost, 'f', 4, 64) + + " >= budget $" + strconv.FormatFloat(*t.Budget.MaxCostUSD, 'f', 4, 64) + return BudgetExhaustion{Yes: true, Reason: &reason} + } + if t.Budget.MaxWallMS != nil { + elapsed := now - t.startTS + if elapsed >= *t.Budget.MaxWallMS { + reason := "wall " + strconv.FormatFloat(math.Round(elapsed/1000), 'f', -1, 64) + + "s >= budget " + strconv.FormatFloat(math.Round(*t.Budget.MaxWallMS/1000), 'f', -1, 64) + "s" + return BudgetExhaustion{Yes: true, Reason: &reason} + } + } + return BudgetExhaustion{Yes: false} +} diff --git a/internal/seniordev/session/sessioncore/sessioncore.go b/internal/seniordev/session/sessioncore/sessioncore.go new file mode 100644 index 0000000000..2863e9b7b3 --- /dev/null +++ b/internal/seniordev/session/sessioncore/sessioncore.go @@ -0,0 +1,512 @@ +//go:build !windows + +// Package sessioncore owns the durable session lifecycle: sessions, messages +// and parts are written to the flat JSON storage and every change is published +// on the bus. +package sessioncore + +import ( + "context" + "encoding/json" + "errors" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/calc" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/id" + "github.com/Agent-Field/codeaf/internal/seniordev/storage" +) + +const ( + parentTitlePrefix = "New session - " + childTitlePrefix = "Child session - " +) + +var defaultTitlePattern = regexp.MustCompile(`^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$`) + +var ( + EventCreated = bus.Define("session.created", nil) + EventUpdated = bus.Define("session.updated", nil) + EventDeleted = bus.Define("session.deleted", nil) + EventDiff = bus.Define("session.diff", nil) + EventError = bus.Define("session.error", nil) + + EventMessageUpdated = bus.Define(msgmodel.EventMessageUpdated, nil) + EventMessageRemoved = bus.Define(msgmodel.EventMessageRemoved, nil) + EventMessagePartUpdated = bus.Define(msgmodel.EventMessagePartUpdated, nil) + EventMessagePartDelta = bus.Define(msgmodel.EventMessagePartDelta, nil) + EventMessagePartRemoved = bus.Define(msgmodel.EventMessagePartRemoved, nil) +) + +type Summary struct { + Additions uint64 `json:"additions"` + Deletions uint64 `json:"deletions"` + Files uint64 `json:"files"` + Diffs []msgmodel.FileDiff `json:"diffs,omitempty"` +} + +type Share struct { + URL string `json:"url"` +} + +type Revert struct { + MessageID string `json:"messageID"` + PartID *string `json:"partID,omitempty"` + Snapshot *string `json:"snapshot,omitempty"` + Diff *string `json:"diff,omitempty"` +} + +type Model struct { + ID string `json:"id"` + ProviderID string `json:"providerID"` + Variant *string `json:"variant,omitempty"` +} + +type Time struct { + Created uint64 `json:"created"` + Updated uint64 `json:"updated"` + Compacting *uint64 `json:"compacting,omitempty"` + Archived *float64 `json:"archived,omitempty"` +} + +// Info is the durable session record. +type Info struct { + ID string `json:"id"` + Slug string `json:"slug"` + ProjectID string `json:"projectID"` + WorkspaceID *string `json:"workspaceID,omitempty"` + Directory string `json:"directory"` + Path *string `json:"path,omitempty"` + ParentID *string `json:"parentID,omitempty"` + Title string `json:"title"` + Agent *string `json:"agent,omitempty"` + Model *Model `json:"model,omitempty"` + Version string `json:"version"` + Summary *Summary `json:"summary,omitempty"` + Share *Share `json:"share,omitempty"` + Revert *Revert `json:"revert,omitempty"` + Permission json.RawMessage `json:"permission,omitempty"` + Time Time `json:"time"` +} + +// Row is the storage-row projection consumed by FromRow and produced by ToRow. +type Row struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + WorkspaceID *string `json:"workspace_id,omitempty"` + ParentID *string `json:"parent_id,omitempty"` + Slug string `json:"slug"` + Directory string `json:"directory"` + Path *string `json:"path,omitempty"` + Title string `json:"title"` + Agent *string `json:"agent,omitempty"` + Model *Model `json:"model,omitempty"` + Version string `json:"version"` + ShareURL *string `json:"share_url,omitempty"` + SummaryAdditions *uint64 `json:"summary_additions,omitempty"` + SummaryDeletions *uint64 `json:"summary_deletions,omitempty"` + SummaryFiles *uint64 `json:"summary_files,omitempty"` + SummaryDiffs []msgmodel.FileDiff `json:"summary_diffs,omitempty"` + Revert *Revert `json:"revert"` + Permission json.RawMessage `json:"permission,omitempty"` + TimeCreated uint64 `json:"time_created"` + TimeUpdated uint64 `json:"time_updated"` + TimeCompacting *uint64 `json:"time_compacting,omitempty"` + TimeArchived *float64 `json:"time_archived,omitempty"` +} + +func IsDefaultTitle(title string) bool { return defaultTitlePattern.MatchString(title) } + +func FromRow(row Row) Info { + var summary *Summary + if row.SummaryAdditions != nil || row.SummaryDeletions != nil || row.SummaryFiles != nil { + summary = &Summary{Diffs: row.SummaryDiffs} + if row.SummaryAdditions != nil { + summary.Additions = *row.SummaryAdditions + } + if row.SummaryDeletions != nil { + summary.Deletions = *row.SummaryDeletions + } + if row.SummaryFiles != nil { + summary.Files = *row.SummaryFiles + } + } + var share *Share + if row.ShareURL != nil && *row.ShareURL != "" { + share = &Share{URL: *row.ShareURL} + } + permission := row.Permission + if string(permission) == "null" { + permission = nil + } + return Info{ + ID: row.ID, Slug: row.Slug, ProjectID: row.ProjectID, + WorkspaceID: row.WorkspaceID, Directory: row.Directory, Path: row.Path, + ParentID: row.ParentID, Summary: summary, Share: share, Title: row.Title, + Agent: row.Agent, Model: row.Model, Version: row.Version, + Time: Time{Created: row.TimeCreated, Updated: row.TimeUpdated, Compacting: row.TimeCompacting, Archived: row.TimeArchived}, + Permission: permission, Revert: row.Revert, + } +} + +func ToRow(info Info) Row { + row := Row{ + ID: info.ID, ProjectID: info.ProjectID, WorkspaceID: info.WorkspaceID, + ParentID: info.ParentID, Slug: info.Slug, Directory: info.Directory, + Path: info.Path, Title: info.Title, Agent: info.Agent, Model: info.Model, + Version: info.Version, Revert: info.Revert, Permission: info.Permission, + TimeCreated: info.Time.Created, TimeUpdated: info.Time.Updated, + TimeCompacting: info.Time.Compacting, TimeArchived: info.Time.Archived, + } + if info.Share != nil { + row.ShareURL = &info.Share.URL + } + if info.Summary != nil { + row.SummaryAdditions = &info.Summary.Additions + row.SummaryDeletions = &info.Summary.Deletions + row.SummaryFiles = &info.Summary.Files + row.SummaryDiffs = info.Summary.Diffs + } + return row +} + +func GetUsage(input calc.GetUsageInput) calc.UsageResult { return calc.GetUsage(input) } + +type CreateInput struct { + ID string + ParentID string + Title string + Agent string + Model *Model + Permission json.RawMessage + WorkspaceID string + Directory string + Path string +} + +type Options struct { + Store *storage.Store + Bus *bus.Bus + ProjectID string + Worktree string + Directory string + WorkspaceID string + Version string + Now func() time.Time + Slug func() string +} + +// Service is safe for concurrent processor and observer use. +type Service struct { + store *storage.Store + bus *bus.Bus + projectID string + worktree string + directory string + workspaceID string + version string + now func() time.Time + slug func() string +} + +func New(opts Options) (*Service, error) { + if opts.Store == nil { + return nil, errors.New("sessioncore: Store is required") + } + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Version == "" { + opts.Version = "0.0.0" + } + if opts.Slug == nil { + opts.Slug = func() string { + value, err := id.Ascending("entry") + if err != nil { + return "" + } + return strings.TrimPrefix(value, "ent_") + } + } + s := &Service{ + store: opts.Store, bus: opts.Bus, projectID: opts.ProjectID, + worktree: opts.Worktree, directory: opts.Directory, + workspaceID: opts.WorkspaceID, version: opts.Version, now: opts.Now, + slug: opts.Slug, + } + return s, nil +} + +func (s *Service) Create(ctx context.Context, input CreateInput) (Info, error) { + _ = ctx + sessionID, err := id.Descending("session", input.ID) + if err != nil { + return Info{}, err + } + now := uint64(s.now().UnixMilli()) + directory := input.Directory + if directory == "" { + directory = s.directory + } + path := input.Path + if path == "" && s.worktree != "" { + rel, relErr := filepath.Rel(filepath.Clean(s.worktree), directory) + if relErr == nil { + path = filepath.ToSlash(rel) + } + } + title := input.Title + if title == "" { + title = createDefaultTitle(input.ParentID != "", time.UnixMilli(int64(now)).UTC()) + } + info := Info{ + ID: sessionID, Slug: s.slug(), ProjectID: s.projectID, Directory: directory, + Title: title, Model: input.Model, Version: s.version, + Time: Time{Created: now, Updated: now}, Permission: input.Permission, + } + if path != "" { + info.Path = &path + } + if input.ParentID != "" { + info.ParentID = &input.ParentID + } + workspace := input.WorkspaceID + if workspace == "" { + workspace = s.workspaceID + } + if workspace != "" { + info.WorkspaceID = &workspace + } + if input.Agent != "" { + info.Agent = &input.Agent + } + if err := s.store.Write(sessionKey(info.ID), info); err != nil { + return Info{}, err + } + s.publish(EventCreated, createdEvent{SessionID: info.ID, Info: info}) + // A session.updated follows session.created so subscribers that only + // track updates also see the new session. + s.publish(EventUpdated, createdEvent{SessionID: info.ID, Info: info}) + return info, nil +} + +func createDefaultTitle(child bool, now time.Time) string { + prefix := parentTitlePrefix + if child { + prefix = childTitlePrefix + } + return prefix + now.UTC().Format("2006-01-02T15:04:05.000Z") +} + +func (s *Service) Get(_ context.Context, sessionID string) (Info, error) { + var info Info + if err := s.store.ReadInto(sessionKey(sessionID), &info); err != nil { + var miss *storage.NotFoundError + if errors.As(err, &miss) { + return Info{}, &storage.NotFoundError{Message: "Session not found: " + sessionID} + } + return Info{}, err + } + return info, nil +} + +func (s *Service) List(ctx context.Context) ([]Info, error) { + _ = ctx + keys, err := s.store.List([]string{"session"}) + if err != nil { + return nil, err + } + out := make([]Info, 0, len(keys)) + for _, key := range keys { + var info Info + if err := s.store.ReadInto(key, &info); err == nil && (s.projectID == "" || info.ProjectID == s.projectID) { + out = append(out, info) + } + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Time.Updated > out[j].Time.Updated }) + return out, nil +} + +func (s *Service) Children(ctx context.Context, parentID string) ([]Info, error) { + all, err := s.List(ctx) + if err != nil { + return nil, err + } + out := []Info{} + for _, item := range all { + if item.ParentID != nil && *item.ParentID == parentID { + out = append(out, item) + } + } + return out, nil +} + +func (s *Service) Touch(ctx context.Context, sessionID string) error { + return s.patch(ctx, sessionID, func(info *Info) { info.Time.Updated = uint64(s.now().UnixMilli()) }) +} + +func (s *Service) patch(ctx context.Context, sessionID string, mutate func(*Info)) error { + _ = ctx + info, err := storage.UpdateAs(s.store, sessionKey(sessionID), func(info *Info) { + mutate(info) + }) + if err != nil { + var miss *storage.NotFoundError + if errors.As(err, &miss) { + return &storage.NotFoundError{Message: "Session not found: " + sessionID} + } + return err + } + s.publish(EventUpdated, createdEvent{SessionID: sessionID, Info: info}) + return nil +} + +func (s *Service) Remove(ctx context.Context, sessionID string) error { + info, err := s.Get(ctx, sessionID) + if err != nil { + return err + } + children, _ := s.Children(ctx, sessionID) + for _, child := range children { + _ = s.Remove(ctx, child.ID) + } + for _, prefix := range [][]string{{"message", sessionID}, {"part", sessionID}} { + keys, _ := s.store.List(prefix) + for _, key := range keys { + _ = s.store.Remove(key) + } + } + if err := s.store.Remove(sessionKey(sessionID)); err != nil { + return err + } + s.publish(EventDeleted, createdEvent{SessionID: sessionID, Info: info}) + return nil +} + +func (s *Service) UpdateMessage(_ context.Context, info msgmodel.Info) error { + sessionID := messageSessionID(info) + if err := s.store.Write(messageKey(sessionID, info.MessageID()), info); err != nil { + return err + } + s.publish(EventMessageUpdated, msgmodel.UpdatedEvent{SessionID: sessionID, Info: info}) + return nil +} + +func (s *Service) UpdatePart(_ context.Context, part msgmodel.Part) error { + base := part.Base() + if err := s.store.Write(partKey(base.SessionID, base.MessageID, base.ID), part); err != nil { + return err + } + s.publish(EventMessagePartUpdated, msgmodel.PartUpdatedEvent{ + SessionID: base.SessionID, Part: part, Time: uint64(s.now().UnixMilli()), + }) + return nil +} + +// UpdateMessageWithParts makes the message visible only after all of its parts +// are durable. The files are written part-first under one store operation, then +// projected message-first so SQLite foreign keys observe a complete turn. +func (s *Service) UpdateMessageWithParts( + _ context.Context, info msgmodel.Info, parts ...msgmodel.Part, +) error { + sessionID := messageSessionID(info) + messageID := info.MessageID() + items := make([]storage.WriteItem, 0, len(parts)+1) + for _, part := range parts { + base := part.Base() + items = append(items, storage.WriteItem{ + Key: partKey(base.SessionID, base.MessageID, base.ID), Content: part, + }) + } + items = append(items, storage.WriteItem{ + Key: messageKey(sessionID, messageID), Content: info, + }) + if err := s.store.WriteBatch(items); err != nil { + return err + } + s.publish(EventMessageUpdated, msgmodel.UpdatedEvent{SessionID: sessionID, Info: info}) + for _, part := range parts { + base := part.Base() + s.publish(EventMessagePartUpdated, msgmodel.PartUpdatedEvent{ + SessionID: base.SessionID, Part: part, Time: uint64(s.now().UnixMilli()), + }) + } + return nil +} + +func (s *Service) UpdatePartDelta(_ context.Context, input msgmodel.PartDeltaEvent) { + s.publish(EventMessagePartDelta, input) +} + +// Messages returns the session's messages oldest-first, each with its parts. +func (s *Service) Messages(ctx context.Context, sessionID string) ([]msgmodel.WithParts, error) { + _ = ctx + keys, err := s.store.List([]string{"message", sessionID}) + if err != nil { + return nil, err + } + out := make([]msgmodel.WithParts, 0, len(keys)) + for _, key := range keys { + var raw json.RawMessage + if err := s.store.ReadInto(key, &raw); err != nil { + return nil, err + } + info, err := msgmodel.UnmarshalInfo(raw) + if err != nil { + return nil, err + } + partKeys, err := s.store.List([]string{"part", sessionID, info.MessageID()}) + if err != nil { + return nil, err + } + parts := make(msgmodel.Parts, 0, len(partKeys)) + for _, partKey := range partKeys { + var partRaw json.RawMessage + if err := s.store.ReadInto(partKey, &partRaw); err != nil { + return nil, err + } + part, err := msgmodel.UnmarshalPart(partRaw) + if err != nil { + return nil, err + } + parts = append(parts, part) + } + out = append(out, msgmodel.WithParts{Info: info, Parts: parts}) + } + return out, nil +} + +func (s *Service) publish(def bus.Definition, properties any) { + if s.bus != nil { + s.bus.Publish(def, properties) + } +} + +type createdEvent struct { + SessionID string `json:"sessionID"` + Info Info `json:"info"` +} + +func sessionKey(id string) []string { return []string{"session", id} } +func messageKey(sessionID, messageID string) []string { + return []string{"message", sessionID, messageID} +} +func partKey(sessionID, messageID, partID string) []string { + return []string{"part", sessionID, messageID, partID} +} + +func messageSessionID(info msgmodel.Info) string { + switch item := info.(type) { + case msgmodel.User: + return item.SessionID + case msgmodel.Assistant: + return item.SessionID + default: + return "" + } +} diff --git a/internal/seniordev/session/sessioncore/sessioncore_test.go b/internal/seniordev/session/sessioncore/sessioncore_test.go new file mode 100644 index 0000000000..9e9fe8b11f --- /dev/null +++ b/internal/seniordev/session/sessioncore/sessioncore_test.go @@ -0,0 +1,189 @@ +//go:build !windows + +package sessioncore + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/storage" +) + +func newTestService(t *testing.T) (*Service, *bus.Bus) { + t.Helper() + b := bus.New(bus.Context{}, bus.WithIDGenerator(func() string { return "evt_test" })) + s, err := New(Options{ + Store: storage.New(t.TempDir()), Bus: b, ProjectID: "p", Worktree: "/work", + Directory: "/work/sub", WorkspaceID: "wrk", Version: "v", + Now: func() time.Time { return time.UnixMilli(1_722_124_923_004) }, + Slug: func() string { return "slug" }, + }) + if err != nil { + t.Fatal(err) + } + return s, b +} + +func TestLifecycleAndEventOrder(t *testing.T) { + s, b := newTestService(t) + var mu sync.Mutex + var events []string + unsub := b.SubscribeAllCallback(func(p bus.Payload) { + mu.Lock() + events = append(events, p.Type) + mu.Unlock() + }) + defer unsub() + ctx := context.Background() + original, err := s.Create(ctx, CreateInput{ID: "ses_original"}) + if err != nil { + t.Fatal(err) + } + user := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "msg_1", SessionID: original.ID}, + Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder", + Model: msgmodel.UserModel{ProviderID: "p", ModelID: "m"}, + } + if err := s.UpdateMessage(ctx, user); err != nil { + t.Fatal(err) + } + if err := s.UpdatePart(ctx, msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "prt_1", SessionID: original.ID, MessageID: user.ID}, + Text: "hello", + }); err != nil { + t.Fatal(err) + } + messages, err := s.Messages(ctx, original.ID) + if err != nil || len(messages) != 1 || len(messages[0].Parts) != 1 { + t.Fatalf("messages = %#v, %v", messages, err) + } + mu.Lock() + defer mu.Unlock() + want := []string{ + EventCreated.Type, EventUpdated.Type, + msgmodel.EventMessageUpdated, msgmodel.EventMessagePartUpdated, + } + if len(events) != len(want) { + t.Fatalf("events = %v, want %v", events, want) + } + for index := range want { + if events[index] != want[index] { + t.Fatalf("events = %v, want %v", events, want) + } + } +} +func TestPartDeltaIsBusOnly(t *testing.T) { + s, b := newTestService(t) + seen := make(chan msgmodel.PartDeltaEvent, 1) + unsub := b.SubscribeCallback(EventMessagePartDelta, func(p bus.Payload) { + raw, _ := json.Marshal(p.Properties) + var event msgmodel.PartDeltaEvent + _ = json.Unmarshal(raw, &event) + seen <- event + }) + defer unsub() + s.UpdatePartDelta(context.Background(), msgmodel.PartDeltaEvent{ + SessionID: "s", MessageID: "m", PartID: "p", Field: "text", Delta: "<&", + }) + select { + case event := <-seen: + if event.Delta != "<&" { + t.Fatalf("event %#v", event) + } + case <-time.After(time.Second): + t.Fatal("missing delta") + } +} + +func TestMessageWithPartsNeverLeavesEmptyUserTurnContract(t *testing.T) { + // A crash/failure boundary may leave an orphan part, but never a visible + // user message without its part. + s, _ := newTestService(t) + blocker := filepath.Join(s.store.Dir, "message", "ses") + if err := os.MkdirAll(filepath.Dir(blocker), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(blocker, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + user := msgmodel.User{ + MessageBase: msgmodel.MessageBase{ID: "msg", SessionID: "ses"}, + Time: msgmodel.TimeCreated{Created: 1}, Agent: "coder", + } + part := msgmodel.TextPart{ + PartBase: msgmodel.PartBase{ID: "part", SessionID: "ses", MessageID: "msg"}, + Text: "prompt", + } + if err := s.UpdateMessageWithParts(context.Background(), user, part); err == nil { + t.Fatal("blocked message write succeeded") + } + if _, err := os.Stat(filepath.Join(s.store.Dir, "part", "ses", "msg", "part.json")); err != nil { + t.Fatalf("part was not written first: %v", err) + } + if _, err := os.Stat(filepath.Join(s.store.Dir, "message", "ses", "msg.json")); err == nil { + t.Fatalf("empty message became visible: %v", err) + } +} + +func TestSessionPatchAtomicAcrossServicesContract(t *testing.T) { + // Two session services sharing flat storage cannot lose independent + // fields through an unlocked read/modify/write. + root := t.TempDir() + makeService := func() *Service { + service, err := New(Options{ + Store: storage.New(root), Bus: bus.New(bus.Context{}), ProjectID: "p", + Directory: "/work", Worktree: "/work", Slug: func() string { return "slug" }, + }) + if err != nil { + t.Fatal(err) + } + return service + } + first, second := makeService(), makeService() + created, err := first.Create(context.Background(), CreateInput{ID: "ses_atomic", Title: "base"}) + if err != nil { + t.Fatal(err) + } + firstEntered, releaseFirst, firstDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) + secondEntered, releaseSecond, secondDone := make(chan struct{}), make(chan struct{}), make(chan error, 1) + go func() { + firstDone <- first.patch(context.Background(), created.ID, func(info *Info) { + close(firstEntered) + <-releaseFirst + info.Title = "preserved title" + }) + }() + <-firstEntered + archived := 42.0 + go func() { + secondDone <- second.patch(context.Background(), created.ID, func(info *Info) { + close(secondEntered) + <-releaseSecond + info.Time.Archived = &archived + }) + }() + select { + case <-secondEntered: + case <-time.After(50 * time.Millisecond): + } + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + <-secondEntered + close(releaseSecond) + if err := <-secondDone; err != nil { + t.Fatal(err) + } + got, err := first.Get(context.Background(), created.ID) + if err != nil || got.Title != "preserved title" || got.Time.Archived == nil || *got.Time.Archived != archived { + t.Fatalf("atomic session patch = %+v, %v", got, err) + } +} diff --git a/internal/seniordev/session/system/system.go b/internal/seniordev/session/system/system.go new file mode 100644 index 0000000000..c4364ea430 --- /dev/null +++ b/internal/seniordev/session/system/system.go @@ -0,0 +1,81 @@ +//go:build !windows + +// Package system builds the environment block of a turn's system prompt: +// the model in use, the working directory and the date. +package system + +import ( + "runtime" + "time" +) + +type API struct { + ID string `json:"id"` +} + +type Model struct { + ProviderID string `json:"providerID"` + API API `json:"api"` +} + +type Project struct { + VCS string `json:"vcs"` +} + +type Context struct { + Directory string `json:"directory"` + Worktree string `json:"worktree"` + Project Project `json:"project"` +} + +type Service struct { + Context Context + Now func() time.Time + Platform string +} + +func New(context Context) *Service { + return &Service{Context: context, Now: time.Now, Platform: platformName()} +} + +func (service *Service) Environment(model Model) []string { + now := service.Now + if now == nil { + now = time.Now + } + platform := service.Platform + if platform == "" { + platform = platformName() + } + return BuildEnvironment(model, service.Context, now(), platform) +} + +func BuildEnvironment( + model Model, context Context, now time.Time, platform string, +) []string { + isGit := "no" + if context.Project.VCS == "git" { + isGit = "yes" + } + return []string{ + "You are powered by the model named " + model.API.ID + + ". The exact model ID is " + model.ProviderID + "/" + model.API.ID + "\n" + + "Here is some useful information about the environment you are running in:\n" + + "<env>\n" + + " Working directory: " + context.Directory + "\n" + + " Workspace root folder: " + context.Worktree + "\n" + + " Is directory a git repo: " + isGit + "\n" + + " Platform: " + platform + "\n" + + " Today's date: " + now.Format("Mon Jan 02 2006") + "\n" + + "</env>", + } +} + +// platformName is the OS label shown in the prompt; Windows is reported as +// win32. +func platformName() string { + if runtime.GOOS == "windows" { + return "win32" + } + return runtime.GOOS +} diff --git a/internal/seniordev/stagewords_test.go b/internal/seniordev/stagewords_test.go new file mode 100644 index 0000000000..062b5f6440 --- /dev/null +++ b/internal/seniordev/stagewords_test.go @@ -0,0 +1,54 @@ +//go:build !windows + +package seniordev + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/app" +) + +// EVERY STAGE senior-dev CAN REPORT HAS A WORD A PERSON READS, and no word is +// machinery. app.Stages is the closed list of what the run can emit (its own +// test holds it to the source), so a stage added there without a word here +// fails now rather than showing its name on somebody's task page. +func TestEveryStageSeniorDevReportsHasAPersonsWord(t *testing.T) { + banned := []string{"auditor", "verdict", "verified", "refuted", "runtime", "contract", "router"} + for _, stage := range app.Stages { + word := strings.TrimSpace(Program.StageWords[stage]) + if word == "" { + t.Errorf("stage %q has no word a person reads", stage) + continue + } + for _, bad := range banned { + if strings.Contains(word, bad) { + t.Errorf("stage %q reads %q, which says %q", stage, word, bad) + } + } + } + for stage := range Program.StageWords { + if !contains(app.Stages, stage) { + t.Errorf("a word is kept for %q, which senior-dev never reports", stage) + } + } +} + +// SENIOR-DEV'S LANDING STAGE IS ITS LAST CHECKS, NOT ITS START. It reports +// `landing` once as it starts (superseded by `implement` within milliseconds) +// and then for its end-of-run landing turn and the build and tests it runs on +// the tree it leaves, which read `starting` for those last minutes. +func TestSeniorDevsLandingStageReadsAsCheckingItsWork(t *testing.T) { + if word := Program.StageWords["landing"]; word != "checking its work" { + t.Fatalf("senior-dev's landing stage reads %q, want %q", word, "checking its work") + } +} + +func contains(list []string, want string) bool { + for _, item := range list { + if item == want { + return true + } + } + return false +} diff --git a/internal/seniordev/storage/storage.go b/internal/seniordev/storage/storage.go new file mode 100644 index 0000000000..881aa7759e --- /dev/null +++ b/internal/seniordev/storage/storage.go @@ -0,0 +1,524 @@ +//go:build !windows + +// Package storage is the flat-file JSON store. Per-resource locks coordinate +// goroutines while advisory file locks make read-modify-write safe across +// processes. +package storage + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" + "golang.org/x/sys/unix" +) + +// NotFoundError is the model-visible storage miss. +type NotFoundError struct { + Message string `json:"message"` +} + +func (e *NotFoundError) Error() string { return e.Message } + +// Store is a JSON store rooted at Dir. +type Store struct { + Dir string + + once sync.Once + init error + mu sync.Mutex + locks map[string]*sync.RWMutex +} + +// WriteItem is one resource in a WriteBatch operation. +type WriteItem struct { + Key []string + Content any +} + +// Option configures a Store. +type Option func(*Store) + +// New constructs a store at the exact storage directory passed by the caller. +func New(dir string, options ...Option) *Store { + s := &Store{ + Dir: dir, + locks: make(map[string]*sync.RWMutex), + } + for _, option := range options { + option(s) + } + return s +} + +// NewFromDataDir roots the store at dataDir/storage. +func NewFromDataDir(dataDir string, options ...Option) *Store { + return New(filepath.Join(dataDir, "storage"), options...) +} + +func (s *Store) initialize() error { + s.once.Do(func() { + s.init = os.MkdirAll(s.Dir, 0o755) + }) + return s.init +} + +func (s *Store) target(key []string) string { + parts := append([]string{s.Dir}, key...) + return filepath.Join(parts...) + ".json" +} + +func (s *Store) lock(target string) *sync.RWMutex { + s.mu.Lock() + defer s.mu.Unlock() + lock := s.locks[target] + if lock == nil { + lock = &sync.RWMutex{} + s.locks[target] = lock + } + return lock +} + +func (s *Store) withAdvisoryLock(exclusive bool, fn func() error) error { + return withFileLock(filepath.Join(s.Dir, ".lock"), exclusive, fn) +} + +func withFileLock(lockPath string, exclusive bool, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return err + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return err + } + defer file.Close() + operation := unix.LOCK_SH + if exclusive { + operation = unix.LOCK_EX + } + if err := unix.Flock(int(file.Fd()), operation); err != nil { + return err + } + defer unix.Flock(int(file.Fd()), unix.LOCK_UN) + return fn() +} + +func (s *Store) resourceLockPath(target string) string { + hash := sha256.Sum256([]byte(filepath.Clean(target))) + return filepath.Join(s.Dir, ".locks", fmt.Sprintf("%x.lock", hash)) +} + +func (s *Store) withResourceAdvisoryLock(target string, exclusive bool, fn func() error) error { + return withFileLock(s.resourceLockPath(target), exclusive, fn) +} + +func (s *Store) withResourceLocks(targets []string, fn func() error) error { + unique := make(map[string]struct{}, len(targets)) + ordered := make([]string, 0, len(targets)) + for _, target := range targets { + if _, exists := unique[target]; exists { + continue + } + unique[target] = struct{}{} + ordered = append(ordered, target) + } + sort.Strings(ordered) + for _, target := range ordered { + s.lock(target).Lock() + } + defer func() { + for index := len(ordered) - 1; index >= 0; index-- { + s.lock(ordered[index]).Unlock() + } + }() + files := make([]*os.File, 0, len(ordered)) + for _, target := range ordered { + lockPath := s.resourceLockPath(target) + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + closeResourceLocks(files) + return err + } + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + closeResourceLocks(files) + return err + } + if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil { + _ = file.Close() + closeResourceLocks(files) + return err + } + files = append(files, file) + } + defer closeResourceLocks(files) + return fn() +} + +func closeResourceLocks(files []*os.File) { + for index := len(files) - 1; index >= 0; index-- { + _ = unix.Flock(int(files[index].Fd()), unix.LOCK_UN) + _ = files[index].Close() + } +} + +// Remove deletes a stored resource. Missing resources are ignored. +func (s *Store) Remove(key []string) error { + if err := s.initialize(); err != nil { + return err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + return s.withResourceAdvisoryLock(target, true, func() error { + err := os.Remove(target) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return err + } + return syncDirectory(filepath.Dir(target)) + }) +} + +// Read decodes a resource into a generic JSON value (maps, slices, float64). +func (s *Store) Read(key []string) (any, error) { + if err := s.initialize(); err != nil { + return nil, err + } + target := s.target(key) + lock := s.lock(target) + lock.RLock() + defer lock.RUnlock() + var data []byte + err := s.withResourceAdvisoryLock(target, false, func() error { + var readErr error + data, readErr = os.ReadFile(target) + return readErr + }) + if errors.Is(err, fs.ErrNotExist) { + return nil, &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return nil, err + } + var value any + if err := json.Unmarshal(data, &value); err != nil { + return nil, err + } + return value, nil +} + +// ReadInto decodes a resource into dst using encoding/json. +func (s *Store) ReadInto(key []string, dst any) error { + if err := s.initialize(); err != nil { + return err + } + target := s.target(key) + lock := s.lock(target) + lock.RLock() + defer lock.RUnlock() + var data []byte + err := s.withResourceAdvisoryLock(target, false, func() error { + var readErr error + data, readErr = os.ReadFile(target) + return readErr + }) + if errors.Is(err, fs.ErrNotExist) { + return &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} + +// ReadAs decodes a resource into a T. +func ReadAs[T any](s *Store, key []string) (T, error) { + var out T + err := s.ReadInto(key, &out) + return out, err +} + +// Update holds the resource mutex and cross-process flock across read, +// mutation, and rewrite. mutate must not call a Store method for the same key: +// the callback is deliberately non-reentrant and doing so will deadlock. +func (s *Store) Update(key []string, mutate func(any)) (any, error) { + if err := s.initialize(); err != nil { + return nil, err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + var value any + err := s.withResourceAdvisoryLock(target, true, func() error { + data, err := os.ReadFile(target) + if errors.Is(err, fs.ErrNotExist) { + return &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return err + } + if err := json.Unmarshal(data, &value); err != nil { + return err + } + mutate(value) + return writeJSON(target, value) + }) + return value, err +} + +// UpdateAs is a typed update helper. It preserves struct field ordering on the +// rewrite, while Update preserves arbitrary parsed-object ordering. mutate has +// the same non-reentrancy requirement as Update. +func UpdateAs[T any](s *Store, key []string, mutate func(*T)) (T, error) { + var zero T + if err := s.initialize(); err != nil { + return zero, err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + var value T + err := s.withResourceAdvisoryLock(target, true, func() error { + data, err := os.ReadFile(target) + if errors.Is(err, fs.ErrNotExist) { + return &NotFoundError{Message: "Resource not found: " + target} + } + if err != nil { + return err + } + if err := json.Unmarshal(data, &value); err != nil { + return err + } + mutate(&value) + return writeJSON(target, value) + }) + if err != nil { + return zero, err + } + return value, nil +} + +// Write persists content as two-space-indented JSON without a trailing +// newline. +func (s *Store) Write(key []string, content any) error { + if err := s.initialize(); err != nil { + return err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + return s.withResourceAdvisoryLock(target, true, func() error { return writeJSON(target, content) }) +} + +// WriteBatch serializes a related group under ordered per-resource locks. +// Callers control item order; prompt persistence writes parts before the +// message that makes the turn visible. Files are synced individually, then +// each touched directory is synced once after all renames. +func (s *Store) WriteBatch(items []WriteItem) error { + if err := s.initialize(); err != nil { + return err + } + targets := make([]string, len(items)) + for index, item := range items { + targets[index] = s.target(item.Key) + } + return s.withResourceLocks(targets, func() error { return writeJSONBatch(targets, items) }) +} + +// CreateExclusive writes a resource only when no claimant has created it. +// The store-wide advisory lock makes the check/atomic-rename indivisible +// across cooperating processes; the returned boolean reports the winner. +func (s *Store) CreateExclusive(key []string, content any) (bool, error) { + if err := s.initialize(); err != nil { + return false, err + } + target := s.target(key) + lock := s.lock(target) + lock.Lock() + defer lock.Unlock() + created := false + err := s.withResourceAdvisoryLock(target, true, func() error { + if _, err := os.Stat(target); err == nil { + return nil + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + if err := writeJSON(target, content); err != nil { + return err + } + created = true + return nil + }) + return created, err +} + +// List returns descendant resource keys in sorted order. +func (s *Store) List(prefix []string) ([][]string, error) { + if err := s.initialize(); err != nil { + return nil, err + } + cwdParts := append([]string{s.Dir}, prefix...) + cwd := filepath.Join(cwdParts...) + result := [][]string{} + err := s.withAdvisoryLock(false, func() error { + return filepath.WalkDir(cwd, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if path != cwd && (entry.Name() == ".locks" || entry.Name() == "quarantine") { + return filepath.SkipDir + } + return nil + } + if entry.Name() == ".lock" || strings.HasPrefix(entry.Name(), ".tmp-") { + return nil + } + rel, err := filepath.Rel(cwd, path) + if err != nil { + return err + } + // The key is the path minus the five-byte ".json" suffix; the + // extension is not validated, so a foreign file in the store yields + // a truncated key rather than an error. + if len(rel) >= 5 { + rel = rel[:len(rel)-5] + } else { + rel = "" + } + key := append(append([]string{}, prefix...), strings.Split(rel, string(filepath.Separator))...) + result = append(result, key) + return nil + }) + }) + if errors.Is(err, fs.ErrNotExist) { + return [][]string{}, nil + } + if err != nil { + // A walk failure lists nothing rather than failing the caller. + return [][]string{}, nil + } + sort.SliceStable(result, func(i, j int) bool { + return strings.Join(result[i], "/") < strings.Join(result[j], "/") + }) + return result, nil +} + +func writeJSON(target string, content any) error { + data, err := jsonutil.MarshalIndent(content) + if err != nil { + return err + } + return writeBytesAtomic(target, data) +} + +func writeJSONBatch(targets []string, items []WriteItem) error { + directories := map[string]struct{}{} + for index, item := range items { + data, err := jsonutil.MarshalIndent(item.Content) + if err != nil { + return joinBatchSyncError(err, directories) + } + temporary, err := prepareBytesAtomic(targets[index], data) + if err != nil { + return joinBatchSyncError(err, directories) + } + if err := os.Rename(temporary, targets[index]); err != nil { + _ = os.Remove(temporary) + return joinBatchSyncError(err, directories) + } + directories[filepath.Dir(targets[index])] = struct{}{} + } + return syncBatchDirectories(directories) +} + +func joinBatchSyncError(writeErr error, directories map[string]struct{}) error { + if syncErr := syncBatchDirectories(directories); syncErr != nil { + return errors.Join(writeErr, syncErr) + } + return writeErr +} + +func syncBatchDirectories(directories map[string]struct{}) error { + orderedDirectories := make([]string, 0, len(directories)) + for directory := range directories { + orderedDirectories = append(orderedDirectories, directory) + } + sort.Strings(orderedDirectories) + for _, directory := range orderedDirectories { + if err := syncDirectory(directory); err != nil { + return err + } + } + return nil +} + +func writeBytesAtomic(target string, data []byte) error { + temporaryPath, err := prepareBytesAtomic(target, data) + if err != nil { + return err + } + defer os.Remove(temporaryPath) + if err := os.Rename(temporaryPath, target); err != nil { + return err + } + return syncDirectory(filepath.Dir(target)) +} + +func prepareBytesAtomic(target string, data []byte) (string, error) { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", err + } + directory := filepath.Dir(target) + temporary, err := os.CreateTemp(directory, ".tmp-*.json") + if err != nil { + return "", err + } + temporaryPath := temporary.Name() + failed := true + defer func() { + if failed { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o644); err != nil { + _ = temporary.Close() + return "", err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return "", err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return "", err + } + if err := temporary.Close(); err != nil { + return "", err + } + failed = false + return temporaryPath, nil +} + +func syncDirectory(directory string) error { + file, err := os.Open(directory) + if err != nil { + return err + } + defer file.Close() + return file.Sync() +} diff --git a/internal/seniordev/storage/storage_test.go b/internal/seniordev/storage/storage_test.go new file mode 100644 index 0000000000..a98db1b88b --- /dev/null +++ b/internal/seniordev/storage/storage_test.go @@ -0,0 +1,271 @@ +//go:build !windows + +package storage + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "sync" + "testing" + "time" +) + +func TestStoreReadWriteUpdateListRemove(t *testing.T) { + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + type record struct { + Z float64 `json:"z"` + A string `json:"a"` + } + if err := store.Write([]string{"session", "s1"}, record{Z: 1, A: "before"}); err != nil { + t.Fatal(err) + } + + raw, err := os.ReadFile(filepath.Join(root, "session", "s1.json")) + if err != nil { + t.Fatal(err) + } + want := "{\n \"z\": 1,\n \"a\": \"before\"\n}" + if string(raw) != want { + t.Fatalf("write bytes:\n got %q\nwant %q", raw, want) + } + + updated, err := store.Update([]string{"session", "s1"}, func(value any) { + object := value.(map[string]any) + object["a"] = "after" + object["new"] = true + }) + if err != nil { + t.Fatal(err) + } + wantUpdated := map[string]any{"z": float64(1), "a": "after", "new": true} + if !reflect.DeepEqual(updated, wantUpdated) { + t.Fatalf("updated value: %#v", updated) + } + if got, err := ReadAs[record](store, []string{"session", "s1"}); err != nil || got.A != "after" { + t.Fatalf("read after update: %+v, %v", got, err) + } + + keys, err := store.List([]string{"session"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(keys, [][]string{{"session", "s1"}}) { + t.Fatalf("list: %#v", keys) + } + + if err := store.Remove([]string{"session", "s1"}); err != nil { + t.Fatal(err) + } + if err := store.Remove([]string{"session", "s1"}); err != nil { + t.Fatal(err) + } + _, err = store.Read([]string{"session", "s1"}) + var notFound *NotFoundError + if !errors.As(err, ¬Found) { + t.Fatalf("expected NotFoundError, got %v", err) + } + if notFound.Message != "Resource not found: "+filepath.Join(root, "session", "s1.json") { + t.Fatalf("message: %q", notFound.Message) + } +} + +func TestStoreUpdateSerializesConcurrentMutations(t *testing.T) { + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + type counter struct { + N int `json:"n"` + } + if err := store.Write([]string{"counter"}, counter{}); err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for range 30 { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := UpdateAs(store, []string{"counter"}, func(value *counter) { + value.N++ + }); err != nil { + t.Errorf("update: %v", err) + } + }() + } + wg.Wait() + got, err := ReadAs[counter](store, []string{"counter"}) + if err != nil { + t.Fatal(err) + } + if got.N != 30 { + t.Fatalf("counter = %d, want 30", got.N) + } +} + +func TestStoreUpdatesDifferentResourcesConcurrently(t *testing.T) { + // Unrelated resources do not queue behind a store-wide exclusive lock. + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + for _, key := range []string{"first", "second"} { + if err := store.Write([]string{key}, map[string]any{"value": 0}); err != nil { + t.Fatal(err) + } + } + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"first"}, func(any) { + close(firstEntered) + <-releaseFirst + }) + firstDone <- err + }() + <-firstEntered + secondEntered := make(chan struct{}) + secondDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"second"}, func(any) { close(secondEntered) }) + secondDone <- err + }() + select { + case <-secondEntered: + case <-time.After(2 * time.Second): + close(releaseFirst) + t.Fatal("different-resource update blocked behind the first resource") + } + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } +} + +func TestStoreUpdateCallbackOwnsResourceLock(t *testing.T) { + // Update's callback runs while holding the resource lock, so same-key + // callers serialize around the non-reentrant callback. + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + store := New(root) + if err := store.Write([]string{"shared"}, map[string]any{"value": 0}); err != nil { + t.Fatal(err) + } + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"shared"}, func(any) { + close(firstEntered) + <-releaseFirst + }) + firstDone <- err + }() + <-firstEntered + secondEntered := make(chan struct{}) + secondDone := make(chan error, 1) + go func() { + _, err := store.Update([]string{"shared"}, func(any) { close(secondEntered) }) + secondDone <- err + }() + select { + case <-secondEntered: + close(releaseFirst) + t.Fatal("same-resource callback ran without owning the resource lock") + case <-time.After(25 * time.Millisecond): + } + close(releaseFirst) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + select { + case <-secondEntered: + case <-time.After(2 * time.Second): + t.Fatal("same-resource waiter did not resume after callback returned") + } + if err := <-secondDone; err != nil { + t.Fatal(err) + } +} + +func TestStoreCrossProcessLockAndAtomicReplacement(t *testing.T) { + // Independent store instances cannot lose a read-modify-write, and each + // durable rewrite is an atomic inode replacement. + root := filepath.Join(t.TempDir(), "storage") + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + type counter struct { + N int `json:"n"` + } + first, second := New(root), New(root) + if err := first.Write([]string{"counter"}, counter{}); err != nil { + t.Fatal(err) + } + target := filepath.Join(root, "counter.json") + commands := make([]*exec.Cmd, 2) + for index := range commands { + commands[index] = exec.Command(os.Args[0], "-test.run=^TestStoreProcessUpdateHelper$", "-test.count=1") + commands[index].Env = append(os.Environ(), "SENIOR_DEV_STORAGE_HELPER_ROOT="+root) + if err := commands[index].Start(); err != nil { + t.Fatal(err) + } + } + for _, command := range commands { + if err := command.Wait(); err != nil { + t.Fatalf("storage helper: %v", err) + } + } + got, err := ReadAs[counter](second, []string{"counter"}) + if err != nil || got.N != 40 { + t.Fatalf("cross-store counter = %+v, %v; want 40", got, err) + } + before, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if err := first.Write([]string{"counter"}, got); err != nil { + t.Fatal(err) + } + after, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if os.SameFile(before, after) { + t.Fatal("durable rewrite mutated the JSON inode in place") + } + matches, err := filepath.Glob(filepath.Join(root, ".tmp-*.json")) + if err != nil || len(matches) != 0 { + t.Fatalf("atomic rewrite leftovers = %v, %v", matches, err) + } +} + +func TestStoreProcessUpdateHelper(t *testing.T) { + root := os.Getenv("SENIOR_DEV_STORAGE_HELPER_ROOT") + if root == "" { + t.Skip("subprocess helper") + } + type counter struct { + N int `json:"n"` + } + store := New(root) + for index := 0; index < 20; index++ { + if _, err := UpdateAs(store, []string{"counter"}, func(value *counter) { value.N++ }); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/seniordev/tool/apply_patch.go b/internal/seniordev/tool/apply_patch.go new file mode 100644 index 0000000000..70fe09d0f8 --- /dev/null +++ b/internal/seniordev/tool/apply_patch.go @@ -0,0 +1,307 @@ +//go:build !windows + +// The apply_patch tool: a multi-file patch envelope (add, update, move, +// delete) validated up front and applied after one permission check that +// covers every file it touches. +package tool + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + patchpkg "github.com/Agent-Field/codeaf/internal/seniordev/patch" +) + +type applyPatchChange struct { + filePath string + oldContent string + newContent string + kind string + movePath string + diff string + additions int + deletions int + bom bool +} + +type applyPatchFileMetadata struct { + FilePath string `json:"filePath"` + RelativePath string `json:"relativePath"` + Type string `json:"type"` + Patch string `json:"patch"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + MovePath string `json:"movePath,omitempty"` +} + +type applyPatchMetadata struct { + Diff string `json:"diff"` + Files []applyPatchFileMetadata `json:"files"` + Diagnostics map[string]any `json:"diagnostics"` +} + +func (r *Registry) executeApplyPatch(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input applyPatchInput + if err := decodeInput(call.Input, &input, "patchText"); err != nil { + return steploop.ToolResult{}, err + } + if input.PatchText == "" { + return steploop.ToolResult{}, errors.New("patchText is required") + } + parsed, err := patchpkg.ParsePatch(input.PatchText) + if err != nil { + return steploop.ToolResult{}, fmt.Errorf("apply_patch verification failed: Error: %s", err) + } + if len(parsed.Hunks) == 0 { + normalized := strings.ReplaceAll(input.PatchText, "\r\n", "\n") + normalized = strings.ReplaceAll(normalized, "\r", "\n") + normalized = strings.TrimSpace(normalized) + if normalized == "*** Begin Patch\n*** End Patch" { + return steploop.ToolResult{}, errors.New("patch rejected: empty patch") + } + return steploop.ToolResult{}, errors.New("apply_patch verification failed: no hunks found") + } + formatter, err := r.formatterService() + if err != nil { + return steploop.ToolResult{}, err + } + + changes := make([]applyPatchChange, 0, len(parsed.Hunks)) + for _, hunk := range parsed.Hunks { + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + filePath, err := r.resolveWritePath(hunk.Path) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, filePath, "file"); err != nil { + return steploop.ToolResult{}, err + } + switch hunk.Type { + case "add": + newContent := hunk.Contents + if newContent != "" && !strings.HasSuffix(newContent, "\n") { + newContent += "\n" + } + bom, newContent := splitBOM(newContent) + additions, deletions := lineChangeCounts("", newContent) + change := applyPatchChange{ + filePath: filePath, + oldContent: "", + newContent: newContent, + kind: "add", + diff: proposedFileDiff(filePath, "", newContent), + additions: additions, + deletions: deletions, + bom: bom, + } + changes = append(changes, change) + case "update": + info, statErr := os.Stat(filePath) + if statErr != nil || info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf( + "apply_patch verification failed: Failed to read file to update: %s", + filePath, + ) + } + source, readErr := os.ReadFile(filePath) + if readErr != nil { + return steploop.ToolResult{}, fmt.Errorf( + "apply_patch verification failed: Failed to read file to update: %s", + filePath, + ) + } + sourceBOM, oldContent := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + update, deriveErr := patchpkg.DeriveNewContentsFromChunks(filePath, hunk.Chunks) + if deriveErr != nil { + return steploop.ToolResult{}, fmt.Errorf("apply_patch verification failed: Error: %s", deriveErr) + } + movePath := "" + if hunk.MovePath != "" { + movePath, err = r.resolveWritePath(hunk.MovePath) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, movePath, "file"); err != nil { + return steploop.ToolResult{}, err + } + } + additions, deletions := lineChangeCounts(oldContent, update.Content) + kind := "update" + if hunk.MovePath != "" { + kind = "move" + } + change := applyPatchChange{ + filePath: filePath, + oldContent: oldContent, + newContent: update.Content, + kind: kind, + movePath: movePath, + diff: proposedFileDiff(filePath, oldContent, update.Content), + additions: additions, + deletions: deletions, + bom: sourceBOM || update.BOM, + } + changes = append(changes, change) + case "delete": + source, readErr := os.ReadFile(filePath) + if readErr != nil { + return steploop.ToolResult{}, fmt.Errorf("apply_patch verification failed: %s", readErr) + } + bom, oldContent := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + change := applyPatchChange{ + filePath: filePath, + oldContent: oldContent, + newContent: "", + kind: "delete", + diff: proposedFileDiff(filePath, oldContent, ""), + additions: 0, + deletions: len(strings.Split(oldContent, "\n")), + bom: bom, + } + changes = append(changes, change) + } + } + + permissionFiles := make([]applyPatchFileMetadata, 0, len(changes)) + mutationPaths := make([]string, 0, len(changes)*2) + permissionPaths := make([]string, 0, len(changes)) + proposedTotalDiff := "" + for _, change := range changes { + proposedTotalDiff += change.diff + "\n" + mutationPaths = append(mutationPaths, change.filePath) + if change.movePath != "" { + mutationPaths = append(mutationPaths, change.movePath) + } + permissionPath, relErr := filepath.Rel(r.worktree(), change.filePath) + if relErr != nil { + permissionPath = change.filePath + } + permissionPaths = append(permissionPaths, filepath.ToSlash(permissionPath)) + target := change.filePath + if change.movePath != "" { + target = change.movePath + } + relative, err := filepath.Rel(r.workDir, target) + if err != nil { + relative = target + } + permissionFiles = append(permissionFiles, applyPatchFileMetadata{ + FilePath: change.filePath, + RelativePath: filepath.ToSlash(relative), + Type: change.kind, + Patch: change.diff, + Additions: change.additions, + Deletions: change.deletions, + MovePath: change.movePath, + }) + } + metadata := map[string]any{ + "filepath": strings.Join(permissionPaths, ", "), + "diff": proposedTotalDiff, + "files": permissionFiles, + } + if err := r.ask(ctx, call, "edit", permissionPaths, metadata); err != nil { + return steploop.ToolResult{}, err + } + + for index := range changes { + change := &changes[index] + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + switch change.kind { + case "add", "update": + if err := os.MkdirAll(filepath.Dir(change.filePath), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(change.filePath, []byte(joinBOM(change.newContent, change.bom)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + change.newContent, err = formatMutationFile(ctx, formatter, change.filePath, change.bom) + if err != nil { + return steploop.ToolResult{}, err + } + case "move": + if err := os.MkdirAll(filepath.Dir(change.movePath), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(change.movePath, []byte(joinBOM(change.newContent, change.bom)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + if err := os.Remove(change.filePath); err != nil { + return steploop.ToolResult{}, err + } + change.newContent, err = formatMutationFile(ctx, formatter, change.movePath, change.bom) + if err != nil { + return steploop.ToolResult{}, err + } + case "delete": + if err := os.Remove(change.filePath); err != nil { + return steploop.ToolResult{}, err + } + } + } + + totalDiff := "" + files := make([]applyPatchFileMetadata, 0, len(changes)) + for index := range changes { + change := &changes[index] + target := change.filePath + if change.movePath != "" { + target = change.movePath + } + change.diff = TrimDiff(patchpkg.GenerateTwoFilesPatch(target, change.oldContent, change.newContent)) + change.additions, change.deletions = lineChangeCounts(change.oldContent, change.newContent) + totalDiff += change.diff + "\n" + relative, relErr := filepath.Rel(r.workDir, target) + if relErr != nil { + relative = target + } + files = append(files, applyPatchFileMetadata{ + FilePath: change.filePath, + RelativePath: filepath.ToSlash(relative), + Type: change.kind, + Patch: change.diff, + Additions: change.additions, + Deletions: change.deletions, + MovePath: change.movePath, + }) + } + + summary := make([]string, 0, len(changes)) + for _, change := range changes { + target := change.filePath + prefix := "M " + if change.kind == "add" { + prefix = "A " + } + if change.kind == "delete" { + prefix = "D " + } + if change.movePath != "" { + target = change.movePath + } + relative, err := filepath.Rel(r.workDir, target) + if err != nil { + relative = target + } + summary = append(summary, prefix+filepath.ToSlash(relative)) + } + output := "Success. Updated the following files:\n" + strings.Join(summary, "\n") + return steploop.ToolResult{ + Title: output, + Output: output, + Metadata: rawMetadata(applyPatchMetadata{ + Diff: totalDiff, + Files: files, + Diagnostics: map[string]any{}, + }), + }, nil +} diff --git a/internal/seniordev/tool/apply_patch.txt b/internal/seniordev/tool/apply_patch.txt new file mode 100644 index 0000000000..5b2d95608c --- /dev/null +++ b/internal/seniordev/tool/apply_patch.txt @@ -0,0 +1,33 @@ +Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: + +*** Begin Patch +[ one or more file sections ] +*** End Patch + +Within that envelope, you get a sequence of file operations. +You MUST include a header to specify the action you are taking. +Each operation starts with one of three headers: + +*** Add File: <path> - create a new file. Every following line is a + line (the initial contents). +*** Delete File: <path> - remove an existing file. Nothing follows. +*** Update File: <path> - patch an existing file in place (optionally with a rename). + +Example patch: + +``` +*** Begin Patch +*** Add File: hello.txt ++Hello world +*** Update File: src/app.py +*** Move to: src/main.py +@@ def greet(): +-print("Hi") ++print("Hello, world!") +*** Delete File: obsolete.txt +*** End Patch +``` + +It is important to remember: + +- You must include a header with your intended action (Add/Delete/Update) +- You must prefix new lines with `+` even when creating a new file diff --git a/internal/seniordev/tool/apply_patch_description.go b/internal/seniordev/tool/apply_patch_description.go new file mode 100644 index 0000000000..8be462b856 --- /dev/null +++ b/internal/seniordev/tool/apply_patch_description.go @@ -0,0 +1,8 @@ +//go:build !windows + +package tool + +import _ "embed" + +//go:embed apply_patch.txt +var applyPatchDescription string diff --git a/internal/seniordev/tool/apply_patch_test.go b/internal/seniordev/tool/apply_patch_test.go new file mode 100644 index 0000000000..17738e2171 --- /dev/null +++ b/internal/seniordev/tool/apply_patch_test.go @@ -0,0 +1,101 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestApplyPatchAddUpdateMoveDelete(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "update.txt", "one\ntwo\n") + writeTestFile(t, workDir, "move.txt", "\ufeffold\n") + writeTestFile(t, workDir, "delete.txt", "gone\n") + patchText := `*** Begin Patch +*** Add File: nested/added.txt ++added +*** Update File: update.txt +@@ +-two ++second +*** Update File: move.txt +*** Move to: moved/new.txt +@@ +-old ++new +*** Delete File: delete.txt +*** End Patch` + result, err := execute(t, New(workDir), "apply_patch", map[string]any{"patchText": patchText}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "Success. Updated the following files:\n" + + "A nested/added.txt\n" + + "M update.txt\n" + + "M moved/new.txt\n" + + "D delete.txt" + if result.Output != want || result.Title != want { + t.Fatalf("result output = %q", result.Output) + } + assertTestFile(t, workDir, "nested/added.txt", "added\n") + assertTestFile(t, workDir, "update.txt", "one\nsecond\n") + assertTestFile(t, workDir, "moved/new.txt", "\ufeffnew\n") + for _, path := range []string{"move.txt", "delete.txt"} { + if _, err := os.Stat(filepath.Join(workDir, path)); !os.IsNotExist(err) { + t.Fatalf("%s still exists, err=%v", path, err) + } + } +} + +func TestApplyPatchVerificationErrors(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + cases := []struct { + name string + patchText string + want string + }{ + {"required", "", "patchText is required"}, + { + "parse", + "bad", + "apply_patch verification failed: Error: Invalid patch format: missing Begin/End markers", + }, + { + "empty", + "*** Begin Patch\r\n*** End Patch", + "patch rejected: empty patch", + }, + { + "no hunks", + "*** Begin Patch\njunk\n*** End Patch", + "apply_patch verification failed: no hunks found", + }, + { + "missing update", + "*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch", + "apply_patch verification failed: Failed to read file to update: " + filepath.Join(workDir, "missing.txt"), + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + _, err := execute(t, registry, "apply_patch", map[string]any{"patchText": test.patchText}) + if err == nil || err.Error() != test.want { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestApplyPatchDescription(t *testing.T) { + for _, fragment := range []string{ + "*** Begin Patch", "*** End Patch", "*** Add File:", "*** Update File:", "*** Delete File:", + } { + if !strings.Contains(applyPatchDescription, fragment) { + t.Fatalf("embedded description lacks %q", fragment) + } + } +} diff --git a/internal/seniordev/tool/bash.go b/internal/seniordev/tool/bash.go new file mode 100644 index 0000000000..e0ef7538cd --- /dev/null +++ b/internal/seniordev/tool/bash.go @@ -0,0 +1,308 @@ +//go:build !windows + +// The bash tool: runs a command in the workspace shell, enforces its timeout, +// and turns the exit into a tool result with the output capped and spilled. +package tool + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/outputoffload" +) + +const ( + defaultBashTimeoutMS = 120000 + maxBashOutputBytes = 30000 +) + +var bashAfter = time.After +var bashOffloader = outputoffload.DefaultOffloader +var bashReadOOMCount = readShellOOMCount +var bashReadMemoryLimit = readShellMemoryLimit + +type bashOutput struct { + mu sync.Mutex + buffer bytes.Buffer + wrote bool + lastOutput time.Time +} + +func (o *bashOutput) Write(data []byte) (int, error) { + o.mu.Lock() + defer o.mu.Unlock() + o.wrote = true + o.lastOutput = time.Now() + return o.buffer.Write(data) +} + +func (o *bashOutput) bytes() []byte { + o.mu.Lock() + defer o.mu.Unlock() + return append([]byte(nil), o.buffer.Bytes()...) +} + +func (o *bashOutput) lastOutputAt() (time.Time, bool) { + o.mu.Lock() + defer o.mu.Unlock() + return o.lastOutput, o.wrote +} + +func (r *Registry) executeBash(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input bashInput + if err := decodeInput(call.Input, &input, "command"); err != nil { + return steploop.ToolResult{}, err + } + timeoutMS := defaultBashTimeoutMS + if input.TimeoutMS != nil { + timeoutMS = *input.TimeoutMS + } + if timeoutMS < 1 || timeoutMS > 600000 { + return steploop.ToolResult{}, fmt.Errorf("timeout_ms must be between 1 and 600000") + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + cwd := r.workDir + if input.Workdir != "" { + resolved, err := r.resolvePath(input.Workdir) + if err != nil { + return steploop.ToolResult{}, err + } + cwd = resolved + info, statErr := os.Stat(cwd) + if statErr != nil { + return steploop.ToolResult{}, statErr + } + if !info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf("workdir must be a directory: %s", cwd) + } + if err := r.askExternalDirectory(ctx, call, cwd, "directory"); err != nil { + return steploop.ToolResult{}, err + } + } + shell, err := r.executionShell() + if err != nil { + return steploop.ToolResult{}, err + } + scan := ScanShellPermissions(input.Command, ShellScanOptions{ + CWD: cwd, Workspace: r.worktree(), Shell: shell, + IsDir: func(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() + }, + }) + if r.hardConfineShell && len(scan.Dirs) > 0 { + return steploop.ToolResult{}, fmt.Errorf("path escapes workspace: %s", scan.Dirs[0]) + } + if len(scan.Dirs) > 0 { + globs := make([]string, 0, len(scan.Dirs)) + for _, dir := range scan.Dirs { + globs = append(globs, filepath.Join(dir, "*")) + } + if err := r.askWithAlways(ctx, call, "external_directory", globs, globs, map[string]any{}); err != nil { + return steploop.ToolResult{}, err + } + } + if len(scan.Patterns) > 0 { + if err := r.askWithAlways(ctx, call, "bash", scan.Patterns, scan.Always, map[string]any{}); err != nil { + return steploop.ToolResult{}, err + } + } + command := shellExecCommand(shell, input.Command) + command.Dir = cwd + command.Env = shellEnvironment(call.SessionID) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + var output bashOutput + command.Stdout = &output + command.Stderr = &output + envSignalsOn := os.Getenv("SENIOR_DEV_ENV_SIGNALS") != "0" + startedAt := time.Now() + var oomBefore *int64 + if envSignalsOn { + oomBefore = bashReadOOMCount() + } + if err := command.Start(); err != nil { + return steploop.ToolResult{}, fmt.Errorf("start shell command: %w", err) + } + + done := make(chan error, 1) + go func() { + done <- command.Wait() + }() + + var runErr error + expired := false + select { + case runErr = <-done: + case <-bashAfter(time.Duration(timeoutMS) * time.Millisecond): + expired = true + killProcessGroup(command.Process.Pid) + <-done + appendOutputLine(&output, fmt.Sprintf("command timed out after %dms", timeoutMS)) + case <-ctx.Done(): + killProcessGroup(command.Process.Pid) + <-done + return steploop.ToolResult{}, ctx.Err() + } + + var exitCode *int + if runErr == nil && !expired { + code := 0 + exitCode = &code + } else if !expired { + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + return steploop.ToolResult{}, fmt.Errorf("wait for shell command: %w", runErr) + } + code := normalizedExitCode(exitErr) + exitCode = &code + appendOutputLine(&output, fmt.Sprintf("exit status %d", code)) + } + + if envSignalsOn { + duration := time.Since(startedAt) + oomAfter := bashReadOOMCount() + var oomDelta *int64 + if oomBefore != nil && oomAfter != nil { + delta := *oomAfter - *oomBefore + oomDelta = &delta + } + var sinceLast *time.Duration + if lastOutput, ok := output.lastOutputAt(); ok { + quiet := time.Since(lastOutput) + sinceLast = &quiet + } + metadata := []string{} + if death := classifyShellDeath(shellDeathInput{ + ExitCode: exitCode, Expired: expired, OOMDelta: oomDelta, + MemoryLimitBytes: bashReadMemoryLimit(), SinceLastOutput: sinceLast, + Timeout: time.Duration(timeoutMS) * time.Millisecond, CommandDuration: duration, + }); death != "" { + metadata = append(metadata, death) + } + failed := exitCode == nil || *exitCode != 0 + if repeat := registerShellOutcome(call.SessionID, input.Command, duration, failed); repeat != "" { + metadata = append(metadata, repeat) + } + if len(metadata) > 0 { + appendOutputLine(&output, "\n<shell_metadata>\n"+strings.Join(metadata, "\n")+"\n</shell_metadata>") + } + } + + fullOutput := output.bytes() + code := -1 + if exitCode != nil { + code = *exitCode + } + return r.bashResult(call, input.Command, fullOutput, code, exitCode != nil), nil +} + +func (r *Registry) bashResult( + call steploop.ToolCall, command string, fullOutput []byte, exitCode int, hasExitCode bool, +) steploop.ToolResult { + inline := truncateMiddle(fullOutput, maxBashOutputBytes) + if len(fullOutput) > maxBashOutputBytes { + offloaded := bashOffloader.OffloadLargeOutput( + outputoffload.OutputOffloadInput{ + Output: string(fullOutput), Workspace: r.workDir, + ToolName: "bash", CallID: call.ID, SessionID: call.SessionID, + }, + outputoffload.OutputOffloadOptions{Force: true}, + ) + if offloaded.OffloadPath != nil { + inline += "\n\nThe tool call succeeded but the output was truncated. Full output saved to: " + *offloaded.OffloadPath + + "\nUse Grep to search the full content or Read with offset/limit to view specific sections." + } else if fallback := strings.TrimSpace(offloaded.Inline); fallback != "" { + if index := strings.LastIndex(fallback, "\n"); index >= 0 { + fallback = fallback[index+1:] + } + inline += "\n\n" + fallback + } + } + metadata := msgmodel.RawObject("{}") + if hasExitCode { + metadata = msgmodel.RawObject(fmt.Sprintf(`{"exitCode":%d}`, exitCode)) + } + return steploop.ToolResult{Title: firstRunes(command, 60), Metadata: metadata, Output: inline} +} + +func shellExecCommand(shell, command string) *exec.Cmd { + switch ShellName(shell) { + case "cmd": + return exec.Command(shell, "/c", command) + case "powershell", "pwsh": + return exec.Command(shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command) + default: + return exec.Command(shell, "-c", command) + } +} + +func normalizedExitCode(exitErr *exec.ExitError) int { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + return exitErr.ExitCode() +} + +func killProcessGroup(pid int) { + _ = syscall.Kill(-pid, syscall.SIGKILL) +} + +func appendOutputLine(output *bashOutput, line string) { + output.mu.Lock() + defer output.mu.Unlock() + if output.buffer.Len() > 0 && output.buffer.Bytes()[output.buffer.Len()-1] != '\n' { + output.buffer.WriteByte('\n') + } + output.buffer.WriteString(line) +} + +func firstRunes(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func truncateMiddle(data []byte, limit int) string { + if len(data) <= limit { + return string(data) + } + + removed := len(data) - limit + var marker string + var kept int + for { + marker = fmt.Sprintf("[... %d bytes truncated ...]", removed) + kept = limit - len(marker) + if kept < 0 { + return marker[:limit] + } + actualRemoved := len(data) - kept + if actualRemoved == removed { + break + } + removed = actualRemoved + } + + head := kept / 2 + tail := kept - head + result := make([]byte, 0, limit) + result = append(result, data[:head]...) + result = append(result, marker...) + result = append(result, data[len(data)-tail:]...) + return string(result) +} diff --git a/internal/seniordev/tool/bash_clock_test.go b/internal/seniordev/tool/bash_clock_test.go new file mode 100644 index 0000000000..3868dd8591 --- /dev/null +++ b/internal/seniordev/tool/bash_clock_test.go @@ -0,0 +1,32 @@ +//go:build !windows + +package tool + +import ( + "strings" + "testing" + "time" +) + +func TestBashTimeoutWithFakeClockInTempDir(t *testing.T) { + previous := bashAfter + t.Cleanup(func() { bashAfter = previous }) + bashAfter = func(time.Duration) <-chan time.Time { + ch := make(chan time.Time, 1) + ch <- time.Unix(0, 0) + return ch + } + + registry := New(t.TempDir()) + result, err := execute(t, registry, "bash", map[string]any{ + "command": "sleep 30", + "timeout_ms": 10_000, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "command timed out after 10000ms") || + !strings.Contains(result.Output, "[environment-signal] this command timed out") { + t.Fatalf("Output = %q", result.Output) + } +} diff --git a/internal/seniordev/tool/confine_writes_test.go b/internal/seniordev/tool/confine_writes_test.go new file mode 100644 index 0000000000..5f35d3b2cb --- /dev/null +++ b/internal/seniordev/tool/confine_writes_test.go @@ -0,0 +1,81 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/permission" +) + +// WRITES STAY IN THE WORKSPACE, READS DO NOT HAVE TO. codeaf lands only the +// copy a run is handed, so under ConfineWrites every writer refuses a path +// outside the workspace — named absolutely, climbed to with `..`, or reached +// through a link inside the workspace that points out — and the file outside +// is untouched. A read outside still works, because a task's statement can +// live outside its copy. This is the road a run took when its brief told it +// to make a checkout in the person's projects folder and it edited there. +func TestConfinedWritesRefuseEveryPathOutsideTheWorkspace(t *testing.T) { + workspace, external := t.TempDir(), t.TempDir() + target := filepath.Join(external, "outside.txt") + if err := os.WriteFile(target, []byte("keep\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(workspace, "out")); err != nil { + t.Fatal(err) + } + registry := NewWithOptions(workspace, RegistryOptions{ + AllowExternalDirectories: true, + ConfineWrites: true, + Permission: permissionEvaluatorFunc(func(permission.AskInput) error { return nil }), + }) + escape, err := filepath.Rel(workspace, target) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{target, escape, filepath.Join("out", "outside.txt")} { + for _, call := range []struct { + tool string + input map[string]any + }{ + {"write", map[string]any{"filePath": path, "content": "changed\n"}}, + {"edit", map[string]any{"filePath": path, "oldString": "keep", "newString": "changed"}}, + {"apply_patch", map[string]any{"patchText": "*** Begin Patch\n*** Update File: " + path + "\n@@\n-keep\n+changed\n*** End Patch"}}, + {"apply_patch", map[string]any{"patchText": "*** Begin Patch\n*** Add File: " + path + ".new\n+new\n*** End Patch"}}, + } { + _, err := execute(t, registry, call.tool, call.input) + if err == nil || !strings.Contains(err.Error(), "outside this run's workspace") { + t.Fatalf("%s of %q: err = %v, want the write refused", call.tool, path, err) + } + } + } + if data, _ := os.ReadFile(target); string(data) != "keep\n" { + t.Fatalf("the file outside the workspace changed: %q", data) + } + if _, err := os.Stat(target + ".new"); !os.IsNotExist(err) { + t.Fatal("a file was added outside the workspace") + } + if result, err := execute(t, registry, "read", map[string]any{"filePath": target}); err != nil || !strings.Contains(result.Output, "keep") { + t.Fatalf("a read outside the workspace was refused: %v", err) + } + if _, err := execute(t, registry, "write", map[string]any{"filePath": "inside.txt", "content": "fine\n"}); err != nil { + t.Fatalf("a write inside the workspace was refused: %v", err) + } +} + +// AND WITHOUT THE OPTION NOTHING CHANGES: an embedder that never asked for the +// fence keeps the permission flow it had. +func TestUnconfinedWritesStillReachOutsideThroughPermission(t *testing.T) { + workspace, external := t.TempDir(), t.TempDir() + target := filepath.Join(external, "outside.txt") + registry := NewWithOptions(workspace, RegistryOptions{ + AllowExternalDirectories: true, + Permission: permissionEvaluatorFunc(func(permission.AskInput) error { return nil }), + }) + if _, err := execute(t, registry, "write", map[string]any{"filePath": target, "content": "x\n"}); err != nil { + t.Fatalf("an unconfined registry refused an allowed write: %v", err) + } +} diff --git a/internal/seniordev/tool/descriptions.go b/internal/seniordev/tool/descriptions.go new file mode 100644 index 0000000000..f6f63892d5 --- /dev/null +++ b/internal/seniordev/tool/descriptions.go @@ -0,0 +1,38 @@ +//go:build !windows + +package tool + +const readDescription = "Read a file or directory from the local filesystem. If the path does not exist, an error is returned.\n" + + "\n" + + "Usage:\n" + + "- The filePath parameter should be an absolute path.\n" + + "- By default, this tool returns up to 2000 lines from the start of the file.\n" + + "- The offset parameter is the line number to start from (1-indexed).\n" + + "- To read later sections, call this tool again with a larger offset.\n" + + "- Use the grep tool to find specific content in large files or files with long lines.\n" + + "- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.\n" + + "- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents \"foo\\n\", you will receive \"1: foo\\n\". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.\n" + + "- Any line longer than 2000 characters is truncated.\n" + + "- Call this tool in parallel when you know there are multiple files you want to read.\n" + + "- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.\n" + + "- This tool can read image files and PDFs and return them as file attachments.\n" + +const writeDescription = `Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- Overwriting an existing file replaces all of its contents; read it first unless you already know exactly what it contains. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +` + +const editDescription = "Performs exact string replacements in files. \n" + + "\n" + + "Usage:\n" + + "- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.\n" + + "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n" + + "- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n" + + "- The edit will FAIL if `oldString` is not found in the file with an error \"oldString not found in content\".\n" + + "- The edit will FAIL if `oldString` is found multiple times in the file with an error \"Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match.\" Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`. \n" + + "- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.\n" diff --git a/internal/seniordev/tool/edit.go b/internal/seniordev/tool/edit.go new file mode 100644 index 0000000000..06fd4b7999 --- /dev/null +++ b/internal/seniordev/tool/edit.go @@ -0,0 +1,869 @@ +//go:build !windows + +// The edit tool: exact string replacement backed by a ladder of progressively +// more lenient matchers, tried in order until exactly one match is found. +package tool + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + patchpkg "github.com/Agent-Field/codeaf/internal/seniordev/patch" + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +const ( + singleCandidateSimilarityThreshold = 0.0 + multipleCandidatesSimilarityThreshold = 0.3 +) + +var editLocks sync.Map + +type editFileDiff struct { + File string `json:"file"` + Patch string `json:"patch"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +type editMetadata struct { + Diagnostics map[string]any `json:"diagnostics"` + Diff string `json:"diff"` + FileDiff editFileDiff `json:"filediff"` +} + +type blockCandidate struct { + startLine int + endLine int +} + +type replacer func(content string, find string) []string + +func (r *Registry) executeEdit(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input editInput + if err := decodeInput(call.Input, &input, "filePath", "oldString", "newString"); err != nil { + return steploop.ToolResult{}, err + } + if input.FilePath == "" { + return steploop.ToolResult{}, errors.New("filePath is required") + } + if input.OldString == input.NewString { + return steploop.ToolResult{}, errors.New("No changes to apply: oldString and newString are identical.") + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + + resolved, err := r.resolveWritePath(input.FilePath) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, resolved, "file"); err != nil { + return steploop.ToolResult{}, err + } + formatter, err := r.formatterService() + if err != nil { + return steploop.ToolResult{}, err + } + mutexValue, _ := editLocks.LoadOrStore(resolved, &sync.Mutex{}) + mutex := mutexValue.(*sync.Mutex) + mutex.Lock() + defer mutex.Unlock() + + var contentOld string + var contentNew string + var desiredBOM bool + if input.OldString == "" { + source, readErr := os.ReadFile(resolved) + if readErr != nil && !os.IsNotExist(readErr) { + return steploop.ToolResult{}, readErr + } + sourceBOM, sourceText := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + nextBOM, nextText := splitBOM(input.NewString) + contentOld = sourceText + contentNew = nextText + desiredBOM = sourceBOM || nextBOM + } else { + info, statErr := os.Stat(resolved) + if os.IsNotExist(statErr) { + return steploop.ToolResult{}, fmt.Errorf("File %s not found", resolved) + } + if statErr != nil { + return steploop.ToolResult{}, statErr + } + if info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf("Path is a directory, not a file: %s", resolved) + } + source, readErr := os.ReadFile(resolved) + if readErr != nil { + return steploop.ToolResult{}, readErr + } + sourceBOM, sourceText := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + contentOld = sourceText + ending := detectLineEnding(contentOld) + oldString := convertToLineEnding(normalizeLineEndings(input.OldString), ending) + newString := convertToLineEnding(normalizeLineEndings(input.NewString), ending) + replaced, replaceErr := Replace(contentOld, oldString, newString, input.ReplaceAll) + if replaceErr != nil { + return steploop.ToolResult{}, replaceErr + } + nextBOM, nextText := splitBOM(replaced) + contentNew = nextText + desiredBOM = sourceBOM || nextBOM + } + + proposedOld, proposedNew := contentOld, contentNew + if input.OldString != "" { + proposedOld = normalizeLineEndings(proposedOld) + proposedNew = normalizeLineEndings(proposedNew) + } + proposedDiff := proposedFileDiff(resolved, proposedOld, proposedNew) + pattern, relErr := filepath.Rel(r.worktree(), resolved) + if relErr != nil { + pattern = resolved + } + metadata := map[string]any{"filepath": resolved, "diff": proposedDiff} + if err := r.ask(ctx, call, "edit", []string{filepath.ToSlash(pattern)}, metadata); err != nil { + return steploop.ToolResult{}, err + } + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(resolved, []byte(joinBOM(contentNew, desiredBOM)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + contentNew, err = formatMutationFile(ctx, formatter, resolved, desiredBOM) + if err != nil { + return steploop.ToolResult{}, err + } + + util.EagerCommit(ctx, util.EagerCommitOptions{Cwd: r.workDir, FilePath: resolved, Label: "edit"}) + + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + additions, deletions := lineChangeCounts(contentOld, contentNew) + diff := TrimDiff(patchpkg.GenerateTwoFilesPatch(resolved, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + )) + fileDiff := editFileDiff{ + File: resolved, + Patch: diff, + Additions: additions, + Deletions: deletions, + } + return steploop.ToolResult{ + Title: title, + Output: "Edit applied successfully.", + Metadata: rawMetadata(editMetadata{ + Diagnostics: map[string]any{}, + Diff: diff, + FileDiff: fileDiff, + }), + }, nil +} + +func normalizeLineEndings(text string) string { + return strings.ReplaceAll(text, "\r\n", "\n") +} + +func detectLineEnding(text string) string { + if strings.Contains(text, "\r\n") { + return "\r\n" + } + return "\n" +} + +func convertToLineEnding(text string, ending string) string { + if ending == "\n" { + return text + } + return strings.ReplaceAll(text, "\n", "\r\n") +} + +func proposedFileDiff(filePath, oldContent, newContent string) string { + return TrimDiff(patchpkg.GenerateTwoFilesPatch(filePath, oldContent, newContent)) +} + +func levenshtein(a string, b string) int { + aa := utf16.Encode([]rune(a)) + bb := utf16.Encode([]rune(b)) + if len(aa) == 0 || len(bb) == 0 { + if len(aa) > len(bb) { + return len(aa) + } + return len(bb) + } + previous := make([]int, len(bb)+1) + current := make([]int, len(bb)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(aa); i++ { + current[0] = i + for j := 1; j <= len(bb); j++ { + cost := 1 + if aa[i-1] == bb[j-1] { + cost = 0 + } + current[j] = min3(previous[j]+1, current[j-1]+1, previous[j-1]+cost) + } + previous, current = current, previous + } + return previous[len(bb)] +} + +func min3(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} + +// SimpleReplacer matches the search text exactly. +func SimpleReplacer(_ string, find string) []string { + return []string{find} +} + +// LineTrimmedReplacer matches line by line, ignoring leading and trailing +// whitespace on each line. +func LineTrimmedReplacer(content string, find string) []string { + originalLines := strings.Split(content, "\n") + searchLines := strings.Split(find, "\n") + if searchLines[len(searchLines)-1] == "" { + searchLines = searchLines[:len(searchLines)-1] + } + out := []string{} + for i := 0; i <= len(originalLines)-len(searchLines); i++ { + matches := true + for j := range searchLines { + if strings.TrimSpace(originalLines[i+j]) != strings.TrimSpace(searchLines[j]) { + matches = false + break + } + } + if matches { + out = append(out, strings.Join(originalLines[i:i+len(searchLines)], "\n")) + } + } + return out +} + +// BlockAnchorReplacer matches a block by its first and last lines and scores +// the lines between them by similarity; a lone candidate is accepted outright, +// competing candidates must clear multipleCandidatesSimilarityThreshold. +func BlockAnchorReplacer(content string, find string) []string { + originalLines := strings.Split(content, "\n") + searchLines := strings.Split(find, "\n") + if len(searchLines) < 3 { + return []string{} + } + if searchLines[len(searchLines)-1] == "" { + searchLines = searchLines[:len(searchLines)-1] + } + firstLineSearch := strings.TrimSpace(searchLines[0]) + lastLineSearch := strings.TrimSpace(searchLines[len(searchLines)-1]) + searchBlockSize := len(searchLines) + candidates := []blockCandidate{} + for i := 0; i < len(originalLines); i++ { + if strings.TrimSpace(originalLines[i]) != firstLineSearch { + continue + } + for j := i + 2; j < len(originalLines); j++ { + if strings.TrimSpace(originalLines[j]) == lastLineSearch { + candidates = append(candidates, blockCandidate{i, j}) + break + } + } + } + if len(candidates) == 0 { + return []string{} + } + if len(candidates) == 1 { + candidate := candidates[0] + actualBlockSize := candidate.endLine - candidate.startLine + 1 + similarity := 0.0 + linesToCheck := searchBlockSize - 2 + if actualBlockSize-2 < linesToCheck { + linesToCheck = actualBlockSize - 2 + } + if linesToCheck > 0 { + for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ { + originalLine := strings.TrimSpace(originalLines[candidate.startLine+j]) + searchLine := strings.TrimSpace(searchLines[j]) + maxLen := utf16Length(originalLine) + if value := utf16Length(searchLine); value > maxLen { + maxLen = value + } + if maxLen == 0 { + continue + } + distance := levenshtein(originalLine, searchLine) + similarity += (1 - float64(distance)/float64(maxLen)) / float64(linesToCheck) + if similarity >= singleCandidateSimilarityThreshold { + break + } + } + } else { + similarity = 1.0 + } + if similarity >= singleCandidateSimilarityThreshold { + return []string{strings.Join(originalLines[candidate.startLine:candidate.endLine+1], "\n")} + } + return []string{} + } + + var best *blockCandidate + maxSimilarity := -1.0 + for i := range candidates { + candidate := candidates[i] + actualBlockSize := candidate.endLine - candidate.startLine + 1 + similarity := 0.0 + linesToCheck := searchBlockSize - 2 + if actualBlockSize-2 < linesToCheck { + linesToCheck = actualBlockSize - 2 + } + if linesToCheck > 0 { + for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ { + originalLine := strings.TrimSpace(originalLines[candidate.startLine+j]) + searchLine := strings.TrimSpace(searchLines[j]) + maxLen := utf16Length(originalLine) + if value := utf16Length(searchLine); value > maxLen { + maxLen = value + } + if maxLen == 0 { + continue + } + similarity += 1 - float64(levenshtein(originalLine, searchLine))/float64(maxLen) + } + similarity /= float64(linesToCheck) + } else { + similarity = 1.0 + } + if similarity > maxSimilarity { + maxSimilarity = similarity + copy := candidate + best = © + } + } + if maxSimilarity >= multipleCandidatesSimilarityThreshold && best != nil { + return []string{strings.Join(originalLines[best.startLine:best.endLine+1], "\n")} + } + return []string{} +} + +// WhitespaceNormalizedReplacer matches after collapsing every run of +// whitespace to a single space. +func WhitespaceNormalizedReplacer(content string, find string) []string { + normalizedFind := normalizeWhitespace(find) + lines := strings.Split(content, "\n") + out := []string{} + for _, line := range lines { + if normalizeWhitespace(line) == normalizedFind { + out = append(out, line) + continue + } + normalizedLine := normalizeWhitespace(line) + if strings.Contains(normalizedLine, normalizedFind) { + words := splitWhitespace(strings.TrimSpace(find)) + if len(words) > 0 { + if match, ok := findWordsMatch(line, words); ok { + out = append(out, match) + } + } + } + } + findLines := strings.Split(find, "\n") + if len(findLines) > 1 { + for i := 0; i <= len(lines)-len(findLines); i++ { + block := strings.Join(lines[i:i+len(findLines)], "\n") + if normalizeWhitespace(block) == normalizedFind { + out = append(out, block) + } + } + } + return out +} + +// IndentationFlexibleReplacer matches after removing the common indentation +// from both the search text and the candidate block. +func IndentationFlexibleReplacer(content string, find string) []string { + normalizedFind := removeIndentation(find) + contentLines := strings.Split(content, "\n") + findLines := strings.Split(find, "\n") + out := []string{} + for i := 0; i <= len(contentLines)-len(findLines); i++ { + block := strings.Join(contentLines[i:i+len(findLines)], "\n") + if removeIndentation(block) == normalizedFind { + out = append(out, block) + } + } + return out +} + +// EscapeNormalizedReplacer matches after unescaping backslash sequences in the +// search text, for a model that sent an escaped string. +func EscapeNormalizedReplacer(content string, find string) []string { + unescapedFind := unescapeEditString(find) + out := []string{} + if strings.Contains(content, unescapedFind) { + out = append(out, unescapedFind) + } + lines := strings.Split(content, "\n") + findLines := strings.Split(unescapedFind, "\n") + for i := 0; i <= len(lines)-len(findLines); i++ { + block := strings.Join(lines[i:i+len(findLines)], "\n") + if unescapeEditString(block) == unescapedFind { + out = append(out, block) + } + } + return out +} + +// MultiOccurrenceReplacer returns one candidate per exact occurrence, which +// is what lets replaceAll act on every one. It assumes find != "": the edit +// tool rejects an empty search before the ladder runs. +func MultiOccurrenceReplacer(content string, find string) []string { + out := []string{} + start := 0 + for { + index := strings.Index(content[start:], find) + if index < 0 { + break + } + out = append(out, find) + start += index + len(find) + } + return out +} + +// TrimmedBoundaryReplacer matches the search text with its surrounding +// whitespace trimmed away. +func TrimmedBoundaryReplacer(content string, find string) []string { + trimmedFind := strings.TrimSpace(find) + if trimmedFind == find { + return []string{} + } + out := []string{} + if strings.Contains(content, trimmedFind) { + out = append(out, trimmedFind) + } + lines := strings.Split(content, "\n") + findLines := strings.Split(find, "\n") + for i := 0; i <= len(lines)-len(findLines); i++ { + block := strings.Join(lines[i:i+len(findLines)], "\n") + if strings.TrimSpace(block) == trimmedFind { + out = append(out, block) + } + } + return out +} + +// ContextAwareReplacer matches a block of the same length by its first and +// last lines when at least half of the inner lines agree. +func ContextAwareReplacer(content string, find string) []string { + findLines := strings.Split(find, "\n") + if len(findLines) < 3 { + return []string{} + } + if findLines[len(findLines)-1] == "" { + findLines = findLines[:len(findLines)-1] + } + contentLines := strings.Split(content, "\n") + firstLine := strings.TrimSpace(findLines[0]) + lastLine := strings.TrimSpace(findLines[len(findLines)-1]) + out := []string{} + for i := 0; i < len(contentLines); i++ { + if strings.TrimSpace(contentLines[i]) != firstLine { + continue + } + for j := i + 2; j < len(contentLines); j++ { + if strings.TrimSpace(contentLines[j]) != lastLine { + continue + } + blockLines := contentLines[i : j+1] + if len(blockLines) == len(findLines) { + matchingLines := 0 + totalNonEmptyLines := 0 + for k := 1; k < len(blockLines)-1; k++ { + blockLine := strings.TrimSpace(blockLines[k]) + findLine := strings.TrimSpace(findLines[k]) + if len(blockLine) > 0 || len(findLine) > 0 { + totalNonEmptyLines++ + if blockLine == findLine { + matchingLines++ + } + } + } + if totalNonEmptyLines == 0 || float64(matchingLines)/float64(totalNonEmptyLines) >= 0.5 { + out = append(out, strings.Join(blockLines, "\n")) + break + } + } + break + } + } + return out +} + +// TrimDiff removes the common leading indentation from a unified diff's +// content lines so the model-visible diff is not dominated by nesting. +func TrimDiff(diff string) string { + lines := strings.Split(diff, "\n") + contentLines := []string{} + for _, line := range lines { + if len(line) == 0 { + continue + } + if (line[0] == '+' || line[0] == '-' || line[0] == ' ') && + !strings.HasPrefix(line, "---") && + !strings.HasPrefix(line, "+++") { + contentLines = append(contentLines, line) + } + } + if len(contentLines) == 0 { + return diff + } + minIndent := int(^uint(0) >> 1) + for _, line := range contentLines { + content := line[1:] + if strings.TrimSpace(content) != "" { + indent := leadingWhitespaceUnits(content) + if indent < minIndent { + minIndent = indent + } + } + } + if minIndent == int(^uint(0)>>1) || minIndent == 0 { + return diff + } + for i, line := range lines { + if len(line) == 0 { + continue + } + if (line[0] == '+' || line[0] == '-' || line[0] == ' ') && + !strings.HasPrefix(line, "---") && + !strings.HasPrefix(line, "+++") { + lines[i] = line[:1] + sliceUTF16Units(line[1:], minIndent) + } + } + return strings.Join(lines, "\n") +} + +// Replace runs the replacer ladder in order and substitutes the first unique +// match, or every match of the first successful replacer when replaceAll is +// set. +func Replace(content string, oldString string, newString string, replaceAll bool) (string, error) { + if oldString == newString { + return "", errors.New("No changes to apply: oldString and newString are identical.") + } + notFound := true + replacers := []replacer{ + SimpleReplacer, + LineTrimmedReplacer, + BlockAnchorReplacer, + WhitespaceNormalizedReplacer, + IndentationFlexibleReplacer, + EscapeNormalizedReplacer, + TrimmedBoundaryReplacer, + ContextAwareReplacer, + MultiOccurrenceReplacer, + } + for _, candidateReplacer := range replacers { + for _, search := range candidateReplacer(content, oldString) { + index := strings.Index(content, search) + if index < 0 { + continue + } + notFound = false + if replaceAll { + return replaceAllExpanding(content, search, newString), nil + } + lastIndex := strings.LastIndex(content, search) + if index != lastIndex { + continue + } + return content[:index] + newString + content[index+len(search):], nil + } + } + if notFound { + return "", errors.New( + "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.", + ) + } + return "", errors.New( + "Found multiple matches for oldString. Provide more surrounding context to make the match unique.", + ) +} + +func utf16Length(value string) int { + return len(utf16.Encode([]rune(value))) +} + +// isWhitespaceRune is the whitespace set the lenient matchers normalize: the +// ASCII controls, space, no-break space, the Unicode space separators, the +// line and paragraph separators and the BOM. +func isWhitespaceRune(r rune) bool { + switch { + case r >= 0x0009 && r <= 0x000d: + return true + case r == 0x0020, r == 0x00a0, r == 0x1680, r == 0x2028, r == 0x2029, + r == 0x202f, r == 0x205f, r == 0x3000, r == 0xfeff: + return true + case r >= 0x2000 && r <= 0x200a: + return true + default: + return false + } +} + +func normalizeWhitespace(value string) string { + var out strings.Builder + inWhitespace := false + for _, r := range value { + if isWhitespaceRune(r) { + if !inWhitespace { + out.WriteByte(' ') + inWhitespace = true + } + continue + } + out.WriteRune(r) + inWhitespace = false + } + return strings.TrimSpace(out.String()) +} + +func splitWhitespace(value string) []string { + if value == "" { + return []string{""} + } + out := []string{} + start := 0 + for i, r := range value { + if !isWhitespaceRune(r) { + continue + } + if start < i { + out = append(out, value[start:i]) + } + start = i + len(string(r)) + } + out = append(out, value[start:]) + return out +} + +func findWordsMatch(line string, words []string) (string, bool) { + if len(words) == 1 && words[0] == "" { + return "", true + } + for start := 0; start <= len(line); { + if !strings.HasPrefix(line[start:], words[0]) { + if start == len(line) { + break + } + _, size := nextRune(line[start:]) + start += size + continue + } + pos := start + len(words[0]) + ok := true + for _, word := range words[1:] { + before := pos + for pos < len(line) { + r, size := nextRune(line[pos:]) + if !isWhitespaceRune(r) { + break + } + pos += size + } + if pos == before || !strings.HasPrefix(line[pos:], word) { + ok = false + break + } + pos += len(word) + } + if ok { + return line[start:pos], true + } + if start == len(line) { + break + } + _, size := nextRune(line[start:]) + start += size + } + return "", false +} + +func nextRune(value string) (rune, int) { + for _, r := range value { + return r, len(string(r)) + } + return 0, 0 +} + +func removeIndentation(value string) string { + lines := strings.Split(value, "\n") + minIndent := int(^uint(0) >> 1) + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + indent := leadingWhitespaceUnits(line) + if indent < minIndent { + minIndent = indent + } + } + if minIndent == int(^uint(0)>>1) { + return value + } + for i, line := range lines { + if strings.TrimSpace(line) != "" { + lines[i] = sliceUTF16Units(line, minIndent) + } + } + return strings.Join(lines, "\n") +} + +// leadingWhitespaceUnits counts a line's indentation in UTF-16 code units, +// the unit sliceUTF16Units removes it in. +func leadingWhitespaceUnits(value string) int { + count := 0 + for _, r := range value { + if !isWhitespaceRune(r) { + break + } + count += utf16Length(string(r)) + } + return count +} + +func sliceUTF16Units(value string, start int) string { + units := utf16.Encode([]rune(value)) + if start < 0 { + start = 0 + } + if start > len(units) { + start = len(units) + } + return string(utf16.Decode(units[start:])) +} + +func unescapeEditString(value string) string { + var out strings.Builder + for i := 0; i < len(value); { + if value[i] != '\\' || i+1 >= len(value) { + r, size := nextRune(value[i:]) + out.WriteRune(r) + i += size + continue + } + next := value[i+1] + switch next { + case 'n': + out.WriteByte('\n') + case 't': + out.WriteByte('\t') + case 'r': + out.WriteByte('\r') + case '\'', '"', '`', '\\', '$': + out.WriteByte(next) + case '\n': + out.WriteByte('\n') + default: + out.WriteByte('\\') + out.WriteByte(next) + i += 2 + continue + } + i += 2 + } + return out.String() +} + +// replaceAllExpanding replaces every occurrence of search, expanding the $&, +// $`, $' and $$ patterns in the replacement (the match, the text before it, the +// text after it, and a literal dollar). +func replaceAllExpanding(content string, search string, replacement string) string { + if search == "" { + return content + } + var out strings.Builder + start := 0 + for { + index := strings.Index(content[start:], search) + if index < 0 { + out.WriteString(content[start:]) + break + } + index += start + out.WriteString(content[start:index]) + out.WriteString(expandReplacement(replacement, search, content[:index], content[index+len(search):])) + start = index + len(search) + } + return out.String() +} + +func expandReplacement(replacement string, match string, before string, after string) string { + var out strings.Builder + for i := 0; i < len(replacement); i++ { + if replacement[i] != '$' || i+1 >= len(replacement) { + out.WriteByte(replacement[i]) + continue + } + switch replacement[i+1] { + case '$': + out.WriteByte('$') + i++ + case '&': + out.WriteString(match) + i++ + case '`': + out.WriteString(before) + i++ + case '\'': + out.WriteString(after) + i++ + default: + out.WriteByte('$') + } + } + return out.String() +} + +func lineChangeCounts(oldContent string, newContent string) (int, int) { + oldLines := strings.Split(oldContent, "\n") + newLines := strings.Split(newContent, "\n") + table := make([][]int, len(oldLines)+1) + for i := range table { + table[i] = make([]int, len(newLines)+1) + } + for i := len(oldLines) - 1; i >= 0; i-- { + for j := len(newLines) - 1; j >= 0; j-- { + if oldLines[i] == newLines[j] { + table[i][j] = table[i+1][j+1] + 1 + } else if table[i+1][j] >= table[i][j+1] { + table[i][j] = table[i+1][j] + } else { + table[i][j] = table[i][j+1] + } + } + } + common := table[0][0] + return len(newLines) - common, len(oldLines) - common +} diff --git a/internal/seniordev/tool/edit_test.go b/internal/seniordev/tool/edit_test.go new file mode 100644 index 0000000000..7bc66129fb --- /dev/null +++ b/internal/seniordev/tool/edit_test.go @@ -0,0 +1,90 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "testing" +) + +func TestEditLineEndingAndBOM(t *testing.T) { + workDir := t.TempDir() + path := filepath.Join(workDir, "file.txt") + if err := os.WriteFile(path, []byte("\ufeffone\r\ntwo\r\n"), 0o644); err != nil { + t.Fatal(err) + } + result, err := execute(t, New(workDir), "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "one\ntwo", + "newString": "first\nsecond", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Title != "file.txt" || result.Output != "Edit applied successfully." { + t.Fatalf("result = %#v", result) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufefffirst\r\nsecond\r\n" { + t.Fatalf("content = %q", data) + } +} + +func TestEditEmptyOldStringCreatesOrOverwrites(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "nested/new.txt", + "oldString": "", + "newString": "\ufeffcreated", + }) + if err != nil { + t.Fatalf("Execute create: %v", err) + } + if result.Output != "Edit applied successfully." { + t.Fatalf("Output = %q", result.Output) + } + data, err := os.ReadFile(filepath.Join(workDir, "nested", "new.txt")) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufeffcreated" { + t.Fatalf("content = %q", data) + } +} + +func TestEditShellErrors(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": "", + "oldString": "a", + "newString": "b", + }) + if err == nil || err.Error() != "filePath is required" { + t.Fatalf("empty path error = %v", err) + } + _, err = execute(t, registry, "edit", map[string]any{ + "filePath": "missing.txt", + "oldString": "a", + "newString": "b", + }) + if err == nil || err.Error() != "File "+filepath.Join(workDir, "missing.txt")+" not found" { + t.Fatalf("missing error = %v", err) + } + if err := os.Mkdir(filepath.Join(workDir, "dir"), 0o755); err != nil { + t.Fatal(err) + } + _, err = execute(t, registry, "edit", map[string]any{ + "filePath": "dir", + "oldString": "a", + "newString": "b", + }) + if err == nil || err.Error() != "Path is a directory, not a file: "+filepath.Join(workDir, "dir") { + t.Fatalf("directory error = %v", err) + } +} diff --git a/internal/seniordev/tool/glob.go b/internal/seniordev/tool/glob.go new file mode 100644 index 0000000000..98d3ae87c2 --- /dev/null +++ b/internal/seniordev/tool/glob.go @@ -0,0 +1,138 @@ +//go:build !windows + +// The glob tool: file-name matching through `rg --files`, newest first, capped +// at globResultLimit entries. +package tool + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +const globResultLimit = 100 + +type globFile struct { + path string + mtime int64 +} + +type globMetadata struct { + Count int `json:"count"` + Truncated bool `json:"truncated"` +} + +func (r *Registry) executeGlob(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input globInput + if err := decodeInput(call.Input, &input, "pattern"); err != nil { + return steploop.ToolResult{}, err + } + if err := r.ask(ctx, call, "glob", []string{input.Pattern}, map[string]any{ + "pattern": input.Pattern, "path": input.Path, + }); err != nil { + return steploop.ToolResult{}, err + } + search := r.workDir + if input.Path != nil { + search = *input.Path + if !filepath.IsAbs(search) { + search = filepath.Join(r.workDir, search) + } + search = filepath.Clean(search) + } + resolved, err := r.resolvePath(search) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, resolved, "directory"); err != nil { + return steploop.ToolResult{}, err + } + if info, statErr := os.Stat(resolved); statErr == nil && !info.IsDir() { + return steploop.ToolResult{}, fmt.Errorf("glob path must be a directory: %s", resolved) + } + + args := []string{ + "--no-config", + "--files", + "--glob=!.git/*", + "--hidden", + "--glob=" + input.Pattern, + ".", + } + result, err := r.rg.Run(ctx, resolved, args) + if err != nil { + return steploop.ToolResult{}, err + } + if result.code != 0 && result.code != 1 { + return steploop.ToolResult{}, ripgrepError(result) + } + + files := make([]globFile, 0, globResultLimit+1) + scanner := bufio.NewScanner(strings.NewReader(string(result.stdout))) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + if scanner.Text() == "" { + continue + } + file := cleanRipgrepPath(scanner.Text()) + full := filepath.Clean(filepath.Join(resolved, file)) + mtime := int64(0) + if info, statErr := os.Stat(full); statErr == nil { + mtime = info.ModTime().UnixMilli() + } + files = append(files, globFile{path: full, mtime: mtime}) + if len(files) == globResultLimit+1 { + break + } + } + if err := scanner.Err(); err != nil { + return steploop.ToolResult{}, err + } + truncated := len(files) > globResultLimit + if truncated { + files = files[:globResultLimit] + } + sort.SliceStable(files, func(i, j int) bool { + return files[i].mtime > files[j].mtime + }) + + output := []string{} + if len(files) == 0 { + output = append(output, "No files found") + } else { + for _, file := range files { + output = append(output, file.path) + } + if truncated { + output = append(output, "") + output = append(output, + "(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)", + ) + } + } + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + return steploop.ToolResult{ + Title: title, + Output: strings.Join(output, "\n"), + Metadata: rawMetadata(globMetadata{ + Count: len(files), + Truncated: truncated, + }), + }, nil +} + +func cleanRipgrepPath(path string) string { + if strings.HasPrefix(path, "./") || strings.HasPrefix(path, `.\`) { + path = path[2:] + } + return filepath.Clean(path) +} diff --git a/internal/seniordev/tool/glob_description.go b/internal/seniordev/tool/glob_description.go new file mode 100644 index 0000000000..543c992fb1 --- /dev/null +++ b/internal/seniordev/tool/glob_description.go @@ -0,0 +1,10 @@ +//go:build !windows + +package tool + +const globDescription = `- Fast file pattern matching tool that works with any codebase size +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths sorted by modification time +- Use this tool when you need to find files by name patterns +- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. +` diff --git a/internal/seniordev/tool/glob_test.go b/internal/seniordev/tool/glob_test.go new file mode 100644 index 0000000000..87c20bdebd --- /dev/null +++ b/internal/seniordev/tool/glob_test.go @@ -0,0 +1,125 @@ +//go:build !windows + +package tool + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +type recordingRipgrep struct { + cwd string + args []string + result ripgrepResult + err error +} + +func (r *recordingRipgrep) Run(_ context.Context, cwd string, args []string) (ripgrepResult, error) { + r.cwd = cwd + r.args = append([]string(nil), args...) + return r.result, r.err +} + +func TestGlobInvocationSortAndOutput(t *testing.T) { + workDir := t.TempDir() + for _, name := range []string{"older.go", "newer.go"} { + writeTestFile(t, workDir, name, name) + } + old := time.Unix(100, 0) + newer := time.Unix(200, 0) + if err := os.Chtimes(filepath.Join(workDir, "older.go"), old, old); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(workDir, "newer.go"), newer, newer); err != nil { + t.Fatal(err) + } + runner := &recordingRipgrep{result: ripgrepResult{ + stdout: []byte("./older.go\n./newer.go\n"), + code: 0, + }} + registry := New(workDir) + registry.rg = runner + + result, err := execute(t, registry, "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + wantArgs := []string{ + "--no-config", + "--files", + "--glob=!.git/*", + "--hidden", + "--glob=*.go", + ".", + } + if runner.cwd != workDir || !reflect.DeepEqual(runner.args, wantArgs) { + t.Fatalf("invocation cwd=%q args=%q", runner.cwd, runner.args) + } + want := filepath.Join(workDir, "newer.go") + "\n" + filepath.Join(workDir, "older.go") + if result.Output != want || result.Title != "." { + t.Fatalf("result = %#v, want output %q", result, want) + } + if string(result.Metadata) != `{"count":2,"truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestGlobTruncatesBeforeMtimeSort(t *testing.T) { + workDir := t.TempDir() + var stdout strings.Builder + for i := 0; i < 101; i++ { + name := "file-" + itoa(i) + ".txt" + writeTestFile(t, workDir, name, "") + stdout.WriteString(name) + stdout.WriteByte('\n') + } + // The 101st item is newest but must be discarded before sorting. + newest := filepath.Join(workDir, "file-100.txt") + when := time.Unix(500, 0) + if err := os.Chtimes(newest, when, when); err != nil { + t.Fatal(err) + } + runner := &recordingRipgrep{result: ripgrepResult{stdout: []byte(stdout.String()), code: 0}} + registry := New(workDir) + registry.rg = runner + result, err := execute(t, registry, "glob", map[string]any{"pattern": "*.txt"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(result.Output, newest) { + t.Fatalf("101st item survived pre-sort truncation") + } + if !strings.HasSuffix(result.Output, "(Results are truncated: showing first 100 results. Consider using a more specific path or pattern.)") { + t.Fatalf("Output suffix = %q", result.Output[len(result.Output)-140:]) + } + if string(result.Metadata) != `{"count":100,"truncated":true}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestGlobRealRipgrep(t *testing.T) { + // This exercises the real rg invocation and skips when rg is not installed; + // the built-in searcher is covered by ripgrep_fallback_test.go. + if _, err := exec.LookPath("rg"); err != nil { + t.Skip("rg not on PATH") + } + workDir := t.TempDir() + writeTestFile(t, workDir, "visible.go", "package visible") + writeTestFile(t, workDir, ".hidden.go", "package hidden") + writeTestFile(t, workDir, "ignored.txt", "text") + result, err := execute(t, New(workDir), "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for _, name := range []string{"visible.go", ".hidden.go"} { + if !strings.Contains(result.Output, filepath.Join(workDir, name)) { + t.Fatalf("%s missing from %q", name, result.Output) + } + } +} diff --git a/internal/seniordev/tool/grep.go b/internal/seniordev/tool/grep.go new file mode 100644 index 0000000000..7d7dbb412d --- /dev/null +++ b/internal/seniordev/tool/grep.go @@ -0,0 +1,219 @@ +//go:build !windows + +// The grep tool: content search through `rg --json`, grouped by file and +// ordered newest first. +package tool + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +const maxGrepLineLength = 2000 + +type ripgrepJSONLine struct { + Type string `json:"type"` + Data struct { + Path struct { + Text string `json:"text"` + } `json:"path"` + Lines struct { + Text string `json:"text"` + } `json:"lines"` + LineNumber int `json:"line_number"` + } `json:"data"` +} + +type grepInput struct { + Pattern string `json:"pattern"` + Path *string `json:"path,omitempty"` + Include *string `json:"include,omitempty"` +} + +type grepMatch struct { + path string + line int + text string + mtime int64 +} + +type grepMetadata struct { + Matches int `json:"matches"` + Truncated bool `json:"truncated"` +} + +func (r *Registry) executeGrep(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input grepInput + if err := decodeInput(call.Input, &input, "pattern"); err != nil { + return steploop.ToolResult{}, err + } + if input.Pattern == "" { + return steploop.ToolResult{}, errors.New("pattern is required") + } + if err := r.ask(ctx, call, "grep", []string{input.Pattern}, map[string]any{ + "pattern": input.Pattern, "path": input.Path, "include": input.Include, + }); err != nil { + return steploop.ToolResult{}, err + } + empty := func() steploop.ToolResult { + return steploop.ToolResult{ + Title: input.Pattern, + Output: "No files found", + Metadata: rawMetadata(grepMetadata{ + Matches: 0, + Truncated: false, + }), + } + } + + search := r.workDir + if input.Path != nil { + search = *input.Path + if !filepath.IsAbs(search) { + search = filepath.Join(r.workDir, search) + } + } + search = filepath.Clean(search) + resolved, err := r.resolvePath(search) + if err != nil { + return steploop.ToolResult{}, err + } + info, statErr := os.Stat(resolved) + isDirectory := statErr == nil && info.IsDir() + kind := "file" + if isDirectory { + kind = "directory" + } + if err := r.askExternalDirectory(ctx, call, resolved, kind); err != nil { + return steploop.ToolResult{}, err + } + cwd := filepath.Dir(resolved) + files := []string{filepath.Base(resolved)} + if isDirectory { + cwd = resolved + files = []string{"."} + } + + args := []string{"--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"} + if input.Include != nil { + args = append(args, "--glob="+*input.Include) + } + args = append(args, "--", input.Pattern) + args = append(args, files...) + result, err := r.rg.Run(ctx, cwd, args) + if err != nil { + return steploop.ToolResult{}, err + } + if result.code != 0 && result.code != 1 && result.code != 2 { + return steploop.ToolResult{}, ripgrepError(result) + } + if result.code == 1 { + return empty(), nil + } + + rows := []grepMatch{} + scanner := bufio.NewScanner(strings.NewReader(string(result.stdout))) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for scanner.Scan() { + if scanner.Text() == "" { + continue + } + var event ripgrepJSONLine + if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { + return steploop.ToolResult{}, errors.New("invalid ripgrep output") + } + if event.Type != "match" { + continue + } + matchPath := cleanRipgrepPath(event.Data.Path.Text) + if !filepath.IsAbs(matchPath) { + matchPath = filepath.Join(cwd, matchPath) + } + matchPath = filepath.Clean(matchPath) + info, statErr := os.Stat(matchPath) + if statErr != nil || info.IsDir() { + continue + } + rows = append(rows, grepMatch{ + path: matchPath, + line: event.Data.LineNumber, + text: event.Data.Lines.Text, + mtime: info.ModTime().UnixMilli(), + }) + } + if err := scanner.Err(); err != nil { + return steploop.ToolResult{}, err + } + if len(rows) == 0 { + return empty(), nil + } + sort.SliceStable(rows, func(i, j int) bool { + return rows[i].mtime > rows[j].mtime + }) + + const limit = 100 + total := len(rows) + truncated := total > limit + final := rows + if truncated { + final = rows[:limit] + } + output := []string{"Found " + itoa(total) + " matches"} + if truncated { + output[0] += " (showing first 100)" + } + current := "" + for _, match := range final { + if current != match.path { + if current != "" { + output = append(output, "") + } + current = match.path + output = append(output, match.path+":") + } + text := match.text + if len(utf16.Encode([]rune(text))) > maxGrepLineLength { + text = truncateUTF16Units(text, maxGrepLineLength) + "..." + } + output = append(output, " Line "+itoa(match.line)+": "+text) + } + if truncated { + output = append(output, "") + output = append( + output, + "(Results truncated: showing 100 of "+itoa(total)+" matches ("+itoa(total-limit)+ + " hidden). Consider using a more specific path or pattern.)", + ) + } + if result.code == 2 { + output = append(output, "") + output = append(output, "(Some paths were inaccessible and skipped)") + } + return steploop.ToolResult{ + Title: input.Pattern, + Output: strings.Join(output, "\n"), + Metadata: rawMetadata(grepMetadata{ + Matches: total, + Truncated: truncated, + }), + }, nil +} + +// truncateUTF16Units cuts a line at limit UTF-16 code units, the unit +// maxGrepLineLength is expressed in. +func truncateUTF16Units(value string, limit int) string { + units := utf16.Encode([]rune(value)) + if len(units) <= limit { + return value + } + return string(utf16.Decode(units[:limit])) +} diff --git a/internal/seniordev/tool/grep_description.go b/internal/seniordev/tool/grep_description.go new file mode 100644 index 0000000000..9c7191ea08 --- /dev/null +++ b/internal/seniordev/tool/grep_description.go @@ -0,0 +1,11 @@ +//go:build !windows + +package tool + +const grepDescription = "- Fast content search tool that works with any codebase size\n" + + "- Searches file contents using regular expressions\n" + + "- Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.)\n" + + "- Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\")\n" + + "- Returns file paths and line numbers with at least one match sorted by modification time\n" + + "- Use this tool when you need to find files containing specific patterns\n" + + "- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.\n" diff --git a/internal/seniordev/tool/grep_test.go b/internal/seniordev/tool/grep_test.go new file mode 100644 index 0000000000..43a8787fbc --- /dev/null +++ b/internal/seniordev/tool/grep_test.go @@ -0,0 +1,130 @@ +//go:build !windows + +package tool + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func grepEvent(t *testing.T, path string, line int, text string) string { + t.Helper() + value := map[string]any{ + "type": "match", + "data": map[string]any{ + "path": map[string]any{"text": path}, + "lines": map[string]any{"text": text}, + "line_number": line, + }, + } + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func TestGrepInvocationGroupingAndPartial(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "old.go", "needle") + writeTestFile(t, workDir, "new.go", "needle") + old := time.Unix(100, 0) + newer := time.Unix(200, 0) + if err := os.Chtimes(filepath.Join(workDir, "old.go"), old, old); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(filepath.Join(workDir, "new.go"), newer, newer); err != nil { + t.Fatal(err) + } + stdout := strings.Join([]string{ + grepEvent(t, "./old.go", 1, "old needle\n"), + grepEvent(t, "./new.go", 2, "new needle\n"), + }, "\n") + "\n" + runner := &recordingRipgrep{result: ripgrepResult{stdout: []byte(stdout), code: 2}} + registry := New(workDir) + registry.rg = runner + + result, err := execute(t, registry, "grep", map[string]any{ + "pattern": "needle", + "include": "*.go", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + wantArgs := []string{ + "--no-config", + "--json", + "--hidden", + "--glob=!.git/*", + "--no-messages", + "--glob=*.go", + "--", + "needle", + ".", + } + if runner.cwd != workDir || !reflect.DeepEqual(runner.args, wantArgs) { + t.Fatalf("invocation cwd=%q args=%q", runner.cwd, runner.args) + } + want := "Found 2 matches\n" + + filepath.Join(workDir, "new.go") + ":\n" + + " Line 2: new needle\n\n" + + "\n" + + filepath.Join(workDir, "old.go") + ":\n" + + " Line 1: old needle\n\n" + + "\n(Some paths were inaccessible and skipped)" + if result.Output != want { + t.Fatalf("Output:\n%q\nwant:\n%q", result.Output, want) + } + if string(result.Metadata) != `{"matches":2,"truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestGrepFilePathInvocationAndLongLine(t *testing.T) { + workDir := t.TempDir() + long := strings.Repeat("界", maxGrepLineLength+1) + "\n" + writeTestFile(t, workDir, "one.txt", long) + runner := &recordingRipgrep{result: ripgrepResult{ + stdout: []byte(grepEvent(t, "one.txt", 1, long) + "\n"), + code: 0, + }} + registry := New(workDir) + registry.rg = runner + result, err := execute(t, registry, "grep", map[string]any{ + "pattern": "界+", + "path": "one.txt", + }) + if err != nil { + t.Fatal(err) + } + if runner.cwd != workDir || !reflect.DeepEqual(runner.args[len(runner.args)-3:], []string{"--", "界+", "one.txt"}) { + t.Fatalf("invocation cwd=%q args=%q", runner.cwd, runner.args) + } + wantText := strings.Repeat("界", maxGrepLineLength) + "..." + if !strings.Contains(result.Output, " Line 1: "+wantText) { + t.Fatalf("long output missing: %q", result.Output[len(result.Output)-100:]) + } +} + +func TestGrepEmptyAndNoMatches(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + _, err := execute(t, registry, "grep", map[string]any{"pattern": ""}) + if err == nil || err.Error() != "pattern is required" { + t.Fatalf("empty pattern error = %v", err) + } + runner := &recordingRipgrep{result: ripgrepResult{code: 1}} + registry.rg = runner + result, err := execute(t, registry, "grep", map[string]any{"pattern": "missing"}) + if err != nil { + t.Fatal(err) + } + if result.Output != "No files found" || string(result.Metadata) != `{"matches":0,"truncated":false}` { + t.Fatalf("result = %#v", result) + } +} diff --git a/internal/seniordev/tool/instance_context_test.go b/internal/seniordev/tool/instance_context_test.go new file mode 100644 index 0000000000..ce49884871 --- /dev/null +++ b/internal/seniordev/tool/instance_context_test.go @@ -0,0 +1,127 @@ +//go:build !windows + +// Per-leaf cwd behaviour: a project instance in context redirects tool paths +// and the shell's working directory into its own directory. +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/project" +) + +func TestConcurrentLeafContextsResolveTheirOwnToolCWD(t *testing.T) { + root := t.TempDir() + leafA := filepath.Join(root, ".worktrees", "wt-a") + leafB := filepath.Join(root, ".worktrees", "wt-b") + for _, directory := range []string{leafA, leafB} { + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + } + registry := New(root) + type leaf struct { + id string + directory string + } + leaves := []leaf{{id: "a", directory: leafA}, {id: "b", directory: leafB}} + var wait sync.WaitGroup + errors := make(chan error, len(leaves)) + for _, item := range leaves { + item := item + wait.Add(1) + go func() { + defer wait.Done() + ctx := project.WithContext(context.Background(), project.InstanceContext{ + Directory: item.directory, + Worktree: root, + Project: project.Info{ID: "p", Worktree: root, Sandboxes: []string{item.directory}}, + }) + raw, _ := json.Marshal(map[string]any{ + // An absolute root path must be transparently redirected into + // this leaf, not written into the shared checkout. + "filePath": filepath.Join(root, "owned.txt"), + "content": item.id, + }) + if _, err := registry.Execute(ctx, steploop.ToolCall{Name: "write", Input: raw}); err != nil { + errors <- err + return + } + bashRaw, _ := json.Marshal(map[string]any{"command": "pwd"}) + result, err := registry.Execute(ctx, steploop.ToolCall{Name: "bash", Input: bashRaw}) + if err != nil { + errors <- err + return + } + // The shell prints the folder the kernel resolved, and a temporary + // folder on macOS is reached through a symlink, so the same folder + // can come back spelled /private/var/…; it is compared resolved. + if !sameFolder(strings.TrimSpace(result.Output), item.directory) { + errors <- &cwdError{got: strings.TrimSpace(result.Output), want: item.directory} + } + }() + } + wait.Wait() + close(errors) + for err := range errors { + t.Error(err) + } + if _, err := os.Stat(filepath.Join(root, "owned.txt")); !os.IsNotExist(err) { + t.Fatalf("shared root was modified: %v", err) + } + for _, item := range leaves { + data, err := os.ReadFile(filepath.Join(item.directory, "owned.txt")) + if err != nil { + t.Fatalf("read %s leaf: %v", item.id, err) + } + if string(data) != item.id { + t.Fatalf("%s leaf content = %q", item.id, data) + } + } +} + +func TestRelativeTraversalIntoMainWorktreeRedirectsToLeafContract(t *testing.T) { + // A sandboxed instance resolves relative paths before redirecting paths + // that land in the main checkout into its own directory. + mainWorktree := t.TempDir() + leafWorktree := filepath.Join(mainWorktree, ".worktrees", "wt-task") + if err := os.MkdirAll(leafWorktree, 0o755); err != nil { + t.Fatal(err) + } + registry := New(mainWorktree) + ctx := project.WithContext(context.Background(), project.InstanceContext{ + Directory: leafWorktree, + Worktree: mainWorktree, + Project: project.Info{ID: "p", Worktree: mainWorktree, Sandboxes: []string{leafWorktree}}, + }) + target := filepath.Join(mainWorktree, "relative-owned.txt") + relative, err := filepath.Rel(leafWorktree, target) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]any{"filePath": relative, "content": "leaf"}) + if _, err := registry.Execute(ctx, steploop.ToolCall{Name: "write", Input: raw}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("relative traversal modified main checkout: %v", err) + } + data, err := os.ReadFile(filepath.Join(leafWorktree, "relative-owned.txt")) + if err != nil || string(data) != "leaf" { + t.Fatalf("redirected leaf file = %q, %v", data, err) + } +} + +type cwdError struct { + got string + want string +} + +func (e *cwdError) Error() string { return "tool cwd = " + e.got + ", want " + e.want } diff --git a/internal/seniordev/tool/mutation_feedback_test.go b/internal/seniordev/tool/mutation_feedback_test.go new file mode 100644 index 0000000000..0948b9abbf --- /dev/null +++ b/internal/seniordev/tool/mutation_feedback_test.go @@ -0,0 +1,82 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + formatpkg "github.com/Agent-Field/codeaf/internal/seniordev/format" +) + +func TestEditDiffReflectsPostFormatContent(t *testing.T) { + // edit reports a unified diff of the actual post-format change. + workDir := t.TempDir() + path := filepath.Join(workDir, "file.txt") + if err := os.WriteFile(path, []byte("before\n"), 0o644); err != nil { + t.Fatal(err) + } + registry := New(workDir) + extensions := []string{".txt"} + command := []string{"test-formatter", "$FILE"} + registry.formatters.services[workDir+"\x00"+workDir] = formatpkg.NewService( + formatpkg.Context{Directory: workDir, Worktree: workDir}, + formatpkg.Configuration{Enabled: true, Overrides: []formatpkg.FormatterOverride{{ + Key: "test", Extensions: &extensions, Command: &command, + }}}, + formatpkg.Dependencies{}, + func(_ context.Context, command []string, _ string, _ map[string]string) (int, error) { + return 0, os.WriteFile(command[1], []byte("formatted\n"), 0o644) + }, + ) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", "oldString": "before", "newString": "raw", + }) + if err != nil { + t.Fatal(err) + } + var metadata editMetadata + if err := json.Unmarshal(result.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if metadata.Diff == "" || metadata.FileDiff.Patch != metadata.Diff { + t.Fatalf("metadata = %#v", metadata) + } + if !strings.Contains(metadata.Diff, "+formatted") || strings.Contains(metadata.Diff, "+raw") { + t.Fatalf("diff does not reflect formatter output: %q", metadata.Diff) + } + assertTestFile(t, workDir, "file.txt", "formatted\n") +} + +func TestApplyPatchReportsPerFileAndAggregateDiffs(t *testing.T) { + // apply_patch reports unified diffs for every actual change. + workDir := t.TempDir() + writeTestFile(t, workDir, "update.txt", "old\n") + result, err := execute(t, New(workDir), "apply_patch", map[string]any{"patchText": `*** Begin Patch +*** Add File: added.txt ++new +*** Update File: update.txt +@@ +-old ++updated +*** End Patch`}) + if err != nil { + t.Fatal(err) + } + var metadata applyPatchMetadata + if err := json.Unmarshal(result.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if metadata.Diff == "" || len(metadata.Files) != 2 { + t.Fatalf("metadata = %#v", metadata) + } + for _, file := range metadata.Files { + if file.Patch == "" || !strings.Contains(metadata.Diff, file.Patch) { + t.Fatalf("file diff missing from aggregate: %#v", file) + } + } +} diff --git a/internal/seniordev/tool/netpolicy_gate_test.go b/internal/seniordev/tool/netpolicy_gate_test.go new file mode 100644 index 0000000000..755807f2c0 --- /dev/null +++ b/internal/seniordev/tool/netpolicy_gate_test.go @@ -0,0 +1,109 @@ +//go:build !windows + +package tool + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +func TestWebFetchBlockedWhenNetOff(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + dialed := false + client := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + dialed = true + return nil, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + _, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": "https://example.com/doc", + }) + if err == nil || !strings.Contains(err.Error(), "[network-policy]") { + t.Fatalf("want [network-policy] error, got %v", err) + } + if !strings.Contains(err.Error(), "example.com") { + t.Fatalf("policy error should name the blocked host: %v", err) + } + if dialed { + t.Fatal("request reached the transport despite SENIOR_DEV_NET=off") + } +} + +// TestWebClientTransportWrapEnforcesPolicy pins the policy wrap inside +// webClient itself, past the tools' pre-execute checks: any HTTP issued +// through the shared client while the policy is restricted must be refused at +// the transport with the [network-policy] no-retry framing rather than +// dialing (or collapsing into a retryable-looking transport error). If the +// wrap is ever dropped from webClient, the request reaches the base +// transport and this test fails. +func TestWebClientTransportWrapEnforcesPolicy(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + dialed := false + injected := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + dialed = true + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil + })} + ctx := WithWebHTTPClient(context.Background(), injected) + + _, err := webClient(ctx).Get("https://example.com/doc") + var blocked *netpolicy.BlockedError + if !errors.As(err, &blocked) { + t.Fatalf("want *netpolicy.BlockedError, got %v", err) + } + if dialed { + t.Fatal("request reached the base transport despite SENIOR_DEV_NET=off") + } + if injected.Transport == nil { + t.Fatal("webClient mutated the injected client instead of copying it") + } + if _, ok := injected.Transport.(webRoundTripFunc); !ok { + t.Fatalf("webClient replaced the injected client's own transport: %T", injected.Transport) + } +} + +func TestWebSearchEndpointBlockedWhenNetOff(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + _, err := callMCPWebSearch( + context.Background(), defaultExaWebSearchURL, "web_search_exa", + map[string]any{"query": "q"}, nil, + ) + if err == nil || !strings.Contains(err.Error(), "[network-policy]") { + t.Fatalf("want [network-policy] error, got %v", err) + } +} + +func TestShellEnvironmentInjectsBlackholeProxyWhenNetOff(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "off") + // The shared-cache early return must not skip the network gate. + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "1") + environment := shellEnvironment("ses-netpolicy") + var proxy, noProxy string + for _, entry := range environment { + if value, ok := strings.CutPrefix(entry, "HTTPS_PROXY="); ok { + proxy = value + } + if value, ok := strings.CutPrefix(entry, "NO_PROXY="); ok { + noProxy = value + } + } + if !strings.HasPrefix(proxy, "http://127.0.0.1:") { + t.Fatalf("HTTPS_PROXY = %q, want local black-hole", proxy) + } + if noProxy != "localhost,127.0.0.1,::1" { + t.Fatalf("NO_PROXY = %q", noProxy) + } +} + +func TestShellEnvironmentUntouchedWhenNetAllow(t *testing.T) { + t.Setenv(netpolicy.EnvMode, "allow") + for _, entry := range shellEnvironment("") { + if strings.HasPrefix(entry, "HTTP_PROXY=http://127.0.0.1:") { + t.Fatalf("allow mode injected proxy entry %q", entry) + } + } +} diff --git a/internal/seniordev/tool/path.go b/internal/seniordev/tool/path.go new file mode 100644 index 0000000000..d9d58ea599 --- /dev/null +++ b/internal/seniordev/tool/path.go @@ -0,0 +1,127 @@ +//go:build !windows + +// Path confinement: every tool path resolves inside the workspace unless the +// registry allows external directories, in which case it asks first. +package tool + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/project" +) + +func (r *Registry) resolvePath(path string) (string, error) { + candidate := path + if !filepath.IsAbs(candidate) { + candidate = filepath.Join(r.workDir, candidate) + } + absolute, err := filepath.Abs(candidate) + if err != nil { + return "", fmt.Errorf("resolve path %q: %w", path, err) + } + absolute = filepath.Clean(absolute) + if r.instance != nil { + absolute = project.RedirectIntoDirectory(absolute, *r.instance) + } + + relative, err := filepath.Rel(r.workDir, absolute) + if !r.allowExternal && (err != nil || outsidePath(relative)) { + return "", fmt.Errorf("path escapes workspace: %s", path) + } + return absolute, nil +} + +// resolveWritePath is resolvePath for a path a tool is about to write: under +// ConfineWrites a path outside the workspace is refused, with a sentence the +// model can act on, before anything is asked or touched. +// +// IT FOLLOWS SYMLINKS. A link inside the workspace that points out of it is a +// write outside it, so the deepest part of the path that exists is resolved on +// both sides before they are compared (and macOS's /var and /private/var are +// the same place by the same rule). +// +// WHY IT EXISTS. codeaf keeps only what a run changes in the folder it is +// handed, on the branch it cut there. A run told by its brief to "make a +// checkout" cloned a repository into the person's own projects folder and +// edited it there with these tools: the task ended saying it had changed +// nothing, and the edits sat in a folder of the person's that no task owned. +// Reads stay open, because a task's statement can live outside its folder; +// the shell cannot be fenced this way, and the prompt says so. +func (r *Registry) resolveWritePath(path string) (string, error) { + resolved, err := r.resolvePath(path) + if err != nil || !r.confineWrites { + return resolved, err + } + if !withinReal(r.workDir, resolved) { + return "", fmt.Errorf("write refused: %s is outside this run's workspace (%s). "+ + "Only changes inside the workspace are handed back, so make this change there", path, r.workDir) + } + return resolved, nil +} + +// withinReal reports whether target is root or under it once both have had +// their symlinks resolved as far as they exist. +func withinReal(root, target string) bool { + relative, err := filepath.Rel(realPrefix(root), realPrefix(target)) + return err == nil && !outsidePath(relative) +} + +// realPrefix resolves the symlinks of the deepest existing ancestor of path and +// puts the parts that do not exist yet back on the end. +func realPrefix(path string) string { + path = filepath.Clean(path) + var rest []string + for current := path; ; { + if real, err := filepath.EvalSymlinks(current); err == nil { + for i := len(rest) - 1; i >= 0; i-- { + real = filepath.Join(real, rest[i]) + } + return real + } + parent := filepath.Dir(current) + if parent == current { + return path + } + rest = append(rest, filepath.Base(current)) + current = parent + } +} + +func outsidePath(relative string) bool { + return relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) +} + +func (r *Registry) askExternalDirectory( + ctx context.Context, + call steploop.ToolCall, + target string, + kind string, +) error { + if !r.allowExternal || target == "" { + return nil + } + inside := func(root string) bool { + if root == "" { + return false + } + relative, err := filepath.Rel(root, target) + return err == nil && !outsidePath(relative) + } + worktree := r.worktree() + if inside(r.workDir) || (worktree != string(filepath.Separator) && inside(worktree)) { + return nil + } + directory := filepath.Dir(target) + if kind == "directory" { + directory = target + } + glob := filepath.ToSlash(filepath.Join(directory, "*")) + return r.askWithAlways(ctx, call, "external_directory", []string{glob}, []string{glob}, map[string]any{ + "filepath": target, + "parentDir": directory, + }) +} diff --git a/internal/seniordev/tool/question.go b/internal/seniordev/tool/question.go new file mode 100644 index 0000000000..1cbbd6d3a7 --- /dev/null +++ b/internal/seniordev/tool/question.go @@ -0,0 +1,161 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" + "github.com/Agent-Field/codeaf/internal/seniordev/question" +) + +const questionSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "ref": "QuestionPrompt", + "type": "object", + "properties": { + "question": {"description": "Complete question", "type": "string"}, + "header": {"description": "Very short label (max 30 chars)", "type": "string"}, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "ref": "QuestionOption", + "type": "object", + "properties": { + "label": {"description": "Display text (1-5 words, concise)", "type": "string"}, + "description": {"description": "Explanation of choice", "type": "string"} + }, + "required": ["label", "description"] + } + }, + "multiple": {"description": "Allow selecting multiple choices", "type": "boolean"} + }, + "required": ["question", "header", "options"] + } + } + }, + "required": ["questions"] +}` + +type questionInput struct { + Questions []question.Prompt `json:"questions"` +} + +func validateQuestion(raw json.RawMessage) error { + _, err := decodeQuestionInput(raw) + return err +} + +func decodeQuestionInput(raw json.RawMessage) (questionInput, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return questionInput{}, fmt.Errorf("input must be a JSON object: %w", err) + } + if fields == nil { + return questionInput{}, fmt.Errorf("input must be a JSON object") + } + questionsField, ok := fields["questions"] + if !ok { + return questionInput{}, fmt.Errorf("missing required field %q", "questions") + } + questionsRaw := bytes.TrimSpace(questionsField) + if len(questionsRaw) == 0 || questionsRaw[0] != '[' { + return questionInput{}, fmt.Errorf("field %q must be an array", "questions") + } + var prompts []json.RawMessage + if err := json.Unmarshal(questionsRaw, &prompts); err != nil { + return questionInput{}, fmt.Errorf("invalid questions: %w", err) + } + for index, prompt := range prompts { + if !question.SchemaAccepts("prompt", "basic", prompt) { + return questionInput{}, fmt.Errorf("invalid question at index %d", index) + } + } + var input questionInput + if err := json.Unmarshal(raw, &input); err != nil { + return questionInput{}, fmt.Errorf("invalid input: %w", err) + } + return input, nil +} + +func (r *Registry) executeQuestion(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + input, err := decodeQuestionInput(call.Input) + if err != nil { + return steploop.ToolResult{}, err + } + questions := make([]question.Info, len(input.Questions)) + for index, prompt := range input.Questions { + questions[index] = question.Info{ + Question: prompt.Question, + Header: prompt.Header, + Options: append([]question.Option(nil), prompt.Options...), + Multiple: prompt.Multiple, + } + } + var origin *question.Tool + if call.ID != "" { + origin = &question.Tool{MessageID: call.MessageID, CallID: call.ID} + } + + // Ask has no timeout of its own: it publishes the question and blocks until + // it is answered, rejected, or the run context is cancelled. An unattended + // runtime rejects through this service; after three consecutive rejections + // the registry returns the bounded, model-visible result below instead of + // another error. + answers, err := r.question.Ask(ctx, question.AskInput{ + SessionID: call.SessionID, + Questions: questions, + Tool: origin, + }) + if err != nil { + var rejected *question.RejectedError + if errors.As(err, &rejected) && r.recordQuestionRejection(call.SessionID) >= 3 { + return steploop.ToolResult{ + Title: "Questions unavailable", + Output: "Questions are unavailable for this run. Your current answers are final; proceed with your best judgment.", + Metadata: msgmodel.RawObject("{}"), + }, nil + } + return steploop.ToolResult{}, err + } + r.resetQuestionRejections(call.SessionID) + + formatted := make([]string, len(input.Questions)) + for index, prompt := range input.Questions { + answer := "Unanswered" + if index < len(answers) && len(answers[index]) > 0 { + answer = strings.Join(answers[index], ", ") + } + formatted[index] = `"` + prompt.Question + `"="` + answer + `"` + } + title := fmt.Sprintf("Asked %d question", len(input.Questions)) + if len(input.Questions) > 1 { + title += "s" + } + metadata, marshalErr := jsonutil.Marshal(struct { + Answers []question.Answer `json:"answers"` + }{Answers: answers}) + if marshalErr != nil { + return steploop.ToolResult{}, marshalErr + } + return steploop.ToolResult{ + Title: title, + Output: "User has answered your questions: " + strings.Join(formatted, ", ") + + ". You can now continue with the user's answers in mind.", + Metadata: msgmodel.RawObject(metadata), + }, nil +} diff --git a/internal/seniordev/tool/question.txt b/internal/seniordev/tool/question.txt new file mode 100644 index 0000000000..03cd496d60 --- /dev/null +++ b/internal/seniordev/tool/question.txt @@ -0,0 +1,10 @@ +Use this tool when you need to ask the user questions during execution. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take. + +Usage notes: +- When `custom` is enabled (default), a "Type your own answer" option is added automatically; don't include "Other" or catch-all options +- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one +- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label diff --git a/internal/seniordev/tool/question_description.go b/internal/seniordev/tool/question_description.go new file mode 100644 index 0000000000..18d45f2da0 --- /dev/null +++ b/internal/seniordev/tool/question_description.go @@ -0,0 +1,8 @@ +//go:build !windows + +package tool + +import _ "embed" + +//go:embed question.txt +var questionDescription string diff --git a/internal/seniordev/tool/question_test.go b/internal/seniordev/tool/question_test.go new file mode 100644 index 0000000000..562908f2c3 --- /dev/null +++ b/internal/seniordev/tool/question_test.go @@ -0,0 +1,264 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/bus" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/question" +) + +type questionPublication struct { + definition bus.Definition + properties any +} + +type questionPublisher struct { + events chan questionPublication +} + +func (p *questionPublisher) Publish(definition bus.Definition, properties any, _ ...bus.PublishOptions) { + p.events <- questionPublication{definition: definition, properties: properties} +} + +func questionDefinition(t *testing.T, registry *Registry) steploop.ToolDefinition { + t.Helper() + for _, item := range registry.Definitions() { + if item.Provider.Name == "question" { + return item + } + } + t.Fatal("question definition not registered") + return steploop.ToolDefinition{} +} + +func newQuestionRegistry(t *testing.T, service *question.Service) *Registry { + t.Helper() + t.Setenv("SENIOR_DEV_ENABLE_QUESTION_TOOL", "0") + return NewWithOptions(t.TempDir(), RegistryOptions{ + ClientIdentity: "cli", + Question: service, + }) +} + +func TestQuestionRegistrationDescriptionSchemaAndFilters(t *testing.T) { + registry := newQuestionRegistry(t, question.NewService(nil, nil)) + definition := questionDefinition(t, registry) + if !definition.WaitForResult { + t.Fatal("question definition does not keep the provider stream open") + } + if definition.Provider.Description != questionDescription { + t.Fatal("registered question description does not use embedded asset") + } + + valid := json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[{"label":"Yes","description":"Continue","ignored":1}],"multiple":true,"custom":false}],"ignored":true}`) + if err := definition.Validate(valid); err != nil { + t.Fatalf("valid parameters rejected: %v", err) + } + for _, invalid := range []json.RawMessage{ + json.RawMessage(`{"questions":[{"header":"Missing question","options":[]}]}`), + json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":null}]}`), + } { + if err := definition.Validate(invalid); err == nil { + t.Fatalf("invalid parameters accepted: %s", invalid) + } + } + + coder := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "anthropic/claude-opus-4-6", + })) + if want := []string{"question", "bash", "read", "glob", "grep", "edit", "write", "webfetch"}; !reflect.DeepEqual(coder, want) { + t.Fatalf("coder definitions = %v, want %v", coder, want) + } +} + +func TestQuestionForceFlagEnablesNonClientRegistry(t *testing.T) { + t.Setenv("SENIOR_DEV_ENABLE_QUESTION_TOOL", "1") + registry := NewWithOptions(t.TempDir(), RegistryOptions{ClientIdentity: "server"}) + _ = questionDefinition(t, registry) +} + +func TestHeadlessQuestionWaitsUntilRunCancellation(t *testing.T) { + publisher := &questionPublisher{events: make(chan questionPublication, 1)} + service := question.NewService(publisher, func() (question.QuestionID, error) { + return "que_headless", nil + }) + registry := newQuestionRegistry(t, service) + ctx, cancel := context.WithCancel(context.Background()) + type execution struct { + result steploop.ToolResult + err error + } + done := make(chan execution, 1) + go func() { + result, err := registry.Execute(ctx, steploop.ToolCall{ + ID: "call_1", Name: "question", SessionID: "ses_1", MessageID: "msg_1", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + }) + done <- execution{result: result, err: err} + }() + + published := <-publisher.events + request, ok := published.properties.(question.Request) + if published.definition.Type != question.Event.Asked.Type || !ok || request.ID != "que_headless" || + request.Tool == nil || request.Tool.MessageID != "msg_1" || request.Tool.CallID != "call_1" { + t.Fatalf("asked publication = %#v", published) + } + select { + case completed := <-done: + t.Fatalf("headless question completed without an answer: %#v", completed) + default: + } + if pending := service.List(); len(pending) != 1 || pending[0].ID != "que_headless" { + t.Fatalf("pending questions = %#v", pending) + } + + cancel() + completed := <-done + if !errors.Is(completed.err, context.Canceled) { + t.Fatalf("question cancellation error = %v", completed.err) + } + if pending := service.List(); len(pending) != 0 { + t.Fatalf("pending after cancellation = %#v", pending) + } +} + +func TestQuestionReplyFormatsToolResult(t *testing.T) { + publisher := &questionPublisher{events: make(chan questionPublication, 1)} + service := question.NewService(publisher, func() (question.QuestionID, error) { + return "que_reply", nil + }) + registry := newQuestionRegistry(t, service) + done := make(chan struct { + result steploop.ToolResult + err error + }, 1) + go func() { + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call_1", Name: "question", SessionID: "ses_1", MessageID: "msg_1", + Input: json.RawMessage(`{"questions":[{"question":"Color?","header":"Color","options":[{"label":"Blue","description":"Use blue"}]},{"question":"Size?","header":"Size","options":[]}]}`), + }) + done <- struct { + result steploop.ToolResult + err error + }{result: result, err: err} + }() + published := <-publisher.events + request := published.properties.(question.Request) + service.Reply(question.ReplyInput{ + RequestID: request.ID, + Answers: []question.Answer{{"Blue"}, {}}, + }) + + completed := <-done + if completed.err != nil { + t.Fatal(completed.err) + } + if completed.result.Title != "Asked 2 questions" { + t.Fatalf("title = %q", completed.result.Title) + } + wantOutput := `User has answered your questions: "Color?"="Blue", "Size?"="Unanswered". You can now continue with the user's answers in mind.` + if completed.result.Output != wantOutput { + t.Fatalf("output = %q, want %q", completed.result.Output, wantOutput) + } + if string(completed.result.Metadata) != `{"answers":[["Blue"],[]]}` { + t.Fatalf("metadata = %s", completed.result.Metadata) + } +} + +func TestQuestionRejectionsBecomeTerminalResultAfterThree(t *testing.T) { + instanceBus := bus.New(bus.Context{}) + service := question.NewService(instanceBus, nil) + instanceBus.SubscribeCallback(question.Event.Asked, func(payload bus.Payload) { + service.Reject(payload.Properties.(question.Request).ID) + }) + registry := newQuestionRegistry(t, service) + call := steploop.ToolCall{ + Name: "question", SessionID: "ses-bounded", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + } + for attempt := 1; attempt <= 3; attempt++ { + result, err := registry.Execute(context.Background(), call) + if attempt < 3 { + var rejected *question.RejectedError + if !errors.As(err, &rejected) { + t.Fatalf("attempt %d = (%#v, %v), want rejection", attempt, result, err) + } + continue + } + if err != nil || result.Title != "Questions unavailable" || + !strings.Contains(result.Output, "answers are final") || + !strings.Contains(result.Output, "best judgment") { + t.Fatalf("terminal attempt = (%#v, %v)", result, err) + } + } +} + +func TestQuestionRejectionCounterResetsAfterSuccessfulTool(t *testing.T) { + instanceBus := bus.New(bus.Context{}) + service := question.NewService(instanceBus, nil) + instanceBus.SubscribeCallback(question.Event.Asked, func(payload bus.Payload) { + service.Reject(payload.Properties.(question.Request).ID) + }) + registry := newQuestionRegistry(t, service) + questionCall := steploop.ToolCall{ + Name: "question", SessionID: "ses-reset", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + } + for range 2 { + if _, err := registry.Execute(context.Background(), questionCall); err == nil { + t.Fatal("question unexpectedly succeeded before reset") + } + } + resetFile := filepath.Join(registry.workDir, "reset-marker.txt") + if err := os.WriteFile(resetFile, []byte("marker\n"), 0o644); err != nil { + t.Fatal(err) + } + readInput, err := json.Marshal(map[string]any{"filePath": resetFile}) + if err != nil { + t.Fatal(err) + } + if _, err := registry.Execute(context.Background(), steploop.ToolCall{ + Name: "read", SessionID: "ses-reset", Input: readInput, + }); err != nil { + t.Fatalf("interleaved read: %v", err) + } + for attempt := 1; attempt <= 2; attempt++ { + if result, err := registry.Execute(context.Background(), questionCall); err == nil { + t.Fatalf("post-reset attempt %d unexpectedly terminal: %#v", attempt, result) + } + } + result, err := registry.Execute(context.Background(), questionCall) + if err != nil || result.Title != "Questions unavailable" { + t.Fatalf("post-reset third attempt = (%#v, %v)", result, err) + } +} + +func TestAnsweredQuestionsNeverHitRejectionBound(t *testing.T) { + instanceBus := bus.New(bus.Context{}) + service := question.NewService(instanceBus, nil) + instanceBus.SubscribeCallback(question.Event.Asked, func(payload bus.Payload) { + request := payload.Properties.(question.Request) + service.Reply(question.ReplyInput{RequestID: request.ID, Answers: []question.Answer{{"Yes"}}}) + }) + registry := newQuestionRegistry(t, service) + call := steploop.ToolCall{ + Name: "question", SessionID: "ses-embedded", + Input: json.RawMessage(`{"questions":[{"question":"Continue?","header":"Confirm","options":[]}]}`), + } + for attempt := 1; attempt <= 5; attempt++ { + result, err := registry.Execute(context.Background(), call) + if err != nil || result.Title != "Asked 1 question" || strings.Contains(result.Output, "unavailable") { + t.Fatalf("answered attempt %d = (%#v, %v)", attempt, result, err) + } + } +} diff --git a/internal/seniordev/tool/read.go b/internal/seniordev/tool/read.go new file mode 100644 index 0000000000..b9d1cb8736 --- /dev/null +++ b/internal/seniordev/tool/read.go @@ -0,0 +1,493 @@ +//go:build !windows + +// The read tool: files with line-number prefixes, directories as entry lists, +// images and PDFs as attachments, plus nested instruction reminders. +package tool + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf16" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +const ( + defaultReadLimit = 2000 + maxLineLength = 2000 + maxReadBytes = 50 * 1024 + sampleBytes = 4096 +) + +const maxLineSuffix = "... (line truncated to 2000 chars)" + +var supportedImageMIMEs = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, + "image/gif": {}, + "image/webp": {}, +} + +var binaryExtensions = map[string]struct{}{ + ".zip": {}, ".tar": {}, ".gz": {}, ".exe": {}, ".dll": {}, ".so": {}, + ".class": {}, ".jar": {}, ".war": {}, ".7z": {}, ".doc": {}, ".docx": {}, + ".xls": {}, ".xlsx": {}, ".ppt": {}, ".pptx": {}, ".odt": {}, ".ods": {}, + ".odp": {}, ".bin": {}, ".dat": {}, ".obj": {}, ".o": {}, ".a": {}, + ".lib": {}, ".wasm": {}, ".pyc": {}, ".pyo": {}, +} + +type readLinesResult struct { + raw []string + count int + cut bool + more bool + offset int +} + +type readMetadata struct { + Preview string `json:"preview"` + Truncated bool `json:"truncated"` + Loaded []string `json:"loaded"` +} + +func (r *Registry) executeRead(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input readInput + if err := decodeInput(call.Input, &input, "filePath"); err != nil { + return steploop.ToolResult{}, err + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + + offset := 1 + if input.Offset != nil && *input.Offset != 0 { + offset = *input.Offset + } + limit := defaultReadLimit + if input.Limit != nil { + limit = *input.Limit + } + + resolved, err := r.resolvePath(input.FilePath) + if err != nil { + return steploop.ToolResult{}, err + } + info, err := os.Stat(resolved) + kind := "file" + if err == nil && info.IsDir() { + kind = "directory" + } + if askErr := r.askExternalDirectory(ctx, call, resolved, kind); askErr != nil { + return steploop.ToolResult{}, askErr + } + if askErr := r.ask(ctx, call, "read", []string{resolved}, map[string]any{}); askErr != nil { + return steploop.ToolResult{}, askErr + } + if errors.Is(err, os.ErrNotExist) { + return steploop.ToolResult{}, readMissError(resolved) + } + if err != nil { + return steploop.ToolResult{}, err + } + + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + if info.IsDir() { + return readDirectory(ctx, resolved, title, offset, limit) + } + + loaded := r.instructionService().Resolve( + steploop.ToolMessagesFromContext(ctx), resolved, call.MessageID, + ) + loadedPaths := make([]string, 0, len(loaded)) + for _, item := range loaded { + loadedPaths = append(loadedPaths, item.Filepath) + } + + sample, err := readSample(resolved, info.Size(), sampleBytes) + if err != nil { + return steploop.ToolResult{}, err + } + mimeType := sniffAttachmentMIME(sample, attachmentMIME(resolved)) + if _, ok := supportedImageMIMEs[mimeType]; ok || mimeType == "application/pdf" { + data, err := os.ReadFile(resolved) + if err != nil { + return steploop.ToolResult{}, err + } + message := "Image read successfully" + if mimeType == "application/pdf" { + message = "PDF read successfully" + } + attachments := []msgmodel.FilePart{{ + Type: "file", + Mime: mimeType, + URL: "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), + }} + return steploop.ToolResult{ + Title: title, + Output: message, + Attachments: &attachments, + Metadata: rawMetadata(readMetadata{ + Preview: message, + Truncated: false, + Loaded: loadedPaths, + }), + }, nil + } + + if isBinaryFile(resolved, sample) { + return steploop.ToolResult{}, fmt.Errorf("Cannot read binary file: %s", resolved) + } + + file, err := readLines(ctx, resolved, limit, offset) + if err != nil { + return steploop.ToolResult{}, err + } + if file.count < file.offset && !(file.count == 0 && file.offset == 1) { + return steploop.ToolResult{}, fmt.Errorf( + "Offset %d is out of range for this file (%d lines)", + file.offset, + file.count, + ) + } + + var output strings.Builder + output.WriteString("<path>") + output.WriteString(resolved) + output.WriteString("</path>\n<type>file</type>\n<content>\n") + for i, line := range file.raw { + if i > 0 { + output.WriteByte('\n') + } + fmt.Fprintf(&output, "%d: %s", i+file.offset, line) + } + + last := file.offset + len(file.raw) - 1 + next := last + 1 + truncated := file.more || file.cut + switch { + case file.cut: + fmt.Fprintf( + &output, + "\n\n(Output capped at 50 KB. Showing lines %d-%d. Use offset=%d to continue.)", + file.offset, + last, + next, + ) + case file.more: + fmt.Fprintf( + &output, + "\n\n(Showing lines %d-%d of %d. Use offset=%d to continue.)", + file.offset, + last, + file.count, + next, + ) + default: + fmt.Fprintf(&output, "\n\n(End of file - total %d lines)", file.count) + } + output.WriteString("\n</content>") + if len(loaded) > 0 { + output.WriteString("\n\n<system-reminder>\n") + for index, item := range loaded { + if index > 0 { + output.WriteString("\n\n") + } + output.WriteString(item.Content) + } + output.WriteString("\n</system-reminder>") + } + + previewLimit := len(file.raw) + if previewLimit > 20 { + previewLimit = 20 + } + return steploop.ToolResult{ + Title: title, + Output: output.String(), + Metadata: rawMetadata(readMetadata{ + Preview: strings.Join(file.raw[:previewLimit], "\n"), + Truncated: truncated, + Loaded: loadedPaths, + }), + }, nil +} + +func readMissError(path string) error { + dir := filepath.Dir(path) + base := filepath.Base(path) + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("File not found: %s", path) + } + baseLower := strings.ToLower(base) + items := make([]string, 0, 3) + for _, entry := range entries { + nameLower := strings.ToLower(entry.Name()) + if strings.Contains(nameLower, baseLower) || strings.Contains(baseLower, nameLower) { + items = append(items, filepath.Join(dir, entry.Name())) + if len(items) == 3 { + break + } + } + } + if len(items) > 0 { + return fmt.Errorf("File not found: %s\n\nDid you mean one of these?\n%s", path, strings.Join(items, "\n")) + } + return fmt.Errorf("File not found: %s", path) +} + +func readDirectory( + ctx context.Context, + path string, + title string, + offset int, + limit int, +) (steploop.ToolResult, error) { + entries, err := os.ReadDir(path) + if err != nil { + return steploop.ToolResult{}, err + } + items := make([]string, 0, len(entries)) + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + name := entry.Name() + isDir := entry.IsDir() + if entry.Type()&os.ModeSymlink != 0 { + if target, statErr := os.Stat(filepath.Join(path, name)); statErr == nil { + isDir = target.IsDir() + } + } + if isDir { + name += "/" + } + items = append(items, name) + } + sort.SliceStable(items, func(i, j int) bool { + return items[i] < items[j] + }) + + start := offset - 1 + if start < 0 { + start = 0 + } + if start > len(items) { + start = len(items) + } + end := start + limit + if end > len(items) { + end = len(items) + } + sliced := items[start:end] + truncated := start+len(sliced) < len(items) + + var output strings.Builder + output.WriteString("<path>") + output.WriteString(path) + output.WriteString("</path>\n<type>directory</type>\n<entries>\n") + output.WriteString(strings.Join(sliced, "\n")) + if truncated { + fmt.Fprintf( + &output, + "\n\n(Showing %d of %d entries. Use 'offset' parameter to read beyond entry %d)", + len(sliced), + len(items), + offset+len(sliced), + ) + } else { + fmt.Fprintf(&output, "\n\n(%d entries)", len(items)) + } + output.WriteString("\n</entries>") + + previewLimit := len(sliced) + if previewLimit > 20 { + previewLimit = 20 + } + return steploop.ToolResult{ + Title: title, + Output: output.String(), + Metadata: rawMetadata(readMetadata{ + Preview: strings.Join(sliced[:previewLimit], "\n"), + Truncated: truncated, + Loaded: []string{}, + }), + }, nil +} + +func rawMetadata(value any) msgmodel.RawObject { + data, err := jsonutil.Marshal(value) + if err != nil { + panic(err) + } + return msgmodel.RawObject(data) +} + +func readSample(path string, fileSize int64, size int) ([]byte, error) { + if fileSize == 0 { + return []byte{}, nil + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + if fileSize < int64(size) { + size = int(fileSize) + } + out := make([]byte, size) + n, err := io.ReadFull(file, out) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) { + return nil, err + } + return out[:n], nil +} + +func isBinaryFile(path string, sample []byte) bool { + if _, ok := binaryExtensions[strings.ToLower(filepath.Ext(path))]; ok { + return true + } + if len(sample) == 0 { + return false + } + nonPrintable := 0 + for _, value := range sample { + if value == 0 { + return true + } + if value < 9 || (value > 13 && value < 32) { + nonPrintable++ + } + } + return float64(nonPrintable)/float64(len(sample)) > 0.3 +} + +func sniffAttachmentMIME(data []byte, fallback string) string { + switch { + case bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}): + return "image/png" + case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}): + return "image/jpeg" + case bytes.HasPrefix(data, []byte{0x47, 0x49, 0x46, 0x38}): + return "image/gif" + case bytes.HasPrefix(data, []byte{0x42, 0x4d}): + return "image/bmp" + case bytes.HasPrefix(data, []byte{0x25, 0x50, 0x44, 0x46, 0x2d}): + return "application/pdf" + case len(data) >= 12 && + bytes.Equal(data[:4], []byte{0x52, 0x49, 0x46, 0x46}) && + bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}): + return "image/webp" + default: + return fallback + } +} + +func attachmentMIME(path string) string { + value := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))) + if semi := strings.IndexByte(value, ';'); semi >= 0 { + value = value[:semi] + } + if value == "" { + return "application/octet-stream" + } + return value +} + +func readLines(ctx context.Context, path string, limit int, offset int) (readLinesResult, error) { + file, err := os.Open(path) + if err != nil { + return readLinesResult{}, err + } + defer file.Close() + + result := readLinesResult{raw: []string{}, offset: offset} + start := offset - 1 + reader := bufio.NewReader(file) + for { + if err := ctx.Err(); err != nil { + return readLinesResult{}, err + } + data, readErr := reader.ReadBytes('\n') + if len(data) == 0 && errors.Is(readErr, io.EOF) { + break + } + if len(data) > 0 && data[len(data)-1] == '\n' { + data = data[:len(data)-1] + if len(data) > 0 && data[len(data)-1] == '\r' { + data = data[:len(data)-1] + } + } + text := strings.ToValidUTF8(string(data), "\uFFFD") + result.count++ + if result.count > start { + if len(result.raw) >= limit { + result.more = true + } else { + line := truncateLineUTF16(text, maxLineLength) + size := len([]byte(line)) + if len(result.raw) > 0 { + size++ + } + if readBytesLength(result.raw)+size > maxReadBytes { + result.cut = true + result.more = true + break + } + result.raw = append(result.raw, line) + } + } + if readErr != nil { + if !errors.Is(readErr, io.EOF) { + return readLinesResult{}, readErr + } + break + } + } + return result, nil +} + +func readBytesLength(lines []string) int { + total := 0 + for i, line := range lines { + total += len([]byte(line)) + if i > 0 { + total++ + } + } + return total +} + +// truncateLineUTF16 cuts a line at limit UTF-16 code units and appends +// maxLineSuffix. +func truncateLineUTF16(value string, limit int) string { + units := utf16.Encode([]rune(value)) + if len(units) <= limit { + return value + } + units = units[:limit] + runes := utf16.Decode(units) + out := string(runes) + if len(runes) > 0 && runes[len(runes)-1] == utf8.RuneError && units[len(units)-1] >= 0xd800 && units[len(units)-1] <= 0xdbff { + // Cutting at a UTF-16 boundary can split a surrogate pair; the dangling + // high surrogate becomes U+FFFD so the output stays valid UTF-8. + out = string(runes[:len(runes)-1]) + "\uFFFD" + } + return out + maxLineSuffix +} diff --git a/internal/seniordev/tool/read_test.go b/internal/seniordev/tool/read_test.go new file mode 100644 index 0000000000..d4d96c46ab --- /dev/null +++ b/internal/seniordev/tool/read_test.go @@ -0,0 +1,302 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func TestReadInjectsNestedInstructionsOncePerSession(t *testing.T) { + // A nearby AGENTS.md is appended as a system-reminder block and its path is + // recorded; persisted read metadata suppresses it on later reads. + workDir := t.TempDir() + nested := filepath.Join(workDir, "src", "pkg") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + rules := filepath.Join(workDir, "src", "AGENTS.md") + target := filepath.Join(nested, "main.go") + if err := os.WriteFile(rules, []byte("keep the nested contract"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("package pkg\n"), 0o644); err != nil { + t.Fatal(err) + } + + registry := New(workDir) + input := json.RawMessage(`{"filePath":"src/pkg/main.go"}`) + first, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call_1", Name: "read", Input: input, + SessionID: "ses_1", MessageID: "msg_1", + }) + if err != nil { + t.Fatal(err) + } + wantReminder := "\n\n<system-reminder>\nInstructions from: " + rules + + "\nkeep the nested contract\n</system-reminder>" + if !strings.HasSuffix(first.Output, wantReminder) { + t.Fatalf("first output missing reminder:\n%s", first.Output) + } + var metadata readMetadata + if err := json.Unmarshal(first.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(metadata.Loaded, []string{rules}) { + t.Fatalf("loaded = %#v, want %q", metadata.Loaded, rules) + } + + history := []msgmodel.WithParts{{Parts: msgmodel.Parts{msgmodel.ToolPart{ + PartBase: msgmodel.PartBase{ID: "part_1", SessionID: "ses_1", MessageID: "msg_1"}, + CallID: "call_1", + Tool: "read", + State: msgmodel.CompletedToolState( + msgmodel.RawObject(input), first.Output, first.Title, first.Metadata, 1, 2, nil, + ), + }}}} + secondCtx := steploop.WithToolMessages(context.Background(), history) + second, err := registry.Execute(secondCtx, steploop.ToolCall{ + ID: "call_2", Name: "read", Input: input, + SessionID: "ses_1", MessageID: "msg_2", + }) + if err != nil { + t.Fatal(err) + } + if strings.Contains(second.Output, "<system-reminder>") { + t.Fatalf("second output duplicated reminder:\n%s", second.Output) + } + if err := json.Unmarshal(second.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if len(metadata.Loaded) != 0 { + t.Fatalf("second loaded = %#v, want empty", metadata.Loaded) + } +} + +func TestReadInstructionClaimsClearAfterAssistantTurn(t *testing.T) { + // An in-flight claim suppresses duplicate reads within one assistant turn, + // but clearing that turn permits a retry when no completed read metadata + // recorded the path. + workDir := t.TempDir() + nested := filepath.Join(workDir, "src") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, nested, "AGENTS.md", "retry this rule") + writeTestFile(t, nested, "main.go", "package main") + registry := New(workDir) + call := steploop.ToolCall{ + ID: "call", Name: "read", Input: json.RawMessage(`{"filePath":"src/main.go"}`), + SessionID: "session", MessageID: "assistant", + } + first, err := registry.Execute(context.Background(), call) + if err != nil || !strings.Contains(first.Output, "retry this rule") { + t.Fatalf("first read = (%q, %v)", first.Output, err) + } + second, err := registry.Execute(context.Background(), call) + if err != nil || strings.Contains(second.Output, "retry this rule") { + t.Fatalf("same-turn read = (%q, %v)", second.Output, err) + } + registry.ClearInstructionClaims(context.Background(), call.MessageID) + third, err := registry.Execute(context.Background(), call) + if err != nil || !strings.Contains(third.Output, "retry this rule") { + t.Fatalf("post-clear retry = (%q, %v)", third.Output, err) + } +} + +func TestReadNestedInstructionPrecedenceAndNoMatch(t *testing.T) { + // Nested lookup prefers AGENTS.md, then CLAUDE.md, then the deprecated + // CONTEXT.md; a file with no nearby instructions is unchanged. + tests := []struct { + name string + files map[string]string + want string + content string + }{ + { + name: "agents", want: "AGENTS.md", content: "agents wins", + files: map[string]string{ + "AGENTS.md": "agents wins", "CLAUDE.md": "claude loses", "CONTEXT.md": "context loses", + }, + }, + { + name: "claude", want: "CLAUDE.md", content: "claude wins", + files: map[string]string{"CLAUDE.md": "claude wins", "CONTEXT.md": "context loses"}, + }, + { + name: "context", want: "CONTEXT.md", content: "context remains", + files: map[string]string{"CONTEXT.md": "context remains"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + workDir := t.TempDir() + nested := filepath.Join(workDir, "nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + for name, content := range test.files { + if err := os.WriteFile(filepath.Join(nested, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(nested, "target.txt"), []byte("target"), 0o644); err != nil { + t.Fatal(err) + } + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "nested/target.txt"}) + if err != nil { + t.Fatal(err) + } + want := "Instructions from: " + filepath.Join(nested, test.want) + "\n" + test.content + if !strings.Contains(result.Output, want) { + t.Fatalf("output missing %q:\n%s", want, result.Output) + } + for name := range test.files { + if name != test.want && strings.Contains(result.Output, filepath.Join(nested, name)) { + t.Fatalf("output included lower-precedence %s:\n%s", name, result.Output) + } + } + }) + } + + workDir := t.TempDir() + writeTestFile(t, workDir, "plain.txt", "plain") + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "plain.txt"}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(result.Output, "<system-reminder>") || !strings.HasSuffix(result.Output, "\n</content>") { + t.Fatalf("no-match output changed:\n%s", result.Output) + } +} + +func TestReadDirectory(t *testing.T) { + workDir := t.TempDir() + if err := os.Mkdir(filepath.Join(workDir, "beta"), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, "alpha.txt", "alpha") + if err := os.Symlink("beta", filepath.Join(workDir, "linked")); err != nil { + t.Fatal(err) + } + + result, err := execute(t, New(workDir), "read", map[string]any{ + "filePath": ".", + "limit": 2, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "<path>" + workDir + "</path>\n<type>directory</type>\n<entries>\n" + + "alpha.txt\nbeta/\n\n" + + "(Showing 2 of 3 entries. Use 'offset' parameter to read beyond entry 3)\n</entries>" + if result.Output != want { + t.Fatalf("Output:\n%s\nwant:\n%s", result.Output, want) + } + if result.Title != "." { + t.Fatalf("Title = %q", result.Title) + } + if got := string(result.Metadata); got != `{"preview":"alpha.txt\nbeta/","truncated":true,"loaded":[]}` { + t.Fatalf("Metadata = %s", got) + } +} + +func TestReadLongLineAndByteCap(t *testing.T) { + workDir := t.TempDir() + long := strings.Repeat("界", maxLineLength+1) + writeTestFile(t, workDir, "long.txt", long+"\nend\n") + + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "long.txt"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + wantLine := strings.Repeat("界", maxLineLength) + maxLineSuffix + if !strings.Contains(result.Output, "1: "+wantLine+"\n2: end") { + t.Fatalf("long line was not truncated by UTF-16 length") + } + + lines := make([]string, 40) + for i := range lines { + lines[i] = strings.Repeat("x", maxLineLength) + } + writeTestFile(t, workDir, "cap.txt", strings.Join(lines, "\n")) + result, err = execute(t, New(workDir), "read", map[string]any{"filePath": "cap.txt"}) + if err != nil { + t.Fatalf("Execute capped: %v", err) + } + if !strings.Contains(result.Output, "(Output capped at 50 KB. Showing lines 1-25. Use offset=26 to continue.)") { + t.Fatalf("cap message missing from %q", result.Output[len(result.Output)-160:]) + } +} + +func TestReadImagePDFAndBinary(t *testing.T) { + workDir := t.TempDir() + png := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, []byte("payload")...) + if err := os.WriteFile(filepath.Join(workDir, "misnamed.dat"), png, 0o644); err != nil { + t.Fatal(err) + } + result, err := execute(t, New(workDir), "read", map[string]any{"filePath": "misnamed.dat"}) + if err != nil { + t.Fatalf("read image: %v", err) + } + if result.Output != "Image read successfully" || result.Attachments == nil || len(*result.Attachments) != 1 { + t.Fatalf("image result = %#v", result) + } + attachment := (*result.Attachments)[0] + wantURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png) + if attachment.Mime != "image/png" || attachment.URL != wantURL { + t.Fatalf("attachment = %#v", attachment) + } + + if err := os.WriteFile(filepath.Join(workDir, "binary.bin"), []byte("plain text"), 0o644); err != nil { + t.Fatal(err) + } + _, err = execute(t, New(workDir), "read", map[string]any{"filePath": "binary.bin"}) + if err == nil || err.Error() != "Cannot read binary file: "+filepath.Join(workDir, "binary.bin") { + t.Fatalf("binary error = %v", err) + } +} + +func TestReadOffsetAndZeroLimit(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "sample.txt", "one\ntwo\n") + registry := New(workDir) + + result, err := execute(t, registry, "read", map[string]any{ + "filePath": "sample.txt", + "offset": 0, + "limit": 0, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "(Showing lines 1-0 of 2. Use offset=1 to continue.)") { + t.Fatalf("zero-limit output = %q", result.Output) + } + + _, err = execute(t, registry, "read", map[string]any{ + "filePath": "sample.txt", + "offset": 3, + }) + if err == nil || err.Error() != "Offset 3 is out of range for this file (2 lines)" { + t.Fatalf("offset error = %v", err) + } +} + +func TestReadDescription(t *testing.T) { + if !strings.HasPrefix(readDescription, "Read a file or directory from the local filesystem.") { + t.Fatalf("read description changed: %q", readDescription) + } + if !strings.HasSuffix(readDescription, "return them as file attachments.\n") { + t.Fatalf("read description changed: %q", readDescription) + } +} diff --git a/internal/seniordev/tool/registry.go b/internal/seniordev/tool/registry.go new file mode 100644 index 0000000000..36fdfd9cd9 --- /dev/null +++ b/internal/seniordev/tool/registry.go @@ -0,0 +1,616 @@ +//go:build !windows + +// Package tool is the workspace-bound tool registry: the tools the model can +// call, each confined to a single workspace and gated by the permission rules. +package tool + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/baked" + "github.com/Agent-Field/codeaf/internal/seniordev/config" + "github.com/Agent-Field/codeaf/internal/seniordev/core" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/orclient" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/permission" + "github.com/Agent-Field/codeaf/internal/seniordev/project" + "github.com/Agent-Field/codeaf/internal/seniordev/question" + "github.com/Agent-Field/codeaf/internal/seniordev/session/instruction" +) + +const ( + bashSchema = `{ + "type": "object", + "properties": { + "command": {"type": "string"}, + "workdir": {"type": "string"}, + "timeout_ms": {"type": "integer", "minimum": 1, "maximum": 600000} + }, + "required": ["command"], + "additionalProperties": false + }` + readSchema = `{ + "type": "object", + "properties": { + "filePath": {"type": "string", "description": "The absolute path to the file or directory to read"}, + "offset": {"type": "integer", "minimum": 0, "description": "The line number to start reading from (1-indexed)"}, + "limit": {"type": "integer", "minimum": 0, "description": "The maximum number of lines to read (defaults to 2000)"} + }, + "required": ["filePath"], + "additionalProperties": false + }` + writeSchema = `{ + "type": "object", + "properties": { + "content": {"type": "string", "description": "The content to write to the file"}, + "filePath": {"type": "string", "description": "The absolute path to the file to write (must be absolute, not relative)"} + }, + "required": ["content", "filePath"], + "additionalProperties": false + }` + editSchema = `{ + "type": "object", + "properties": { + "filePath": {"type": "string", "description": "The absolute path to the file to modify"}, + "oldString": {"type": "string", "description": "The text to replace"}, + "newString": {"type": "string", "description": "The text to replace it with (must be different from oldString)"}, + "replaceAll": {"type": "boolean", "description": "Replace all occurrences of oldString (default false)"} + }, + "required": ["filePath", "oldString", "newString"], + "additionalProperties": false + }` + globSchema = `{ + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "The glob pattern to match files against"}, + "path": {"type": "string", "description": "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - simply omit it for the default behavior. Must be a valid directory path if provided."} + }, + "required": ["pattern"], + "additionalProperties": false + }` + grepSchema = `{ + "type": "object", + "properties": { + "pattern": {"type": "string", "description": "The regex pattern to search for in file contents"}, + "path": {"type": "string", "description": "The directory to search in. Defaults to the current working directory."}, + "include": {"type": "string", "description": "File pattern to include in the search (e.g. \"*.js\", \"*.{ts,tsx}\")"} + }, + "required": ["pattern"], + "additionalProperties": false + }` + applyPatchSchema = `{ + "type": "object", + "properties": { + "patchText": {"type": "string", "description": "The full patch text that describes all changes to be made"} + }, + "required": ["patchText"], + "additionalProperties": false + }` +) + +type bashInput struct { + Command string `json:"command"` + Workdir string `json:"workdir,omitempty"` + TimeoutMS *int `json:"timeout_ms,omitempty"` +} + +type readInput struct { + FilePath string `json:"filePath"` + Offset *int `json:"offset,omitempty"` + Limit *int `json:"limit,omitempty"` +} + +type writeInput struct { + Content string `json:"content"` + FilePath string `json:"filePath"` +} + +type editInput struct { + FilePath string `json:"filePath"` + OldString string `json:"oldString"` + NewString string `json:"newString"` + ReplaceAll bool `json:"replaceAll,omitempty"` +} + +type globInput struct { + Pattern string `json:"pattern"` + Path *string `json:"path,omitempty"` +} + +type applyPatchInput struct { + PatchText string `json:"patchText"` +} + +// Registry is a collection of tools whose file operations are confined to a +// single workspace. +type Registry struct { + workDir string + instance *project.InstanceContext + rg ripgrepRunner + instructions *instructionRegistry + permission PermissionEvaluator + rules PermissionRules + config *config.Service + formatters *formatterServices + npm *core.Npm + instructionConfig instruction.Config + allowExternal bool + confineWrites bool + hardConfineShell bool + question *question.Service + questionEnabled bool + questionRejects *questionRejectionState + // submitFreeze captures the candidate when the model calls submit. Nil in + // embedders that do not run a submission protocol; the tool is then not + // advertised at all rather than advertised and refused. + submitFreeze SubmitFreezer +} + +type questionRejectionState struct { + mu sync.Mutex + counts map[string]int +} + +type instructionRegistry struct { + mu sync.Mutex + services map[string]*instruction.Service +} + +// PermissionRules resolves the agent and instance rules for one tool call. +type PermissionRules func(context.Context, steploop.ToolCall) permission.Ruleset + +// PermissionEvaluator decides one permission request; mutation requests carry +// the proposed diff in their metadata. +type PermissionEvaluator interface { + Evaluate(permission.AskInput) error +} + +// RegistryOptions supplies the host services used by live tool execution. +type RegistryOptions struct { + Permission PermissionEvaluator + PermissionRules PermissionRules + Instructions []string + Config *config.Service + AllowExternalDirectories bool + // ConfineWrites refuses every file write outside the workspace, whatever + // AllowExternalDirectories says of reads: codeaf keeps only what a program + // changes in the folder it is handed, so a write anywhere else is work that + // no run owns and a change made to somebody else's folder (path.go). + ConfineWrites bool + // HardConfineShellPaths rejects parsed external shell operands instead of + // asking permission, for an embedder that must not prompt. senior-dev leaves it + // disabled and asks. + HardConfineShellPaths bool + // ClientIdentity names the kind of client driving the registry (app, cli, + // desktop) and decides whether the question tool is advertised. The senior-dev + // binary supplies "cli" unless SENIOR_DEV_CLIENT overrides it; embedders that + // omit it are not assumed to have an interactive client. + ClientIdentity string + Question *question.Service + // SubmitFreeze installs the submit tool and receives the candidate at the + // moment the model submits. See submit.go. + SubmitFreeze SubmitFreezer +} + +var bakedPermissionCache sync.Map + +func bakedPermissionRules(_ context.Context, call steploop.ToolCall) permission.Ruleset { + if call.Agent == "" { + return nil + } + if cached, ok := bakedPermissionCache.Load(call.Agent); ok { + return cached.(permission.Ruleset) + } + markdown, ok := baked.GetBakedAgentMarkdown(call.Agent) + if !ok { + return nil + } + rules, err := permission.RulesetFromFrontmatter(markdown) + if err != nil { + return nil + } + bakedPermissionCache.Store(call.Agent, rules) + return rules +} + +// WebSearchFlags are the two feature flags consulted by webSearchEnabled. +type WebSearchFlags struct { + Exa bool + Parallel bool +} + +// FilterInput carries the provider, model and search flags that decide which +// tools are advertised for a turn. +type FilterInput struct { + ProviderID string + ModelID string + Flags WebSearchFlags +} + +// New returns a registry bound to workDir. +func New(workDir string) *Registry { + return NewWithOptions(workDir, RegistryOptions{}) +} + +// NewWithOptions returns a configured registry bound to workDir. +func NewWithOptions(workDir string, options RegistryOptions) *Registry { + absolute, err := filepath.Abs(workDir) + if err != nil { + absolute = workDir + } + service := options.Permission + if service == nil { + service = &permission.Service{} + } + rules := options.PermissionRules + if rules == nil { + rules = bakedPermissionRules + } + cacheDir, _ := os.UserCacheDir() + configService := options.Config + if configService == nil { + configService = config.NewService(config.Loader{Env: config.NewEnv(nil)}) + } + env := config.NewEnv(nil) + clientIdentity := options.ClientIdentity + if clientIdentity == "" { + if configured, ok := env.Get("SENIOR_DEV_CLIENT"); ok { + clientIdentity = configured + } + } + questionService := options.Question + if questionService == nil { + questionService = question.Default + } + return &Registry{ + workDir: filepath.Clean(absolute), + rg: pickRipgrepRunner(), + permission: service, + rules: rules, + config: configService, + allowExternal: options.AllowExternalDirectories, + confineWrites: options.ConfineWrites, + hardConfineShell: options.HardConfineShellPaths, + question: questionService, + questionEnabled: clientIdentity == "app" || clientIdentity == "cli" || clientIdentity == "desktop" || env.Enabled("SENIOR_DEV_ENABLE_QUESTION_TOOL"), + questionRejects: &questionRejectionState{counts: map[string]int{}}, + submitFreeze: options.SubmitFreeze, + formatters: newFormatterServices(), + npm: core.NewNpm(filepath.Join(cacheDir, "senior-dev"), nil), + instructionConfig: instruction.Config{ + Instructions: append([]string(nil), options.Instructions...), + }, + instructions: &instructionRegistry{ + services: map[string]*instruction.Service{}, + }, + } +} + +// Definitions returns the provider declarations for all workspace tools. +func (r *Registry) Definitions() []steploop.ToolDefinition { + definitions := make([]steploop.ToolDefinition, 0, 10) + // The question tool is advertised for interactive client identities (app, + // cli, desktop) or when SENIOR_DEV_ENABLE_QUESTION_TOOL forces it on for an + // embedder. + if r.questionEnabled { + question := definition("question", questionDescription, questionSchema, validateQuestion) + // A question blocks until it is answered or rejected, so the result stream + // is held open for it rather than settled on the usual abort timer. + question.WaitForResult = true + definitions = append(definitions, question) + } + if r.submitFreeze != nil { + definitions = append(definitions, + definition("submit", submitDescription, submitSchema, validateSubmit)) + } + return append(definitions, + definition("bash", "Run a Bash command in the workspace. Output is capped at 30000 bytes and execution at 600000ms.", bashSchema, validateBash), + definition("read", readDescription, readSchema, validateRead), + definition("glob", globDescription, globSchema, validateGlob), + definition("grep", grepDescription, grepSchema, validateGrep), + definition("edit", editDescription, editSchema, validateEdit), + definition("write", writeDescription, writeSchema, validateWrite), + definition("webfetch", webFetchDescription, webFetchSchema, validateWebFetch), + definition("websearch", webSearchDescription(), webSearchSchema, validateWebSearch), + definition("apply_patch", applyPatchDescription, applyPatchSchema, validateApplyPatch), + ) +} + +// IDs returns builtin tool IDs in registry insertion order. +func (r *Registry) IDs() []string { + definitions := r.Definitions() + out := make([]string, 0, len(definitions)) + for _, item := range definitions { + out = append(out, item.Provider.Name) + } + return out +} + +// DefinitionsFor applies the registry's provider and model-family visibility +// rules. +func (r *Registry) DefinitionsFor(input FilterInput) []steploop.ToolDefinition { + return FilterDefinitions(r.Definitions(), input) +} + +// WebSearchEnabled reports whether a search backend is available: the senior-dev +// provider, or an Exa or Parallel flag. +func WebSearchEnabled(providerID string, flags WebSearchFlags) bool { + return providerID == "senior-dev" || flags.Exa || flags.Parallel +} + +// FilterDefinitions narrows the advertised tool list to what the provider and +// model can use: websearch needs a search backend, and GPT-family models get +// apply_patch in place of edit/write. It is separate from Registry so +// plugin/custom definitions can pass through the same seam. +func FilterDefinitions( + definitions []steploop.ToolDefinition, + input FilterInput, +) []steploop.ToolDefinition { + usePatch := strings.Contains(input.ModelID, "gpt-") && + !strings.Contains(input.ModelID, "oss") && + !strings.Contains(input.ModelID, "gpt-4") + out := make([]steploop.ToolDefinition, 0, len(definitions)) + for _, item := range definitions { + id := item.Provider.Name + if id == "websearch" && !WebSearchEnabled(input.ProviderID, input.Flags) { + continue + } + if id == "apply_patch" && !usePatch { + continue + } + if (id == "edit" || id == "write") && usePatch { + continue + } + out = append(out, item) + } + return out +} + +func definition(name, description, schema string, validate func(json.RawMessage) error) steploop.ToolDefinition { + return steploop.ToolDefinition{ + Provider: orclient.Tool{ + Type: "function", + Name: name, + Description: description, + InputSchema: json.RawMessage(schema), + }, + Validate: validate, + } +} + +// Execute dispatches an already-validated call to its named tool. +func (r *Registry) Execute(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + r = r.forContext(ctx) + var result steploop.ToolResult + var err error + switch call.Name { + case "question": + return r.executeQuestion(ctx, call) + case "submit": + result, err = r.executeSubmit(ctx, call) + case "bash": + result, err = r.executeBash(ctx, call) + case "read": + result, err = r.executeRead(ctx, call) + case "glob": + result, err = r.executeGlob(ctx, call) + case "grep": + result, err = r.executeGrep(ctx, call) + case "write": + result, err = r.executeWrite(ctx, call) + case "edit": + result, err = r.executeEdit(ctx, call) + case "apply_patch": + result, err = r.executeApplyPatch(ctx, call) + case "webfetch": + result, err = r.executeWebFetch(ctx, call) + case "websearch": + result, err = r.executeWebSearch(ctx, call) + default: + return steploop.ToolResult{}, fmt.Errorf("unknown tool: %s", call.Name) + } + if err == nil { + r.resetQuestionRejections(call.SessionID) + } + return result, err +} + +func (r *Registry) resetQuestionRejections(sessionID string) { + if r.questionRejects == nil { + return + } + r.questionRejects.mu.Lock() + delete(r.questionRejects.counts, sessionID) + r.questionRejects.mu.Unlock() +} + +func (r *Registry) recordQuestionRejection(sessionID string) int { + if r.questionRejects == nil { + return 1 + } + r.questionRejects.mu.Lock() + defer r.questionRejects.mu.Unlock() + r.questionRejects.counts[sessionID]++ + return r.questionRejects.counts[sessionID] +} + +func (r *Registry) instructionService() *instruction.Service { + worktree := r.workDir + if r.instance != nil && r.instance.Worktree != "" { + worktree = filepath.Clean(r.instance.Worktree) + } + key := r.workDir + "\x00" + worktree + r.instructions.mu.Lock() + defer r.instructions.mu.Unlock() + if service := r.instructions.services[key]; service != nil { + return service + } + home, _ := os.UserHomeDir() + config, _ := os.UserConfigDir() + service := instruction.New(instruction.Options{ + Config: r.instructionConfig, + Global: instruction.Global{Config: filepath.Join(config, "senior-dev"), Home: home}, + Instance: instruction.Instance{ + Directory: r.workDir, + Worktree: worktree, + }, + }) + r.instructions.services[key] = service + return service +} + +func (r *Registry) ask( + ctx context.Context, + call steploop.ToolCall, + name string, + patterns []string, + metadata map[string]any, +) error { + return r.askWithAlways(ctx, call, name, patterns, []string{"*"}, metadata) +} + +func (r *Registry) askWithAlways( + ctx context.Context, + call steploop.ToolCall, + name string, + patterns []string, + always []string, + metadata map[string]any, +) error { + rules := permission.Ruleset(nil) + if r.rules != nil { + rules = r.rules(ctx, call) + } + return r.permission.Evaluate(permission.AskInput{ + Request: permission.Request{ + SessionID: call.SessionID, Permission: name, Patterns: patterns, + Metadata: metadata, Always: always, + }, + Ruleset: rules, + }) +} + +// SystemInstructions returns the root/global instruction blocks used by the +// engine system prompt for the same workspace-bound service as read tools. +func (r *Registry) SystemInstructions(ctx context.Context) []string { + return r.forContext(ctx).instructionService().System(ctx) +} + +// ClearInstructionClaims releases one assistant turn's in-flight nested-path +// claims. Persisted read metadata remains the cross-turn loaded-path memory. +func (r *Registry) ClearInstructionClaims(ctx context.Context, messageID string) { + r.forContext(ctx).instructionService().Clear(messageID) +} + +// forContext resolves the per-leaf cwd at call time. A shallow clone makes a +// single registry safe for concurrent leaf contexts. +func (r *Registry) forContext(ctx context.Context) *Registry { + instance, ok := project.FromContext(ctx) + if !ok || instance.Directory == "" { + return r + } + copy := *r + copy.workDir = filepath.Clean(instance.Directory) + copy.instance = &instance + return © +} + +func validateBash(raw json.RawMessage) error { + var input bashInput + if err := decodeInput(raw, &input, "command"); err != nil { + return err + } + if input.TimeoutMS != nil && (*input.TimeoutMS < 1 || *input.TimeoutMS > 600000) { + return fmt.Errorf("timeout_ms must be between 1 and 600000") + } + return nil +} + +func validateRead(raw json.RawMessage) error { + var input readInput + if err := decodeInput(raw, &input, "filePath"); err != nil { + return err + } + if input.Offset != nil && *input.Offset < 0 { + return fmt.Errorf("offset must be at least 0") + } + if input.Limit != nil && *input.Limit < 0 { + return fmt.Errorf("limit must be at least 0") + } + return nil +} + +func validateWrite(raw json.RawMessage) error { + var input writeInput + return decodeInput(raw, &input, "content", "filePath") +} + +func validateGlob(raw json.RawMessage) error { + var input globInput + return decodeInput(raw, &input, "pattern") +} + +func validateGrep(raw json.RawMessage) error { + var input grepInput + return decodeInput(raw, &input, "pattern") +} + +func validateEdit(raw json.RawMessage) error { + var input editInput + return decodeInput(raw, &input, "filePath", "oldString", "newString") +} + +func validateApplyPatch(raw json.RawMessage) error { + var input applyPatchInput + return decodeInput(raw, &input, "patchText") +} + +func decodeInput(raw json.RawMessage, destination any, required ...string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return fmt.Errorf("input must be a JSON object: %w", err) + } + if fields == nil { + return fmt.Errorf("input must be a JSON object") + } + for _, name := range required { + if _, ok := fields[name]; !ok { + return fmt.Errorf("missing required field %q", name) + } + } + for name, value := range fields { + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("field %q must not be null", name) + } + } + + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + return fmt.Errorf("invalid input: %w", err) + } + if err := ensureJSONEnd(decoder); err != nil { + return err + } + return nil +} + +func ensureJSONEnd(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("invalid input: %w", err) + } + return fmt.Errorf("input must contain one JSON object") +} diff --git a/internal/seniordev/tool/registry_policy_test.go b/internal/seniordev/tool/registry_policy_test.go new file mode 100644 index 0000000000..ab0d33947a --- /dev/null +++ b/internal/seniordev/tool/registry_policy_test.go @@ -0,0 +1,207 @@ +//go:build !windows + +package tool + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/permission" +) + +type permissionEvaluatorFunc func(permission.AskInput) error + +func (fn permissionEvaluatorFunc) Evaluate(input permission.AskInput) error { return fn(input) } + +func TestRegistryPermissionDenyBlocksAndAskAutoApproves(t *testing.T) { + workspace := t.TempDir() + readTarget := filepath.Join(workspace, "secret.txt") + if err := os.WriteFile(readTarget, []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + rules := permission.Ruleset{ + {Permission: "read", Pattern: readTarget, Action: permission.ActionDeny}, + {Permission: "bash", Pattern: "git status", Action: permission.ActionDeny}, + {Permission: "edit", Pattern: "blocked.txt", Action: permission.ActionDeny}, + {Permission: "edit", Pattern: "asked.txt", Action: permission.ActionAsk}, + } + registry := NewWithOptions(workspace, RegistryOptions{ + PermissionRules: func(context.Context, steploop.ToolCall) permission.Ruleset { + return rules + }, + }) + + // A deny rule rejects before any mutation happens. + _, err := execute(t, registry, "write", map[string]any{ + "filePath": filepath.Join(workspace, "blocked.txt"), "content": "blocked", + }) + var denied permission.DeniedError + if !errors.As(err, &denied) { + t.Fatalf("write error = %T %v, want permission.DeniedError", err, err) + } + if !strings.HasPrefix(err.Error(), "The user has specified a rule which prevents you from using this specific tool call.") { + t.Fatalf("denial text = %q", err) + } + if _, statErr := os.Stat(filepath.Join(workspace, "blocked.txt")); !os.IsNotExist(statErr) { + t.Fatalf("denied write changed filesystem: %v", statErr) + } + if _, err := execute(t, registry, "read", map[string]any{"filePath": readTarget}); !errors.As(err, &denied) { + t.Fatalf("configured read error = %T %v, want permission.DeniedError", err, err) + } + if _, err := execute(t, registry, "bash", map[string]any{ + "command": "git status && echo should-not-run", + }); !errors.As(err, &denied) { + t.Fatalf("configured parsed bash error = %T %v, want permission.DeniedError", err, err) + } + + // An unattended literal ask is auto-approved. + if _, err := execute(t, registry, "write", map[string]any{ + "filePath": filepath.Join(workspace, "asked.txt"), "content": "approved", + }); err != nil { + t.Fatalf("ask policy blocked unattended write: %v", err) + } +} + +func TestRegistryMutationPermissionReceivesProposedDiffBeforeWrite(t *testing.T) { + workspace := t.TempDir() + target := filepath.Join(workspace, "proposed.txt") + var request permission.AskInput + registry := NewWithOptions(workspace, RegistryOptions{ + Permission: permissionEvaluatorFunc(func(input permission.AskInput) error { + request = input + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("permission evaluated after mutation: %v", err) + } + return nil + }), + }) + + // Mutation asks include the proposed diff before any I/O. + if _, err := execute(t, registry, "write", map[string]any{ + "filePath": target, "content": "new content", + }); err != nil { + t.Fatal(err) + } + diff, _ := request.Metadata["diff"].(string) + if request.Permission != "edit" || request.Patterns[0] != "proposed.txt" || + !strings.Contains(diff, "+new content") { + t.Fatalf("permission request = %+v", request) + } +} + +func TestEditPermissionDiffNormalizesCRLFContract(t *testing.T) { + // The proposed diff handed to the permission evaluator is CRLF-normalized. + workspace := t.TempDir() + target := filepath.Join(workspace, "windows.txt") + if err := os.WriteFile(target, []byte("old\r\nkeep\r\n"), 0o644); err != nil { + t.Fatal(err) + } + var request permission.AskInput + registry := NewWithOptions(workspace, RegistryOptions{ + Permission: permissionEvaluatorFunc(func(input permission.AskInput) error { + request = input + return nil + }), + }) + if _, err := execute(t, registry, "edit", map[string]any{ + "filePath": target, "oldString": "old", "newString": "new", + }); err != nil { + t.Fatal(err) + } + diff, _ := request.Metadata["diff"].(string) + if strings.Contains(diff, "\r") || !strings.Contains(diff, "-old\n+new\n") { + t.Fatalf("CRLF proposed diff = %q", diff) + } +} + +func TestRegistryExternalDirectoryPermissionFlowContract(t *testing.T) { + // senior-dev resolves external targets, asks for their parent glob, then + // continues through each tool's ordinary permission. + workspace := t.TempDir() + external := t.TempDir() + target := filepath.Join(external, "outside.txt") + if err := os.WriteFile(target, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + requests := []permission.Request{} + registry := NewWithOptions(workspace, RegistryOptions{ + AllowExternalDirectories: true, + Permission: permissionEvaluatorFunc(func(input permission.AskInput) error { + requests = append(requests, input.Request) + return nil + }), + }) + assertPair := func(t *testing.T, ordinary string, run func() error) { + t.Helper() + requests = nil + if err := run(); err != nil { + t.Fatal(err) + } + if len(requests) < 2 || requests[0].Permission != "external_directory" || + requests[len(requests)-1].Permission != ordinary || + requests[0].Patterns[0] != filepath.ToSlash(filepath.Join(external, "*")) { + t.Fatalf("%s permission flow = %#v", ordinary, requests) + } + } + assertPair(t, "read", func() error { + _, err := execute(t, registry, "read", map[string]any{"filePath": target}) + return err + }) + assertPair(t, "edit", func() error { + _, err := execute(t, registry, "write", map[string]any{"filePath": target, "content": "write\n"}) + return err + }) + assertPair(t, "edit", func() error { + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": target, "oldString": "write", "newString": "edited", + }) + return err + }) + assertPair(t, "edit", func() error { + _, err := execute(t, registry, "apply_patch", map[string]any{ + "patchText": "*** Begin Patch\n*** Update File: " + target + "\n@@\n-edited\n+patched\n*** End Patch", + }) + return err + }) + assertPair(t, "bash", func() error { + _, err := execute(t, registry, "bash", map[string]any{ + "command": "pwd", "workdir": external, + }) + return err + }) + + confined := New(workspace) + if _, err := execute(t, confined, "read", map[string]any{"filePath": target}); err == nil || !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("default confinement error = %v", err) + } +} + +func TestRegistryHardShellConfinementContract(t *testing.T) { + // HardConfineShellPaths rejects parsed shell paths outside the workspace + // before the permission evaluator is consulted. + workspace := t.TempDir() + inside := filepath.Join(workspace, "inside.txt") + if err := os.WriteFile(inside, []byte("inside"), 0o644); err != nil { + t.Fatal(err) + } + external := t.TempDir() + copyTarget := filepath.Join(external, "copied.txt") + registry := NewWithOptions(workspace, RegistryOptions{HardConfineShellPaths: true}) + for _, command := range []string{ + "cat /etc/passwd", + "cp " + inside + " " + copyTarget, + } { + if _, err := execute(t, registry, "bash", map[string]any{"command": command}); err == nil || + !strings.Contains(err.Error(), "path escapes workspace") { + t.Fatalf("hard-confined command %q error = %v", command, err) + } + } + if _, err := os.Stat(copyTarget); !os.IsNotExist(err) { + t.Fatalf("hard-confined cp wrote outside workspace: %v", err) + } +} diff --git a/internal/seniordev/tool/registry_worktree.go b/internal/seniordev/tool/registry_worktree.go new file mode 100644 index 0000000000..19a0f196f1 --- /dev/null +++ b/internal/seniordev/tool/registry_worktree.go @@ -0,0 +1,16 @@ +//go:build !windows + +package tool + +import "path/filepath" + +// worktree is the directory tool paths are reported relative to. It is the +// registry's workspace unless a project instance in context names a different +// worktree, which is the case only when an embedder runs the registry against +// a checkout other than the one it was constructed for. +func (r *Registry) worktree() string { + if r.instance != nil && r.instance.Worktree != "" { + return filepath.Clean(r.instance.Worktree) + } + return r.workDir +} diff --git a/internal/seniordev/tool/ripgrep.go b/internal/seniordev/tool/ripgrep.go new file mode 100644 index 0000000000..a32fd105b3 --- /dev/null +++ b/internal/seniordev/tool/ripgrep.go @@ -0,0 +1,92 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "strings" +) + +type ripgrepResult struct { + stdout []byte + stderr []byte + code int +} + +type ripgrepRunner interface { + Run(ctx context.Context, cwd string, args []string) (ripgrepResult, error) +} + +type execRipgrepRunner struct{} + +func (execRipgrepRunner) Run(ctx context.Context, cwd string, args []string) (ripgrepResult, error) { + command := exec.CommandContext(ctx, "rg", args...) + command.Dir = cwd + command.Env = withoutEnv(os.Environ(), "RIPGREP_CONFIG_PATH") + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + if err == nil { + return ripgrepResult{stdout: stdout.Bytes(), stderr: stderr.Bytes(), code: 0}, nil + } + if ctx.Err() != nil { + return ripgrepResult{}, ctx.Err() + } + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + return ripgrepResult{}, err + } + return ripgrepResult{ + stdout: stdout.Bytes(), + stderr: stderr.Bytes(), + code: exitError.ExitCode(), + }, nil +} + +func withoutEnv(environment []string, name string) []string { + prefix := name + "=" + out := make([]string, 0, len(environment)) + for _, entry := range environment { + if strings.HasPrefix(entry, prefix) { + continue + } + out = append(out, entry) + } + return out +} + +func ripgrepError(result ripgrepResult) error { + message := strings.TrimSpace(string(result.stderr)) + if message == "" { + message = "ripgrep failed with code " + itoa(result.code) + } + return errors.New(message) +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + negative := value < 0 + if negative { + value = -value + } + var digits [20]byte + index := len(digits) + for value > 0 { + index-- + digits[index] = byte('0' + value%10) + value /= 10 + } + if negative { + index-- + digits[index] = '-' + } + return string(digits[index:]) +} diff --git a/internal/seniordev/tool/ripgrep_fallback.go b/internal/seniordev/tool/ripgrep_fallback.go new file mode 100644 index 0000000000..c6515402ed --- /dev/null +++ b/internal/seniordev/tool/ripgrep_fallback.go @@ -0,0 +1,485 @@ +//go:build !windows + +// Ripgrep is the one external binary the search tools depend on, and it is +// never fetched at run time: a sealed or offline deployment could not download +// it anyway. +// +// A missing rg is not a cosmetic loss. grep and glob are how the agent reads a +// codebase, so without them a run does not degrade gracefully — it fails tool +// call after tool call and never gets to the work. This file keeps the +// existing ripgrepRunner seam and answers in process instead, so the engine is +// self-contained. rg stays authoritative whenever it is installed: the +// fallback is selected only when exec.LookPath("rg") misses, which means an +// existing deployment's behaviour is untouched. +// +// Fidelity notes (the deliberate gaps, so nobody has to rediscover them): +// - Inside a git work tree the file list comes from `git ls-files --cached +// --others --exclude-standard`, which reproduces rg's default .gitignore +// behaviour exactly. Outside one, every regular file is walked: rg would +// also honour .ignore/.rgignore files there, and this does not. +// - Patterns are compiled with Go's regexp (RE2), the same family as rg's +// default engine, so ordinary patterns behave the same. Anything relying on +// Rust-regex-only syntax will not compile here. +// - Results are emitted in lexicographic order. rg emits in traversal order; +// both are arbitrary as far as the callers are concerned, and a stable +// order makes the 100-result cap deterministic instead of filesystem +// dependent. +package tool + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io/fs" + "log" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" +) + +// binarySniffBytes mirrors rg's habit of skipping binary files: a NUL byte in +// the first chunk is the signal. +const binarySniffBytes = 8192 + +var warnMissingRipgrep sync.Once + +// pickRipgrepRunner returns the real rg whenever it is on PATH and the +// in-process searcher otherwise. Resolution happens per Registry rather than +// once per process so a deployment that installs rg mid-flight picks it up. +func pickRipgrepRunner() ripgrepRunner { + if _, err := exec.LookPath("rg"); err == nil { + return execRipgrepRunner{} + } + warnMissingRipgrep.Do(func() { + log.Printf("search: ripgrep (rg) is not on PATH — using the built-in searcher. " + + "Install ripgrep for faster search and exact ignore-file semantics.") + }) + return builtinSearchRunner{} +} + +// builtinSearchRunner answers the two invocation shapes the tools construct: +// `--files` (glob.go) and `--json` (grep.go). +type builtinSearchRunner struct{} + +type searchRequest struct { + listFiles bool + includes []string + excludes []string + pattern string + roots []string +} + +func (builtinSearchRunner) Run(ctx context.Context, cwd string, args []string) (ripgrepResult, error) { + request := parseSearchArgs(args) + filter, err := newGlobFilter(request.includes, request.excludes) + if err != nil { + return ripgrepResult{}, err + } + files, degraded := collectSearchFiles(ctx, cwd, request.roots, filter) + if ctx.Err() != nil { + return ripgrepResult{}, ctx.Err() + } + if request.listFiles { + return listFilesOutput(files), nil + } + return grepOutput(ctx, cwd, request.pattern, files, degraded) +} + +// parseSearchArgs reads the flags the callers actually pass. Unmodelled flags +// are ignored rather than rejected: every flag in play (--no-config, --hidden, +// --no-messages) only widens or quiets the search, so ignoring one can never +// turn a narrow search into a broad one. +func parseSearchArgs(args []string) searchRequest { + var request searchRequest + positional := []string{} + index := 0 + for ; index < len(args); index++ { + argument := args[index] + if argument == "--" { + index++ + break + } + switch { + case argument == "--files": + request.listFiles = true + case strings.HasPrefix(argument, "--glob="): + glob := strings.TrimPrefix(argument, "--glob=") + if strings.HasPrefix(glob, "!") { + request.excludes = append(request.excludes, strings.TrimPrefix(glob, "!")) + continue + } + request.includes = append(request.includes, glob) + case strings.HasPrefix(argument, "-"): + default: + positional = append(positional, argument) + } + } + positional = append(positional, args[index:]...) + if !request.listFiles && len(positional) > 0 { + request.pattern = positional[0] + positional = positional[1:] + } + request.roots = positional + if len(request.roots) == 0 { + request.roots = []string{"."} + } + return request +} + +// collectSearchFiles resolves every root to a de-duplicated, filtered, sorted +// list of paths relative to cwd. degraded reports whether anything was skipped +// because it could not be read — the caller turns that into rg's exit code 2. +func collectSearchFiles( + ctx context.Context, + cwd string, + roots []string, + filter *globFilter, +) (files []string, degraded bool) { + seen := map[string]bool{} + add := func(relative string) { + relative = path.Clean(filepath.ToSlash(relative)) + if relative == "." || relative == "" || relative == "/" || seen[relative] { + return + } + // .git is skipped unconditionally, not just via the caller's + // `--glob=!.git/*`: leaking object files into a code search is never + // what the caller wanted, whatever globs they passed. + if hasGitSegment(relative) || !filter.match(relative) { + return + } + seen[relative] = true + files = append(files, relative) + } + + for _, root := range roots { + if ctx.Err() != nil { + break + } + cleaned := filepath.Clean(root) + absolute := cleaned + if !filepath.IsAbs(absolute) { + absolute = filepath.Join(cwd, cleaned) + } + info, err := os.Stat(absolute) + if err != nil { + degraded = true + continue + } + if !info.IsDir() { + add(cleaned) + continue + } + listed, ok := gitTrackedFiles(ctx, absolute) + if !ok { + var walkFailed bool + listed, walkFailed = walkRegularFiles(ctx, absolute) + degraded = degraded || walkFailed + } + for _, relative := range listed { + joined := relative + if cleaned != "." { + joined = path.Join(filepath.ToSlash(cleaned), relative) + } + add(joined) + } + } + sort.Strings(files) + return files, degraded +} + +// gitTrackedFiles asks git for the working-tree file list, which is what makes +// the fallback honour .gitignore for free. ok is false whenever dir is not a +// work tree (or git is unavailable), leaving the caller to walk instead. +func gitTrackedFiles(ctx context.Context, dir string) (files []string, ok bool) { + command := exec.CommandContext( + ctx, "git", "-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard", + ) + var stdout bytes.Buffer + command.Stdout = &stdout + command.Stderr = nil + if err := command.Run(); err != nil { + return nil, false + } + for _, entry := range strings.Split(stdout.String(), "\x00") { + if entry == "" { + continue + } + // `--cached` also reports files deleted from the working tree, and it + // reports symlinks that rg would not follow. Lstat screens out both: + // only a regular file that is actually present is searchable. + if info, err := os.Lstat(filepath.Join(dir, filepath.FromSlash(entry))); err != nil || + !info.Mode().IsRegular() { + continue + } + files = append(files, entry) + } + return files, true +} + +// walkRegularFiles is the non-git path. Symlinks are left out to match rg, +// which does not follow them by default, and the walk aborts on cancellation +// so a huge tree cannot outlive the request that asked for it. +func walkRegularFiles(ctx context.Context, dir string) (files []string, failed bool) { + _ = filepath.WalkDir(dir, func(current string, entry fs.DirEntry, err error) error { + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + failed = true + if entry != nil && entry.IsDir() { + return fs.SkipDir + } + return nil + } + if entry.IsDir() { + if entry.Name() == ".git" { + return fs.SkipDir + } + return nil + } + if !entry.Type().IsRegular() { + return nil + } + relative, relErr := filepath.Rel(dir, current) + if relErr != nil { + failed = true + return nil + } + files = append(files, filepath.ToSlash(relative)) + return nil + }) + return files, failed +} + +func hasGitSegment(relative string) bool { + for _, segment := range strings.Split(relative, "/") { + if segment == ".git" { + return true + } + } + return false +} + +// listFilesOutput reproduces `rg --files`: one path per line, exit 1 when +// nothing matched. It never reports exit 2 — glob.go treats anything other +// than 0 or 1 as a hard error. +func listFilesOutput(files []string) ripgrepResult { + if len(files) == 0 { + return ripgrepResult{code: 1} + } + var stdout bytes.Buffer + for _, file := range files { + stdout.WriteString(file) + stdout.WriteByte('\n') + } + return ripgrepResult{stdout: stdout.Bytes(), code: 0} +} + +// grepOutput reproduces `rg --json`, emitting only the "match" events grep.go +// consumes. Exit codes follow rg: 0 matched, 1 matched nothing, 2 finished but +// skipped something unreadable. +func grepOutput( + ctx context.Context, + cwd string, + pattern string, + files []string, + degraded bool, +) (ripgrepResult, error) { + expression, err := regexp.Compile(pattern) + if err != nil { + return ripgrepResult{}, err + } + var stdout bytes.Buffer + encoder := json.NewEncoder(&stdout) + matched := false + for _, relative := range files { + if ctx.Err() != nil { + return ripgrepResult{}, ctx.Err() + } + fileMatched, readable := grepFile( + filepath.Join(cwd, filepath.FromSlash(relative)), relative, expression, encoder, + ) + if !readable { + degraded = true + continue + } + matched = matched || fileMatched + } + code := 1 + switch { + case degraded: + code = 2 + case matched: + code = 0 + } + return ripgrepResult{stdout: stdout.Bytes(), code: code}, nil +} + +func grepFile( + absolute string, + relative string, + expression *regexp.Regexp, + encoder *json.Encoder, +) (matched bool, readable bool) { + file, err := os.Open(absolute) + if err != nil { + return false, false + } + defer func() { _ = file.Close() }() + + reader := bufio.NewReaderSize(file, 64*1024) + if head, _ := reader.Peek(binarySniffBytes); bytes.IndexByte(head, 0) >= 0 { + // Binary: readable, just not searched — same as rg, and not a reason + // to report the run as degraded. + return false, true + } + + for number := 1; ; number++ { + line, readErr := reader.ReadString('\n') + if line == "" && readErr != nil { + return matched, true + } + if expression.MatchString(strings.TrimSuffix(line, "\n")) { + matched = true + event := ripgrepJSONLine{Type: "match"} + event.Data.Path.Text = relative + event.Data.Lines.Text = line + event.Data.LineNumber = number + if encodeErr := encoder.Encode(event); encodeErr != nil { + return matched, true + } + } + if readErr != nil { + return matched, true + } + } +} + +// globFilter applies rg's --glob rules: an exclude wins outright, and when any +// include is present a path must match at least one of them. +type globFilter struct { + includes []compiledGlob + excludes []compiledGlob +} + +type compiledGlob struct { + expression *regexp.Regexp + baseOnly bool +} + +func newGlobFilter(includes, excludes []string) (*globFilter, error) { + filter := &globFilter{} + for _, pattern := range includes { + compiled, err := compileGlob(pattern) + if err != nil { + return nil, err + } + filter.includes = append(filter.includes, compiled) + } + for _, pattern := range excludes { + compiled, err := compileGlob(pattern) + if err != nil { + return nil, err + } + filter.excludes = append(filter.excludes, compiled) + } + return filter, nil +} + +func (f *globFilter) match(relative string) bool { + for _, exclude := range f.excludes { + if exclude.matches(relative) { + return false + } + } + if len(f.includes) == 0 { + return true + } + for _, include := range f.includes { + if include.matches(relative) { + return true + } + } + return false +} + +func (g compiledGlob) matches(relative string) bool { + if g.baseOnly { + return g.expression.MatchString(path.Base(relative)) + } + if g.expression.MatchString(relative) { + return true + } + // A path-bearing pattern also covers everything beneath a directory it + // matches, which is what makes `!.git/*` exclude .git/refs/heads/main and + // not merely .git/config. + for parent := path.Dir(relative); parent != "." && parent != "/" && parent != ""; parent = path.Dir(parent) { + if g.expression.MatchString(parent) { + return true + } + } + return false +} + +// compileGlob translates a gitignore-style glob into a regexp. A pattern with +// no separator matches the basename at any depth ("*.go"); one with a +// separator is anchored at the search root (".git/*"). +func compileGlob(pattern string) (compiledGlob, error) { + trimmed := strings.TrimSuffix(pattern, "/") + expression, err := globToRegexp(trimmed) + if err != nil { + return compiledGlob{}, err + } + return compiledGlob{expression: expression, baseOnly: !strings.Contains(trimmed, "/")}, nil +} + +func globToRegexp(pattern string) (*regexp.Regexp, error) { + runes := []rune(pattern) + var builder strings.Builder + builder.WriteString(`\A`) + for index := 0; index < len(runes); index++ { + switch character := runes[index]; character { + case '*': + if index+1 < len(runes) && runes[index+1] == '*' { + index++ + if index+1 < len(runes) && runes[index+1] == '/' { + index++ + builder.WriteString(`(?:[^/]*/)*`) + continue + } + builder.WriteString(`.*`) + continue + } + builder.WriteString(`[^/]*`) + case '?': + builder.WriteString(`[^/]`) + case '[': + closing := indexRune(runes[index:], ']') + if closing < 0 { + builder.WriteString(regexp.QuoteMeta("[")) + continue + } + builder.WriteString(string(runes[index : index+closing+1])) + index += closing + default: + builder.WriteString(regexp.QuoteMeta(string(character))) + } + } + builder.WriteString(`\z`) + return regexp.Compile(builder.String()) +} + +func indexRune(runes []rune, target rune) int { + for index, current := range runes { + if current == target { + return index + } + } + return -1 +} diff --git a/internal/seniordev/tool/ripgrep_fallback_equivalence_test.go b/internal/seniordev/tool/ripgrep_fallback_equivalence_test.go new file mode 100644 index 0000000000..571d0546be --- /dev/null +++ b/internal/seniordev/tool/ripgrep_fallback_equivalence_test.go @@ -0,0 +1,176 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" +) + +// The fallback only earns its keep if it answers the way ripgrep does. These +// tests run the same invocation through both runners against one real tree and +// compare the answers, so a divergence shows up here rather than as the agent +// quietly reading the wrong files. They skip when rg is absent — which is the +// very situation the fallback exists for. + +func fallbackFixture(t *testing.T) string { + t.Helper() + workDir := t.TempDir() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + } { + command := exec.Command("git", append([]string{"-C", workDir}, args...)...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, output) + } + } + for _, directory := range []string{"src", filepath.Join("src", "inner"), "build"} { + if err := os.MkdirAll(filepath.Join(workDir, directory), 0o755); err != nil { + t.Fatal(err) + } + } + files := map[string]string{ + ".gitignore": "build/\n*.log\n", + "top.go": "package top\n// needle at top\n", + ".hidden.go": "package hidden\n// needle hidden\n", + "notes.txt": "needle in text\n", + "debug.log": "needle in an ignored log\n", + filepath.Join("src", "a.go"): "package a\nfunc A() {} // needle\n", + filepath.Join("src", "inner", "b.go"): "package b\n// needle deeper\n", + filepath.Join("build", "generated.go"): "package generated\n// needle generated\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(workDir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + // rg does not follow symlinks by default. Including one keeps the comparison + // honest about that; a platform without symlink support just + // exercises one dimension less. + if err := os.Symlink( + filepath.Join(workDir, "top.go"), filepath.Join(workDir, "link.go"), + ); err != nil { + t.Logf("symlink unsupported on this platform: %v", err) + } + command := exec.Command("git", "-C", workDir, "add", "-A") + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git add: %v (%s)", err, output) + } + return workDir +} + +func requireRipgrep(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("rg"); err != nil { + t.Skip("rg not on PATH") + } +} + +// globPaths runs a --files invocation through a runner and returns the sorted +// path set, which is what glob.go consumes. +func globPaths(t *testing.T, runner ripgrepRunner, workDir string, args []string) []string { + t.Helper() + result, err := runner.Run(context.Background(), workDir, args) + if err != nil { + t.Fatalf("%T: %v", runner, err) + } + if result.code != 0 && result.code != 1 { + t.Fatalf("%T: unexpected code %d (%s)", runner, result.code, result.stderr) + } + paths := []string{} + for _, line := range strings.Split(string(result.stdout), "\n") { + if line == "" { + continue + } + paths = append(paths, cleanRipgrepPath(line)) + } + sort.Strings(paths) + return paths +} + +// grepHits runs a --json invocation and returns sorted "path:line:text" keys. +func grepHits(t *testing.T, runner ripgrepRunner, workDir string, args []string) []string { + t.Helper() + result, err := runner.Run(context.Background(), workDir, args) + if err != nil { + t.Fatalf("%T: %v", runner, err) + } + if result.code != 0 && result.code != 1 && result.code != 2 { + t.Fatalf("%T: unexpected code %d (%s)", runner, result.code, result.stderr) + } + hits := []string{} + for _, line := range strings.Split(string(result.stdout), "\n") { + if line == "" { + continue + } + var event ripgrepJSONLine + if err := json.Unmarshal([]byte(line), &event); err != nil { + t.Fatalf("%T: invalid event %q", runner, line) + } + if event.Type != "match" { + continue + } + hits = append(hits, cleanRipgrepPath(event.Data.Path.Text)+ + ":"+itoa(event.Data.LineNumber)+ + ":"+strings.TrimSuffix(event.Data.Lines.Text, "\n")) + } + sort.Strings(hits) + return hits +} + +func TestFallbackGlobMatchesRipgrep(t *testing.T) { + requireRipgrep(t) + workDir := fallbackFixture(t) + + for _, pattern := range []string{"*.go", "**/*.go", "src/*.go", "*.txt", "*.rs"} { + t.Run(pattern, func(t *testing.T) { + args := []string{ + "--no-config", "--files", "--glob=!.git/*", "--hidden", "--glob=" + pattern, ".", + } + want := globPaths(t, execRipgrepRunner{}, workDir, args) + got := globPaths(t, builtinSearchRunner{}, workDir, args) + if strings.Join(want, "|") != strings.Join(got, "|") { + t.Fatalf("glob %q\n rg: %v\nfallback: %v", pattern, want, got) + } + }) + } +} + +func TestFallbackGrepMatchesRipgrep(t *testing.T) { + requireRipgrep(t) + workDir := fallbackFixture(t) + + cases := []struct { + name string + pattern string + include string + }{ + {name: "literal", pattern: "needle"}, + {name: "anchored", pattern: "^package"}, + {name: "charclass", pattern: "func [A-Z]"}, + {name: "include-go", pattern: "needle", include: "*.go"}, + {name: "no-match", pattern: "absolutely-not-present"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + args := []string{"--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"} + if testCase.include != "" { + args = append(args, "--glob="+testCase.include) + } + args = append(args, "--", testCase.pattern, ".") + want := grepHits(t, execRipgrepRunner{}, workDir, args) + got := grepHits(t, builtinSearchRunner{}, workDir, args) + if strings.Join(want, "|") != strings.Join(got, "|") { + t.Fatalf("grep %q\n rg: %v\nfallback: %v", testCase.pattern, want, got) + } + }) + } +} diff --git a/internal/seniordev/tool/ripgrep_fallback_test.go b/internal/seniordev/tool/ripgrep_fallback_test.go new file mode 100644 index 0000000000..cbf9ea824c --- /dev/null +++ b/internal/seniordev/tool/ripgrep_fallback_test.go @@ -0,0 +1,307 @@ +//go:build !windows + +package tool + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// fallbackRegistry builds a Registry pinned to the in-process searcher, so +// these tests exercise the no-ripgrep path regardless of what is on PATH. +func fallbackRegistry(t *testing.T, workDir string) *Registry { + t.Helper() + registry := New(workDir) + registry.rg = builtinSearchRunner{} + return registry +} + +func initTestRepo(t *testing.T, dir string) { + t.Helper() + for _, args := range [][]string{ + {"init", "-q"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "test"}, + } { + command := exec.Command("git", append([]string{"-C", dir}, args...)...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v (%s)", args, err, output) + } + } +} + +// Contract 1: rg stays authoritative when it is installed, and only a genuine +// absence selects the fallback. +func TestPickRipgrepRunnerPrefersRealRipgrep(t *testing.T) { + stub := t.TempDir() + name := "rg" + if os.PathListSeparator == ';' { + name = "rg.exe" + } + if err := os.WriteFile(filepath.Join(stub, name), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", stub) + if _, ok := pickRipgrepRunner().(execRipgrepRunner); !ok { + t.Fatalf("rg on PATH must select the real runner, got %T", pickRipgrepRunner()) + } + + t.Setenv("PATH", t.TempDir()) + if _, ok := pickRipgrepRunner().(builtinSearchRunner); !ok { + t.Fatalf("rg absent must select the fallback, got %T", pickRipgrepRunner()) + } +} + +// Contract 2: glob still finds files, hidden ones included, non-matching +// extensions excluded. +func TestFallbackGlobFindsHiddenAndFiltersByPattern(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "visible.go", "package visible") + writeTestFile(t, workDir, ".hidden.go", "package hidden") + writeTestFile(t, workDir, "ignored.txt", "text") + + result, err := execute(t, fallbackRegistry(t, workDir), "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for _, name := range []string{"visible.go", ".hidden.go"} { + if !strings.Contains(result.Output, filepath.Join(workDir, name)) { + t.Fatalf("%s missing from %q", name, result.Output) + } + } + if strings.Contains(result.Output, "ignored.txt") { + t.Fatalf("non-matching file leaked into %q", result.Output) + } +} + +// Contract 2 (cont.): a pattern with a separator is anchored at the root and +// matches nested paths rather than bare basenames. +func TestFallbackGlobNestedPattern(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "src", "inner"), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, filepath.Join("src", "a.go"), "package a") + writeTestFile(t, workDir, filepath.Join("src", "inner", "b.go"), "package b") + writeTestFile(t, workDir, "top.go", "package top") + + result, err := execute(t, fallbackRegistry(t, workDir), "glob", map[string]any{"pattern": "src/**/*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + for _, name := range []string{filepath.Join("src", "a.go"), filepath.Join("src", "inner", "b.go")} { + if !strings.Contains(result.Output, filepath.Join(workDir, name)) { + t.Fatalf("%s missing from %q", name, result.Output) + } + } + if strings.Contains(result.Output, filepath.Join(workDir, "top.go")) { + t.Fatalf("unanchored match leaked into %q", result.Output) + } +} + +// Contract 3: grep reports path, 1-based line number and the line text. +func TestFallbackGrepReportsPathLineAndText(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.go", "first\nneedle here\nthird\n") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "Found 1 matches") { + t.Fatalf("missing count in %q", result.Output) + } + if !strings.Contains(result.Output, filepath.Join(workDir, "one.go")+":") { + t.Fatalf("missing path in %q", result.Output) + } + if !strings.Contains(result.Output, " Line 2: needle here") { + t.Fatalf("missing line 2 in %q", result.Output) + } +} + +// Contract 4: .git contents are never searched, whatever the pattern. +func TestFallbackSkipsGitDirectory(t *testing.T) { + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, ".git", "refs", "heads"), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, filepath.Join(".git", "config"), "needle") + writeTestFile(t, workDir, filepath.Join(".git", "refs", "heads", "main"), "needle") + writeTestFile(t, workDir, "kept.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.Contains(result.Output, ".git") { + t.Fatalf(".git leaked into %q", result.Output) + } + if !strings.Contains(result.Output, "kept.txt") { + t.Fatalf("kept.txt missing from %q", result.Output) + } +} + +// Contract 5: inside a work tree .gitignore is honoured, and untracked files +// that are not ignored are still searched. +func TestFallbackHonoursGitignoreButKeepsUntracked(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + workDir := t.TempDir() + initTestRepo(t, workDir) + writeTestFile(t, workDir, ".gitignore", "ignored.go\n") + writeTestFile(t, workDir, "ignored.go", "needle") + writeTestFile(t, workDir, "untracked.go", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.Contains(result.Output, "ignored.go") { + t.Fatalf("gitignored file searched: %q", result.Output) + } + if !strings.Contains(result.Output, "untracked.go") { + t.Fatalf("untracked file missing from %q", result.Output) + } +} + +// Contract 6: the include filter narrows grep to matching files. +func TestFallbackGrepHonoursInclude(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "code.go", "needle") + writeTestFile(t, workDir, "notes.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{ + "pattern": "needle", "include": "*.go", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "code.go") || strings.Contains(result.Output, "notes.txt") { + t.Fatalf("include not applied: %q", result.Output) + } +} + +// Contract 6 (cont.): a path pointing at one file searches only that file. +func TestFallbackGrepSingleFilePath(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.txt", "needle") + writeTestFile(t, workDir, "two.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{ + "pattern": "needle", "path": "one.txt", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "one.txt") || strings.Contains(result.Output, "two.txt") { + t.Fatalf("path not honoured: %q", result.Output) + } +} + +// Contract 7: an empty result is "No files found", not an error, for both tools. +func TestFallbackEmptyResults(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.go", "content") + registry := fallbackRegistry(t, workDir) + + grepResult, err := execute(t, registry, "grep", map[string]any{"pattern": "absent"}) + if err != nil { + t.Fatalf("grep: %v", err) + } + if grepResult.Output != "No files found" { + t.Fatalf("grep output = %q", grepResult.Output) + } + globResult, err := execute(t, registry, "glob", map[string]any{"pattern": "*.rs"}) + if err != nil { + t.Fatalf("glob: %v", err) + } + if globResult.Output != "No files found" { + t.Fatalf("glob output = %q", globResult.Output) + } +} + +// Contract 8: binary files are skipped rather than dumped into the output. +func TestFallbackSkipsBinaryFiles(t *testing.T) { + workDir := t.TempDir() + if err := os.WriteFile( + filepath.Join(workDir, "blob.bin"), []byte("needle\x00needle"), 0o644, + ); err != nil { + t.Fatal(err) + } + writeTestFile(t, workDir, "text.txt", "needle") + + result, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{"pattern": "needle"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if strings.Contains(result.Output, "blob.bin") { + t.Fatalf("binary file searched: %q", result.Output) + } + if !strings.Contains(result.Output, "text.txt") { + t.Fatalf("text file missing from %q", result.Output) + } +} + +// Contract 9: an invalid pattern surfaces as an error, not a panic. +func TestFallbackInvalidPatternErrors(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "one.go", "content") + + if _, err := execute(t, fallbackRegistry(t, workDir), "grep", map[string]any{ + "pattern": "([unclosed", + }); err == nil { + t.Fatal("invalid pattern must error") + } +} + +// Contract 10: the fallback plugs in below the shared caps, so the 100-result +// limit and its notice still apply. +func TestFallbackGlobTruncatesAtLimit(t *testing.T) { + workDir := t.TempDir() + for index := 0; index < globResultLimit+10; index++ { + writeTestFile(t, workDir, "file"+itoa(index)+".go", "package p") + } + + result, err := execute(t, fallbackRegistry(t, workDir), "glob", map[string]any{"pattern": "*.go"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "Results are truncated") { + t.Fatalf("missing truncation notice in %q", result.Output) + } + if string(result.Metadata) != `{"count":100,"truncated":true}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +// The exit-code contract the two callers depend on: glob rejects anything but +// 0/1, so an unreadable root must never surface as rg's exit 2 there. +func TestFallbackMissingRootExitCodes(t *testing.T) { + workDir := t.TempDir() + runner := builtinSearchRunner{} + + globResult, err := runner.Run(t.Context(), workDir, []string{ + "--no-config", "--files", "--glob=!.git/*", "--hidden", "--glob=*.go", "absent", + }) + if err != nil { + t.Fatalf("glob run: %v", err) + } + if globResult.code != 1 { + t.Fatalf("glob code = %d, want 1", globResult.code) + } + + grepResult, err := runner.Run(t.Context(), workDir, []string{ + "--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages", "--", "needle", "absent", + }) + if err != nil { + t.Fatalf("grep run: %v", err) + } + if grepResult.code != 2 { + t.Fatalf("grep code = %d, want 2", grepResult.code) + } +} diff --git a/internal/seniordev/tool/settings.go b/internal/seniordev/tool/settings.go new file mode 100644 index 0000000000..8e5b23b9b6 --- /dev/null +++ b/internal/seniordev/tool/settings.go @@ -0,0 +1,148 @@ +//go:build !windows + +package tool + +import ( + "context" + "os" + "os/exec" + "sort" + "strings" + "sync" + + "github.com/Agent-Field/codeaf/internal/seniordev/config" + formatpkg "github.com/Agent-Field/codeaf/internal/seniordev/format" +) + +type formatterServices struct { + mu sync.Mutex + services map[string]*formatpkg.Service +} + +func newFormatterServices() *formatterServices { + return &formatterServices{services: map[string]*formatpkg.Service{}} +} + +func (r *Registry) settings() (config.Info, error) { + worktree := r.workDir + if r.instance != nil && r.instance.Worktree != "" { + worktree = r.instance.Worktree + } + return r.config.Get(r.workDir, worktree) +} + +func (r *Registry) formatterService() (*formatpkg.Service, error) { + worktree := r.workDir + if r.instance != nil && r.instance.Worktree != "" { + worktree = r.instance.Worktree + } + key := r.workDir + "\x00" + worktree + r.formatters.mu.Lock() + defer r.formatters.mu.Unlock() + if service := r.formatters.services[key]; service != nil { + return service, nil + } + settings, err := r.settings() + if err != nil { + return nil, err + } + configuration := formatterConfiguration(settings["formatter"]) + service := formatpkg.NewService( + formatpkg.Context{Directory: r.workDir, Worktree: worktree}, + configuration, + formatpkg.Dependencies{ + Which: func(command string) (string, bool) { + match, err := exec.LookPath(command) + return match, err == nil + }, + NpmWhich: func(ctx context.Context, name string) (string, bool) { + return r.npm.Which(ctx, name) + }, + ExperimentalOxfmt: config.ParseBoolean( + config.Truthy, + environmentValue("SENIOR_DEV_EXPERIMENTAL_OXFMT"), + ), + }, + nil, + ) + r.formatters.services[key] = service + return service, nil +} + +func environmentValue(name string) *string { + value, ok := os.LookupEnv(name) + if !ok { + return nil + } + return &value +} + +func formatterConfiguration(value any) formatpkg.Configuration { + configuration := formatpkg.Configuration{} + switch value := value.(type) { + case bool: + configuration.Enabled = value + case map[string]any: + configuration.Enabled = true + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + entry, _ := value[key].(map[string]any) + override := formatpkg.FormatterOverride{Key: key} + override.Disabled, _ = entry["disabled"].(bool) + override.Extensions = stringSliceSetting(entry["extensions"]) + override.Command = stringSliceSetting(entry["command"]) + if environment, ok := entry["environment"].(map[string]any); ok { + override.Environment = make(map[string]string, len(environment)) + for name, raw := range environment { + if item, ok := raw.(string); ok { + override.Environment[name] = item + } + } + } + configuration.Overrides = append(configuration.Overrides, override) + } + } + return configuration +} + +func stringSliceSetting(value any) *[]string { + raw, ok := value.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, value := range raw { + if item, ok := value.(string); ok { + out = append(out, item) + } + } + return &out +} + +func formatMutationFile(ctx context.Context, service *formatpkg.Service, path string, bom bool) (string, error) { + formatted, err := service.File(ctx, path) + if err != nil { + return "", err + } + if !formatted { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + _, content := splitBOM(strings.ToValidUTF8(string(data), "\uFFFD")) + return content, nil + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + _, content := splitBOM(strings.ToValidUTF8(string(data), "\uFFFD")) + if err := os.WriteFile(path, []byte(joinBOM(content, bom)), 0o644); err != nil { + return "", err + } + return content, nil +} diff --git a/internal/seniordev/tool/shell_env_signal.go b/internal/seniordev/tool/shell_env_signal.go new file mode 100644 index 0000000000..c32caccb77 --- /dev/null +++ b/internal/seniordev/tool/shell_env_signal.go @@ -0,0 +1,158 @@ +//go:build !windows + +package tool + +import ( + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/session/loopguard" +) + +const ( + shellStallWindow = 30 * time.Second + shellHeavyWindow = 30 * time.Second +) + +type shellDeathInput struct { + ExitCode *int + Expired bool + Aborted bool + OOMDelta *int64 + MemoryLimitBytes *int64 + SinceLastOutput *time.Duration + Timeout time.Duration + CommandDuration time.Duration +} + +func readCgroupInt(path, key string) *int64 { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + text := strings.TrimSpace(string(data)) + if key == "" { + if text == "" || text == "max" { + return nil + } + value, err := strconv.ParseInt(text, 10, 64) + if err != nil || value <= 0 { + return nil + } + return &value + } + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) != 2 || fields[0] != key { + continue + } + value, err := strconv.ParseInt(fields[1], 10, 64) + if err == nil { + return &value + } + } + return nil +} + +func readShellOOMCount() *int64 { + return readCgroupInt("/sys/fs/cgroup/memory.events", "oom_kill") +} + +func readShellMemoryLimit() *int64 { + return readCgroupInt("/sys/fs/cgroup/memory.max", "") +} + +func shellGB(bytes *int64) string { + if bytes == nil || *bytes <= 0 { + return "unknown" + } + return strconv.FormatFloat(float64(*bytes)/(1024*1024*1024), 'f', 1, 64) + "GB" +} + +func shellSeconds(duration time.Duration) string { + return strconv.FormatInt(int64(duration.Round(time.Second)/time.Second), 10) + "s" +} + +func classifyShellDeath(input shellDeathInput) string { + if input.Aborted { + return "" + } + if input.OOMDelta != nil && *input.OOMDelta > 0 { + return "[environment-signal] " + strconv.FormatInt(*input.OOMDelta, 10) + + " process(es) were OOM-killed by the kernel during this command (cgroup memory limit ≈ " + + shellGB(input.MemoryLimitBytes) + "). This is an environment resource limit, not a code bug. " + + "Do not rerun the same command unchanged — reduce its memory footprint (fewer parallel workers, " + + "narrower scope) or verify with a cheaper command (e.g. a targeted test instead of a full build)." + } + if input.ExitCode != nil && *input.ExitCode == 137 && (input.OOMDelta == nil || *input.OOMDelta == 0) { + return "[environment-signal] this command was killed by the system (SIGKILL / exit 137), likely memory pressure " + + "or an external kill rather than a code bug. Do not blindly retry the same command — reduce its memory " + + "footprint (fewer parallel workers, narrower scope) or verify with a cheaper command." + } + if input.Expired && input.SinceLastOutput != nil && *input.SinceLastOutput > shellStallWindow { + return "[environment-signal] this command timed out after " + shellSeconds(input.Timeout) + + " AND produced no output for the final " + shellSeconds(*input.SinceLastOutput) + + " — it was stalled (hung, waiting on I/O, or resource-starved), so a larger timeout alone is unlikely " + + "to help. Diagnose the hang or run a cheaper check instead of rerunning." + } + if input.Expired { + return "[environment-signal] this command timed out while still producing output — it may simply need more time; " + + "raise timeout only if this command is genuinely required, otherwise prefer a cheaper verification." + } + if input.ExitCode != nil && *input.ExitCode == 143 { + return "[environment-signal] this command was terminated by SIGTERM (exit 143) — an external stop signal rather " + + "than a normal exit. Check whether an orchestrator or timeout ended it before assuming a code failure." + } + return "" +} + +var shellRepeatGuards = struct { + sync.Mutex + bySession map[string]loopguard.LoopGuard +}{bySession: map[string]loopguard.LoopGuard{}} + +var shellCommandPlumbing = []*regexp.Regexp{ + regexp.MustCompile(`\s*2>&1\s*$`), + regexp.MustCompile(`\s*\|\s*tail\b[^|]*$`), + regexp.MustCompile(`\s*\|\s*head\b[^|]*$`), +} +var shellCommandWhitespace = regexp.MustCompile(`\s+`) + +func normalizeShellCommand(command string) string { + out := strings.TrimSpace(command) + for { + before := out + for _, pattern := range shellCommandPlumbing { + out = pattern.ReplaceAllString(out, "") + } + out = strings.TrimRight(out, " \t\r\n") + if out == before { + break + } + } + return strings.TrimSpace(shellCommandWhitespace.ReplaceAllString(out, " ")) +} + +func registerShellOutcome(sessionID, command string, duration time.Duration, failed bool) string { + if duration <= shellHeavyWindow && !failed { + return "" + } + shellRepeatGuards.Lock() + guard := shellRepeatGuards.bySession[sessionID] + if guard == nil { + cap := float64(3) + guard = loopguard.CreateLoopGuard(loopguard.LoopGuardOptions{RepeatCap: &cap}) + shellRepeatGuards.bySession[sessionID] = guard + } + verdict := guard.Observe(loopguard.LoopAction{Tool: "shell", ArgsKey: normalizeShellCommand(command)}) + shellRepeatGuards.Unlock() + if verdict.Status == loopguard.LoopStatusWarn || verdict.Status == loopguard.LoopStatusStop { + return "[environment-signal] this is a repeated attempt of a failing/expensive command — repeating it unchanged " + + "is unlikely to succeed; change the approach (narrower test, different diagnosis) instead." + } + return "" +} diff --git a/internal/seniordev/tool/shell_feedback_test.go b/internal/seniordev/tool/shell_feedback_test.go new file mode 100644 index 0000000000..f55c5001ab --- /dev/null +++ b/internal/seniordev/tool/shell_feedback_test.go @@ -0,0 +1,229 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func TestShellSessionBuildCachesAreIsolated(t *testing.T) { + // Concurrent leaves receive distinct build-cache namespaces. + for _, name := range []string{ + "CARGO_TARGET_DIR", "GOCACHE", "GOMODCACHE", "npm_config_cache", "PIP_CACHE_DIR", + } { + unsetEnvironmentForTest(t, name) + } + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + first := environmentMap(shellEnvironment("ses_first")) + second := environmentMap(shellEnvironment("ses_second")) + for name, suffix := range map[string]string{ + "CARGO_TARGET_DIR": "cargo", "GOCACHE": "go-build", + "npm_config_cache": "npm", "PIP_CACHE_DIR": "pip", + } { + wantFirst := filepath.Join(root, "ses_first", suffix) + wantSecond := filepath.Join(root, "ses_second", suffix) + if first[name] != wantFirst || second[name] != wantSecond || first[name] == second[name] { + t.Fatalf("%s paths = %q, %q", name, first[name], second[name]) + } + } +} + +func TestShellScratchLeavesGOMODCACHEAlone(t *testing.T) { + // GOMODCACHE is a source of truth, not a derived cache: the module sources + // live in it, and an offline environment may have pre-populated it. + // Redirecting it to an empty per-session dir while the network is + // blackholed leaves Go unable to build. It must be inherited, never + // rewritten. + for _, name := range []string{"GOMODCACHE", "GOCACHE"} { + unsetEnvironmentForTest(t, name) + } + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + environment := environmentMap(shellEnvironment("ses_gomod")) + if value, set := environment["GOMODCACHE"]; set { + t.Fatalf("GOMODCACHE was redirected to %q; it must be inherited untouched", value) + } + // The derived cache next to it still is redirected, proving the isolation + // mechanism is intact and only the source-of-truth entry was removed. + if want := filepath.Join(root, "ses_gomod", "go-build"); environment["GOCACHE"] != want { + t.Fatalf("GOCACHE = %q, want %q", environment["GOCACHE"], want) + } +} + +func TestShellScratchHonoursInheritedGOMODCACHE(t *testing.T) { + // A GOMODCACHE the operator set must survive untouched. + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", t.TempDir()) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + t.Setenv("GOMODCACHE", "/root/go/pkg/mod") + if got := environmentMap(shellEnvironment("ses_inherit"))["GOMODCACHE"]; got != "/root/go/pkg/mod" { + t.Fatalf("GOMODCACHE = %q, want the inherited /root/go/pkg/mod", got) + } +} + +func TestShellScratchTeardownAtLeafEndContract(t *testing.T) { + // A completed leaf reclaims its private caches. + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + t.Setenv("SENIOR_DEV_SHARED_BUILD_CACHE", "0") + _ = shellEnvironment("ses_finished") + cache := filepath.Join(root, "ses_finished", "go-build") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + TeardownShellScratch("ses_finished") + if _, err := os.Stat(filepath.Join(root, "ses_finished")); !os.IsNotExist(err) { + t.Fatalf("completed leaf scratch remains: %v", err) + } +} + +func TestShellScratchTeardownWaitsForLastSessionUserContract(t *testing.T) { + // Two concurrent leaves sharing a claimed session cannot delete one + // another's live build caches. + root := t.TempDir() + t.Setenv("SENIOR_DEV_SCRATCH_ROOT", root) + firstRelease := AcquireShellScratch("ses_shared") + secondRelease := AcquireShellScratch("ses_shared") + cache := filepath.Join(root, "ses_shared", "go-build") + if err := os.MkdirAll(cache, 0o755); err != nil { + t.Fatal(err) + } + firstRelease() + if _, err := os.Stat(cache); err != nil { + t.Fatalf("first user removed shared scratch: %v", err) + } + secondRelease() + if _, err := os.Stat(filepath.Join(root, "ses_shared")); !os.IsNotExist(err) { + t.Fatalf("last user did not remove shared scratch: %v", err) + } +} + +func TestBashWorkdirRunsThereAndRejectsEscapes(t *testing.T) { + // workdir runs inside the workspace and rejects escapes. + workDir := t.TempDir() + nested := filepath.Join(workDir, "nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + registry := New(workDir) + result, err := execute(t, registry, "bash", map[string]any{"command": "pwd", "workdir": "nested"}) + if err != nil { + t.Fatal(err) + } + if !sameFolder(strings.TrimSpace(result.Output), nested) { + t.Fatalf("pwd output = %q, want %q", result.Output, nested) + } + _, err = execute(t, registry, "bash", map[string]any{"command": "pwd", "workdir": "../outside"}) + if err == nil || err.Error() != "path escapes workspace: ../outside" { + t.Fatalf("escape error = %v", err) + } +} + +func TestBashHonorsConfiguredShell(t *testing.T) { + // Shell execution honors the configured acceptable shell. + workDir := t.TempDir() + shell := filepath.Join(workDir, "configured-sh") + if err := os.WriteFile(shell, []byte("#!/bin/sh\nprintf 'configured-shell\\n'\nexec /bin/sh \"$@\"\n"), 0o755); err != nil { + t.Fatal(err) + } + settings, _ := json.Marshal(map[string]any{"shell": shell}) + t.Setenv("SENIOR_DEV_CONFIG_CONTENT", string(settings)) + result, err := execute(t, New(workDir), "bash", map[string]any{"command": "printf command-body"}) + if err != nil { + t.Fatal(err) + } + if result.Output != "configured-shell\ncommand-body" { + t.Fatalf("output = %q", result.Output) + } +} + +func TestBashReportsOOMKill(t *testing.T) { + // A cgroup OOM kill is diagnosed instead of appearing as a plain exit status. + previousOOM := bashReadOOMCount + previousLimit := bashReadMemoryLimit + t.Cleanup(func() { + bashReadOOMCount = previousOOM + bashReadMemoryLimit = previousLimit + }) + reads := 0 + bashReadOOMCount = func() *int64 { + reads++ + value := int64(8) + if reads > 1 { + value = 9 + } + return &value + } + bashReadMemoryLimit = func() *int64 { + value := int64(2 * 1024 * 1024 * 1024) + return &value + } + t.Setenv("SENIOR_DEV_ENV_SIGNALS", "1") + input := json.RawMessage(`{"command":"exit 137"}`) + result, err := New(t.TempDir()).Execute(context.Background(), steploop.ToolCall{ + ID: "call_oom", Name: "bash", Input: input, SessionID: "ses_oom", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Output, "were OOM-killed by the kernel") || + !strings.Contains(result.Output, "environment resource limit, not a code bug") { + t.Fatalf("output = %q", result.Output) + } +} + +func TestShellClassifiesStallAndRepeatedFailure(t *testing.T) { + // Quiet timeouts and repeated heavy/failing commands are actionable. + exit := 1 + quiet := 45 * time.Second + stall := classifyShellDeath(shellDeathInput{ + ExitCode: &exit, Expired: true, SinceLastOutput: &quiet, Timeout: time.Minute, + }) + if !strings.Contains(stall, "it was stalled") || !strings.Contains(stall, "larger timeout alone") { + t.Fatalf("stall diagnostic = %q", stall) + } + session := "ses_repeat_test" + for attempt := 1; attempt <= 3; attempt++ { + warning := registerShellOutcome(session, "go test ./... 2>&1 | tail -20", time.Second, true) + if attempt < 3 && warning != "" { + t.Fatalf("attempt %d warned early: %q", attempt, warning) + } + if attempt == 3 && !strings.Contains(warning, "repeated attempt") { + t.Fatalf("third attempt warning = %q", warning) + } + } +} + +func environmentMap(values []string) map[string]string { + out := make(map[string]string, len(values)) + for _, value := range values { + name, item, _ := strings.Cut(value, "=") + out[name] = item + } + return out +} + +func unsetEnvironmentForTest(t *testing.T, name string) { + t.Helper() + previous, existed := os.LookupEnv(name) + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if existed { + _ = os.Setenv(name, previous) + } else { + _ = os.Unsetenv(name) + } + }) +} diff --git a/internal/seniordev/tool/shell_scan.go b/internal/seniordev/tool/shell_scan.go new file mode 100644 index 0000000000..ed5fae229b --- /dev/null +++ b/internal/seniordev/tool/shell_scan.go @@ -0,0 +1,547 @@ +//go:build !windows + +// The deterministic shell core shared by executors: shell identification and +// the permission scan that extracts paths and command patterns from a command +// line. Process execution lives in bash.go. +package tool + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +// shellMeta lists the shells that are refused as tool shells and the ones +// that use PowerShell syntax. Any shell absent from it is accepted and +// scanned as POSIX. +var shellMeta = map[string]struct { + deny bool + ps bool +}{ + "fish": {deny: true}, + "nu": {deny: true}, + "powershell": {ps: true}, + "pwsh": {ps: true}, +} + +// ShellName returns the lowercase executable basename, without its extension +// on Windows. +func ShellName(file string) string { + base := filepath.Base(file) + if runtime.GOOS == "windows" { + base = strings.TrimSuffix(base, filepath.Ext(base)) + } + return strings.ToLower(base) +} + +// ShellPowerShell reports whether a shell uses PowerShell syntax. +func ShellPowerShell(file string) bool { return shellMeta[ShellName(file)].ps } + +// ShellAcceptable reports whether a shell may run tool commands; fish and nu +// are refused. +func ShellAcceptable(file string) bool { return !shellMeta[ShellName(file)].deny } + +// shellKind classifies a shell for permission scanning as bash, pwsh, +// powershell or cmd; anything else is scanned as bash. +func shellKind(file string) string { + switch name := ShellName(file); name { + case "bash", "pwsh", "powershell", "cmd": + return name + default: + return "bash" + } +} + +// ShellPermissionScan is the ordered permission material gathered from a +// parsed shell command. +type ShellPermissionScan struct { + Dirs []string `json:"dirs"` + Patterns []string `json:"patterns"` + Always []string `json:"always"` +} + +// ShellScanOptions supplies the path context used by the permission scanner. +type ShellScanOptions struct { + CWD string + Shell string + Workspace string + Home string + Env map[string]string + IsDir func(string) bool +} + +var shellCWDCommands = stringSet("cd", "chdir", "popd", "pushd", "push-location", "set-location") +var shellFileCommands = stringSet( + "cd", "chdir", "popd", "pushd", "push-location", "set-location", + "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat", + "get-content", "set-content", "add-content", "copy-item", "move-item", + "remove-item", "new-item", "rename-item", +) +var cmdFileCommands = stringSet( + "copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", + "rename", "rmdir", "type", +) + +// ScanShellPermissions extracts the permission material from a command line: +// directories it touches outside the workspace, the simple-command patterns, +// and their arity prefixes. It keeps shell source strings and token boundaries +// intact and skips dynamic path expressions rather than guess at them. +func ScanShellPermissions(command string, opts ShellScanOptions) ShellPermissionScan { + if opts.Home == "" { + opts.Home, _ = os.UserHomeDir() + } + if opts.CWD == "" { + opts.CWD = "." + } + ps := ShellPowerShell(opts.Shell) + kind := shellKind(opts.Shell) + dirs := newOrderedStrings() + patterns := newOrderedStrings() + always := newOrderedStrings() + + var process func(string) + process = func(simple string) { + tokens := shellWords(simple) + if len(tokens) == 0 { + return + } + cmd := tokens[0] + if ps || kind == "cmd" { + cmd = strings.ToLower(cmd) + } + + if shellFileCommands[cmd] || (kind == "cmd" && cmdFileCommands[cmd]) { + for _, arg := range shellPathArgs(tokens, kind == "cmd") { + file := shellArgPath(arg, opts, ps) + if file == "" || pathWithin(file, opts.Workspace) { + continue + } + dir := filepath.Dir(file) + if opts.IsDir != nil && opts.IsDir(file) { + dir = file + } + dirs.Add(dir) + } + } + + if !shellCWDCommands[cmd] { + patterns.Add(strings.TrimSpace(simple)) + always.Add(strings.Join(shellArityPrefix(tokens), " ") + " *") + } + for _, nested := range shellSubstitutions(simple) { + for _, command := range splitShellCommands(nested) { + process(command) + } + } + } + for _, simple := range splitShellCommands(command) { + process(simple) + } + return ShellPermissionScan{Dirs: dirs.Values(), Patterns: patterns.Values(), Always: always.Values()} +} + +func shellSubstitutions(text string) []string { + var out []string + for start := 0; start+1 < len(text); { + rel := strings.Index(text[start:], "$(") + if rel < 0 { + break + } + open := start + rel + 1 + depth := 1 + quote := byte(0) + escaped := false + end := open + 1 + for ; end < len(text); end++ { + c := text[end] + if escaped { + escaped = false + continue + } + if c == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if c == quote { + quote = 0 + } + continue + } + if c == '\'' || c == '"' { + quote = c + continue + } + if c == '(' { + depth++ + } else if c == ')' { + depth-- + if depth == 0 { + break + } + } + } + if depth != 0 { + break + } + out = append(out, text[open+1:end]) + start = end + 1 + } + return out +} + +func shellPathArgs(tokens []string, cmd bool) []string { + out := make([]string, 0, len(tokens)-1) + for _, token := range tokens[1:] { + if strings.HasPrefix(token, "-") || (cmd && strings.HasPrefix(token, "/")) || + (tokens[0] == "chmod" && strings.HasPrefix(token, "+")) { + continue + } + out = append(out, token) + } + return out +} + +func shellArgPath(arg string, opts ShellScanOptions, ps bool) string { + text := unquoteShell(arg) + if ps { + text = expandPowerShellPath(text, opts) + } else { + text = expandHome(text, opts.Home) + } + text = globPrefix(text) + if text == "" || dynamicShellPath(text, ps) { + return "" + } + if ps { + text = filesystemProvider(text) + if text == "" { + return "" + } + } + if filepath.IsAbs(text) { + return filepath.Clean(text) + } + return filepath.Clean(filepath.Join(opts.CWD, text)) +} + +func unquoteShell(text string) string { + if len(text) < 2 { + return text + } + if (text[0] == '"' || text[0] == '\'') && text[len(text)-1] == text[0] { + return text[1 : len(text)-1] + } + return text +} + +func expandHome(text, home string) string { + if text == "~" { + return home + } + if strings.HasPrefix(text, "~/") || strings.HasPrefix(text, `~\`) { + return filepath.Join(home, text[2:]) + } + return text +} + +func expandPowerShellPath(text string, opts ShellScanOptions) string { + // PowerShell arguments are not parsed by a grammar here; these are the + // deterministic expansion rules for already-tokenized arguments. + replaceEnv := func(s string) string { + lower := strings.ToLower(s) + for key, value := range opts.Env { + if strings.ToLower(key) == lower { + return value + } + } + return "" + } + for { + lower := strings.ToLower(text) + start := strings.Index(lower, "${env:") + if start < 0 { + break + } + endRel := strings.IndexByte(text[start:], '}') + if endRel < 0 { + break + } + end := start + endRel + text = text[:start] + replaceEnv(text[start+6:end]) + text[end+1:] + } + for _, prefix := range []string{"$env:"} { + for { + lower := strings.ToLower(text) + start := strings.Index(lower, prefix) + if start < 0 { + break + } + end := start + len(prefix) + for end < len(text) && (text[end] == '_' || text[end] >= '0' && text[end] <= '9' || + text[end] >= 'A' && text[end] <= 'Z' || text[end] >= 'a' && text[end] <= 'z') { + end++ + } + text = text[:start] + replaceEnv(text[start+len(prefix):end]) + text[end:] + } + } + autos := map[string]string{"HOME": opts.Home, "PWD": opts.CWD, "PSHOME": filepath.Dir(opts.Shell)} + for key, value := range autos { + for _, spelling := range []string{"$" + key, "$" + strings.ToLower(key)} { + text = strings.ReplaceAll(text, spelling+"/", value+"/") + text = strings.ReplaceAll(text, spelling+`\`, value+`\`) + if text == spelling { + text = value + } + } + } + return expandHome(text, opts.Home) +} + +func filesystemProvider(text string) string { + if i := strings.Index(text, "::"); i > 0 { + if strings.EqualFold(text[:i], "filesystem") { + return text[i+2:] + } + return "" + } + if i := strings.IndexByte(text, ':'); i > 0 { + if i == 1 { + return text + } + return "" + } + return text +} + +func dynamicShellPath(text string, ps bool) bool { + if strings.HasPrefix(text, "(") || strings.HasPrefix(text, "@(") || + strings.Contains(text, "$(") || strings.Contains(text, "${") || + strings.Contains(text, "`") { + return true + } + if ps { + for i := 0; i < len(text); i++ { + if text[i] == '$' && !strings.HasPrefix(strings.ToLower(text[i:]), "$env:") { + return true + } + } + return false + } + return strings.Contains(text, "$") +} + +func globPrefix(text string) string { + for i, r := range text { + if r == '?' || r == '*' || r == '[' { + if i == 0 { + return "" + } + return text[:i] + } + } + return text +} + +func pathWithin(candidate, root string) bool { + if root == "" { + return false + } + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +// splitShellCommands preserves simple-command source while respecting quoted +// separators and nested substitutions. Redirections remain attached to the +// command they belong to. +func splitShellCommands(text string) []string { + var out []string + start := 0 + quote := byte(0) + escaped := false + paren, brace := 0, 0 + for i := 0; i < len(text); i++ { + c := text[i] + if escaped { + escaped = false + continue + } + if c == '\\' && quote != '\'' { + escaped = true + continue + } + if quote != 0 { + if c == quote { + quote = 0 + } + continue + } + if c == '\'' || c == '"' { + quote = c + continue + } + switch c { + case '(': + paren++ + case ')': + if paren > 0 { + paren-- + } + case '{': + brace++ + case '}': + if brace > 0 { + brace-- + } + } + if paren != 0 || brace != 0 { + continue + } + separator := c == '\n' || c == ';' || c == '|' + if c == '&' { + separator = i+1 < len(text) && text[i+1] == '&' + } + if !separator { + continue + } + if part := strings.TrimSpace(text[start:i]); part != "" { + out = append(out, part) + } + if i+1 < len(text) && text[i+1] == c && (c == '|' || c == '&') { + i++ + } + start = i + 1 + } + if part := strings.TrimSpace(text[start:]); part != "" { + out = append(out, part) + } + return out +} + +func shellWords(text string) []string { + var words []string + var b strings.Builder + quote := byte(0) + escaped := false + flush := func() { + if b.Len() > 0 { + words = append(words, b.String()) + b.Reset() + } + } + for i := 0; i < len(text); i++ { + c := text[i] + if escaped { + b.WriteByte(c) + escaped = false + continue + } + if c == '\\' && quote != '\'' { + b.WriteByte(c) + escaped = true + continue + } + if quote != 0 { + b.WriteByte(c) + if c == quote { + quote = 0 + } + continue + } + if c == '\'' || c == '"' { + quote = c + b.WriteByte(c) + continue + } + if c == ' ' || c == '\t' || c == '\r' || c == '\n' { + flush() + continue + } + if c == '>' || c == '<' { + flush() + break + } + b.WriteByte(c) + } + flush() + return words +} + +var oneTokenCommands = stringSet( + "cat", "cd", "chmod", "chown", "cp", "echo", "env", "export", "grep", + "kill", "killall", "ln", "ls", "mkdir", "mv", "ps", "pwd", "rm", + "rmdir", "sleep", "source", "tail", "touch", "unset", "which", +) + +var threeTokenPrefixes = stringSet( + "bun run", "bun x", "cargo add", "cargo run", "consul kv", "docker builder", + "docker compose", "docker container", "docker image", "docker network", + "docker volume", "eksctl create", "ip addr", "ip link", "ip netns", + "ip route", "kind create", "kubectl kustomize", "kubectl rollout", + "mc admin", "npm exec", "npm init", "npm run", "npm view", "openssl req", + "openssl x509", "pnpm dlx", "pnpm exec", "pnpm run", "podman container", + "podman image", "pulumi stack", "terraform workspace", "vault auth", + "vault kv", "yarn dlx", "yarn run", +) + +var twoTokenCommands = stringSet( + "bazel", "brew", "bun", "cargo", "cdk", "cf", "cmake", "composer", + "consul", "crictl", "deno", "docker", "eksctl", "firebase", "flyctl", + "git", "go", "gradle", "helm", "heroku", "hugo", "ip", "kind", + "kubectl", "kustomize", "make", "mc", "minikube", "mongosh", "mysql", + "mvn", "ng", "npm", "nvm", "nx", "openssl", "pip", "pipenv", "pnpm", + "poetry", "podman", "psql", "pulumi", "pyenv", "python", "rake", + "rbenv", "redis-cli", "rustup", "serverless", "skaffold", "sls", "sst", + "swift", "systemctl", "terraform", "tmux", "turbo", "ufw", "vault", + "vercel", "volta", "wp", "yarn", +) + +var threeTokenCommands = stringSet("aws", "az", "doctl", "gcloud", "gh", "sfdx") + +func shellArityPrefix(tokens []string) []string { + if len(tokens) == 0 { + return []string{} + } + arity := 1 + if oneTokenCommands[tokens[0]] { + arity = 1 + } else if threeTokenCommands[tokens[0]] { + arity = 3 + } else if twoTokenCommands[tokens[0]] { + arity = 2 + } + if len(tokens) >= 2 && threeTokenPrefixes[tokens[0]+" "+tokens[1]] { + arity = 3 + } + if arity > len(tokens) { + arity = len(tokens) + } + return append([]string(nil), tokens[:arity]...) +} + +type orderedStrings struct { + seen map[string]bool + list []string +} + +func newOrderedStrings() *orderedStrings { return &orderedStrings{seen: map[string]bool{}} } +func (s *orderedStrings) Add(value string) { + if !s.seen[value] { + s.seen[value] = true + s.list = append(s.list, value) + } +} +func (s *orderedStrings) Values() []string { + out := make([]string, len(s.list)) + copy(out, s.list) + return out +} + +func stringSet(values ...string) map[string]bool { + out := make(map[string]bool, len(values)) + for _, value := range values { + out[value] = true + } + return out +} diff --git a/internal/seniordev/tool/shell_scan_test.go b/internal/seniordev/tool/shell_scan_test.go new file mode 100644 index 0000000000..0872f68c85 --- /dev/null +++ b/internal/seniordev/tool/shell_scan_test.go @@ -0,0 +1,50 @@ +//go:build !windows + +package tool + +import ( + "path/filepath" + "reflect" + "testing" +) + +func TestScanShellPermissions(t *testing.T) { + workspace := filepath.Join(string(filepath.Separator), "work", "repo") + scan := ScanShellPermissions( + `git status && cp "inside.txt" /outside/dst; cd ../other; npm run test`, + ShellScanOptions{ + CWD: workspace, + Workspace: workspace, + Shell: "/bin/bash", + Home: "/home/test", + IsDir: func(path string) bool { + return path == "/outside/dst" + }, + }, + ) + if want := []string{"/outside/dst", "/work"}; !reflect.DeepEqual(scan.Dirs, want) { + t.Errorf("Dirs = %#v, want %#v", scan.Dirs, want) + } + if want := []string{"git status", `cp "inside.txt" /outside/dst`, "npm run test"}; !reflect.DeepEqual(scan.Patterns, want) { + t.Errorf("Patterns = %#v, want %#v", scan.Patterns, want) + } + if want := []string{"git status *", "cp *", "npm run test *"}; !reflect.DeepEqual(scan.Always, want) { + t.Errorf("Always = %#v, want %#v", scan.Always, want) + } +} + +func TestShellPathScannerSkipsDynamicAndKeepsGlobPrefix(t *testing.T) { + scan := ScanShellPermissions( + `rm /external/logs/*.txt; cat "$HOME/secret"; touch ~/outside/new`, + ShellScanOptions{ + CWD: "/repo", + Workspace: "/repo", + Shell: "/bin/bash", + Home: "/home/test", + }, + ) + want := []string{"/external", "/home/test/outside"} + if !reflect.DeepEqual(scan.Dirs, want) { + t.Fatalf("Dirs = %#v, want %#v", scan.Dirs, want) + } +} diff --git a/internal/seniordev/tool/shell_scratch.go b/internal/seniordev/tool/shell_scratch.go new file mode 100644 index 0000000000..024a845410 --- /dev/null +++ b/internal/seniordev/tool/shell_scratch.go @@ -0,0 +1,247 @@ +//go:build !windows + +package tool + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +const ( + shellScratchMarker = ".senior-dev-scratch.json" + defaultScratchRoot = "/tmp/senior-dev-scratch" + defaultScratchTTLHours = 24 +) + +type shellScratchOwner struct { + PID int `json:"pid"` + Hostname string `json:"hostname"` + StartedAt int64 `json:"startedAt"` +} + +var shellScratchSweep sync.Once + +var shellScratchUsers = struct { + sync.Mutex + counts map[string]int +}{counts: map[string]int{}} + +func scratchRoot() string { + if root := os.Getenv("SENIOR_DEV_SCRATCH_ROOT"); root != "" { + return root + } + return defaultScratchRoot +} + +func scratchDir(sessionID string) string { + return filepath.Join(scratchRoot(), sessionID) +} + +func ensureShellScratch(sessionID string) { + shellScratchSweep.Do(sweepShellScratch) + dir := scratchDir(sessionID) + if err := os.MkdirAll(dir, 0o755); err != nil { + return + } + hostname, _ := os.Hostname() + marker, _ := json.Marshal(shellScratchOwner{ + PID: os.Getpid(), Hostname: hostname, StartedAt: time.Now().UnixMilli(), + }) + file, err := os.OpenFile(filepath.Join(dir, shellScratchMarker), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return + } + _, _ = file.Write(marker) + _ = file.Close() +} + +// AcquireShellScratch registers one active user of a session's private build +// caches. The returned release removes the cache after the last user exits. +func AcquireShellScratch(sessionID string) func() { + if sessionID == "" { + return func() {} + } + shellScratchUsers.Lock() + shellScratchUsers.counts[sessionID]++ + shellScratchUsers.Unlock() + var once sync.Once + return func() { + once.Do(func() { TeardownShellScratch(sessionID) }) + } +} + +// TeardownShellScratch releases one leaf's private build caches. Shared-cache +// mode never creates these directories, so removal remains a best-effort no-op. +func TeardownShellScratch(sessionID string) { + if sessionID == "" { + return + } + shellScratchUsers.Lock() + defer shellScratchUsers.Unlock() + users := shellScratchUsers.counts[sessionID] + if users > 1 { + shellScratchUsers.counts[sessionID] = users - 1 + return + } + delete(shellScratchUsers.counts, sessionID) + _ = os.RemoveAll(scratchDir(sessionID)) +} + +func sweepShellScratch() { + entries, err := os.ReadDir(scratchRoot()) + if err != nil { + return + } + hostname, _ := os.Hostname() + now := time.Now() + ttl := time.Duration(defaultScratchTTLHours) * time.Hour + if raw := os.Getenv("SENIOR_DEV_SCRATCH_TTL_H"); raw != "" { + if hours, err := strconv.ParseFloat(raw, 64); err == nil && hours > 0 { + ttl = time.Duration(hours * float64(time.Hour)) + } + } + type keptScratch struct { + dir string + owner *shellScratchOwner + started time.Time + } + kept := []keptScratch{} + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), "ses_") { + continue + } + dir := filepath.Join(scratchRoot(), entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + owner := readShellScratchOwner(dir) + orphan := false + if owner != nil && owner.Hostname == hostname { + orphan = !shellScratchPIDAlive(owner.PID) + } else { + started := info.ModTime() + if owner != nil && owner.StartedAt > 0 { + started = time.UnixMilli(owner.StartedAt) + } + orphan = now.Sub(started) > ttl + } + if orphan { + _ = os.RemoveAll(dir) + } else { + started := info.ModTime() + if owner != nil && owner.StartedAt > 0 { + started = time.UnixMilli(owner.StartedAt) + } + kept = append(kept, keptScratch{dir: dir, owner: owner, started: started}) + } + } + rawCap := os.Getenv("SENIOR_DEV_SCRATCH_MAX_GB") + capGB, err := strconv.ParseFloat(rawCap, 64) + if err != nil || capGB <= 0 { + return + } + capBytes := int64(capGB * 1024 * 1024 * 1024) + sizes := make(map[string]int64, len(kept)) + var total int64 + for _, item := range kept { + sizes[item.dir] = shellScratchDirSize(item.dir) + total += sizes[item.dir] + } + if total <= capBytes { + return + } + sort.SliceStable(kept, func(i, j int) bool { return kept[i].started.Before(kept[j].started) }) + for _, item := range kept { + if total <= capBytes { + break + } + if item.owner != nil && item.owner.Hostname == hostname && shellScratchPIDAlive(item.owner.PID) { + continue + } + if os.RemoveAll(item.dir) == nil { + total -= sizes[item.dir] + } + } +} + +func shellScratchDirSize(dir string) int64 { + var total int64 + _ = filepath.WalkDir(dir, func(_ string, entry os.DirEntry, err error) error { + if err != nil || entry.IsDir() { + return nil + } + if info, infoErr := entry.Info(); infoErr == nil { + total += info.Size() + } + return nil + }) + return total +} + +func readShellScratchOwner(dir string) *shellScratchOwner { + data, err := os.ReadFile(filepath.Join(dir, shellScratchMarker)) + if err != nil { + return nil + } + var owner shellScratchOwner + if json.Unmarshal(data, &owner) != nil || owner.PID == 0 { + return nil + } + return &owner +} + +func shellScratchPIDAlive(pid int) bool { + if runtime.GOOS == "windows" { + return true + } + err := syscall.Kill(pid, 0) + return err == nil || err == syscall.EPERM +} + +func shellEnvironment(sessionID string) []string { + environment := append([]string(nil), os.Environ()...) + // Appended after os.Environ() so exec's last-entry-wins dedup overrides + // any proxy the parent carries; independent of the shared-cache early + // return below, which must not open the network gate. + environment = append(environment, netpolicy.ShellProxyEnv(netpolicy.Current())...) + if os.Getenv("SENIOR_DEV_SHARED_BUILD_CACHE") == "1" || sessionID == "" { + return environment + } + ensureShellScratch(sessionID) + leaf := scratchDir(sessionID) + // Only DERIVED caches may be redirected. Each of these is reconstructible + // from source plus a toolchain, so pointing it at an empty per-session dir + // costs a cold build and nothing else. + // + // GOMODCACHE is deliberately NOT here. It is a source of truth, not an + // output cache: the module sources themselves live in it, and an offline + // environment may have pre-populated it precisely because nothing can be + // downloaded. Redirecting it to an empty dir while netpolicy blackholes the + // network leaves Go unable to build anything -- `go: downloading` storms + // ending in 403 from our own blackhole, then "module lookup disabled by + // GOPROXY=off". Sharing the module cache across sessions is safe -- it is + // content-addressed and written read-only by the go tool. + defaults := map[string]string{ + "CARGO_TARGET_DIR": filepath.Join(leaf, "cargo"), + "GOCACHE": filepath.Join(leaf, "go-build"), + "npm_config_cache": filepath.Join(leaf, "npm"), + "PIP_CACHE_DIR": filepath.Join(leaf, "pip"), + } + for _, name := range []string{"CARGO_TARGET_DIR", "GOCACHE", "npm_config_cache", "PIP_CACHE_DIR"} { + if _, exists := os.LookupEnv(name); !exists { + environment = append(environment, name+"="+defaults[name]) + } + } + return environment +} diff --git a/internal/seniordev/tool/shell_settings.go b/internal/seniordev/tool/shell_settings.go new file mode 100644 index 0000000000..27dd33cbe9 --- /dev/null +++ b/internal/seniordev/tool/shell_settings.go @@ -0,0 +1,52 @@ +//go:build !windows + +package tool + +import ( + "os" + "os/exec" + "path/filepath" +) + +func (r *Registry) executionShell() (string, error) { + settings, err := r.settings() + if err != nil { + return "", err + } + configured, _ := settings["shell"].(string) + if configured != "" { + if shell := resolveShellExecutable(configured); shell != "" && ShellAcceptable(shell) { + return shell, nil + } + return fallbackShell(), nil + } + if shell := resolveShellExecutable(os.Getenv("SHELL")); shell != "" && ShellAcceptable(shell) { + return shell, nil + } + return fallbackShell(), nil +} + +func resolveShellExecutable(shell string) string { + if shell == "" { + return "" + } + if filepath.IsAbs(shell) { + info, err := os.Stat(shell) + if err == nil && !info.IsDir() { + return filepath.Clean(shell) + } + return "" + } + match, err := exec.LookPath(shell) + if err != nil { + return "" + } + return match +} + +func fallbackShell() string { + if bash := resolveShellExecutable("bash"); bash != "" { + return bash + } + return "/bin/sh" +} diff --git a/internal/seniordev/tool/submit.go b/internal/seniordev/tool/submit.go new file mode 100644 index 0000000000..da695ab654 --- /dev/null +++ b/internal/seniordev/tool/submit.go @@ -0,0 +1,138 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +// Submitting is the point of no return. Calling it hands senior-dev the tree as it +// stands and ends the run's authority to change it: the candidate is captured +// inside this call, so nothing the model does afterwards can reach what ships. +// +// Finishing is an explicit action rather than a condition inferred from the +// tree. A run that ends because its budget ran out leaves "done" as whatever +// the tree happened to look like at that moment; submit turns it into a +// decision the model makes, with evidence attached. +const submitDescription = `Declare the work finished. The working tree at this instant -- committed, +modified, and untracked files alike, minus git-ignored paths and .senior-dev/ -- is +captured as the answer, and the run ends. This is irreversible: later edits are +not part of the answer. + +Takes a reason, the evidence you verified with (the command you ran and what it +returned), and checklist_satisfied. Refuses, naming the cause, when the tree is +identical to the starting commit, when .senior-dev/checklist.md does not exist, +when reason or evidence is empty, or when this run already submitted. A refusal +does not capture anything and does not end the run.` + +const submitSchema = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "reason": { + "description": "Why the work is finished, in one sentence.", + "type": "string" + }, + "evidence": { + "description": "The commands you ran to prove it and what they returned. Name the pinned command and its exit status, and the test counts.", + "type": "string" + }, + "checklist_satisfied": { + "description": "True only if every intake checklist item is satisfied. Do not set it true to get past this gate.", + "type": "boolean" + } + }, + "required": ["reason", "evidence", "checklist_satisfied"] +}` + +type submitInput struct { + Reason string `json:"reason"` + Evidence string `json:"evidence"` + ChecklistSatisfied bool `json:"checklist_satisfied"` +} + +// Submission is what the model claimed when it submitted. The claim is +// recorded verbatim and separately from what senior-dev verifies itself afterwards +// -- a run that says "all tests pass" and did not run them must leave both +// facts in the record, not one reconciled story. +type Submission struct { + Reason string + Evidence string + ChecklistSatisfied bool + SessionID string +} + +// SubmitFreezer captures the candidate at submit time. It returns a short +// human-readable description of what was frozen (a patch size, a sha) that is +// echoed back to the model so the transcript records the handoff, or an error +// if there was nothing to freeze. +// +// The freeze happens inside the tool call rather than after the turn returns +// because that is the only placement where "no later stage may reopen the +// implementation" is structural instead of aspirational. +type SubmitFreezer func(ctx context.Context, submission Submission) (string, error) + +func validateSubmit(raw json.RawMessage) error { + var input submitInput + if err := json.Unmarshal(raw, &input); err != nil { + return fmt.Errorf("submit: %w", err) + } + if strings.TrimSpace(input.Reason) == "" { + return errors.New("submit: reason is required") + } + if strings.TrimSpace(input.Evidence) == "" { + return errors.New("submit: evidence is required — name the command you ran and what it returned") + } + return nil +} + +func (r *Registry) executeSubmit( + ctx context.Context, call steploop.ToolCall, +) (steploop.ToolResult, error) { + var input submitInput + if err := json.Unmarshal(call.Input, &input); err != nil { + return steploop.ToolResult{}, fmt.Errorf("submit: %w", err) + } + if r.submitFreeze == nil { + return steploop.ToolResult{}, errors.New( + "submit: this run has no submission handler; finish by describing the work instead", + ) + } + submission := Submission{ + Reason: strings.TrimSpace(input.Reason), + Evidence: strings.TrimSpace(input.Evidence), + ChecklistSatisfied: input.ChecklistSatisfied, + SessionID: call.SessionID, + } + description, err := r.submitFreeze(ctx, submission) + if err != nil { + // A refused submit is not a crash: the model is told why and may keep + // working. Refusing loudly here is the whole point -- an empty patch or + // a dirty tree caught at submit is worth more than the same thing + // discovered by the verification that runs after the freeze. + return steploop.ToolResult{ + Title: "submit refused", + Output: "Submission refused: " + err.Error() + "\n\nThe tree was NOT captured. Fix the problem and submit again.", + }, nil + } + return steploop.ToolResult{ + Title: "submitted", + Output: "Submission accepted and the tree is frozen: " + description + + "\n\nThis is your answer. Stop editing. Reply with a short summary of what you changed" + + " and the evidence it works; nothing you do now can change what ships.", + }, nil +} + +// SetSubmitFreezer installs the submission handler after construction. The +// registry is built inside the runtime, before the pipeline that owns the +// freeze exists; this is the seam between them. Setting it also advertises the +// submit tool, so it must be called before the first turn is configured. +func (r *Registry) SetSubmitFreezer(freeze SubmitFreezer) { + r.submitFreeze = freeze +} diff --git a/internal/seniordev/tool/submit_test.go b/internal/seniordev/tool/submit_test.go new file mode 100644 index 0000000000..1c96e441c6 --- /dev/null +++ b/internal/seniordev/tool/submit_test.go @@ -0,0 +1,162 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +func submitCall(t *testing.T, input map[string]any) steploop.ToolCall { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + return steploop.ToolCall{ + ID: "call_1", Name: "submit", Input: raw, SessionID: "ses_solo", Agent: "coder", + } +} + +func TestSubmitToolIsAbsentWithoutAFreezer(t *testing.T) { + // An embedder that runs no submission protocol must not advertise a submit + // action at all. Advertising one and then refusing every call teaches the + // model that the tool is broken, which is worse than not having it. + for _, id := range New(t.TempDir()).IDs() { + if id == "submit" { + t.Fatal("submit advertised with no SubmitFreeze installed") + } + } +} + +func TestSubmitFreezesTheCandidateInsideTheCall(t *testing.T) { + // The freeze must happen during the tool call, not after the turn returns. + // That placement is what makes "no later stage may reopen the + // implementation" structural: by the time the model's next step runs, the + // artifact of record already exists. + var captured Submission + frozen := 0 + registry := NewWithOptions(t.TempDir(), RegistryOptions{ + SubmitFreeze: func(_ context.Context, submission Submission) (string, error) { + frozen++ + captured = submission + return "812 B across 2 files", nil + }, + }) + + advertised := false + for _, id := range registry.IDs() { + if id == "submit" { + advertised = true + } + } + if !advertised { + t.Fatal("submit not advertised despite an installed freezer") + } + + result, err := registry.Execute(context.Background(), submitCall(t, map[string]any{ + "reason": "parser fix implemented and green", + "evidence": "make test: 12 passed, exit 0", + "checklist_satisfied": true, + })) + if err != nil { + t.Fatal(err) + } + if frozen != 1 { + t.Fatalf("freezer called %d times, want exactly 1", frozen) + } + if captured.Reason != "parser fix implemented and green" || + !captured.ChecklistSatisfied || captured.SessionID != "ses_solo" { + t.Fatalf("captured submission = %#v", captured) + } + if !strings.Contains(result.Output, "812 B across 2 files") { + t.Fatalf("freeze description not echoed to the model: %q", result.Output) + } + if !strings.Contains(result.Output, "Stop editing") { + t.Fatalf("accepted submit does not tell the model to stop: %q", result.Output) + } +} + +func TestRefusedSubmitIsRecoverableRatherThanFatal(t *testing.T) { + // A submit the freezer refuses -- empty patch, dirty tree -- must come back + // as a tool result the model can act on, not an error that kills the turn. + // Catching it here is the entire value: the same defect found after the run + // has ended costs the whole run. + registry := NewWithOptions(t.TempDir(), RegistryOptions{ + SubmitFreeze: func(context.Context, Submission) (string, error) { + return "", errors.New("the working tree is identical to the base commit") + }, + }) + result, err := registry.Execute(context.Background(), submitCall(t, map[string]any{ + "reason": "done", "evidence": "make test exit 0", "checklist_satisfied": true, + })) + if err != nil { + t.Fatalf("a refused submit must not error the turn: %v", err) + } + if !strings.Contains(result.Output, "identical to the base commit") { + t.Fatalf("refusal reason lost: %q", result.Output) + } + if !strings.Contains(result.Output, "NOT captured") { + t.Fatalf("refusal must say the tree was not captured: %q", result.Output) + } +} + +func TestSubmitDemandsEvidenceBeforeItReachesTheFreezer(t *testing.T) { + // "reason" alone is a claim. The evidence field is where the pinned command + // and its exit status go, and a submit without it is refused at validation + // so the freezer never sees an unsupported claim. + for name, input := range map[string]map[string]any{ + "no evidence": {"reason": "done", "checklist_satisfied": true}, + "blank evidence": { + "reason": "done", "evidence": " ", "checklist_satisfied": true, + }, + "no reason": {"evidence": "make test exit 0", "checklist_satisfied": true}, + } { + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + if err := validateSubmit(raw); err == nil { + t.Fatalf("%s: validation accepted %v", name, input) + } + } + raw, err := json.Marshal(map[string]any{ + "reason": "done", "evidence": "make test exit 0", "checklist_satisfied": true, + }) + if err != nil { + t.Fatal(err) + } + if err := validateSubmit(raw); err != nil { + t.Fatalf("a complete submit was rejected: %v", err) + } +} + +func TestSubmitSurvivesTheFilterAndThePerLeafClone(t *testing.T) { + // Two ways a tool silently disappears in this registry: the visibility + // filter drops it for the agent, or forContext's shallow clone loses the + // field it depends on. Both would turn every submit into the "no submission + // handler" refusal at runtime rather than at wiring time. + registry := NewWithOptions(t.TempDir(), RegistryOptions{ + SubmitFreeze: func(context.Context, Submission) (string, error) { return "ok", nil }, + }) + filtered := registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "deepseek-v4-flash", + }) + found := false + for _, definition := range filtered { + if definition.Provider.Name == "submit" { + found = true + } + } + if !found { + t.Fatal("submit was filtered away for the coder agent") + } + if registry.forContext(context.Background()).submitFreeze == nil { + t.Fatal("forContext dropped the freezer") + } +} diff --git a/internal/seniordev/tool/testsupport_test.go b/internal/seniordev/tool/testsupport_test.go new file mode 100644 index 0000000000..6fa2ea5f1c --- /dev/null +++ b/internal/seniordev/tool/testsupport_test.go @@ -0,0 +1,25 @@ +//go:build !windows + +package tool + +import ( + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" +) + +// Shared helpers for the tests in this package. + +func definitionNames(definitions []steploop.ToolDefinition) []string { + out := make([]string, 0, len(definitions)) + for _, item := range definitions { + out = append(out, item.Provider.Name) + } + return out +} +func containsName(names []string, name string) bool { + for _, item := range names { + if item == name { + return true + } + } + return false +} diff --git a/internal/seniordev/tool/tool_test.go b/internal/seniordev/tool/tool_test.go new file mode 100644 index 0000000000..c083ff5d17 --- /dev/null +++ b/internal/seniordev/tool/tool_test.go @@ -0,0 +1,423 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/session/outputoffload" +) + +type failingBashOutputSink struct{} + +func (failingBashOutputSink) WriteOutput(string, string) (string, error) { + return "", errors.New("spill failed") +} + +func TestBash(t *testing.T) { + registry := New(t.TempDir()) + + t.Run("echo roundtrip", func(t *testing.T) { + result, err := execute(t, registry, "bash", map[string]any{"command": "echo roundtrip"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "roundtrip\n" { + t.Fatalf("Output = %q", result.Output) + } + }) + + t.Run("non-zero exit", func(t *testing.T) { + result, err := execute(t, registry, "bash", map[string]any{"command": "printf failure; exit 7"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(result.Output, "failure\nexit status 7") { + t.Fatalf("Output = %q", result.Output) + } + }) + + t.Run("timeout kills process group", func(t *testing.T) { + start := time.Now() + result, err := execute(t, registry, "bash", map[string]any{ + "command": "sleep 30", + "timeout_ms": 200, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("timeout took %v", elapsed) + } + if !strings.Contains(result.Output, "command timed out after 200ms") { + t.Fatalf("Output = %q", result.Output) + } + }) + + t.Run("large output spills full content", func(t *testing.T) { + // A large bash result keeps the existing preview and makes the complete + // output recoverable by tool-call ID. + workDir := t.TempDir() + registry := New(workDir) + input, _ := json.Marshal(map[string]any{ + "command": "head -c 30001 /dev/zero | tr '\\0' x", + }) + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call-large-output", Name: "bash", Input: input, SessionID: "ses_large", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + parts := strings.Split(result.Output, "\n\nThe tool call succeeded but the output was truncated. ") + if len(parts) != 2 { + t.Fatalf("missing recovery message in %q", result.Output) + } + preview := parts[0] + if len(preview) != maxBashOutputBytes { + t.Fatalf("preview length = %d, want %d", len(preview), maxBashOutputBytes) + } + if !strings.Contains(preview, "[... 29 bytes truncated ...]") { + t.Fatalf("missing truncation marker in %q", preview) + } + if !strings.HasPrefix(preview, "xxx") || !strings.HasSuffix(preview, "xxx") { + t.Fatalf("truncation did not preserve head and tail") + } + wantPath := filepath.Join(workDir, ".senior-dev", "tool-output", "ses_large", "call-large-output.log") + if !strings.Contains(result.Output, "Full output saved to: "+wantPath+"\n") { + t.Fatalf("recovery path missing from %q", result.Output) + } + full, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("ReadFile(%s): %v", wantPath, err) + } + if string(full) != strings.Repeat("x", maxBashOutputBytes+1) { + t.Fatalf("saved output length = %d, want %d complete bytes", len(full), maxBashOutputBytes+1) + } + }) + + t.Run("large output reports spill failure", func(t *testing.T) { + // A failed spill keeps the bash preview and appends the offloader's + // could-not-save notice. + previous := bashOffloader + bashOffloader = outputoffload.Offloader{Sink: failingBashOutputSink{}} + t.Cleanup(func() { bashOffloader = previous }) + registry := New(t.TempDir()) + input, _ := json.Marshal(map[string]any{ + "command": "head -c 30001 /dev/zero | tr '\\0' x", + }) + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call-spill-failure", Name: "bash", Input: input, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Output, "full output could not be saved") { + t.Fatalf("spill failure notice missing from %q", result.Output) + } + }) + + t.Run("small output remains inline", func(t *testing.T) { + // Output at or below the cap is unchanged and creates no spill directory. + workDir := t.TempDir() + registry := New(workDir) + input := json.RawMessage(`{"command":"printf unchanged"}`) + result, err := registry.Execute(context.Background(), steploop.ToolCall{ + ID: "call-small-output", Name: "bash", Input: input, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "unchanged" { + t.Fatalf("Output = %q", result.Output) + } + _, err = os.Stat(filepath.Join(workDir, ".senior-dev", "tool-output")) + if !os.IsNotExist(err) { + t.Fatalf("spill directory exists or stat failed: %v", err) + } + }) +} + +func TestRegistryExecuteNonBashBehaviorUnchanged(t *testing.T) { + // Threading call metadata does not alter non-bash dispatch. + workDir := t.TempDir() + input := json.RawMessage(`{"filePath":"result.txt","content":"unchanged"}`) + result, err := New(workDir).Execute(context.Background(), steploop.ToolCall{ + ID: "call-write", Name: "write", Input: input, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Wrote file successfully." { + t.Fatalf("Output = %q", result.Output) + } + assertTestFile(t, workDir, "result.txt", "unchanged") +} + +func TestRead(t *testing.T) { + workDir := t.TempDir() + if err := os.WriteFile(filepath.Join(workDir, "sample.txt"), []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil { + t.Fatal(err) + } + registry := New(workDir) + + result, err := execute(t, registry, "read", map[string]any{"filePath": "sample.txt"}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "<path>" + filepath.Join(workDir, "sample.txt") + "</path>\n<type>file</type>\n<content>\n" + + "1: alpha\n2: beta\n3: gamma\n\n(End of file - total 3 lines)\n</content>" + if result.Output != want { + t.Fatalf("Output = %q, want %q", result.Output, want) + } + + result, err = execute(t, registry, "read", map[string]any{ + "filePath": "sample.txt", + "offset": 2, + "limit": 1, + }) + if err != nil { + t.Fatalf("Execute offset/limit: %v", err) + } + want = "<path>" + filepath.Join(workDir, "sample.txt") + "</path>\n<type>file</type>\n<content>\n" + + "2: beta\n\n(Showing lines 2-2 of 3. Use offset=3 to continue.)\n</content>" + if result.Output != want { + t.Fatalf("offset/limit output = %q", result.Output) + } + + _, err = execute(t, registry, "read", map[string]any{"filePath": "missing.txt"}) + if err == nil || err.Error() != "File not found: "+filepath.Join(workDir, "missing.txt") { + t.Fatalf("missing file error = %v", err) + } +} + +func TestWrite(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + result, err := execute(t, registry, "write", map[string]any{ + "filePath": "nested/deep/file.txt", + "content": "roundtrip", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Wrote file successfully." { + t.Fatalf("Output = %q", result.Output) + } + content, err := os.ReadFile(filepath.Join(workDir, "nested", "deep", "file.txt")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(content) != "roundtrip" { + t.Fatalf("content = %q", content) + } +} + +func TestEdit(t *testing.T) { + t.Run("unique replace", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "before middle after") + registry := New(workDir) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "middle", + "newString": "changed", + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Edit applied successfully." { + t.Fatalf("Output = %q", result.Output) + } + assertTestFile(t, workDir, "file.txt", "before changed after") + }) + + t.Run("not found", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "content") + registry := New(workDir) + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "missing", + "newString": "changed", + }) + if err == nil || err.Error() != "Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings." { + t.Fatalf("error = %v", err) + } + }) + + t.Run("not unique", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "same and same") + registry := New(workDir) + _, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "same", + "newString": "changed", + }) + if err == nil || err.Error() != "Found multiple matches for oldString. Provide more surrounding context to make the match unique." { + t.Fatalf("error = %v", err) + } + assertTestFile(t, workDir, "file.txt", "same and same") + }) + + t.Run("replace all", func(t *testing.T) { + workDir := t.TempDir() + writeTestFile(t, workDir, "file.txt", "same and same") + registry := New(workDir) + result, err := execute(t, registry, "edit", map[string]any{ + "filePath": "file.txt", + "oldString": "same", + "newString": "changed", + "replaceAll": true, + }) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if result.Output != "Edit applied successfully." { + t.Fatalf("Output = %q", result.Output) + } + assertTestFile(t, workDir, "file.txt", "changed and changed") + }) +} + +func TestPathsCannotEscapeWorkspace(t *testing.T) { + root := t.TempDir() + workDir := filepath.Join(root, "workspace") + if err := os.Mkdir(workDir, 0o755); err != nil { + t.Fatal(err) + } + absoluteOutside := filepath.Join(root, "outside.txt") + registry := New(workDir) + + for _, toolName := range []string{"read", "write", "edit"} { + toolName := toolName + t.Run(toolName, func(t *testing.T) { + for _, path := range []string{"../outside.txt", absoluteOutside} { + input := map[string]any{"path": path} + switch toolName { + case "read": + delete(input, "path") + input["filePath"] = path + case "write": + delete(input, "path") + input["filePath"] = path + input["content"] = "blocked" + case "edit": + delete(input, "path") + input["filePath"] = path + input["oldString"] = "old" + input["newString"] = "new" + } + _, err := execute(t, registry, toolName, input) + if err == nil || err.Error() != "path escapes workspace: "+path { + t.Fatalf("path %q error = %v", path, err) + } + } + }) + } +} + +func TestDefinitions(t *testing.T) { + definitions := New(t.TempDir()).Definitions() + if len(definitions) != 9 { + t.Fatalf("len(Definitions) = %d", len(definitions)) + } + + var names []string + for _, definition := range definitions { + names = append(names, definition.Provider.Name) + if definition.Provider.Type != "function" { + t.Errorf("%s type = %q", definition.Provider.Name, definition.Provider.Type) + } + var schema map[string]any + if err := json.Unmarshal(definition.Provider.InputSchema, &schema); err != nil { + t.Errorf("%s schema: %v", definition.Provider.Name, err) + } + if schema["type"] != "object" { + t.Errorf("%s schema type = %v", definition.Provider.Name, schema["type"]) + } + } + if want := []string{"bash", "read", "glob", "grep", "edit", "write", "webfetch", "websearch", "apply_patch"}; !reflect.DeepEqual(names, want) { + t.Fatalf("names = %v, want %v", names, want) + } +} + +func TestValidationAndUnknownTool(t *testing.T) { + definitions := New(t.TempDir()).Definitions() + if err := definitions[0].Validate(json.RawMessage(`{"timeout_ms":200}`)); err == nil || !strings.Contains(err.Error(), `missing required field "command"`) { + t.Fatalf("missing field error = %v", err) + } + if err := definitions[0].Validate(json.RawMessage(`{"command":7}`)); err == nil || !strings.Contains(err.Error(), "cannot unmarshal number") { + t.Fatalf("wrong type error = %v", err) + } + if err := definitions[0].Validate(json.RawMessage(`{"command":null}`)); err == nil || !strings.Contains(err.Error(), "must not be null") { + t.Fatalf("null field error = %v", err) + } + if err := definitions[0].Validate(json.RawMessage(`{"command":"true","extra":1}`)); err == nil || !strings.Contains(err.Error(), `unknown field "extra"`) { + t.Fatalf("unknown field error = %v", err) + } + + _, err := New(t.TempDir()).Execute(context.Background(), steploop.ToolCall{Name: "missing", Input: json.RawMessage(`{}`)}) + if err == nil || err.Error() != "unknown tool: missing" { + t.Fatalf("unknown tool error = %v", err) + } +} + +func execute(t *testing.T, registry *Registry, name string, input any) (steploop.ToolResult, error) { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + return registry.Execute(context.Background(), steploop.ToolCall{Name: name, Input: raw}) +} + +func writeTestFile(t *testing.T, workDir, path, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(workDir, path), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertTestFile(t *testing.T, workDir, path, want string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(workDir, path)) + if err != nil { + t.Fatal(err) + } + if string(content) != want { + t.Fatalf("content = %q, want %q", content, want) + } +} + +func TestExecuteHonorsCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := New(t.TempDir()).Execute(ctx, steploop.ToolCall{ + Name: "bash", + Input: json.RawMessage(`{"command":"sleep 30"}`), + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v", err) + } +} + +// sameFolder answers whether two spellings name one folder once their +// symlinks are resolved. A shell reports the folder it runs in as the kernel +// resolved it, and on macOS every temporary folder is reached through one +// (/var/folders is /private/var/folders), so a test that compared spellings +// failed there while the command ran exactly where it should. +func sameFolder(got, want string) bool { + resolvedGot, errGot := filepath.EvalSymlinks(got) + resolvedWant, errWant := filepath.EvalSymlinks(want) + return errGot == nil && errWant == nil && resolvedGot == resolvedWant +} diff --git a/internal/seniordev/tool/web_common.go b/internal/seniordev/tool/web_common.go new file mode 100644 index 0000000000..58682f0fbb --- /dev/null +++ b/internal/seniordev/tool/web_common.go @@ -0,0 +1,316 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/id" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +const ( + webOutputMaxLines = 2000 + webOutputMaxBytes = 50 * 1024 + webOutputRetention = 7 * 24 * time.Hour +) + +type webExecutionOptions struct { + client *http.Client + exaURL string + parallelURL string + outputDir string + version string + resolveHost func(context.Context, string) ([]net.IP, error) +} + +// WithWebHostResolver injects redirect-boundary DNS resolution for hermetic +// tests. Production uses net.DefaultResolver. +func WithWebHostResolver( + ctx context.Context, resolver func(context.Context, string) ([]net.IP, error), +) context.Context { + options := webOptions(ctx) + options.resolveHost = resolver + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +type webExecutionOptionsKey struct{} + +// WithWebHTTPClient injects the HTTP seam used by webfetch and websearch. +// Production calls use http.DefaultClient, which follows redirects. +func WithWebHTTPClient(ctx context.Context, client *http.Client) context.Context { + options := webOptions(ctx) + options.client = client + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +// WithWebSearchEndpoints redirects the provider MCP endpoints. It exists so +// tests can exercise the complete registry path without external network I/O. +func WithWebSearchEndpoints(ctx context.Context, exaURL, parallelURL string) context.Context { + options := webOptions(ctx) + options.exaURL = exaURL + options.parallelURL = parallelURL + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +// WithWebOutputDir redirects the shared 50 KiB/2000-line truncation spill. +func WithWebOutputDir(ctx context.Context, directory string) context.Context { + options := webOptions(ctx) + options.outputDir = directory + return context.WithValue(ctx, webExecutionOptionsKey{}, options) +} + +func webOptions(ctx context.Context) webExecutionOptions { + options, _ := ctx.Value(webExecutionOptionsKey{}).(webExecutionOptions) + return options +} + +func webClient(ctx context.Context) *http.Client { + client := http.DefaultClient + if injected := webOptions(ctx).client; injected != nil { + client = injected + } + // netpolicy is enforced at this single chokepoint so every present and + // future in-process tool HTTP call inherits it, including each redirect + // hop (redirects re-enter the transport). The client is copied, never + // mutated: http.DefaultClient is shared process state, and injected test + // clients belong to their owners. + policy := netpolicy.Current() + if !policy.Restricted() { + return client + } + wrapped := *client + wrapped.Transport = policy.Transport(client.Transport) + return &wrapped +} + +func webHostResolver(ctx context.Context) func(context.Context, string) ([]net.IP, error) { + if resolver := webOptions(ctx).resolveHost; resolver != nil { + return resolver + } + return func(ctx context.Context, host string) ([]net.IP, error) { + return net.DefaultResolver.LookupIP(ctx, "ip", host) + } +} + +type redirectBlockedError struct{ destination string } + +func (err *redirectBlockedError) Error() string { + return "redirect destination blocked: " + err.destination +} + +func redirectSafeWebClient(ctx context.Context, base *http.Client, originalURL string) *http.Client { + client := *base + resolve := webHostResolver(ctx) + var originalOnce sync.Once + var originalRestricted bool + originalHost := "" + if parsed, err := url.Parse(originalURL); err == nil { + originalHost = parsed.Hostname() + } + client.CheckRedirect = func(request *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + originalOnce.Do(func() { + originalRestricted = hostResolvesRestricted(request.Context(), resolve, originalHost) + }) + if !originalRestricted && hostResolvesRestricted(request.Context(), resolve, request.URL.Hostname()) { + return &redirectBlockedError{destination: request.URL.String()} + } + return nil + } + return &client +} + +func hostResolvesRestricted( + ctx context.Context, + resolve func(context.Context, string) ([]net.IP, error), + host string, +) bool { + if host == "" { + return false + } + addresses := []net.IP(nil) + if literal := net.ParseIP(host); literal != nil { + addresses = []net.IP{literal} + } else { + resolved, err := resolve(ctx, host) + if err != nil { + return false + } + addresses = resolved + } + metadata4 := net.ParseIP("169.254.169.254") + metadata6 := net.ParseIP("fd00:ec2::254") + for _, address := range addresses { + if address.IsLoopback() || address.IsPrivate() || address.IsLinkLocalUnicast() || + address.IsLinkLocalMulticast() || address.Equal(metadata4) || address.Equal(metadata6) { + return true + } + } + return false +} + +func webOutputDirectory(ctx context.Context) string { + if directory := webOptions(ctx).outputDir; directory != "" { + return directory + } + if directory := os.Getenv("XDG_DATA_HOME"); directory != "" { + return filepath.Join(directory, "senior-dev", "tool-output") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", "senior-dev", "tool-output") +} + +type webTruncationMetadata struct { + Truncated bool `json:"truncated"` + OutputPath string `json:"outputPath,omitempty"` +} + +func (r *Registry) truncateWebOutput( + ctx context.Context, + call steploop.ToolCall, + output string, +) (string, webTruncationMetadata, error) { + directory := webOutputDirectory(ctx) + // A seven-day sweep of old spill files runs on every truncation pass; + // cleanup failures are non-fatal. + cleanupWebOutput(directory, time.Now()) + // Output beyond the 2000-line/50-KiB head preview is spilled to a file; the + // preview carries a marker, the path, and matching metadata. + maxLines, maxBytes := r.webOutputLimits() + lines := strings.Split(output, "\n") + if len(lines) <= maxLines && len([]byte(output)) <= maxBytes { + return output, webTruncationMetadata{Truncated: false}, nil + } + + preview := make([]string, 0, min(len(lines), maxLines)) + bytesUsed := 0 + hitBytes := false + for index := 0; index < len(lines) && index < maxLines; index++ { + size := len([]byte(lines[index])) + if index > 0 { + size++ + } + if bytesUsed+size > maxBytes { + hitBytes = true + break + } + preview = append(preview, lines[index]) + bytesUsed += size + } + + removed := len(lines) - len(preview) + unit := "lines" + if hitBytes { + removed = len([]byte(output)) - bytesUsed + unit = "bytes" + } + if err := os.MkdirAll(directory, 0o755); err != nil { + return "", webTruncationMetadata{}, err + } + name, err := id.Ascending("tool") + if err != nil { + return "", webTruncationMetadata{}, err + } + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte(output), 0o644); err != nil { + return "", webTruncationMetadata{}, err + } + + hint := "The tool call succeeded but the output was truncated. Full output saved to: " + path + + "\nUse Grep to search the full content or Read with offset/limit to view specific sections." + content := fmt.Sprintf("%s\n\n...%d %s truncated...\n\n%s", strings.Join(preview, "\n"), removed, unit, hint) + return content, webTruncationMetadata{Truncated: true, OutputPath: path}, nil +} + +func cleanupWebOutput(directory string, now time.Time) { + entries, err := os.ReadDir(directory) + if err != nil { + return + } + cutoffID, err := id.Create("tool", id.AscendingDirection, now.Add(-webOutputRetention).UnixMilli()) + if err != nil { + return + } + cutoff, err := id.Timestamp(cutoffID) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasPrefix(entry.Name(), "tool_") { + continue + } + timestamp, err := id.Timestamp(entry.Name()) + if err != nil || timestamp >= cutoff { + continue + } + _ = os.Remove(filepath.Join(directory, entry.Name())) + } +} + +func (r *Registry) webOutputLimits() (int, int) { + maxLines, maxBytes := webOutputMaxLines, webOutputMaxBytes + settings, err := r.settings() + if err != nil { + return maxLines, maxBytes + } + value, ok := settings["tool_output"].(map[string]any) + if !ok { + return maxLines, maxBytes + } + if number, ok := value["max_lines"].(float64); ok { + maxLines = int(number) + } + if number, ok := value["max_bytes"].(float64); ok { + maxBytes = int(number) + } + return maxLines, maxBytes +} + +func statusCodeError(method, rawURL string, status int) error { + return fmt.Errorf("StatusCode error (%d %s %s)", status, method, rawURL) +} + +func transportError(method, rawURL string) error { + return fmt.Errorf("Transport error (%s %s)", method, rawURL) +} + +func decodeWebInput(raw json.RawMessage, destination any, known []string, required ...string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return fmt.Errorf("input must be a JSON object: %w", err) + } + if fields == nil { + return fmt.Errorf("input must be a JSON object") + } + for _, name := range required { + if _, ok := fields[name]; !ok { + return fmt.Errorf("missing required field %q", name) + } + } + for _, name := range known { + if value, ok := fields[name]; ok && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return fmt.Errorf("field %q must not be null", name) + } + } + // Unknown properties are ignored rather than rejected for the web tools. + if err := json.Unmarshal(raw, destination); err != nil { + return fmt.Errorf("invalid input: %w", err) + } + return nil +} diff --git a/internal/seniordev/tool/web_descriptions.go b/internal/seniordev/tool/web_descriptions.go new file mode 100644 index 0000000000..22b12aa52f --- /dev/null +++ b/internal/seniordev/tool/web_descriptions.go @@ -0,0 +1,11 @@ +//go:build !windows + +package tool + +import _ "embed" + +//go:embed webfetch.txt +var webFetchDescription string + +//go:embed websearch.txt +var webSearchDescriptionTemplate string diff --git a/internal/seniordev/tool/webfetch.go b/internal/seniordev/tool/webfetch.go new file mode 100644 index 0000000000..adae12940c --- /dev/null +++ b/internal/seniordev/tool/webfetch.go @@ -0,0 +1,244 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/msgmodel" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +const ( + webFetchMaxResponseSize = 5 * 1024 * 1024 + webFetchDefaultTimeout = 30 * time.Second + webFetchMaxTimeout = 120 * time.Second + webFetchUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" +) + +const webFetchSchema = `{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "type":"object", + "properties":{ + "url":{"type":"string","description":"The URL to fetch content from"}, + "format":{"type":"string","enum":["text","markdown","html"],"default":"markdown","description":"The format to return the content in (text, markdown, or html). Defaults to markdown."}, + "timeout":{"type":"number","description":"Optional timeout in seconds (max 120)"} + }, + "required":["url"] +}` + +type webFetchInput struct { + URL string `json:"url"` + Format string `json:"format,omitempty"` + Timeout *float64 `json:"timeout,omitempty"` +} + +type webFetchMetadata struct { + Truncated bool `json:"truncated"` + OutputPath string `json:"outputPath,omitempty"` +} + +func validateWebFetch(raw json.RawMessage) error { + var input webFetchInput + if err := decodeWebInput(raw, &input, []string{"url", "format", "timeout"}, "url"); err != nil { + return err + } + if input.Format != "" && input.Format != "text" && input.Format != "markdown" && input.Format != "html" { + return fmt.Errorf("format must be one of text, markdown, or html") + } + return nil +} + +func (r *Registry) executeWebFetch(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input webFetchInput + if err := decodeWebInput(call.Input, &input, []string{"url", "format", "timeout"}, "url"); err != nil { + return steploop.ToolResult{}, err + } + if input.Format == "" { + input.Format = "markdown" + } + // Only the literal http:// and https:// prefixes are accepted; http URLs + // are fetched as given, not upgraded. + if !strings.HasPrefix(input.URL, "http://") && !strings.HasPrefix(input.URL, "https://") { + return steploop.ToolResult{}, fmt.Errorf("URL must start with http:// or https://") + } + // Refuse before the permission ask so the user is never prompted for a + // request that cannot proceed. The transport wrap in webClient repeats + // the refusal on every hop as a fast-fail backstop. + if policy := netpolicy.Current(); policy.Restricted() { + host := input.URL + if parsed, parseErr := url.Parse(input.URL); parseErr == nil { + host = parsed.Host + } + return steploop.ToolResult{}, policy.HostError(host) + } + metadata := map[string]any{"url": input.URL, "format": input.Format} + if input.Timeout != nil { + metadata["timeout"] = *input.Timeout + } + if err := r.ask(ctx, call, "webfetch", []string{input.URL}, metadata); err != nil { + return steploop.ToolResult{}, err + } + + timeout := webFetchDefaultTimeout + if input.Timeout != nil { + // Cap in floating-point space before converting to time.Duration so a + // huge JSON number saturates at the maximum instead of overflowing the + // Go duration. + if *input.Timeout > webFetchMaxTimeout.Seconds() { + timeout = webFetchMaxTimeout + } else { + timeout = time.Duration(*input.Timeout * float64(time.Second)) + } + } + requestCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + response, err := executeWebFetchRequest(requestCtx, webClient(ctx), input) + if err != nil { + if requestCtx.Err() == context.DeadlineExceeded { + return steploop.ToolResult{}, fmt.Errorf("Request timed out") + } + return steploop.ToolResult{}, err + } + defer response.Body.Close() + + // A response over 5 MiB is rejected both from a declared Content-Length + // and after reading, before any content conversion. + if declared := response.Header.Get("Content-Length"); declared != "" { + if size, parseErr := strconv.ParseInt(declared, 10, 64); parseErr == nil && size > webFetchMaxResponseSize { + return steploop.ToolResult{}, fmt.Errorf("Response too large (exceeds 5MB limit)") + } + } + body, err := io.ReadAll(io.LimitReader(response.Body, webFetchMaxResponseSize+1)) + if err != nil { + return steploop.ToolResult{}, err + } + if len(body) > webFetchMaxResponseSize { + return steploop.ToolResult{}, fmt.Errorf("Response too large (exceeds 5MB limit)") + } + + contentType := response.Header.Get("Content-Type") + mime := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + title := input.URL + " (" + contentType + ")" + // Images (every image/* type except SVG and fastbidsheet) are returned as + // a data-URL attachment. + if isWebFetchImage(mime) { + attachments := []msgmodel.FilePart{{ + Type: "file", Mime: mime, + URL: "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(body), + }} + return steploop.ToolResult{ + Title: title, Output: "Image fetched successfully", + Metadata: rawMetadata(webFetchMetadata{Truncated: false}), Attachments: &attachments, + }, nil + } + + // Malformed UTF-8 is replaced rather than rejected, and no MIME type other + // than images is treated specially. + content := strings.ToValidUTF8(string(body), "\uFFFD") + if strings.Contains(contentType, "text/html") { + switch input.Format { + case "markdown": + content, err = convertWebHTMLToMarkdown(content) + case "text": + content, err = extractWebHTMLText(content) + } + if err != nil { + return steploop.ToolResult{}, err + } + } + output, truncation, err := r.truncateWebOutput(ctx, call, content) + if err != nil { + return steploop.ToolResult{}, err + } + return steploop.ToolResult{ + Title: title, Output: output, + Metadata: rawMetadata(webFetchMetadata{ + Truncated: truncation.Truncated, OutputPath: truncation.OutputPath, + }), + }, nil +} + +func executeWebFetchRequest(ctx context.Context, client *http.Client, input webFetchInput) (*http.Response, error) { + // A direct GET with no robots.txt check. Redirects follow the client's + // normal behaviour, bounded by redirectSafeWebClient. + headers := map[string]string{ + "User-Agent": webFetchUserAgent, + "Accept": acceptHeader(input.Format), + "Accept-Language": "en-US,en;q=0.9", + } + do := func(userAgent string) (*http.Response, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, input.URL, nil) + if err != nil { + return nil, fmt.Errorf("InvalidUrl error (GET %s)", input.URL) + } + for name, value := range headers { + request.Header.Set(name, value) + } + request.Header.Set("User-Agent", userAgent) + response, err := redirectSafeWebClient(ctx, client, input.URL).Do(request) + if err != nil { + // A policy refusal on a redirect hop comes back wrapped in + // *url.Error; surface its no-retry framing instead of collapsing + // it into a retryable-looking transport failure. + var policyBlocked *netpolicy.BlockedError + if errors.As(err, &policyBlocked) { + return nil, policyBlocked + } + var blocked *redirectBlockedError + if errors.As(err, &blocked) { + return nil, transportError(http.MethodGet, blocked.destination) + } + return nil, transportError(http.MethodGet, input.URL) + } + return response, nil + } + + response, err := do(headers["User-Agent"]) + if err != nil { + return nil, err + } + // Only Cloudflare's explicit 403 challenge is retried, with an honest + // senior-dev User-Agent. Other non-2xx responses are not retried. + if response.StatusCode == http.StatusForbidden && response.Header.Get("cf-mitigated") == "challenge" { + response.Body.Close() + response, err = do("senior-dev") + if err != nil { + return nil, err + } + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + response.Body.Close() + return nil, statusCodeError(http.MethodGet, input.URL, response.StatusCode) + } + return response, nil +} + +func acceptHeader(format string) string { + switch format { + case "markdown": + return "text/markdown;q=1.0, text/x-markdown;q=0.9, text/plain;q=0.8, text/html;q=0.7, */*;q=0.1" + case "text": + return "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1" + case "html": + return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1" + default: + return "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" + } +} + +func isWebFetchImage(mime string) bool { + return strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" && mime != "image/vnd.fastbidsheet" +} diff --git a/internal/seniordev/tool/webfetch.txt b/internal/seniordev/tool/webfetch.txt new file mode 100644 index 0000000000..532a213f18 --- /dev/null +++ b/internal/seniordev/tool/webfetch.txt @@ -0,0 +1,12 @@ +- Fetches content from a specified URL +- Takes a URL and optional format as input +- Fetches the URL content, converts to requested format (markdown by default) +- Returns the content in the specified format +- Use this tool when you need to retrieve and analyze web content + +Usage notes: + - IMPORTANT: if another tool is present that offers better web fetching capabilities, is more targeted to the task, or has fewer restrictions, prefer using that tool instead of this one. + - The URL must be a fully-formed valid URL starting with http:// or https:// + - Format options: "markdown" (default), "text", or "html" + - This tool is read-only and does not modify any files + - Results may be summarized if the content is very large diff --git a/internal/seniordev/tool/webfetch_html.go b/internal/seniordev/tool/webfetch_html.go new file mode 100644 index 0000000000..a2a57afaa1 --- /dev/null +++ b/internal/seniordev/tool/webfetch_html.go @@ -0,0 +1,216 @@ +//go:build !windows + +package tool + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "golang.org/x/net/html" +) + +var webHTMLWhitespace = regexp.MustCompile(`\s+`) +var ( + webMarkdownLeadingEquals = regexp.MustCompile(`^(=+)`) + webMarkdownLeadingHash = regexp.MustCompile(`^(#{1,6}) `) + webMarkdownLeadingNumber = regexp.MustCompile(`^(\d+)\. `) +) + +func extractWebHTMLText(source string) (string, error) { + document, err := html.Parse(strings.NewReader(source)) + if err != nil { + return "", err + } + var output strings.Builder + var walk func(*html.Node, bool) + walk = func(node *html.Node, skip bool) { + if node.Type == html.ElementNode { + switch node.Data { + case "script", "style", "noscript", "iframe", "object", "embed": + skip = true + } + } + if node.Type == html.TextNode && !skip { + output.WriteString(node.Data) + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + walk(child, skip) + } + } + walk(document, false) + // Text chunks are concatenated with only a final trim; no separators are + // invented between elements. + return strings.TrimSpace(output.String()), nil +} + +func convertWebHTMLToMarkdown(source string) (string, error) { + document, err := html.Parse(strings.NewReader(source)) + if err != nil { + return "", err + } + markdown := renderWebMarkdown(document, markdownRenderState{}) + markdown = strings.ReplaceAll(markdown, "\u00a0", " ") + markdown = regexp.MustCompile(`[ \t]+\n`).ReplaceAllString(markdown, "\n") + markdown = regexp.MustCompile(`\n{3,}`).ReplaceAllString(markdown, "\n\n") + return strings.TrimSpace(markdown), nil +} + +type markdownRenderState struct { + pre bool + code bool + listDepth int +} + +func renderWebMarkdown(node *html.Node, state markdownRenderState) string { + if node.Type == html.TextNode { + if state.pre { + return node.Data + } + text := webHTMLWhitespace.ReplaceAllString(node.Data, " ") + if state.code { + return text + } + return escapeWebMarkdownText(text) + } + if node.Type != html.ElementNode && node.Type != html.DocumentNode { + return "" + } + if node.Type == html.ElementNode { + switch node.Data { + case "script", "style", "meta", "link": + // These four elements are dropped outright (rather than all head + // content). + return "" + } + } + + childState := state + if node.Type == html.ElementNode && node.Data == "pre" { + childState.pre = true + } + if node.Type == html.ElementNode && node.Data == "code" { + childState.code = true + } + if node.Type == html.ElementNode && (node.Data == "ul" || node.Data == "ol") { + childState.listDepth++ + } + var content strings.Builder + for child := node.FirstChild; child != nil; child = child.NextSibling { + content.WriteString(renderWebMarkdown(child, childState)) + } + inner := content.String() + if node.Type != html.ElementNode { + return inner + } + + switch node.Data { + case "h1", "h2", "h3", "h4", "h5", "h6": + level, _ := strconv.Atoi(node.Data[1:]) + return "\n\n" + strings.Repeat("#", level) + " " + strings.TrimSpace(inner) + "\n\n" + case "p", "div", "section", "article", "header", "footer", "main", "aside", "nav", "figure", "figcaption": + if strings.TrimSpace(inner) == "" { + return "" + } + return "\n\n" + strings.TrimSpace(inner) + "\n\n" + case "br": + return " \n" + case "hr": + return "\n\n---\n\n" + case "strong", "b": + return "**" + strings.TrimSpace(inner) + "**" + case "em", "i": + return "*" + strings.TrimSpace(inner) + "*" + case "del", "s", "strike": + return "~~" + strings.TrimSpace(inner) + "~~" + case "code": + if state.pre { + return inner + } + return "`" + strings.TrimSpace(inner) + "`" + case "pre": + return "\n\n```\n" + strings.Trim(inner, "\n") + "\n```\n\n" + case "a": + href := webHTMLAttribute(node, "href") + if href == "" { + return inner + } + title := webHTMLAttribute(node, "title") + if title != "" { + href += ` "` + title + `"` + } + return "[" + strings.TrimSpace(inner) + "](" + href + ")" + case "img": + source := webHTMLAttribute(node, "src") + if source == "" { + return "" + } + title := webHTMLAttribute(node, "title") + if title != "" { + source += ` "` + title + `"` + } + return "![" + webHTMLAttribute(node, "alt") + "](" + source + ")" + case "blockquote": + value := strings.TrimSpace(inner) + return "\n\n> " + strings.ReplaceAll(value, "\n", "\n> ") + "\n\n" + case "ul", "ol": + return "\n\n" + strings.Trim(inner, "\n") + "\n\n" + case "li": + prefix := "- " + if node.Parent != nil && node.Parent.Data == "ol" { + index := 1 + for sibling := node.PrevSibling; sibling != nil; sibling = sibling.PrevSibling { + if sibling.Type == html.ElementNode && sibling.Data == "li" { + index++ + } + } + prefix = fmt.Sprintf("%d. ", index) + } + indent := strings.Repeat(" ", max(0, state.listDepth-1)) + value := strings.TrimSpace(inner) + value = strings.ReplaceAll(value, "\n", "\n"+indent+" ") + return "\n" + indent + prefix + value + case "table", "thead", "tbody", "tfoot", "tr": + return "\n" + strings.TrimSpace(inner) + "\n" + case "th", "td": + return strings.TrimSpace(inner) + "\t" + default: + return inner + } +} + +// escapeWebMarkdownText is Turndown 7.2.0's ordered escape list. Applying it +// only to ordinary text nodes keeps markup emitted by element rules intact. +func escapeWebMarkdownText(text string) string { + text = strings.ReplaceAll(text, `\`, `\\`) + text = strings.ReplaceAll(text, `*`, `\*`) + if strings.HasPrefix(text, "-") { + text = `\` + text + } + if strings.HasPrefix(text, "+ ") { + text = `\` + text + } + text = webMarkdownLeadingEquals.ReplaceAllString(text, `\$1`) + text = webMarkdownLeadingHash.ReplaceAllString(text, `\$1 `) + text = strings.ReplaceAll(text, "`", "\\`") + if strings.HasPrefix(text, "~~~") { + text = `\` + text + } + text = strings.ReplaceAll(text, "[", `\[`) + text = strings.ReplaceAll(text, "]", `\]`) + if strings.HasPrefix(text, ">") { + text = `\` + text + } + text = strings.ReplaceAll(text, "_", `\_`) + return webMarkdownLeadingNumber.ReplaceAllString(text, `$1\. `) +} + +func webHTMLAttribute(node *html.Node, name string) string { + for _, attribute := range node.Attr { + if attribute.Key == name { + return attribute.Val + } + } + return "" +} diff --git a/internal/seniordev/tool/webfetch_test.go b/internal/seniordev/tool/webfetch_test.go new file mode 100644 index 0000000000..51b30eeb66 --- /dev/null +++ b/internal/seniordev/tool/webfetch_test.go @@ -0,0 +1,373 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/id" +) + +type webRoundTripFunc func(*http.Request) (*http.Response, error) + +func (function webRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return function(request) +} + +func newLocalWebServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("sandbox blocks loopback listeners: %v", err) + } + server := &httptest.Server{ + Listener: listener, + Config: &http.Server{Handler: handler}, + } + server.Start() + t.Cleanup(server.Close) + return server +} + +func executeWebTest( + t *testing.T, + registry *Registry, + ctx context.Context, + name string, + input map[string]any, +) (steploop.ToolResult, error) { + t.Helper() + raw, err := json.Marshal(input) + if err != nil { + t.Fatal(err) + } + return registry.Execute(ctx, steploop.ToolCall{ + ID: "call-web", Name: name, Input: raw, SessionID: "ses-web", Agent: "coder", + ModelID: "fixture/model", + }) +} + +func TestWebFetchHTMLFullExecution(t *testing.T) { + html := `<!doctype html><html><head><title>T

Hello

plain bold & link.

  • one
  • two
` + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Accept") != acceptHeader("markdown") { + t.Errorf("Accept = %q", request.Header.Get("Accept")) + } + if request.Header.Get("User-Agent") != webFetchUserAgent { + t.Errorf("User-Agent = %q", request.Header.Get("User-Agent")) + } + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(writer, html) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{"url": server.URL}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + want := "T\n\n# Hello\n\nplain **bold** & [link](/x).\n\n- one\n- two" + if result.Output != want { + t.Fatalf("Output = %q, want %q", result.Output, want) + } + if result.Title != server.URL+" (text/html; charset=utf-8)" { + t.Fatalf("Title = %q", result.Title) + } + if string(result.Metadata) != `{"truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestWebFetchHTMLConversionFixture(t *testing.T) { + source := `T

Hello

plain bold & link.

  • one
  • two
` + want := "T\n\n# Hello\n\nplain **bold** & [link](/x).\n\n- one\n- two" + got, err := convertWebHTMLToMarkdown(source) + if err != nil || got != want { + t.Fatalf("conversion = (%q, %v), want %q", got, err, want) + } +} + +func TestWebFetchHTMLMarkdownEscapesTurndownPunctuation(t *testing.T) { + tests := []struct { + html string + want string + }{ + {`

# not heading

`, `\# not heading`}, + {`

1. not list

`, `1\. not list`}, + {`

a_b*c

`, `a\_b\*c`}, + {"

`literal`

", `\` + "`literal\\`"}, + {`

[not a link]

`, `\[not a link\]`}, + {`

> not a quote

`, `\> not a quote`}, + } + for _, test := range tests { + got, err := convertWebHTMLToMarkdown(test.html) + if err != nil || got != test.want { + t.Errorf("convert %q = (%q, %v), want %q", test.html, got, err, test.want) + } + } +} + +func TestWebFetchHTMLTextFixture(t *testing.T) { + got, err := extractWebHTMLText(`

Hello world

tail
`) + if err != nil || got != "Hello worldtail" { + t.Fatalf("text = (%q, %v)", got, err) + } +} + +func TestWebFetchTruncatesToolOutput(t *testing.T) { + full := strings.Repeat("x", webOutputMaxBytes+1) + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(writer, full) + })) + spill := t.TempDir() + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebOutputDir(ctx, spill) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": server.URL, "format": "text", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Output, "...51201 bytes truncated...") || + !strings.Contains(result.Output, "The tool call succeeded but the output was truncated.") { + t.Fatalf("Output = %q", result.Output) + } + var metadata webFetchMetadata + if err := json.Unmarshal(result.Metadata, &metadata); err != nil { + t.Fatal(err) + } + if !metadata.Truncated || metadata.OutputPath == "" { + t.Fatalf("Metadata = %s", result.Metadata) + } + saved, err := os.ReadFile(metadata.OutputPath) + if err != nil { + t.Fatal(err) + } + if string(saved) != full { + t.Fatalf("saved output length = %d", len(saved)) + } +} + +func TestWebFetchTruncatesWithoutSocket(t *testing.T) { + full := strings.Repeat("x", webOutputMaxBytes+1) + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader(full)), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": "https://fixture.invalid/large", "format": "text", + }) + if err != nil || !strings.Contains(result.Output, "...51201 bytes truncated...") { + t.Fatalf("result = %#v, error = %v", result, err) + } +} + +func TestWebOutputSweepExpiresSevenDayOldSpills(t *testing.T) { + directory := t.TempDir() + now := time.Now() + oldID, err := id.Create("tool", id.AscendingDirection, now.Add(-8*24*time.Hour).UnixMilli()) + if err != nil { + t.Fatal(err) + } + recentID, err := id.Create("tool", id.AscendingDirection, now.Add(-6*24*time.Hour).UnixMilli()) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{oldID, recentID, "unrelated"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("spill"), 0o644); err != nil { + t.Fatal(err) + } + } + registry := New(t.TempDir()) + if _, _, err := registry.truncateWebOutput(WithWebOutputDir(context.Background(), directory), steploop.ToolCall{}, "short"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(directory, oldID)); !os.IsNotExist(err) { + t.Fatalf("expired spill still exists: %v", err) + } + for _, name := range []string{recentID, "unrelated"} { + if _, err := os.Stat(filepath.Join(directory, name)); err != nil { + t.Fatalf("retained file %s: %v", name, err) + } + } +} + +func TestWebFetchResponseAndFailure(t *testing.T) { + registry := New(t.TempDir()) + t.Run("invalid scheme", func(t *testing.T) { + _, err := executeWebTest(t, registry, context.Background(), "webfetch", map[string]any{"url": "ftp://example.com"}) + if err == nil || err.Error() != "URL must start with http:// or https://" { + t.Fatalf("error = %v", err) + } + }) + t.Run("invalid URL", func(t *testing.T) { + _, err := executeWebTest(t, registry, context.Background(), "webfetch", map[string]any{"url": "http://["}) + if err == nil || err.Error() != "InvalidUrl error (GET http://[)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("connection refused shape", func(t *testing.T) { + client := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })} + ctx := WithWebHTTPClient(context.Background(), client) + _, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": "http://127.0.0.1:1/x"}) + if err == nil || err.Error() != "Transport error (GET http://127.0.0.1:1/x)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("non-2xx", func(t *testing.T) { + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusTeapot) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + _, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": server.URL}) + want := "StatusCode error (418 GET " + server.URL + ")" + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) + t.Run("unsupported MIME is decoded", func(t *testing.T) { + // There is no unsupported-content-type rejection: all non-image bodies + // are decoded as text. + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.WriteString(writer, "opaque") + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + result, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": server.URL}) + if err != nil || result.Output != "opaque" { + t.Fatalf("result = %#v, error = %v", result, err) + } + }) + t.Run("declared over 5 MiB", func(t *testing.T) { + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Length", "5242881") + _, _ = io.WriteString(writer, "short") + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + _, err := executeWebTest(t, registry, ctx, "webfetch", map[string]any{"url": server.URL}) + if err == nil || err.Error() != "Response too large (exceeds 5MB limit)" { + t.Fatalf("error = %v", err) + } + }) +} + +func TestWebFetchFailuresWithoutSocket(t *testing.T) { + executeResponse := func(status int, headers http.Header, body string) (steploop.ToolResult, error) { + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, Header: headers, + Body: io.NopCloser(strings.NewReader(body)), Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebOutputDir(ctx, t.TempDir()) + return executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": "https://fixture.invalid/value", + }) + } + + _, err := executeResponse(http.StatusBadGateway, http.Header{}, "") + if err == nil || err.Error() != "StatusCode error (502 GET https://fixture.invalid/value)" { + t.Fatalf("status error = %v", err) + } + result, err := executeResponse(http.StatusOK, http.Header{ + "Content-Type": []string{"application/octet-stream"}, + }, "opaque") + if err != nil || result.Output != "opaque" { + t.Fatalf("unsupported MIME result = %#v, error = %v", result, err) + } + _, err = executeResponse(http.StatusOK, http.Header{ + "Content-Length": []string{"5242881"}, + }, "") + if err == nil || err.Error() != "Response too large (exceeds 5MB limit)" { + t.Fatalf("size error = %v", err) + } +} + +func TestWebFetchRedirectBoundaryWithoutSocket(t *testing.T) { + resolver := func(_ context.Context, host string) ([]net.IP, error) { + addresses := map[string]string{ + "public-origin.test": "203.0.113.10", + "public-next.test": "198.51.100.20", + "public-final.test": "192.0.2.30", + "private-origin.test": "10.0.0.10", + "private-next.test": "127.0.0.2", + } + return []net.IP{net.ParseIP(addresses[host])}, nil + } + redirectClient := func(routes map[string]string) *http.Client { + return &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if destination := routes[request.URL.String()]; destination != "" { + return &http.Response{ + StatusCode: http.StatusFound, + Header: http.Header{"Location": []string{destination}}, + Body: io.NopCloser(strings.NewReader("")), + Request: request, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/plain"}}, + Body: io.NopCloser(strings.NewReader("redirected")), + Request: request, + }, nil + })} + } + execute := func(origin string, routes map[string]string) (steploop.ToolResult, error) { + ctx := WithWebHTTPClient(context.Background(), redirectClient(routes)) + ctx = WithWebHostResolver(ctx, resolver) + ctx = WithWebOutputDir(ctx, t.TempDir()) + return executeWebTest(t, New(t.TempDir()), ctx, "webfetch", map[string]any{ + "url": origin, "format": "text", + }) + } + + publicOrigin := "https://public-origin.test/start" + publicNext := "https://public-next.test/next" + publicFinal := "https://public-final.test/final" + result, err := execute(publicOrigin, map[string]string{ + publicOrigin: publicNext, + publicNext: publicFinal, + }) + if err != nil || result.Output != "redirected" { + t.Fatalf("public redirect chain = (%#v, %v)", result, err) + } + + for _, destination := range []string{ + "http://127.0.0.1/private", + "http://169.254.169.254/latest/meta-data", + } { + _, err := execute(publicOrigin, map[string]string{publicOrigin: destination}) + want := "Transport error (GET " + destination + ")" + if err == nil || err.Error() != want { + t.Errorf("redirect to %s error = %v, want %q", destination, err, want) + } + } + + privateOrigin := "http://private-origin.test/start" + privateNext := "http://private-next.test/inside" + result, err = execute(privateOrigin, map[string]string{privateOrigin: privateNext}) + if err != nil || result.Output != "redirected" { + t.Fatalf("private-origin redirect = (%#v, %v)", result, err) + } +} diff --git a/internal/seniordev/tool/websearch.go b/internal/seniordev/tool/websearch.go new file mode 100644 index 0000000000..0c94b10400 --- /dev/null +++ b/internal/seniordev/tool/websearch.go @@ -0,0 +1,390 @@ +//go:build !windows + +package tool + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + "unicode/utf16" + + "github.com/Agent-Field/codeaf/internal/seniordev/config" + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + "github.com/Agent-Field/codeaf/internal/seniordev/netpolicy" +) + +const ( + defaultExaWebSearchURL = "https://mcp.exa.ai/mcp" + defaultParallelWebSearchURL = "https://search.parallel.ai/mcp" + webSearchTimeout = 25 * time.Second + webSearchMaxResponseSize = 5 * 1024 * 1024 +) + +const webSearchSchema = `{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "type":"object", + "properties":{ + "query":{"type":"string","description":"Websearch query"}, + "numResults":{"type":"number","description":"Number of search results to return (default: 8)"}, + "livecrawl":{"type":"string","enum":["fallback","preferred"],"description":"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')"}, + "type":{"type":"string","enum":["auto","fast","deep"],"description":"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"}, + "contextMaxCharacters":{"type":"number","description":"Maximum characters for context string optimized for LLMs (default: 10000)"} + }, + "required":["query"] +}` + +type webSearchInput struct { + Query string `json:"query"` + NumResults *float64 `json:"numResults,omitempty"` + Livecrawl string `json:"livecrawl,omitempty"` + Type string `json:"type,omitempty"` + ContextMaxCharacters *float64 `json:"contextMaxCharacters,omitempty"` +} + +type webSearchMetadata struct { + Provider string `json:"provider"` + Truncated bool `json:"truncated"` + OutputPath string `json:"outputPath,omitempty"` +} + +func webSearchDescription() string { + // Only the first {{year}} placeholder is replaced, each time the + // description is read. + return strings.Replace(webSearchDescriptionTemplate, "{{year}}", strconv.Itoa(time.Now().Year()), 1) +} + +func validateWebSearch(raw json.RawMessage) error { + var input webSearchInput + if err := decodeWebInput(raw, &input, []string{"query", "numResults", "livecrawl", "type", "contextMaxCharacters"}, "query"); err != nil { + return err + } + if input.Livecrawl != "" && input.Livecrawl != "fallback" && input.Livecrawl != "preferred" { + return fmt.Errorf("livecrawl must be fallback or preferred") + } + if input.Type != "" && input.Type != "auto" && input.Type != "fast" && input.Type != "deep" { + return fmt.Errorf("type must be auto, fast, or deep") + } + return nil +} + +// CurrentWebSearchFlags reads the search-backend switches: SENIOR_DEV_EXPERIMENTAL +// (which enables Exa), SENIOR_DEV_ENABLE_EXA or SENIOR_DEV_EXPERIMENTAL_EXA, and +// SENIOR_DEV_ENABLE_PARALLEL or SENIOR_DEV_EXPERIMENTAL_PARALLEL. API keys are +// intentionally not feature flags. +func CurrentWebSearchFlags() WebSearchFlags { + truthy := func(name string) bool { + return config.ParseBoolean(config.Truthy, environmentValue(name)) + } + experimental := truthy("SENIOR_DEV_EXPERIMENTAL") + return WebSearchFlags{ + Exa: experimental || truthy("SENIOR_DEV_ENABLE_EXA") || truthy("SENIOR_DEV_EXPERIMENTAL_EXA"), + Parallel: truthy("SENIOR_DEV_ENABLE_PARALLEL") || truthy("SENIOR_DEV_EXPERIMENTAL_PARALLEL"), + } +} + +func (r *Registry) executeWebSearch(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input webSearchInput + if err := decodeWebInput(call.Input, &input, []string{"query", "numResults", "livecrawl", "type", "contextMaxCharacters"}, "query"); err != nil { + return steploop.ToolResult{}, err + } + provider := selectWebSearchProvider(call.SessionID, CurrentWebSearchFlags()) + label := webSearchProviderLabel(provider) + permissionMetadata := map[string]any{"query": input.Query, "provider": provider} + if input.NumResults != nil { + permissionMetadata["numResults"] = *input.NumResults + } + if input.Livecrawl != "" { + permissionMetadata["livecrawl"] = input.Livecrawl + } + if input.Type != "" { + permissionMetadata["type"] = input.Type + } + if input.ContextMaxCharacters != nil { + permissionMetadata["contextMaxCharacters"] = *input.ContextMaxCharacters + } + if err := r.ask(ctx, call, "websearch", []string{input.Query}, permissionMetadata); err != nil { + return steploop.ToolResult{}, err + } + + result, err := callWebSearchProvider(ctx, provider, input, call) + if err != nil { + return steploop.ToolResult{}, err + } + if result == "" { + result = "No search results found. Please try a different query." + } + output, truncation, err := r.truncateWebOutput(ctx, call, result) + if err != nil { + return steploop.ToolResult{}, err + } + return steploop.ToolResult{ + Title: label + ": " + input.Query, Output: output, + Metadata: rawMetadata(webSearchMetadata{ + Provider: provider, Truncated: truncation.Truncated, OutputPath: truncation.OutputPath, + }), + }, nil +} + +func selectWebSearchProvider(sessionID string, flags WebSearchFlags) string { + // The env override wins, then Parallel, then Exa, and finally a stable + // per-session split. + if override := os.Getenv("SENIOR_DEV_WEBSEARCH_PROVIDER"); override == "exa" || override == "parallel" { + return override + } + if flags.Parallel { + return "parallel" + } + if flags.Exa { + return "exa" + } + if fnv1aUTF16(sessionID)%2 == 0 { + return "exa" + } + return "parallel" +} + +// fnv1aUTF16 hashes a session id over its UTF-16 code units so the provider +// split is stable for the life of the session. An empty id hashes to 0. +func fnv1aUTF16(value string) uint32 { + if value == "" { + return 0 + } + hash := uint32(0x811c9dc5) + for _, unit := range utf16.Encode([]rune(value)) { + hash ^= uint32(unit) + hash *= 0x01000193 + } + return hash +} + +func webSearchProviderLabel(provider string) string { + if provider == "parallel" { + return "Parallel Web Search" + } + if provider == "exa" { + return "Exa Web Search" + } + return "Web Search" +} + +func callWebSearchProvider( + ctx context.Context, + provider string, + input webSearchInput, + call steploop.ToolCall, +) (string, error) { + options := webOptions(ctx) + endpoint := options.exaURL + toolName := "web_search_exa" + arguments := map[string]any{ + "query": input.Query, + "type": valueOr(input.Type, "auto"), + "numResults": nonzeroOr(input.NumResults, 8), + "livecrawl": valueOr(input.Livecrawl, "fallback"), + } + if input.ContextMaxCharacters != nil { + arguments["contextMaxCharacters"] = *input.ContextMaxCharacters + } + headers := map[string]string{} + if endpoint == "" { + endpoint = defaultExaWebSearchURL + } + if key := os.Getenv("EXA_API_KEY"); provider == "exa" && key != "" { + // An Exa key, when present, goes in the query string; otherwise the + // public endpoint is called unchanged. + separator := "?" + if strings.Contains(endpoint, "?") { + separator = "&" + } + endpoint += separator + "exaApiKey=" + encodeURIComponent(key) + } + if provider == "parallel" { + endpoint = options.parallelURL + if endpoint == "" { + endpoint = defaultParallelWebSearchURL + } + toolName = "web_search" + arguments = map[string]any{ + "objective": input.Query, "search_queries": []string{input.Query}, + "session_id": call.SessionID, + } + if call.ModelID != "" { + arguments["model_name"] = firstRunes(call.ModelID, 100) + } + version := options.version + if version == "" { + version = "local" + } + headers["User-Agent"] = "senior-dev/" + version + if key := os.Getenv("PARALLEL_API_KEY"); key != "" { + headers["Authorization"] = "Bearer " + key + } + } + return callMCPWebSearch(ctx, endpoint, toolName, arguments, headers) +} + +func callMCPWebSearch( + ctx context.Context, + endpoint, toolName string, + arguments map[string]any, + headers map[string]string, +) (string, error) { + // Refuse up front with the model-facing policy error rather than the + // transport's, which would otherwise surface wrapped in a generic fetch + // failure. The transport wrap in webClient repeats the refusal as a + // fast-fail backstop. + if policy := netpolicy.Current(); policy.Restricted() { + host := endpoint + if parsed, parseErr := url.Parse(endpoint); parseErr == nil { + host = parsed.Host + } + return "", policy.HostError(host) + } + payload := struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + }{JSONRPC: "2.0", ID: 1, Method: "tools/call"} + payload.Params.Name = toolName + payload.Params.Arguments = arguments + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + requestCtx, cancel := context.WithTimeout(ctx, webSearchTimeout) + defer cancel() + request, err := http.NewRequestWithContext(requestCtx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("InvalidUrl error (POST %s)", endpoint) + } + request.Header.Set("Accept", "application/json, text/event-stream") + request.Header.Set("Content-Type", "application/json") + for name, value := range headers { + request.Header.Set(name, value) + } + response, err := webClient(ctx).Do(request) + if err != nil { + // Surface a policy refusal (initial host or redirect hop) with its + // no-retry framing instead of a retryable-looking transport failure. + var policyBlocked *netpolicy.BlockedError + if errors.As(err, &policyBlocked) { + return "", policyBlocked + } + if requestCtx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("%s request timed out", toolName) + } + return "", transportError(http.MethodPost, endpoint) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", statusCodeError(http.MethodPost, endpoint, response.StatusCode) + } + if declared := response.Header.Get("Content-Length"); declared != "" { + if size, parseErr := strconv.ParseInt(declared, 10, 64); parseErr == nil && size > webSearchMaxResponseSize { + return "", fmt.Errorf("Response too large (exceeds 5MB limit)") + } + } + raw, err := io.ReadAll(io.LimitReader(response.Body, webSearchMaxResponseSize+1)) + if err != nil { + return "", err + } + if len(raw) > webSearchMaxResponseSize { + return "", fmt.Errorf("Response too large (exceeds 5MB limit)") + } + // The endpoint may answer with a direct JSON-RPC object or with SSE data + // lines; the first non-empty content text wins. + return parseMCPWebSearchResponse(string(raw)) +} + +func parseMCPWebSearchResponse(body string) (string, error) { + trimmed := strings.TrimSpace(body) + if strings.HasPrefix(trimmed, "{") { + return parseMCPWebSearchPayload(trimmed) + } + for _, line := range strings.Split(body, "\n") { + if !strings.HasPrefix(line, "data: ") { + continue + } + result, err := parseMCPWebSearchPayload(line[6:]) + if err != nil || result != "" { + return result, err + } + } + return "", nil +} + +func parseMCPWebSearchPayload(payload string) (string, error) { + trimmed := strings.TrimSpace(payload) + if !strings.HasPrefix(trimmed, "{") { + return "", nil + } + var value struct { + Result *struct { + Content []struct { + Type *string `json:"type"` + Text *string `json:"text"` + } `json:"content"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(trimmed), &value); err != nil { + return "", err + } + if value.Result == nil || value.Result.Content == nil { + return "", fmt.Errorf("invalid MCP response") + } + for _, item := range value.Result.Content { + if item.Type == nil || item.Text == nil { + return "", fmt.Errorf("invalid MCP response") + } + } + for _, item := range value.Result.Content { + if *item.Text != "" { + return *item.Text, nil + } + } + return "", nil +} + +func valueOr(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func nonzeroOr(value *float64, fallback float64) float64 { + if value == nil || *value == 0 { + return fallback + } + return *value +} + +func encodeURIComponent(value string) string { + const hexadecimal = "0123456789ABCDEF" + var output strings.Builder + for index := 0; index < len(value); index++ { + character := value[index] + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || strings.ContainsRune("-_.!~*'()", rune(character)) { + output.WriteByte(character) + continue + } + output.WriteByte('%') + output.WriteByte(hexadecimal[character>>4]) + output.WriteByte(hexadecimal[character&15]) + } + return output.String() +} diff --git a/internal/seniordev/tool/websearch.txt b/internal/seniordev/tool/websearch.txt new file mode 100644 index 0000000000..ad5238cbd5 --- /dev/null +++ b/internal/seniordev/tool/websearch.txt @@ -0,0 +1,14 @@ +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs +- Provides up-to-date information for current events and recent data +- Supports configurable result counts and returns the content from the most relevant websites +- Use this tool for accessing information beyond knowledge cutoff +- Searches are performed automatically within a single API call + +Usage notes: + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Configurable context length for optimal LLM integration + - Domain filtering and advanced search options available + +The current year is {{year}}. You MUST use this year when searching for recent information or current events +- Example: If the current year is 2026 and the user asks for "latest AI news", search for "AI news 2026", NOT "AI news 2025" diff --git a/internal/seniordev/tool/websearch_test.go b/internal/seniordev/tool/websearch_test.go new file mode 100644 index 0000000000..34a6b01d9e --- /dev/null +++ b/internal/seniordev/tool/websearch_test.go @@ -0,0 +1,272 @@ +//go:build !windows + +package tool + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +func TestWebSearchFullExecutionWithoutAPIKey(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + t.Setenv("EXA_API_KEY", "") + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.RawQuery != "" { + t.Errorf("unexpected API key query: %q", request.URL.RawQuery) + } + if request.Header.Get("Accept") != "application/json, text/event-stream" { + t.Errorf("Accept = %q", request.Header.Get("Accept")) + } + var payload struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.JSONRPC != "2.0" || payload.Method != "tools/call" || payload.Params.Name != "web_search_exa" { + t.Errorf("payload = %#v", payload) + } + if payload.Params.Arguments["query"] != "go tools" || payload.Params.Arguments["numResults"] != float64(8) || + payload.Params.Arguments["type"] != "auto" || payload.Params.Arguments["livecrawl"] != "fallback" { + t.Errorf("arguments = %#v", payload.Params.Arguments) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"result":{"content":[{"type":"text","text":"first result"}]}}`) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebSearchEndpoints(ctx, server.URL, "") + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "go tools"}) + if err != nil { + t.Fatal(err) + } + if result.Output != "first result" || result.Title != "Exa Web Search: go tools" { + t.Fatalf("result = %#v", result) + } + if string(result.Metadata) != `{"provider":"exa","truncated":false}` { + t.Fatalf("Metadata = %s", result.Metadata) + } +} + +func TestWebSearchFullExecutionWithoutSocket(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.Method != http.MethodPost || request.Header.Get("Accept") != "application/json, text/event-stream" { + t.Errorf("request = %s, Accept = %q", request.Method, request.Header.Get("Accept")) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"result":{"content":[{"type":"text","text":"fixture result"}]}}`, + )), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "https://fixture.invalid/mcp", "") + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "fixture"}) + if err != nil || result.Output != "fixture result" { + t.Fatalf("result = %#v, error = %v", result, err) + } +} + +func TestParallelWebSearchSendsModelNameAndFinalMetadata(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "parallel") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + var payload struct { + Params struct { + Arguments map[string]any `json:"arguments"` + } `json:"params"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Error(err) + } + if payload.Params.Arguments["model_name"] != "fixture/model" { + t.Errorf("parallel arguments = %#v", payload.Params.Arguments) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"result":{"content":[{"type":"text","text":"parallel result"}]}}`, + )), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "", "https://parallel.test/mcp") + ctx = WithWebOutputDir(ctx, t.TempDir()) + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "fixture"}) + if err != nil || result.Output != "parallel result" || string(result.Metadata) != `{"provider":"parallel","truncated":false}` { + t.Fatalf("parallel result = (%#v, %v)", result, err) + } +} + +func TestWebSearchResponseIsCappedAtFiveMiB(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(strings.Repeat("x", webSearchMaxResponseSize+1))), + Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "https://exa.test/mcp", "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "large"}) + if err == nil || err.Error() != "Response too large (exceeds 5MB limit)" { + t.Fatalf("oversized search error = %v", err) + } +} + +func TestWebSearchRegistrationGateAndDescriptions(t *testing.T) { + registry := New(t.TempDir()) + definitions := registry.Definitions() + all := definitionNames(definitions) + if !containsName(all, "webfetch") || !containsName(all, "websearch") { + t.Fatalf("Definitions = %v", all) + } + withoutBackend := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "claude", + })) + if !containsName(withoutBackend, "webfetch") || containsName(withoutBackend, "websearch") { + t.Fatalf("without backend = %v", withoutBackend) + } + seniorDev := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "senior-dev", ModelID: "claude", + })) + if !containsName(seniorDev, "websearch") { + t.Fatalf("senior-dev = %v", seniorDev) + } + exa := definitionNames(registry.DefinitionsFor(FilterInput{ + ProviderID: "openrouter", ModelID: "claude", Flags: WebSearchFlags{Exa: true}, + })) + if !containsName(exa, "websearch") { + t.Fatalf("exa = %v", exa) + } + for _, definition := range definitions { + switch definition.Provider.Name { + case "webfetch": + if definition.Provider.Description != webFetchDescription { + t.Fatal("webfetch description differs from embedded bytes") + } + case "websearch": + if definition.Provider.Description != webSearchDescription() { + t.Fatal("websearch description did not substitute the current year") + } + } + } +} + +func TestCurrentWebSearchFlags(t *testing.T) { + for _, name := range []string{ + "SENIOR_DEV_EXPERIMENTAL", "SENIOR_DEV_ENABLE_EXA", "SENIOR_DEV_EXPERIMENTAL_EXA", + "SENIOR_DEV_ENABLE_PARALLEL", "SENIOR_DEV_EXPERIMENTAL_PARALLEL", + } { + t.Setenv(name, "") + } + if got := CurrentWebSearchFlags(); got != (WebSearchFlags{}) { + t.Fatalf("empty flags = %#v", got) + } + t.Setenv("SENIOR_DEV_EXPERIMENTAL", "TRUE") + if got := CurrentWebSearchFlags(); !got.Exa || got.Parallel { + t.Fatalf("experimental flags = %#v", got) + } + t.Setenv("SENIOR_DEV_EXPERIMENTAL", "") + t.Setenv("SENIOR_DEV_EXPERIMENTAL_PARALLEL", "1") + if got := CurrentWebSearchFlags(); got.Exa || !got.Parallel { + t.Fatalf("parallel alias flags = %#v", got) + } +} + +func TestWebSearchProviderAndResponse(t *testing.T) { + t.Run("encodeURIComponent API key", func(t *testing.T) { + input := "a b!~*'()+/?=:&" + if got, want := encodeURIComponent(input), "a%20b!~*'()%2B%2F%3F%3D%3A%26"; got != want { + t.Fatalf("encoded = %q, want %q", got, want) + } + }) + t.Run("provider priority", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "") + if got := selectWebSearchProvider("session", WebSearchFlags{Exa: true, Parallel: true}); got != "parallel" { + t.Fatalf("provider = %q", got) + } + }) + t.Run("SSE", func(t *testing.T) { + got, err := parseMCPWebSearchResponse("event: message\ndata: {\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"from sse\"}]}}\n") + if err != nil || got != "from sse" { + t.Fatalf("parse = (%q, %v)", got, err) + } + }) + t.Run("empty fallback", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(writer, `{"result":{"content":[]}}`) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebSearchEndpoints(ctx, server.URL, "") + result, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "none"}) + if err != nil || result.Output != "No search results found. Please try a different query." { + t.Fatalf("result = %#v, error = %v", result, err) + } + }) + t.Run("non-2xx", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + server := newLocalWebServer(t, http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusUnauthorized) + })) + ctx := WithWebHTTPClient(context.Background(), server.Client()) + ctx = WithWebSearchEndpoints(ctx, server.URL, "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "denied"}) + want := "StatusCode error (401 POST " + server.URL + ")" + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } + }) + t.Run("connection refused shape", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "http://127.0.0.1:1/mcp", "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "offline"}) + if err == nil || err.Error() != "Transport error (POST http://127.0.0.1:1/mcp)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("non-2xx without socket", func(t *testing.T) { + t.Setenv("SENIOR_DEV_WEBSEARCH_PROVIDER", "exa") + client := &http.Client{Transport: webRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, Header: http.Header{}, + Body: io.NopCloser(strings.NewReader("denied")), Request: request, + }, nil + })} + ctx := WithWebHTTPClient(context.Background(), client) + ctx = WithWebSearchEndpoints(ctx, "https://fixture.invalid/mcp", "") + _, err := executeWebTest(t, New(t.TempDir()), ctx, "websearch", map[string]any{"query": "denied"}) + if err == nil || err.Error() != "StatusCode error (403 POST https://fixture.invalid/mcp)" { + t.Fatalf("error = %v", err) + } + }) + t.Run("malformed MCP response", func(t *testing.T) { + _, err := parseMCPWebSearchResponse(`{"result":{}}`) + if err == nil || !strings.Contains(err.Error(), "invalid MCP response") { + t.Fatalf("error = %v", err) + } + }) +} diff --git a/internal/seniordev/tool/write.go b/internal/seniordev/tool/write.go new file mode 100644 index 0000000000..44304394a7 --- /dev/null +++ b/internal/seniordev/tool/write.go @@ -0,0 +1,127 @@ +//go:build !windows + +// The write tool: whole-file writes that preserve an existing BOM, run the +// configured formatter, and report a unified diff of the change. +package tool + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/engine/steploop" + patchpkg "github.com/Agent-Field/codeaf/internal/seniordev/patch" + "github.com/Agent-Field/codeaf/internal/seniordev/util" +) + +type writeMetadata struct { + Diagnostics map[string]any `json:"diagnostics"` + Diff string `json:"diff"` + FilePath string `json:"filepath"` + Exists bool `json:"exists"` + // Additions and Deletions are the lines the write added and removed, the + // counts edit and apply_patch already report. Nothing reaches the model: + // they are metadata, read by the run's step record (app/step_records.go). + Additions int `json:"additions"` + Deletions int `json:"deletions"` +} + +func (r *Registry) executeWrite(ctx context.Context, call steploop.ToolCall) (steploop.ToolResult, error) { + var input writeInput + if err := decodeInput(call.Input, &input, "content", "filePath"); err != nil { + return steploop.ToolResult{}, err + } + if err := ctx.Err(); err != nil { + return steploop.ToolResult{}, err + } + + resolved, err := r.resolveWritePath(input.FilePath) + if err != nil { + return steploop.ToolResult{}, err + } + if err := r.askExternalDirectory(ctx, call, resolved, "file"); err != nil { + return steploop.ToolResult{}, err + } + formatter, err := r.formatterService() + if err != nil { + return steploop.ToolResult{}, err + } + source, readErr := os.ReadFile(resolved) + exists := readErr == nil + if readErr != nil && !os.IsNotExist(readErr) { + return steploop.ToolResult{}, readErr + } + sourceBOM, contentOld := splitBOM(strings.ToValidUTF8(string(source), "\uFFFD")) + nextBOM, content := splitBOM(input.Content) + desiredBOM := sourceBOM || nextBOM + proposedDiff := proposedFileDiff(resolved, contentOld, content) + pattern, relErr := filepath.Rel(r.worktree(), resolved) + if relErr != nil { + pattern = resolved + } + metadata := map[string]any{"filepath": resolved, "diff": proposedDiff} + if err := r.ask(ctx, call, "edit", []string{filepath.ToSlash(pattern)}, metadata); err != nil { + return steploop.ToolResult{}, err + } + + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return steploop.ToolResult{}, err + } + if err := os.WriteFile(resolved, []byte(joinBOM(content, desiredBOM)), 0o644); err != nil { + return steploop.ToolResult{}, err + } + content, err = formatMutationFile(ctx, formatter, resolved, desiredBOM) + if err != nil { + return steploop.ToolResult{}, err + } + + util.EagerCommit(ctx, util.EagerCommitOptions{Cwd: r.workDir, FilePath: resolved, Label: "write"}) + + title, err := filepath.Rel(r.workDir, resolved) + if err != nil { + title = resolved + } + diff := TrimDiff(patchpkg.GenerateTwoFilesPatch(resolved, contentOld, content)) + additions, deletions := writeLineCounts(exists, contentOld, content) + return steploop.ToolResult{ + Title: title, + Output: "Wrote file successfully.", + Metadata: rawMetadata(writeMetadata{ + Diagnostics: map[string]any{}, + Diff: diff, + FilePath: resolved, + Exists: exists, + Additions: additions, + Deletions: deletions, + }), + }, nil +} + +// writeLineCounts is a write's lines added and removed. A new file is all +// additions, counted without the line-by-line comparison, which a large new +// file would pay for with nothing to compare against. +func writeLineCounts(exists bool, oldContent, newContent string) (int, int) { + if !exists || oldContent == "" { + if newContent == "" { + return 0, 0 + } + return strings.Count(strings.TrimSuffix(newContent, "\n"), "\n") + 1, 0 + } + return lineChangeCounts(oldContent, newContent) +} + +func splitBOM(value string) (bool, string) { + if strings.HasPrefix(value, "\ufeff") { + return true, strings.TrimPrefix(value, "\ufeff") + } + return false, value +} + +func joinBOM(value string, bom bool) string { + _, stripped := splitBOM(value) + if bom { + return "\ufeff" + stripped + } + return stripped +} diff --git a/internal/seniordev/tool/write_test.go b/internal/seniordev/tool/write_test.go new file mode 100644 index 0000000000..7f966b1107 --- /dev/null +++ b/internal/seniordev/tool/write_test.go @@ -0,0 +1,68 @@ +//go:build !windows + +package tool + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +func TestWriteBOMAndMetadata(t *testing.T) { + workDir := t.TempDir() + registry := New(workDir) + path := filepath.Join(workDir, "nested", "file.txt") + + result, err := execute(t, registry, "write", map[string]any{ + "content": "\ufefffirst", + "filePath": "nested/file.txt", + }) + if err != nil { + t.Fatalf("Execute add: %v", err) + } + if result.Title != filepath.Join("nested", "file.txt") || result.Output != "Wrote file successfully." { + t.Fatalf("result = %#v", result) + } + // write results expose the unified diff of the actual change. + diffPrefix := "Index: " + path + "\n===================================================================\n--- " + path + "\n+++ " + path + "\n" + wantMetadata := `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -0,0 +1,1 @@\n+first\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":false,"additions":1,"deletions":0}` + if string(result.Metadata) != wantMetadata { + t.Fatalf("Metadata = %s, want %s", result.Metadata, wantMetadata) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufefffirst" { + t.Fatalf("content = %q", data) + } + + result, err = execute(t, registry, "write", map[string]any{ + "content": "second", + "filePath": "nested/file.txt", + }) + if err != nil { + t.Fatalf("Execute overwrite: %v", err) + } + wantMetadata = `{"diagnostics":{},"diff":` + quotedJSON(diffPrefix+"@@ -1,1 +1,1 @@\n-first\n\\ No newline at end of file\n+second\n\\ No newline at end of file\n") + `,"filepath":` + quotedJSON(path) + `,"exists":true,"additions":1,"deletions":1}` + if string(result.Metadata) != wantMetadata { + t.Fatalf("Metadata = %s", result.Metadata) + } + data, err = os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != "\ufeffsecond" { + t.Fatalf("existing BOM was not preserved: %q", data) + } +} + +func quotedJSON(value string) string { + data, err := jsonutil.Marshal(value) + if err != nil { + panic(err) + } + return string(data) +} diff --git a/internal/seniordev/util/eagercommit.go b/internal/seniordev/util/eagercommit.go new file mode 100644 index 0000000000..64fb1b86ee --- /dev/null +++ b/internal/seniordev/util/eagercommit.go @@ -0,0 +1,105 @@ +//go:build !windows + +// Eager per-write git checkpoint +package util + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" +) + +// skipEagerCommit is read on every write and set from two places: the +// environment at startup, and the run once it knows which workspace recorder +// it is using. Atomic because the write happens before the run starts while +// the reads happen on tool goroutines. +var skipEagerCommit atomic.Bool + +func init() { skipEagerCommit.Store(os.Getenv("SENIOR_DEV_EAGER_COMMIT") == "0") } + +// DisableEagerCommit turns off the per-write checkpoint for the rest of the +// process. A run whose recorder keeps its own snapshots does not want commits +// in the workspace -- under --in-place that workspace may be a repository the +// run has no business writing history into. Call it before the run starts. +func DisableEagerCommit() { skipEagerCommit.Store(true) } + +type EagerCommitOptions struct { + Cwd string + FilePath string + Label string +} + +func EagerCommit(ctx context.Context, options EagerCommitOptions) { + if skipEagerCommit.Load() { + return + } + defer func() { _ = recover() }() + inRepo, _ := RunProcess(ctx, []string{"git", "rev-parse", "--is-inside-work-tree"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: options.Cwd}, NoThrow: true, + }) + if inRepo.Code != 0 { + return + } + rootResult, _ := RunProcess(ctx, []string{"git", "rev-parse", "--show-toplevel"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: options.Cwd}, NoThrow: true, + }) + root := options.Cwd + if rootResult.Code == 0 { + root = strings.TrimSpace(string(rootResult.Stdout)) + } + relative := repositoryRelative(root, options.Cwd, options.FilePath) + add, _ := RunProcess(ctx, []string{"git", "add", "--", relative}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, + }) + if add.Code != 0 { + return + } + diff, _ := RunProcess(ctx, []string{"git", "diff", "--cached", "--quiet", "--", relative}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true, + }) + if diff.Code == 0 { + return + } + message := "wip(" + options.Label + "): " + relative + _, _ = RunProcess(ctx, GitArgv( + "commit", "-m", message, "--no-verify", "--only", "--", relative, + ), RunOptions{ProcessOptions: ProcessOptions{Cwd: root}, NoThrow: true}) +} + +// repositoryRelative names a written file inside the repository whose top +// level git reported as root. +// +// GIT REPORTS ITS TOP LEVEL WITH EVERY SYMLINK RESOLVED, and the path a tool +// hands in need not be. On macOS every temporary folder is /var/folders/…, +// which is a link to /private/var/folders/…, so a file under the one measured +// against a root under the other walked out of the repository +// ("../../../var/folders/…"), `git add` refused it, and every per-file commit +// in such a workspace stopped without a word while the run went on believing +// it was checkpointing. Both sides are resolved before they are compared. +func repositoryRelative(root, cwd, path string) string { + if !filepath.IsAbs(path) { + path = filepath.Join(cwd, path) + } + relative, err := filepath.Rel(resolveExisting(root), resolveExisting(path)) + if err != nil || relative == "" { + return path + } + return relative +} + +// resolveExisting resolves the symlinks in path, or in its nearest ancestor +// that exists when the path itself does not (a file just deleted still has a +// folder, and the folder is what carries the link). +func resolveExisting(path string) string { + path = filepath.Clean(path) + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + parent := filepath.Dir(path) + if parent == path { + return path + } + return filepath.Join(resolveExisting(parent), filepath.Base(path)) +} diff --git a/internal/seniordev/util/error.go b/internal/seniordev/util/error.go new file mode 100644 index 0000000000..1807272f3e --- /dev/null +++ b/internal/seniordev/util/error.go @@ -0,0 +1,230 @@ +//go:build !windows + +// Error formatting +package util + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "runtime/debug" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +// StackError may supply a pre-recorded stack trace. +type StackError interface { + error + Stack() string +} + +func ErrorFormat(value any) string { + if err, ok := value.(error); ok { + if stack, ok := err.(StackError); ok && stack.Stack() != "" { + return stack.Stack() + } + name := reflect.TypeOf(err).String() + name = strings.TrimPrefix(name, "*") + if named, ok := err.(interface{ ErrorName() string }); ok { + name = named.ErrorName() + } + return name + ": " + err.Error() + } + if IsRecord(value) { + data, err := jsonutil.MarshalIndent(value) + if err != nil { + return "Unexpected error (unserializable)" + } + if string(data) == "{}" { + t := reflect.TypeOf(value) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + prefix := t.Name() + if prefix == "" { + prefix = "Error" + } + names := ownPropertyNames(value) + if len(names) == 0 { + return prefix + " (no message)" + } + return prefix + " { " + strings.Join(names, ", ") + " }" + } + return string(data) + } + return stringifyValue(value) +} + +func ErrorMessage(value any) string { + if err, ok := value.(error); ok { + if err.Error() != "" { + return err.Error() + } + name := reflect.TypeOf(err).String() + if name != "" { + return strings.TrimPrefix(name, "*") + } + } + if record, ok := stringMap(value); ok { + if message, ok := record["message"].(string); ok && message != "" { + return message + } + if data, ok := stringMap(record["data"]); ok { + if message, ok := data["message"].(string); ok && message != "" { + return message + } + } + } + text := stringifyValue(value) + if text != "" && text != "[object Object]" { + return text + } + if formatted := ErrorFormat(value); formatted != "" { + return formatted + } + return "unknown error" +} + +func ErrorData(value any) map[string]any { + if err, ok := value.(error); ok { + name := reflect.TypeOf(err).String() + name = strings.TrimPrefix(name, "*") + out := map[string]any{ + "type": name, + "message": ErrorMessage(err), + "formatted": ErrorFormat(err), + } + if stack, ok := err.(StackError); ok { + out["stack"] = stack.Stack() + } + if cause := errors.Unwrap(err); cause != nil { + out["cause"] = ErrorFormat(cause) + } + return out + } + if !IsRecord(value) { + return map[string]any{ + "type": valueTypeName(value), + "message": ErrorMessage(value), + "formatted": ErrorFormat(value), + } + } + out := map[string]any{} + if record, ok := stringMap(value); ok { + for key, item := range record { + switch typed := item.(type) { + case nil: + out[key] = "null" + case string, float64, float32, int, int64, bool: + out[key] = typed + case error: + out[key] = typed.Error() + default: + out[key] = stringifyValue(typed) + } + } + } + if _, ok := out["message"].(string); !ok { + out["message"] = ErrorMessage(value) + } + if _, ok := out["type"].(string); !ok { + t := reflect.TypeOf(value) + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + name := t.Name() + if name == "" && t.Kind() == reflect.Map { + name = "Object" + } + out["type"] = name + } + out["formatted"] = ErrorFormat(value) + return out +} + +// stringifyValue renders a value the way error payloads expect: nil as +// "null", records as "[object Object]", scalars as their plain text. +func stringifyValue(value any) string { + switch typed := value.(type) { + case nil: + return "null" + case string: + return typed + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 64) + case error: + return typed.Error() + } + if IsRecord(value) { + return "[object Object]" + } + return fmt.Sprint(value) +} + +// valueTypeName is the coarse type label reported for non-record error +// values; nil and unknown kinds report "object". +func valueTypeName(value any) string { + switch value.(type) { + case nil: + return "object" + case string: + return "string" + case bool: + return "boolean" + case float64, float32, int, int64, uint, uint64: + return "number" + case func(): + return "function" + default: + return "object" + } +} + +func stringMap(value any) (map[string]any, bool) { + if direct, ok := value.(map[string]any); ok { + return direct, true + } + data, err := json.Marshal(value) + if err != nil { + return nil, false + } + var out map[string]any + if json.Unmarshal(data, &out) != nil { + return nil, false + } + return out, out != nil +} + +func ownPropertyNames(value any) []string { + v := reflect.ValueOf(value) + for v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.Kind() == reflect.Map { + out := []string{} + iter := v.MapRange() + for iter.Next() { + out = append(out, fmt.Sprint(iter.Key().Interface())) + } + return out + } + if v.Kind() == reflect.Struct { + out := []string{} + for i := 0; i < v.NumField(); i++ { + out = append(out, v.Type().Field(i).Name) + } + return out + } + return nil +} + +// CaptureStack provides a convenient StackError stack for callers that need +// ErrorFormat's stack branch. +func CaptureStack() string { return string(debug.Stack()) } diff --git a/internal/seniordev/util/filesystem.go b/internal/seniordev/util/filesystem.go new file mode 100644 index 0000000000..88b70dea4e --- /dev/null +++ b/internal/seniordev/util/filesystem.go @@ -0,0 +1,199 @@ +//go:build !windows + +// Filesystem helpers +package util + +import ( + "encoding/json" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/seniordev/core" + "github.com/Agent-Field/codeaf/internal/seniordev/jsonutil" +) + +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func IsDir(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +} + +func Stat(path string) (os.FileInfo, bool) { + info, err := os.Stat(path) + return info, err == nil +} + +func StatAsync(path string) (os.FileInfo, bool, error) { + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, false, nil + } + return info, err == nil, err +} + +func Size(path string) int64 { + info, err := os.Stat(path) + if err != nil { + return 0 + } + return info.Size() +} + +func ReadText(path string) (string, error) { + data, err := os.ReadFile(path) + return string(data), err +} + +func ReadJSON(path string, dst any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(data, dst) +} + +func ReadBytes(path string) ([]byte, error) { return os.ReadFile(path) } + +func Write(path string, content []byte, mode ...fs.FileMode) error { + permission := fs.FileMode(0o666) + if len(mode) > 0 && mode[0] != 0 { + permission = mode[0] + } + err := os.WriteFile(path, content, permission) + if errors.Is(err, fs.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil { + return err + } + return os.WriteFile(path, content, permission) + } + return err +} + +func WriteText(path, content string, mode ...fs.FileMode) error { + return Write(path, []byte(content), mode...) +} + +func WriteJSON(path string, data any, mode ...fs.FileMode) error { + content, err := jsonutil.MarshalIndent(data) + if err != nil { + return err + } + return Write(path, content, mode...) +} + +func WriteStream(path string, stream io.Reader, mode ...fs.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o777); err != nil { + return err + } + file, err := os.Create(path) + if err != nil { + return err + } + _, copyErr := io.Copy(file, stream) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + if len(mode) > 0 && mode[0] != 0 { + return os.Chmod(path, mode[0]) + } + return nil +} + +func FileMimeType(path string) string { return core.MimeType(path) } +func NormalizePath(path string) string { return core.NormalizePath(path) } +func NormalizePathPattern(path string) string { return core.NormalizePathPattern(path) } +func WindowsPath(path string) string { return core.WindowsPath(path) } +func Overlaps(a, b string) bool { return core.Overlaps(a, b) } +func Contains(parent, child string) bool { return core.Contains(parent, child) } +func ResolvePath(path string) (string, error) { return core.Resolve(path) } + +type FindUpOptions struct { + RootFirst bool +} + +func FindUp(targets []string, start string, stop string, options ...FindUpOptions) []string { + dirs := []string{start} + current := start + for { + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + dirs = append(dirs, parent) + current = parent + } + if len(options) > 0 && options[0].RootFirst { + for left, right := 0, len(dirs)-1; left < right; left, right = left+1, right-1 { + dirs[left], dirs[right] = dirs[right], dirs[left] + } + } + result := []string{} + for _, dir := range dirs { + for _, target := range targets { + search := filepath.Join(dir, target) + if Exists(search) { + result = append(result, search) + } + } + } + return result +} + +func Up(targets []string, start, stop string) []string { + result := []string{} + current := start + for { + for _, target := range targets { + search := filepath.Join(current, target) + if Exists(search) { + result = append(result, search) + } + } + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result +} + +func GlobUp(pattern, start, stop string) []string { + filesystem := core.NewFileSystem() + result := []string{} + current := start + for { + matches, err := filesystem.Glob(pattern, core.GlobOptions{ + Cwd: current, Absolute: true, Dot: true, + }) + if err == nil { + result = append(result, matches...) + } + if stop == current { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return result +} diff --git a/internal/seniordev/util/gitexclude.go b/internal/seniordev/util/gitexclude.go new file mode 100644 index 0000000000..afc6e73de8 --- /dev/null +++ b/internal/seniordev/util/gitexclude.go @@ -0,0 +1,77 @@ +//go:build !windows + +// Git workflow-artifact exclusion +package util + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" +) + +var ExcludedPaths = []string{ + ".senior-dev/", +} + +const excludeSentinel = "# senior-dev: workflow artifacts (managed by senior-dev)" + +// EnsureSeniorDevExcluded adds senior-dev's own folder to the exclude file git +// reads for workspace, once. +// +// THE FILE IS THE ONE GIT READS, which is not always /info/exclude. +// In a linked worktree the git dir is .git/worktrees/, and git ignores +// an info/ folder there in favour of the common dir's. An exclude written +// beside the git dir changed nothing: `.senior-dev/` stayed untracked, and a +// commit of the tree's own status would have taken senior-dev's database, +// spec and tool logs with it. `rev-parse --git-path` names the file git +// actually consults, which for a linked worktree is the repository's shared +// one. +func EnsureSeniorDevExcluded(ctx context.Context, workspace string) (bool, error) { + result, err := RunProcess(ctx, []string{"git", "rev-parse", "--git-path", "info/exclude"}, RunOptions{ + ProcessOptions: ProcessOptions{Cwd: workspace}, + NoThrow: true, + }) + if err != nil || result.Code != 0 { + return false, nil + } + excludePath := strings.TrimSpace(string(result.Stdout)) + if excludePath == "" { + return false, nil + } + if !filepath.IsAbs(excludePath) { + excludePath, _ = filepath.Abs(filepath.Join(workspace, excludePath)) + } + _ = os.MkdirAll(filepath.Dir(excludePath), 0o777) + currentBytes, err := os.ReadFile(excludePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + currentBytes = nil + } + current := string(currentBytes) + existing := map[string]bool{} + for _, line := range strings.Split(current, "\n") { + line = strings.TrimSpace(line) + if line != "" { + existing[line] = true + } + } + missing := []string{} + for _, path := range ExcludedPaths { + if !existing[path] { + missing = append(missing, path) + } + } + if len(missing) == 0 { + return true, nil + } + addition := "" + if current != "" && !strings.HasSuffix(current, "\n") { + addition = "\n" + } + addition += excludeSentinel + "\n" + strings.Join(missing, "\n") + "\n" + if err := os.WriteFile(excludePath, []byte(current+addition), 0o666); err != nil { + return false, err + } + return true, nil +} diff --git a/internal/seniordev/util/gitidentity.go b/internal/seniordev/util/gitidentity.go new file mode 100644 index 0000000000..934f627769 --- /dev/null +++ b/internal/seniordev/util/gitidentity.go @@ -0,0 +1,28 @@ +//go:build !windows + +package util + +// The identity senior-dev's own commits carry. +// +// senior-dev commits as it works: its exact starting tree, every file its +// model writes, each coherent checkpoint and the candidate it submits. A +// working copy on a machine that has never been told who is committing (a +// fresh container, a hermetic HOME) refuses every one of those commits, and a +// refused candidate commit is a submission the run cannot make. So each git +// command that can commit carries an identity of its own, as `-c` overrides, +// which GIT_AUTHOR_* and GIT_COMMITTER_* in the environment still win over. +// +// These commits stay on the branch codeaf cut for the run, under the one +// commit codeaf makes of whatever the run left uncommitted when it ended, +// which carries codeaf's identity rather than this one. The address is a local +// one: it names the program that made a commit and no account anywhere. +const ( + CommitterName = "senior-dev" + CommitterEmail = "senior-dev@localhost" +) + +// GitArgv is a git command line that carries senior-dev's commit identity. +func GitArgv(args ...string) []string { + argv := []string{"git", "-c", "user.name=" + CommitterName, "-c", "user.email=" + CommitterEmail} + return append(argv, args...) +} diff --git a/internal/seniordev/util/gitutils_test.go b/internal/seniordev/util/gitutils_test.go new file mode 100644 index 0000000000..43688ac5f7 --- /dev/null +++ b/internal/seniordev/util/gitutils_test.go @@ -0,0 +1,142 @@ +//go:build !windows + +package util + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func gitTestRun(t *testing.T, dir string, args ...string) string { + t.Helper() + command := exec.Command("git", args...) + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } + return string(output) +} + +func initGitRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitTestRun(t, dir, "init", "-q") + gitTestRun(t, dir, "config", "user.email", "test@example.com") + gitTestRun(t, dir, "config", "user.name", "Test") + return dir +} + +func TestEnsureSeniorDevExcludedIdempotent(t *testing.T) { + dir := initGitRepo(t) + ok, err := EnsureSeniorDevExcluded(context.Background(), dir) + if err != nil || !ok { + t.Fatalf("ensure = %v, %v", ok, err) + } + ok, err = EnsureSeniorDevExcluded(context.Background(), dir) + if err != nil || !ok { + t.Fatalf("second ensure = %v, %v", ok, err) + } + data, err := os.ReadFile(filepath.Join(dir, ".git", "info", "exclude")) + if err != nil { + t.Fatal(err) + } + text := string(data) + if strings.Count(text, excludeSentinel) != 1 { + t.Fatalf("exclude:\n%s", text) + } + for _, path := range ExcludedPaths { + if strings.Count(text, path+"\n") != 1 { + t.Fatalf("%q count in:\n%s", path, text) + } + } +} + +func TestEagerCommit(t *testing.T) { + dir := initGitRepo(t) + file := filepath.Join(dir, "file.txt") + if err := os.WriteFile(file, []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTestRun(t, dir, "add", "file.txt") + gitTestRun(t, dir, "commit", "-qm", "initial") + if err := os.WriteFile(file, []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + + previous := skipEagerCommit.Load() + skipEagerCommit.Store(false) + defer func() { skipEagerCommit.Store(previous) }() + EagerCommit(context.Background(), EagerCommitOptions{Cwd: dir, FilePath: file, Label: "write"}) + subject := strings.TrimSpace(gitTestRun(t, dir, "log", "-1", "--pretty=%s")) + if subject != "wip(write): file.txt" { + t.Fatalf("subject = %q", subject) + } + // The commit carries senior-dev's own identity, so a machine that was + // never told who commits can still take it. + author := strings.TrimSpace(gitTestRun(t, dir, "log", "-1", "--pretty=%cn <%ce>")) + if author != CommitterName+" <"+CommitterEmail+">" { + t.Fatalf("committer = %q", author) + } +} + +// A path spelled through a symlink still commits: the per-file commit is +// measured against git's resolved top level, and a workspace reached through a +// link (every temporary folder on macOS) used to walk out of the repository. +func TestEagerCommitThroughASymlinkedWorkspace(t *testing.T) { + dir := initGitRepo(t) + file := filepath.Join(dir, "file.txt") + if err := os.WriteFile(file, []byte("one\n"), 0o644); err != nil { + t.Fatal(err) + } + gitTestRun(t, dir, "add", "file.txt") + gitTestRun(t, dir, "commit", "-qm", "initial") + link := filepath.Join(t.TempDir(), "workspace-link") + if err := os.Symlink(dir, link); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(link, "file.txt"), []byte("two\n"), 0o644); err != nil { + t.Fatal(err) + } + + previous := skipEagerCommit.Load() + skipEagerCommit.Store(false) + defer func() { skipEagerCommit.Store(previous) }() + EagerCommit(context.Background(), EagerCommitOptions{ + Cwd: link, FilePath: filepath.Join(link, "file.txt"), Label: "edit", + }) + subject := strings.TrimSpace(gitTestRun(t, dir, "log", "-1", "--pretty=%s")) + if subject != "wip(edit): file.txt" { + t.Fatalf("subject = %q, want the per-file commit through the link", subject) + } +} + +// A task's working copy is a linked worktree, whose own info/ folder git does +// not read. The exclude has to land where git looks, or `.senior-dev/` is +// untracked work that a landing would commit. +func TestEnsureSeniorDevExcludedReachesALinkedWorktree(t *testing.T) { + dir := initGitRepo(t) + gitTestRun(t, dir, "commit", "-q", "--allow-empty", "-m", "base") + copyDir := filepath.Join(t.TempDir(), "copy") + gitTestRun(t, dir, "worktree", "add", "-q", "--detach", copyDir, "HEAD") + if err := os.MkdirAll(filepath.Join(copyDir, ".senior-dev"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(copyDir, ".senior-dev", "spec.md"), []byte("brief\n"), 0o644); err != nil { + t.Fatal(err) + } + if status := gitTestRun(t, copyDir, "status", "--porcelain", "--untracked-files=all"); !strings.Contains(status, ".senior-dev/") { + t.Fatalf("the fixture is wrong: .senior-dev is not untracked before the exclude:\n%s", status) + } + ok, err := EnsureSeniorDevExcluded(context.Background(), copyDir) + if err != nil || !ok { + t.Fatalf("ensure = %v, %v", ok, err) + } + if status := gitTestRun(t, copyDir, "status", "--porcelain", "--untracked-files=all"); strings.Contains(status, ".senior-dev") { + t.Fatalf("senior-dev's folder is still untracked in the linked worktree:\n%s", status) + } +} diff --git a/internal/seniordev/util/localcontext.go b/internal/seniordev/util/localcontext.go new file mode 100644 index 0000000000..f0c450c3b5 --- /dev/null +++ b/internal/seniordev/util/localcontext.go @@ -0,0 +1,48 @@ +//go:build !windows + +// Local context: typed values carried on an explicit context.Context, the +// native propagation mechanism across goroutines. +package util + +import ( + "context" + "fmt" +) + +type ContextNotFound struct{ Name string } + +func (e *ContextNotFound) Error() string { return "No context found for " + e.Name } + +type localContextKey[T any] struct{ owner *LocalContext[T] } + +type LocalContext[T any] struct { + Name string + key localContextKey[T] +} + +func CreateLocalContext[T any](name string) *LocalContext[T] { + local := &LocalContext[T]{Name: name} + local.key.owner = local + return local +} + +func (l *LocalContext[T]) Use(ctx context.Context) (T, error) { + value, ok := ctx.Value(l.key).(T) + if !ok { + var zero T + return zero, &ContextNotFound{Name: l.Name} + } + return value, nil +} + +func (l *LocalContext[T]) Provide(ctx context.Context, value T) context.Context { + return context.WithValue(ctx, l.key, value) +} + +func (l *LocalContext[T]) MustUse(ctx context.Context) T { + value, err := l.Use(ctx) + if err != nil { + panic(fmt.Sprint(err)) + } + return value +} diff --git a/internal/seniordev/util/namederror.go b/internal/seniordev/util/namederror.go new file mode 100644 index 0000000000..0f60f73e67 --- /dev/null +++ b/internal/seniordev/util/namederror.go @@ -0,0 +1,43 @@ +//go:build !windows + +// Named schema error +package util + +type NamedSchemaError struct { + Name string `json:"name"` + Data map[string]any `json:"data"` + Cause error `json:"-"` +} + +func (e *NamedSchemaError) Error() string { return e.Name } +func (e *NamedSchemaError) Unwrap() error { return e.Cause } + +func (e *NamedSchemaError) ToObject() map[string]any { + return map[string]any{"name": e.Name, "data": e.Data} +} + +type NamedErrorFactory struct { + Tag string +} + +func NamedSchemaErrorFactory(tag string) NamedErrorFactory { return NamedErrorFactory{Tag: tag} } + +func (f NamedErrorFactory) New(data map[string]any, cause ...error) *NamedSchemaError { + var err error + if len(cause) > 0 { + err = cause[0] + } + return &NamedSchemaError{Name: f.Tag, Data: data, Cause: err} +} + +func (f NamedErrorFactory) IsInstance(value any) bool { + switch typed := value.(type) { + case *NamedSchemaError: + return typed != nil && typed.Name == f.Tag + case map[string]any: + name, _ := typed["name"].(string) + return name == f.Tag + default: + return false + } +} diff --git a/internal/seniordev/util/process.go b/internal/seniordev/util/process.go new file mode 100644 index 0000000000..c46aa607f4 --- /dev/null +++ b/internal/seniordev/util/process.go @@ -0,0 +1,334 @@ +//go:build !windows + +// Process helpers +package util + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "syscall" + "time" +) + +type ProcessOptions struct { + Cwd string + Env map[string]string + // ClearEnv starts the child with an empty environment instead of + // inheriting the parent's. + ClearEnv bool + Stdin string + Stdout string + Stderr string + Shell string + Kill os.Signal + Timeout time.Duration +} + +type RunOptions struct { + ProcessOptions + NoThrow bool +} + +type ProcessResult struct { + Code int `json:"code"` + Stdout []byte `json:"-"` + Stderr []byte `json:"-"` +} + +type TextResult struct { + ProcessResult + Text string `json:"text"` +} + +type RunFailedError struct { + Cmd []string + Code int + Stdout []byte + Stderr []byte +} + +func (e *RunFailedError) Error() string { + message := fmt.Sprintf("Command failed with code %d: %s", e.Code, strings.Join(e.Cmd, " ")) + if text := strings.TrimSpace(string(e.Stderr)); text != "" { + message += "\n" + text + } + return message +} + +func (e *RunFailedError) ErrorName() string { return "ProcessRunFailedError" } + +type Child struct { + Cmd *exec.Cmd + Stdin io.WriteCloser + Stdout io.ReadCloser + Stderr io.ReadCloser + Exited <-chan int + + exit chan int + done chan struct{} + mu sync.Mutex + closed bool +} + +func SpawnProcess(ctx context.Context, command []string, options ...ProcessOptions) (*Child, error) { + if len(command) == 0 { + return nil, errors.New("Command is required") + } + opt := ProcessOptions{} + if len(options) > 0 { + opt = options[0] + } + name := command[0] + args := command[1:] + if opt.Shell != "" { + shell := opt.Shell + if shell == "true" { + if runtime.GOOS == "windows" { + shell = "cmd.exe" + } else { + shell = "/bin/sh" + } + } + line := strings.Join(command, " ") + name, args = shell, []string{"-c", line} + if runtime.GOOS == "windows" { + args = []string{"/d", "/s", "/c", line} + } + } + cmd := exec.Command(name, args...) + cmd.Dir = opt.Cwd + switch { + case opt.ClearEnv: + cmd.Env = []string{} + case opt.Env != nil: + values := map[string]string{} + order := []string{} + for _, item := range os.Environ() { + key, value, _ := strings.Cut(item, "=") + if _, ok := values[key]; !ok { + order = append(order, key) + } + values[key] = value + } + for key, value := range opt.Env { + if _, ok := values[key]; !ok { + order = append(order, key) + } + values[key] = value + } + for _, key := range order { + cmd.Env = append(cmd.Env, key+"="+values[key]) + } + } + child := &Child{Cmd: cmd, exit: make(chan int, 1), done: make(chan struct{})} + child.Exited = child.exit + var err error + child.Stdin, err = configureInput(cmd, opt.Stdin) + if err != nil { + return nil, err + } + var outWrite, errWrite *os.File + child.Stdout, outWrite, err = configureOutput(cmd, opt.Stdout, os.Stdout) + if err != nil { + return nil, err + } + child.Stderr, errWrite, err = configureOutput(cmd, opt.Stderr, os.Stderr) + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + for _, f := range []*os.File{outWrite, errWrite} { + if f != nil { + _ = f.Close() + } + } + close(child.done) + close(child.exit) + return nil, err + } + for _, f := range []*os.File{outWrite, errWrite} { + if f != nil { + _ = f.Close() + } + } + go func() { + err := cmd.Wait() + code := 0 + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } else if err != nil { + code = 1 + } + if code < 0 { + code = 1 + } + child.exit <- code + close(child.exit) + close(child.done) + }() + go func() { + select { + case <-ctx.Done(): + child.abort(opt) + case <-child.done: + } + }() + return child, nil +} + +func configureInput(cmd *exec.Cmd, mode string) (io.WriteCloser, error) { + switch mode { + case "inherit": + cmd.Stdin = os.Stdin + return nil, nil + case "pipe": + return cmd.StdinPipe() + default: + cmd.Stdin = strings.NewReader("") + return nil, nil + } +} + +func configureOutput(cmd *exec.Cmd, mode string, inherit io.Writer) (io.ReadCloser, *os.File, error) { + switch mode { + case "inherit": + if inherit == os.Stdout { + cmd.Stdout = inherit + } else { + cmd.Stderr = inherit + } + return nil, nil, nil + case "pipe": + // Explicit os.Pipe, not StdoutPipe/StderrPipe: the exit goroutine calls + // cmd.Wait immediately after Start, and Wait auto-closes exec-managed + // pipes while consumers may still be draining them (truncating output). + // The caller closes the parent's write-end copy after Start so readers + // see EOF when the child exits. + pr, pw, err := os.Pipe() + if err != nil { + return nil, nil, err + } + if inherit == os.Stdout { + cmd.Stdout = pw + } else { + cmd.Stderr = pw + } + return pr, pw, nil + default: + if inherit == os.Stdout { + cmd.Stdout = io.Discard + } else { + cmd.Stderr = io.Discard + } + return nil, nil, nil + } +} + +func (c *Child) abort(options ProcessOptions) { + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return + } + c.closed = true + c.mu.Unlock() + signal := options.Kill + if signal == nil { + signal = syscall.SIGTERM + } + _ = c.Cmd.Process.Signal(signal) + timeout := options.Timeout + if timeout == 0 { + timeout = 5 * time.Second + } + if timeout <= 0 { + return + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-c.done: + case <-timer.C: + _ = c.Cmd.Process.Kill() + } +} + +func RunProcess(ctx context.Context, command []string, options ...RunOptions) (ProcessResult, error) { + opt := RunOptions{} + if len(options) > 0 { + opt = options[0] + } + spawnOptions := opt.ProcessOptions + spawnOptions.Stdout = "pipe" + spawnOptions.Stderr = "pipe" + child, err := SpawnProcess(ctx, command, spawnOptions) + if err != nil { + if !opt.NoThrow { + return ProcessResult{}, err + } + return ProcessResult{Code: 1, Stdout: []byte{}, Stderr: []byte(ErrorMessage(err))}, nil + } + var stdout bytes.Buffer + var stderr bytes.Buffer + var wg sync.WaitGroup + wg.Add(2) + go func() { defer wg.Done(); _, _ = io.Copy(&stdout, child.Stdout) }() + go func() { defer wg.Done(); _, _ = io.Copy(&stderr, child.Stderr) }() + code := <-child.Exited + wg.Wait() + result := ProcessResult{Code: code, Stdout: stdout.Bytes(), Stderr: stderr.Bytes()} + if code == 0 || opt.NoThrow { + return result, nil + } + return ProcessResult{}, &RunFailedError{ + Cmd: append([]string(nil), command...), Code: code, + Stdout: result.Stdout, Stderr: result.Stderr, + } +} + +func TextProcess(ctx context.Context, command []string, options ...RunOptions) (TextResult, error) { + result, err := RunProcess(ctx, command, options...) + if err != nil { + return TextResult{}, err + } + return TextResult{ProcessResult: result, Text: string(result.Stdout)}, nil +} + +func ProcessLines(ctx context.Context, command []string, options ...RunOptions) ([]string, error) { + result, err := TextProcess(ctx, command, options...) + if err != nil { + return nil, err + } + lines := []string{} + for _, line := range strings.Split(strings.ReplaceAll(result.Text, "\r\n", "\n"), "\n") { + if line != "" { + lines = append(lines, line) + } + } + return lines, nil +} + +func StopProcess(ctx context.Context, child *Child) { + select { + case <-child.done: + return + default: + } + if runtime.GOOS != "windows" || child.Cmd.Process == nil { + _ = child.Cmd.Process.Kill() + return + } + result, _ := RunProcess(ctx, []string{"taskkill", "/pid", fmt.Sprint(child.Cmd.Process.Pid), "/T", "/F"}, + RunOptions{NoThrow: true}) + if result.Code != 0 { + _ = child.Cmd.Process.Kill() + } +} diff --git a/internal/seniordev/util/process_test.go b/internal/seniordev/util/process_test.go new file mode 100644 index 0000000000..79cf45ecd8 --- /dev/null +++ b/internal/seniordev/util/process_test.go @@ -0,0 +1,71 @@ +//go:build !windows + +package util + +import ( + "context" + "encoding/json" + "os" + "reflect" + "strings" + "testing" +) + +func TestUtilProcessHelper(t *testing.T) { + if os.Getenv("GO_UTIL_HELPER") != "1" { + return + } + separator := 0 + for i, arg := range os.Args { + if arg == "--" { + separator = i + 1 + break + } + } + _ = json.NewEncoder(os.Stdout).Encode(os.Args[separator:]) + _, _ = os.Stderr.WriteString("warning\n") + if os.Getenv("GO_UTIL_FAIL") == "1" { + os.Exit(7) + } + os.Exit(0) +} + +func TestRunTextLinesAndFailure(t *testing.T) { + command := []string{os.Args[0], "-test.run=TestUtilProcessHelper", "--", "a b", "", "c"} + options := RunOptions{ProcessOptions: ProcessOptions{Env: map[string]string{"GO_UTIL_HELPER": "1"}}} + result, err := TextProcess(context.Background(), command, options) + if err != nil { + t.Fatal(err) + } + var args []string + if err := json.Unmarshal([]byte(result.Text), &args); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(args, []string{"a b", "", "c"}) || string(result.Stderr) != "warning\n" { + t.Fatalf("result: args=%v stderr=%q", args, result.Stderr) + } + + options.Env["GO_UTIL_FAIL"] = "1" + _, err = RunProcess(context.Background(), command, options) + failed, ok := err.(*RunFailedError) + if !ok || failed.Code != 7 || + failed.Error() != "Command failed with code 7: "+strings.Join(command, " ")+"\nwarning" { + t.Fatalf("failure: %#v %v", failed, err) + } + options.NoThrow = true + nothrow, err := RunProcess(context.Background(), command, options) + if err != nil || nothrow.Code != 7 { + t.Fatalf("nothrow: %+v %v", nothrow, err) + } +} + +func TestProcessLinesFiltersOnlyEmptyLines(t *testing.T) { + command := []string{"printf", "a\\n\\nb\\r\\n"} + lines, err := ProcessLines(context.Background(), command) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(lines, []string{"a", "b"}) { + t.Fatalf("lines: %#v", lines) + } +} diff --git a/internal/seniordev/util/record.go b/internal/seniordev/util/record.go new file mode 100644 index 0000000000..4a7e9e0cf9 --- /dev/null +++ b/internal/seniordev/util/record.go @@ -0,0 +1,20 @@ +//go:build !windows + +// Record predicate. +package util + +import "reflect" + +func IsRecord(value any) bool { + if value == nil { + return false + } + v := reflect.ValueOf(value) + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return false + } + v = v.Elem() + } + return v.Kind() == reflect.Map || v.Kind() == reflect.Struct +} diff --git a/internal/seniordev/util/util_test.go b/internal/seniordev/util/util_test.go new file mode 100644 index 0000000000..c52aed7ab8 --- /dev/null +++ b/internal/seniordev/util/util_test.go @@ -0,0 +1,50 @@ +//go:build !windows + +package util + +import ( + "context" + "errors" + "path/filepath" + "reflect" + "testing" +) + +func TestLocalContext(t *testing.T) { + local := CreateLocalContext[string]("test") + if _, err := local.Use(context.Background()); err == nil || err.Error() != "No context found for test" { + t.Fatalf("missing context: %v", err) + } + ctx := local.Provide(context.Background(), "value") + if got := local.MustUse(ctx); got != "value" { + t.Fatalf("context = %q", got) + } +} + +func TestFindUpRootFirst(t *testing.T) { + root := t.TempDir() + for _, relative := range []string{"root.txt", "a/one.txt", "a/b/two.txt"} { + path := filepath.Join(root, filepath.FromSlash(relative)) + if err := WriteText(path, relative); err != nil { + t.Fatal(err) + } + } + start := filepath.Join(root, "a", "b") + got := FindUp([]string{"root.txt", "one.txt", "two.txt"}, start, "", FindUpOptions{RootFirst: true}) + want := []string{ + filepath.Join(root, "root.txt"), + filepath.Join(root, "a", "one.txt"), + filepath.Join(root, "a", "b", "two.txt"), + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("findUp = %v, want %v", got, want) + } +} + +func TestNamedSchemaErrorFactory(t *testing.T) { + factory := NamedSchemaErrorFactory("Boom") + err := factory.New(map[string]any{"message": "x"}, errors.New("cause")) + if !factory.IsInstance(err) || err.Error() != "Boom" || !errors.Is(err, err.Cause) { + t.Fatalf("named error: %+v", err) + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index 5e5f40e4f0..a428261e7a 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -376,6 +376,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { // recovery is load, reconcile with the disk, continue the frontier // (task_store.go). A fresh session has no checkpoint and this is a stat. agent.recoverTasks() + // AND A PROGRAM'S RUN THE LAST PROCESS LEFT OPEN IS ENDED, where it was last + // seen, so its page stops reading `running` (task_run_belt.go). + agent.endInterruptedProgramRun() // AND THE PROJECT'S RECORD IS RECONCILED BESIDE IT. The checkpoint above is // one conversation's graph; the project index is every window's record of // what this directory ever ran, and it holds rows that say "running" — a run @@ -1276,6 +1279,14 @@ type userMessage struct { // seat and dedicated role page; ordinary settle wakes leave both empty. settleModel string settlePrompt string + // settleWindow widens the turn's window past the settle turn's own + // ([Agent.settleWindow]) — a program's ending may be checked by running the + // project's tests ([programOutcomeWindow]); zero leaves it as it is. + settleWindow time.Duration + // programOutcome is a program run's ending, on the note its landing wakes + // the conversation with ([Agent.programLandingNote]); nil on every other + // message. + programOutcome *programOutcome // landingQuestion and landingOutcome preserve the two roles inside an owed // landing document: what was asked and the evidence the run returned. @@ -2199,7 +2210,9 @@ type sessionCompleter struct { } func (f sessionCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { - ctx = provider.WithCacheKey(ctx, f.cacheKey) + if !bringsOwnLineage(ctx) { + ctx = provider.WithCacheKey(ctx, f.cacheKey) + } if f.patient { ctx = provider.WithPatientRateLimits(ctx) } @@ -2212,6 +2225,34 @@ func (f sessionCompleter) CompleteWithMessages(ctx context.Context, messages []a return f.inner.CompleteWithMessages(ctx, messages, options...) } +// ownLineageKey marks a call that brings its own prompt-cache lineage +// ([WithOwnCacheLineage]). +type ownLineageKey struct{} + +// WithOwnCacheLineage marks a call whose context already carries the cache key +// its request must travel under, so the conversation's wrapper keeps that key +// rather than stamping its own. +// +// IT EXISTS FOR ONE CALLER AND IT IS OPT-IN. A program codeaf carries talks to +// its model through the run's model API (internal/provider/modelapi), which +// hands each call to this conversation's completer — and the program keeps +// conversations of its own, each with its own `prompt_cache_key`. Stamped with +// the conversation's key, every one of them would ask for the conversation's +// warm instance: two different prefixes on one lineage, each cold-starting the +// other, which is [unwrapCompleter]'s reason for giving a task node a lineage +// of its own. A call that is not marked keeps exactly the stamp it always had. +func WithOwnCacheLineage(ctx context.Context) context.Context { + return context.WithValue(ctx, ownLineageKey{}, true) +} + +// bringsOwnLineage reports a call marked by [WithOwnCacheLineage] that really +// does carry a key: a marked call with none is stamped like any other, so the +// mark can never send a request out unkeyed. +func bringsOwnLineage(ctx context.Context) bool { + own, _ := ctx.Value(ownLineageKey{}).(bool) + return own && provider.CacheKeyFrom(ctx) != "" +} + // ProbeLanes passes the keystroke's pre-warm through, and does nothing at all // for an inner completer that cannot buy one ([laneProber]). // @@ -3741,6 +3782,7 @@ type settleWake struct { ceiling int model string prompt string + window time.Duration } type settleWakeKey struct{} @@ -3777,6 +3819,9 @@ func (a *Agent) settleWakeLocked() (settleWake, bool) { if note.settlePrompt != "" { wake.model, wake.prompt = note.settleModel, note.settlePrompt } + if note.settleWindow > wake.window { + wake.window = note.settleWindow + } } return wake, wake.ceiling != 0 } diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index 3c8b04a08a..3171ad4665 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -221,6 +221,16 @@ func signedModel(node *TaskNode) string { return node.model() } +// namedModel is the model this signature's line names, and "" when it names +// none — the spelling a record that outlives the agent keeps it in +// ([ProgramFolder.SignModel]). +func (s gitSignature) namedModel() string { + if !s.named { + return "" + } + return s.model +} + // sign is a commit message as the harness leaves it: one blank line, then the // two trailer lines. The files where the harness writes its OWN commits import // os/exec as `exec`, which is why they reach internal/exec's one spelling of @@ -319,6 +329,10 @@ type beltFact struct { // because that is the only verb such a belt carries. Empty falls back to // [beltFact.present]. oneRoad string + // fill, when set, is applied to the chosen text before it is placed: it is + // how a fact writes a fact of THIS launch into itself — the delegates this + // machine has (delegate_door.go) — where every other fact is a constant. + fill func(Config, string) string } // beltFacts is the whole of it, in the order the section reads. @@ -586,7 +600,7 @@ var handoffFacts = []beltFact{{ "stand, so a sweep across many files, research across many sources or the same\n" + "change over many items is work you open and carry yourself, in the order that\n" + "finishes it.", -}, { +}, delegateFact, { tools: []string{"build_harness", loadCapabilityToolName}, holds: Config.mayDesignHarness, present: "AND A SHAPE OF WORK THAT WILL RECUR is neither of them: `build_harness` designs it once and saves it.", @@ -758,6 +772,9 @@ func renderBeltFacts(config Config, facts []beltFact, join string) string { text = fact.shelved } } + if text != "" && fact.fill != nil { + text = fact.fill(config, text) + } if text != "" { // THE ASSISTED-BY LINE IS FILLED HERE because this is the one point // that holds both the belt's bytes and the model the page is diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index e10d658975..b48e8b4e9c 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -75,6 +75,33 @@ func modelServiceCanAnswer(service modelsource.Connected) bool { return strings.TrimSpace(service.Key) != "" || service.Source.KeyOptional } +// ServesModel answers whether one of these services can take a call on model: +// the service the model's id resolves to ([modelsource.Set.For], which reads a +// service prefix such as `openrouter/` off the id) holds a key, or is one that +// needs none. It is the pool's own test ([modelServiceCanAnswer]) opened to the +// run's model API (internal/provider/modelapi), which decides the same question +// for a program's call and may not answer it a second way: a program that +// names a model this machine cannot reach is answered on the run's work seat +// instead, and the pool and the API must agree about what cannot be reached. +func ServesModel(sources modelsource.Set, model string) bool { + model = strings.TrimSpace(model) + if model == "" || sources.Empty() { + return false + } + service, _ := sources.For(model) + return service.Source.ID != "" && modelServiceCanAnswer(service) +} + +// servesModel is [ServesModel] over this conversation's own services, read live +// under the lock the surface moves them under ([Agent.SetSources], +// [Agent.SetAPIKey]), so a key pasted after the run began counts for its next +// call. +func (a *Agent) servesModel(model string) bool { + a.mu.Lock() + defer a.mu.Unlock() + return ServesModel(a.config.Sources.OrDefault(a.config.APIKey, a.config.BaseURL), model) +} + // setSeat moves the one live fallback beside the source snapshot. A model // chosen after launch must carry the next turn; construction-time config is a // receipt of how the conversation opened, not an answer about where it sits. diff --git a/internal/session/delegate_asked.go b/internal/session/delegate_asked.go new file mode 100644 index 0000000000..7f69239d02 --- /dev/null +++ b/internal/session/delegate_asked.go @@ -0,0 +1,333 @@ +package session + +// THE PERSON NAMED A PROGRAM: what a proposal meets when the person's own +// message said which program codeaf carries should do the work. +// +// A PROMPT IS REMEMBERED EXACTLY AS OFTEN AS THE MODEL REMEMBERS IT. The +// hand-off page says a program the person asks for is used (delegate_door.go's +// [delegateFact]), and a model that has just read a long issue and decided it +// is a task writes the proposal it always writes, with no `via`. So the +// person's words are read here, in code, for the names of the programs this +// build carries, and two things follow from them that cost no prompt bytes: +// +// - A proposal with no `via` is turned back ONCE for the message that named +// a program, with a sentence saying which program was named and both ways +// to answer it. The next proposal the model makes after reading that +// passes as it is, which is how "fix it with senior-dev" and "don't use +// senior-dev for this" both come out right: which of the two the person +// said is read by the model, from words code cannot weigh. +// - A proposal whose `via` is the program the person asked for is never too +// small. The spawn floor (spawnfloor.go) keeps a one-file fix in the +// conversation, and a person who typed "fix this file with senior-dev" has +// overruled it already, exactly as a person who typed `/task` has. +// +// THE TWO READ THE WORDS DIFFERENTLY, because they are wrong at different +// prices. A bounce the person did not mean costs one round trip, and the model +// that reads it can answer "did not mean the program", so it hears any mention +// of the name ([Config.programNamedIn]). A floor lifted that the person did not +// mean starts a billed run that cannot do what they asked, and nothing reads +// it again, so it hears only the words that ask for the program +// ([Config.programsAskedIn]), and never lifts a commit, an undo or a revert. +// +// AND THE PERSON'S WORDS ARE EVERY MESSAGE OF THE TURN, not the newest one +// ([programsHeard]): a steer typed while the model reads the code names +// nothing, and it does not unsay the program the turn opened by asking for. +// +// ONLY WHERE A `via` COULD BE HONOURED. Inside a task, with no run road, or +// with no program carried, a `via` is refused anyway ([Agent.stageTask]), and a +// bounce there would be a round trip that ends where it began. + +import ( + "context" + "slices" + "strings" +) + +// programNamedSentence is what a proposal with no `via` reads back when the +// person's message named a program. It is a result the turn goes on from, and +// it offers both answers, because only the model can tell "use it" from +// "don't use it" and from a word that was never meant as the program's name. +func programNamedSentence(name string) string { + return "the person named " + name + ": if they want it to do this work, propose this again with `via: \"" + name + "\"`; " + + "if they asked for it not to be used, or did not mean the program, propose it again unchanged" +} + +// mayHandToProgram says a `via` on a proposal from this agent could be +// honoured: it is a conversation rather than a task, and the run road a +// program rides is linked. It is the one reading of that, asked by the +// refusal in [Agent.stageTask] and by the bounce below, so the two cannot +// disagree about where a program may be named. +func (a *Agent) mayHandToProgram() bool { + return !a.config.InTask && chatRunEngine != nil +} + +// programAskBounce is the once-per-message refusal of a proposal that left out +// the program the person named, or nil when this proposal is not turned back. +// +// ONCE IS COUNTED PER MESSAGE THAT NAMED THE PROGRAM. [Agent.personSeq] +// numbers what the person has typed, steering included, and the bounce is +// counted against the newest message of the turn that named one +// ([programsHeard]): a new message of theirs that names the program again +// earns one more bounce, a steer that names nothing earns none and does not +// unsay the one before it, and a woken turn, which types nothing, inherits the +// count of the message it is still answering. +// +// AND A PROPOSAL PASSES ONLY ONCE THE MODEL HAS READ THE BOUNCE, which is never +// in the step that made it. A model reads a result in the request after the +// batch that returned it, and every proposal of one message is staged before +// any of their results is read: side by side in the batch, or while the +// message is still arriving. "fix issues #31 and #32 with senior-dev" is two +// proposals in one message, and the second used to pass as though the first +// one's bounce had been read, go up with no `via`, and be admitted to codeaf's +// own worker by its countdown. So the mark carries the step that made it +// ([Agent.stepSeq]), every proposal without a `via` in that step is turned back +// too, and it is a proposal from a later step that passes as it is. The check +// and the mark are made under one lock. +func (a *Agent) programAskBounce(spec taskSpec) *askBounce { + if spec.via != "" || !a.mayHandToProgram() || len(a.config.Delegates) == 0 { + return nil + } + step := a.stepSeq.Load() + a.mu.Lock() + defer a.mu.Unlock() + heard := a.programsHeard + if heard.named == "" { + return nil + } + if a.programBounced.seq == heard.seq && a.programBounced.step < step { + return nil + } + bounce := &askBounce{agent: a, name: heard.named, prior: a.programBounced, mark: bounceMark{seq: heard.seq, step: step}} + a.programBounced = bounce.mark + return bounce +} + +// bounceMark is where a proposal was last turned back for leaving out the +// program the person named: the [Agent.personSeq] of the message that named +// it, and the [Agent.stepSeq] of the request whose proposals were turned back. +type bounceMark struct { + seq uint64 + step uint64 +} + +// askBounce is one proposal turned back by [Agent.programAskBounce], as the +// staged call it is (task.go's [Agent.stageTask]). It is its own [bare.Staged] +// rather than a settled refusal because it leaves something behind: the mark +// that lets the next proposal through. +type askBounce struct { + agent *Agent + name string + // mark is what this bounce wrote on [Agent.programBounced], and prior is + // what was there before it. + mark, prior bounceMark +} + +// Commit hands the bounce over as the call's result. +func (b *askBounce) Commit(context.Context) (string, bool, error) { + return programNamedSentence(b.name), true, nil +} + +// Withdraw takes the mark back. A call withdrawn before it went ahead is one +// the model never reads — the reply carrying it was cut, or the turn ended — +// so the bounce it carried was never read either, and the next proposal for +// the message has to be turned back in its place. A sibling from the same +// step wrote the same mark over this one, so the mark is put back only while +// it is still this bounce's own, and the siblings withdrawn in any order leave +// what was there before the first of them. +func (b *askBounce) Withdraw() { + b.agent.mu.Lock() + defer b.agent.mu.Unlock() + if b.agent.programBounced == b.mark { + b.agent.programBounced = b.prior + } +} + +// text is the sentence the bounce hands over, and "" for no bounce. +func (b *askBounce) text() string { + if b == nil { + return "" + } + return programNamedSentence(b.name) +} + +// programMayLiftFloor says a proposal made on a trivial ask whose verb is verb +// may pass the spawn floor ([Agent.refuseProposedTask]): the verb is not work +// on the person's own branch ([yourBranchVerbs]), and the person asked for a +// program in the turn they last spoke in — for a `via`, that program. A +// proposal with no `via` may pass only as far as the bounce that asks it to +// name the program, and meets the floor after that. A name this build does not +// carry is never heard ([Agent.hearProgramsLocked]), so it lifts nothing. +func (a *Agent) programMayLiftFloor(verb, via string) bool { + if yourBranchVerbs[verb] { + return false + } + a.mu.Lock() + defer a.mu.Unlock() + if via == "" { + return len(a.programsHeard.asked) > 0 + } + return slices.Contains(a.programsHeard.asked, via) +} + +// programsHeard is what the person's messages of one turn said about the +// programs this build carries. +// +// A TURN'S ASK IS EVERY MESSAGE TYPED INTO IT, not the newest one. A person +// names senior-dev as the turn opens and steers with a detail while the model +// reads the code, which is exactly when a model that has forgotten the name +// proposes without `via`, and the steer names nothing. [Agent.personAsk] is +// that steer by then, and reading it alone sent the work to codeaf's own +// worker and held "fix this file only" on the spawn floor although the turn +// had asked for senior-dev. So this is kept from the message that opens a turn +// to the last one steered into it, and started again by the next message that +// opens a turn. A woken turn types nothing and leaves it where it was, so it +// still answers the ask it was woken under. +type programsHeard struct { + // turn is the [Agent.turnSeq] the messages were typed into. + turn uint64 + // named is the program the newest message that named one named, and seq is + // that message's [Agent.personSeq], which the bounce is counted against. + named string + seq uint64 + // asked is every program a message of the turn asked to do the work + // ([Config.programsAskedIn]), which is what lifts the floor for a proposal + // whose `via` is one of them. + asked []string +} + +// hearProgramsLocked reads one message of the person's for the programs this +// build carries, into [Agent.programsHeard]. [Agent.rememberAskLocked] is the +// one caller, after the message is numbered, so every message they type is +// read here exactly once and nothing the session wrote itself is. The caller +// holds a.mu. +func (a *Agent) hearProgramsLocked(text string) { + if len(a.config.Delegates) == 0 { + return + } + if a.programsHeard.turn != a.turnSeq { + a.programsHeard = programsHeard{turn: a.turnSeq} + } + for _, name := range a.config.programsAskedIn(text) { + if !slices.Contains(a.programsHeard.asked, name) { + a.programsHeard.asked = append(a.programsHeard.asked, name) + } + } + if name := a.config.programNamedIn(text); name != "" { + a.programsHeard.named, a.programsHeard.seq = name, a.personSeq + } +} + +// programNamedIn is the first program, by name, that the person's words name, +// or "" when they name none. +func (c Config) programNamedIn(asked string) string { + words := normalizedWords(asked) + for _, name := range c.delegateNames() { + if namesProgram(words, name) { + return name + } + } + return "" +} + +// namesProgram says the words hold a program's name the ways a person types +// it: in any case, as `/name`, and with spaces or with nothing where the name +// has hyphens ("senior dev", "seniordev"). [normalizedWords] has already +// lowered the case and split on every mark that is not a letter or a digit, so +// the name is a run of whole words, or those words written as one. +func namesProgram(words []string, name string) bool { + parts := normalizedWords(name) + for at := range words { + if nameWidthAt(words, at, parts) > 0 { + return true + } + } + return false +} + +// nameWidthAt is how many of the words the name (split as [normalizedWords] +// splits it) takes starting at words[at]: one when it is written as one word, +// all of its parts when it is spelled out, and zero when it is not there. +func nameWidthAt(words []string, at int, parts []string) int { + switch { + case len(parts) == 0: + return 0 + case words[at] == strings.Join(parts, ""): + return 1 + case slices.Equal(words[at:min(at+len(parts), len(words))], parts): + return len(parts) + } + return 0 +} + +// programAskWords are the words that, right before a program's name, hand it +// the work: "fix this file with senior-dev", "give it to senior-dev", "have +// senior-dev fix it". A word that only points at the program ("the senior-dev +// run", "what senior-dev did") is not one. +var programAskWords = map[string]bool{ + "with": true, + "via": true, + "using": true, + "use": true, + "give": true, + "hand": true, + "to": true, + "have": true, + "let": true, + "ask": true, + "get": true, + "want": true, +} + +// programsAskedIn is every program the person's words ask to do the work, +// which is stricter than naming it ([Config.programNamedIn]): the program's +// command typed as a word of its own (`/senior-dev`), or its name first in the +// message, as the one addressed, or right after one of [programAskWords]. A +// possessive is never an ask ("revert senior-dev's commit"), and neither is +// the name anywhere else ("undo what senior-dev did"). +func (c Config) programsAskedIn(text string) []string { + words := normalizedWords(text) + var asked []string + for _, name := range c.delegateNames() { + if typedAsCommand(text, name) || addressedByName(words, normalizedWords(name)) { + asked = append(asked, name) + } + } + return asked +} + +// addressedByName says the name stands in the words as the one asked to do +// the work: first in them, or right after a word that hands it the work, and +// not followed by the "s" a possessive leaves once its apostrophe is gone. +func addressedByName(words, parts []string) bool { + for at := range words { + width := nameWidthAt(words, at, parts) + if width == 0 || (at+width < len(words) && words[at+width] == "s") { + continue + } + if at == 0 || programAskWords[words[at-1]] { + return true + } + } + return false +} + +// typedAsCommand says the text holds the program's command, `/name`, as a word +// of its own. The same letters inside a path ("internal/senior-dev/main.go") +// are not one, so the slash must open a word and the name must end one, on the +// same reading of a name's bytes the brief's path guard uses ([pathByte]). +func typedAsCommand(text, name string) bool { + lower, command := strings.ToLower(text), "/"+name + for from := 0; from < len(lower); { + at := strings.Index(lower[from:], command) + if at < 0 { + return false + } + at += from + end := at + len(command) + if (at == 0 || !pathByte(lower[at-1])) && (end == len(lower) || !pathByte(lower[end])) { + return true + } + from = end + } + return false +} diff --git a/internal/session/delegate_asked_test.go b/internal/session/delegate_asked_test.go new file mode 100644 index 0000000000..f4040303a3 --- /dev/null +++ b/internal/session/delegate_asked_test.go @@ -0,0 +1,514 @@ +package session + +import ( + "context" + "encoding/json" + "slices" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/manual" +) + +// heard records text as the person's newest message, where a turn they open +// or steer records it ([Agent.rememberAskLocked]). +func heard(agent *Agent, text string) { + agent.mu.Lock() + defer agent.mu.Unlock() + agent.rememberAskLocked(userText(text)) +} + +// programConversation is a conversation that carries a program called +// senior-dev, with the run road linked, which is where a `via` can be +// honoured and so where the person naming it is read. The engine is a double +// nothing here starts. +func programConversation(t *testing.T, mutate func(*Config)) *Agent { + t.Helper() + registerBeltRunEngine(t, newBeltRunDouble("unused")) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Delegates = testPrograms("senior-dev") + if mutate != nil { + mutate(config) + } + }) + return agent +} + +// proposeOutputs is every propose_task result a turn showed, in order, clean +// or refused. +func proposeOutputs(events []Event) []string { + var outputs []string + for _, event := range events { + if event.Tool == "propose_task" && (event.Kind == EventToolEnd || event.Kind == EventToolFailed) { + outputs = append(outputs, event.Output) + } + } + return outputs +} + +// A PROPOSAL THAT LEAVES OUT THE PROGRAM THE PERSON NAMED IS TURNED BACK ONCE. +// The person asked for senior-dev and the model proposed the work for its own +// worker, which is the proposal a model writes by habit: it is told who was +// named and both ways to answer. The next proposal for the same message, made +// after the model has read that, passes as it is, because "don't use +// senior-dev" is an ask too, and only the model can read which one it was. +func TestAProposalLeavingOutTheProgramThePersonNamedIsTurnedBackOnce(t *testing.T) { + registerBeltRunEngine(t, newBeltRunDouble("unused")) + completer := &routedCompleter{parent: []step{ + proposeCall("Fix the dropped retries", "the scheduler drops retries under load"), + proposeCall("Fix the dropped retries", "the scheduler drops retries under load"), + finalText("started"), + }} + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.Delegates = testPrograms("senior-dev") + }) + nodes := make(ranNodes, 4) + graph := stubbedGraph(agent, func(node *TaskNode) { nodes <- node }) + + collected := collect(t, mustSubmit(t, agent, "the scheduler drops retries under load; fix it with senior-dev")) + + outputs := proposeOutputs(collected) + if len(outputs) != 2 { + t.Fatalf("want two proposal results, got %d: %q", len(outputs), outputs) + } + if want := programNamedSentence("senior-dev"); outputs[0] != want { + t.Fatalf("the first proposal read %q, want %q", outputs[0], want) + } + for _, want := range []string{"the person named senior-dev", "`via: \"senior-dev\"`", "propose it again unchanged"} { + if !strings.Contains(outputs[0], want) { + t.Fatalf("the bounce does not say %q: %q", want, outputs[0]) + } + } + if !strings.HasPrefix(outputs[1], "task ") { + t.Fatalf("the second proposal for the same message was not let through: %q", outputs[1]) + } + nodes.await(t) + if admitted(graph) != 1 { + t.Fatalf("the graph admitted %d nodes, want the one second proposal", admitted(graph)) + } +} + +// proposalsInOneMessage is the model sending several proposals in one message, +// which is how the hand-off page tells it to work in parallel. Each call has an +// id of its own, as a provider's calls in one message do. +func proposalsInOneMessage(titles ...string) step { + return func(context.Context, []ai.Message) (*ai.Response, error) { + calls := make([]ai.ToolCall, len(titles)) + for index, title := range titles { + arguments, _ := json.Marshal(taskArguments{ + Title: title, + Summary: "two lines the person reads", + Brief: title + "\n" + taskBriefMark, + Deliverable: "the fix, on the branch the run leaves", + Acceptance: "the issue's own reproduction passes", + }) + calls[index] = ai.ToolCall{ + ID: "call-task-" + strconv.Itoa(index), + Type: "function", + Function: ai.ToolCallFunction{Name: "propose_task", Arguments: string(arguments)}, + } + } + return callsResponse(calls...), nil + } +} + +// EVERY PROPOSAL OF ONE MESSAGE IS TURNED BACK. "fix issues #31 and #32 with +// senior-dev" is two proposals in one message, sent together before the model +// has read either result. Only the first used to be turned back: the second +// passed as though the model had read the bounce, went up with no `via`, and +// its countdown admitted it to codeaf's own worker against the person's ask. +// Both are turned back, and the proposals of the message the model writes +// after reading them pass as they are. +func TestEveryProposalOfOneMessageIsTurnedBack(t *testing.T) { + registerBeltRunEngine(t, newBeltRunDouble("unused")) + completer := &routedCompleter{parent: []step{ + proposalsInOneMessage("Fix issue #31", "Fix issue #32"), + proposalsInOneMessage("Fix issue #31", "Fix issue #32"), + finalText("started"), + }} + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.Delegates = testPrograms("senior-dev") + }) + nodes := make(ranNodes, 4) + graph := stubbedGraph(agent, func(node *TaskNode) { nodes <- node }) + + outputs := proposeOutputs(collect(t, mustSubmit(t, agent, "fix issues #31 and #32 with senior-dev"))) + + if len(outputs) != 4 { + t.Fatalf("want four proposal results, got %d: %q", len(outputs), outputs) + } + for _, output := range outputs[:2] { + if output != programNamedSentence("senior-dev") { + t.Fatalf("a proposal sent beside the one turned back read %q, want the bounce too: %q", output, outputs[:2]) + } + } + for _, output := range outputs[2:] { + if !strings.HasPrefix(output, "task ") { + t.Fatalf("a proposal written after the bounce was read was not let through: %q", output) + } + } + nodes.await(t) + nodes.await(t) + if admitted(graph) != 2 { + t.Fatalf("the graph admitted %d nodes, want the two proposals of the second message", admitted(graph)) + } +} + +// THE NAME IS HEARD HOWEVER A PERSON TYPES IT: as its command, in any case, +// with a space or nothing where it has a hyphen. And a word that only shares +// part of it is not the name. +func TestTheProgramIsHeardHoweverThePersonSpellsIt(t *testing.T) { + config := Config{Delegates: testPrograms("senior-dev")} + for _, asked := range []string{ + "/senior-dev fix the flaky retry", + "fix the flaky retry with senior dev", + "have Senior-Dev fix the flaky retry", + "SENIORDEV should take this one", + "use senior_dev for it", + "senior-dev", + } { + if got := config.programNamedIn(asked); got != "senior-dev" { + t.Errorf("%q named %q, want senior-dev", asked, got) + } + } + for _, asked := range []string{ + "fix the flaky retry", + "a senior developer wrote this", + "ask a senior about the dev branch", + "dev senior", + "", + } { + if got := config.programNamedIn(asked); got != "" { + t.Errorf("%q named %q, want nothing", asked, got) + } + } + for _, asked := range []string{"/senior-dev fix the retry", "fix the retry with senior dev", "Senior-Dev, fix the retry"} { + agent := programConversation(t, nil) + heard(agent, asked) + if bounce := agent.programAskBounce(taskSpec{title: "t"}).text(); bounce != programNamedSentence("senior-dev") { + t.Errorf("%q: the proposal without via read %q, want the bounce", asked, bounce) + } + } +} + +// NO BOUNCE WHERE NOTHING WAS NAMED, OR WHERE A `via` COULD NOT BE HONOURED. +// A message that names no program is proposed as it always was; a task node, +// a build with no run road and a build carrying no program would refuse the +// `via` the bounce asks for, so a bounce there is a round trip to nowhere. +// And a proposal that already names a program is never turned back. +func TestNoBounceWhereNothingWasNamedOrNoProgramCouldBe(t *testing.T) { + plain := programConversation(t, nil) + heard(plain, "the scheduler drops retries under load; fix it") + if bounce := plain.programAskBounce(taskSpec{}).text(); bounce != "" { + t.Fatalf("a message naming no program was bounced: %q", bounce) + } + + named := "the scheduler drops retries under load; fix it with senior-dev" + inTask := programConversation(t, func(config *Config) { config.InTask = true }) + heard(inTask, named) + if bounce := inTask.programAskBounce(taskSpec{}).text(); bounce != "" { + t.Fatalf("a task node was bounced toward a program it cannot name: %q", bounce) + } + + noPrograms := programConversation(t, func(config *Config) { config.Delegates = nil }) + heard(noPrograms, named) + if bounce := noPrograms.programAskBounce(taskSpec{}).text(); bounce != "" { + t.Fatalf("a build carrying no program was bounced: %q", bounce) + } + + withVia := programConversation(t, nil) + heard(withVia, named) + if bounce := withVia.programAskBounce(taskSpec{via: "senior-dev"}).text(); bounce != "" { + t.Fatalf("a proposal naming the program was bounced: %q", bounce) + } + + registerBeltRunEngine(t, nil) + noRoad, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.Delegates = testPrograms("senior-dev") }) + heard(noRoad, named) + if bounce := noRoad.programAskBounce(taskSpec{}).text(); bounce != "" { + t.Fatalf("a build with no run road was bounced: %q", bounce) + } +} + +// modelReadTheResults is the turn sending its next request, which is the one +// moment a model reads what its last batch returned ([episode.decisionBegins]). +func modelReadTheResults(agent *Agent) { agent.stepSeq.Add(1) } + +// refusedWith is what a proposal of spec reads back from the door refusals, +// and "" when none of them turns it around. +func refusedWith(agent *Agent, spec taskSpec) string { + refusal := agent.refuseProposedTask(spec) + if refusal == nil { + return "" + } + text, _, _ := refusal.Commit(context.Background()) + return text +} + +// ONCE IS PER MESSAGE. A second message of the person's that names the program +// again is a new ask and earns its own bounce; a proposal for the same message +// made after the model has read the bounce does not. +func TestTheBounceIsOncePerMessageOfThePersons(t *testing.T) { + agent := programConversation(t, nil) + heard(agent, "fix the flaky retry with senior-dev") + if agent.programAskBounce(taskSpec{}) == nil { + t.Fatal("the first proposal for the message was not bounced") + } + modelReadTheResults(agent) + if bounce := agent.programAskBounce(taskSpec{}).text(); bounce != "" { + t.Fatalf("the proposal made after the bounce was read was bounced again: %q", bounce) + } + heard(agent, "no really, give it to senior-dev") + if agent.programAskBounce(taskSpec{}) == nil { + t.Fatal("a new message naming the program again was not bounced") + } +} + +// AND NOT BEFORE THE MODEL HAS READ IT. Every proposal of one message is +// staged before any of their results is read, so a second proposal from the +// same step is turned back beside the first rather than passing as though the +// bounce had been read. And a bounce withdrawn before it went ahead (the reply +// carrying it was cut) was never read at all: it takes its mark back, and the +// next proposal is turned back in its place. +func TestTheBounceIsNotSpentUntilTheModelHasReadIt(t *testing.T) { + agent := programConversation(t, nil) + heard(agent, "fix issues #31 and #32 with senior-dev") + first, second := agent.programAskBounce(taskSpec{}), agent.programAskBounce(taskSpec{}) + if first == nil || second == nil { + t.Fatalf("two proposals of one step were not both bounced: %q, %q", first.text(), second.text()) + } + + first.Withdraw() + second.Withdraw() + modelReadTheResults(agent) + if agent.programAskBounce(taskSpec{}) == nil { + t.Fatal("the proposal after a withdrawn bounce passed, and the model never read that bounce") + } + + withdrawnLate := programConversation(t, nil) + heard(withdrawnLate, "fix issues #31 and #32 with senior-dev") + first, second = withdrawnLate.programAskBounce(taskSpec{}), withdrawnLate.programAskBounce(taskSpec{}) + second.Withdraw() + first.Withdraw() + modelReadTheResults(withdrawnLate) + if withdrawnLate.programAskBounce(taskSpec{}) == nil { + t.Fatal("siblings withdrawn in the other order left a mark behind") + } + + arguments, _ := json.Marshal(taskArguments{Title: "t", Summary: "s", Brief: "b", Deliverable: "d", Acceptance: "a"}) + throughTheDoor := programConversation(t, nil) + heard(throughTheDoor, "fix issues #31 and #32 with senior-dev") + hold := bare.NewHold() + hold.Withdraw() + if _, _, err := throughTheDoor.proposeTask(bare.WithHold(context.Background(), hold), arguments); err != nil { + t.Fatalf("the withdrawn proposal errored the turn: %v", err) + } + modelReadTheResults(throughTheDoor) + if bounce := refusedWith(throughTheDoor, taskSpec{}); bounce != programNamedSentence("senior-dev") { + t.Fatalf("after a proposal withdrawn from the door, the next one read %q, want the bounce", bounce) + } +} + +// heardInANewTurn records text as the message that opens a turn of its own, +// rather than one steered into the turn running ([Agent.startTurnLocked]). +func heardInANewTurn(agent *Agent, text string) { + agent.mu.Lock() + defer agent.mu.Unlock() + agent.turnSeq++ + agent.rememberAskLocked(userText(text)) +} + +// A STEER DOES NOT UNSAY THE PROGRAM. The person named senior-dev when the turn +// opened, then steered with a detail while the model read the code. The steer +// is the newest thing they typed and names no program, and it used to be all +// the bounce and the floor read: a proposal without `via` went to codeaf's own +// worker, and "fix this file only" steered after the ask held senior-dev on the +// floor. The ask stands for the rest of the turn it was typed into, and a woken +// turn still answering it; the steer earns no bounce of its own, because it +// named nothing new; and a message that opens a turn of its own is a new ask. +func TestASteerDoesNotUnsayTheProgramThePersonNamed(t *testing.T) { + named := "fix the dropped-retries issue with senior-dev" + steered := programConversation(t, nil) + heard(steered, named) + heard(steered, "the failing test is TestRetryUnderLoad") + if bounce := refusedWith(steered, taskSpec{}); bounce != programNamedSentence("senior-dev") { + t.Fatalf("after a steer naming nothing, the proposal without via read %q, want the bounce", bounce) + } + modelReadTheResults(steered) + heard(steered, "and keep the old retry budget") + if bounce := refusedWith(steered, taskSpec{}); bounce != "" { + t.Fatalf("a steer naming nothing new earned a second bounce: %q", bounce) + } + + floored := programConversation(t, nil) + heard(floored, named) + heard(floored, "fix this file only") + if !trivialAsk("fix this file only") { + t.Fatal("the steer is off the floor, so this would prove nothing") + } + if refusal := refusedWith(floored, taskSpec{via: "senior-dev"}); refusal != "" { + t.Fatalf("a trivial steer held the program the person asked for on the floor: %q", refusal) + } + + woken := programConversation(t, nil) + heard(woken, named) + woken.mu.Lock() + woken.turnSeq++ + woken.mu.Unlock() + if bounce := refusedWith(woken, taskSpec{}); bounce != programNamedSentence("senior-dev") { + t.Fatalf("a woken turn still answering the ask read %q, want the bounce", bounce) + } + + nextTurn := programConversation(t, nil) + heard(nextTurn, named) + heardInANewTurn(nextTurn, "now tidy the changelog") + if bounce := refusedWith(nextTurn, taskSpec{}); bounce != "" { + t.Fatalf("a message opening a turn of its own was read with the last turn's program: %q", bounce) + } +} + +// AN ASK FOR A PROGRAM IS NEVER TOO SMALL. "fix this file with senior-dev" is +// on the spawn floor as a one-file fix, and the floor ran before `via` was +// read, so the person who asked for senior-dev by name was refused with "do it +// here". The proposal that names the program they asked for passes the floor; +// one that leaves it out is bounced once and then meets the floor as any +// proposal does; and a program the person did not ask for lifts nothing. +func TestAnAskForAProgramIsNeverTooSmall(t *testing.T) { + asked := "fix this file with senior-dev" + if !trivialAsk(asked) { + t.Fatalf("%q is off the floor, so this test would prove nothing", asked) + } + agent := programConversation(t, nil) + heard(agent, asked) + if refusal := refusedWith(agent, taskSpec{via: "senior-dev"}); refusal != "" { + t.Fatalf("the proposal naming the program the person asked for was refused: %q", refusal) + } + if refusal := refusedWith(agent, taskSpec{}); refusal != programNamedSentence("senior-dev") { + t.Fatalf("the proposal leaving the program out read %q, want the bounce", refusal) + } + modelReadTheResults(agent) + if refusal := refusedWith(agent, taskSpec{}); refusal != spawnFloorRefusal { + t.Fatalf("the second proposal leaving the program out read %q, want the floor", refusal) + } + + unasked := programConversation(t, nil) + heard(unasked, "fix this file") + if refusal := refusedWith(unasked, taskSpec{via: "senior-dev"}); refusal != spawnFloorRefusal { + t.Fatalf("a program nobody asked for lifted the floor: %q", refusal) + } + if refusal := refusedWith(unasked, taskSpec{via: "nosuch"}); refusal != spawnFloorRefusal { + t.Fatalf("a program this build does not carry lifted the floor: %q", refusal) + } +} + +// A COMMIT, AN UNDO OR A REVERT STAYS HERE, WHATEVER IT NAMES. "revert +// senior-dev's commit" is on the floor as a revert, and it names senior-dev, +// which used to lift the floor: the proposal without `via` was bounced toward +// senior-dev, and the one with it went up and started a billed run on a branch +// of its own, where the revert the person asked for can never land on their +// branch. And a name said in passing is no ask for the program: "fix +// senior-dev's typo in this file" is a one-file fix, and stays one without a +// bounce toward a program the floor would then refuse. +func TestAnAskThatOnlyMentionsAProgramStaysOnTheFloor(t *testing.T) { + for _, asked := range []string{ + "revert senior-dev's commit", + "commit senior-dev's changes", + "undo what senior-dev did", + "git revert the senior-dev commit", + "revert this commit with senior-dev", + "fix senior-dev's typo in this file", + "fix the line senior-dev changed in this file", + } { + if !trivialAsk(asked) { + t.Fatalf("%q is off the floor, so this test would prove nothing", asked) + } + for _, spec := range []taskSpec{{via: "senior-dev"}, {}} { + agent := programConversation(t, nil) + heard(agent, asked) + if refusal := refusedWith(agent, spec); refusal != spawnFloorRefusal { + t.Errorf("%q with via %q read %q, want the floor", asked, spec.via, refusal) + } + } + } +} + +// THE NAME LIFTS THE FLOOR WHERE IT ASKS FOR THE PROGRAM: typed as its command, +// first in the message, or right after a word that hands it the work. The +// same name as a possessive, or after a word that only points at it, is a +// mention, and the looser reading that turns a proposal back +// ([Config.programNamedIn]) still hears it. +func TestTheProgramIsAskedForOnlyWhereTheWordsAskForIt(t *testing.T) { + config := Config{Delegates: testPrograms("senior-dev")} + for _, asked := range []string{ + "fix this file with senior-dev", + "fix this one line using senior dev", + "fix this file, give it to senior-dev", + "fix this file via /senior-dev", + "edit this file, have seniordev do it", + "fix this file /senior-dev", + "senior-dev, fix this file", + "Senior Dev should take this one", + "let senior-dev do it", + "ask senior-dev to fix the retry", + "I want senior-dev on this", + } { + if got := config.programsAskedIn(asked); !slices.Equal(got, []string{"senior-dev"}) { + t.Errorf("%q asked for %q, want senior-dev", asked, got) + } + } + for _, asked := range []string{ + "revert senior-dev's commit", + "undo what senior-dev did", + "fix the line senior-dev changed in this file", + "senior-dev's branch broke the build", + "the senior-dev run left a typo", + "fix the typo in internal/senior-dev/main.go", + "fix this file", + } { + if got := config.programsAskedIn(asked); len(got) != 0 { + t.Errorf("%q asked for %q, want nothing", asked, got) + } + } + if config.programNamedIn("revert senior-dev's commit") != "senior-dev" { + t.Fatal("the reading that turns a proposal back no longer hears a mention") + } + for _, asked := range []string{"fix this file with senior-dev", "fix this file /senior-dev"} { + agent := programConversation(t, nil) + heard(agent, asked) + if refusal := refusedWith(agent, taskSpec{via: "senior-dev"}); refusal != "" { + t.Errorf("%q: the proposal naming the program asked for was refused: %q", asked, refusal) + } + } +} + +// THE PROGRAMS PAGE QUOTES THE BOUNCE AS THE MODEL READS IT, so a person +// asking why their proposal came back, and the chat answering from the page, +// both read the sentence that was actually sent. +func TestTheProgramsPageQuotesTheBounceWordForWord(t *testing.T) { + page, found := manual.Chat().Page("delegates") + if !found { + t.Fatal("there is no chat manual page called delegates") + } + if want := programNamedSentence("senior-dev"); !strings.Contains(page, want) { + t.Fatalf("the programs page does not quote the bounce %q", want) + } +} + +// AND THE WHOLE DOOR SAYS IT: a proposal called for a one-file fix the person +// gave to senior-dev is refused with the bounce, and never with the floor's +// "do it here", which is what the person was told before. +func TestTheDoorBouncesARequestedProgramBeforeTheFloor(t *testing.T) { + agent := programConversation(t, nil) + heard(agent, "fix this file with senior-dev") + arguments, _ := json.Marshal(taskArguments{Title: "t", Summary: "s", Brief: "b", Deliverable: "d", Acceptance: "a"}) + result, isError, err := agent.proposeTask(context.Background(), arguments) + if err != nil { + t.Fatalf("proposeTask errored the turn: %v", err) + } + if !isError || result != programNamedSentence("senior-dev") { + t.Fatalf("the proposal read %q (error %v), want the bounce", result, isError) + } +} diff --git a/internal/session/delegate_door.go b/internal/session/delegate_door.go new file mode 100644 index 0000000000..df13053837 --- /dev/null +++ b/internal/session/delegate_door.go @@ -0,0 +1,404 @@ +package session + +// THE DELEGATE DOOR: how a conversation hands a task to a program codeaf +// carries — senior-dev first (internal/delegate, docs/design/delegate/). A +// program is one more worker kind behind the run engine, and this file is the +// half a conversation needs of it: which programs this build carries, the door +// `/ ` and `propose_task`'s `via` both open, and the landing of a +// run whose worker was a program rather than a bash worker. +// +// "DELEGATE" IS A WORKING TITLE. Every sentence here a person or the model can +// read names the program itself, so a later rename of the idea changes code +// and never a promise already made on a screen. +// +// IT RIDES THE RUN ROAD WHATEVER THE BELT SAYS. `/task` takes the run road only +// under CODEAF_TASK_BELT=bash, because that road's WORKER is the bash belt. A +// program's worker is the program, so the road is asked for outright here: the +// store, the supervisor and the row are the run's, and nothing in them reads +// the belt switch. What a delegated run does not have is a copy — a program +// that edits files works in the folder itself, on a branch of its own in a +// repository (programfolder.go) — or the review round, because a check seat is +// a bash-belt worker and the belt may be off; the program's own verification is +// what its terminal record reports. + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// DelegateRow is one program as a surface lists it: the command word, the +// sentence under it, and what it leaves behind. +type DelegateRow struct { + Name string + Description string + // Lands is delegate.LandsTree or delegate.LandsText. + Lands string +} + +// DelegateReport is the programs this conversation can hand work to, as the +// surface draws its command rows from them. +type DelegateReport struct { + Rows []DelegateRow +} + +// Delegates is the report for this conversation. A build that carries none +// answers the zero report, and the surface draws no rows. +func (a *Agent) Delegates() DelegateReport { return a.config.delegateReport() } + +func (c Config) delegateReport() DelegateReport { + var report DelegateReport + for _, program := range c.Delegates { + lands := program.Lands + if lands == "" { + lands = delegate.LandsTree + } + report.Rows = append(report.Rows, DelegateRow{Name: program.Name, Description: program.Summary, Lands: lands}) + } + return report +} + +// delegateNames is the programs' names, sorted, for the prompt and the refusal. +func (c Config) delegateNames() []string { + names := make([]string, 0, len(c.Delegates)) + for _, program := range c.Delegates { + names = append(names, program.Name) + } + sort.Strings(names) + return names +} + +// mayDelegate says whether this belt may hand work to a program: it is the +// conversation's own hand-off predicate with one more condition, that this +// build carries at least one. A task node never delegates, for the reason it +// never proposes: there is nowhere for the work to go from there. +func (c Config) mayDelegate() bool { + return c.mayProposeTask() && !c.InTask && len(c.Delegates) > 0 +} + +// delegateFact is the hand-off page's one paragraph about these programs. It is +// rendered only where [Config.mayDelegate] holds, and its `fill` writes the +// programs in — each one's name and its own guide — so the model is told the +// words it can put in `via` and never a name this build does not carry. +// +// THE PARAGRAPH SAYS WHAT CODEAF DOES, AND EACH PROGRAM SAYS WHAT IT IS. What +// a program is for, what its brief must hold and what it needs of its folder +// are the program's own [delegate.Delegate.Guide], printed under its name, so +// nothing here names senior-dev. What is true of every program that edits +// files — that it works in the folder itself, and so which folder it must be +// handed — is codeaf's mechanics, and is said here once ([delegateFolderRule]). +// That nobody can be asked anything is `propose_task`'s own `brief` +// description, and that small work is never handed off is this section's +// own; neither is said a second time here. +// +// AND CODEAF PREFERS A PROGRAM FOR THE WORK IT IS FOR. The paragraph said a +// large task "can" go to one, which is a permission, and a permission loses +// to the two roads the rest of the section teaches: an issue in a mature +// project is worked through inline, or handed to codeaf's own worker, which +// has none of a program's machinery for that work. The owner's call of +// 2026-09-24 is that codeaf reach for a program by itself on complicated, +// many-sided coding work, and almost always when the person asks for one. So +// the first sentence is a preference over both of those roads, the program's +// guide under it says which work that is, and WHATEVER ITS CRITICAL PATH is +// said because the section's own test for a hand-off is the critical path: +// one hard fix has a single one, and a model holding that test alone would +// keep the fix. The second sentence is the person's ask, which outranks the +// section's floor on small work; code holds both halves of it, where a prompt +// would be forgotten (delegate_asked.go). +// +// THE TWO SENTENCES STAND APART FROM THE FOLDER RULE, which is spliced in +// after them, so either can be reworded without touching the other. +var delegateFact = beltFact{ + tools: []string{"propose_task"}, + holds: Config.mayDelegate, + present: "AND WORK A PROGRAM BUILT INTO CODEAF IS FOR GOES TO IT WHOLE, named in\n" + + "`propose_task`'s `via`, rather than to you or a worker, whatever its critical path;\n" + + "so does work the person asks one for, by name or as `/name`.%s The programs here:\n%s", + fill: func(config Config, text string) string { + rule := "" + if config.carriesTreeProgram() { + rule = delegateFolderRule + } + return fmt.Sprintf(text, rule, config.delegateGuides()) + }, +} + +// delegateFolderRule is codeaf's one sentence about the folder a program that +// edits files is handed, and it is printed only when the build carries one. +// +// IT EXISTS BECAUSE A MODEL SENT SENIOR-DEV TO THE WRONG REPOSITORY. Asked to +// solve a benchmark task whose code lived in a repository not on the machine, +// the conversation handed senior-dev the one repository it knew — the +// benchmark's, which holds the task's reference solution beside its statement — +// and wrote a brief telling it to make a checkout of the real one. senior-dev +// cloned it into the person's own projects folder and edited it there, and the +// task ended saying it had changed nothing. The program works in the folder +// the proposal names ([PrepareProgramFolder]), so the folder is the one thing +// the model has to get right, and fetching a repository that is not here is +// its job, done before the proposal. +// +// AND IT PROMISES NO MERGE, because there is none: in a repository the work is +// left on the program's own branch, checked out in that folder, and bringing +// it into the person's branch is a separate step the landing's line names. +const delegateFolderRule = "\nIt works in the task's folder itself, on a branch of its own in a repository, so hand\n" + + "it the repository the work belongs in: clone one this machine lacks into a new folder,\n" + + "at the commit the work names, and pass it as `ground`. Never brief it to work elsewhere." + +// carriesTreeProgram says whether any program this conversation can hand work +// to edits files, which is when [delegateFolderRule] is true of it. +func (c Config) carriesTreeProgram() bool { + for _, program := range c.Delegates { + if program.LandsTree() { + return true + } + } + return false +} + +// delegateGuides is the programs as the hand-off paragraph lists them: one item +// each, sorted by name, the name as `via` takes it and then the program's own +// guide. +func (c Config) delegateGuides() string { + programs := append([]delegate.Delegate(nil), c.Delegates...) + sort.Slice(programs, func(i, j int) bool { return programs[i].Name < programs[j].Name }) + items := make([]string, 0, len(programs)) + for _, program := range programs { + items = append(items, "- `"+program.Name+"`: "+strings.TrimSpace(program.Guide)) + } + return strings.Join(items, "\n") +} + +// delegateReceipt is the sentence an approved hand-off to a program adds to +// its receipt: who has the work, where, and where it will be when it ends. +// ground is the folder the run was started on and record the run's own record +// of it, written as the run started ([runCopyOf]): the program's branch, and +// the person's branch it was cut from. They are read off the record and not off +// the live run, which a program that dies in its first second has already +// left by the time the receipt is written. +// +// IT NEVER SAYS THE WORK LANDS. A program's work is left on its own branch and +// merged by nobody; a model that read "lands" told the person their branch +// held work it did not. +// +// IT NAMES THE FOLDER. A receipt that said "a copy" and "the folder itself" +// without saying which let a model that had named ~/Desktop/pong read that its +// program was there while it had been handed the person's home folder. +// +// AND IT SAYS THE FOLDER IS THE PROGRAM'S UNTIL IT ENDS ([programHoldGuard]). +// The receipt's next sentence invites the model to carry on with other work, +// and a model that did it by writing into the program's folder was refused one +// file at a time; told once here, it works elsewhere or waits. +func delegateReceipt(ground string, via delegate.Delegate, record *TaskCopyRecord) string { + if !via.LandsTree() { + return "It is " + via.Name + "'s: it works alone, and its answer arrives when it ends." + } + return delegateFolderReceipt(ground, via, record) + " Until it ends, codeaf's own tools write nothing in " + ground + "." +} + +// delegateFolderReceipt is where a program that edits files works, as its +// receipt says it ([delegateReceipt]). +// +// A FOLDER WITH NO BRANCH IS NOT ALWAYS A FOLDER WITH NO HISTORY. One inside a +// repository whose root holds the home folder — a dotfiles repository — is +// worked in without git because codeaf will not cut a branch there, and a +// receipt that said it "has no git history" had the chat telling the person so, +// or advising `git init` inside their dotfiles; it names the repository, the +// way the run's ending does ([ProgramFolderEnd.Sentence]). +func delegateFolderReceipt(ground string, via delegate.Delegate, record *TaskCopyRecord) string { + if record == nil || record.Branch == "" { + if _, _, outer, _ := programFolderOf(ground); outer != "" { + return "It is " + via.Name + "'s: it works alone in " + ground + " itself, inside the git repository at " + outer + + ", which holds your home folder, so codeaf cuts no branch there and commits nothing; its changes are there as it makes them." + } + return "It is " + via.Name + "'s: it works alone in " + ground + " itself, which has no git history, so its changes are there as it makes them." + } + folder := ProgramFolder{Home: record.Home, Start: record.HomeSha} + stays := folder.homeWords() + " does not move" + // THE PROMISE IS READ BEFORE IT IS MADE ([ProgramFolder.homeMoved]): a + // branch of the person's that has already moved from where the run was + // cut is said to have, rather than promised to stay. One that cannot be + // read at all, or a record that never wrote down where it stood, is a + // branch this receipt cannot hold up against anything, and keeps the + // promise the run itself keeps. + if tip := branchCommit(ground, record.Home); tip != "" && record.HomeSha != "" && tip != record.HomeSha { + stays = "your branch " + record.Home + " has already moved, from " + shortSha(record.HomeSha) + " to " + shortSha(tip) + + ", and codeaf does not move it" + } + on := "on a new branch " + record.Branch + if record.Continues { + on = "carrying on on its branch " + record.Branch + ", where the last run left it" + } + return "It is " + via.Name + "'s: it works alone in " + ground + " itself, " + on + "; " + + stays + ", and when it ends " + record.Branch + " stays checked out there with its work." +} + +// runRowCopy is the record a run's row was published with as it started +// ([runCopyOf]): where it works, and a program's branch. Nil when there is +// none. +func (a *Agent) runRowCopy(id uint64) *TaskCopyRecord { + g := a.graph() + if g == nil { + return nil + } + if kept, ok := runRowOf(g, id); ok { + return kept.Copy + } + return nil +} + +// programPlace is where a program works, in a person's words: the folder +// itself, on a branch of its own when it is a repository and the program +// edits code. +func programPlace(program delegate.Delegate, ground string) string { + if !program.LandsTree() || !hasGitHistory(ground) { + return ground + } + return ground + ", on a branch of its own" +} + +// hasGitHistory says a program handed ground works there on a branch of its +// own: ground is in a repository with a commit, whose root is below the home +// folder. It is the one reading of "a branch or not" ([programFolderOf]), so +// the card, the receipt and the run cannot disagree about it. +func hasGitHistory(ground string) bool { + _, repo, _, _ := programFolderOf(ground) + return repo +} + +// delegateStartedReceipt is an approved hand-off's receipt: a task's first line +// and its wake sentence, with the program's own account of where it works +// ([delegateReceipt]) in place of a task's "in a copy of its own", which a +// program never is. +// on is the models the person asked it to work with, "" for the crew's. +func delegateStartedReceipt(id uint64, title, on, where, elsewhere string) string { + if on != "" { + on = " on " + on + } + return withElsewhere(fmt.Sprintf("task %d started%s: %s\n%s %s", id, on, title, where, taskHandoffWakeSentence), elsewhere) +} + +// programHomeRefusal is the one folder a tree program is never handed: the +// person's home folder, or one that holds it. It is not a project, and a +// program on a folder with no git history snapshots the whole of it to know +// what it changed — every file under the home folder, and a refusal from the +// first one macOS keeps to itself. instead is what the one refused can do. +func programHomeRefusal(program delegate.Delegate, dir, instead string) string { + if !program.LandsTree() || !holdsHomeFolder(dir) { + return "" + } + what := "holds your home folder" + if home, err := os.UserHomeDir(); err == nil && canonicalPath(home) == canonicalPath(dir) { + what = "is your home folder" + } + return program.Name + " works in one project's folder, and " + dir + " " + what + "; " + instead +} + +// holdsHomeFolder says dir is the person's home folder or a folder above it. +func holdsHomeFolder(dir string) bool { + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" || strings.TrimSpace(dir) == "" { + return false + } + rel, err := filepath.Rel(canonicalPath(dir), canonicalPath(home)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// DelegateUnknownError is the refusal for a `via` or a command naming no +// program this build carries. It names the ones it does, sorted, so the next +// attempt has the words in front of it. +type DelegateUnknownError struct { + Named string + Have []string +} + +func (e DelegateUnknownError) Error() string { + if len(e.Have) == 0 { + return "this codeaf carries no program called " + e.Named + } + have := append([]string(nil), e.Have...) + sort.Strings(have) + return "this codeaf carries no program called " + e.Named + "; it carries " + strings.Join(have, ", ") +} + +// delegateFor resolves a name to the program, or the refusal. +func (a *Agent) delegateFor(name string) (delegate.Delegate, error) { + name = strings.TrimSpace(name) + if name == "" { + return delegate.Delegate{}, errors.New("name the program to hand the work to") + } + for _, program := range a.config.Delegates { + if program.Name == name { + return program, nil + } + } + return delegate.Delegate{}, DelegateUnknownError{Named: name, Have: a.config.delegateNames()} +} + +// StartDelegate hands one person-authored brief to the named program. It is +// `/ `'s door and it answers what StartTask answers: the id the +// row wears, the title, a note about where the work stands (always empty here) +// and the error. Nothing is waited for: the run starts and the turn goes on. +// +// The refusals a person can meet, in their own words: a name this build +// carries no program for, an empty brief, and a build whose run road is not +// linked. +func (a *Agent) StartDelegate(ctx context.Context, name, brief string) (uint64, string, string, error) { + brief = strings.TrimSpace(brief) + if brief == "" { + return 0, "", "", errors.New("/" + strings.TrimSpace(name) + " needs a brief: the whole task, in words") + } + program, err := a.delegateFor(name) + if err != nil { + return 0, "", "", err + } + if a.config.InTask { + return 0, "", "", errors.New("a task cannot hand its work to " + program.Name + "; only the conversation can") + } + folder := canonicalPath(a.config.Workspace) + if refusal := programHomeRefusal(program, folder, "open codeaf in that folder, or ask for the work in the chat and say which folder it is in"); refusal != "" { + return 0, "", "", errors.New(refusal) + } + g := a.graph() + if chatRunEngine == nil || g == nil || g.planPath() == "" { + return 0, "", "", errors.New(program.Name + " needs the run road, and this build has none") + } + id := g.reserve() + title := taskPersonTitle(brief) + if err := a.startKnownTaskRunVia(ctx, id, title, brief, nil, delegateStand(folder), "", &program); err != nil { + return 0, "", "", err + } + return id, title, "", nil +} + +// delegateStand is where a program works: the folder itself, always. One that +// edits files is readied there by [PrepareProgramFolder], on a branch of its +// own in a repository; one that lands text reads the person's folder and +// changes nothing, which is what it promises. +func delegateStand(folder string) taskStand { + return taskStand{dir: folder, mode: TaskModeInPlace} +} + +// landDelegateRun is a delegated run's landing, in place of the engine's own: +// the program's folder finished per the contract ([ProgramFolder.Finish]) — +// what it left uncommitted committed on its branch with the run's ending as +// the commit's body, or a run that changed nothing undone — and the landing +// that says where the work is ([ProgramFolderEnd.landing]). +// +// A TEXT PROGRAM LANDS NOTHING: it worked in place and promised to change +// nothing, and its answer is the run's result, which the outcome note carries. +func (a *Agent) landDelegateRun(run *beltRun, summary RunSummary) RunLanding { + if run.folder == nil { + return RunLanding{Home: mergeInPlace} + } + outcome, result := runEndingWords(summary) + if result == "" { + result = outcome + } + return run.folder.Finish(result).landing() +} diff --git a/internal/session/delegate_door_test.go b/internal/session/delegate_door_test.go new file mode 100644 index 0000000000..612c59407b --- /dev/null +++ b/internal/session/delegate_door_test.go @@ -0,0 +1,514 @@ +package session + +import ( + "context" + "flag" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// testPrograms is a build that carries one program called name. The session +// never starts its process — the run engine is a double here — so its command +// is a body that is never called. +func testPrograms(name string) []delegate.Delegate { + return []delegate.Delegate{{ + Name: name, Summary: "a fake program", Default: "run", Page: name, + Guide: "For work a fake does, with a brief that names the fake's files.", + Commands: []delegate.Command{{Name: "run", Bind: func(*flag.FlagSet) delegate.Body { + return func(context.Context, delegate.Host, []string) error { return nil } + }}}, + }} +} + +// The whole road from the door to the branch: `/fake ` starts a run +// whose spec names the delegate and whose workspace is THE PERSON'S FOLDER +// ITSELF, checked out on a branch codeaf cut for it. The program's own commits +// stay on that branch, what it left uncommitted is committed there in one +// commit whose subject is the task's title and whose body is the run's result, +// the branch is left checked out, and the person's own branch never moves. The +// engine is a double whose `work` hook plays the program: one file committed +// the way senior-dev commits every edit, and one left uncommitted. +func TestADelegatedRunWorksOnItsOwnBranchInTheFolderAndLeavesItCheckedOut(t *testing.T) { + // The double answers the run's result off the completer it is handed, so + // the result is scripted there: the sentence the last commit must carry. + const result = "submitted and verified. fake's model said: tests pass" + double := newBeltRunDouble(result) + double.work = func(workspace string) { + commitIn(t, workspace, "one.txt") + writeFile(t, filepath.Join(workspace, "two.txt"), "two\n") + } + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + base := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "HEAD")) + sessionDir := t.TempDir() + registry := testPrograms("fake") + agent, _ := newTestAgent(t, beltRunCompleter{text: result}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: sessionDir} + config.AskConsent = false + config.Delegates = registry + }) + + id, title, note, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + if id == 0 || title == "" || note != "" { + t.Fatalf("StartDelegate answered id %d title %q note %q", id, title, note) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if spec.Delegate == nil || spec.Delegate.Name != "fake" { + t.Fatalf("the engine was handed no delegate: %+v", spec.Delegate) + } + if spec.Brief != "add two files to the project" { + t.Fatalf("brief = %q", spec.Brief) + } + // THE PROGRAM WORKS IN THE FOLDER ITSELF, on a branch of its own. + if canonicalPath(spec.Workspace) != canonicalPath(conversation) || spec.PlainFolder { + t.Fatalf("the program works in %q (plain %v), want the person's repository %q itself", spec.Workspace, spec.PlainFolder, conversation) + } + branch := currentBranch(conversation) + if !strings.HasPrefix(branch, "task/add-two-files-to-the-project-") { + t.Fatalf("the checkout is on %q while the program works, want a task branch of its own", branch) + } + endBeltRun(t, agent, double) + + // THE PERSON'S BRANCH NEVER MOVED, and the program's branch is left checked + // out with the work in the folder. + if tip := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "work")); tip != base { + t.Fatalf("the person's branch moved from %s to %s", base, tip) + } + if head := currentBranch(conversation); head != branch { + t.Fatalf("the checkout is on %q after the run, want the program's branch %q left checked out", head, branch) + } + for _, name := range []string{"one.txt", "two.txt"} { + if _, err := os.Stat(filepath.Join(conversation, name)); err != nil { + t.Fatalf("%s is not in the person's folder: %v", name, err) + } + } + if status := strings.TrimSpace(gitOut(t, conversation, "status", "--porcelain")); status != "" { + t.Fatalf("the run left the folder with changes that are not committed:\n%s", status) + } + // THE PROGRAM'S COMMIT STAYS, and codeaf's one commit of what was left is on + // top of it: the title, then the result. + subjects := strings.Fields(strings.ReplaceAll(gitOut(t, conversation, "log", "--format=%s", base+".."+branch), " ", "_")) + if len(subjects) != 2 || subjects[1] != "wip(edit):_one.txt" || !strings.HasPrefix(subjects[0], "add_two_files") { + t.Fatalf("the branch holds %q, want the program's commit under one commit of the title", subjects) + } + if body := gitOut(t, conversation, "log", "-1", "--format=%b", branch); !strings.Contains(body, "fake's model said: tests pass") { + t.Fatalf("the last commit's body does not carry the run's result:\n%s", body) + } + // AND THE PAGE AND THE CONVERSATION SAY WHERE IT IS AND HOW TO GO BACK. + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + var said []string + for _, n := range store.Notes(store.RootID(), 0) { + said = append(said, n.Body) + } + root := canonicalPath(conversation) + want := "its work is on the branch " + branch + " in " + root + ", 2 files, and that branch is checked out there; your branch work is as it was: `git -C '" + + root + "' switch work` goes back to it, and `git -C '" + root + "' merge " + branch + "` from there brings the work in" + if joined := strings.Join(said, "\n"); !strings.Contains(joined, want) { + t.Fatalf("the run's notes = %q, want %q", said, want) + } + if got := conversationJournalLines(agent, want); got != 1 { + t.Fatalf("the conversation was told %d times %q", got, want) + } + var row TaskNotice + for _, kept := range agent.graph().runRows(id) { + if kept.ID == id { + row = kept + } + } + if row.Branch != branch || row.Merge != mergeKept || len(row.Changed) != 2 || row.Copy == nil || row.Copy.Branch != branch || row.Copy.Home != "work" { + t.Fatalf("the row = branch %q (%s), files %q, copy %+v; want the program's branch, kept, with both files", row.Branch, row.Merge, row.Changed, row.Copy) + } +} + +// A FOLDER WITH NO GIT HISTORY: the program is told so on its line, works in +// the folder itself because there is nothing to copy from, and its landing +// commits nothing — no repository is made in the person's folder — and says +// where the work is instead of refusing a commit git could never make. +func TestADelegatedRunOnAPlainFolderIsToldSoAndLandsWhereItWorked(t *testing.T) { + const result = "submitted and verified. fake's model said: done" + double := newBeltRunDouble(result) + double.work = func(workspace string) { + if err := os.WriteFile(filepath.Join(workspace, "made.txt"), []byte("made\n"), 0o644); err != nil { + t.Error(err) + } + } + registerBeltRunEngine(t, double) + folder := t.TempDir() + if err := os.WriteFile(filepath.Join(folder, "notes.txt"), []byte("mine\n"), 0o644); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, beltRunCompleter{text: result}, func(config *Config) { + config.Workspace = folder + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file in this folder"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) { + t.Fatalf("spec = plain %v in %q, want the plain folder itself, said to be one", spec.PlainFolder, spec.Workspace) + } + endBeltRun(t, agent, double) + + if content, err := os.ReadFile(filepath.Join(folder, "made.txt")); err != nil || string(content) != "made\n" { + t.Fatalf("the work is not in the folder: %q %v", content, err) + } + if _, err := os.Stat(filepath.Join(folder, ".git")); !os.IsNotExist(err) { + t.Fatalf("the landing made the plain folder a repository: %v", err) + } + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + var said []string + for _, note := range store.Notes(store.RootID(), 0) { + said = append(said, note.Body) + } + joined := strings.Join(said, "\n") + if !strings.Contains(joined, "no git history, so nothing was committed") || strings.Contains(joined, "not a git repository") { + t.Fatalf("the run's notes = %q, want the plain-folder landing and no git refusal", said) + } +} + +// A folder with history is worked in on a branch, and the program is told +// nothing extra. +func TestADelegatedRunOnARepositoryIsNotToldItIsPlain(t *testing.T) { + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + plain := double.spec.PlainFolder + double.mu.Unlock() + endBeltRun(t, agent, double) + if plain { + t.Fatal("a repository with a commit was called a plain folder") + } +} + +// A program that ended without finishing is drawn from its own words: the row +// names what it said, with no fault in front of it, and a crash is the fault +// it is. The row that read "a fault: ran and did not finish" over an hour of +// work that had said exactly why it would not stand told a person nothing. +func TestAProgramsOwnEndingIsTheRowsReasonAndNotAFault(t *testing.T) { + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) {}) + program := testPrograms("fake")[0] + run := &beltRun{row: 3, title: "the task", delegate: &program} + summary := RunSummary{Outcome: "ran and did not finish", Program: &ProgramEnding{ + Status: delegate.StatusFail, + Reason: "fake did not finish: submitted a change the project's own tests do not pass", + Result: "submitted a change the project's own tests do not pass. fake's model said: done", + }} + notice := agent.beltRunNotice(run, summary, RunLanding{}) + if notice.State != TaskFailed || notice.Ending != TaskEndingProgram { + t.Fatalf("notice = %s / %q, want failed on the program's own ending", notice.State, notice.Ending) + } + if reason := TaskReasonOf(notice.Ending, notice.Report); reason != summary.Program.Reason { + t.Fatalf("reason = %q, want the program's sentence %q", reason, summary.Program.Reason) + } + if taskEndingIsFault(notice.Ending) { + t.Fatal("a program judging its own work unfinished was drawn as a fault") + } + if note := beltRunOutcomeNote(nil, "", summary, RunLanding{}, 0); !strings.HasPrefix(note, summary.Program.Reason) || strings.Contains(note, "ran and did not finish") { + t.Fatalf("the outcome note = %q, want the program's own words and not the run's generic one", note) + } + summary.Program.Status = delegate.StatusCrashed + if notice := agent.beltRunNotice(run, summary, RunLanding{}); notice.Ending != TaskEndingError { + t.Fatalf("a crash ended %q, want the fault it is", notice.Ending) + } +} + +// A program is handed the conversation's crew — its planning, working and +// light seats, with any effort taken off — and a run no program works is handed +// none. +func TestAProgramIsHandedTheConversationsCrew(t *testing.T) { + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.RolesSource = tierSettings(map[string]string{ + "tiers.mastermind": "vendor/brain:high", + "tiers.worker": "vendor/hands", + "tiers.low": "vendor/light", + }) + }) + program := testPrograms("fake")[0] + got := agent.delegateCrew(&beltRun{delegate: &program}) + if want := (delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light"}); !reflect.DeepEqual(got, want) { + t.Fatalf("crew = %+v, want %+v", got, want) + } + // A run the person asked a model for is handed that model beside the crew. + asked := agent.delegateCrew(&beltRun{delegate: &program, asked: []string{"vendor/one", "vendor/two"}}) + if want := (delegate.Crew{Brain: "vendor/brain", Hands: "vendor/hands", Light: "vendor/light", Asked: []string{"vendor/one", "vendor/two"}}); !reflect.DeepEqual(asked, want) { + t.Fatalf("asked crew = %+v, want %+v", asked, want) + } + if got := agent.delegateCrew(&beltRun{}); !got.IsZero() { + t.Fatalf("a run no program works was handed a crew: %+v", got) + } +} + +// A PROGRAM'S RUN THAT DID NOT FINISH IS OVER ON ITS PAGE. The engine left its +// store open, the page read `running · … · x stop it` for forty minutes over a +// program that had ended, and the next hand-off would have adopted it. Its +// store's run task is now ended with the program's own sentence. +func TestAProgramsRunThatDidNotFinishIsEndedInItsStore(t *testing.T) { + double := newBeltRunDouble("") + double.leaveOpen = true + double.summary = RunSummary{Outcome: "ran and did not finish", Program: &ProgramEnding{ + Status: delegate.StatusFail, Reason: "fake did not finish: its tests fail", Result: "its tests fail", + }} + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + root := store.Task(store.RootID()) + if root.Status != plandb.StatusFailed || root.Error != "fake did not finish: its tests fail" { + t.Fatalf("the run's task = %s (%q), want failed with the program's own sentence", root.Status, root.Error) + } + page, ok := agent.PlanTaskPage(strconv.FormatUint(id, 10)) + if !ok || page.Row.Status != string(plandb.StatusFailed) { + t.Fatalf("the task's page row = %+v (%v), want it ended and not running", page.Row, ok) + } +} + +func TestStartDelegateRefusesANameThisMachineDoesNotHave(t *testing.T) { + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + registry := testPrograms("fake") + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.Delegates = registry + }) + _, _, _, err := agent.StartDelegate(context.Background(), "other", "do a thing") + if err == nil || err.Error() != "this codeaf carries no program called other; it carries fake" { + t.Fatalf("err = %v", err) + } + if double.didRun() { + t.Fatal("a refused delegate started a run") + } + // And a build that carries none says so plainly. + none, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + }) + _, _, _, err = none.StartDelegate(context.Background(), "fake", "do a thing") + if err == nil || err.Error() != "this codeaf carries no program called fake" { + t.Fatalf("err = %v", err) + } +} + +// A DELEGATE RUNS ALONE. A second hand-off while a delegated run is going is +// refused with the folder that is busy, and a delegate proposed while an +// ordinary run is going is refused the same way. +func TestNothingJoinsADelegatedRunAndADelegateJoinsNothing(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + double.honoursStop = true + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + registry := testPrograms("fake") + agent, _ := newTestAgent(t, beltRunCompleter{text: "unused"}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = registry + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "the delegated work"); err != nil { + t.Fatal(err) + } + <-double.entered + stand := taskStand{dir: conversation, mode: TaskModeWorktree} + err := agent.startKnownTaskRun(context.Background(), 99, "a second piece", "brief", nil, stand, "") + if err == nil || !strings.Contains(err.Error(), "fake runs alone") { + t.Fatalf("a task joined a delegated run: %v", err) + } + endBeltRun(t, agent, double) +} + +// The prompt names the programs this build carries, and only where there are +// some: a conversation with one reads its name and its own guide under the +// hand-off facts, and one without reads nothing about them at all. +func TestThePromptNamesTheDelegatesThisLaunchHasAndOnlyThose(t *testing.T) { + with := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} + page := promptWithBeltFacts(with) + if !strings.Contains(page, "The programs here:\n- `fake`: "+with.Delegates[0].Guide) { + t.Fatalf("the page does not list the delegate with its own guide:\n%s", page) + } + if !strings.Contains(page, "`via`") { + t.Fatal("the page does not say how a delegate is named on a proposal") + } + without := Config{Workspace: t.TempDir()} + if page := promptWithBeltFacts(without); strings.Contains(page, "The programs here") || strings.Contains(page, "PROGRAM BUILT INTO CODEAF") { + t.Fatalf("a launch with no delegates still speaks of them:\n%s", page) + } + inTask := Config{Workspace: t.TempDir(), Delegates: with.Delegates, InTask: true} + if page := promptWithBeltFacts(inTask); strings.Contains(page, "The programs here") { + t.Fatal("a task node is told it may delegate") + } +} + +// CODEAF PREFERS A PROGRAM FOR THE WORK IT IS FOR, AND USES ONE WHEN ASKED. +// The paragraph said a large task "can" go to a program, and the model took a +// permission for no reason to: the owner's call of 2026-09-24 is that it +// reach for one by itself on the work the program's guide claims, over its +// own hands and its own worker, and whenever the person names one. Both +// sentences are said whatever the program lands, before the folder rule and +// apart from it, so either can be reworded without the other; and `via` +// says the same when the proposal is being written. +func TestThePagePrefersAProgramForItsWorkAndForTheAsk(t *testing.T) { + preference := []string{ + "AND WORK A PROGRAM BUILT INTO CODEAF IS FOR GOES TO IT WHOLE, named in\n`propose_task`'s `via`", + "rather than to you or a worker, whatever its critical path;", + "so does work the person asks one for, by name or as `/name`.", + } + textOnly := testPrograms("reader") + textOnly[0].Lands = delegate.LandsText + for _, programs := range [][]delegate.Delegate{testPrograms("fake"), textOnly} { + page := promptWithBeltFacts(Config{Workspace: t.TempDir(), Delegates: programs}) + for _, want := range preference { + if !strings.Contains(page, want) { + t.Fatalf("a build carrying %s is not told %q:\n%s", programs[0].Name, want, page) + } + } + if rule := strings.Index(page, "It works in a copy"); rule >= 0 && rule < strings.Index(page, preference[2]) { + t.Fatalf("the folder rule is said inside the preference rather than after it:\n%s", page) + } + } + if !strings.Contains(taskSchemaJSON, `"via":{"type":"string","description":"A program your instructions list, to do the whole task alone in ground (or this conversation's folder): set it for work one is for, and when the person names one"}`) { + t.Fatal("`via` does not say when it is set") + } +} + +// THE FOLDER A PROGRAM IS HANDED IS CODEAF'S TO EXPLAIN, and it is explained +// only where it is true. A program that edits files works in the proposal's +// folder itself, on a branch of its own in a repository, so the page tells the +// model to hand it the repository the work belongs in — cloned first when this +// machine lacks it — and never to brief it to work somewhere else: the failure +// this sentence was written from is senior-dev cloning a repository into the +// person's projects folder because its brief said to. A program that only +// answers reads the folder and changes nothing, so a build carrying only those +// is told nothing about branches. +func TestTheFolderRuleIsSaidWhereAProgramEditsFilesAndOnlyThere(t *testing.T) { + tree := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} + page := promptWithBeltFacts(tree) + for _, want := range []string{ + "It works in the task's folder itself, on a branch of its own in a repository, so hand\nit the repository the work belongs in", + "clone one this machine lacks into a new folder", + "at the commit the work names, and pass it as `ground`.", + "Never brief it to work elsewhere.", + } { + if !strings.Contains(page, want) { + t.Fatalf("a build carrying a program that edits files is not told %q:\n%s", want, page) + } + } + if strings.Contains(page, "a copy of the task's folder") { + t.Fatalf("the page still says a program works in a copy:\n%s", page) + } + textOnly := testPrograms("reader") + textOnly[0].Lands = delegate.LandsText + page = promptWithBeltFacts(Config{Workspace: t.TempDir(), Delegates: textOnly}) + if !strings.Contains(page, "- `reader`: ") { + t.Fatalf("the program that answers is not listed:\n%s", page) + } + if strings.Contains(page, "task's folder itself") { + t.Fatalf("a build whose only program reads in place is told about branches:\n%s", page) + } +} + +// THE PAGE SAYS NOTHING ABOUT A PROGRAM THAT THE PROGRAM DOES NOT SAY. Two +// programs are listed in name order, each with its own guide and nobody +// else's, so a second program joins the page by bringing its guide and never +// by an edit to the conversation's words. +func TestEachProgramIsListedWithItsOwnGuideInNameOrder(t *testing.T) { + programs := append(testPrograms("zeta"), testPrograms("alpha")...) + programs[0].Guide = "For the zeta work." + programs[1].Guide = "For the alpha work." + page := promptWithBeltFacts(Config{Workspace: t.TempDir(), Delegates: programs}) + want := "The programs here:\n- `alpha`: For the alpha work.\n- `zeta`: For the zeta work." + if !strings.Contains(page, want) { + t.Fatalf("the page does not list both programs with their own guides in order; want %q in:\n%s", want, page) + } +} + +// A PROGRAM'S PAGE IS READ WITH THE SWITCH OFF. `/senior-dev` takes the run +// road whatever CODEAF_TASK_BELT says, and its store is written either way, so +// the pages that read that store must answer either way: with the readers +// gated on the switch, a person on the default belt clicked into senior-dev's +// task and got a room that said it would fill in, for the whole run. +func TestAProgramsRunIsReadableWithTheSwitchOff(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "off") + if bashBeltAsked() { + t.Fatal("the switch is still on, so this test would prove nothing") + } + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "add a file"); err != nil { + t.Fatal(err) + } + <-double.entered + var program PlanTaskRow + for _, row := range agent.PlanTasks() { + if row.Program == "fake" { + program = row + } + } + if program.ID == "" { + t.Fatalf("the program's run has no row with the switch off: %+v", agent.PlanTasks()) + } + page, found := agent.PlanTaskPage(program.ID) + if !found || page.Program == nil || page.Program.Name != "fake" { + t.Fatalf("the program's page is not readable with the switch off: found %v, program %+v", found, page.Program) + } + // AND NOTHING WAS ARMED: the switch's own roads stay closed to every + // ordinary task of this conversation. + if g := agent.graph(); g != nil && g.planIfArmed() != nil { + t.Fatal("reading the program's page armed the plan for the switch's other roads") + } + endBeltRun(t, agent, double) +} diff --git a/internal/session/delegate_landing_test.go b/internal/session/delegate_landing_test.go new file mode 100644 index 0000000000..03a08a164b --- /dev/null +++ b/internal/session/delegate_landing_test.go @@ -0,0 +1,386 @@ +package session + +// WHERE A PROGRAM'S WORK IS WHEN IT ENDS, WHATEVER IT DID IN THE FOLDER. +// +// A program works in the person's folder itself, on a branch codeaf cut for +// it when the folder is a repository (programfolder.go). These pin what the +// person finds when it ends: its branch checked out with everything it left +// committed there, their own branch untouched, nothing at all when it changed +// nothing, a HEAD its shell moved left exactly where it was, and its own notes +// moved out of the folder and never committed. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// delegatedRunThatDid runs one program whose work is play, in a repository +// newTestRepo makes (prepare may give it branches first), and answers the +// repository, its first commit, the run's row and the notes on its page. +func delegatedRunThatDid(t *testing.T, prepare func(repo string), play func(t *testing.T, workspace string)) (string, string, TaskNotice, []string) { + t.Helper() + double := newBeltRunDouble("done") + double.work = func(workspace string) { play(t, workspace) } + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + if prepare != nil { + prepare(conversation) + } + base := strings.TrimSpace(gitOut(t, conversation, "rev-parse", "HEAD")) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + var row TaskNotice + for _, kept := range agent.graph().runRows(id) { + if kept.ID == id { + row = kept + } + } + return conversation, base, row, beltRunNotes(t, filepath.Dir(spec.Store.Path()), spec.Store.RootID()) +} + +// commitIn writes each file and commits it the way senior-dev commits an edit. +func commitIn(t *testing.T, workspace string, names ...string) { + t.Helper() + for _, name := range names { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + mustGit(t, workspace, "add", name) + mustGit(t, workspace, "-c", "user.name=p", "-c", "user.email=p@p", "commit", "-q", "-m", "wip(edit): "+name) + } +} + +// taskBranchLog answers the task's one branch and the subjects of its history, +// newest first. +func taskBranchLog(t *testing.T, repo string) (string, string) { + t.Helper() + branches := strings.Fields(gitOut(t, repo, "branch", "--format=%(refname:short)", "--list", "task/*")) + if len(branches) != 1 { + t.Fatalf("want the task's one branch in the repository, got %q", branches) + } + return branches[0], gitOut(t, repo, "log", "--format=%s", branches[0]) +} + +// A PROGRAM RUN THAT CHANGED NOTHING LEAVES NOTHING: the person's own branch is +// checked out again and the empty branch is gone, so every look-only, failed or +// crashed run does not leave one more `task/*` in the person's repository. +func TestADelegatedRunThatChangedNothingGoesBackAndLeavesNoBranch(t *testing.T) { + repo, base, row, notes := delegatedRunThatDid(t, nil, func(*testing.T, string) {}) + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a run that changed nothing left a branch behind: %q", branches) + } + if head := currentBranch(repo); head != "work" || strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) != base { + t.Fatalf("the checkout is on %q after a run that changed nothing, want the person's branch work at %s", head, base) + } + if row.Branch != "" { + t.Fatalf("the row names a branch %q over no work", row.Branch) + } + if !strings.Contains(strings.Join(notes, "\n"), "it changed nothing, so "+canonicalPath(repo)+" is back on your branch work and its branch task/") { + t.Fatalf("the page does not say the run changed nothing and went back: %q", notes) + } +} + +// A PERSON WHOSE CHECKOUT WAS ON NO BRANCH GETS THAT COMMIT BACK, and is told +// how to go back to it when the run leaves work. +func TestADelegatedRunFromADetachedCheckoutNamesTheCommitToGoBackTo(t *testing.T) { + repo, base, row, notes := delegatedRunThatDid(t, func(repo string) { + mustGit(t, repo, "checkout", "-q", "--detach") + }, func(t *testing.T, workspace string) { + writeFile(t, filepath.Join(workspace, "one.txt"), "one\n") + }) + branch, _ := taskBranchLog(t, repo) + if head := currentBranch(repo); head != branch || row.Branch != branch { + t.Fatalf("the checkout is on %q and the row names %q, want the program's branch %q", head, row.Branch, branch) + } + want := "your checkout was on no branch, at " + shortSha(base) + ", and `git -C '" + canonicalPath(repo) + "' switch --detach " + shortSha(base) + "` goes back to it" + if !strings.Contains(strings.Join(notes, "\n"), want) { + t.Fatalf("the page does not say how to go back to the commit: %q, want %q", notes, want) + } +} + +// A HEAD THE PROGRAM'S SHELL MOVED IS LEFT WHERE IT IS. senior-dev's shell can +// run `git checkout`, and it did, four times in one run. codeaf then commits +// nothing and switches nothing: committing where HEAD is would put codeaf's +// commit on a branch that may be the person's own, and switching would carry +// whatever is in the folder somewhere nobody chose. It says where HEAD is. +func TestAProgramThatMovedHeadOffItsBranchIsLeftWhereItIs(t *testing.T) { + var left string + repo, base, row, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + commitIn(t, workspace, "one.txt") + mustGit(t, workspace, "checkout", "-q", "work") + writeFile(t, filepath.Join(workspace, "loose.txt"), "loose\n") + left = strings.TrimSpace(gitOut(t, workspace, "rev-parse", "HEAD")) + }) + branch, log := taskBranchLog(t, repo) + if head := currentBranch(repo); head != "work" || left != base { + t.Fatalf("the checkout is on %q at %s, want it left on work where the program put it", head, left) + } + if !strings.Contains(log, "wip(edit): one.txt") || strings.Count(log, "\n") != 2 { + t.Fatalf("the program's branch holds:\n%s\nwant its own commit and nothing of codeaf's", log) + } + if status := gitOut(t, repo, "status", "--porcelain"); !strings.Contains(status, "loose.txt") { + t.Fatalf("codeaf committed what the program left while HEAD was elsewhere:\n%s", status) + } + want := "fake left " + canonicalPath(repo) + " on the branch work instead of its own branch " + branch + + ", so codeaf changed nothing there: nothing was committed and nothing was switched; " + branch + " holds 1 file" + if !strings.Contains(strings.Join(notes, "\n"), want) { + t.Fatalf("the page does not say where HEAD was left: %q, want %q", notes, want) + } + if row.Branch != branch { + t.Fatalf("the row names %q, want the program's branch %q, which holds its commit", row.Branch, branch) + } +} + +// AND A HEAD LEFT ON NO BRANCH IS SAID WITH ITS COMMIT. +func TestAProgramThatDetachedHeadIsLeftWhereItIs(t *testing.T) { + var at string + repo, _, _, notes := delegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + mustGit(t, workspace, "checkout", "-q", "--detach") + commitIn(t, workspace, "one.txt") + at = strings.TrimSpace(gitOut(t, workspace, "rev-parse", "HEAD")) + }) + if head := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); head != at || currentBranch(repo) != "" { + t.Fatalf("codeaf moved a detached HEAD from %s to %s", at, head) + } + if !strings.Contains(strings.Join(notes, "\n"), "fake left "+canonicalPath(repo)+" on no branch, at "+shortSha(at)+" instead of its own branch task/") { + t.Fatalf("the page does not say HEAD was left on no branch: %q", notes) + } +} + +// THE RECEIPT PROMISES NO MERGE. An approved hand-off to a program says who has +// the work and where it will be: on a new branch in the folder itself, left +// checked out, with the person's branch named as the one that does not move; +// in the folder itself for a folder with no history; in the conversation for +// a program that only answers. +func TestAProgramsReceiptSaysWhereTheWorkWillBeAndPromisesNoMerge(t *testing.T) { + tree := testPrograms("fake")[0] + repo, plain := "/r/repo", "/r/plain" + record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "main", HomeSha: "0123456789abcdef"} + if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in /r/repo itself, on a new branch task/pong-abc123; your branch main does not move, and when it ends task/pong-abc123 stays checked out there with its work. Until it ends, codeaf's own tools write nothing in /r/repo."; got != want { + t.Fatalf("the receipt for a repository = %q, want %q", got, want) + } + detached := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", HomeSha: "0123456789abcdef"} + if got := delegateReceipt(repo, tree, detached); !strings.Contains(got, "; the commit 0123456789ab does not move") { + t.Fatalf("the receipt for a detached checkout = %q", got) + } + if got := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}); got != "It is fake's: it works alone in /r/plain itself, which has no git history, so its changes are there as it makes them. Until it ends, codeaf's own tools write nothing in /r/plain." { + t.Fatalf("the receipt for a plain folder = %q", got) + } + reader := tree + reader.Lands = delegate.LandsText + if got := delegateReceipt(plain, reader, nil); got != "It is fake's: it works alone, and its answer arrives when it ends." { + t.Fatalf("the receipt for a program that answers = %q", got) + } + for _, got := range []string{delegateReceipt(repo, tree, record), delegateReceipt(plain, tree, nil), delegateReceipt(plain, reader, nil)} { + if strings.Contains(got, "lands") || strings.Contains(got, "copy") { + t.Fatalf("a receipt promises a landing or a copy: %q", got) + } + } +} + +// A PROGRAM HANDED A FOLDER INSIDE A REPOSITORY WORKS AT THE REPOSITORY'S ROOT, +// which is where its branch is, and its brief reaches it as it was written: +// there is no copy for a path to be rewritten into. +func TestAProgramHandedASubfolderWorksAtTheRepositorysRoot(t *testing.T) { + double := newBeltRunDouble("") + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + sub := filepath.Join(repo, "packages", "foo") + writeFile(t, filepath.Join(sub, "src", "a.ts"), "export {}\n") + mustGit(t, repo, "add", "-A") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "the package") + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = sub + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + brief := "fix " + sub + "/src/a.ts, then run git -C " + repo + " status" + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", brief); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if canonicalPath(spec.Workspace) != canonicalPath(repo) || spec.Brief != brief { + t.Fatalf("the program works in %q on %q, want the repository's root %q and the brief as written", spec.Workspace, spec.Brief, repo) + } + endBeltRun(t, agent, double) +} + +// A PROGRAM'S NOTES IN A REPOSITORY ARE MOVED OUT AND NEVER COMMITTED. They +// are the program's records — its database and its whole conversation — and a +// commit of what the run left would otherwise have taken them onto the branch. +func TestARepositoryRunsNotesAreMovedOutAndNeverCommitted(t *testing.T) { + double := newBeltRunDouble("done") + double.work = func(workspace string) { + writeFile(t, filepath.Join(workspace, ".fake", "spec.md"), "the brief\n") + writeFile(t, filepath.Join(workspace, "made.txt"), "made\n") + } + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + programs := testPrograms("fake") + programs[0].Notes = ".fake" + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = programs + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + branch, _ := taskBranchLog(t, repo) + files := gitOut(t, repo, "ls-tree", "-r", "--name-only", branch) + if !strings.Contains(files, "made.txt") || strings.Contains(files, ".fake") { + t.Fatalf("the program's branch holds:\n%s\nwant its work and none of its notes", files) + } + taskDir := plandb.TaskDir(place, spec.Store.RootID()) + if _, err := os.Stat(filepath.Join(taskDir, "fake", "spec.md")); err != nil { + t.Fatalf("the notes are not in the task's record folder: %v", err) + } + if _, err := os.Stat(filepath.Join(repo, ".fake")); !os.IsNotExist(err) { + t.Fatalf("the notes were left in the person's folder: %v", err) + } +} + +// NOTES THAT WERE THERE BEFORE A REPOSITORY RUN are not this run's to take, do +// not refuse the run as changes of the person's, and are not committed. +func TestNotesThatWereThereBeforeARepositoryRunStayAndAreNotCommitted(t *testing.T) { + double := newBeltRunDouble("done") + double.work = func(workspace string) { writeFile(t, filepath.Join(workspace, "made.txt"), "made\n") } + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, ".fake", "old.md"), "an earlier run's\n") + programs := testPrograms("fake") + programs[0].Notes = ".fake" + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = programs + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file"); err != nil { + t.Fatalf("a folder whose only untracked files are the program's own notes was refused: %v", err) + } + <-double.entered + endBeltRun(t, agent, double) + branch, _ := taskBranchLog(t, repo) + if files := gitOut(t, repo, "ls-tree", "-r", "--name-only", branch); strings.Contains(files, ".fake") || !strings.Contains(files, "made.txt") { + t.Fatalf("the program's branch holds:\n%s", files) + } + if _, err := os.Stat(filepath.Join(repo, ".fake", "old.md")); err != nil { + t.Fatalf("notes that were there before the run were taken: %v", err) + } +} + +// plainFolderRun runs a program that keeps notes in `.fake` on a folder with +// no git history, playing a run that writes its work and its notes there, and +// answers the folder and the task's record folder. +func plainFolderRun(t *testing.T, before func(folder string)) (string, string, []string) { + t.Helper() + double := newBeltRunDouble("done") + double.work = func(workspace string) { + if err := os.MkdirAll(filepath.Join(workspace, ".fake", "storage"), 0o755); err != nil { + t.Error(err) + return + } + for name, body := range map[string]string{ + "made.txt": "made\n", + ".fake/spec.md": "the brief\n", + ".fake/storage/session.json": "{}\n", + } { + if err := os.WriteFile(filepath.Join(workspace, name), []byte(body), 0o644); err != nil { + t.Error(err) + } + } + } + registerBeltRunEngine(t, double) + folder := t.TempDir() + if before != nil { + before(folder) + } + programs := testPrograms("fake") + programs[0].Notes = ".fake" + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = folder + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = programs + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a file in this folder"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + root := spec.Store.RootID() + return folder, plandb.TaskDir(place, root), beltRunNotes(t, place, root) +} + +// A PROGRAM THAT WORKED IN A PLAIN FOLDER LEAVES ONLY ITS WORK THERE. Its own +// records (a session database and its whole model conversation, for +// senior-dev) are moved into the task's record folder, where the page says +// they are, instead of waiting in the person's folder for a `git add -A`. +func TestAPlainFolderRunsNotesAreMovedIntoTheTasksRecordFolder(t *testing.T) { + folder, taskDir, notes := plainFolderRun(t, nil) + if _, err := os.Stat(filepath.Join(folder, "made.txt")); err != nil { + t.Fatalf("the work is not in the folder: %v", err) + } + if _, err := os.Stat(filepath.Join(folder, ".fake")); !os.IsNotExist(err) { + t.Fatalf("the program's notes were left in the person's folder: %v", err) + } + for _, name := range []string{"spec.md", filepath.Join("storage", "session.json")} { + if _, err := os.Stat(filepath.Join(taskDir, "fake", name)); err != nil { + t.Fatalf("the program's %s is not in the task's record folder: %v", name, err) + } + } + if !strings.Contains(strings.Join(notes, "\n"), "its notes (.fake/) are kept in "+filepath.Join(taskDir, "fake")) { + t.Fatalf("the page does not say where the notes went: %q", notes) + } +} + +// NOTES THAT WERE THERE BEFORE THE RUN ARE NOT THIS RUN'S TO TAKE. +func TestAPlainFolderRunLeavesNotesThatWereThereBeforeIt(t *testing.T) { + folder, taskDir, _ := plainFolderRun(t, func(folder string) { + if err := os.MkdirAll(filepath.Join(folder, ".fake"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(folder, ".fake", "old.md"), "an earlier run's\n") + }) + if _, err := os.Stat(filepath.Join(folder, ".fake", "old.md")); err != nil { + t.Fatalf("notes that were there before the run were taken: %v", err) + } + if _, err := os.Stat(filepath.Join(taskDir, "fake")); !os.IsNotExist(err) { + t.Fatalf("notes that were not this run's alone were moved: %v", err) + } +} diff --git a/internal/session/delegate_stop_test.go b/internal/session/delegate_stop_test.go new file mode 100644 index 0000000000..03db4266ee --- /dev/null +++ b/internal/session/delegate_stop_test.go @@ -0,0 +1,145 @@ +package session + +// WHERE A STOPPED PROGRAM'S WORK GOES. +// +// A stop ends a program's run the way every ending of it ends: its folder +// finished (programfolder.go), with what it had left uncommitted committed on +// its own branch, that branch left checked out, and the person's branch where +// it was. A stop that changed nothing leaves no branch, and the person is told +// at once where the work will be. + +import ( + "context" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// stoppedDelegatedRunThatDid runs one program whose work before the stop is +// play, in a repository newTestRepo makes (prepare may give it branches first), +// stops it, and answers the repository, the run's row and the notes on its page. +func stoppedDelegatedRunThatDid(t *testing.T, prepare func(repo string), play func(t *testing.T, workspace string)) (string, TaskNotice, []string) { + t.Helper() + double := newBeltRunDouble("unused") + double.honoursStop = true + double.early = func(workspace string) { play(t, workspace) } + registerBeltRunEngine(t, double) + conversation := newTestRepo(t) + if prepare != nil { + prepare(conversation) + } + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if _, err := agent.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)); err != nil { + t.Fatal(err) + } + beltRunWaitFor(t, "the run to end", func() bool { + agent.beltMu.Lock() + defer agent.beltMu.Unlock() + return agent.beltRun == nil + }) + var row TaskNotice + for _, kept := range agent.graph().runRows(id) { + if kept.ID == id { + row = kept + } + } + return conversation, row, beltRunNotes(t, filepath.Dir(spec.Store.Path()), spec.Store.RootID()) +} + +// A STOPPED PROGRAM'S WORK IS COMMITTED ON ITS BRANCH, committed by the +// program or not, and the branch is left checked out: the stop's own words are +// the body of the commit that holds what it left. +func TestAStoppedProgramsWorkIsCommittedOnItsBranchAndLeftCheckedOut(t *testing.T) { + repo, row, notes := stoppedDelegatedRunThatDid(t, nil, func(t *testing.T, workspace string) { + commitIn(t, workspace, "one.txt") + writeFile(t, filepath.Join(workspace, "two.txt"), "two\n") + }) + branch, log := taskBranchLog(t, repo) + if head := currentBranch(repo); head != branch { + t.Fatalf("the checkout is on %q after the stop, want the program's branch %q", head, branch) + } + files := gitOut(t, repo, "ls-tree", "--name-only", branch) + for _, name := range []string{"one.txt", "two.txt"} { + if !strings.Contains(files, name) { + t.Fatalf("the program's branch does not hold %s:\n%s", name, files) + } + } + if !strings.Contains(log, "wip(edit): one.txt") { + t.Fatalf("the program's own commit is gone from its branch:\n%s", log) + } + if body := gitOut(t, repo, "log", "-1", "--format=%b", branch); !strings.Contains(body, "stopped") { + t.Fatalf("the commit of what the stop left does not say it was stopped:\n%s", body) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")); tip != strings.TrimSpace(gitOut(t, repo, "rev-parse", branch+"~2")) { + t.Fatalf("the person's branch moved to %s", tip) + } + if row.Branch != branch || len(row.Changed) != 2 || row.Merge != mergeKept { + t.Fatalf("the row names %q with %q (%s), want the program's branch with both files", row.Branch, row.Changed, row.Merge) + } + if joined := strings.Join(notes, "\n"); !strings.Contains(joined, "stopped · its work is on the branch "+branch+" in "+canonicalPath(repo)+", 2 files, and that branch is checked out there") { + t.Fatalf("the stop does not say where the work is: %q", notes) + } +} + +// A STOPPED PROGRAM THAT CHANGED NOTHING LEAVES NO BRANCH, as one that ended +// does, and the person's own branch is checked out again. +func TestAStoppedProgramThatChangedNothingLeavesNoBranch(t *testing.T) { + repo, row, notes := stoppedDelegatedRunThatDid(t, nil, func(*testing.T, string) {}) + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a stopped run that changed nothing left a branch behind: %q", branches) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q, want the person's branch work back", head) + } + if row.Branch != "" || !strings.Contains(strings.Join(notes, "\n"), "stopped · it changed nothing, so "+canonicalPath(repo)+" is back on your branch work") { + t.Fatalf("a stopped run that changed nothing draws %q and says %q", row.Branch, notes) + } +} + +// THE STOP SAYS AT ONCE WHERE THE WORK WILL BE: on the program's branch in the +// person's folder, not on "its branch", which named nothing a person could +// find. +func TestAStoppedProgramSaysWhereItsWorkWillBe(t *testing.T) { + double := newBeltRunDouble("unused") + double.honoursStop = true + registerBeltRunEngine(t, double) + repo := newTestRepo(t) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add files") + if err != nil { + t.Fatal(err) + } + <-double.entered + branch := currentBranch(repo) + said, err := agent.Cancel(CancelTask + ":" + strconv.FormatUint(id, 10)) + if err != nil { + t.Fatal(err) + } + if want := "its work so far stays on its branch " + branch + ", checked out in " + canonicalPath(repo); !strings.Contains(said, want) { + t.Fatalf("the stop said %q, want %q", said, want) + } + beltRunWaitFor(t, "the run to end", func() bool { + agent.beltMu.Lock() + defer agent.beltMu.Unlock() + return agent.beltRun == nil + }) +} diff --git a/internal/session/hooks.go b/internal/session/hooks.go index a023128ae8..5ea2bf35fe 100644 --- a/internal/session/hooks.go +++ b/internal/session/hooks.go @@ -234,6 +234,12 @@ func (a *Agent) controlPlaneFor() *controlPlane { // left to say about who is holding the tree — and it is a no-op in every // session that has never groomed a task, which is most of them. plane.register(treeClaimGuard{agent: a}) + // AND WHETHER A PROGRAM'S RUN IS WORKING THERE, which is the same question + // asked of a different holder (programhold.go): not a node of this + // conversation's graph, but a run that holds the person's folder itself, + // from this conversation or any other window or shell. It asks no graph, so + // it binds a session that never groomed a task as much as one that did. + plane.register(programHoldGuard{agent: a}) // AND WHERE A TASK IS STANDING, which is the path half of the same question // (taskoutside.go): not whose work a command would take, but which directory // it is aimed at. It is registered BEFORE the git guard below and the order diff --git a/internal/session/hooks_test.go b/internal/session/hooks_test.go index 8688861a54..7c694e6d56 100644 --- a/internal/session/hooks_test.go +++ b/internal/session/hooks_test.go @@ -106,14 +106,16 @@ func TestTheControlPlaneRegistersTheFourMechanismsInOrder(t *testing.T) { // nothing until an agent is built with a scope (orchestrate.go), or some // node is running in a tree this agent is writing in (treehold.go). The // claim comes after the scope because the scope is about the writer and - // the claim is about everybody else. + // the claim is about everybody else. The program hold beside it is inert + // the same way until a program's run holds a folder this agent writes in + // (programhold.go). // // AND THE GROUND COMES BEFORE THE GIT GUARD, which is the one order in // this list that a person would notice being wrong: the git guard's // sentences are about a task's OWN copy, so a command aimed at another // directory has to meet the path law first or be refused with a // paragraph that is false about every path in it (taskoutside.go). - {"pre-action", planeNames(plane.preAction), []string{"approval", "changes", "write-scope", "tree-claim", "task-ground", "task-git"}}, + {"pre-action", planeNames(plane.preAction), []string{"approval", "changes", "write-scope", "tree-claim", "program-hold", "task-ground", "task-git"}}, {"post-feedback", planeNames(plane.postFeedback), []string{"changes", "writes", "loop"}}, } { if !sameNames(expected.got, expected.want) { diff --git a/internal/session/lawregistry_test.go b/internal/session/lawregistry_test.go index 2bc534cf5f..c658493a24 100644 --- a/internal/session/lawregistry_test.go +++ b/internal/session/lawregistry_test.go @@ -184,6 +184,12 @@ var lawRegistry = []lawUnit{ {id: "tasks.look-inside", class: lawCore, key: "Look inside running or landed work with `tasks` and its id"}, {id: "tasks.continue-is-not-a-new-task", class: lawCore, key: "never a fresh `propose_task`"}, {id: "read.what-read-cannot-turn-into-text", class: lawCore, key: "What `read` cannot turn into text → `read_document`"}, + // ── and the two triggers for a program codeaf carries (delegate_door.go's + // [delegateFact], the owner's call of 2026-09-24): the work a program's own + // guide claims goes to it, and so does work the person asks one for. What + // each program is for is its guide's; these say only that codeaf prefers it. + {id: "program.work-it-is-for", class: lawCore, key: "AND WORK A PROGRAM BUILT INTO CODEAF IS FOR GOES TO IT WHOLE"}, + {id: "program.the-one-asked-for", class: lawCore, key: "so does work the person asks one for, by name or as `/name`."}, // ── the mark codeaf leaves on work it did in somebody's name. It is core // rather than verb: `bash` is where it happens, but `bash` is pi's own // description and this law is codeaf's, and it is stated in ONE place for diff --git a/internal/session/loop.go b/internal/session/loop.go index 3699704a44..ecd59d3731 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -308,8 +308,12 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // and the deadline it puts on the context both tells every request how long is // left and cuts the turn itself ([Agent.settleBoundTripped] reads the ceiling // at the loop's boundary). Every other turn is left exactly as it was. - if _, settle := settleWakeFrom(ctx); settle { - windowed, closeWindow := openCallWindow(ctx, a.settleWindow(), callWindow{}) + if wake, settle := settleWakeFrom(ctx); settle { + window := a.settleWindow() + if wake.window > window { + window = wake.window + } + windowed, closeWindow := openCallWindow(ctx, window, callWindow{}) defer closeWindow() ctx = windowed } @@ -5372,6 +5376,18 @@ func (a *Agent) addFoldedUsageAs(response *ai.Response, model string, calls int, a.addUsageAs(response, model, calls, role, false, false) } +// addDetachedFoldedUsage is [Agent.addFoldedUsage] for work that runs BESIDE +// the conversation's turns rather than inside one: a run the conversation +// handed a task to (task_run_money.go's beltFold). It writes no ledger row, +// because the run's own worker wrote one per call, and it moves no turn's +// share ([Agent.addDetachedUsageAs]'s reason): a run's call priced while the +// person's next chat turn is running is not that turn's spending, and a turn +// abandoned then would otherwise be journaled with the run's dollars as its +// own. +func (a *Agent) addDetachedFoldedUsage(response *ai.Response, model string, calls int) { + a.addUsageAs(response, model, calls, "", false, false, detachedFromTurn) +} + // The roles an auxiliary line can name. A line is journaled with the role that // made the call so a bad answer can be traced to the model that gave it: the // session's name and a piece of work's name are the two that a person SEES, and diff --git a/internal/session/modelapi_seams_test.go b/internal/session/modelapi_seams_test.go new file mode 100644 index 0000000000..f859a4bbaf --- /dev/null +++ b/internal/session/modelapi_seams_test.go @@ -0,0 +1,85 @@ +package session + +import ( + "context" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// The two seams a delegated run's model API reaches this package through +// (internal/provider/modelapi): the account pool's own "can a service answer +// this model" test, and a call that keeps the program's own cache lineage. + +// SERVESMODEL IS THE POOL'S OWN TEST, NOT A SECOND ONE: the service a model's +// id resolves to — a prefix read off it — holds a key, or needs none. +func TestServesModelIsThePoolsOwnTest(t *testing.T) { + router := modelsource.DefaultSource("https://openrouter.ai/api/v1") + proxy := modelsource.Source{ID: modelsource.CustomID, Written: "mybox", Name: "mybox", Address: "http://127.0.0.1:9000/v1"} + local := modelsource.Source{ID: "custom-ollama", Written: "ollama", Name: "ollama", Address: "http://127.0.0.1:11434/v1", KeyOptional: true} + keyless := modelsource.NewSet( + modelsource.Connected{Source: router, Address: router.Address}, + modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address}, + modelsource.Connected{Source: local, Address: local.Address}, + ) + keyed := modelsource.NewSet(modelsource.Connected{Source: router, Key: "sk-or-v1-routerkey0000000000", Address: router.Address}) + for _, row := range []struct { + sources modelsource.Set + model string + want bool + }{ + {keyless, "deepseek/deepseek-v4-flash-0731", false}, + {keyless, "openrouter/deepseek/deepseek-v4-flash-0731", false}, + {keyless, "mybox/qwen3-coder", true}, + {keyless, "ollama/llama4", true}, + {keyed, "deepseek/deepseek-v4-flash-0731", true}, + {keyed, "openrouter/deepseek/deepseek-v4-flash-0731", true}, + {keyed, "", false}, + {modelsource.Set{}, "deepseek/deepseek-v4-flash-0731", false}, + } { + if got := ServesModel(row.sources, row.model); got != row.want { + t.Errorf("ServesModel(%q) = %v, want %v", row.model, got, row.want) + } + } + // And it agrees with the pool: a model the pool would move to the seat is + // exactly one this answers no for. + pool := &modelClientPool{config: Config{Sources: keyless}, seat: "mybox/qwen3-coder"} + if seated := pool.seatedModel("deepseek/deepseek-v4-flash-0731"); seated != "mybox/qwen3-coder" || ServesModel(keyless, "deepseek/deepseek-v4-flash-0731") { + t.Fatalf("the pool seated %q and ServesModel disagrees with it", seated) + } +} + +// keyCapture is a completer that remembers the cache key each call carried. +type keyCapture struct{ keys *[]string } + +func (c keyCapture) CompleteWithMessages(ctx context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { + *c.keys = append(*c.keys, provider.CacheKeyFrom(ctx)) + return &ai.Response{}, nil +} + +// A CALL MARKED AS BRINGING ITS OWN LINEAGE KEEPS IT, and nothing else +// changes: an unmarked call, and a marked one that carries no key, are +// stamped with the conversation's key exactly as before. +func TestAMarkedCallKeepsItsOwnCacheLineage(t *testing.T) { + var keys []string + wrapper := sessionCompleter{inner: keyCapture{keys: &keys}, cacheKey: "conversation"} + program := provider.WithCacheKey(context.Background(), "program-thread") + for _, ctx := range []context.Context{ + WithOwnCacheLineage(program), + program, + WithOwnCacheLineage(context.Background()), + context.Background(), + } { + if _, err := wrapper.CompleteWithMessages(ctx, nil); err != nil { + t.Fatal(err) + } + } + want := []string{"program-thread", "conversation", "conversation", "conversation"} + for index := range want { + if keys[index] != want[index] { + t.Fatalf("keys = %q, want %q", keys, want) + } + } +} diff --git a/internal/session/plandb_plan.go b/internal/session/plandb_plan.go index c0e05ed263..5f83c32f1b 100644 --- a/internal/session/plandb_plan.go +++ b/internal/session/plandb_plan.go @@ -108,6 +108,39 @@ func (g *TaskGraph) planIfArmed() *planState { return g.plan } +// planForPages is the plan the surface's task pages are read from: the armed +// plan under the switch, and otherwise the store this conversation's session +// folder already holds, read and never armed. +// +// A PROGRAM'S RUN WRITES ITS STORE WHATEVER THE SWITCH SAYS, and its page was +// read only under it. `/senior-dev` and `propose_task`'s `via` take the run +// road with the switch off (task.go's run-road gate), so the run's rows, its +// conversation with codeaf and its stage were all on disk while every reader +// here answered nil: the rail drew no stage, and every door into the task +// opened a room that said it would fill in and never did. Arming the plan +// instead would hand the switch's other roads — the worker's bash prefix, the +// seed, the pulse — to every ordinary task of a conversation that once ran a +// program, so the readers get a state of their own and nothing else moves. +// No store is ever made here; a conversation that never ran one answers nil. +func (g *TaskGraph) planForPages() *planState { + if plan := g.planIfArmed(); plan != nil { + return plan + } + path := g.planPath() + if path == "" { + return nil + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return nil + } + g.planMu.Lock() + defer g.planMu.Unlock() + if g.pagePlan == nil || g.pagePlan.path != path { + g.pagePlan = &planState{path: path, chat: g.planChat()} + } + return g.pagePlan +} + // planPath resolves where this run's store lives: the session folder, or — // for the legacy flat layout, whose Place is zero — the workspace's .codeaf // folder. The CLI finds the same file by walking up from the worker's own diff --git a/internal/session/plandb_program.go b/internal/session/plandb_program.go new file mode 100644 index 0000000000..4a0bcf7f8c --- /dev/null +++ b/internal/session/plandb_program.go @@ -0,0 +1,352 @@ +package session + +// A PROGRAM'S TASK IS READ AS THE ACTIONS IT TOOK. A task a run handed to a +// program codeaf carries (senior-dev first; internal/delegate) is drawn as what +// the program did, step by step through its own process: every stage, step and +// ending it reported, which the run's worker writes to the task's action log +// as each arrives (delegate.ActionsFile), read through the program's own +// vocabulary for them (delegate.Delegate's Present). Beside them the page +// carries the program's calls to a model — every one of which goes through the +// model API codeaf serves the run, which writes each as one turn into the +// task's own record folder (delegate.ConversationFile) — because the calls +// know two things the actions do not always say (a compaction, a change of +// model) and because the raw calls stay one key away on the page. This file is +// the reading side of both for the task page: whose run it is, the stages it +// said it would move through, the step it is in now, its actions and its +// turns, cut to what a page draws. +// +// NOTHING HERE WRITES. The run's worker writes the program record when the +// program says hello (delegate.ProgramFile) and the action log as the records +// arrive, and the model API writes the turns; this file reads all three, +// inside the page read the surface already makes off its loop +// ([Agent.PlanTaskPage]) and the row read the side list already makes on its +// beat ([Agent.PlanTasks]), and never on a frame. + +import ( + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// planProgramTurns is how many of a program's calls one page carries: the +// newest, because the page opens stuck to its bottom edge and an hour's run +// makes hundreds. The rest are counted ([PlanProgram.Earlier]) rather than +// carried, which is also what keeps a page read over a connection the size of +// a page and not the size of the log. +const planProgramTurns = 200 + +// planProgramActions is how many of a program's actions one page carries: the +// newest, for the reason [planProgramTurns] keeps the newest calls, and more of +// them, because a call that ran five tools is five actions. +const planProgramActions = 400 + +// planProgramHead is the most of any one text a page carries, in bytes. The +// page draws the first line of what a program sent and of what the model +// answered and never more, so the rest of each text would cross the wire on +// every beat to be thrown away at the far end; the whole of it stays in the +// program's own record on disk. +const planProgramHead = 240 + +// PlanProgram is the program a task was handed to, as the task's page reads it: +// its name and stages off the program record, and its conversation with codeaf +// off the log the model API writes. A page carries one only for a task whose +// record folder holds either — which is to say only for a program's task — and +// every other page carries nil. +type PlanProgram struct { + // Name is the program's own name, the word its command is spelled with. It + // is empty only for a log with no record beside it, which is a program that + // never said who it was; the page draws no name it was not given. + Name string + // Stages is every stage the program said, in its hello, it would move + // through, in order. Empty when the program named none. + Stages []string + // Turns is the conversation: the newest [planProgramTurns] calls the + // program made, in the order they started, each as its latest record says. + // EVERY TEXT IN IT IS CUT TO ITS HEAD — the first line that says anything, at + // most [planProgramHead] bytes — and the run's own copy is taken out of it + // ([planRunCopies.strip]), because that is all the page draws. The record on + // disk is untouched: this is a reading of it. + Turns []delegate.Turn + // Earlier is how many calls came before the first of Turns, which the page + // says rather than draws. Zero when the page carries the whole conversation. + Earlier int + // Calls is how many calls reached a model: every call the log holds that + // codeaf did not refuse, the one in flight included. It is counted over the + // whole log and not over Turns, so it stays the run's own figure however + // long the run has gone. + Calls int + // CeilingUSD is the dollar ceiling the run handed the program, off the + // program record the worker writes at the hello (delegate.ProgramRecord), + // and zero when the run set none or the program has not said hello yet — + // which the page draws as no ceiling at all rather than as $0.00. + CeilingUSD float64 + // Actions is what the program did: the newest [planProgramActions] lines of + // its action log, in the order they arrived, each as the program's own + // vocabulary reads it (delegate.Delegate.Reader) — the step of its process + // it served, the words, how it came out — and the lines it leaves out + // absent. Every text is cut to its head and the run's own copy is taken out + // of it, as the turns' are. Empty for a run from before the log existed, + // whose page is drawn from its turns. + Actions []delegate.Shown + // EarlierActions is how many actions came before the first of Actions, + // which the page says rather than draws. + EarlierActions int +} + +// planProgramRecord is the program a task was handed to: the record the run's +// worker wrote in the task's own folder when the program said hello, and +// otherwise the name the conversation's live run carries for its root +// (`carried`), with no stages. False is every task no program was handed — +// which is every task but a program's run's root. +// +// THE CARRIED NAME IS FOR THE SECONDS BEFORE THE HELLO. A run is published the +// moment it starts and the program writes its hello a moment later, so a row +// read in between would otherwise wear no name at all; once the record is on +// disk it is the record that answers, during the run and after it. +func planProgramRecord(dir, id, carried string) (delegate.ProgramRecord, bool) { + if record, ok := delegate.ReadProgram(plandb.TaskDir(dir, id)); ok { + return record, true + } + if carried = strings.TrimSpace(carried); carried != "" { + return delegate.ProgramRecord{Name: carried}, true + } + return delegate.ProgramRecord{}, false +} + +// planProgramStage is where a program says it is, read off its task's live +// step. The worker publishes it as the live step in the program's own words — +// the step of its process, `: explore`, or before it has named one its +// stage's word, and for a program that gave no words `: · +// ` (internal/run's delegateSink.live) — so the word is what is left +// without the name in front and without a status behind, and nothing at all +// when no step is live, because a run whose program has ended is in no step. +// +// IT IS READ FOR A PROGRAM'S TASK AND FOR NO OTHER. A live step of any other +// task is a command a worker is running, and a command is not a stage. +func planProgramStage(name string, live plandb.LiveStep) string { + name, label := strings.TrimSpace(name), strings.TrimSpace(live.Command) + if name == "" || label == "" { + return "" + } + label = strings.TrimPrefix(label, name+": ") + if stage, _, found := strings.Cut(label, " · "); found { + label = stage + } + return strings.TrimSpace(label) +} + +// planProgramRow names a row's program and the stage it is in now. It is the +// one place a row learns both, so the record's name and the name the live run +// carries are read into the row the same way. +func planProgramRow(row *PlanTaskRow, name string) { + row.Program = strings.TrimSpace(name) + row.Stage = planProgramStage(row.Program, row.Live) +} + +// planCarriedRow gives a row the program the conversation's live run carries +// for it, when the program's own record has not reached the disk yet +// ([planProgramRecord] says why there is such a moment). A row that already +// names its program keeps the record's name. +func planCarriedRow(row *PlanTaskRow, carried string) { + if row.Program != "" || strings.TrimSpace(carried) == "" { + return + } + planProgramRow(row, carried) +} + +// planCarriedPrograms is the program this conversation's live run was handed +// to, keyed by the run's root task: at most one entry, and none while no +// program's run is live. It is read once for a whole listing, under the belt's +// own lock, the way [Agent.planDisplayRunCopy] reads the live copy beside it. +func (a *Agent) planCarriedPrograms() map[string]string { + a.beltMu.Lock() + defer a.beltMu.Unlock() + if a.beltRun == nil || a.beltRun.delegate == nil { + return nil + } + return map[string]string{a.beltRun.root: a.beltRun.delegate.Name} +} + +// planRootIsProgram answers whether a run's root task was handed to a program: +// its record folder holds the program's record, or the conversation's live run +// carries a program for it before that record has reached the disk. +func (a *Agent) planRootIsProgram(store *plandb.Store, rootID string) bool { + id := planTaskID(rootID) + _, ok := planProgramRecord(filepath.Dir(store.Path()), id, a.planCarriedPrograms()[id]) + return ok +} + +// planProgramPage reads one task's program and conversation for its page, or +// nil for a task that is not a program's: no record in its folder, no name the +// live run carries for it, and no conversation log. +// +// A LOG THAT CANNOT BE READ IS A CONVERSATION WITH NO TURNS YET, never a page +// that fails. [delegate.ReadTurns] answers a missing log as nothing said and +// skips a line cut mid-write; anything worse leaves the page with its brief and +// its pinned line, which is still the truth about a run that has said nothing +// this page can read. +func planProgramPage(dir, id, carried string, copies planRunCopies, programs []delegate.Delegate) *PlanProgram { + record, known := planProgramRecord(dir, id, carried) + taskDir := plandb.TaskDir(dir, id) + all, _ := delegate.ReadTurns(taskDir, 0) + logged, _ := delegate.ReadActions(taskDir, 0) + if !known && len(all) == 0 && len(logged) == 0 { + return nil + } + program := &PlanProgram{Name: record.Name, CeilingUSD: record.CeilingUSD} + program.Actions, program.EarlierActions = planProgramActionsFor(logged, planProgramOf(programs, record.Name), copies) + if len(record.Stages) > 0 { + program.Stages = append([]string(nil), record.Stages...) + } + for _, turn := range all { + if strings.TrimSpace(turn.Refused) == "" { + program.Calls++ + } + } + kept := all + if len(kept) > planProgramTurns { + kept = kept[len(kept)-planProgramTurns:] + } + program.Earlier = len(all) - len(kept) + if len(kept) > 0 { + program.Turns = make([]delegate.Turn, len(kept)) + for i, turn := range kept { + program.Turns[i] = planTurnForPage(turn, copies) + } + } + return program +} + +// planProgramOf is the program of this build's list with this name, and a +// program of that name with no vocabulary of its own — read plainly — when the +// list does not carry it: a run of a program this build no longer carries is +// still a run whose page draws what it did. +func planProgramOf(programs []delegate.Delegate, name string) delegate.Delegate { + for _, program := range programs { + if program.Name == name { + return program + } + } + return delegate.Delegate{Name: name} +} + +// planProgramActionsFor is a program's action log as its page carries it: every +// line read, from the first, by one reader of the program's own vocabulary — +// which may need the lines before to read the one in front of it — with the +// run's copy taken out of what it names; the newest [planProgramActions] of +// what it shows, every text cut to its head; and how many shown actions came +// before those. +func planProgramActionsFor(logged []delegate.Action, program delegate.Delegate, copies planRunCopies) ([]delegate.Shown, int) { + read := program.Reader() + var shown []delegate.Shown + for _, action := range logged { + action.Command = copies.strip(action.Command) + action.Observation = copies.strip(action.Observation) + action.Message = copies.strip(action.Message) + line, ok := read(action) + if !ok { + continue + } + line.Text = planTextHead(copies.strip(line.Text)) + line.Outcome = planTextHead(line.Outcome) + line.Reason = planTextHead(line.Reason) + if line.Text == "" { + continue + } + shown = append(shown, line) + } + earlier := 0 + if len(shown) > planProgramActions { + earlier = len(shown) - planProgramActions + shown = shown[earlier:] + } + return shown, earlier +} + +// planTurnForPage is one turn as a page carries it: every text cut to its head +// and the run's own copy taken out of it, and every other field — the call's +// clock, its models, its size and price, whether it was refused or failed — +// exactly as the log says. An absent list stays absent, so a turn that sent +// nothing new reads back the same across the wire as it was written. +func planTurnForPage(turn delegate.Turn, copies planRunCopies) delegate.Turn { + head := func(text string) string { return planTextHead(copies.strip(text)) } + if len(turn.Sent) > 0 { + sent := make([]delegate.Said, len(turn.Sent)) + for i, said := range turn.Sent { + said.Text = head(said.Text) + sent[i] = said + } + turn.Sent = sent + } + turn.Reply = head(turn.Reply) + if len(turn.Calls) > 0 { + calls := make([]delegate.ToolUse, len(turn.Calls)) + for i, call := range turn.Calls { + call.Args = planTextHead(copies.strip(call.Args)) + calls[i] = call + } + turn.Calls = calls + } + turn.Refused = head(turn.Refused) + turn.Failed = head(turn.Failed) + return turn +} + +// planTextHead is the first line of a text that says anything, bounded to +// [planProgramHead] bytes on a rune boundary with the cut marked. +func planTextHead(text string) string { + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimSpace(line); line != "" { + return clip(line, planProgramHead) + } + } + return "" +} + +// strip takes the run's own copy out of a line a program wrote, FOR THE PAGE +// ONLY. A program's tools name every file by its absolute path, and the path of +// the run's copy is the same forty-odd cells in front of every one of them: +// fitted to a row from the right, it was all the row said, and the file the +// call was about was the part cut off. What a path means inside the copy is the +// part after it, so that is what the page carries. +// +// TWO FOLDERS ARE A COPY, and they are the two [planRunCopies] already names: +// the live run's copy (or the row's own folder, when no run is live), and any +// folder directly under the conversation's own folder of copies — an ended +// run's copy has been given back, and its program's words still name it. +func (c planRunCopies) strip(text string) string { + if text == "" { + return text + } + if live := strings.TrimSpace(c.live); live != "" && filepath.IsAbs(live) { + text = strings.ReplaceAll(text, strings.TrimRight(filepath.Clean(live), "/")+"/", "") + } + root := strings.TrimSpace(c.root) + if root == "" || !filepath.IsAbs(root) { + return text + } + prefix := strings.TrimRight(filepath.Clean(root), "/") + "/" + var out strings.Builder + for { + at := strings.Index(text, prefix) + if at < 0 { + break + } + rest := text[at+len(prefix):] + // A COPY IS ONE FOLDER DOWN, named without a space or a quote in it: the + // root followed by anything else is some other path that happens to + // start there, and it is left as it was written. + slash := strings.IndexByte(rest, '/') + if slash <= 0 || strings.ContainsAny(rest[:slash], " \t\"'") { + out.WriteString(text[:at+len(prefix)]) + text = rest + continue + } + out.WriteString(text[:at]) + text = rest[slash+1:] + } + out.WriteString(text) + return out.String() +} diff --git a/internal/session/plandb_program_test.go b/internal/session/plandb_program_test.go new file mode 100644 index 0000000000..32174023a2 --- /dev/null +++ b/internal/session/plandb_program_test.go @@ -0,0 +1,392 @@ +package session + +// A program's task page, read off the two records a program's run leaves in the +// task's own folder: the program record the worker writes at the program's +// hello, and the conversation log the model API appends a turn to per call. +// Every fixture writes them through the contract's own doors +// (delegate.WriteProgram, delegate.AppendTurn) and seeds the store through its +// own API, exactly as the run does; no model is called and no program runs. + +import ( + "math" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// programPageFixture is a store holding one task that was handed to +// senior-dev: its record, a conversation of three calls — one answered, one +// refused by codeaf, one still in flight — the stage the worker published as +// its live step, and two spend rows the model API banked, one per call. +func programPageFixture(t *testing.T) (*Agent, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha", Description: "rewrite the auth middleware"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev", Stages: []string{"intake", "implement", "verification"}, CeilingUSD: 5}); err != nil { + t.Fatalf("write the program record: %v", err) + } + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + for _, turn := range []delegate.Turn{ + // The first call, as it is written when it starts and again when it ends. + {Seq: 1, Started: began, Model: "deepseek/deepseek-v4-flash", Sent: []delegate.Said{{Role: "system", Text: "you are senior-dev"}, {Role: "user", Text: "rewrite the auth middleware"}}}, + {Seq: 1, Started: began, Ended: began.Add(4 * time.Second), Model: "deepseek/deepseek-v4-flash", + Sent: []delegate.Said{{Role: "system", Text: "you are senior-dev"}, {Role: "user", Text: "rewrite the auth middleware"}}, + Reply: "I'll read the middleware first.", Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"internal/auth/middleware.go"}`}}, + TokensIn: 1200, TokensOut: 40, CostUSD: 0.01}, + {Seq: 2, Started: began.Add(5 * time.Second), Model: "deepseek/deepseek-v4-flash", Refused: "the run's dollar ceiling is reached"}, + {Seq: 3, Started: began.Add(6 * time.Second), Model: "deepseek/deepseek-v4-flash", Sent: []delegate.Said{{Role: "tool", Tool: "read", Text: "package auth\n\nfunc Middleware() {}"}}}, + } { + if err := delegate.AppendTurn(folder, turn); err != nil { + t.Fatalf("append a turn: %v", err) + } + } + store, err := plandb.Open(path, "", planRootID, "", "") + if err != nil { + t.Fatalf("reopen the store: %v", err) + } + if err := store.SetLive("alpha", 2, "senior-dev: implement · running"); err != nil { + t.Fatalf("publish the stage: %v", err) + } + for _, usd := range []float64{0.01, 0.02} { + if err := store.AddSpend("alpha", "deepseek/deepseek-v4-flash", "work", usd, 10, 20); err != nil { + t.Fatalf("bank a call: %v", err) + } + } + _ = store.Close() + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + return agent, dir +} + +// A PROGRAM'S PAGE CARRIES ITS PROGRAM, ITS CONVERSATION AND THE STAGE IT IS IN, +// and the live step the page used to leave unset. The name and the stages are +// the record's; the turns are the log's, one per call, each as its latest +// record says; the calls counted are the ones that reached a model, the call in +// flight included and codeaf's refusal left out; the spend is the store's own +// spend rows, which is what a page read while the run is still going shows. +func TestAProgramsPageCarriesItsConversationStageAndLiveStep(t *testing.T) { + agent, _ := programPageFixture(t) + page, ok := agent.PlanTaskPage("t-alpha") + if !ok { + t.Fatal("the program's task answered no page") + } + program := page.Program + if program == nil { + t.Fatal("a task whose folder holds a program record and a conversation read as no program's") + } + if program.Name != "senior-dev" || strings.Join(program.Stages, ",") != "intake,implement,verification" { + t.Fatalf("program = %q with stages %v, want senior-dev and its three stages", program.Name, program.Stages) + } + if len(program.Turns) != 3 { + t.Fatalf("the page carries %d turns, want the three calls once each", len(program.Turns)) + } + first := program.Turns[0] + if first.Ended.IsZero() || first.Reply != "I'll read the middleware first." || len(first.Calls) != 1 || first.Calls[0].Name != "read" { + t.Fatalf("the first call reads %+v, want its ending record with the reply and the call", first) + } + if program.Turns[1].Refused == "" { + t.Fatalf("the refused call lost its refusal: %+v", program.Turns[1]) + } + if last := program.Turns[2]; !last.InFlight() || len(last.Sent) != 1 || last.Sent[0].Tool != "read" { + t.Fatalf("the call in flight reads %+v, want it open with what the program sent", last) + } + if program.CeilingUSD != 5 { + t.Fatalf("ceiling = %v, want the 5 the program record carries", program.CeilingUSD) + } + if program.Calls != 2 || program.Earlier != 0 { + t.Fatalf("calls = %d, earlier = %d; want 2 calls that reached a model and nothing earlier", program.Calls, program.Earlier) + } + if page.Live.Step != 2 || page.Live.Command != "senior-dev: implement · running" { + t.Fatalf("the page's live step = %+v, want the stage the worker published", page.Live) + } + if page.Row.Program != "senior-dev" || page.Row.Stage != "implement" { + t.Fatalf("the page's row names %q in stage %q, want senior-dev in implement", page.Row.Program, page.Row.Stage) + } + if math.Abs(page.Row.USD-0.03) > 1e-9 { + t.Fatalf("the page's spend = %v, want the 0.03 its spend rows carry", page.Row.USD) + } + + // AND THE SIDE LIST'S ROW SAYS THE SAME, off the same read. + row := planRowByID(t, agent.PlanTasks(), "t-alpha") + if row.Program != "senior-dev" || row.Stage != "implement" || math.Abs(row.USD-0.03) > 1e-9 { + t.Fatalf("the row reads program %q, stage %q, spend %v", row.Program, row.Stage, row.USD) + } +} + +// AN ORDINARY TASK IS NO PROGRAM'S. A task with neither record in its folder +// carries no program on its page and no program or stage on its row, so every +// page but a program's draws exactly what it drew — and its live step, which +// is a command, is never read as a stage. +func TestAnOrdinaryTaskCarriesNoProgram(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + store, err := plandb.Open(path, "", planRootID, "", "") + if err != nil { + t.Fatal(err) + } + if err := store.SetLive("alpha", 4, "go test: ./internal/api · -run TestX"); err != nil { + t.Fatal(err) + } + _ = store.Close() + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + page, ok := agent.PlanTaskPage("t-alpha") + if !ok { + t.Fatal("the task answered no page") + } + if page.Program != nil || page.Row.Program != "" || page.Row.Stage != "" { + t.Fatalf("an ordinary task reads as a program's: page %+v, row program %q stage %q", page.Program, page.Row.Program, page.Row.Stage) + } + if page.Live.Step != 4 { + t.Fatalf("an ordinary page's live step = %+v, want the one its row carries", page.Live) + } +} + +// A LONG RUN'S PAGE CARRIES ITS NEWEST CALLS AND COUNTS THE REST. The page +// opens at its bottom edge, so the newest calls are the ones it holds; the ones +// before them are a number the page says, and the call count is the whole +// log's, however long the run has gone. +func TestAProgramsPageCarriesTheNewestCallsAndCountsTheRest(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + total := planProgramTurns + 5 + for seq := 1; seq <= total; seq++ { + at := began.Add(time.Duration(seq) * time.Second) + if err := delegate.AppendTurn(folder, delegate.Turn{Seq: seq, Started: at, Ended: at.Add(time.Second), Model: "m", Reply: "ok"}); err != nil { + t.Fatal(err) + } + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil { + t.Fatal("the program's task answered no program page") + } + if got := len(page.Program.Turns); got != planProgramTurns { + t.Fatalf("the page carries %d turns, want the newest %d", got, planProgramTurns) + } + if page.Program.Earlier != 5 || page.Program.Calls != total { + t.Fatalf("earlier = %d, calls = %d; want 5 earlier and %d calls", page.Program.Earlier, page.Program.Calls, total) + } + if first := page.Program.Turns[0].Seq; first != 6 { + t.Fatalf("the first call the page carries is %d, want 6", first) + } +} + +// EVERY TEXT A PAGE CARRIES IS ITS HEAD, AND THE RUN'S COPY IS TAKEN OUT OF IT. +// A program's tools name files by their absolute path inside the copy, and a +// row fitted from the right drew the copy and cut the file off; a reply of a +// hundred lines crossed the wire every beat to have one line drawn. The record +// on disk is not touched: the page is a reading of it. +func TestAProgramsTurnsAreCutToTheirHeadsAndLeaveTheCopyOut(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + // THE CONVERSATION HAS A FOLDER OF ITS OWN, and its copies are cut under it: + // an ended run's copy is one folder down from there. + place := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.Place = Place{Dir: place} }) + armPlanStore(t, agent, path, "chat-a") + if agent.treesDir() == "" { + t.Fatal("the conversation has no folder of copies to cut a run's copy under") + } + copyDir := filepath.Join(agent.treesDir(), "7") + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + long := strings.Repeat("word ", 200) + if err := delegate.AppendTurn(folder, delegate.Turn{ + Seq: 1, Started: began, Ended: began.Add(time.Second), Model: "m", + Sent: []delegate.Said{{Role: "tool", Tool: "bash", Text: "\n\n ok " + copyDir + "/internal/auth\t0.3s\nPASS\n"}}, + Reply: "\nFirst line of the answer.\nSecond line.\n" + long, + Calls: []delegate.ToolUse{{Name: "edit", Args: `{"filePath":"` + copyDir + `/internal/auth/middleware.go","oldString":"x"}`}}, + }); err != nil { + t.Fatal(err) + } + onDisk, err := os.ReadFile(filepath.Join(folder, delegate.ConversationFile)) + if err != nil { + t.Fatal(err) + } + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil || len(page.Program.Turns) != 1 { + t.Fatalf("the program's page = %+v", page.Program) + } + turn := page.Program.Turns[0] + if turn.Reply != "First line of the answer." { + t.Fatalf("the reply's head = %q, want its first line that says anything", turn.Reply) + } + if got := turn.Sent[0].Text; got != "ok internal/auth\t0.3s" { + t.Fatalf("the tool result's head = %q, want its first line with the copy taken out", got) + } + if got := turn.Calls[0].Args; strings.Contains(got, copyDir) || !strings.Contains(got, `"internal/auth/middleware.go"`) { + t.Fatalf("the call's arguments = %q, want the file named inside the copy", got) + } + after, err := os.ReadFile(filepath.Join(folder, delegate.ConversationFile)) + if err != nil || string(after) != string(onDisk) { + t.Fatalf("reading the page changed the record on disk (err %v)", err) + } + if len(turn.Reply) > planProgramHead { + t.Fatalf("a head is %d bytes, over the %d a page carries", len(turn.Reply), planProgramHead) + } +} + +// THE LIVE RUN NAMES ITS PROGRAM BEFORE THE PROGRAM HAS SAID HELLO. A run is +// published the moment it starts and its program writes its record a moment +// later; the row read in between wears the name the run was handed, and once +// the record is there it is the record that answers. +func TestTheLiveRunNamesItsProgramBeforeTheRecordIsOnDisk(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + agent.beltMu.Lock() + agent.beltRun = &beltRun{root: "alpha", delegate: &delegate.Delegate{Name: "senior-dev"}} + agent.beltMu.Unlock() + t.Cleanup(func() { + agent.beltMu.Lock() + agent.beltRun = nil + agent.beltMu.Unlock() + }) + if row := planRowByID(t, agent.PlanTasks(), "t-alpha"); row.Program != "senior-dev" { + t.Fatalf("the live run's row names %q before the hello, want senior-dev", row.Program) + } + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil || page.Program.Name != "senior-dev" || len(page.Program.Turns) != 0 { + t.Fatalf("the live run's page before the hello = %+v", page.Program) + } + if other := planRowByID(t, agent.PlanTasks(), "t-"+planRootID); other.Program != "" { + t.Fatalf("a task the program was not handed names %q", other.Program) + } +} + +// THE STAGE IS THE PROGRAM'S PHASE AND NOTHING ELSE: the worker's label without +// the program's name in front and without the status behind, and nothing for a +// task no program runs or a program with no step live. +func TestTheStageIsReadOffTheProgramsLiveStep(t *testing.T) { + for _, tc := range []struct { + name, label, want string + }{ + {"senior-dev", "senior-dev: implement · running", "implement"}, + {"senior-dev", "senior-dev: verification", "verification"}, + {"senior-dev", "implement · submitted", "implement"}, + {"senior-dev", "", ""}, + {"", "senior-dev: implement · running", ""}, + } { + if got := planProgramStage(tc.name, plandb.LiveStep{Step: 1, Command: tc.label}); got != tc.want { + t.Errorf("planProgramStage(%q, %q) = %q, want %q", tc.name, tc.label, got, tc.want) + } + } +} + +// THE COPY IS STRIPPED WHERE IT IS A COPY AND NOWHERE ELSE: the live copy and +// any folder one step under the conversation's folder of copies, and never a +// path that merely starts at that folder. +func TestStripTakesOnlyTheRunsCopyOut(t *testing.T) { + copies := planRunCopies{live: "/w/trees/9", root: "/w/trees"} + for _, tc := range []struct{ in, want string }{ + {"/w/trees/9/a.go and /w/trees/3/b.go", "a.go and b.go"}, + {"cat /w/trees/README", "cat /w/trees/README"}, + {`{"path":"/w/trees/my copy/x"}`, `{"path":"/w/trees/my copy/x"}`}, + {"/elsewhere/x.go", "/elsewhere/x.go"}, + } { + if got := copies.strip(tc.in); got != tc.want { + t.Errorf("strip(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// A PROGRAM'S PAGE CARRIES WHAT IT DID, IN ITS OWN WORDS. Every line of the +// task's action log is read, in the order it arrived, by the program's own +// reader of it — which is handed the lines with the run's copy taken out of +// what they name — and what it shows is carried with its moment, its step's +// word, its head and its outcome; what it leaves out is absent. A program this +// build does not carry is read plainly, so its page still says what it did. +func TestAProgramsPageCarriesItsActionsInItsOwnWords(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "alpha", Title: "Alpha"}) + folder := plandb.TaskDir(dir, "alpha") + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + place := t.TempDir() + var seen []string + own := delegate.Delegate{Name: "senior-dev", Present: func() delegate.ActionReader { + return func(action delegate.Action) (delegate.Shown, bool) { + seen = append(seen, action.Command) + if action.Kind == delegate.ActionStage { + return delegate.Shown{}, false + } + return delegate.Shown{Step: "explore", Text: "ran " + action.Command + "\nand a second line", Outcome: delegate.ExitWord(action.Exit)}, true + } + }} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = Place{Dir: place} + c.Delegates = []delegate.Delegate{own} + }) + armPlanStore(t, agent, path, "chat-a") + copyDir := filepath.Join(agent.treesDir(), "7") + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + exit := 1 + for _, action := range []delegate.Action{ + delegate.StageAction(began, delegate.StageRecord{Stage: "run-contract", Status: "ready"}), + delegate.StepAction(began.Add(time.Second), delegate.StepRecord{Command: "go test " + copyDir + "/internal/auth", Tool: "bash", Step: "explore", Exit: &exit}), + } { + if err := delegate.AppendAction(folder, action); err != nil { + t.Fatal(err) + } + } + page, ok := agent.PlanTaskPage("t-alpha") + if !ok || page.Program == nil { + t.Fatal("the program's task answered no program page") + } + if len(page.Program.Actions) != 1 || page.Program.EarlierActions != 0 { + t.Fatalf("actions = %+v, want the one step its reader showed", page.Program.Actions) + } + got := page.Program.Actions[0] + want := delegate.Shown{At: began.Add(time.Second), Step: "explore", Text: "ran go test internal/auth", Outcome: "fails · exit 1"} + if got != want { + t.Fatalf("the action = %+v, want %+v", got, want) + } + if len(seen) != 2 || strings.Contains(seen[1], copyDir) { + t.Fatalf("the reader was handed %q, want both lines with the copy taken out", seen) + } + + // A PROGRAM THIS BUILD DOES NOT CARRY IS READ PLAINLY. + plain := planProgramOf(nil, "senior-dev") + shown, _ := planProgramActionsFor([]delegate.Action{delegate.StageAction(began, delegate.StageRecord{Stage: "intake", Status: "captured"})}, plain, planRunCopies{}) + if len(shown) != 1 || shown[0].Text != "intake · captured" { + t.Fatalf("a program with no vocabulary read %+v, want its stage and status", shown) + } +} + +// A LONG RUN'S PAGE CARRIES ITS NEWEST ACTIONS AND COUNTS THE REST, as it does +// its calls. +func TestAProgramsPageCarriesTheNewestActionsAndCountsTheRest(t *testing.T) { + began := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + var logged []delegate.Action + for i := 0; i < planProgramActions+5; i++ { + logged = append(logged, delegate.StepAction(began.Add(time.Duration(i)*time.Second), delegate.StepRecord{Command: "bash: step " + itoa(i)})) + } + shown, earlier := planProgramActionsFor(logged, planProgramOf(nil, "senior-dev"), planRunCopies{}) + if len(shown) != planProgramActions || earlier != 5 || shown[0].Text != "bash: step 5" { + t.Fatalf("carried %d actions from %q with %d earlier, want the newest %d and 5 earlier", len(shown), shown[0].Text, earlier, planProgramActions) + } +} diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index d1f6c34b42..858687ab6d 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -79,7 +79,11 @@ type PlanTaskRow struct { Model string `json:",omitempty"` Tokens int `json:",omitempty"` // Started is when the task was created and Ended when it completed; a task - // still open carries the zero Ended. + // still open carries the zero Ended. A PROGRAM's task carries its run's one + // pair instead — the hand-off and the instant the program was gone + // (task_run_clock.go's [planRunClocks.apply]) — because the store's pair + // brackets the copy being cut at one end and whenever each kind of ending + // wrote the store at the other. Started time.Time Ended time.Time // Note is the text of the task's last note, empty when nobody has left one. @@ -92,6 +96,17 @@ type PlanTaskRow struct { // ending of its loop, so a task that is not running never claims a present. Live plandb.LiveStep LiveParts []PlanCommandPart + // Program is the name of the program this task was handed to — senior-dev — + // read off the program record in the task's own record folder + // ([planProgramRecord]), and empty for every task a worker of this + // conversation's own drives. Stage is the word for where that program says + // it is right now — the step of its own process, `explore`, or before it + // has named one its stage's word — its live step read without its name in + // front ([planProgramStage]), and empty whenever nothing is live. The rail draws + // both under the run's own row, where a program's run used to wear only its + // clock. + Program string + Stage string // TrajectoryPath is the file the task's steps are recorded in, for a reader // that wants the record itself and not only its length. TrajectoryPath string @@ -181,6 +196,12 @@ type PlanTaskPage struct { // WaitRows feed the page's two-way waits reading: own dependencies first, // then open tasks directly waiting on this task. Empty omits the section. WaitRows []PlanTaskRow + // Program is the program this task was handed to and the conversation it + // has had with codeaf so far (plandb_program.go): nil for every task a worker + // of this conversation's own drives, which is every page but a program's. + // A page that carries one is drawn as that conversation rather than as a + // list of steps. + Program *PlanProgram } // PlanStep is one line of a task's trajectory — one command the worker ran and @@ -262,6 +283,8 @@ func (a *Agent) PlanTasks() []PlanTaskRow { } var rows []PlanTaskRow copies := a.planDisplayRunCopy() + carried := a.planCarriedPrograms() + clocks := a.planRunClocks() for _, store := range stores { dir := filepath.Dir(store.Path()) // AN ENDED RUN'S STORE IS NAMED FOR ITS PLACE IN THE LINE (`plan.db.1`, @@ -277,6 +300,8 @@ func (a *Agent) PlanTasks() []PlanTaskRow { root := store.RootID() for _, task := range tasks { row := planTaskRow(store, dir, task, spend, live) + planCarriedRow(&row, carried[task.ID]) + clocks.apply(&row, dir, task, root) a.markPlanMachineHold(&row, store.Path(), task.ID == root) row.Folder = a.planTaskRunCopy(task.ID) row.LiveParts = planStepDisplayFacts(PlanStep{Command: row.Live.Command}, copies.or(row.Folder), planShimFilename).Parts @@ -318,6 +343,8 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { spend := planSpendByTask(store.Path()) live := store.LiveSteps() copies := a.planDisplayRunCopy() + carried := a.planCarriedPrograms() + clocks := a.planRunClocks() // Walk admission order once; membership follows parent edges only. all := store.Tasks(plandb.Filter{Chat: plan.chat}) rows := make(map[string]PlanTaskRow, len(all)) @@ -325,6 +352,8 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { depths := map[string]int{task.ID: -1} for _, child := range all { row := planTaskRow(store, dir, child, spend, live) + planCarriedRow(&row, carried[child.ID]) + clocks.apply(&row, dir, child, store.RootID()) a.markPlanMachineHold(&row, store.Path(), child.ID == store.RootID()) row.Folder = a.planTaskRunCopy(child.ID) row.LiveParts = planStepDisplayFacts(PlanStep{Command: row.Live.Command}, copies.or(row.Folder), planShimFilename).Parts @@ -376,8 +405,14 @@ func (a *Agent) PlanTaskPage(id string) (PlanTaskPage, bool) { Folder: pageRow.Folder, Notes: planTaskNotes(store, task.ID), Steps: planStepDisplayFactsForPage(planTrajectory(dir, task.ID), copies.or(pageRow.Folder)), - Children: children, - WaitRows: waitRows, + // THE PAGE CARRIES ITS OWN LIVE STEP, lifted off its row. The field was + // declared for a surface to draw the step one step early and was never + // set, so the step in flight — and a program's stage, which is published + // as that same step — was drawn nowhere on the page. + Live: pageRow.Live, + Children: children, + WaitRows: waitRows, + Program: planProgramPage(dir, task.ID, carried[task.ID], copies.or(pageRow.Folder), a.config.Delegates), }, true } @@ -401,7 +436,7 @@ func (a *Agent) openPlanReadHandles() ([]*plandb.Store, *planState, func()) { if g == nil { return nil, nil, func() {} } - plan := g.planIfArmed() + plan := g.planForPages() if plan == nil { return nil, nil, func() {} } @@ -480,7 +515,7 @@ func (a *Agent) openPlanHandle() (*plandb.Store, *planState, func()) { if g == nil { return nil, nil, func() {} } - plan := g.planIfArmed() + plan := g.planForPages() if plan == nil { return nil, nil, func() {} } @@ -685,6 +720,13 @@ func planTaskRow(store *plandb.Store, dir string, task *plandb.Task, spend map[s TrajectoryPath: planTrajectoryPath(dir, task.ID), Live: live[task.ID], } + // A PROGRAM'S ROW NAMES ITS PROGRAM AND THE STAGE IT IS IN, both off what is + // on disk beside the trajectory or already read: the record the worker wrote + // at the program's hello, and the live step the worker publishes the stage + // as. Every other row costs one look for a record that is not there. + if record, ok := planProgramRecord(dir, task.ID, ""); ok { + planProgramRow(&row, record.Name) + } if task.ParentID != "" { row.Parent = planStoreID(task.ParentID) } diff --git a/internal/session/prefixbudget_test.go b/internal/session/prefixbudget_test.go index 7ec269e383..06dbf1af94 100644 --- a/internal/session/prefixbudget_test.go +++ b/internal/session/prefixbudget_test.go @@ -43,6 +43,7 @@ import ( "time" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" "github.com/Agent-Field/codeaf/internal/exec/bare" ) @@ -493,9 +494,46 @@ const fixedPrefixTarget = 48_000 // by default and paid those bytes all along. The arm now weighs the page people // were already reading, 48,814, and the raise carries this name so it can be // questioned. +// +// 2026-09-23, the programs codeaf carries, and the owner's call by name. The +// hand-off page's programs paragraph (delegate_door.go) is each carried +// program's own guide, printed under its name, and codeaf's rule about the +// folder a program that edits files must be handed; `propose_task`'s `via` +// field points at `ground`. The paragraph was already on the shipped page +// and was never weighed: the shipping shape carried no programs, and +// [widestPage] weighed the frame without its fill. Both are fixed in the same +// change, and the true cost shows on both arms: fixed is 56,271, over its +// 55,442 by 829, and lean is 47,719, over its 47,055 by 664. Both waivers rise +// by exactly that and sit on the measurement. Paying it back out of other +// prompt text was offered and declined: the paragraph is how the conversation +// learns what senior-dev is for and which folder to hand it, and cutting +// other lanes' wording to make room was the riskier edit days before a ship. +// +// 2026-09-24, codeaf reaches for a program by itself, and the owner's call by +// name: "raise the cap only as much as necessary — the prompts will be refined +// later". The programs paragraph now says work a program is for goes to it +// whole, rather than to the conversation or its own worker whatever its +// critical path, and that work the person asks one for by name goes to it; +// senior-dev's guide claims the complex, many-sided coding work it was going +// unused on (an issue in a mature codebase whose cause spans files) and says +// its brief carries the issue in full; and `via` says when it is set. That +// grew fixed by 202 bytes (page 153, `propose_task` 49) to 56,447, and lean +// by 153 to 47,859. Both arms already had room, 26 and 13 bytes of it, so +// fixed rises by 176 and lean by 140, and both sit exactly on the +// measurement again. +// +// 2026-09-24, a program works in the folder itself. The folder rule stopped +// describing a copy nothing merges and says the folder and its branch instead +// ([delegateFolderRule]), 38 bytes shorter on both arms, and both waivers come +// down by exactly that: fixed measures 56,409 and lean 47,821. +// +// 2026-09-24, dev merged under senior-dev (#1488). Both ledgers above stand, and +// the waivers are measured again on the merged page: fixed is 57,124 and lean +// 49,590, which is dev's 56,146 and 48,814 plus what the senior-dev entries +// above added (978 and 776 bytes on this page). Both sit on the measurement. const ( - fixedPrefixWaiver = 8_146 - leanPrefixWaiver = 17_314 + fixedPrefixWaiver = 9_124 + leanPrefixWaiver = 18_090 ) // THE LEAN PROFILE GETS A BUDGET OF ITS OWN (2026-09-10, the prompt diet's lane @@ -643,6 +681,15 @@ func widestPage() string { if len(fact.shelved) > len(widest) { widest = fact.shelved } + // AND A FILLED FACT IS WEIGHED FILLED. The programs paragraph is a + // frame whose body is each carried program's own guide + // (delegate_door.go), and weighing the frame alone once let a + // paragraph of a few hundred bytes ride every request unseen. It is + // filled with the programs this build carries, as the chat door + // hands them over. + if fact.fill != nil { + widest = fact.fill(Config{Delegates: builtin.All()}, widest) + } lines = append(lines, widest) } page = strings.Replace(page, section.token, strings.Join(lines, section.join), 1) diff --git a/internal/session/principal_structure_test.go b/internal/session/principal_structure_test.go index cc5b91bdf5..a5e75792a9 100644 --- a/internal/session/principal_structure_test.go +++ b/internal/session/principal_structure_test.go @@ -74,6 +74,12 @@ var wakeRoads = map[string]string{ "session's goal owner answered about that landing ([Agent.addressLanding] → " + "[Principal.Report]), so a run that lands while nobody is watching is told to whoever the " + "run was started for rather than to an empty room", + "programLandingNote": "a program's run's landing, which [Agent.deliverBeltRunLanding] hands " + + "here instead of its own note. It is addressed to THE CONVERSATION'S OWN MODEL, not to a " + + "person: the program's ending is codeaf's to act on (program_outcome.go) — check the work, " + + "send it back within the retry cap, or stop and say where it stands — so an unattended " + + "session still has somebody to act on it, and the person reads the summary that turn writes, " + + "owed their question when the hand-off carried one ([owedLandingDocument])", } // TestEveryWakeRoadSaysWhoItIsAddressedTo fails when a new road into the wake diff --git a/internal/session/program_depends.go b/internal/session/program_depends.go new file mode 100644 index 0000000000..65dc27260a --- /dev/null +++ b/internal/session/program_depends.go @@ -0,0 +1,131 @@ +package session + +// A PROGRAM'S RUN AND depends_on. +// +// depends_on is kept by codeaf's own task graph: a node waits, queued, until +// what it names has landed ([TaskGraph.readinessLocked]). A program's run is +// not a node, and it has nothing to wait in: it starts the moment it is +// approved, in the person's folder, on a branch of its own. So a proposal +// handed to a program that names work not yet finished was started at once, +// with its depends_on read and dropped — the program worked on a folder the +// work it was meant to follow had not reached. +// +// AND THE OTHER WAY ROUND. A program's run is a row, not a node, so an ordinary +// proposal naming one was refused with "no task in this session has that id" +// over a run the rail was drawing. +// +// Both are settled here, before a card: a program's proposal names only work +// that has finished, or is refused in a sentence that says to propose it again +// once it has; an ordinary proposal may name a program's run that ended done +// (its work is on its branch, checked out in its folder, so nothing is left to +// wait for), and is refused while that run is still going, because nothing +// would wake the node when it ends. + +import ( + "fmt" + "strings" +) + +// proposalDependencyRefusal is the sentence a proposal's depends_on is refused +// in, or "" when every dependency it names is one it may name. +func (a *Agent) proposalDependencyRefusal(spec taskSpec) string { + if len(spec.dependsOn) == 0 { + return "" + } + g := a.graph() + missing, failed := g.doomedDependencies(spec.dependsOn) + missing, failed, running := programRunDependencies(g, missing, failed) + if bashBeltAsked() { + missing = a.missingRunDependencies(missing) + } + if len(missing)+len(failed) > 0 { + return dependencyRefusal(missing, failed) + } + if len(running) > 0 { + return programRunWaitRefusal(running) + } + if spec.via != "" { + if waiting := g.unfinishedDependencies(spec.dependsOn); len(waiting) > 0 { + return programCannotWaitRefusal(spec.via, waiting) + } + } + return "" +} + +// programRunDependencies takes the ids the graph did not know out of missing +// when they name a program's run, and sorts them by how that run stands: ended +// done is satisfied and dropped, ended any other way joins failed, and still +// going is running. +func programRunDependencies(g *TaskGraph, missing, failed []uint64) (stillMissing, nowFailed, running []uint64) { + nowFailed = failed + for _, id := range missing { + row, found := runRowOf(g, id) + if !found || strings.TrimSpace(row.Program) == "" { + stillMissing = append(stillMissing, id) + continue + } + switch { + case row.State == TaskDone: + case row.State.settled(): + nowFailed = append(nowFailed, id) + default: + running = append(running, id) + } + } + return stillMissing, nowFailed, running +} + +// withoutEndedProgramRuns is ids with every program run that ended done taken +// out: [Agent.proposalDependencyRefusal] let it through as satisfied, and the +// graph, which knows no such node, would otherwise wait on it for ever. +func (a *Agent) withoutEndedProgramRuns(ids []uint64) []uint64 { + if len(ids) == 0 { + return ids + } + g := a.tasker() + kept := make([]uint64, 0, len(ids)) + for _, id := range ids { + if g != nil && !g.holdsNode(id) { + if row, found := runRowOf(g, id); found && row.Program != "" && row.State == TaskDone { + continue + } + } + kept = append(kept, id) + } + return kept +} + +// unfinishedDependencies is each id whose node has not landed done. +func (g *TaskGraph) unfinishedDependencies(ids []uint64) []uint64 { + g.mu.Lock() + defer g.mu.Unlock() + var waiting []uint64 + for _, id := range ids { + if node := g.nodes[id]; node != nil && node.state != TaskDone { + waiting = append(waiting, id) + } + } + return waiting +} + +// holdsNode is whether id is one of the graph's own nodes. +func (g *TaskGraph) holdsNode(id uint64) bool { + g.mu.Lock() + defer g.mu.Unlock() + return g.nodes[id] != nil +} + +// programCannotWaitRefusal is a program's proposal naming work not yet done. +func programCannotWaitRefusal(program string, ids []uint64) string { + return fmt.Sprintf("Invalid arguments: depends_on names %s, which has not finished, and %s starts the moment it is approved — it cannot wait. "+ + "Propose it again once %s has landed, or with depends_on left out if nothing must finish first.", + numberedTasks(ids), program, numberedTasks(ids)) +} + +// programRunWaitRefusal is an ordinary proposal naming a program's run that +// is still going. +func programRunWaitRefusal(ids []uint64) string { + return fmt.Sprintf("Invalid arguments: depends_on names %s, a program's run that has not ended, and a task cannot wait on one. "+ + "Propose it again once %s has ended, or with depends_on left out if nothing must finish first.", + numberedTasks(ids), numberedTasks(ids)) +} diff --git a/internal/session/program_depends_test.go b/internal/session/program_depends_test.go new file mode 100644 index 0000000000..fce686f739 --- /dev/null +++ b/internal/session/program_depends_test.go @@ -0,0 +1,78 @@ +package session + +import ( + "slices" + "strings" + "testing" +) + +// dependsConversation is a program-carrying conversation whose graph holds a +// node 3 in the state given and a program's run row 5 in the state given. +func dependsConversation(t *testing.T, node, run TaskState) *Agent { + t.Helper() + agent := programConversation(t, nil) + g := agent.graph() + g.mu.Lock() + g.nodes[3] = &TaskNode{id: 3, state: node} + g.mu.Unlock() + agent.publishRunRow(g, TaskNotice{ID: 5, Title: "rewrite the auth", State: run, Program: "senior-dev", StartedAt: agent.taskClockNow()}) + return agent +} + +// A PROGRAM'S PROPOSAL NAMING WORK NOT YET DONE IS REFUSED, because a program +// starts the moment it is approved and has nothing to wait in: it used to start +// at once with its depends_on dropped. +func TestAProgramsProposalNamingUnfinishedWorkIsRefused(t *testing.T) { + for _, state := range []TaskState{TaskQueued, TaskRunning, TaskUnverified} { + agent := dependsConversation(t, state, TaskDone) + spec := taskSpec{via: "senior-dev", dependsOn: []uint64{3}} + if got, want := agent.proposalDependencyRefusal(spec), programCannotWaitRefusal("senior-dev", []uint64{3}); got != want { + t.Fatalf("a program's proposal on a %s node answered %q, want %q", state, got, want) + } + if refusal := agent.refuseProposedTask(spec); refusal == nil { + t.Fatalf("the door let a program's proposal on a %s node through", state) + } + } + agent := dependsConversation(t, TaskDone, TaskDone) + if got := agent.proposalDependencyRefusal(taskSpec{via: "senior-dev", dependsOn: []uint64{3, 5}}); got != "" { + t.Fatalf("a program's proposal on landed work was refused: %q", got) + } + // AN ORDINARY PROPOSAL STILL WAITS in the graph, as it always has. + agent = dependsConversation(t, TaskRunning, TaskDone) + if got := agent.proposalDependencyRefusal(taskSpec{dependsOn: []uint64{3}}); got != "" { + t.Fatalf("an ordinary proposal on a running node was refused: %q", got) + } +} + +// AN ORDINARY PROPOSAL MAY NAME A PROGRAM'S RUN. It was refused with "no task +// in this session has that id" over a run the rail was drawing. A run that +// ended done is a dependency met and is taken out before the graph sees it; one +// still going is refused, because nothing would wake the node when it ends; one +// that ended any other way is refused as failed. +func TestAnOrdinaryProposalMayNameAProgramsRun(t *testing.T) { + agent := dependsConversation(t, TaskDone, TaskDone) + spec := taskSpec{dependsOn: []uint64{3, 5}} + if got := agent.proposalDependencyRefusal(spec); got != "" { + t.Fatalf("a proposal naming a program's finished run was refused: %q", got) + } + if kept := agent.withoutEndedProgramRuns(spec.dependsOn); !slices.Equal(kept, []uint64{3}) { + t.Fatalf("the dependencies handed on are %v, want the program's finished run taken out", kept) + } + + agent = dependsConversation(t, TaskDone, TaskRunning) + if got, want := agent.proposalDependencyRefusal(spec), programRunWaitRefusal([]uint64{5}); got != want { + t.Fatalf("a proposal naming a running program's run answered %q, want %q", got, want) + } + if kept := agent.withoutEndedProgramRuns(spec.dependsOn); !slices.Equal(kept, []uint64{3, 5}) { + t.Fatalf("a running program's run was taken out of the dependencies: %v", kept) + } + + agent = dependsConversation(t, TaskDone, TaskFailed) + if got := agent.proposalDependencyRefusal(spec); !strings.Contains(got, "depends_on names task 5, which already failed") { + t.Fatalf("a proposal naming a failed program's run answered %q", got) + } + + if got := agent.proposalDependencyRefusal(taskSpec{dependsOn: []uint64{9}}); !strings.Contains(got, "no task in this session has that id") { + t.Fatalf("an id nothing holds answered %q", got) + } +} diff --git a/internal/session/program_ground_test.go b/internal/session/program_ground_test.go new file mode 100644 index 0000000000..766e556fdb --- /dev/null +++ b/internal/session/program_ground_test.go @@ -0,0 +1,186 @@ +package session + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// A PROGRAM WORKS IN THE FOLDER IT WAS GIVEN. A chat opened in a plain folder +// made ~/Desktop/pong, named it as ground and said `in place`; the ladder's +// `in place` rung answered with the conversation's folder before ground was +// read, and senior-dev was handed the person's home folder. A program's folder +// is its ground, or the conversation's folder when it names none, `where` is +// not read for it, and it is that folder itself, never a copy. A ground that +// is not there yet is taken when the folder it would be made in is there, and +// made only when the run starts; one with nowhere to be made is refused. +func TestAProgramWorksInTheGroundItWasGivenAndNowhereElse(t *testing.T) { + conversation := t.TempDir() + ground := newTestRepo(t) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = conversation + config.Delegates = testPrograms("fake") + }) + for _, where := range []string{"in place", "", filepath.Join(conversation, "elsewhere")} { + stand := agent.resolveTaskGround(taskSpec{via: "fake", where: where, ground: ground, brief: "build it", deliverable: "the game", acceptance: "it runs"}) + if stand.refusal != "" || stand.ask != "" || stand.dir != canonicalPath(ground) || stand.mode != TaskModeInPlace { + t.Fatalf("where %q: stand = %+v, want the ground %s itself", where, stand, canonicalPath(ground)) + } + } + stand := agent.resolveTaskGround(taskSpec{via: "fake", where: "in place", brief: "build it", deliverable: "the game", acceptance: "it runs"}) + if stand.refusal != "" || stand.dir != canonicalPath(conversation) { + t.Fatalf("no ground: stand = %+v, want the conversation's folder %s", stand, canonicalPath(conversation)) + } + fresh := filepath.Join(conversation, "pong") + if stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: fresh, brief: "b", deliverable: "d", acceptance: "a"}); stand.refusal != "" || stand.dir != canonicalPath(fresh) { + t.Fatalf("a new folder whose parent is there: stand = %+v, want it taken as %s", stand, canonicalPath(fresh)) + } + if _, err := os.Stat(fresh); !os.IsNotExist(err) { + t.Fatalf("the card made the folder before anybody approved the work: %v", err) + } + if stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: filepath.Join(conversation, "missing", "deeper"), brief: "b", deliverable: "d", acceptance: "a"}); !strings.Contains(stand.refusal, "not there") { + t.Fatalf("a ground with nowhere to be made: stand = %+v, want the refusal", stand) + } +} + +// A PROGRAM IS NEVER HANDED THE HOME FOLDER, or one above it: it is not a +// project, and senior-dev on a folder with no git history snapshots all of it. +// Both doors refuse it and say what to do instead; a folder under it is fine. +func TestAProgramIsNeverHandedTheHomeFolder(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + project := filepath.Join(home, "Desktop", "pong") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = home + config.Delegates = testPrograms("fake") + }) + spec := taskSpec{via: "fake", where: "in place", brief: "build pong", deliverable: "the game", acceptance: "it runs"} + want := "fake works in one project's folder, and " + canonicalPath(home) + " is your home folder; say which folder the work is in, as ground" + if stand := agent.resolveTaskGround(spec); stand.refusal != want { + t.Fatalf("the conversation's folder is home: refusal = %q, want %q", stand.refusal, want) + } + above := spec + above.ground = filepath.Dir(home) + if stand := agent.resolveTaskGround(above); !strings.Contains(stand.refusal, canonicalPath(filepath.Dir(home))+" holds your home folder") { + t.Fatalf("a ground above home: stand = %+v", stand) + } + under := spec + under.ground = project + if stand := agent.resolveTaskGround(under); stand.refusal != "" || stand.dir != canonicalPath(project) { + t.Fatalf("a ground under home: stand = %+v, want %s", stand, canonicalPath(project)) + } + _, _, _, err := agent.StartDelegate(context.Background(), "fake", "build pong") + if err == nil || !strings.Contains(err.Error(), "is your home folder; open codeaf in that folder") { + t.Fatalf("/fake typed in the home folder: err = %v", err) + } +} + +// THE RECEIPT NAMES THE FOLDER AND THE BRANCH, read off the record the run +// wrote as it started rather than off a live run a program that died at once +// has left. +func TestAProgramsReceiptNamesItsFolder(t *testing.T) { + tree := testPrograms("fake")[0] + repo := newTestRepo(t) + plain := t.TempDir() + record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "work"} + if got, want := delegateReceipt(repo, tree, record), "It is fake's: it works alone in "+repo+" itself, on a new branch task/pong-abc123; your branch work does not move, and when it ends task/pong-abc123 stays checked out there with its work. Until it ends, codeaf's own tools write nothing in "+repo+"."; got != want { + t.Fatalf("the receipt for a repository = %q, want %q", got, want) + } + if got, want := delegateReceipt(plain, tree, &TaskCopyRecord{Dir: plain}), "It is fake's: it works alone in "+plain+" itself, which has no git history, so its changes are there as it makes them. Until it ends, codeaf's own tools write nothing in "+plain+"."; got != want { + t.Fatalf("the receipt for a plain folder = %q, want %q", got, want) + } + got := delegateStartedReceipt(3, "Pong", "", delegateReceipt(plain, tree, nil), "") + if !strings.HasPrefix(got, "task 3 started: Pong\nIt is fake's: it works alone in "+plain+" itself") || strings.Contains(got, "a copy of its own") || !strings.Contains(got, taskHandoffWakeSentence) { + t.Fatalf("the started receipt = %q", got) + } +} + +// THE CARD NAMES THE PROJECT. A program's card said `where:` and the path its +// copy would have under codeaf's state; it says the folder itself, and that it +// gets a branch of its own there when the folder is a repository. +func TestAProgramsCardNamesTheProject(t *testing.T) { + repo, plain := newTestRepo(t), t.TempDir() + config := Config{Workspace: t.TempDir(), Delegates: testPrograms("fake")} + if got := taskCardWhere(config, 1, taskSpec{via: "fake", ground: repo}); got != repo+", on a branch of its own" { + t.Fatalf("a repository's card says where: %q", got) + } + if got := taskCardWhere(config, 1, taskSpec{via: "fake", ground: plain}); got != plain { + t.Fatalf("a plain folder's card says where: %q", got) + } +} + +// A PROGRAM WORKS WITH THE MODELS THE PERSON ASKED FOR. The card showed the +// model a proposal named and the run was handed the crew's; now one word or +// several (comma-separated) resolve to the models the run is handed, a word +// that names no model is refused, and a proposal naming none is handed the +// crew rather than the default a task's card shows. +func TestAProgramWorksWithTheModelsThePersonAskedFor(t *testing.T) { + router := modelsource.DefaultSource("https://openrouter.ai/api/v1") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.TaskModels = func() []string { + return []string{"moonshotai/kimi-k2.6", "z-ai/glm-5.1", "z-ai/glm-5.3-flash", "deepseek/deepseek-v4-pro"} + } + config.Sources = modelsource.NewSet(modelsource.Connected{Source: router, Key: "sk-or-v1-routerkey0000000000", Address: router.Address}) + }) + choice := agent.resolveProgramModels("kimi-k2.6, deepseek-v4-pro") + if choice.problem != "" || choice.model != "moonshotai/kimi-k2.6,deepseek/deepseek-v4-pro" { + t.Fatalf("two words = %+v", choice) + } + if got := programAsked(taskSpec{modelWord: "kimi-k2.6, deepseek-v4-pro", model: choice.model}); strings.Join(got, " ") != "moonshotai/kimi-k2.6 deepseek/deepseek-v4-pro" { + t.Fatalf("asked = %q", got) + } + if choice := agent.resolveProgramModels("kimi-k2.6, nosuchmodel"); choice.problem == "" { + t.Fatalf("a word naming no model was not refused: %+v", choice) + } + if choice := agent.resolveProgramModels("glm, kimi-k2.6"); choice.problem == "" { + t.Fatalf("a word naming several models in a list was not refused: %+v", choice) + } + if got := programAsked(taskSpec{model: "z-ai/glm-5.3-flash"}); got != nil { + t.Fatalf("a proposal naming no model asked for %q", got) + } + if got := delegateStartedReceipt(2, "Invaders", "moonshotai/kimi-k2.6", "It is fake's.", ""); !strings.HasPrefix(got, "task 2 started on moonshotai/kimi-k2.6: Invaders\n") { + t.Fatalf("receipt = %q", got) + } +} + +// A MODEL NO CONNECTED SERVICE SERVES IS REFUSED BY NAME, before a card. It was +// handed to the program and every call on it was answered on the crew's +// working seat instead: the person asked for one model and got another. +func TestAProgramIsNotHandedAModelNoServiceServes(t *testing.T) { + router := modelsource.DefaultSource("https://openrouter.ai/api/v1") + proxy := modelsource.Source{ID: modelsource.CustomID, Written: "mybox", Name: "mybox", Address: "http://127.0.0.1:9000/v1"} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.TaskModels = func() []string { return []string{"moonshotai/kimi-k2.6", "mybox/qwen3-coder"} } + config.Sources = modelsource.NewSet( + modelsource.Connected{Source: router, Address: router.Address}, + modelsource.Connected{Source: proxy, Key: "local", Address: proxy.Address}, + ) + }) + if choice := agent.resolveProgramModels("kimi-k2.6"); !strings.Contains(choice.problem, "none of the model services connected here can serve moonshotai/kimi-k2.6") { + t.Fatalf("an unserved model = %+v", choice) + } + if choice := agent.resolveProgramModels("qwen3-coder, kimi-k2.6"); !strings.Contains(choice.problem, "none of the model services connected here can serve moonshotai/kimi-k2.6") { + t.Fatalf("an unserved model in a list = %+v", choice) + } + if choice := agent.resolveProgramModels("qwen3-coder"); choice.problem != "" || choice.model != "mybox/qwen3-coder" { + t.Fatalf("a served model = %+v", choice) + } + // A model spelled with a connected service's prefix is taken as written, + // though the catalog lists nothing of that service. + if choice := agent.resolveProgramModels("mybox/some-local-model"); choice.problem != "" || choice.model != "mybox/some-local-model" { + t.Fatalf("a model named with its service = %+v", choice) + } + if choice := agent.resolveProgramModels("openrouter/moonshotai/kimi-k2.6"); !strings.Contains(choice.problem, "serve openrouter/moonshotai/kimi-k2.6") { + t.Fatalf("a keyless service's model named with its prefix = %+v", choice) + } + if choice := agent.resolveProgramModels(""); choice.problem != "" { + t.Fatalf("no model named was refused: %+v", choice) + } +} diff --git a/internal/session/program_outcome.go b/internal/session/program_outcome.go new file mode 100644 index 0000000000..761a2a4518 --- /dev/null +++ b/internal/session/program_outcome.go @@ -0,0 +1,252 @@ +package session + +// A PROGRAM'S ENDING IS FOR CODEAF TO ACT ON, NOT FOR THE PERSON TO DECODE. +// +// senior-dev ends with a status — its change passed the project's own build and +// tests, nothing finished checking it, it did not pass, it hit a ceiling, it +// broke — and until this file that status was a sentence on a row and in the +// conversation's record, read by the model only if the person happened to ask +// something next. The owner asked on 2026-09-25 that the status inform codeaf +// rather than the person: the chat reads it the moment the run ends, checks +// what it can, sends senior-dev back with a sharper brief when the work did not +// stand, and tells the person where the work is in one plain summary. +// +// So every program run's landing WAKES a bounded turn ([Agent.deliverBeltRunLanding]) +// carrying the ending as a fact ([programVerdict]) and the one line of what to +// do about it, under a role page that says the whole playbook +// (prompts/program-outcome.md) — the per-event vehicle, which costs the fixed +// prefix nothing. +// +// TWO BOUNDS ARE CODE, NOT ADVICE. codeaf sends a program back on its own at +// most [programAutoRetries] times in a line of runs on one piece of work, and +// never after a run that ended on a dollar or time ceiling: that re-run spends +// more of the person's money, so it waits for their word ([Agent.programRetryRefusal]). +// A hand-off the person asks for in their own turn is theirs, and starts the +// count again. + +import ( + "fmt" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// programVerdict is how a program's run came out, as codeaf acts on it. +type programVerdict string + +const ( + // programPassed is finished work the program's own run of the project's + // build and tests passed. + programPassed programVerdict = "passed" + // programUnverified is finished work nothing finished checking. + programUnverified programVerdict = "unverified" + // programFailed is work the program handed in that does not pass, or work + // it did not finish. + programFailed programVerdict = "failed" + // programLimit is a run that stopped on a ceiling: the program's own, or a + // dollar or time limit the person set. + programLimit programVerdict = "limit" + // programCrashed is a run that broke — the program crashed, or ended + // without an ending of its own. + programCrashed programVerdict = "crashed" +) + +// programAutoRetries is how many times codeaf sends a program back to one +// piece of work on its own, after the first run: the owner's cap. +const programAutoRetries = 2 + +// programOutcomeCallCeiling bounds the turn a program's landing wakes. It is +// wider than a settle turn's ([settleCallCeiling]), because this turn may run +// the project's checks on the program's branch and hand the work back, and +// narrow enough that a turn cannot become an unbounded session of its own. +const programOutcomeCallCeiling = 16 + +// programOutcomeWindow is how long that turn has: a project's test suite has +// to fit in it. +const programOutcomeWindow = 15 * time.Minute + +// programVerdictOf reads how a program's run came out off the run's summary: +// a limit first (the person's own or the program's ceiling), then the +// program's own unfinished ending, then the word it finished on. +func programVerdictOf(summary RunSummary) programVerdict { + if summary.Limit != "" { + return programLimit + } + if ended := summary.Program; ended != nil { + switch ended.Status { + case delegate.StatusBudget: + return programLimit + case delegate.StatusCrashed: + return programCrashed + } + return programFailed + } + if summary.Outcome == beltRunOutcomeDone { + if summary.ProgramVerdict == "pass" { + return programPassed + } + return programUnverified + } + // A run that did not finish and carried no ending of the program's own: + // it exited without one, or the road under it failed. + return programCrashed +} + +// programAttempt is one run's place in a line of runs on one piece of work. +type programAttempt struct { + // attempt counts the runs in the line, 1 for the first. + attempt int + // auto counts the runs in it codeaf started on its own after an ending. + auto int +} + +// programOutcome is a program run's ending as the turn it wakes holds it +// ([Agent.programOutcomeNow]): which run, what it came to, and where in its +// line it stands. +type programOutcome struct { + row uint64 + program string + verdict programVerdict + programAttempt +} + +// programOutcomeNote is the note a program's landing wakes the conversation +// with: the landing's own line, then the ending as a fact and the one thing to +// do about it now. +func programOutcomeNote(outcome programOutcome, line string, costUSD float64) string { + var b strings.Builder + b.WriteString(line) + b.WriteString("\n\n") + fmt.Fprintf(&b, "[%s ended — for you to act on] task %d · %s · run %d", outcome.program, outcome.row, outcome.verdict, outcome.attempt) + if costUSD > 0 { + fmt.Fprintf(&b, " · $%.2f", costUSD) + } + b.WriteString("\n") + b.WriteString(programNextStep(outcome)) + return b.String() +} + +// programNextStep is what to do about one ending, in one sentence the model +// reads with the playbook it expands. +func programNextStep(o programOutcome) string { + left := programAutoRetries - o.auto + switch o.verdict { + case programPassed: + return "Its change passed the project's own checks. Check the result against what was asked, then tell the person in one short summary where the work is and offer to merge it." + case programUnverified: + return "Nothing finished checking its change. Run the project's checks on its branch yourself, then act on what they show as you would on a pass or a failure." + case programLimit: + return "It stopped on a limit, so another run spends more of the person's money: do not hand it back. Tell the person briefly what is done and what is left, and ask whether to spend more." + } + if left <= 0 { + return fmt.Sprintf("It has been sent back %d times already, which is the most codeaf does on its own: do not hand it back. Tell the person plainly what still does not work, where the work is, and what you would try next.", programAutoRetries) + } + if o.verdict == programCrashed { + return fmt.Sprintf("It broke rather than finished. If the cause looks passing (a network or provider failure), hand the same work to %s again; otherwise tell the person plainly. You may send it back %d more time%s on your own.", o.program, left, plural(left)) + } + return fmt.Sprintf("Its work does not stand yet. Read what failed; fix a small gap on its branch yourself, or hand the work back to %s with a brief sharpened by what failed. You may send it back %d more time%s on your own.", o.program, left, plural(left)) +} + +// rememberProgramOutcomeLocked keeps a program's ending for the turn it +// arrives in, so a hand-off that turn makes is known as a re-attempt of it. +// The caller holds a.mu. +func (a *Agent) rememberProgramOutcomeLocked(user userMessage) { + if user.programOutcome != nil { + outcome := *user.programOutcome + a.programOutcomeNow = &outcome + } +} + +// programRetryRefusal is why a hand-off to a program made in the turn a +// program's ending woke may not go, and "" when it may. It is the code half +// of the playbook's two bounds. +func (a *Agent) programRetryRefusal(via string) string { + if strings.TrimSpace(via) == "" { + return "" + } + a.mu.Lock() + now := a.programOutcomeNow + a.mu.Unlock() + if now == nil { + return "" + } + switch { + case now.verdict == programLimit: + return fmt.Sprintf("task %d stopped on a limit, and another %s run spends more of the person's money: ask the person first, and hand it over only on their word", now.row, via) + case now.auto >= programAutoRetries: + return fmt.Sprintf("%s has been sent back to this work %d times already, the most codeaf does on its own: tell the person where the work stands and let them decide", now.program, programAutoRetries) + } + return "" +} + +// programAttemptOf is the place in its line of a hand-off to a program made +// now: the next run of the line the turn's ending belongs to, counted as +// codeaf's own, or the first run of a new line when the person's turn made it. +func (a *Agent) programAttemptOf() programAttempt { + a.mu.Lock() + defer a.mu.Unlock() + if now := a.programOutcomeNow; now != nil { + return programAttempt{attempt: now.attempt + 1, auto: now.auto + 1} + } + return programAttempt{attempt: 1} +} + +// keepProgramAttempt writes down a started hand-off's place in its line, by +// the row the run is published under. +func (a *Agent) keepProgramAttempt(row uint64, attempt programAttempt) { + a.mu.Lock() + defer a.mu.Unlock() + if a.programAttempts == nil { + a.programAttempts = map[uint64]programAttempt{} + } + a.programAttempts[row] = attempt +} + +// programAttemptFor is a run's place in its line, the first run of one when +// nothing was written down (a typed `/senior-dev`, or a conversation reopened +// since). +func (a *Agent) programAttemptFor(row uint64) programAttempt { + a.mu.Lock() + defer a.mu.Unlock() + if attempt, ok := a.programAttempts[row]; ok { + return attempt + } + return programAttempt{attempt: 1} +} + +// programLandingNote is a program run's landing as the note that wakes the +// conversation to act on it ([programOutcomeNote]), owing the person's +// question when the hand-off carried one. +func (a *Agent) programLandingNote(run *beltRun, summary RunSummary, line string) userMessage { + outcome := programOutcome{ + row: run.row, program: programName(run.delegate), verdict: programVerdictOf(summary), + programAttempt: a.programAttemptFor(run.row), + } + text := programOutcomeNote(outcome, line, a.beltRunSpent(run.row)) + document := userText(text) + if task := run.store.Task(run.root); landingOwesAnswer(task) { + document = owedLandingDocument(task, text) + } + note := wakeNote(document.text()) + note.landingQuestion, note.landingOutcome = document.landingQuestion, document.landingOutcome + note.batch = false + note.settle, note.settleCeiling, note.settleWindow = true, programOutcomeCallCeiling, programOutcomeWindow + note.settlePrompt = programOutcomePrompt + note.programOutcome = &outcome + return note +} + +// programPageNote is what a program run's own page keeps as its ending: that +// the ending went to the conversation, and where the work is. THE PROGRAM'S +// STATUS IS NOT ON IT. The page's notes used to carry the whole outcome line — +// the program's status sentence, what its model claimed, what it observed — +// which is the account codeaf acts on, not one a person reads; the chat's reply +// says what came of the work, and the program's own words are on its actions. +func programPageNote(program string, landing RunLanding) string { + said := program + "'s ending went to the chat" + if line := beltLandingLine(landing); line != "" { + said += " · " + line + } + return said +} diff --git a/internal/session/program_outcome_test.go b/internal/session/program_outcome_test.go new file mode 100644 index 0000000000..56af69729e --- /dev/null +++ b/internal/session/program_outcome_test.go @@ -0,0 +1,179 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// EVERY WAY A PROGRAM'S RUN ENDS READS AS ONE VERDICT codeaf acts on. +func TestAProgramsEndingReadsAsTheVerdictCodeafActsOn(t *testing.T) { + for _, tc := range []struct { + name string + summary RunSummary + want programVerdict + }{ + {"checked and passing", RunSummary{Outcome: beltRunOutcomeDone, ProgramVerdict: "pass"}, programPassed}, + {"nothing finished checking it", RunSummary{Outcome: beltRunOutcomeDone, ProgramVerdict: "pass-unverified"}, programUnverified}, + {"a finish that named no verdict", RunSummary{Outcome: beltRunOutcomeDone}, programUnverified}, + {"handed in work that fails", RunSummary{Outcome: "incomplete", Program: &ProgramEnding{Status: delegate.StatusFail}}, programFailed}, + {"its own ceiling", RunSummary{Outcome: "incomplete", Program: &ProgramEnding{Status: delegate.StatusBudget}}, programLimit}, + {"a limit the person set", RunSummary{Outcome: "incomplete", Limit: RunLimitCost}, programLimit}, + {"it crashed", RunSummary{Outcome: "incomplete", Program: &ProgramEnding{Status: delegate.StatusCrashed}}, programCrashed}, + {"it left no ending", RunSummary{Outcome: "incomplete"}, programCrashed}, + } { + if got := programVerdictOf(tc.summary); got != tc.want { + t.Errorf("%s: verdict %q, want %q", tc.name, got, tc.want) + } + } +} + +// THE LINE UNDER THE ENDING SAYS WHAT TO DO NOW, and the two bounds are in it: +// a limit is never handed back on codeaf's own, and neither is a third retry. +func TestTheOutcomeNoteSaysWhatToDoNowAndKeepsBothBounds(t *testing.T) { + note := func(verdict programVerdict, auto int) string { + return programOutcomeNote(programOutcome{row: 3, program: "senior-dev", verdict: verdict, + programAttempt: programAttempt{attempt: auto + 1, auto: auto}}, "the landing line", 1.5) + } + first := note(programFailed, 0) + for _, want := range []string{"the landing line", "[senior-dev ended — for you to act on] task 3 · failed · run 1 · $1.50", "hand the work back to senior-dev", "2 more times"} { + if !strings.Contains(first, want) { + t.Fatalf("a first failure's note lacks %q:\n%s", want, first) + } + } + if last := note(programFailed, programAutoRetries); strings.Contains(last, "hand the work back") || !strings.Contains(last, "do not hand it back") { + t.Fatalf("a failure after the last retry is still told to hand it back:\n%s", last) + } + if limit := note(programLimit, 0); !strings.Contains(limit, "ask whether to spend more") || strings.Contains(limit, "hand the work back") { + t.Fatalf("a limit's note does not say to ask the person first:\n%s", limit) + } + if unverified := note(programUnverified, 0); !strings.Contains(unverified, "Run the project's checks on its branch yourself") { + t.Fatalf("an unverified ending is not told to check the branch:\n%s", unverified) + } + if passed := note(programPassed, 0); !strings.Contains(passed, "offer to merge") { + t.Fatalf("a pass is not told to offer the merge:\n%s", passed) + } +} + +// THE BOUNDS ARE CODE. In the turn a program's ending woke, a hand-off to a +// program after a limit is refused, and so is one past the retry cap; a +// person's own turn is never refused by either, and starts a new line. +func TestARetryPastTheCapOrAfterALimitIsRefusedOnlyInTheOutcomesTurn(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + set := func(o *programOutcome) { + agent.mu.Lock() + agent.programOutcomeNow = o + agent.mu.Unlock() + } + if got := agent.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("a person's turn was refused a hand-off: %q", got) + } + if got := agent.programAttemptOf(); got != (programAttempt{attempt: 1}) { + t.Fatalf("a person's hand-off starts at %+v, want the first run of a new line", got) + } + set(&programOutcome{row: 4, program: "senior-dev", verdict: programFailed, programAttempt: programAttempt{attempt: 1}}) + if got := agent.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("a first failure's retry was refused: %q", got) + } + if got := agent.programAttemptOf(); got != (programAttempt{attempt: 2, auto: 1}) { + t.Fatalf("the retry of a first run is %+v, want run 2, codeaf's first", got) + } + if got := agent.programRetryRefusal(""); got != "" { + t.Fatalf("a hand-off to no program was refused: %q", got) + } + set(&programOutcome{row: 4, program: "senior-dev", verdict: programFailed, programAttempt: programAttempt{attempt: 3, auto: programAutoRetries}}) + if got := agent.programRetryRefusal("senior-dev"); !strings.Contains(got, "sent back to this work 2 times already") { + t.Fatalf("a third retry was not refused: %q", got) + } + set(&programOutcome{row: 4, program: "senior-dev", verdict: programLimit, programAttempt: programAttempt{attempt: 1}}) + if got := agent.programRetryRefusal("senior-dev"); !strings.Contains(got, "ask the person first") { + t.Fatalf("a re-run after a limit was not refused: %q", got) + } + agent.mu.Lock() + agent.forgetOwedLocked() + agent.mu.Unlock() + if got := agent.programRetryRefusal("senior-dev"); got != "" { + t.Fatalf("the next turn still carries the last one's ending: %q", got) + } +} + +// A PROGRAM'S LANDING ALWAYS WAKES THE CONVERSATION, owed or not, with the +// playbook as its role page, the conversation's own model, and the ending on +// the note as a fact. +func TestAProgramsLandingWakesATurnWithThePlaybook(t *testing.T) { + completer := &scriptedCompleter{steps: []step{finalText("It passes; its branch is task/x.")}} + agent, _ := newTestAgent(t, completer, func(config *Config) { config.AskConsent = false }) + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "the run", "1", "Repair", "repair the parser") + if err != nil { + t.Fatal(err) + } + defer store.Close() + program := testPrograms("senior-dev")[0] + run := &beltRun{store: store, root: store.RootID(), row: 7, delegate: &program} + summary := RunSummary{Outcome: beltRunOutcomeDone, Result: "submitted a change", ProgramVerdict: "pass-unverified"} + + agent.deliverBeltRunLanding(run, summary, RunLanding{}) + beltRunWaitFor(t, "the program outcome turn", func() bool { return completer.requests() == 1 }) + + request := completer.request(0) + var playbook bool + for _, message := range request { + playbook = playbook || strings.Contains(messageText(message), strings.TrimSpace(programOutcomePrompt)) + } + if !playbook { + t.Fatal("the program's outcome turn was not handed the playbook") + } + last := messageText(request[len(request)-1]) + if !strings.Contains(last, "task 7 · unverified · run 1") || !strings.Contains(last, "Run the project's checks on its branch yourself") { + t.Fatalf("the outcome note = %q, want the verdict and what to do now", last) + } + if got := completer.model(0); got != agent.model { + t.Fatalf("the outcome turn ran on %q, want the conversation's own model %q", got, agent.model) + } +} + +// A SECOND RUN IN A FOLDER THE FIRST LEFT ON ITS BRANCH CARRIES ON THERE. It +// names the person's own branch, keeps every run's work on one branch, and a +// run that adds nothing never deletes what the first one committed. +func TestASecondRunCarriesOnOnTheFirstRunsBranch(t *testing.T) { + repo := newTestRepo(t) + home := strings.TrimSpace(gitOut(t, repo, "rev-parse", "--abbrev-ref", "HEAD")) + fake := testPrograms("fake")[0] + first, err := PrepareProgramFolder(ProgramFolderOrder{Program: fake, Dir: repo, Title: "Build the parser", Holder: "task 1", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "parser.go"), []byte("package p\n"), 0o644); err != nil { + t.Fatal(err) + } + if end := first.Finish("did not finish"); !end.Kept { + t.Fatalf("the first run's work was not kept: %s", end.Sentence()) + } + + second, err := PrepareProgramFolder(ProgramFolderOrder{Program: fake, Dir: repo, Title: "Finish the parser", Holder: "task 2", Keep: t.TempDir()}) + if err != nil { + t.Fatalf("the second run was refused: %v", err) + } + if second.Branch != first.Branch || second.Home != home || second.Start != first.Start || !second.Continues { + t.Fatalf("the second run = branch %q home %q continues %v, want the first run's branch %q and the person's %q", + second.Branch, second.Home, second.Continues, first.Branch, home) + } + if receipt := delegateReceipt(repo, fake, runCopyOf(second.tree())); !strings.Contains(receipt, "carrying on on its branch "+first.Branch) || + !strings.Contains(receipt, "your branch "+home+" does not move") { + t.Fatalf("the second run's receipt = %q", receipt) + } + end := second.Finish("finished") + if end.Dropped || !end.Kept || strings.TrimSpace(gitOut(t, repo, "rev-parse", "--abbrev-ref", "HEAD")) != first.Branch { + t.Fatalf("a second run that added nothing threw the first run's work away: %s", end.Sentence()) + } + if said := end.Sentence(); !strings.Contains(said, "your branch "+home+" is as it was") { + t.Fatalf("the ending does not name the person's own branch: %s", said) + } + if files := gitOut(t, repo, "ls-tree", "--name-only", first.Branch); !strings.Contains(files, "parser.go") { + t.Fatalf("the first run's work is gone from its branch:\n%s", files) + } +} diff --git a/internal/session/programfolder.go b/internal/session/programfolder.go new file mode 100644 index 0000000000..b319b0d3d4 --- /dev/null +++ b/internal/session/programfolder.go @@ -0,0 +1,1136 @@ +package session + +// A PROGRAM WORKS IN THE FOLDER IT IS GIVEN, ON A BRANCH OF ITS OWN WHEN THAT +// FOLDER IS A GIT REPOSITORY. +// +// THE CONTRACT. This is the whole of what codeaf does to the folder a program +// that edits files works in (senior-dev first), for a run a conversation hands +// off and for one a person starts at a shell alike; senior-dev.md and +// delegates.md say it in a person's words. +// +// 1. WHICH FOLDER. The folder the proposal names as `ground`, or the +// conversation's own when it names none (a typed `/senior-dev` names none), +// or the one a shell run was started in or named with `--dir` — THAT FOLDER +// ITSELF, never a copy of it. Inside a git repository it is the +// repository's root. It is never the home folder or a folder holding it +// ([programHomeRefusal]). A folder that is not there yet is made, empty, +// when the folder it would be made in is there. +// 2. A GIT REPOSITORY — history, a commit, and a root below the home folder. +// The person's branch (or the commit their checkout is on) is written +// down, `git switch -c` cuts the program's own branch ([taskBranchName]), +// and the program works there in its own git mode. THE PERSON'S BRANCH +// NEVER MOVES. A checkout with changes that are not committed, or in the +// middle of a merge, a rebase or a cherry-pick, is refused before anything +// starts, with what is in the way named ([programCheckoutInTheWay]). +// 3. ANYTHING ELSE — no history, no commit yet, or a repository whose root is +// the home folder or above it: the program works in the folder as it is, +// started with its own flags for that ([delegate.Delegate.PlainFolder]; +// senior-dev's `--in-place`). codeaf passes them whenever it decided so, +// because the program's own reading of a folder climbs to any repository +// around it. +// 4. WHEN IT ENDS — done, not finished, stopped, or crashed, an end the +// process holding the run saw — in a repository, what the program left +// uncommitted is committed onto its branch in one commit (the task's +// title, the result under it) and the branch is LEFT CHECKED OUT, so the +// person sees the work in their folder. A run that changed nothing is +// undone: the person's branch is checked out again and the empty branch +// deleted. A HEAD the program's shell moved off its branch is left exactly +// where it is, and said, and so is a branch of the person's that moved. +// A RUN WHOSE PROCESS WENT AWAY — codeaf closed, crashed or killed — is +// settled by the next codeaf that finds it WITHOUT A SINGLE GIT WRITE +// ([ProgramFolder.settleGone]): its work stays as it left it, and the +// person is told where and in what state. In either kind of folder the +// program's notes ([delegate.Delegate.Notes]) are moved into the run's +// record folder unless they were there before the run. +// 5. ONE RUN PER FOLDER. codeaf starts and stops the run and keeps its money, +// its time and its screen, and nothing else. A second program run on a +// folder one is working in, or on a folder inside it or around it — from +// any conversation, any window, or a shell — is refused, naming the run +// that holds it; the hold is a file lock, which dies with the process that +// took it (programhold.go). +// +// WHY THERE IS SO LITTLE HERE. Until 2026-09-24 a program ran through the +// general task machinery: a copy of the folder cut for every run, the brief's +// paths rewritten to name the copy, the program's commits squashed and its +// HEAD put back at the landing, and a ladder of placement rules, each layer +// patching the one before it. The owner asked why it was so hard to have +// senior-dev just work on the problem — "if it's in a git repo, great - if +// not, just do it" — and the answer was that codeaf had made it hard. The +// copy, the rewriting, the squash and the ladder went, and this is what +// stayed. +// +// ONE ROAD FOR BOTH DOORS. The conversation's run (task_run_belt.go) and the +// shell's `codeaf senior-dev` (cmd/codeaf/carried.go) prepare a folder with +// [PrepareProgramFolder] and finish it with [ProgramFolder.Finish], so a +// person at a shell and a person in the chat get the same folder, the same +// branch, the same refusals and the same last sentence. + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" +) + +// programFolderDir is where the hold on each folder a program works in, and +// the record of that run's folder, live: under the state root, keyed by the +// folder, and never inside the person's folder. +const programFolderDir = "program-folders" + +// programFolderShown is how many of the paths in the way a refused checkout +// names before it counts the rest. +const programFolderShown = 3 + +// ProgramFolderOrder is what a door hands [PrepareProgramFolder]. +type ProgramFolderOrder struct { + // Program is the program that will work in the folder. + Program delegate.Delegate + // Dir is the folder asked for, absolute. + Dir string + // Title is the run's title: the program's branch is named from it, and + // the commit that finishes the run carries it. Empty is the first words of + // Brief, the way a task names itself from its brief ([taskPersonTitle]). + Title string + Brief string + // Holder is how a second run on the folder is told whose run holds it: + // `task 4 (Fix the parser)`, or `a run started at a shell`. + Holder string + // Keep is the run's record folder. The program's notes are moved into it + // when the run ends, how the folder was left is written there + // ([programFolderEndFile]), and it is the name a reopen finds the run's + // folder by ([settleOwedProgramFolder]). + Keep string + // Instead is what a folder that is the home folder is answered with, + // after the refusal itself ([programHomeRefusal]). + Instead string + // Place is the conversation's session folder, which says where the + // repository's git lock lives ([lockGitRoot]); zero for a shell run, + // which takes none. + Place Place + // SignModel is the model the attribution line on the commit that finishes + // the run names, and "" for the line that names none: codeaf signs every + // commit it writes, and the only choice is whether the model is named + // ([gitSignature]). + SignModel string +} + +// ProgramFolder is one program run's folder as [PrepareProgramFolder] readied +// it. It is also the record a later process settles the run's folder from +// when the process that started it went away first +// ([settleOwedProgramFolder]), which is why its fields are written down. +type ProgramFolder struct { + // Program is the program's name, and Title is the run's. + Program string `json:"program"` + Title string `json:"title"` + // Dir is the folder the program works in, spelled the way the door asked + // for it when it asked for the folder itself. + Dir string `json:"dir"` + // Branch is the program's own branch, cut by codeaf; empty for a folder the + // program works in without git. Home is the branch the person had checked + // out, empty when their checkout was on no branch, and Start is the commit + // it stood on: together they are where going back goes. + Branch string `json:"branch,omitempty"` + Home string `json:"home,omitempty"` + Start string `json:"start,omitempty"` + // Outer is a repository around a folder worked in without git, which codeaf + // cut no branch in because its root holds the home folder. + Outer string `json:"outer,omitempty"` + // Notes is the program's notes folder inside Dir, and NotesWereThere says + // it was already there when the run began, which leaves it where it is. + Notes string `json:"notes,omitempty"` + NotesWereThere bool `json:"notesWereThere,omitempty"` + // Keep is the run's record folder ([ProgramFolderOrder.Keep]) and + // SignModel the model its attribution line names + // ([ProgramFolderOrder.SignModel]). + Keep string `json:"keep,omitempty"` + SignModel string `json:"signModel,omitempty"` + // Ended is the sentence the run's folder was finished with. Empty is a + // folder still owed its ending. + Ended string `json:"ended,omitempty"` + // Continues says this run carries on on the branch an earlier finished + // run of the same program left checked out in the folder, rather than + // cutting one of its own ([ProgramFolder.carryOn]): Branch, Home and Start + // are that run's, so the person's branch is still the one named and a run + // that adds nothing never deletes what the earlier run left. + Continues bool `json:"continues,omitempty"` + + key string + place Place + lock *os.File +} + +// Plain says the program works in its folder without git. +func (f *ProgramFolder) Plain() bool { return f == nil || f.Branch == "" } + +// PrepareProgramFolder readies the folder a program was asked to work in, per +// the contract at the top of this file, and holds it for the run: the folder +// resolved and made when it must be, the hold taken, a run that went away in +// it settled first, and in a repository the checkout read and the program's +// branch cut. The refusal is a sentence a person can act on, and nothing of +// the person's has been changed when there is one. +func PrepareProgramFolder(order ProgramFolderOrder) (*ProgramFolder, error) { + if strings.TrimSpace(order.Dir) == "" { + return nil, errors.New(order.Program.Name + " was handed no folder to work in") + } + asked := absolutePath(filepath.Clean(strings.TrimSpace(order.Dir))) + dir, repo, outer, refusal := programFolderAt(order.Program, asked, order.Instead) + if refusal != "" { + return nil, errors.New(refusal) + } + title := strings.TrimSpace(order.Title) + if title == "" { + title = taskPersonTitle(order.Brief) + } + folder := &ProgramFolder{ + Program: order.Program.Name, Title: title, Dir: dir, Outer: outer, + Notes: order.Program.Notes, Keep: order.Keep, SignModel: order.SignModel, + key: canonicalPath(dir), place: order.Place, + } + lock, hold, busy := claimProgramFolder(folder.key, order.Program.Name+", "+order.Holder) + if busy { + return nil, errors.New(programFolderBusy(dir, hold)) + } + folder.lock = lock + // A RUN THAT WENT AWAY IN THIS FOLDER IS SETTLED BEFORE THE NEXT ONE STARTS, + // AND NOTHING OF IT IS COMMITTED ([ProgramFolder.settleGone]): its record + // ended with where its work is and its notes moved, so the next run is + // never handed the last one's checklist as its own. What it left + // uncommitted stays exactly where it was, and the next run meets it the way + // it meets anybody's changes — refused, and told whose they may be. Nothing + // else holds the folder, because this does; on a filesystem that takes no + // locks nothing can say so, and an owed run there is left alone. + var earlier *ProgramFolderEnd + if owed, ok := readProgramFolder(folder.key); ok && owed.Ended == "" && lock != nil { + owed.key = folder.key + end := owed.settleGone() + owed.Ended = end.Sentence() + owed.write() + end.keepEnding() + earlier = &end + } + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.Mkdir(dir, 0o755); err != nil { + folder.release() + return nil, fmt.Errorf("make the folder %s: %w", dir, err) + } + } + if folder.Notes != "" { + _, err := os.Lstat(filepath.Join(dir, folder.Notes)) + folder.NotesWereThere = err == nil + } + if !repo { + folder.write() + return folder, nil + } + carried, err := folder.carryOn() + if !carried && err == nil { + err = folder.cutBranch() + } + if err != nil { + folder.release() + if earlier != nil && earlier.Folder.Branch != "" && !earlier.Moved { + // AND THE REFUSAL SAYS WHOSE THE CHANGES MAY BE. codeaf cannot tell a + // run's last edits from the person's own made on its branch since, so + // it commits neither and says both. + return nil, fmt.Errorf("%w; they may be an earlier %s run's, which codeaf could not finish: its branch %s is checked out there", + err, earlier.Folder.Program, earlier.Folder.Branch) + } + return nil, err + } + return folder, nil +} + +// carryOn takes up the branch an earlier finished run of the same program left +// checked out in this folder, and answers false when there is none to take up. +// +// A SECOND RUN STACKED A BRANCH ON THE FIRST AND CALLED THE FIRST "YOUR +// BRANCH". The earlier run's branch is left checked out and nothing merges it, +// so a run handed the same folder next — codeaf sending senior-dev back to +// finish what it left, or a person asking for more — met a clean checkout on +// `task/` and cut `task/` from it: its landing told the person +// their branch was `task/`, and a second attempt that changed nothing +// deleted its own branch and switched the folder back to the first run's. The +// run now carries on on that branch, with the person's branch and the commit +// it stood on read from the earlier run's record: the page names the person's +// real branch, every attempt's work is on one branch, and "changed nothing" is +// measured from where the first run started, so it can never throw away what +// an earlier attempt committed. +// +// Only a record whose run ENDED is taken up, and only while its branch is the +// one checked out: a person who switched away has chosen where the next run +// starts, and a record still owed its ending was settled above and is refused +// by the checkout's own changes if it left any. +func (f *ProgramFolder) carryOn() (bool, error) { + earlier, ok := readProgramFolder(f.key) + if !ok || earlier.Ended == "" || earlier.Branch == "" || earlier.Program != f.Program { + return false, nil + } + if canonicalPath(earlier.Dir) != f.key || currentBranch(f.Dir) != earlier.Branch { + return false, nil + } + if strings.TrimSpace(f.place.Dir) != "" { + defer lockGitRoot(f.place, f.key)() + } + if refusal := programCheckoutInTheWay(f.Dir, f.Notes); refusal != "" { + return true, errors.New(refusal) + } + f.Branch, f.Home, f.Start, f.Continues = earlier.Branch, earlier.Home, earlier.Start, true + f.write() + return true, nil +} + +// cutBranch reads the person's checkout and cuts the program's branch in it, +// under the repository's git lock when there is a session to keep one. +func (f *ProgramFolder) cutBranch() error { + if strings.TrimSpace(f.place.Dir) != "" { + defer lockGitRoot(f.place, f.key)() + } + if refusal := programCheckoutInTheWay(f.Dir, f.Notes); refusal != "" { + return errors.New(refusal) + } + start, err := git(f.Dir, "rev-parse", "--verify", "HEAD") + if err != nil { + return fmt.Errorf("%s has no commit to cut a branch from: %s", f.Dir, firstLine(start)) + } + f.Start, f.Home = strings.TrimSpace(start), currentBranch(f.Dir) + f.Branch = taskBranchName(f.Title) + // THE RECORD IS WRITTEN BEFORE THE BRANCH IS CUT, so a process that goes + // away between the two leaves a record a later one can finish from rather + // than a branch nothing knows about. + f.write() + out, err := git(f.Dir, append(switchWithoutHooks(), "-c", f.Branch)...) + if err == nil { + return nil + } + // A SWITCH THAT FAILED IS READ AGAIN BEFORE IT IS ANSWERED. git's exit says + // the command failed, not that nothing happened: a lock it could not take + // after the branch was made leaves the branch behind, and whatever else can + // go wrong once HEAD has moved leaves the checkout on it. A cut that did + // happen is carried on with, because the record written above is exactly + // what it needs; a branch made without the checkout following is deleted, + // so nothing is left in the person's repository that nothing knows about. + if currentBranch(f.Dir) == f.Branch { + return nil + } + if tip := branchCommit(f.Dir, f.Branch); tip != "" && tip == f.Start { + _, _ = git(f.Dir, "branch", "-q", "-D", f.Branch) + } + _ = os.Remove(programFolderRecord(f.key)) + refusal := fmt.Sprintf("could not cut %s's branch in %s: %s", f.Program, f.Dir, firstLine(out)) + if !f.onHome() { + refusal += "; the checkout is now on " + checkoutWords(f.Dir) + ", not " + f.homeWords() + } + return errors.New(refusal) +} + +// switchWithoutHooks is the head of every `git switch` codeaf runs in a +// program's folder: quiet, and with the repository's hooks turned off. +// +// BOTH SWITCHES GO BETWEEN TWO NAMES FOR ONE COMMIT — the program's branch cut +// where the checkout stands, and the person's own checked out again over a +// branch that holds nothing past it — so there is no checkout work a hook +// could have to do. And a hook that fails is the one way a switch that moved +// HEAD still exits non-zero: an LFS post-checkout hook with no git-lfs on +// codeaf's PATH left the checkout on a branch the refusal said was never cut. +func switchWithoutHooks() []string { + return []string{"-c", "core.hooksPath=" + os.DevNull, "switch", "-q"} +} + +// onHome says the checkout is where the person had it before the run: on +// their branch, or at the commit when it was on none. +func (f *ProgramFolder) onHome() bool { + head := currentBranch(f.Dir) + if f.Home != "" { + return head == f.Home + } + at, err := git(f.Dir, "rev-parse", "--verify", "-q", "HEAD") + return head == "" && err == nil && strings.TrimSpace(at) == f.Start +} + +// checkoutWords names where a checkout is now, as a person reads it: the +// branch, or the commit when it is on none. +func checkoutWords(dir string) string { + if head := currentBranch(dir); head != "" { + return "the branch " + head + } + return "no branch, at " + shortCommit(dir, "HEAD") +} + +// programFolderAt is the folder a program asked to work in asked works in, +// whether it works there on a branch, the repository around it codeaf will +// not cut one in, and the refusal when there is nowhere it may work. +func programFolderAt(program delegate.Delegate, asked, instead string) (string, bool, string, string) { + dir, repo, outer, refusal := programFolderOf(asked) + if refusal != "" { + return "", false, "", refusal + } + if refusal := programHomeRefusal(program, dir, instead); refusal != "" { + return "", false, "", refusal + } + return dir, repo, outer, "" +} + +// programFolderOf is [programFolderAt] without the home folder's refusal, +// which is the program's to say: the repository's root when asked is in a +// repository with a commit whose root is below the home folder, and asked +// itself otherwise. A folder that is not there yet is read by the folder it +// would be made in, and refused when that is not there either. +func programFolderOf(asked string) (dir string, repo bool, outer string, refusal string) { + probe := asked + if info, err := os.Stat(asked); err != nil { + parent := filepath.Dir(asked) + if info, err := os.Stat(parent); err != nil || !info.IsDir() { + return "", false, "", asked + " is not there, and neither is " + parent + ", the folder it would be made in" + } + probe = parent + } else if !info.IsDir() { + return "", false, "", asked + " is a file, not a folder" + } + root, ok := repositoryRoot(probe) + switch { + case !ok || !hasCommit(root): + return asked, false, "", "" + case holdsHomeFolder(root): + // A REPOSITORY AT THE HOME FOLDER IS NOBODY'S PROJECT. A dotfiles + // repository there would otherwise have every folder under home read as + // its subfolder, and the program's branch cut in the person's dotfiles. + return asked, false, root, "" + case canonicalPath(asked) == root: + return asked, true, "", "" + } + return root, true, "", "" +} + +// programCheckoutInTheWay is why a repository's checkout cannot take a +// program's branch now, in a sentence that says what to do; empty when nothing +// is in the way. The program's own notes folder is never in the way: it is +// the program's, and it is kept out of every commit. +// +// A CHANGE THAT IS NOT COMMITTED IS THE PERSON'S, AND A BRANCH CUT OVER IT +// TAKES IT ALONG. The program would count it as its own work, commit it with +// its first write, or — restoring a tree whose tests cannot start — put the +// file back to its last commit. So nothing starts until the person has put it +// somewhere of their own. +func programCheckoutInTheWay(dir, notes string) string { + if half := halfDone(dir); half != "" { + return dir + " is in the middle of a " + half + "; finish it or abort it, then ask again" + } + out, err := git(dir, "status", "--porcelain", "--untracked-files=all", "-z") + if err != nil { + return "git could not read " + dir + ": " + firstLine(out) + } + var paths []string + for _, path := range porcelainZPaths(out) { + if notes != "" && (path == notes || strings.HasPrefix(path, strings.TrimSuffix(notes, "/")+"/")) { + continue + } + paths = append(paths, path) + } + if len(paths) == 0 { + return "" + } + return dir + " has changes that are not committed (" + namedFew(paths, programFolderShown) + "); commit or stash them, then ask again" +} + +// halfDone is the git operation a checkout is in the middle of — a merge, a +// rebase, a cherry-pick or a revert — in the word a person uses for it, "" when +// it is in the middle of none. +func halfDone(dir string) string { + for _, half := range []struct{ path, what string }{ + {"MERGE_HEAD", "merge"}, + {"rebase-merge", "rebase"}, + {"rebase-apply", "rebase"}, + {"CHERRY_PICK_HEAD", "cherry-pick"}, + {"REVERT_HEAD", "revert"}, + } { + out, err := git(dir, "rev-parse", "--git-path", half.path) + if err != nil { + continue + } + path := strings.TrimSpace(out) + if !filepath.IsAbs(path) { + path = filepath.Join(dir, path) + } + if _, err := os.Lstat(path); err == nil { + return half.what + } + } + return "" +} + +// porcelainZPaths is every path `git status --porcelain -z` names, a rename +// by where it went. +func porcelainZPaths(out string) []string { + fields := strings.Split(out, "\x00") + var paths []string + for i := 0; i < len(fields); i++ { + entry := fields[i] + if len(entry) < 4 { + continue + } + paths = append(paths, entry[3:]) + if entry[0] == 'R' || entry[0] == 'C' { + // The path it came from follows, and is not a second change. + i++ + } + } + return paths +} + +// ProgramFolderEnd is how a program's run left its folder, as +// [ProgramFolder.Finish] found it and made it. +type ProgramFolderEnd struct { + Folder ProgramFolder + // Changed is every path the program's branch changed from where it + // started. + Changed []string + // Kept says the program's branch holds its work. + Kept bool + // Dropped says the run changed nothing, so the person's own branch (or + // commit) is checked out again and the program's branch is gone. + Dropped bool + // Moved says HEAD was not on the program's branch when the run ended: + // HeadOn is the branch it was on, empty with At naming the commit when it + // was on none. + Moved bool + HeadOn string + At string + // HomeMoved says the person's own branch no longer points where it did + // when the run began — something committed on it, reset it or deleted it + // while the program worked — and HomeAt is the commit it points at now, + // empty when it is gone. codeaf moves it back no more than it moved it. + HomeMoved bool + HomeAt string + // Gone says the run's process went away before it could end the run + // itself, so codeaf settled its folder without writing to git at all + // ([ProgramFolder.settleGone]), and Uncommitted is how many files it found + // there that are not committed. + Gone bool + Uncommitted int + // Refused is git's own line when what the program left could not be + // committed, or the checkout could not be put back. + Refused string + // Notes is where the program's notes went, as a sentence. + Notes string + + // said is the sentence a run's folder was ended with, read back from its + // record folder by a process that did not end it ([keptProgramFolderEnd]); + // [ProgramFolderEnd.Sentence] answers it as it was said. + said string +} + +// programFolderEndFile is the file in a run's own record folder that says how +// its folder was left: the branch, whether it holds the work, the files, and +// the sentence. +// +// IT IS WRITTEN WHERE THE RUN'S RECORD LIVES, NOT ONLY BESIDE THE HOLD. The +// record beside the hold is one folder's, and the next run in that folder +// writes over it; and a run whose folder was ended by one process can have its +// row settled by another — a codeaf that closed after the ending and before +// the row. Either way the conversation that reopens the run finds its ending +// here, and its row names the branch and where the work went instead of +// staying `interrupted` ([Agent.settleInterruptedProgramRow]). +const programFolderEndFile = "program-folder.json" + +// programFolderEnding is what [programFolderEndFile] holds. +type programFolderEnding struct { + Branch string `json:"branch,omitempty"` + Kept bool `json:"kept,omitempty"` + Changed []string `json:"changed,omitempty"` + Said string `json:"said"` +} + +// keepEnding writes how the run's folder was left into the run's record +// folder ([programFolderEndFile]). It is a record, so a disk that refuses it +// costs a later reopen its sentence and never the run. +func (e ProgramFolderEnd) keepEnding() { + keep := strings.TrimSpace(e.Folder.Keep) + if keep == "" { + return + } + body, err := json.MarshalIndent(programFolderEnding{Branch: e.Folder.Branch, Kept: e.Kept, Changed: e.Changed, Said: e.Sentence()}, "", " ") + if err != nil || os.MkdirAll(keep, 0o700) != nil { + return + } + _ = os.WriteFile(filepath.Join(keep, programFolderEndFile), body, 0o600) +} + +// keptProgramFolderEnd is how the run whose record folder is keep left its +// folder, as it wrote it there ([ProgramFolderEnd.keepEnding]); false when it +// wrote nothing. +func keptProgramFolderEnd(keep string) (ProgramFolderEnd, bool) { + body, err := os.ReadFile(filepath.Join(keep, programFolderEndFile)) + if err != nil { + return ProgramFolderEnd{}, false + } + var ending programFolderEnding + if json.Unmarshal(body, &ending) != nil || strings.TrimSpace(ending.Said) == "" { + return ProgramFolderEnd{}, false + } + return ProgramFolderEnd{Folder: ProgramFolder{Branch: ending.Branch, Keep: keep}, Kept: ending.Kept, Changed: ending.Changed, said: ending.Said}, true +} + +// Finish ends a program's run in its folder, per the fourth point of the +// contract at the top of this file, and lets the folder go. result is the +// run's ending in words, the body of the commit that holds what the program +// left. It answers what it found and did. +func (f *ProgramFolder) Finish(result string) ProgramFolderEnd { + end := f.settle(result) + f.Ended = end.Sentence() + f.write() + end.keepEnding() + f.release() + return end +} + +// settle is [ProgramFolder.Finish] without the record and the hold, which the +// caller owns. +func (f *ProgramFolder) settle(result string) ProgramFolderEnd { + end := ProgramFolderEnd{Folder: *f} + // THE NOTES GO FIRST, so the commit below can never hold them. + end.Notes = f.keepNotes() + if f.Branch == "" { + return end + } + if strings.TrimSpace(f.place.Dir) != "" { + defer lockGitRoot(f.place, f.key)() + } + if head := currentBranch(f.Dir); head != f.Branch { + // A HEAD THE PROGRAM MOVED IS LEFT WHERE IT IS. Committing there would put + // codeaf's commit on a branch that may be the person's own, and moving HEAD + // back would carry whatever is in the folder somewhere nobody chose; the + // person is told where it is instead, and decides. + end.Moved, end.HeadOn = true, head + if head == "" { + end.At = shortCommit(f.Dir, "HEAD") + } + if tip := branchCommit(f.Dir, f.Branch); tip != "" { + end.Changed = changedBetween(f.Dir, f.Start, tip) + end.Kept = tip != f.Start + } + return end + } + end.HomeMoved, end.HomeAt = f.homeMoved() + end.Refused = f.commitLeftovers(result) + head, _ := git(f.Dir, "rev-parse", "--verify", "HEAD") + end.Changed = changedSince(f.Dir, f.Start) + if end.Refused == "" && strings.TrimSpace(head) == f.Start { + if end.HomeMoved { + // A BRANCH OF THE PERSON'S THAT MOVED IS NOT SWITCHED TO. Going back + // would check out commits nobody here made or read, under a sentence + // saying the run changed nothing; the empty branch stays checked out, + // and the sentence says why. + return end + } + // A RUN THAT CHANGED NOTHING LEAVES NOTHING: no branch holding nothing + // in the person's repository, and their own branch checked out again. + if refused := f.goBack(); refused != "" { + end.Refused = refused + return end + } + end.Dropped = true + return end + } + end.Kept = true + return end +} + +// settleGone settles the folder of a run whose process went away before it +// could end the run itself — a crash, a kill, codeaf closed — and it WRITES +// NOTHING TO GIT: no add, no commit, no switch, no branch deleted. It reads +// where the checkout is, what the program's branch holds and how many files +// are not committed, moves the program's notes into the run's record folder, +// and answers the ending that says so ([ProgramFolderEnd.Gone]). +// +// ONLY AN END CODEAF SAW IS FINISHED WITH A COMMIT. Once the process that +// held the folder is gone, the folder is the person's again, and what is +// uncommitted in it may be the run's last edits or their own made on its +// branch since — codeaf cannot tell the two apart. A commit here once swept a +// person's day of edits, and a merge they were resolving, into a commit under +// codeaf's name with their hooks skipped. The read is made with git's optional +// locks off, so not even the index is refreshed. +func (f *ProgramFolder) settleGone() ProgramFolderEnd { + end := ProgramFolderEnd{Folder: *f, Gone: true} + end.Notes = f.keepNotes() + if f.Branch == "" { + return end + } + if head := currentBranch(f.Dir); head != f.Branch { + end.Moved, end.HeadOn = true, head + if head == "" { + end.At = shortCommit(f.Dir, "HEAD") + } + } + if tip := branchCommit(f.Dir, f.Branch); tip != "" { + end.Changed = changedBetween(f.Dir, f.Start, tip) + end.Kept = tip != f.Start + } + if !end.Moved { + end.Uncommitted = uncommittedCount(f.Dir, f.Notes) + end.HomeMoved, end.HomeAt = f.homeMoved() + } + return end +} + +// uncommittedCount is how many files in a checkout are not committed, the +// program's notes left out, read without taking or writing any of git's locks; +// zero when git cannot say. +func uncommittedCount(dir, notes string) int { + out, err := git(dir, "--no-optional-locks", "status", "--porcelain", "--untracked-files=all", "-z") + if err != nil { + return 0 + } + count := 0 + for _, path := range porcelainZPaths(out) { + if notes != "" && (path == notes || strings.HasPrefix(path, strings.TrimSuffix(notes, "/")+"/")) { + continue + } + count++ + } + return count +} + +// homeMoved reads the person's own branch again, the one the run was cut +// from, and answers whether it no longer points at the commit the run began +// on, and where it points now ("" when it is gone). A checkout that was on no +// branch has nothing that can move: a commit is where it is. +// +// NOTHING SAYS "AS IT WAS" WITHOUT LOOKING. The program never writes the +// person's branch, but its shell can — a checkout of it, a commit there, a +// switch back — and the sentence the person relies on before they push is +// the one that must not repeat a promise nobody checked. +func (f *ProgramFolder) homeMoved() (bool, string) { + if f.Home == "" { + return false, "" + } + tip := branchCommit(f.Dir, f.Home) + return tip != f.Start, tip +} + +// commitLeftovers commits everything the program left uncommitted in its +// folder onto its branch, in one commit whose subject is the run's title and +// whose body is result, and answers git's line when it would not go. +// +// IT IS THE PROGRAM'S FOLDER, SO IT IS ALL OF IT. The checkout was clean when +// the branch was cut ([programCheckoutInTheWay]), and nothing else of codeaf's +// writes there while the run holds it ([programHoldGuard]), so everything in +// it now that is not committed is the run's. It is only ever asked of a run +// whose end this process saw: a run whose process went away is settled +// without a commit ([ProgramFolder.settleGone]). +// +// THE NOTES ARE TAKEN BACK OUT OF THE INDEX, NOT LEFT OUT OF THE ADD. A +// pathspec that excludes `.senior-dev` makes `git add` exit 1 whenever that +// folder is there and ignored — and senior-dev ignores it in every repository +// it works in — so a notes folder that was there before the run, or would not +// move, failed every finishing commit. The whole folder is staged and the +// notes' own path reset to what HEAD holds, which git does whatever its +// ignore rules say, the way [sealGroundWork] does it. +// +// A CHECKOUT IN THE MIDDLE OF A MERGE IS NOT COMMITTED. The program's shell can +// start one, and a commit now would conclude it, conflict markers and all, +// under codeaf's name; the work is left as it is and the ending says why. +func (f *ProgramFolder) commitLeftovers(result string) string { + if half := halfDone(f.Dir); half != "" { + return f.Dir + " is in the middle of a " + half + } + if out, err := git(f.Dir, "add", "-A", "--", "."); err != nil { + return "git add: " + firstLine(out) + } + if f.Notes != "" { + if out, err := git(f.Dir, "reset", "-q", "--", f.Notes); err != nil { + return "git reset: " + firstLine(out) + } + } + if _, err := git(f.Dir, "diff", "--cached", "--quiet"); err == nil { + return "" + } + message := clip(firstLine(f.Title), 72) + if strings.TrimSpace(message) == "" { + // A run a shell started with no brief, on a command of its own, has no + // title, and git takes no commit without a subject. + message = f.Program + "'s work" + } + if result = strings.TrimSpace(result); result != "" { + message += "\n\n" + result + } + args := append([]string{"-c", "commit.gpgsign=false"}, codeafGitIdentity()...) + args = append(args, "commit", "-q", "--no-verify", "-m", signed(message, gitSignature{named: f.SignModel != "", model: f.SignModel})) + if out, err := git(f.Dir, args...); err != nil { + return "git commit: " + firstLine(out) + } + return "" +} + +// goBack checks out the person's own branch again (or the commit their +// checkout was on) and deletes the program's empty branch, answering git's +// line when either would not go. +// +// A SWITCH THAT FAILED AND STILL ARRIVED IS AN ARRIVAL. The checkout is read +// again after a failure ([ProgramFolder.onHome]), so a switch git reported +// badly after it had moved HEAD goes on to delete the empty branch rather +// than telling the person their folder could not be put back while it was. +func (f *ProgramFolder) goBack() string { + back := append(switchWithoutHooks(), f.Home) + if f.Home == "" { + back = append(switchWithoutHooks(), "--detach", f.Start) + } + if out, err := git(f.Dir, back...); err != nil && !f.onHome() { + return firstLine(out) + } + if out, err := git(f.Dir, "branch", "-q", "-D", f.Branch); err != nil { + return firstLine(out) + } + return "" +} + +// keepNotes moves the program's notes folder out of the folder it worked in +// and into the run's record folder, and answers the sentence that says where +// they went ("" when nothing moved). +// +// THE PERSON'S FOLDER GETS BACK ONLY THE WORK. A senior-dev run left 46 files +// in `.senior-dev/` — its session database and its whole conversation with its +// model among them — where `git add -A` would commit every one; and the next +// run in the same folder read the last one's checklist and pinned command as +// its own. A notes folder that was there when the run began is left alone, +// because it is not this run's alone. A move across disks falls back to a +// copy and then a removal, and a move that fails leaves the folder whole. +func (f *ProgramFolder) keepNotes() string { + if f.Notes == "" || f.NotesWereThere || strings.TrimSpace(f.Keep) == "" { + return "" + } + from := filepath.Join(f.Dir, f.Notes) + if info, err := os.Lstat(from); err != nil || !info.IsDir() { + return "" + } + if err := os.MkdirAll(f.Keep, 0o700); err != nil { + return "" + } + to := filepath.Join(f.Keep, f.Program) + for n := 1; ; n++ { + if _, err := os.Lstat(to); os.IsNotExist(err) { + break + } + to = filepath.Join(f.Keep, fmt.Sprintf("%s.%d", f.Program, n)) + } + if err := os.Rename(from, to); err != nil { + if err := copyPath(from, to); err != nil { + _ = os.RemoveAll(to) + return "its notes (" + f.Notes + "/) could not be moved out of " + f.Dir + ": " + err.Error() + } + _ = os.RemoveAll(from) + } + return "its notes (" + f.Notes + "/) are kept in " + to +} + +// abandon lets a folder go that a run was readied in and then never started: +// the branch it cut, which holds nothing, deleted and the person's own checked +// out again. A nil folder is a run that readied none. +func (f *ProgramFolder) abandon() { + if f != nil { + f.Finish("") + } +} + +// tree is the folder as a run's tree: the folder itself, worked in where it +// is, with the program's branch and where the person's checkout was, which the +// run's row writes down ([runCopyOf]). Zero for a nil folder. +func (f *ProgramFolder) tree() taskTree { + if f == nil { + return taskTree{} + } + tree := taskTree{dir: f.Dir, merge: mergeInPlace, ground: f.Dir, mode: TaskModeInPlace, rung: GroundRungHere} + if f.Branch != "" { + tree.root, tree.branch, tree.home, tree.homeSha = f.Dir, f.Branch, f.Home, f.Start + tree.continues = f.Continues + } + return tree +} + +// StopPromise is what a person who stops a program's run is told at once +// about where its work will be. +func (f *ProgramFolder) StopPromise() string { + if f.Plain() { + return "its work so far stays in " + f.Dir + } + return "its work so far stays on its branch " + f.Branch + ", checked out in " + f.Dir +} + +// Sentence is how a run left its folder, in the one sentence the run's page, +// the conversation and a shell run's last lines all say: where the work is, +// how much of it, that its branch is checked out, and how to go back to the +// person's own branch and bring the work in. +func (e ProgramFolderEnd) Sentence() string { + if e.said != "" { + return e.said + } + f := e.Folder + var said string + switch { + case e.Gone && f.Branch == "" && f.Outer != "": + said = "its work so far is in " + f.Dir + ", as it left it; the git repository around it is at " + f.Outer + + ", which holds your home folder, so codeaf cut no branch there and committed nothing" + case e.Gone && f.Branch == "": + said = "its work so far is in " + f.Dir + ", which has no git history, as it left it" + case f.Branch == "" && f.Outer != "": + said = "its work is in " + f.Dir + "; the git repository around it is at " + f.Outer + + ", which holds your home folder, so codeaf cut no branch there and committed nothing" + case f.Branch == "": + said = "its work is in " + f.Dir + ", which has no git history, so nothing was committed" + case e.Moved: + where := "the branch " + e.HeadOn + if e.HeadOn == "" { + where = "no branch, at " + e.At + } + said = f.Program + " left " + f.Dir + " on " + where + " instead of its own branch " + f.Branch + + ", so codeaf changed nothing there: nothing was committed and nothing was switched" + if e.Kept { + said += "; " + f.Branch + " holds " + fileCount(len(e.Changed)) + } + case e.Gone: + said = e.goneWords() + case e.Dropped: + said = "it changed nothing, so " + f.Dir + " is back on " + f.homeWords() + " and its branch " + f.Branch + " was deleted" + case e.HomeMoved && !e.Kept && e.Refused == "": + said = "it changed nothing, but " + e.homeMovedWords() + ", so codeaf did not switch back to it: its empty branch " + + f.Branch + " is still checked out in " + f.Dir + case e.Refused != "" && !e.Kept: + said = "it changed nothing, but " + f.Dir + " could not be put back on " + f.homeWords() + " (" + e.Refused + + "), so its empty branch " + f.Branch + " is still checked out there" + case e.Refused != "": + said = "its branch " + f.Branch + " is checked out in " + f.Dir + ", but what it left uncommitted could not be committed (" + + e.Refused + "), so those changes are in the folder, uncommitted; " + e.goBackWords() + default: + said = "its work is on the branch " + f.Branch + " in " + f.Dir + ", " + fileCount(len(e.Changed)) + + ", and that branch is checked out there; " + e.goBackWords() + } + if e.Notes != "" { + said += "; " + e.Notes + } + return said +} + +// goneWords is where a run whose process went away left its work in a +// repository, still on its own branch ([ProgramFolder.settleGone]): the branch, +// that it is checked out as the run left it, how many files are not committed, +// and the way back — which, while something is uncommitted, starts with +// putting that somewhere, because a switch would carry it along. +func (e ProgramFolderEnd) goneWords() string { + f := e.Folder + said := "its work so far is on its branch " + f.Branch + " in " + f.Dir + ", which is checked out there, as it left it" + if e.Uncommitted == 0 { + return said + "; " + e.goBackWords() + } + said += ", with " + fileCount(e.Uncommitted) + " not committed" + if e.HomeMoved { + said += "; " + e.homeMovedWords() + } + return said + "; commit or stash them there before you go back to " + f.homeWords() +} + +// homeWords names where the person's checkout was before the run. +func (f ProgramFolder) homeWords() string { + if f.Home != "" { + return "your branch " + f.Home + } + return "the commit " + shortSha(f.Start) +} + +// goBackWords is the two commands a person holding a program's finished +// branch wants: the one that goes back to their own branch, and the one that +// brings the work in from there. THE FOLDER IS QUOTED FOR A SHELL the way +// every path this package hands one is ([shellQuoted]). +// +// AND IT SAYS "AS IT WAS" ONLY WHEN IT IS ([ProgramFolder.homeMoved]): a branch +// of the person's that moved during the run is said to have moved, from where +// to where, before anybody is told how to merge onto it. +func (e ProgramFolderEnd) goBackWords() string { + f := e.Folder + folder := shellQuoted(f.Dir) + if f.Home == "" { + return "your checkout was on no branch, at " + shortSha(f.Start) + ", and `git -C " + folder + + " switch --detach " + shortSha(f.Start) + "` goes back to it" + } + back := "`git -C " + folder + " switch " + f.Home + "` goes back to it, and `git -C " + folder + " merge " + + f.Branch + "` from there brings the work in" + switch { + case e.HomeMoved && e.HomeAt == "": + return e.homeMovedWords() + case e.HomeMoved: + return e.homeMovedWords() + ", and codeaf did not move it: look at it before you push or merge it; " + back + } + return "your branch " + f.Home + " is as it was: " + back +} + +// homeMovedWords says how the person's own branch moved during the run +// ([ProgramFolderEnd.HomeMoved]). +func (e ProgramFolderEnd) homeMovedWords() string { + f := e.Folder + if e.HomeAt == "" { + return "your branch " + f.Home + " is gone: it was at " + shortSha(f.Start) + + " when the run began, and codeaf did not make it again" + } + return "your branch " + f.Home + " moved during the run, from " + shortSha(f.Start) + " to " + shortSha(e.HomeAt) +} + +// landing is a finished folder as the run's landing: the program's branch +// when it holds the work, the files, and the sentence ([RunLanding.Line]). +func (e ProgramFolderEnd) landing() RunLanding { + landing := RunLanding{Changed: e.Changed, Home: mergeInPlace, Line: e.Sentence()} + if e.Kept { + landing.Branch, landing.Home = e.Folder.Branch, mergeKept + } + return landing +} + +// shortSha is a commit as a person reads it. +func shortSha(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} + +// shortCommit is the commit a ref names, as a person reads it. +func shortCommit(dir, ref string) string { + out, err := git(dir, "rev-parse", "--verify", "-q", ref) + if err != nil { + return "" + } + return shortSha(strings.TrimSpace(out)) +} + +// changedSince is every path HEAD's tree differs from a commit in, empty when +// either cannot be read: the work a program's branch holds past its start. +func changedSince(dir, sha string) []string { + return changedBetween(dir, sha, "HEAD") +} + +// changedBetween is every path two commits' trees differ in. +func changedBetween(dir, from, to string) []string { + if strings.TrimSpace(from) == "" || strings.TrimSpace(to) == "" { + return nil + } + out, err := git(dir, "diff", "--name-only", from, to) + if err != nil { + return nil + } + var paths []string + for _, line := range strings.Split(out, "\n") { + if line = strings.TrimSpace(line); line != "" { + paths = append(paths, line) + } + } + return paths +} + +// release lets the folder go. +func (f *ProgramFolder) release() { + if f.lock == nil { + return + } + _ = filelock.Unlock(f.lock) + _ = f.lock.Close() + f.lock = nil +} + +// programFolderName is one folder's name under [programFolderDir]: the head +// of the SHA-256 of its resolved path, the way a repository's git lock is +// named ([gitRootLockFile]). +func programFolderName(key string) string { + digest := sha256.Sum256([]byte(filepath.Clean(key))) + return hex.EncodeToString(digest[:])[:gitRootLockStem] +} + +// programFolderRecord is where one folder's run is written down. +func programFolderRecord(key string) string { + return filepath.Join(home.Join("v3", programFolderDir), programFolderName(key)+".json") +} + +// write keeps the record, whole, beside the hold. It is a record, so a disk +// that refuses it costs a later process its ending and never the run. +func (f *ProgramFolder) write() { + body, err := json.MarshalIndent(f, "", " ") + if err != nil { + return + } + path := programFolderRecord(f.key) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + temporary := path + ".tmp" + if err := os.WriteFile(temporary, body, 0o600); err != nil { + return + } + _ = os.Rename(temporary, path) +} + +// readProgramFolder is the record of the last run in one folder. +func readProgramFolder(key string) (*ProgramFolder, bool) { + return readProgramFolderAt(programFolderRecord(key)) +} + +func readProgramFolderAt(path string) (*ProgramFolder, bool) { + body, err := os.ReadFile(path) + if err != nil { + return nil, false + } + var folder ProgramFolder + if json.Unmarshal(body, &folder) != nil || strings.TrimSpace(folder.Dir) == "" { + return nil, false + } + folder.key = canonicalPath(folder.Dir) + return &folder, true +} + +// settleOwedProgramFolder settles the folder of the run whose record folder +// is keep, when that run's process went away before it could finish it: the +// reopen of its conversation, or the next hand-off in it, comes here +// ([endOrphanedProgramRun]). It answers how the folder was left, and false +// when nothing was owed or somebody else holds the folder now. +func settleOwedProgramFolder(keep string) (ProgramFolderEnd, bool) { + if strings.TrimSpace(keep) == "" { + return ProgramFolderEnd{}, false + } + records, _ := filepath.Glob(filepath.Join(home.Join("v3", programFolderDir), "*.json")) + for _, path := range records { + owed, ok := readProgramFolderAt(path) + if !ok || owed.Ended != "" || filepath.Clean(owed.Keep) != filepath.Clean(keep) { + continue + } + lock, _, busy := claimProgramFolder(owed.key, owed.Program+", settling a run codeaf closed under") + if busy || lock == nil { + // A HOLD SOMEBODY ELSE HAS, or one nobody can take, is a folder this + // reopen cannot know is idle: it is left for the next codeaf that can. + return ProgramFolderEnd{}, false + } + owed.lock = lock + // NOTHING IS COMMITTED FOR A RUN WHOSE END NOBODY SAW + // ([ProgramFolder.settleGone]): the folder has been the person's since + // the process went away, however long ago that was. + end := owed.settleGone() + owed.Ended = end.Sentence() + owed.write() + end.keepEnding() + owed.release() + return end, true + } + return ProgramFolderEnd{}, false +} + +// taskBranchName is the branch a task's work is cut on: `task/`, the title as +// a branch name can spell it, and a short random tail, so the same work +// proposed twice lands on two branches. ONE SPELLING FOR EVERY ROAD that cuts +// one — a task's own worktree ([cutTaskWorktree]) and a program's branch in +// the person's folder ([PrepareProgramFolder]) — so a person reading `git +// branch` meets one shape. +func taskBranchName(title string) string { + return "task/" + slugify(title) + "-" + shortID() +} diff --git a/internal/session/programfolder_git_test.go b/internal/session/programfolder_git_test.go new file mode 100644 index 0000000000..192096b0d2 --- /dev/null +++ b/internal/session/programfolder_git_test.go @@ -0,0 +1,262 @@ +package session + +// THE PERSON'S BRANCH AND CHECKOUT ARE NEVER CLAIMED SAFE WITHOUT BEING READ +// (programfolder.go), in real git in temporary repositories: a switch runs with +// the repository's hooks off and is read again when it fails, the ending looks +// at the person's branch before it says it is as it was, and the commit that +// finishes a run goes whatever the program's notes folder is. + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// notesProgram is the fake program with a notes folder of its own, the way +// senior-dev keeps `.senior-dev/`. +func notesProgram() delegate.Delegate { + program := testPrograms("fake")[0] + program.Notes = ".fake-notes" + return program +} + +// prepareIn readies repo for a run of program, failing the test on a refusal. +func prepareIn(t *testing.T, program delegate.Delegate, repo, title string) *ProgramFolder { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: program, Dir: repo, Title: title, Holder: "task 7 (" + title + ")", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return folder +} + +// failingHook installs a post-checkout hook in hooks that fails and leaves a +// mark, the way an LFS hook does on a PATH with no git-lfs, and answers where +// the mark would be. +func failingHook(t *testing.T, hooks string) string { + t.Helper() + mark := filepath.Join(t.TempDir(), "the-hook-ran") + writeFile(t, filepath.Join(hooks, "post-checkout"), "#!/bin/sh\necho ran > '"+mark+"'\nexit 2\n") + if err := os.Chmod(filepath.Join(hooks, "post-checkout"), 0o755); err != nil { + t.Fatal(err) + } + return mark +} + +// A SWITCH RUNS WITH THE REPOSITORY'S HOOKS OFF. A post-checkout hook that +// fails used to make `git switch -c` exit non-zero after HEAD had moved, and +// the refusal then said nothing was changed while the person's checkout sat +// on an orphan branch; the switch back of a run that changed nothing failed +// the same way. Both switches go between two names for one commit, so no hook +// has anything to do — neither in .git/hooks nor where core.hooksPath points. +func TestAProgramsSwitchesRunWithTheRepositorysHooksOff(t *testing.T) { + for _, where := range []string{"git-hooks", "hooks-path"} { + t.Run(where, func(t *testing.T) { + repo := newTestRepo(t) + hooks := filepath.Join(repo, ".git", "hooks") + if where == "hooks-path" { + hooks = filepath.Join(t.TempDir(), "husky") + mustGit(t, repo, "config", "core.hooksPath", hooks) + } + mark := failingHook(t, hooks) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + if head := currentBranch(repo); head != folder.Branch { + t.Fatalf("the checkout is on %q after the cut, want %q", head, folder.Branch) + } + end := folder.Finish("") + if !end.Dropped || end.Refused != "" { + t.Fatalf("a run that changed nothing ended %+v, want its branch dropped", end) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q after a run that changed nothing, want the person's branch", head) + } + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("the empty branch was left behind: %q", branches) + } + if _, err := os.Stat(mark); !os.IsNotExist(err) { + t.Fatalf("the repository's hook ran on codeaf's switch: %v", err) + } + }) + } +} + +// A CUT THAT FAILED IS READ AGAIN BEFORE IT IS ANSWERED. A lock git could not +// take after it had made the branch left a `task/…` branch in the person's +// repository that nothing knew about; now the stray branch is deleted, the +// checkout is where it was, and nothing is owed or held. +func TestACutThatFailedLeavesNoBranchBehind(t *testing.T) { + repo := newTestRepo(t) + writeFile(t, filepath.Join(repo, ".git", "HEAD.lock"), "") + _, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "Fix the parser", Holder: "task 7 (Fix the parser)", Keep: t.TempDir()}) + if err == nil || !strings.HasPrefix(err.Error(), "could not cut fake's branch in "+repo+": ") { + t.Fatalf("PrepareProgramFolder = %v, want the cut refused", err) + } + if strings.Contains(err.Error(), "the checkout is now on") { + t.Fatalf("the refusal says the checkout moved when it did not: %v", err) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q, want the person's branch", head) + } + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("the failed cut left a branch behind: %q", branches) + } + if record, ok := readProgramFolder(canonicalPath(repo)); ok && record.Ended == "" { + t.Fatalf("the failed cut left a run owed: %+v", record) + } + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the failed cut still holds the folder: %q", holder) + } +} + +// moveBranch puts a commit of nobody's on branch without checking it out, the +// way a program's shell that checked the person's branch out, committed there +// and switched back leaves it, and answers the commit. +func moveBranch(t *testing.T, repo, branch string) string { + t.Helper() + tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", branch)) + moved := strings.TrimSpace(gitOut(t, repo, "-c", "user.name=s", "-c", "user.email=s@s", "commit-tree", tip+"^{tree}", "-p", tip, "-m", "a commit nobody read")) + mustGit(t, repo, "update-ref", "refs/heads/"+branch, moved) + return moved +} + +// THE ENDING LOOKS AT THE PERSON'S BRANCH BEFORE IT SAYS IT IS AS IT WAS. A +// program's shell that committed on the person's branch mid-run was reported +// as `your branch work is as it was`; a run that changed nothing then switched +// the checkout onto commits nobody had read and said it changed nothing. +func TestTheEndingSaysThePersonsBranchMovedDuringTheRun(t *testing.T) { + t.Run("with work", func(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + moved := moveBranch(t, repo, "work") + said := folder.Finish("done").Sentence() + want := "your branch work moved during the run, from " + shortSha(start) + " to " + shortSha(moved) + + ", and codeaf did not move it: look at it before you push or merge it; `git -C " + if !strings.Contains(said, want) || strings.Contains(said, "as it was") { + t.Fatalf("the ending = %q, want it to say %q", said, want) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")); tip != moved { + t.Fatalf("codeaf moved the person's branch to %s", tip) + } + }) + t.Run("with nothing", func(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + moved := moveBranch(t, repo, "work") + end := folder.Finish("") + if end.Dropped || currentBranch(repo) != folder.Branch { + t.Fatalf("a run whose person's branch moved was dropped onto it: %+v, on %q", end, currentBranch(repo)) + } + want := "it changed nothing, but your branch work moved during the run, from " + shortSha(start) + " to " + shortSha(moved) + + ", so codeaf did not switch back to it: its empty branch " + folder.Branch + " is still checked out in " + repo + if said := end.Sentence(); said != want { + t.Fatalf("the ending = %q, want %q", said, want) + } + }) + t.Run("gone", func(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + mustGit(t, repo, "branch", "-D", "work") + said := folder.Finish("done").Sentence() + want := "your branch work is gone: it was at " + shortSha(start) + " when the run began, and codeaf did not make it again" + if !strings.HasSuffix(said, want) { + t.Fatalf("the ending = %q, want it to end %q", said, want) + } + }) +} + +// AND THE RECEIPT READS IT TOO before it promises the branch does not move. +func TestTheReceiptSaysWhenThePersonsBranchHasAlreadyMoved(t *testing.T) { + repo := newTestRepo(t) + start := strings.TrimSpace(gitOut(t, repo, "rev-parse", "work")) + moved := moveBranch(t, repo, "work") + record := &TaskCopyRecord{Dir: repo, Branch: "task/pong-abc123", Home: "work", HomeSha: start} + want := "It is fake's: it works alone in " + repo + " itself, on a new branch task/pong-abc123; your branch work has already moved, from " + + shortSha(start) + " to " + shortSha(moved) + ", and codeaf does not move it, and when it ends task/pong-abc123 stays checked out there with its work. Until it ends, codeaf's own tools write nothing in " + repo + "." + if got := delegateReceipt(repo, testPrograms("fake")[0], record); got != want { + t.Fatalf("the receipt = %q, want %q", got, want) + } +} + +// THE COMMIT THAT FINISHES A RUN GOES WHATEVER THE NOTES FOLDER IS. A notes +// folder named by an exclude pathspec made `git add` exit 1 whenever it was +// there and ignored — senior-dev ignores its own in every repository — so a +// folder whose notes predated the run never had its leftovers committed and +// never had an empty branch dropped. +func TestTheLeftoversAreCommittedWhateverTheNotesFolderIs(t *testing.T) { + for _, tc := range []struct { + name string + ready func(t *testing.T, repo string) + }{ + {"there and ignored", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, ".fake-notes", "old.md"), "an earlier run's checklist\n") + writeFile(t, filepath.Join(repo, ".git", "info", "exclude"), ".fake-notes/\n") + }}, + {"there and not ignored", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, ".fake-notes", "old.md"), "an earlier run's checklist\n") + }}, + {"not there", func(*testing.T, string) {}}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newTestRepo(t) + tc.ready(t, repo) + folder := prepareIn(t, notesProgram(), repo, "Fix the parser") + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + writeFile(t, filepath.Join(repo, ".fake-notes", "checklist.md"), "- [x] fix\n") + end := folder.Finish("done") + if end.Refused != "" || !end.Kept { + t.Fatalf("the run's leftovers were not committed: %+v", end) + } + if files := strings.Fields(gitOut(t, repo, "ls-tree", "-r", "--name-only", folder.Branch)); strings.Join(files, " ") != "fix.go shared.txt" { + t.Fatalf("the branch holds %q, want the work and none of the notes", files) + } + if staged := strings.TrimSpace(gitOut(t, repo, "diff", "--cached", "--name-only")); staged != "" { + t.Fatalf("the notes were left staged: %q", staged) + } + }) + t.Run(tc.name+", changing nothing", func(t *testing.T) { + repo := newTestRepo(t) + tc.ready(t, repo) + folder := prepareIn(t, notesProgram(), repo, "Fix the parser") + writeFile(t, filepath.Join(repo, ".fake-notes", "checklist.md"), "- [ ] fix\n") + if end := folder.Finish(""); !end.Dropped || end.Refused != "" { + t.Fatalf("a run that changed nothing ended %+v, want its branch dropped", end) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("the checkout is on %q, want the person's branch", head) + } + }) + } +} + +// A CHECKOUT THE PROGRAM LEFT IN THE MIDDLE OF A MERGE IS NOT COMMITTED: a +// commit then would conclude the merge, conflict markers and all, under +// codeaf's name. +func TestAMergeTheProgramLeftHalfDoneIsNotCommitted(t *testing.T) { + repo := newTestRepo(t) + folder := prepareIn(t, testPrograms("fake")[0], repo, "Fix the parser") + mustGit(t, repo, "checkout", "-q", "-b", "other", "work") + writeFile(t, filepath.Join(repo, "shared.txt"), "theirs\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "theirs") + mustGit(t, repo, "checkout", "-q", folder.Branch) + writeFile(t, filepath.Join(repo, "shared.txt"), "ours\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "ours") + if _, err := git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "merge", "other"); err == nil { + t.Fatal("the merge did not stop on its conflict") + } + head := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + end := folder.Finish("done") + if end.Refused != repo+" is in the middle of a merge" { + t.Fatalf("the ending = %+v, want the merge named and nothing committed", end) + } + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); after != head { + t.Fatalf("a half-done merge was committed: HEAD moved from %s to %s", head, after) + } +} diff --git a/internal/session/programfolder_test.go b/internal/session/programfolder_test.go new file mode 100644 index 0000000000..8784077383 --- /dev/null +++ b/internal/session/programfolder_test.go @@ -0,0 +1,454 @@ +package session + +// THE CONTRACT OF A PROGRAM'S FOLDER (programfolder.go), in real git in +// temporary repositories: which folder, a branch in a repository and nothing +// of git anywhere else, a checkout that is in the way refused before anything +// starts, one run per folder, and a run whose process went away settled by the +// next codeaf that finds it without a single git write. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// programAgent is a conversation on folder carrying the fake program, with a +// run engine double registered and not yet released. +func programAgent(t *testing.T, folder string) (*Agent, *beltRunDouble) { + t.Helper() + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = folder + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent, double +} + +// A CHECKOUT WITH CHANGES THAT ARE NOT COMMITTED IS REFUSED BEFORE ANYTHING +// STARTS — a modified file, an untracked one, a staged one — naming them, and +// nothing is switched, cut or started. The proposal is refused before its +// card, in the same words. +func TestAProgramIsRefusedACheckoutWithChangesThatAreNotCommitted(t *testing.T) { + for _, tc := range []struct { + name string + dirty func(t *testing.T, repo string) + named string + }{ + {"modified", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own line\n") + }, "(shared.txt)"}, + {"untracked", func(t *testing.T, repo string) { writeFile(t, filepath.Join(repo, "notes", "draft.md"), "draft\n") }, "(notes/draft.md)"}, + {"staged", func(t *testing.T, repo string) { + writeFile(t, filepath.Join(repo, "new.go"), "package x\n") + mustGit(t, repo, "add", "new.go") + }, "(new.go)"}, + {"many", func(t *testing.T, repo string) { + for _, name := range []string{"a.go", "b.go", "c.go", "d.go", "e.go"} { + writeFile(t, filepath.Join(repo, name), "x\n") + } + }, "(a.go, b.go, c.go and 2 more)"}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := newTestRepo(t) + tc.dirty(t, repo) + agent, double := programAgent(t, repo) + want := canonicalPath(repo) + " has changes that are not committed " + tc.named + "; commit or stash them, then ask again" + _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project") + if err == nil || err.Error() != want { + t.Fatalf("StartDelegate = %v, want %q", err, want) + } + if double.didRun() { + t.Fatal("a refused checkout started a run") + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("a refused checkout was switched to %q", head) + } + if branches := strings.TrimSpace(gitOut(t, repo, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a refused checkout was given a branch: %q", branches) + } + stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: repo, brief: "b", deliverable: "d", acceptance: "a"}) + if stand.refusal != want { + t.Fatalf("the proposal's refusal = %q, want %q", stand.refusal, want) + } + }) + } +} + +// A CHECKOUT IN THE MIDDLE OF A MERGE IS REFUSED, and says what to do. +func TestAProgramIsRefusedACheckoutInTheMiddleOfAMerge(t *testing.T) { + repo := newTestRepo(t) + mustGit(t, repo, "checkout", "-q", "-b", "other") + writeFile(t, filepath.Join(repo, "shared.txt"), "theirs\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "theirs") + mustGit(t, repo, "checkout", "-q", "work") + writeFile(t, filepath.Join(repo, "shared.txt"), "ours\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "ours") + if _, err := git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "merge", "other"); err == nil { + t.Fatal("the merge did not stop on its conflict") + } + agent, double := programAgent(t, repo) + _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project") + if err == nil || err.Error() != canonicalPath(repo)+" is in the middle of a merge; finish it or abort it, then ask again" { + t.Fatalf("StartDelegate = %v, want the merge named", err) + } + if double.didRun() { + t.Fatal("a checkout in the middle of a merge started a run") + } +} + +// A PLAIN FOLDER IS WORKED IN AS IT IS: no git is made there, and the program +// is started with its own flags for it. +func TestAProgramOnAPlainFolderGetsNoGit(t *testing.T) { + folder := t.TempDir() + agent, double := programAgent(t, folder) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "make a thing"); err != nil { + t.Fatal(err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(folder) { + t.Fatalf("the program works in %q (plain %v), want the folder itself without git", spec.Workspace, spec.PlainFolder) + } + if _, err := os.Stat(filepath.Join(folder, ".git")); !os.IsNotExist(err) { + t.Fatalf("a plain folder was made a repository: %v", err) + } +} + +// A REPOSITORY AT THE HOME FOLDER IS NOBODY'S PROJECT. A folder under a +// dotfiles repository rooted at home is worked in as a plain folder — no +// branch cut in the person's dotfiles, the program told it works without git +// — and the ending says why nothing was committed. +func TestAFolderInARepositoryAtTheHomeFolderIsWorkedInWithoutGit(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + mustGit(t, home, "init", "-q") + writeFile(t, filepath.Join(home, ".zshrc"), "export A=1\n") + mustGit(t, home, "add", "-A") + mustGit(t, home, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "dotfiles") + before := strings.TrimSpace(gitOut(t, home, "rev-parse", "HEAD")) + project := filepath.Join(home, "Desktop", "pong") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + double := newBeltRunDouble("done") + double.work = func(workspace string) { writeFile(t, filepath.Join(workspace, "pong.py"), "print('pong')\n") } + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = project + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "build pong"); err != nil { + t.Fatal(err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + if !spec.PlainFolder || canonicalPath(spec.Workspace) != canonicalPath(project) { + t.Fatalf("the program works in %q (plain %v), want %q without git", spec.Workspace, spec.PlainFolder, project) + } + if after := strings.TrimSpace(gitOut(t, home, "rev-parse", "HEAD")); after != before || strings.HasPrefix(currentBranch(home), "task/") { + t.Fatalf("the dotfiles repository moved from %s to %s (on %q)", before, after, currentBranch(home)) + } + if branches := strings.TrimSpace(gitOut(t, home, "branch", "--list", "task/*")); branches != "" { + t.Fatalf("a branch was cut in the repository at home: %q", branches) + } + notes := beltRunNotes(t, filepath.Dir(spec.Store.Path()), spec.Store.RootID()) + if !strings.Contains(strings.Join(notes, "\n"), "the git repository around it is at "+canonicalPath(home)+", which holds your home folder, so codeaf cut no branch there and committed nothing") { + t.Fatalf("the page does not say why nothing was committed: %q", notes) + } +} + +// A GROUND THAT IS NOT THERE YET IS MADE WHEN THE RUN STARTS, empty, and the +// program works in it. +func TestAMissingGroundIsMadeWhenTheRunStarts(t *testing.T) { + parent := t.TempDir() + fresh := filepath.Join(parent, "pong") + agent, double := programAgent(t, parent) + stand := agent.resolveTaskGround(taskSpec{via: "fake", ground: fresh, brief: "b", deliverable: "d", acceptance: "a"}) + if stand.refusal != "" { + t.Fatalf("a new folder was refused: %q", stand.refusal) + } + program := testPrograms("fake")[0] + if err := agent.startKnownTaskRunVia(context.Background(), agent.graph().reserve(), "Pong", "build pong", nil, stand, "", &program); err != nil { + t.Fatal(err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + if info, err := os.Stat(fresh); err != nil || !info.IsDir() || canonicalPath(spec.Workspace) != canonicalPath(fresh) { + t.Fatalf("the program works in %q, and the new folder is %v (%v)", spec.Workspace, info, err) + } +} + +// ONE RUN PER FOLDER. A second program run on a folder one is working in — +// from another conversation here — is refused, naming the run that holds it, +// at its card and at its start alike; once the first has ended the folder is +// free again. +func TestASecondProgramRunOnTheSameFolderIsRefusedWhileTheFirstRuns(t *testing.T) { + repo := newTestRepo(t) + first, double := programAgent(t, repo) + id, title, _, err := first.StartDelegate(context.Background(), "fake", "the first piece of work") + if err != nil { + t.Fatal(err) + } + <-double.entered + second, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + want := canonicalPath(repo) + " is busy: fake, " + taskStopName(id, title) + ", is working in it, and one folder takes one program run at a time; ask again when that run has ended" + if _, _, _, err := second.StartDelegate(context.Background(), "fake", "a second piece"); err == nil || err.Error() != want { + t.Fatalf("the second run's start = %v, want %q", err, want) + } + if stand := second.resolveTaskGround(taskSpec{via: "fake", ground: repo, brief: "b", deliverable: "d", acceptance: "a"}); stand.refusal != want { + t.Fatalf("the second run's proposal = %q, want %q", stand.refusal, want) + } + endBeltRun(t, first, double) + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the folder is still held by %q after its run ended", holder) + } +} + +// deadProgramFolder readies repo for a program's run the way a run that went +// away leaves it: its branch cut, a file of its work left uncommitted, and its +// hold dropped by a process that is gone, with its record folder keep. +func deadProgramFolder(t *testing.T, repo, keep string) *ProgramFolder { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The dead run", Holder: "task 9 (The dead run)", Keep: keep}) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(repo, "half.txt"), "half done\n") + folder.release() + return folder +} + +// A RUN FOUND INTERRUPTED AT A REOPEN IS SETTLED WITHOUT A COMMIT. Its process +// went away, so what is uncommitted in its folder may be its last edits or the +// person's own made on its branch since — here both — and codeaf commits +// neither: the checkout stays on the run's branch as it was left, and the row +// and page say where the work is and how much of it is not committed. +func TestAProgramRunFoundInterruptedAtAReopenIsSettledWithoutACommit(t *testing.T) { + repo := newTestRepo(t) + var dead *ProgramFolder + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, _ time.Time) { + dead = deadProgramFolder(t, repo, taskDir) + writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own edit, made after codeaf closed\n") + }) + row := reopenedRow(t, agent, id) + if head := currentBranch(repo); head != dead.Branch { + t.Fatalf("the checkout is on %q after the reopen, want the dead run's branch %q", head, dead.Branch) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", dead.Branch)); tip != dead.Start { + t.Fatalf("the reopen committed on the dead run's branch: %s, want it still at %s", tip, dead.Start) + } + if status := gitOut(t, repo, "status", "--porcelain"); !strings.Contains(status, " M shared.txt") || !strings.Contains(status, "?? half.txt") { + t.Fatalf("what was uncommitted was not left as it was:\n%s", status) + } + want := "its work so far is on its branch " + dead.Branch + " in " + repo + ", which is checked out there, as it left it, with 2 files not committed; commit or stash them there before you go back to your branch work" + if !strings.Contains(row.Report, want) || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { + t.Fatalf("the reopened row = %+v, want its ending and %q", row, want) + } + if again, ok := readProgramFolder(canonicalPath(repo)); !ok || again.Ended != want { + t.Fatalf("the folder's record = %+v, want it ended with %q", again, want) + } + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the reopen still holds the folder: %q", holder) + } +} + +// A RUN THAT WENT AWAY IN A FOLDER IS SETTLED BEFORE THE NEXT ONE STARTS THERE, +// AND NOTHING OF IT IS COMMITTED: the next run meets what is uncommitted the +// way it meets anybody's changes — refused, naming them — and is told whose +// they may be. A person's edits made on the dead run's branch used to be swept +// into a commit under codeaf's name, and the next run cut on top of it. +func TestTheNextRunInAFolderIsRefusedWhatTheOneThatWentAwayLeft(t *testing.T) { + repo := newTestRepo(t) + dead := deadProgramFolder(t, repo, t.TempDir()) + writeFile(t, filepath.Join(repo, "shared.txt"), "the person's own edit, made after codeaf closed\n") + _, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + want := repo + " has changes that are not committed (shared.txt, half.txt); commit or stash them, then ask again; they may be an earlier fake run's, which codeaf could not finish: its branch " + dead.Branch + " is checked out there" + if err == nil || err.Error() != want { + t.Fatalf("the next run = %v, want %q", err, want) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", dead.Branch)); tip != dead.Start { + t.Fatalf("the next run committed on the dead run's branch: %s, want it still at %s", tip, dead.Start) + } + if head := currentBranch(repo); head != dead.Branch { + t.Fatalf("the checkout is on %q, want the dead run's branch", head) + } + if again, ok := readProgramFolder(canonicalPath(repo)); !ok || !strings.HasPrefix(again.Ended, "its work so far is on its branch "+dead.Branch) { + t.Fatalf("the dead run's record = %+v, want it settled", again) + } + if holder := programFolderHolder(canonicalPath(repo)); holder != "" { + t.Fatalf("the refused run still holds the folder: %q", holder) + } +} + +// A MERGE THE PERSON IS RESOLVING ON A DEAD RUN'S BRANCH IS NOT CONCLUDED: the +// next run is refused over it, and MERGE_HEAD and the conflict are left alone. +func TestTheNextRunDoesNotConcludeAMergeOnTheBranchAnEarlierRunLeft(t *testing.T) { + repo := newTestRepo(t) + dead := deadProgramFolder(t, repo, t.TempDir()) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + mustGit(t, repo, "checkout", "-q", "-b", "other", "work") + writeFile(t, filepath.Join(repo, "shared.txt"), "theirs\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "theirs") + mustGit(t, repo, "checkout", "-q", dead.Branch) + writeFile(t, filepath.Join(repo, "shared.txt"), "ours\n") + mustGit(t, repo, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-am", "ours") + if _, err := git(repo, "-c", "user.name=t", "-c", "user.email=t@t", "merge", "other"); err == nil { + t.Fatal("the merge did not stop on its conflict") + } + head := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")) + _, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + if err == nil || !strings.HasPrefix(err.Error(), repo+" is in the middle of a merge; finish it or abort it, then ask again") { + t.Fatalf("the next run = %v, want the merge named", err) + } + if after := strings.TrimSpace(gitOut(t, repo, "rev-parse", "HEAD")); after != head { + t.Fatalf("the person's merge was concluded: HEAD moved from %s to %s", head, after) + } + if _, err := os.Stat(filepath.Join(repo, ".git", "MERGE_HEAD")); err != nil { + t.Fatalf("the merge in progress is gone: %v", err) + } +} + +// A CLEAN FOLDER A RUN WENT AWAY IN IS WORKED IN AGAIN: its committed work +// stays on its branch, which the next run is cut from, as it would be from +// any branch the person had checked out. +func TestTheNextRunCarriesOnInAFolderTheOneThatWentAwayLeftClean(t *testing.T) { + repo := newTestRepo(t) + dead := deadProgramFolder(t, repo, t.TempDir()) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + commitIn(t, repo, "done.txt") + next, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + if err != nil { + t.Fatalf("the next run was refused a clean folder: %v", err) + } + defer next.Finish("") + // IT CARRIES ON ON THE DEAD RUN'S BRANCH, which was left checked out, and + // the person's branch it names is still theirs ([ProgramFolder.carryOn]). + if next.Branch != dead.Branch || next.Home != dead.Home || next.Start != dead.Start || !next.Continues { + t.Fatalf("the next run = branch %q home %q start %q continues %v, want it on the dead run's branch %q with its home %q and start %q", + next.Branch, next.Home, shortSha(next.Start), next.Continues, dead.Branch, dead.Home, shortSha(dead.Start)) + } + if files := gitOut(t, repo, "ls-tree", "--name-only", dead.Branch); !strings.Contains(files, "done.txt") { + t.Fatalf("the dead run's committed work is not on its branch:\n%s", files) + } +} + +// A REOPEN THAT SETTLES A FINISHED PROGRAM'S FOLDER SETTLES ITS ROW DONE. The +// program finished and codeaf closed before the row was published; the row was +// left `interrupted` for ever, with no branch, while the page said where the +// work was. It now reads done, with the program's result and the folder's +// sentence, and names the branch that holds the work. +func TestAReopenSettlesTheRowOfAProgramThatFinished(t *testing.T) { + repo := newTestRepo(t) + var dead *ProgramFolder + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, _ time.Time) { + dead = deadProgramFolder(t, repo, taskDir) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + commitIn(t, repo, "done.txt") + if err := store.CompleteRoot("finished: its tests pass"); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.State != TaskDone || !strings.HasPrefix(row.Report, "finished: its tests pass") || + !strings.Contains(row.Report, "its work so far is on its branch "+dead.Branch) { + t.Fatalf("the reopened row = %+v, want it done, with its result and where its work is", row) + } + if row.Branch != dead.Branch || row.Merge != mergeKept || row.EndedAt.IsZero() { + t.Fatalf("the reopened row = %+v, want it to name the branch that holds the work", row) + } +} + +// A FOLDER ANOTHER PROCESS ENDED IS READ BACK FROM THE RUN'S RECORD FOLDER. +// Its folder was finished — by the run itself before codeaf closed, or by the +// next run in that folder, which then wrote its own record over the one beside +// the hold — and the reopen found nothing owed, so the row said only that +// codeaf closed. It now says where the work went and names the branch. +func TestAReopenReadsTheEndingOfAFolderAnotherProcessEnded(t *testing.T) { + t.Run("ended by the run", func(t *testing.T) { + repo := newTestRepo(t) + var said, branch string + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, _ time.Time) { + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The ended run", Holder: "task 9 (The ended run)", Keep: taskDir}) + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(repo, "fix.go"), "package fix\n") + said, branch = folder.Finish("done").Sentence(), folder.Branch + }) + row := reopenedRow(t, agent, id) + if !strings.Contains(row.Report, said) || row.Branch != branch || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { + t.Fatalf("the reopened row = %+v, want %q and the branch %s", row, said, branch) + } + }) + t.Run("settled by the next run", func(t *testing.T) { + repo := newTestRepo(t) + var dead *ProgramFolder + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, _ time.Time) { + dead = deadProgramFolder(t, repo, taskDir) + if err := os.Remove(filepath.Join(repo, "half.txt")); err != nil { + t.Fatal(err) + } + commitIn(t, repo, "done.txt") + next, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: repo, Title: "The next run", Holder: "task 10 (The next run)", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + next.Finish("") + }) + row := reopenedRow(t, agent, id) + if !strings.Contains(row.Report, "its work so far is on its branch "+dead.Branch) || row.Branch != dead.Branch { + t.Fatalf("the reopened row = %+v, want where the dead run's work is", row) + } + }) +} + +// THE RECEIPT OF A FOLDER INSIDE A REPOSITORY AT THE HOME FOLDER NAMES THAT +// REPOSITORY. It said the folder "has no git history", which the chat repeated +// to the person, or answered with a `git init` inside their dotfiles. +func TestTheReceiptOfAFolderUnderARepositoryAtHomeNamesIt(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + mustGit(t, home, "init", "-q") + writeFile(t, filepath.Join(home, ".zshrc"), "export A=1\n") + mustGit(t, home, "add", "-A") + mustGit(t, home, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "dotfiles") + project := filepath.Join(home, "Desktop", "pong") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + want := "It is fake's: it works alone in " + project + " itself, inside the git repository at " + canonicalPath(home) + + ", which holds your home folder, so codeaf cuts no branch there and commits nothing; its changes are there as it makes them." + if got := delegateReceipt(project, testPrograms("fake")[0], &TaskCopyRecord{Dir: project}); !strings.HasPrefix(got, want) || strings.Contains(got, "no git history") { + t.Fatalf("the receipt = %q, want %q", got, want) + } +} diff --git a/internal/session/programhold.go b/internal/session/programhold.go new file mode 100644 index 0000000000..a11b2c75be --- /dev/null +++ b/internal/session/programhold.go @@ -0,0 +1,337 @@ +package session + +// THE HOLD A PROGRAM'S RUN HAS ON ITS FOLDER, and everything that asks it. +// +// A program works in the person's folder itself (programfolder.go), so while +// it runs that folder is its. The hold is how every other road in codeaf knows: +// a flock on a file under the state root named for the folder, which dies with +// the process that took it however that process dies, and which holds between +// two windows, two conversations in one engine and a shell alike, because a +// flock is per open file. +// +// A FOLDER IS BUSY WHEN A HELD FOLDER IS IT, HOLDS IT, OR IS INSIDE IT. A run +// on a plain folder of projects checkpoints everything under it and, once it +// has submitted, puts back whatever changed there and removes whatever was +// added; a second run in one of those projects had its edits reverted and its +// new files deleted under it, while both held their own exact path and each +// was sure it was alone. So the question is asked of the tree: the folders +// above a path by name, and every held folder below it by reading what each +// hold says it holds. + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/home" +) + +// programHold is one live hold on a folder, the two things a refusal names: +// the folder, resolved, and whose run holds it (`senior-dev, task 4 (Fix the +// parser)`, or `senior-dev, a run started at a shell`). +type programHold struct { + dir string + holder string +} + +// programHoldWait is how long a run goes on asking for a hold it found taken, +// and programHoldPause how far apart it asks. A door that only wants to know +// whether a folder is busy takes the hold's file for the instant of asking +// ([programHoldAt]), and a run that asks at that same instant would read that +// as a run holding it; a real run holds its folder for minutes, so a tenth of +// a second costs a refused run nothing it would notice. +const ( + programHoldWait = 100 * time.Millisecond + programHoldPause = 20 * time.Millisecond +) + +// claimProgramFolder takes the hold on one folder for a program's run and +// writes into it whose run it is and which folder, so a second run is told. +// It answers the held lock; or, when the folder is busy — held itself, or +// inside or around a folder held — the hold in the way; or a nil lock and no +// hold, which is a filesystem that takes no locks, and the run goes ahead +// unheld, which is what every run did before the hold existed. +// +// THE FOLDER'S OWN HOLD IS TAKEN BEFORE THE TREE AROUND IT IS READ, and kept +// while it is. Two runs readying a parent and a child at the same moment each +// hold their own and then look for the other, so at least one of them finds +// the other and refuses: they can both be refused, and never both start. +func claimProgramFolder(key, holder string) (*os.File, programHold, bool) { + directory := home.Join("v3", programFolderDir) + if err := os.MkdirAll(directory, 0o700); err != nil { + return nil, programHold{}, false + } + path := programHoldFile(key) + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, programHold{}, false + } + for asked := time.Now(); ; { + err = filelock.Lock(file, true, true) + if err == nil || !isLockHeld(err) || time.Since(asked) >= programHoldWait { + break + } + time.Sleep(programHoldPause) + } + if err != nil { + _ = file.Close() + if !isLockHeld(err) { + return nil, programHold{}, false + } + return nil, readProgramHold(path, key), true + } + _ = file.Truncate(0) + _, _ = file.WriteAt([]byte(strings.TrimSpace(holder)+"\n"+key), 0) + if near, busy := programHoldNear(key, path); busy { + _ = filelock.Unlock(file) + _ = file.Close() + return nil, near, true + } + return file, programHold{}, false +} + +// programFolderHolder is who holds a folder now, or a folder around it or +// inside it, "" when nobody does, for a door that only wants to know. +func programFolderHolder(key string) string { + hold, busy := programHoldNear(key, "") + if !busy { + return "" + } + return hold.holder +} + +// programHoldNear answers the live hold on key, on a folder above it, or on a +// folder inside it, leaving the hold file skip out (the asker's own). It takes +// no hold of its own for longer than it takes to ask. +func programHoldNear(key, skip string) (programHold, bool) { + key = filepath.Clean(strings.TrimSpace(key)) + if key == "" || key == "." { + return programHold{}, false + } + if hold, busy := programHoldOver(key, skip); busy { + return hold, true + } + files, _ := filepath.Glob(filepath.Join(home.Join("v3", programFolderDir), "*.lock")) + for _, path := range files { + if path == skip { + continue + } + // THE FILE IS READ BEFORE IT IS ASKED, so a hold on a folder that has + // nothing to do with this one is never touched at all. + if held := readProgramHold(path, ""); held.dir == "" || !strictlyInside(held.dir, key) { + continue + } + if hold, busy := programHoldAt(path, ""); busy { + return hold, true + } + } + return programHold{}, false +} + +// programHoldOver answers the live hold on path or on a folder above it, +// leaving the hold file skip out. It asks by name — one file for each folder +// on the way up — so it is cheap enough to ask before every write a tool +// makes ([programHoldGuard]). +func programHoldOver(path, skip string) (programHold, bool) { + for dir := filepath.Clean(path); ; { + if file := programHoldFile(dir); file != skip { + if hold, busy := programHoldAt(file, dir); busy { + return hold, true + } + } + parent := filepath.Dir(dir) + if parent == dir { + return programHold{}, false + } + dir = parent + } +} + +// programHoldAt asks one hold file whether a run holds it, by taking it, +// shared, for the instant of asking: a run's hold is exclusive, so a shared +// one is refused exactly when a run has it. key is the folder the file is +// named for, when the asker knows it. +func programHoldAt(path, key string) (programHold, bool) { + file, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return programHold{}, false + } + defer file.Close() + if err := filelock.Lock(file, false, true); err != nil { + if !isLockHeld(err) { + return programHold{}, false + } + return readProgramHold(path, key), true + } + _ = filelock.Unlock(file) + return programHold{}, false +} + +// readProgramHold is what a hold file says: whose run, and which folder. A +// file written before it said the folder is read by the run's record beside +// it ([programFolderRecord]), and failing that by key. +func readProgramHold(path, key string) programHold { + body, _ := os.ReadFile(path) + holder, dir, _ := strings.Cut(string(body), "\n") + hold := programHold{dir: strings.TrimSpace(dir), holder: strings.TrimSpace(holder)} + if hold.dir == "" { + if record, ok := readProgramFolderAt(strings.TrimSuffix(path, ".lock") + ".json"); ok { + hold.dir = record.key + } else { + hold.dir = key + } + } + if hold.holder == "" { + hold.holder = "another program run" + } + return hold +} + +// programHoldFile is the file one folder's hold is taken on. +func programHoldFile(key string) string { + return filepath.Join(home.Join("v3", programFolderDir), programFolderName(key)+".lock") +} + +// programFolderBusy is the refusal for a folder a program's run cannot have +// because another run holds it, or holds a folder around it or inside it. +func programFolderBusy(dir string, hold programHold) string { + return dir + " is busy: " + hold.holder + ", is working in " + hold.where(dir) + + ", and one folder takes one program run at a time; ask again when that run has ended" +} + +// where is the held folder as a sentence about dir names it: "it" when it is +// dir, and the folder itself, with how the two stand, when it is not. +func (h programHold) where(dir string) string { + switch { + case h.dir == "" || h.dir == canonicalPath(dir): + return "it" + case strictlyInside(h.dir, canonicalPath(dir)): + return h.dir + ", which is inside it" + } + return h.dir + ", which holds it" +} + +// ── nothing else of codeaf's writes in a held folder ──────────────────────── +// +// WITHOUT THE COPY, A PROGRAM WORKS IN THE PERSON'S LIVE FOLDER, and what +// anything else writes there meanwhile becomes the program's to act on. Once +// senior-dev has submitted it checks its frozen tree, and a tree that moved is +// put back — `checkout --force`, `reset`, `clean -fd` — which reverts a file +// the chat edited and deletes one it wrote, with no copy kept; an ordinary +// task merged into it mid-run is undone the same way while its row says it +// landed; and whatever survives is swept into the run's finishing commit as +// the program's work. So while a program holds a folder, from any conversation, +// window or shell: +// +// - the chat's own file tools refuse a path inside it ([programHoldGuard]), +// and reading stays open; +// - an ordinary task — a proposal's card, a typed `/task`, a quick task, a +// node starting on the session's own graph or on the run road — whose +// folder is inside it, or holds it, is refused before it starts +// ([programHoldRefusal]); +// - a task that was already running lands beside it rather than into it: its +// branch kept, its copy not laid, a `/land` refused. +// +// `bash` IS NOT FENCED, and neither is the person's own editor: a command's +// effects are whatever it did, and a guard that pattern-matched commands would +// promise what it cannot keep ([treeClaimGuard] says the same). The manual says +// so plainly (senior-dev.md), and that edits made there join the run's work. + +// programHoldGuard refuses a write by one of codeaf's own file tools into a +// folder a program's run holds. It is a pre-action citizen for the reason +// every guard here is one: [Agent.executeTool] is the single door every call +// passes through (hooks.go). +// +// IT BINDS EVERY HAND THAT PUTS A FILE ON THE PERSON'S DISK AT A PATH THE CALL +// NAMES ([savingPath]): write and edit, edit_video's writing actions, and the +// generated picture, music, video and speech a path was given for. A +// generation that names no path lands in this session's own folders, which no +// program holds. +type programHoldGuard struct{ agent *Agent } + +func (programHoldGuard) Name() string { return "program-hold" } + +func (g programHoldGuard) PreAction(_ context.Context, _ *episode, _ *eventHub, call ai.ToolCall) (ai.ToolCall, toolResult, bool) { + path, shown, ok := g.agent.savingPath(call) + if !ok { + return call, toolResult{}, true + } + hold, busy := programHoldOver(canonicalPath(path), "") + if !busy { + return call, toolResult{}, true + } + return call, toolResult{text: programHoldWriteRefusal(shown, hold), isError: true}, false +} + +// programHoldWriteRefusal is what the model reads instead of a write into a +// held folder: the file, the folder, whose run holds it, that nothing was +// written, and the two things that work. +func programHoldWriteRefusal(shown string, hold programHold) string { + return shown + " is in " + hold.dir + ", where " + hold.holder + + ", is working, so nothing was written; wait for that run to end, or stop it, then write there" +} + +// savingPath is the absolute path one call is about to put a file at, and the +// same path as a person reads it, for every hand in [savingTools] whose call +// both writes ([producedAFile]) and names where. It resolves a relative path +// against the workspace the way [Agent.mutatingPath] does, which answers for +// the hands it knows. +func (a *Agent) savingPath(call ai.ToolCall) (string, string, bool) { + if path, shown, ok := a.mutatingPath(call); ok { + return path, shown, true + } + name := call.Function.Name + if _, known := mutatingTools[name]; known || !producedAFile(name, call.Function.Arguments) { + return "", "", false + } + var args struct { + Path string `json:"path"` + } + if err := decodeToolArguments(json.RawMessage(call.Function.Arguments), &args); err != nil { + return "", "", false + } + path, workspace := strings.TrimSpace(args.Path), strings.TrimSpace(a.config.Workspace) + if path == "" || (workspace == "" && !filepath.IsAbs(path)) { + return "", "", false + } + if !filepath.IsAbs(path) { + return filepath.Clean(filepath.Join(workspace, path)), filepath.ToSlash(filepath.Clean(path)), true + } + return filepath.Clean(path), filepath.Clean(path), true +} + +// programHoldRefusal is why an ordinary task may not work in dir now — a +// program's run holds it, a folder around it, or a folder inside it — in one +// sentence naming the run and saying what to do; "" when nothing holds it. +func programHoldRefusal(dir string) string { + dir = strings.TrimSpace(dir) + if dir == "" { + return "" + } + hold, busy := programHoldNear(canonicalPath(dir), "") + if !busy { + return "" + } + return dir + " is busy: " + hold.holder + ", is working in " + hold.where(dir) + + ", and nothing else of codeaf's works there until that run has ended; wait for it, or stop it, then ask again" +} + +// standHeldRefusal is [programHoldRefusal] for where a task would stand, and "" +// for a stand that only reads its ground: a REFERENCE works in a folder of its +// own and writes nothing where it looks. +func standHeldRefusal(stand taskStand, workspace string) string { + if stand.mode == TaskModeReference { + return "" + } + dir := stand.dir + if strings.TrimSpace(dir) == "" { + dir = workspace + } + return programHoldRefusal(dir) +} diff --git a/internal/session/programhold_test.go b/internal/session/programhold_test.go new file mode 100644 index 0000000000..931400cdf1 --- /dev/null +++ b/internal/session/programhold_test.go @@ -0,0 +1,275 @@ +package session + +// THE HOLD A PROGRAM'S RUN HAS ON ITS FOLDER (programhold.go), in real folders +// and real git: a folder is busy when a held folder is it, holds it or is +// inside it, and a folder nobody holds is left exactly as it was by every +// door that asks. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/codeaf/internal/approval" +) + +// holdFolder readies dir for a run of the fake program and answers the +// folder, held until the test ends. +func holdFolder(t *testing.T, dir, title string) *ProgramFolder { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: dir, Title: title, Holder: "task 4 (" + title + ")", Keep: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { folder.Finish("") }) + return folder +} + +// prepareErr is the refusal a run readied on dir meets, "" when it was let +// through (and then finished at once). +func prepareErr(t *testing.T, dir string) string { + t.Helper() + folder, err := PrepareProgramFolder(ProgramFolderOrder{Program: testPrograms("fake")[0], Dir: dir, Title: "The second run", Holder: "task 5 (The second run)", Keep: t.TempDir()}) + if err != nil { + return err.Error() + } + folder.Finish("") + return "" +} + +// ONE FOLDER TAKES ONE PROGRAM RUN, AND SO DO THE FOLDERS INSIDE IT. A run on a +// plain folder of projects puts back whatever changed under it once it has +// submitted, so a second run in one of those projects had its work reverted +// under it while each held only its own exact path. A run on a folder inside a +// held one, or around one, is refused naming the run and the folder it holds, +// at its start and at its card alike; two runs side by side are not. +func TestAProgramRunIsRefusedAFolderInsideOrAroundAHeldOne(t *testing.T) { + work := t.TempDir() + project := filepath.Join(work, "proj") + sibling := filepath.Join(work, "other") + for _, dir := range []string{project, sibling} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + t.Run("inside", func(t *testing.T) { + held := holdFolder(t, work, "Tidy the projects") + want := project + " is busy: fake, task 4 (Tidy the projects), is working in " + canonicalPath(work) + + ", which holds it, and one folder takes one program run at a time; ask again when that run has ended" + if got := prepareErr(t, project); got != want { + t.Fatalf("a run inside a held folder = %q, want %q", got, want) + } + folder := project + if got := programGroundRefusal(testPrograms("fake")[0], &folder); got != want { + t.Fatalf("its card = %q, want %q", got, want) + } + held.Finish("") + if got := prepareErr(t, project); got != "" { + t.Fatalf("the folder was still refused after the run around it ended: %q", got) + } + }) + t.Run("around", func(t *testing.T) { + held := holdFolder(t, project, "Fix the parser") + want := work + " is busy: fake, task 4 (Fix the parser), is working in " + canonicalPath(project) + + ", which is inside it, and one folder takes one program run at a time; ask again when that run has ended" + if got := prepareErr(t, work); got != want { + t.Fatalf("a run around a held folder = %q, want %q", got, want) + } + if got := prepareErr(t, sibling); got != "" { + t.Fatalf("a run beside a held folder was refused: %q", got) + } + held.Finish("") + }) + t.Run("a repository inside", func(t *testing.T) { + repo := newTestRepo(t) + parent := filepath.Dir(repo) + held := holdFolder(t, parent, "Tidy the projects") + got := prepareErr(t, repo) + if !strings.HasPrefix(got, repo+" is busy: fake, task 4 (Tidy the projects), is working in "+canonicalPath(parent)+", which holds it") { + t.Fatalf("a repository inside a held folder = %q", got) + } + if head := currentBranch(repo); head != "work" { + t.Fatalf("a refused repository was switched to %q", head) + } + held.Finish("") + }) +} + +// holdOf is the hold a refusal names for a run holdFolder readied on dir. +func holdOf(dir, title string) programHold { + return programHold{dir: canonicalPath(dir), holder: "fake, task 4 (" + title + ")"} +} + +// THE CHAT'S OWN FILE TOOLS DO NOT WRITE IN A FOLDER A PROGRAM'S RUN HOLDS. +// senior-dev works in the person's folder itself, and once it has submitted it +// puts back whatever changed there and removes whatever was added; a file the +// chat wrote meanwhile was deleted with no copy kept, while the chat had told +// the person it was written. Every hand that puts a file at a path it names is +// refused, through the real tool door, naming the file, the folder and the +// run; reading stays open, a path outside the folder is written, and the same +// write goes through once the run has ended. +func TestTheChatsFileToolsAreRefusedAFolderAProgramHolds(t *testing.T) { + repo := newTestRepo(t) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = repo + config.ApprovalPolicy = &approval.Policy{Default: approval.ActionAllow} + }) + held := holdFolder(t, repo, "Fix the parser") + result := agent.executeTool(context.Background(), agent.newEpisode(), nil, + withdrawnCall("c1", "write", `{"path":"NOTES.md","content":"the chat's note\n"}`), "") + if want := programHoldWriteRefusal("NOTES.md", holdOf(repo, "Fix the parser")); !result.isError || result.text != want { + t.Fatalf("the chat's write = %q (error %v), want %q", result.text, result.isError, want) + } + if _, err := os.Stat(filepath.Join(repo, "NOTES.md")); !os.IsNotExist(err) { + t.Fatalf("the refused write was written: %v", err) + } + if read := agent.executeTool(context.Background(), agent.newEpisode(), nil, + withdrawnCall("c2", "read", `{"path":"shared.txt"}`), ""); read.isError { + t.Fatalf("reading in a held folder was refused: %q", read.text) + } + guard := programHoldGuard{agent: agent} + for _, call := range []ai.ToolCall{ + scopedCall("edit", filepath.Join(repo, "shared.txt")), + withdrawnCall("c3", "edit_video", `{"action":"join","path":"cut.mp4"}`), + withdrawnCall("c4", "generate_image", `{"prompt":"a harbour","path":"art/harbour"}`), + withdrawnCall("c5", "speak", `{"text":"hello","path":"clips/hello"}`), + withdrawnCall("c6", "generate_music", `{"description":"a tune","path":"tune"}`), + withdrawnCall("c7", "generate_video", `{"prompt":"a boat","path":"boat"}`), + } { + if _, refusal, ok := guard.PreAction(context.Background(), nil, nil, call); ok || !refusal.isError || !strings.Contains(refusal.text, "where fake, task 4 (Fix the parser), is working, so nothing was written") { + t.Fatalf("%s into the held folder = %+v (let through %v)", call.Function.Name, refusal, ok) + } + } + elsewhere := t.TempDir() + for _, call := range []ai.ToolCall{ + scopedCall("write", filepath.Join(elsewhere, "notes.md")), + withdrawnCall("c8", "generate_image", `{"prompt":"a harbour"}`), + withdrawnCall("c9", "edit_video", `{"action":"measure","path":"cut.mp4"}`), + withdrawnCall("c10", "bash", `{"command":"echo hi > note.txt"}`), + } { + if _, refusal, ok := guard.PreAction(context.Background(), nil, nil, call); !ok { + t.Fatalf("%s was refused though it writes nothing in the held folder: %q", call.Function.Name, refusal.text) + } + } + held.Finish("") + if again := agent.executeTool(context.Background(), agent.newEpisode(), nil, + withdrawnCall("c11", "write", `{"path":"NOTES.md","content":"the chat's note\n"}`), ""); again.isError { + t.Fatalf("the write was still refused after the run ended: %q", again.text) + } +} + +// AN ORDINARY TASK IS REFUSED A FOLDER A PROGRAM'S RUN HOLDS, OR ONE AROUND +// IT, BEFORE IT STARTS. A task cut from the held repository recorded the +// program's branch as the person's, sealed its unfinished edits in as theirs, +// and merged back into the live checkout under it. Every door says so in one +// sentence: a proposal before its card, a typed `/task`, a quick task, a node +// starting on the session's own graph, and a run on the run road. A reference, +// which only reads its ground, is not refused, and a folder nobody holds is +// untouched by any of it. +func TestAnOrdinaryTaskIsRefusedAFolderAProgramHolds(t *testing.T) { + repo := newTestRepo(t) + registerBeltRunEngine(t, newBeltRunDouble("done")) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(config *Config) { + config.Workspace = repo + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + }) + spec := taskSpec{ground: repo, brief: "fix the parser", deliverable: "the parser fixed in parser.go", acceptance: "its tests pass"} + if stand := agent.resolveTaskGround(spec); stand.refusal != "" || standHeldRefusal(stand, repo) != "" { + t.Fatalf("a folder nobody holds was refused: %+v", stand) + } + holdFolder(t, repo, "Fix the parser") + want := repo + " is busy: fake, task 4 (Fix the parser), is working in it, and nothing else of codeaf's works there until that run has ended; wait for it, or stop it, then ask again" + if stand := agent.resolveTaskGround(spec); stand.refusal != canonicalPath(repo)+strings.TrimPrefix(want, repo) { + t.Fatalf("the proposal's refusal = %q, want %q", stand.refusal, want) + } + if _, _, _, err := agent.StartTask(context.Background(), "fix the parser", false); err == nil || !strings.Contains(err.Error(), " is busy: fake, task 4 (Fix the parser), is working in it, and nothing else of codeaf's works there") { + t.Fatalf("a typed /task = %v, want it refused", err) + } + if _, _, refusal := agent.admitQuick(quickAsk{line: "tidy the readme"}); refusal.said != want { + t.Fatalf("a quick task = %q, want %q", refusal.said, want) + } + graph := &TaskGraph{nodes: map[uint64]*TaskNode{}} + node := &TaskNode{graph: graph, id: 9, Ground: repo, Mode: TaskModeWorktree} + if _, err := prepareTaskTreeForNode(context.Background(), agent.config.Place, repo, "a1a1a1a1a1a1a1a1", node); err == nil || err.Error() != want { + t.Fatalf("a node starting there = %v, want %q", err, want) + } + if err := agent.startKnownTaskRun(context.Background(), agent.graph().reserve(), "Fix", "fix the parser", nil, taskStand{dir: repo, mode: TaskModeWorktree}, ""); err == nil || err.Error() != want { + t.Fatalf("a run on the run road = %v, want %q", err, want) + } + if branches := strings.TrimSpace(gitOut(t, repo, "worktree", "list", "--porcelain")); strings.Count(branches, "worktree ") != 1 { + t.Fatalf("a refused task cut a working copy from the held repository:\n%s", branches) + } + if refusal := standHeldRefusal(taskStand{dir: repo, mode: TaskModeReference}, repo); refusal != "" { + t.Fatalf("a reference, which only reads, was refused: %q", refusal) + } + around := filepath.Dir(repo) + if refusal := programHoldRefusal(around); !strings.Contains(refusal, "is working in "+canonicalPath(repo)+", which is inside it") { + t.Fatalf("a task on a folder around the held one = %q", refusal) + } +} + +// A TASK THAT WAS ALREADY RUNNING LANDS BESIDE A HELD FOLDER, NOT INTO IT. Its +// branch is kept rather than merged into the program's live checkout — where +// the program's restore would have undone it while its row said it landed — +// a mirror is not laid, and a `/land` of the chat's own copy is refused with +// the copy left whole; each goes through once the run has ended. +func TestATaskThatWasRunningLandsBesideAHeldFolder(t *testing.T) { + t.Run("a branch", func(t *testing.T) { + repo := newTestRepo(t) + tree, err := prepareTaskTree(Place{Dir: t.TempDir()}, repo, "b1b1b1b1b1b1b1b1", 1, "update the changelog") + if err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(tree.dir, "CHANGELOG.md"), "a line\n") + held := holdFolder(t, repo, "Fix the parser") + programTip := strings.TrimSpace(gitOut(t, repo, "rev-parse", held.Branch)) + merge, said, _, _ := tree.comeHome("update the changelog", []string{"CHANGELOG.md"}, gitSignature{}) + if merge != mergeKept || !strings.Contains(said, "its branch "+tree.branch+" was kept: fake, task 4 (Fix the parser), is working in it — bring it in when that run has ended") { + t.Fatalf("the landing = %q %q, want its branch kept beside the held folder", merge, said) + } + if tip := strings.TrimSpace(gitOut(t, repo, "rev-parse", held.Branch)); tip != programTip { + t.Fatalf("the task merged into the program's branch: %s, want %s", tip, programTip) + } + if files := gitOut(t, repo, "ls-tree", "--name-only", tree.branch); !strings.Contains(files, "CHANGELOG.md") { + t.Fatalf("the kept branch does not hold the work:\n%s", files) + } + }) + t.Run("a mirror", func(t *testing.T) { + plain, copy := t.TempDir(), t.TempDir() + writeFile(t, filepath.Join(copy, "notes.md"), "the family's notes\n") + held := holdFolder(t, plain, "Fix the parser") + mirror := taskTree{dir: copy, ground: plain, mode: TaskModeMirror} + merge, said, _, refusal := mirror.comeHome("notes", []string{"notes.md"}, gitSignature{}) + if merge != mergeAborted || refusal != refusedByTheWork || !strings.HasPrefix(said, "its work was not laid into "+plain+" and is kept in "+copy+": ") { + t.Fatalf("the mirror's landing = %q %q %v, want it kept in its copy", merge, said, refusal) + } + if _, err := os.Stat(filepath.Join(plain, "notes.md")); !os.IsNotExist(err) { + t.Fatalf("the mirror was laid into the held folder: %v", err) + } + held.Finish("") + if merge, said, _, _ := mirror.comeHome("notes", []string{"notes.md"}, gitSignature{}); merge != mergeInPlace { + t.Fatalf("the mirror's landing once the run ended = %q %q", merge, said) + } + }) + t.Run("a /land", func(t *testing.T) { + repo := newTestRepo(t) + agent, _, _ := standingLab(t, repo) + writeThrough(t, agent, filepath.Join(repo, "shared.txt"), "the changed line\n") + held := holdFolder(t, repo, "Fix the parser") + if _, err := agent.Land(repo); err == nil || !strings.Contains(err.Error(), " is busy: fake, task 4 (Fix the parser), is working in it") { + t.Fatalf("a /land into the held folder = %v", err) + } + if waiting := agent.UnlandedChanges(); len(waiting) != 1 { + t.Fatalf("the refused landing dropped the copy: %+v", waiting) + } + held.Finish("") + if landing, err := agent.Land(repo); err != nil || landing.Merged != mergeMerged { + t.Fatalf("the /land once the run ended = %+v, %v", landing, err) + } + }) +} diff --git a/internal/session/prompt.go b/internal/session/prompt.go index d0729c1cae..6739bc99d3 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -123,6 +123,12 @@ var quickPrompt string //go:embed prompts/landing-answer.md var landingAnswerPrompt string +// programOutcomePrompt is the role page for the turn a program's ending wakes +// (program_outcome.go): the playbook for each way a program's run can end. +// +//go:embed prompts/program-outcome.md +var programOutcomePrompt string + // shapePrompt is what the BRIEF-SHAPER is told (task_shape.go): how to reason // its way from the words a person typed after /task to the brief a worker with // nobody to ask is actually given. diff --git a/internal/session/prompt_belt_test.go b/internal/session/prompt_belt_test.go index 5a1965e9f9..27d8873471 100644 --- a/internal/session/prompt_belt_test.go +++ b/internal/session/prompt_belt_test.go @@ -30,6 +30,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" configpkg "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/delegate/builtin" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/store" "github.com/Agent-Field/codeaf/internal/subharness" @@ -86,6 +87,12 @@ func buildShippedConversation(t *testing.T, config *Config) { config.standingItems = &fakeStanding{} config.Subharnesses = registryWith(t, &fakeGeneralist{}, &fakeRunner{manifest: theProgram()}) config.HarnessCards = true + // AND THE PROGRAMS THE BUILD CARRIES, as the chat door hands them over + // (cmd/codeaf's chatv3.go: `Delegates: v3Delegates()`). Their paragraph — + // each program's own guide, and codeaf's rule about the folder one is handed + // — rides every request of the shipping conversation, so a shape that left + // them off would weigh, and lint, a page nobody is sent. + config.Delegates = builtin.All() } // beltShapes is every shape, and each is built the way its own door builds it — diff --git a/internal/session/prompts/program-outcome.md b/internal/session/prompts/program-outcome.md new file mode 100644 index 0000000000..2f6da6c2d1 --- /dev/null +++ b/internal/session/prompts/program-outcome.md @@ -0,0 +1,13 @@ +A program you handed work to has just ended. Its ending is for you to act on, not for the person to decode: decide what happens next, do it, and then tell the person in plain words where the work stands. + +The note ends with a line in brackets: the task, how it came out (passed, unverified, failed, limit, crashed), and which run on this work it was. The line under it says what to do now. Follow it. + +- passed: its own run of the project's build and tests passed. Look at what it changed against what was asked (git diff on its branch). If it is right, say so in one short summary and offer to merge its branch. If it plainly misses part of the ask, treat it as failed. +- unverified: nothing finished checking it. Run the project's own checks on its branch yourself, then act on the result as passed or failed. +- failed: read what failed (the ending names the failing checks; the branch holds the work). A small, clear gap you fix yourself on its branch, then check again. Anything bigger you hand back to the same program with propose_task and via, as a new brief that says what it built, what still fails and exactly what to change, so it starts from its own branch and does not repeat itself. +- limit: it stopped on a dollar or time ceiling. Never hand it back on your own: say briefly what is done and what is left, and ask the person whether to spend more. +- crashed: it broke rather than finished. Hand it back once if the cause looks passing (network, provider, a timeout); otherwise tell the person what broke. + +codeaf sends a program back to one piece of work at most twice on its own; a hand-off past that, or after a limit, is refused, and then the person decides. + +Keep the person's view simple. Do not paste the program's status words or its log; say what now works, what does not, where the work is (the branch and folder), and the one thing they might do next. If the work still does not pass after the last attempt, say so plainly: never report unfinished work as done. diff --git a/internal/session/question.go b/internal/session/question.go index 073183c80c..bd737ae537 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -2801,7 +2801,7 @@ func (a *Agent) proposalQuestion(id uint64, notice TaskNotice) Question { Ask: AskPermission, Form: FormCard, Asker: Asker{Kind: AskerModel}, - Head: TaskProposalLead + strings.TrimSpace(notice.Title), + Head: TaskProposalHead(notice), Reason: strings.TrimSpace(notice.Summary), Subject: SubjectRef{Kind: SubjectNode, ID: id, Name: strings.TrimSpace(notice.Title)}, Options: AnswerOptions(QuestionTask), @@ -2875,6 +2875,45 @@ func TaskModelShape(notice TaskNotice) InputShape { // would put two questions on screen about one proposal. const TaskProposalLead = "wants to start a task: " +// TaskProposalHead is the whole sentence a proposal asks with: [TaskProposalLead] +// and the title, and — for work going to a program codeaf carries — the +// program's badge where the word `task` is, so the question reads +// `wants to start a [senior-dev] task: `. +// +// THE PROGRAM IS IN THE SENTENCE AND NOT ONLY ON THE CARD, because the sentence +// is what every reader of a proposal gets: the block above the box, home's +// needs panel, a surface over `--host`, and a plain-text or screen-reader +// reader that draws no card at all. A person approving work is owed who it is +// going to wherever they approve it. The badge stands before the title rather +// than after it because a narrow reader cuts a head from its end. +// +// IT IS EXPORTED FOR [TaskProposalLead]'s REASON: the surface builds the same +// question from the same notice, and two builders that drifted would be two +// questions about one proposal. +func TaskProposalHead(notice TaskNotice) string { + title := strings.TrimSpace(notice.Title) + if program := strings.TrimSpace(notice.Program); program != "" { + return "wants to start a " + ProgramBadge(program) + " task: " + title + } + return TaskProposalLead + title +} + +// ProgramBadge is a program's name as the badge its work wears everywhere a +// task is named — `[senior-dev]` — and it is the ONE spelling of the brackets +// (internal/tui3's programbadge.go draws its full spelling from this). +// +// THE BRACKETS ARE THE BADGE, NOT DECORATION. A surface paints the badge in its +// own ink, and a terminal with no colour, a selected row whose ground swallows a +// tint and a sentence read aloud have only the brackets left to say that the +// word inside them is a program's name rather than part of the title. +func ProgramBadge(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + return "[" + name + "]" +} + // TaskProposalPickReason is why the clock recommends starting it, in the words // the recommendation is made in. It is exported for [TaskProposalLead]'s // reason. diff --git a/internal/session/recovery_law_test.go b/internal/session/recovery_law_test.go index c2071108ef..cecefdbd9a 100644 --- a/internal/session/recovery_law_test.go +++ b/internal/session/recovery_law_test.go @@ -58,6 +58,7 @@ var boundsACount = map[string]string{ "SilentCutAttempts": "internal/taxonomy's, named here only where a test states the same figure", "DegenerateCutAttempts": "internal/taxonomy's, as above", "BlindCutAttempts": "internal/taxonomy's, as above", + "programAutoRetries": "hand-offs of one piece of work to a program that codeaf starts on its own, each a new billed run on a sharper brief — the owner's cap on spending without them, not a patience for one call", } // TestNoAttemptCountingLoopInTheSession refuses a loop that counts its own diff --git a/internal/session/runask.go b/internal/session/runask.go index 9c87973093..6622e108a6 100644 --- a/internal/session/runask.go +++ b/internal/session/runask.go @@ -81,10 +81,14 @@ func (a *Agent) AskRun(ctx context.Context, rootID, question string, earlier []R func (a *Agent) runAskCalls(ctx context.Context, rootID string, messages []ai.Message) (RunAskAnswer, error) { tool := ai.ToolDefinition{Type: "function", Function: ai.ToolFunction{Name: "read_task", Description: "Read one task from this run", Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{"id": map[string]interface{}{"type": "string"}}, "required": []string{"id"}, "additionalProperties": false}}} for round := 0; round < 3; round++ { - response, _, err := a.callRole(ctx, roles.RoleWorker, a.Model(), messages, ai.WithTools([]ai.ToolDefinition{tool})) + response, called, err := a.callRole(ctx, roles.RoleWorker, a.Model(), messages, ai.WithTools([]ai.ToolDefinition{tool})) if err != nil { return RunAskAnswer{}, err } + // EVERY ROUND IS PAID FOR, the read_task round as much as the answer, and + // is banked detached because a person asked a page, not a turn + // (runsummary.go says the same of the card's lines). + a.addDetachedUsageAs(response, called, 1, string(roles.RoleWorker)) if len(response.Choices) == 0 { return RunAskAnswer{}, errors.New("run ask returned no answer") } diff --git a/internal/session/runask_test.go b/internal/session/runask_test.go index 3caf270175..9588784bde 100644 --- a/internal/session/runask_test.go +++ b/internal/session/runask_test.go @@ -2,6 +2,7 @@ package session import ( "context" + "math" "path/filepath" "strings" "sync" @@ -188,3 +189,23 @@ func TestRunAskAnswerIsReadThroughAFenceAndANoteIsRecognised(t *testing.T) { t.Fatalf("a recognised steer = %#v, want a note and no answer", note) } } + +// A QUESTION ASKED OF A RUN IS PAID FOR, SO IT IS IN THE BOOKS, every round of +// it: the read_task round and the answer alike, which reached the journal and +// nothing else until this test. +func TestAQuestionAskedOfARunIsInTheConversationsBooks(t *testing.T) { + agent, _, _ := runAskFixture(t, + func(context.Context, []ai.Message) (*ai.Response, error) { + response := toolResponse("c1", "read_task", `{"id":"t-child"}`) + cost := 0.01 + response.Usage = &ai.Usage{PromptTokens: 10, CompletionTokens: 5, Cost: &cost} + return response, nil + }, + pricedText(`{"text":"It changed the lookup.","from":[{"id":"t-child","title":"Write handler","step_start":3,"step_end":14}]}`, 0.02)) + if _, err := agent.AskRun(context.Background(), "t-root", "what changed?", nil); err != nil { + t.Fatal(err) + } + if got := agent.Usage().CostUSD; math.Abs(got-0.03) > 1e-9 { + t.Fatalf("the conversation's books hold $%.4f, want both rounds' $0.03", got) + } +} diff --git a/internal/session/runsummary.go b/internal/session/runsummary.go index ae9ec14015..b5fc05ebc8 100644 --- a/internal/session/runsummary.go +++ b/internal/session/runsummary.go @@ -66,6 +66,16 @@ func (a *Agent) RefreshRunSummary(ctx context.Context, rootID string, lastLook t } stored, had := readRunSummary(store, rootID) family := runSummaryFamily(store, rootID) + // A RUN WITH NOTHING IN IT BUYS NO READING, and neither does a program's. + // The first refresh of a run could arrive before its store held the task, + // and a model was paid to summarise an empty ask and no rows. A program's + // run holds one task and a live stage, and its page is its conversation with + // codeaf, so four model-written lines about that one row would say again, + // for money, what the row already says (plandb_program.go). + if len(family) == 0 || a.planRootIsProgram(store, rootID) { + closeStore() + return stored.Summary, had + } questions := a.runSummaryQuestions(family) stamp := runSummaryStamp(family, questions) if had && stored.Stamp == stamp { @@ -83,11 +93,29 @@ func (a *Agent) RefreshRunSummary(ctx context.Context, rootID string, lastLook t closeStore() return stored.Summary, had } + // ONE READING AT A TIME FOR ONE RUN. Two surfaces that ask in the same + // moment (a window's own refresh and a page it just opened) both found the + // reading stale and both paid for one; the second keeps the last reading. + if _, busy := a.runSummaryBusy.LoadOrStore(rootID, struct{}{}); busy { + closeStore() + return stored.Summary, had + } + defer a.runSummaryBusy.Delete(rootID) input := runSummaryInput(family, questions, rootID, lastLook, a.summaryNow(), stored.Summary) closeStore() - response, _, err := a.callRole(ctx, roles.RoleWorker, a.model, []ai.Message{ + response, called, err := a.callRole(ctx, roles.RoleWorker, a.model, []ai.Message{ textMessage("system", runSummaryPrompt), textMessage("user", input), }, ai.WithMaxTokens(320)) + // THE READING IS PAID FOR WHETHER OR NOT IT CAN BE USED, so it is banked + // before it is read, the way every other errand's answer is (caption.go, + // title.go). It is DETACHED: a surface asked for it, not a turn, so it must + // not move whichever turn happens to be running ([Agent.addDetachedUsageAs]). + // Until this line the card's lines reached the journal and nothing else — a + // stub service that billed every call found three unbanked calls on every + // senior-dev run. + if err == nil && response != nil { + a.addDetachedUsageAs(response, called, 1, string(roles.RoleWorker)) + } if err != nil || response == nil || len(response.Choices) == 0 { return stored.Summary, had } diff --git a/internal/session/runsummary_test.go b/internal/session/runsummary_test.go index 10483abad7..9ac5d8fb84 100644 --- a/internal/session/runsummary_test.go +++ b/internal/session/runsummary_test.go @@ -3,16 +3,19 @@ package session import ( "context" "errors" + "math" "path/filepath" "strings" + "sync" "testing" "time" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" ) -func runSummaryFixture(t *testing.T, completer *scriptedCompleter) (*Agent, *plandb.Store, time.Time) { +func runSummaryFixture(t *testing.T, completer *scriptedCompleter, more ...func(*Config)) (*Agent, *plandb.Store, time.Time) { t.Helper() t.Setenv("CODEAF_TASK_BELT", "bash") dir := t.TempDir() @@ -28,6 +31,9 @@ func runSummaryFixture(t *testing.T, completer *scriptedCompleter) (*Agent, *pla agent, _ := newTestAgent(t, completer, func(c *Config) { c.Place = Place{Dir: dir} c.clock = func() time.Time { return now } + for _, apply := range more { + apply(c) + } }) return agent, store, now } @@ -161,3 +167,103 @@ func TestRunSummaryStampAndInputCarryTheHeldQuestions(t *testing.T) { t.Fatalf("a run never looked at must say so:\n%s", input) } } + +// A RUN'S READING IS PAID FOR, SO IT IS IN THE BOOKS. The card's lines are a +// worker-tier call, and until this test they reached the conversation's journal +// and nowhere else: the status line, `/cost` and the machine's spending ledger +// all left them out, which a stub service that billed every call caught at +// three unbanked calls on every senior-dev run. +func TestARunSummaryIsInTheConversationsBooksAndTheLedger(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + client := &scriptedCompleter{steps: []step{pricedText("what: w\nsince: s\nnow: n\nnext: Nothing needs you.", 0.0123)}} + agent, _, _ := runSummaryFixture(t, client, func(c *Config) { c.usageLedger = ledger }) + if _, ok := agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}); !ok { + t.Fatal("the refresh stored no summary") + } + if got := agent.Usage().CostUSD; math.Abs(got-0.0123) > 1e-9 { + t.Fatalf("the conversation's books hold $%.4f, want the reading's $0.0123", got) + } + FlushUsage() + lines, err := ReadUsage(ledger, time.Time{}) + if err != nil { + t.Fatalf("read the ledger: %v", err) + } + if len(lines) != 1 || math.Abs(lines[0].USD-0.0123) > 1e-9 { + t.Fatalf("ledger = %+v, want one row of $0.0123", lines) + } +} + +// AN ANSWER THAT CANNOT BE READ WAS STILL PAID FOR. The last good reading +// stands on the card, and the money for the one that could not be used is in +// the books all the same. +func TestARunSummaryAnswerThatCannotBeReadIsStillInTheBooks(t *testing.T) { + client := &scriptedCompleter{steps: []step{pricedText("not the four lines", 0.004)}} + agent, _, _ := runSummaryFixture(t, client) + if _, ok := agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}); ok { + t.Fatal("an unreadable answer was stored as a summary") + } + if got := agent.Usage().CostUSD; math.Abs(got-0.004) > 1e-9 { + t.Fatalf("the conversation's books hold $%.4f, want the unreadable answer's $0.004", got) + } +} + +// A PROGRAM'S RUN BUYS NO READING. Its store holds one task and a live stage, +// its page is its conversation with codeaf, and four model-written lines about +// one row would say again, for money, what the row already says. +func TestAProgramsRunBuysNoRunSummary(t *testing.T) { + client := &scriptedCompleter{steps: []step{pricedText("what: w\nsince: s\nnow: n\nnext: n", 0.0123)}} + agent, store, _ := runSummaryFixture(t, client) + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(store.Path()), planRootID), delegate.ProgramRecord{Name: "senior-dev"}); err != nil { + t.Fatal(err) + } + if _, ok := agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}); ok { + t.Fatal("a program's run was given a summary") + } + if client.requests() != 0 { + t.Fatalf("a program's run asked a model %d times for a summary, want none", client.requests()) + } +} + +// A RUN WITH NOTHING IN IT BUYS NO READING. The first refresh of a run used to +// be sent before its store held the task, with an empty ask and no rows, and a +// model was paid to summarise nothing. +func TestARunSummaryOfNoRowsMakesNoCall(t *testing.T) { + client := &scriptedCompleter{steps: []step{pricedText("what: w\nsince: s\nnow: n\nnext: n", 0.0123)}} + agent, _, _ := runSummaryFixture(t, client) + if _, ok := agent.RefreshRunSummary(context.Background(), "no-such-root", time.Time{}); ok { + t.Fatal("a run with no rows was given a summary") + } + if client.requests() != 0 { + t.Fatalf("a run with no rows asked a model %d times, want none", client.requests()) + } +} + +// ONE READING AT A TIME FOR ONE RUN. Two surfaces asking in the same moment +// (a window's own refresh and the page it just opened) both found the reading +// stale and both paid for one; the second now keeps the last reading. +func TestTwoRefreshesOfOneRunAtOnceBuyOneReading(t *testing.T) { + release := make(chan struct{}) + entered := make(chan struct{}, 2) + client := &scriptedCompleter{steps: []step{func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + entered <- struct{}{} + <-release + cost := 0.0123 + response := textResponse("what: w\nsince: s\nnow: n\nnext: n") + response.Usage.Cost = &cost + return response, nil + }}} + agent, _, _ := runSummaryFixture(t, client) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}) + }() + <-entered + agent.RefreshRunSummary(context.Background(), planRootID, time.Time{}) + close(release) + wg.Wait() + if client.requests() != 1 { + t.Fatalf("two refreshes at once asked a model %d times, want one", client.requests()) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 510ee00204..944297c530 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -28,6 +28,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/approval" "github.com/Agent-Field/codeaf/internal/connect" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec" "github.com/Agent-Field/codeaf/internal/exec/bare" @@ -1631,6 +1632,14 @@ type Config struct { // the "absent, not broken" law arriving at a door that was never wired. Subharnesses *exec.Registry + // Delegates is the programs this build carries that a task can be handed to + // whole — senior-dev first (delegate_door.go, internal/delegate). The + // surface hands in the build's list (internal/delegate/builtin) rather than + // this package importing it, so a test of this package never carries a + // program's whole engine. EMPTY IS NONE: the door lists nothing, `via` + // refuses every name, and the prompt says nothing about them. + Delegates []delegate.Delegate + // SubharnessMemory is where a running subharness keeps what it has learned // about its OWN domain — its file in its own bundle, never this // conversation's memory (subharness_env.go's [SubharnessMemory] says why the @@ -2652,6 +2661,13 @@ type Agent struct { // landingOutcomes are owed landing reports returned in this turn. They are // completion evidence, not another part of the person's ask. landingOutcomes []string + // programOutcomeNow is the program run's ending the turn now running was + // woken with, nil for every other turn: a hand-off it makes is a re-attempt + // of that run ([Agent.programRetryRefusal]). Cleared with owedAsks. + programOutcomeNow *programOutcome + // programAttempts is each started program run's place in its line of runs, + // by row ([Agent.keepProgramAttempt]). + programAttempts map[uint64]programAttempt // turnResults are the tasks whose RESULTS ARRIVED IN THIS TURN, by id, in // arrival order and cleared with owedAsks when a turn opens. // @@ -2697,6 +2713,19 @@ type Agent struct { // person is not currently saying under their live authority // (task_forward.go). personHeard uint64 + // programBounced is where a proposal was last turned back because the + // person's message named a program and the proposal did not + // (delegate_asked.go): that message, and the step of the turn whose + // proposals were turned back. It is what makes the bounce once per message: + // a proposal for the same message from a later step, after the model has + // read the bounce, passes as it is. + programBounced bounceMark + // programsHeard is what the person's messages of the turn they last spoke + // in said about the programs this build carries: the newest one named, + // the message that named it, and every one asked for (delegate_asked.go). It is + // written where their words are recorded ([Agent.rememberAskLocked]), so a + // steer that names nothing does not unsay what the turn opened by asking. + programsHeard programsHeard // callOutcomes is whether a finished call came back a failure, by call occurrence // (admission_compile.go). It is recorded at the batch's own fan-out because // the flag the tool returned does not survive into the transcript, and it is @@ -3261,6 +3290,10 @@ type Agent struct { // held. beltMu sync.Mutex beltRun *beltRun + // runSummaryBusy holds the roots whose card reading is being bought right + // now ([Agent.RefreshRunSummary]), so a second surface that asks in the same + // moment keeps the last reading rather than paying for a second one. + runSummaryBusy sync.Map // beltStartMu is the start lock: it is held from a hand-off's look for a // live run until the run it opens is registered on beltRun, so a batch of // hand-offs committed at one moment is one run and never several racing to diff --git a/internal/session/spawnfloor.go b/internal/session/spawnfloor.go index 5fc9a088a0..fa74812078 100644 --- a/internal/session/spawnfloor.go +++ b/internal/session/spawnfloor.go @@ -29,7 +29,11 @@ package session // commit is how the commit is lost. The matcher is a closed set and nothing // else. -import "strings" +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/exec/bare" +) // spawnFloorRefusal is what propose_task reads back when the person's words // are a trivial ask. It names the fix the model can act on: do the command @@ -37,23 +41,62 @@ import "strings" const spawnFloorRefusal = "this ask is one command — do it here. A commit, an undo, a one-file edit or a single read stays in the conversation; handing it to a task is how the work gets dropped." // refuseProposedTask is every check that can turn a propose_task call around -// BEFORE a card is raised or a slot is taken: the spawn floor, then a -// depends_on that can never resolve. Both used to live as endings of -// [Agent.proposeTask]; they are here so that road does not grow (the -// complexity ratchet holds it at 16). -func (a *Agent) refuseProposedTask(spec taskSpec) string { - if !a.config.InTask && trivialAsk(a.taskRequest()) { - return spawnFloorRefusal - } - if missing, failed := a.graph().doomedDependencies(spec.dependsOn); len(missing)+len(failed) > 0 { - if bashBeltAsked() { - missing = a.missingRunDependencies(missing) - } - if len(missing)+len(failed) > 0 { - return dependencyRefusal(missing, failed) - } +// BEFORE a card is raised or a slot is taken: the spawn floor where no program +// could lift it, a proposal that left out the program the person named, the +// floor again for a proposal that still leaves it out, then a depends_on that +// can never resolve. They used to live as endings of [Agent.proposeTask]; they +// are here so that road does not grow (the complexity ratchet holds it at 16). +// +// THE PROGRAM THE PERSON ASKED FOR LIFTS THE FLOOR. "fix this file with +// senior-dev" is a one-file fix on the floor's reading and an ask for a +// program on the person's: the proposal without `via` is turned back to name +// it, and the one that names it is not refused for being small +// (delegate_asked.go). A proposal naming a program the person did not ask +// for meets the floor as any proposal does. +// +// AND THE FLOOR COMES FIRST WHERE NOTHING COULD LIFT IT, so nothing there is +// pushed toward a program the floor would then refuse. A commit, an undo or a +// revert is never lifted: a program works on a branch of its own and never +// moves the person's, so "revert senior-dev's commit" handed to senior-dev is +// a billed run that cannot do the revert, which is F26 again. And a name said +// in passing ("fix senior-dev's typo in this file") asks nothing of the +// program, so only the words that ask for it lift anything +// ([Config.programsAskedIn]). +// +// IT ANSWERS A STAGED CALL, nil for none, because the bounce is not a settled +// refusal: it marks the message it was made for, and a call withdrawn before +// it went ahead takes that mark back ([askBounce.Withdraw]). +func (a *Agent) refuseProposedTask(spec taskSpec) bare.Staged { + verb := "" + if !a.config.InTask { + verb = trivialVerb(a.taskRequest()) } - return "" + if verb != "" && !a.programMayLiftFloor(verb, spec.via) { + return bare.Settled(spawnFloorRefusal, true) + } + if bounce := a.programAskBounce(spec); bounce != nil { + return bounce + } + if verb != "" && spec.via == "" { + return bare.Settled(spawnFloorRefusal, true) + } + if refusal := a.proposalDependencyRefusal(spec); refusal != "" { + return bare.Settled(refusal, true) + } + if refusal := a.programRetryRefusal(spec.via); refusal != "" { + return bare.Settled(refusal, true) + } + return nil +} + +// yourBranchVerbs are the trivial asks that are work on the person's own +// branch: a commit, an undo, a revert. Naming a program never lifts the floor +// for them ([Agent.programMayLiftFloor]), because a program works on a branch +// of its own and never moves the person's, so it could not do them at all. +var yourBranchVerbs = map[string]bool{ + "commit": true, + "undo": true, + "revert": true, } // spawnFloorWide is the words that mean the ask has MORE THAN ONE piece of @@ -76,9 +119,17 @@ var spawnFloorWide = map[string]bool{ // staged five files is still a commit, and converting it is how F26 lost // the commit. Breadth of what the turn has touched does not lift the floor. func trivialAsk(asked string) bool { + return trivialVerb(asked) != "" +} + +// trivialVerb is the verb that puts the person's words on the floor, with any +// "git" in front of it dropped, or "" when they are not a trivial ask. It is +// [trivialAsk]'s one reading, and the verb is kept because what can lift the +// floor depends on it ([yourBranchVerbs]). +func trivialVerb(asked string) string { words := dropAskLeadIn(normalizedWords(asked)) if len(words) == 0 { - return false + return "" } // An explicit ask for a task lifts the floor. "as a task", "make this a // task", "spin it off", "hand it to a task" is the person OVER RULING the @@ -86,24 +137,29 @@ func trivialAsk(asked string) bool { // the one-file bug" ran inline and "continue task N" had no task to // continue (R1). if wantsTask(normalizedWords(asked)) { - return false + return "" } if hasWideSignal(words) { - return false + return "" } head, rest := words[0], words[1:] if head == "git" && len(rest) > 0 { head, rest = rest[0], rest[1:] } + if yourBranchVerbs[head] { + return head + } switch head { - case "commit", "undo", "revert": - return true case "read": - return isSingleRead(rest) + if isSingleRead(rest) { + return head + } case "fix", "edit", "change", "patch": - return isOneFileOrLineEdit(words) + if isOneFileOrLineEdit(words) { + return head + } } - return false + return "" } // dropAskLeadIn strips the politeness a person puts in front of a command diff --git a/internal/session/standingtree.go b/internal/session/standingtree.go index 854faac074..9437f45435 100644 --- a/internal/session/standingtree.go +++ b/internal/session/standingtree.go @@ -698,6 +698,11 @@ func (a *Agent) Land(folder string) (FolderLanding, error) { if len(tree.Wrote) == 0 { return FolderLanding{}, fmt.Errorf("nothing has been changed in %s", filepath.Base(tree.Folder)) } + // A FOLDER A PROGRAM'S RUN HOLDS TAKES NO LANDING (programhold.go): the copy + // and its record stay exactly as they are, for a `/land` once that run ends. + if refusal := programHoldRefusal(tree.Folder); refusal != "" { + return FolderLanding{}, errors.New(refusal) + } landing := FolderLanding{Folder: tree.Folder, Name: filepath.Base(tree.Folder), Files: append([]string{}, tree.Wrote...)} // THE TASK TREE IS BUILT HERE AND HELD NOWHERE, because it is the argument // the landing takes rather than a second record of the copy: this file's diff --git a/internal/session/stoplaw_test.go b/internal/session/stoplaw_test.go index 5b46b7b53c..9d7228a840 100644 --- a/internal/session/stoplaw_test.go +++ b/internal/session/stoplaw_test.go @@ -31,7 +31,7 @@ import ( // here because they are not built from a literal state: their notices copy the // node's state, and the graph is the owner `task:N` has always reached. var stoppableRowPublishers = map[string]struct{ kind, proof string }{ - "startOrJoinTaskRun": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, + "startOrJoinTaskRunVia": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, "setBeltRunMachineHold": {CancelTask, "TestAStopOnARunsOwnRowEndsTheRun"}, "ContinueRun": {CancelTask, "TestAStopReachesARunThatWasCarriedOn"}, "newOrchestrateFamily": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index ffad50f8ea..3071b70f6b 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -26,7 +26,7 @@ package session // home after that finds its task already ended and writes // nothing over it, so no part of a stopped run reads as a // failure with a cut call's error for its reason. And a store -// whose run is over is one the next hand-off cannot adopt. +// whose run is over reads as over on its page. // the context next every worker and every call a worker has out was handed // this context, so the spend ends here and not at the next // pass of anybody's loop. @@ -34,7 +34,10 @@ package session // what they had made is committed on the run's own branch and // the copy is given back ([keptWork], the road every stopped // task takes). NOTHING GOES INTO THE PERSON'S FOLDER: work -// that was stopped half-way is work nobody checked. +// that was stopped half-way is work nobody checked. A +// program's run has no copy: its folder is finished the way +// every ending of it finishes it ([ProgramFolder.Finish]), on +// its own branch, which the person's branch never becomes. // // IT IS IDEMPOTENT, like every other stop here (cancel.go): a second press on a // run that is stopping says so, and a press on a run that is over says that. @@ -95,7 +98,11 @@ func (a *Agent) stopBeltRow(id uint64, why string) (string, bool, error) { if cut != nil { cut() } - return "stopping " + stopBecause(name, why) + " — its branch is kept", true, nil + promise := "its branch is kept" + if run.folder != nil { + promise = run.folder.StopPromise() + } + return "stopping " + stopBecause(name, why) + " — " + promise, true, nil } joined := false for _, row := range run.joined { @@ -109,9 +116,10 @@ func (a *Agent) stopBeltRow(id uint64, why string) (string, bool, error) { } // liveBeltTaskByToken resolves the model's task spelling against the run that -// is alive now. A run's rows are deliberately not graph nodes and do not reach -// the project's finished-work index until they end, so that index cannot be -// the door onto stopping one. The run's own kept rows carry the same ids and +// is alive now. A run's rows are deliberately not graph nodes, and the index +// rows they write are left out of this conversation's own reading of the index +// (task_run_index.go), so that index cannot be the door onto stopping one. The +// run's own kept rows carry the same ids and // titles the rail shows, which makes a number and a title-derived name mean the // same thing here that they mean for an ordinary task. func (a *Agent) liveBeltTaskByToken(token string) (TaskIndexEntry, uint64, bool) { @@ -318,11 +326,33 @@ func (a *Agent) beltRunRootRow(id string) (uint64, bool) { // and the conversation are told once where that work is, and the rows settle as // stopped by a person. NOTHING IS LANDED AND NO TURN IS BOUGHT: the person // ended the spend, and a model call to narrate the ending would be more of it. +// +// A PROGRAM'S STOPPED WORK GOES WHERE AN ENDED ONE'S DOES: its folder finished +// the one way every ending of it is ([ProgramFolder.Finish]), with the stop's +// words as the body of the commit that holds what it left. func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { - merge, changed := keptWork(run.tree, run.title, nil, a.signsGitWork()) report := stopBecause(taskStoppedWord, why) - if merge != mergeInPlace { - report += " · " + beltStoppedWhere(run.tree.branch, run.ground, changed) + var merge, branch string + var changed []string + if run.folder != nil { + end := run.folder.Finish(report) + report += " · " + end.Sentence() + merge, changed = mergeInPlace, end.Changed + if end.Kept { + merge, branch = mergeKept, run.folder.Branch + } + } else { + merge, changed = keptWork(run.tree, run.title, nil, a.signsGitWork()) + if merge != mergeInPlace { + report += " · " + beltStoppedWhere(run.tree.branch, run.ground, changed) + } + // THE ROW NAMES A BRANCH ONLY WHEN THERE IS WORK ON IT, for the reason + // the sentence does ([beltStoppedWhere]): measured on the real binary, a + // run stopped in its first seconds drew `branch kept` beside "it had + // changed nothing". + if merge != mergeInPlace && len(changed) > 0 { + branch = run.tree.branch + } } if _, err := run.store.AddNote(run.root, run.root, report); err != nil { if g := a.graph(); g != nil { @@ -335,16 +365,12 @@ func (a *Agent) settleStoppedBeltRun(run *beltRun, why string, cut []string) { a.recordUserLocked(note) a.mu.Unlock() + // THE ROW ENDS WHERE THE RUN'S WORK DID — the instant the program was gone, + // or the engine answered — and not after the kept work was committed + // ([Agent.beltRunEndedAt]). notice := TaskNotice{ ID: run.row, Title: run.title, State: TaskFailed, Stopped: true, - Report: report, Changed: changed, Merge: merge, EndedAt: a.taskClockNow(), - } - // THE ROW NAMES A BRANCH ONLY WHEN THERE IS WORK ON IT, for the reason the - // sentence does ([beltStoppedWhere]): measured on the real binary, a run - // stopped in its first seconds drew `branch kept` beside "it had changed - // nothing". - if merge != mergeInPlace && len(changed) > 0 { - notice.Branch = run.tree.branch + Report: report, Changed: changed, Merge: merge, Branch: branch, EndedAt: a.beltRunEndedAt(run), } g := a.graph() if g == nil { diff --git a/internal/session/task.go b/internal/session/task.go index 0e0292aef1..ebebb4b995 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -83,6 +83,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec/bare" ) @@ -178,6 +179,15 @@ var taskDescription = "Hand self-contained work to a task outside this conversat // carries it whole, and [composeBrief] bounds the person's verbatim ask and // nothing else — so a findings-rich handoff reaches the worker entire, and this // sentence is the only thing standing between the model and writing one. +// +// AND `via` SAYS WHEN IT IS SET, NOT ONLY WHAT IT IS. It opened on "Optional", +// and a model already writing a proposal for an issue in a mature project read +// that as the field to leave out. The hand-off page says codeaf prefers a +// program for the work its guide claims and uses one the person asks for +// (delegate_door.go's [delegateFact]); this is that same preference at the +// moment the field is being filled, in as few bytes as say it. The page stays +// the rule's home: on the lean belt this schema is fetched on demand, and the +// page is all that is read before the model decides to propose at all. var taskSchemaJSON = `{"type":"object","properties":{` + `"title":{"type":"string","description":"One line naming the work as a person would say it"},` + `"summary":{"type":"string","description":"Two or three lines the person reads to decide whether to redirect it"},` + @@ -191,6 +201,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + + `"via":{"type":"string","description":"A program your instructions list, to do the whole task alone in ground (or this conversation's folder): set it for work one is for, and when the person names one"},` + `"max_steps":{"type":"integer","description":"Optional. Finished tool calls per progress checkpoint (default ` + strconv.Itoa(taskMaxSteps) + `); work still advancing is given more."},` + `"no_progress":{"type":"integer","description":"Optional. Tool calls in a row that may add nothing before it is stopped as stuck (default ` + strconv.Itoa(taskNoProgress) + `). Raise it for work that must read a great deal first"}` + `},"required":["title","summary","brief","deliverable","acceptance"],"additionalProperties":false}` @@ -215,6 +226,7 @@ type taskArguments struct { DependsOn []uint64 `json:"depends_on"` Wide bool `json:"wide"` Model string `json:"model"` + Via string `json:"via"` MaxSteps int `json:"max_steps"` NoProgress int `json:"no_progress"` } @@ -338,6 +350,10 @@ type taskSpec struct { modelWord string model string modelOptions []string + // via is the delegate this work is proposed for, empty for the conversation's + // own worker (delegate_door.go). It is resolved at staging, so a name this + // machine has no delegate for is a refusal before any card goes up. + via string // effort is the rung this node's workers ask the model for, empty when // nobody has set one and the ladder's next rung down decides // (internal/effort). It travels the same road `model` travels — set at @@ -632,12 +648,27 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged } return bare.Settled(problem, true) } - // THE DOOR REFUSALS, before a card or a slot. A trivial ask and a - // depends_on that can never resolve are both "do not start this"; they - // live in one helper so this road does not grow another ending - // (complexity_test.go's ratchet on this function). - if refusal := a.refuseProposedTask(spec); refusal != "" { - return bare.Settled(refusal, true) + // THE DOOR REFUSALS, before a card or a slot. A proposal that left out the + // program the person named, a trivial ask and a depends_on that can never + // resolve are all "do not start this"; they live in one helper so this + // road does not grow another ending (complexity_test.go's ratchet on this + // function). + if refusal := a.refuseProposedTask(spec); refusal != nil { + return refusal + } + // A PROGRAM'S RUN THAT ENDED DONE IS A DEPENDENCY MET, and the graph knows + // no node by its id (program_depends.go). + spec.dependsOn = a.withoutEndedProgramRuns(spec.dependsOn) + // A DELEGATE IS RESOLVED BEFORE THE CARD, so a name this machine has no + // delegate for is answered with the names it has and nobody is asked to + // approve work that could not start (delegate_door.go). + if spec.via != "" { + if _, err := a.delegateFor(spec.via); err != nil { + return bare.Settled(err.Error(), true) + } + if !a.mayHandToProgram() { + return bare.Settled(spec.via+" can only be given work from the conversation, and only where the run road is linked", true) + } } // WHICH HANDS THE WORK LEAVES ON, settled before anybody is asked anything // (taskmodel.go). A word that names no model this install has is a refusal @@ -645,6 +676,9 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged // several is not refused at all: the shortlist rides on the proposal, and the // person settles it in the same breath as the work. choice := a.resolveTaskModel(spec.modelWord) + if spec.via != "" { + choice = a.resolveProgramModels(spec.modelWord) + } if choice.problem != "" { return bare.Settled(choice.problem, true) } @@ -820,48 +854,89 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // quietly became old-tree nodes. The receipt now says the task did not start // and why ([runDidNotStart]), and reads as a failure. Only a run road that // is not there at all (no engine linked, no place for a store) leaves this - // door for the older one. - if bashBeltAsked() && chatRunEngine != nil && !a.config.InTask { - a.mu.Lock() - question := questionAtTaskHandoff(a.owedAsks) - a.mu.Unlock() - description := composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) - // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. - // This context is the turn's, and the turn cancels it on its way out - // (agent.go, `defer cancel(nil)`); a run driven under it would be stopped - // the moment the model finished its sentence. The values ride along, the - // cancellation does not. - // - // What ends it instead is the conversation: the person's stop - // (stoprun.go) or the room closing ([Agent.cutBeltRun]). Dropping the - // turn's cancellation here without either of those is what left a run's - // life belonging to the PROCESS, and a run whose room had closed went on - // spending with nobody able to read it or stop it. - // - // WHETHER IT JOINED is the start door's answer and not a look taken - // before it: in a batch committed at one moment none of the hand-offs - // could see a live run beforehand, and the one that opened the run is - // decided under the start lock ([Agent.startOrJoinTaskRun]). - joined, err := a.startOrJoinTaskRun(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, p.stand, question) - if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { - return refusal.Error(), true, nil - } - if err == nil { - receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) - if joined { - receipt = withReport(receipt, "It joined the work already underway and shares its copy.") - } - return receipt, false, nil - } - if !errors.Is(err, errRunRoadUnavailable) { - return withElsewhere(runDidNotStart(p.id, err), elsewhere), true, nil - } + // door for the older one ([Agent.commitProposalToRun]). + if answer, refused, handled := a.commitProposalToRun(ctx, p, spec, elsewhere); handled { + return answer, refused, nil } state := graph.admit(p.id, spec) admitted = true return taskReceipt(p.id, spec, state, p.stand, elsewhere), false, nil } +// commitProposalToRun is the run road of an approved proposal: an approved +// hand-off under the bash belt, and every hand-off that names a delegate, is a +// RUN and never a session-tree node. It keeps the id the card showed, carries +// its acceptance in the brief and its depends_on as the store's own +// dependencies, and takes the person's ask with it when this turn owes one +// (CHAT-ROLE.md, "A landing speaks only when an answer is owed"). It answers +// handled=false when this proposal is not the run road's — the shipped engine +// admits it then — and handled=true with the model's answer otherwise. +// +// A task about ANOTHER FOLDER than the work already underway is refused here +// ([standsElsewhereError]). Any other failure ON the run road is said, as a +// task that did not start ([runDidNotStart]); only a run road that is not there +// at all ([errRunRoadUnavailable]) leaves an ordinary hand-off to the shipped +// engine. A DELEGATE HAS NO OTHER ROAD: the shipped engine would seat a worker +// of its own on the brief, which is not what was asked for, so its failure is +// answered as a refusal. +func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec taskSpec, elsewhere string) (string, bool, bool) { + if !(bashBeltAsked() || spec.via != "") || chatRunEngine == nil || a.config.InTask { + return "", false, false + } + a.mu.Lock() + question := questionAtTaskHandoff(a.owedAsks) + a.mu.Unlock() + var via *delegate.Delegate + if spec.via != "" { + m, err := a.delegateFor(spec.via) + if err != nil { + return err.Error(), true, true + } + via = &m + } + description := composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) + // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. + // This context is the turn's, and the turn cancels it on its way out + // (agent.go, `defer cancel(nil)`); a run driven under it would be stopped + // the moment the model finished its sentence. The values ride along, the + // cancellation does not. What ends it instead is the conversation: the + // person's stop (stoprun.go) or the room closing ([Agent.cutBeltRun]). + // + // WHETHER IT JOINED is the start door's answer and not a look taken + // before it: in a batch committed at one moment none of the hand-offs + // could see a live run beforehand, and the one that opened the run is + // decided under the start lock ([Agent.startOrJoinTaskRunVia]). + stand := p.stand + if via != nil { + stand = delegateStand(stand.dir) + } + asked := programAsked(spec) + if via != nil { + a.keepProgramAttempt(p.id, a.programAttemptOf()) + } + joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) + if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { + return refusal.Error(), true, true + } + if err == nil { + receipt := taskReceipt(p.id, spec, TaskRunning, p.stand, elsewhere) + switch { + case via != nil: + receipt = delegateStartedReceipt(p.id, spec.title, strings.Join(asked, ", "), delegateReceipt(canonicalPath(stand.dir), *via, a.runRowCopy(p.id)), elsewhere) + case joined: + receipt = withReport(receipt, "It joined the work already underway and shares its copy.") + } + return receipt, false, true + } + if via != nil { + return via.Name + " could not start: " + err.Error(), true, true + } + if !errors.Is(err, errRunRoadUnavailable) { + return withElsewhere(runDidNotStart(p.id, err), elsewhere), true, true + } + return "", false, false +} + // taskReceipt is what an admitted proposal hands back to the model. // // THE MODEL IS NAMED BACK ONLY WHEN IT WAS ASKED FOR. A word resolves to an id @@ -988,6 +1063,7 @@ func parseTaskArguments(args json.RawMessage) (taskSpec, string) { // proposed before it existed. wide: parsed.Wide, modelWord: strings.TrimSpace(parsed.Model), + via: strings.TrimSpace(parsed.Via), maxSteps: parsed.MaxSteps, noProgress: parsed.NoProgress, } @@ -1380,7 +1456,7 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T Summary: spec.summary, Brief: spec.brief, Acceptance: spec.acceptance, - Where: taskWhereNotice(config.Place, config.Workspace, id, spec.where, spec.mode), + Where: taskCardWhere(config, id, spec), Ground: spec.ground, Mode: spec.mode, DependsOn: spec.dependsOn, @@ -1392,6 +1468,10 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T Model: firstTaskModel(spec.modelOptions, spec.model), ModelOptions: append([]string(nil), spec.modelOptions...), Elsewhere: elsewhere, + // AND WHICH PROGRAM THE WORK IS GOING TO, so the card names it before + // anybody is asked to say yes ([TaskNotice.Program]). The name was + // resolved at staging, so it is always one this build carries. + Program: spec.via, }, } } @@ -1468,6 +1548,20 @@ func (a *Agent) taskClockTimer(after time.Duration) (<-chan time.Time, func()) { return timer.C, func() { timer.Stop() } } +// taskCardWhere is the card's `where`. A PROGRAM'S CARD NAMES ITS PROJECT: the +// folder it will work in itself, and that it gets a branch of its own there +// when the folder is a repository ([programPlace]). A card that showed a path +// under codeaf's state asked a person to approve work going somewhere they had +// never heard of. +func taskCardWhere(config Config, id uint64, spec taskSpec) string { + for _, program := range config.Delegates { + if program.Name == spec.via && strings.TrimSpace(spec.ground) != "" { + return programPlace(program, spec.ground) + } + } + return taskWhereNotice(config.Place, config.Workspace, id, spec.where, spec.mode) +} + func taskWhereNotice(place Place, workspace string, id uint64, where string, mode TaskMode) string { where = strings.TrimSpace(where) redirectedInPlace := false diff --git a/internal/session/task_brief.go b/internal/session/task_brief.go index 0ac730b99a..977bdd8ce0 100644 --- a/internal/session/task_brief.go +++ b/internal/session/task_brief.go @@ -1042,6 +1042,11 @@ func (a *Agent) rememberAskLocked(user userMessage) { // the transcript, where their words and the session's own notes are // both user-role. a.rememberPersonTurnLocked(user.message, text) + // AND WHICH PROGRAM THEY NAMED, read from every message of the turn + // rather than the newest, so a steer does not unsay it + // (delegate_asked.go). It is read after the line above has numbered the + // message, because the bounce is counted against that number. + a.hearProgramsLocked(text) // AND THE SESSION'S GOAL OWNER IS TOLD THE SAME THING, in the same // place, on the same test (principal.go). It is one writer rather than // two for the reason stated directly below: a second recorder of the diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index bd487227ef..5b6ad28999 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -307,6 +307,13 @@ const ( // Its reason names the dollar limit ([taskReasonCostLimit]), and the two // endings exist apart so a person who set both is told which one fired. TaskEndingCostLimit TaskEnding = "cost-limit" + // TaskEndingProgram says the program a task was handed to + // (delegate_door.go) ended it without finishing, and said why: its own + // check did not pass what it made, or it stopped on its own ceiling. The + // program's sentence is the reason ([TaskReasonOf]), and it is not a fault: + // nothing broke, a program judged its own work and said so, and what it + // made is on its branch. A program that crashed is [TaskEndingError]. + TaskEndingProgram TaskEnding = "program" // TaskEndingError is everything else: a working copy that could not be // made, a worker that would not start, an error nobody classified. TaskEndingError TaskEnding = "error" @@ -458,6 +465,25 @@ type TaskNotice struct { // worktree. It is on the proposal AND on every update, because it is the one // fact about a node that is true before it starts and after it lands. Kind TaskKind + // Program is the program codeaf carries that this work is handed to — + // senior-dev — by the one name that program answers to (the Name of its + // [delegate.Delegate], the word its command row says), and "" for every task + // a worker of this conversation's own does, which is almost all of them. + // + // IT IS ON THE PROPOSAL AND ON EVERY ROW A PROGRAM'S RUN PUBLISHES, and that + // is the whole of why it is here. A surface used to learn a program's name + // only from the run's plan rows, which it reads on a beat of its own and + // drops on a conversation switch — so the card a person answered could not + // say which program the work was going to, and a program's row on the side + // list looked exactly like an ordinary task's for its first seconds and again + // after every switch. Carried here, the badge a program's work wears + // (internal/tui3's programbadge.go) is there from the first frame. + // + // IT IS A FACT FOR THE ROW'S WHOLE LIFE, like Kind above it: settled before the + // work starts and moved by nothing that happens to the work afterwards, so a + // publisher that forgets it has not changed it ([Agent.publishRunRow] carries + // it forward). + Program string // ── proposal fields (EventTaskProposal) ───────────────────────────── diff --git a/internal/session/task_depends_kept.go b/internal/session/task_depends_kept.go index 202f0b3507..9d378c6eeb 100644 --- a/internal/session/task_depends_kept.go +++ b/internal/session/task_depends_kept.go @@ -34,8 +34,15 @@ func (n *TaskNode) keptDependencyBranches() []string { // prepareTaskTreeForNode adds the one inheritance a graph, rather than a stand, // knows about: completed dependency branches that were kept out of the person's // protected checkout. +// +// IT IS WHERE EVERY NODE OF THE SESSION'S OWN GRAPH STARTS, whichever door +// admitted it, so it is also where a node is refused a folder a program's run +// holds, before anything is cut from it or written in it (programhold.go). func prepareTaskTreeForNode(ctx context.Context, place Place, workspace, session string, node *TaskNode) (taskTree, error) { stand := node.stand() + if refusal := standHeldRefusal(stand, workspace); refusal != "" { + return taskTree{}, errors.New(refusal) + } branches := node.keptDependencyBranches() if len(branches) == 0 { return prepareTaskTreeOn(ctx, place, workspace, session, node.id, node.title(), stand) diff --git a/internal/session/task_index.go b/internal/session/task_index.go index 9cfcb1b88f..cea78d1ebd 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -69,6 +69,7 @@ import ( "sync" "time" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" ) @@ -156,6 +157,17 @@ type TaskIndexEntry struct { // spelled in [TaskKindWord] so that a live row merged in from a graph reads // the same way as a landed one, not because the file holds any. Kind TaskKind `json:"kind,omitempty"` + // Program is the program codeaf carries that this work was handed to — + // senior-dev — and empty for every task a conversation's own worker did + // ([TaskNotice.Program]). It is what lets a surface drawing this file — the + // `@` list, home, the tasks place, another conversation's tasks tool — tell + // a program's work from an ordinary task's, which it otherwise could not + // do from anything a row carries. + // + // IT IS ADDITIVE AND ABSENCE IS ORDINARY, on [TaskIndexEntry.Kind]'s terms: + // rows written before the field existed decode with none, and a blank program + // and a plain task are drawn the same way on purpose. + Program string `json:"program,omitempty"` // Where is the worker's resolved directory, or the explicit placement from a // restored proposal that has not started yet. Where string `json:"where,omitempty"` @@ -704,6 +716,15 @@ const taskInterruptedOutcome = "incomplete — codeaf closed while this was stil // // The close is an APPEND, like every other write to this file, so nothing is // rewritten and a crash during it costs at most one row. +// +// A RUN'S ROW IS CLOSED WHERE THE RUN ENDED, WITH ITS SPAN, not at this +// instant. A hand-off's run rows reach this file too (task_run_index.go), and +// a run knows when it was last at work — its program's recorded exit, its +// store's ending, its last model call or charge ([Agent.interruptedRunEnds]). +// Closed at the reopen instant with no duration, a run that had ended at +// 09:55 read `ended 10:53` with no time at all, and the `@` list said it had +// ended moments ago. A graph node's row, and a run row nothing is known of, +// still close now with the duration they had. func (a *Agent) closeInflightTaskIndexRows() { // A node's own agent shares its parent's project directory and has no // business closing the conversation's rows (the argument [Agent.recoverTasks] @@ -722,6 +743,8 @@ func (a *Agent) closeInflightTaskIndexRows() { return } held := a.heldTaskIDs() + runEnd, done := a.interruptedRunEnds() + defer done() now := time.Now() for _, row := range ReadTaskIndex(path) { if row.SessionID != session || !row.Live() || held[strings.TrimSpace(row.ID)] { @@ -731,10 +754,75 @@ func (a *Agent) closeInflightTaskIndexRows() { closed.Status = string(TaskFailed) closed.Outcome = taskInterruptedOutcome closed.EndedAt = now + if ended := runEnd(row); !ended.IsZero() { + closed.EndedAt = ended + closed.DurationMS = runSpan(row.StartedAt, ended).Milliseconds() + } appendTaskIndex(path, closed) } } +// interruptedRunEnds answers, for a run row this conversation keeps, the +// instant its run was last known to be at work, read out of the conversation's +// run store; zero for every other row, and for a run the store does not hold. +// done closes the store once the rows are closed. +func (a *Agent) interruptedRunEnds() (func(TaskIndexEntry) time.Time, func()) { + none := func(TaskIndexEntry) time.Time { return time.Time{} } + runs := a.ownRunRowIDs() + g := a.tasker() + if len(runs) == 0 || g == nil || g.planPath() == "" { + return none, func() {} + } + if info, err := os.Stat(g.planPath()); err != nil || info.IsDir() { + return none, func() {} + } + store, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + return none, func() {} + } + return func(row TaskIndexEntry) time.Time { + if !runs[strings.TrimSpace(row.ID)] { + return time.Time{} + } + return interruptedRunEnd(store, row) + }, func() { _ = store.Close() } +} + +// interruptedRunEnd is where a run the store holds ended: for the run's own +// row, the end of its one pair ([runClockEnd]) — the program's recorded exit, +// else the store's ending, else the run's last evidence of life (its last +// model call, its last charge, the latest write to any of its tasks); for a +// hand-off that joined it, that task's own ending, else the run's. +func interruptedRunEnd(store *plandb.Store, row TaskIndexEntry) time.Time { + rootID := store.RootID() + root := store.Task(rootID) + if root == nil { + return time.Time{} + } + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), rootID) + record, _ := delegate.ReadProgram(taskDir) + ending := lastEvidenceOfLife(store, root, taskDir, record) + for _, task := range store.Tasks() { + if task.UpdatedAt.After(ending) { + ending = task.UpdatedAt + } + } + if terminalStoreStatus(root.Status) { + ending = root.CompletedAt + } + if id := strings.TrimSpace(row.ID); id != rootID { + task := store.Task(id) + if task == nil { + return time.Time{} + } + if terminalStoreStatus(task.Status) { + return task.CompletedAt + } + return ending + } + return runClockEnd(row.StartedAt, record, ending) +} + // heldTaskIDs is the set of node ids this session's graph is holding, or nil for // a session that never built one. It reads the graph WITHOUT constructing one, // on [Agent.liveTaskRows]'s own terms. diff --git a/internal/session/task_person.go b/internal/session/task_person.go index becb234e50..4ac9b88239 100644 --- a/internal/session/task_person.go +++ b/internal/session/task_person.go @@ -145,6 +145,11 @@ func (a *Agent) startTaskLegacy(ctx context.Context, brief string, solo bool) (u // takes the rung below rather than stopping. The ladder reads the paths in // the person's own sentence, which is the brief it is handed here. stand := a.taskGroundOrStandingIn(spec) + // A FOLDER A PROGRAM'S RUN HOLDS IS REFUSED AT THE DOOR (programhold.go): + // this door has nobody to ask, so the rung below may still land on it. + if refusal := standHeldRefusal(stand, a.config.Workspace); refusal != "" { + return 0, "", "", errors.New(refusal) + } spec.ground, spec.mode = stand.dir, stand.mode graph.admit(id, spec) return id, spec.title, stand.redirect, nil diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 426fc2da1c..5195812b23 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -575,6 +575,11 @@ func (a *Agent) newQuickSpec(ask quickAsk) (taskSpec, string) { if refusal != "" { return taskSpec{}, refusal } + // A QUICK TASK WORKS WHERE ITS CALLER WORKS, so a folder a program's run + // holds is refused it before it starts (programhold.go). + if refusal := programHoldRefusal(a.config.Workspace); refusal != "" { + return taskSpec{}, refusal + } dependsOn := append([]uint64(nil), ask.dependsOn...) // A DEPENDENCY THAT CAN NEVER RESOLVE IS REFUSED AT THE DOOR, on // [Agent.refuseProposedTask]'s terms and through the same reader: an id no diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 07515bb847..af44b608be 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -1018,6 +1018,9 @@ type TaskGraph struct { // other way round. plan *planState planMu sync.Mutex + // pagePlan is the store as the task pages read it when the switch is off + // ([TaskGraph.planForPages]); it is never the plan any worker runs on. + pagePlan *planState // order is admission order, and it is what makes the frontier // DETERMINISTIC: with a cap in play, which of two ready nodes starts first // must not be Go's map iteration. @@ -7417,13 +7420,20 @@ func (a *Agent) foldTaskUsage(node *TaskNode, child *Agent) { // THE FOLD DOOR, not the ordinary auxiliary one: the node journaled these // same tokens into the machine's usage ledger as it spent them, and folding // the total in again would count them twice ([Agent.addFoldedUsage]). - a.spendLedger(node).addFoldedUsage(&ai.Response{Usage: &ai.Usage{ + ledger := a.spendLedger(node) + ledger.addFoldedUsage(&ai.Response{Usage: &ai.Usage{ PromptTokens: used.Input, CompletionTokens: used.Output, CacheReadInputTokens: used.CacheRead, CacheCreationInputTokens: used.CacheWrite, Cost: &cost, }}, child.Model(), used.Calls) + // The books on disk are told as the node's tally reaches them, for the + // reason [Agent.driveBeltRun] stamps a run's: home takes the larger of the + // stamped books and the index rows, and that is exact only while meta.json + // already holds every closed node the index names. A worker that folds a part + // has no place of its own, and its stamp writes nothing. + ledger.stampSpend() } // spendLedger is WHICH SET OF BOOKS this node's spend goes into: the agent that @@ -8133,6 +8143,10 @@ type taskTree struct { // about the run the ground law was written from. rung GroundRung seal string + // continues says a program's run carries on on the branch an earlier run + // of it left checked out ([ProgramFolder.Continues]); false for every + // other tree. + continues bool // base is the machine commit the parent's world was sealed into, when a rung // made one. It is the replay point the landing takes the inheritance back out // at ([taskTree.replayOwnWork]) and it is empty for a parent that had nothing @@ -8323,7 +8337,7 @@ func cutTaskWorktree(ctx context.Context, place Place, root, session string, id root: root, dir: dir, mode: mode, - branch: "task/" + slugify(title) + "-" + shortID(), + branch: taskBranchName(title), title: title, promise: TaskModeWorktree, frozen: frozen, @@ -8715,20 +8729,14 @@ func (t taskTree) comeHome(title string, wrote []string, sign gitSignature) (str return mergeConflicted, withReport(withReport(unreachedSentence(t.branch, t.dir, out), stranded), leftBehindSentence(left, true)), nil, refusedByTheWork } - // A TASK NEVER WRITES A PROTECTED, MOVED OR DETACHED CHECKOUT. The branch is - // already committed and present in the ground repository at this point, so - // keeping it gives the person a durable result and gives the working copy - // back without changing a byte of the checkout they are using. - if t.landsInThePersonsRepository() { - if kept := t.keptLandingSentence(); kept != "" { - t.releaseKeptLocked() - // refusedNothing: the landing was not refused, it was HONOURED. The - // work is committed on its branch and the person has been told which - // one — a refusal here would put a policy keep on the unsaved road - // (task_land_unsaved.go) and offer to try it again, which is the one - // thing that must not happen to a checkout codeaf will not write. - return mergeKept, withReport(withReport(kept, stranded), leftBehindSentence(left, true)), nil, refusedNothing - } + if kept := t.keptInsteadOfMerged(); kept != "" { + t.releaseKeptLocked() + // refusedNothing: the landing was not refused, it was HONOURED. The + // work is committed on its branch and the person has been told which + // one — a refusal here would put a policy keep on the unsaved road + // (task_land_unsaved.go) and offer to try it again, which is the one + // thing that must not happen to a checkout codeaf will not write. + return mergeKept, withReport(withReport(kept, stranded), leftBehindSentence(left, true)), nil, refusedNothing } // AND THE MERGE IS THE CARRY-OR-REFUSE ONE (groundcarry.go). The ground a // task was carved from is the ground it merges into: work of the person's own @@ -8772,6 +8780,27 @@ func (t taskTree) comeHome(title string, wrote []string, sign gitSignature) (str leftBehindSentence(left, false)), nil, refusedNothing } +// keptInsteadOfMerged is the sentence for a branch that lands by being kept +// rather than merged, and "" for one that is merged. It is asked once the +// branch is committed and present in the ground repository, so keeping it +// gives the person a durable result and gives the working copy back without +// changing a byte of the checkout they are using. +func (t taskTree) keptInsteadOfMerged() string { + // NOR ONE A PROGRAM'S RUN IS WORKING IN (programhold.go). A merge there + // lands under the program — stashing its unfinished edits, or put back by + // its own restore once it has submitted — while this row says it landed; + // kept, the work waits on its own branch for the run to end. + if hold, busy := programHoldNear(canonicalPath(t.root), ""); busy { + return "its branch " + t.branch + " was kept: " + hold.holder + ", is working in " + hold.where(t.root) + + " — bring it in when that run has ended" + } + // A TASK NEVER WRITES A PROTECTED, MOVED OR DETACHED CHECKOUT. + if t.landsInThePersonsRepository() { + return t.keptLandingSentence() + } + return "" +} + // landMirror brings a mirrored folder home: the files the node wrote, laid over // the ground BY NAME, and the ones it wrote and then deleted taken away again. // @@ -8791,6 +8820,14 @@ func (t taskTree) landMirror(wrote []string) (string, string, []string, landingR if strings.TrimSpace(t.ground) == "" || strings.TrimSpace(t.dir) == "" { return mergeInPlace, "", nil, refusedNothing } + // A FOLDER A PROGRAM'S RUN HOLDS IS NOT LAID INTO (programhold.go): the + // program would count the files as its own, or put them back once it has + // submitted. Nothing is laid and the copy stays whole, a refusal a second + // answer gets past once that run has ended. + if refusal := programHoldRefusal(t.ground); refusal != "" { + return mergeAborted, "its work was not laid into " + t.ground + " and is kept in " + t.dir + ": " + refusal, + nil, refusedByTheWork + } // AND IT DOES NOT WRITE OVER A FILE THAT CHANGED UNDER IT // (task_mirror_manners.go). The mark is [mergeConflicted] because that is what // this is — the same file changed on both sides — and because every one of the diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 2c88dd0382..b4054adc71 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -36,11 +36,12 @@ import ( "fmt" "math" "os" + "path/filepath" "strconv" "strings" "time" - "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/roles" ) @@ -51,8 +52,9 @@ import ( // limits is the smaller, so a run and an adaptive run cannot come to disagree // about it. Zero means no limit. A spent or overspent limit becomes the smallest // positive figure rather than zero because the run engine reads zero as -// unlimited; its existing limit ending then stops the run before a second paid -// call if admission did not already refuse the turn. +// unlimited. Both the engine and a program's model API read a limit with +// nothing left as already reached, so such a run makes no paid call at all and +// ends on the person's cost limit. func runCostLeft(limit, spent float64) float64 { if limit <= 0 { return 0 @@ -72,8 +74,9 @@ type RunSpec struct { // for the life of the run; the engine reads and writes it like any other // writer of the store. Store *plandb.Store - // Workspace is the run's own working copy, the directory every worker - // types in and the landing commits. + // Workspace is the directory every worker types in and the landing + // commits: the run's own working copy, or the folder itself for a program + // that edits files (programfolder.go). Workspace string // Title and Brief are the run's own words: the title names the root row, // and the brief is the assignment the root worker reads. @@ -117,8 +120,51 @@ type RunSpec struct { // conversation's do — and a nil one lets the engine build each worker's // client itself. CompleterFor func(model string) Completer + // Serves answers whether this conversation's services can take a call on a + // model ([ServesModel], read live). A delegated run's model API asks it of + // every model the program names, and answers a model nothing here can + // reach on the run's work seat instead. Nil answers yes for every model. + Serves func(model string) bool // OnSpend observes the reconciled cumulative run spend while work is live. OnSpend func(float64) + // OnCharge observes each priced call a worker that meters call by call + // makes — a delegated run's program, through its model API — with the + // call's own tokens and model, so the conversation folds the call whole + // rather than as a bare dollar figure ([RunCharge]). Nil for a worker that + // only reports its running total. + OnCharge func(RunCharge) + // Conversation is the id of the conversation that started the run: the + // journal id its own ledger rows carry as their Session. A delegated + // run's ledger rows name it as their Root and their Session, beside the + // task's id, so the conversation's receipt and the spending page can place + // the money. Empty leaves those rows naming no conversation. + Conversation string + // Delegate, when set, is the program this run's root task is handed to + // instead of a bash worker (delegate_door.go). No key goes with it: the + // program reaches a model only through the API codeaf serves the run. Nil is + // every run the conversation's own workers drive. + Delegate *delegate.Delegate + // PlainFolder says the delegated run's program works in its folder without + // git ([ProgramFolder.Plain]), so it is started with its own flags for that + // (delegate.Delegate.PlainFolder). False for every other run. + PlainFolder bool + // Crew is the conversation's crew as a delegated run's program is handed it + // ([conversationCrew]), so the program works on the models the person + // chose. Zero for every other run. + Crew delegate.Crew +} + +// ProgramEnding is a delegated run's program's own ending when it did not +// finish, as the engine read it off the program's terminal record: the status +// word, the one sentence the row says, and the program's account. +type ProgramEnding struct { + // Status is delegate.StatusFail, StatusBudget, StatusCrashed, or a word + // this build does not know. + Status string + // Reason is the sentence: `senior-dev did not finish: …`. + Reason string + // Result is the program's account in full. + Result string } // RunLimit is which bound a person set ended a run. The engine's outcome word @@ -145,6 +191,13 @@ type RunSummary struct { Result string // Limit is empty on every run that did not end on a bound its person set. Limit RunLimit + // Program is how a delegated run's program ended when it did not finish, + // nil otherwise ([ProgramEnding]). + Program *ProgramEnding + // ProgramVerdict is a delegated run's program's own word for the work it + // FINISHED — senior-dev's `pass` or `pass-unverified` — and empty for + // every other ending and every other run. + ProgramVerdict string // Cut is every task the run's own ending cut mid-flight, by store id: the // same typed fact as the limit, read where the run recorded it. A joined // row in this set is drawn with the run's own ending and never as a fault. @@ -166,6 +219,10 @@ type RunLanding struct { // brought back to its ground ([Agent.landBeltRun]). Empty is an engine's own // landing, which commits on the copy's branch and merges nothing. Home string + // Line is a landing that says itself: a program's run, whose folder's + // ending ([ProgramFolderEnd.Sentence]) is the whole account of where its + // work is ([beltLandingLine]). Empty for every other run. + Line string } // RunEngine is the run engine as this door reaches it. Start drives one store @@ -213,10 +270,14 @@ type beltRun struct { // workspace is the run's own copy, the directory every worker types in, and // ground is the folder that copy was cut from and comes home to. tree is the // copy as the ground ladder made it, kept so the run's landing is the ladder's - // own ([Agent.landBeltRun]). + // own ([Agent.landBeltRun]). A program that edits files has no copy: all + // three name the folder it works in ([ProgramFolder.tree]). workspace string ground string tree taskTree + // asked is the models the person asked this run's program to work with, in + // place of the crew's working seat; empty for the crew ([Agent.delegateCrew]). + asked []string // joined is every hand-off that joined this run after it started, by the // number its row wears. Each was published as a running row of its own, and // each is settled with the run ([Agent.settleBeltRun]); it is written and @@ -247,6 +308,22 @@ type beltRun struct { // It is the same reading the row published to the surface carries, so the // tree and the row cannot disagree about when the work began. born time.Time + // ended is the instant the run's engine answered, off the same clock, and + // spent is what the engine said the run came to: both zero until the run's + // work is over. ended is what a run with no program's clock settles at + // ([Agent.beltRunEndedAt]), which is never the later instant its landing + // and its summary have finished at. Both are written and read under + // [Agent.beltMu], because a hand-off joining the run publishes from another + // goroutine while the run is ending. + ended time.Time + spent float64 + // delegate is the program this run's root is handed to, nil for a run the + // conversation's own workers drive; folder is the folder a program that + // edits files works in, held for the run and finished when it ends + // ([PrepareProgramFolder]), nil for every other run. It is set once, before + // the run starts, and never written again. + delegate *delegate.Delegate + folder *ProgramFolder } // startTaskRun is StartTask's second road, taken whenever the bash belt is asked @@ -324,7 +401,23 @@ func (e standsElsewhereError) Error() string { // An approved hand-off under the bash belt belongs to the run store and never to the session tree. func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string) error { - _, err := a.startOrJoinTaskRun(ctx, id, title, brief, dependsOn, stand, question) + _, err := a.startOrJoinTaskRunVia(ctx, id, title, brief, dependsOn, stand, question, nil) + return err +} + +// startKnownTaskRunVia is [Agent.startKnownTaskRun] with the worker named: nil +// is the conversation's own bash worker, and a program is the one the root task +// is handed to (delegate_door.go). One body serves both because a +// delegated run IS a run — the store, the row and the stop road are the same — +// and a second body would be two roads that must stay in step. What differs is +// where it works: a program that edits files works in the folder itself +// (programfolder.go), where every other run gets the copy its ground ladder +// cuts. +// +// asked is the models the person asked the program to work with, resolved; +// none means the conversation's crew ([Agent.delegateCrew]). +func (a *Agent) startKnownTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Delegate, asked ...string) error { + _, err := a.startOrJoinTaskRunVia(ctx, id, title, brief, dependsOn, stand, question, via, asked...) return err } @@ -333,6 +426,12 @@ func (a *Agent) startKnownTaskRun(ctx context.Context, id uint64, title, brief s // this door can know: a batch of hand-offs is one run, and which of them opened // it is decided here, under the start lock, and nowhere before. func (a *Agent) startOrJoinTaskRun(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string) (bool, error) { + return a.startOrJoinTaskRunVia(ctx, id, title, brief, dependsOn, stand, question, nil) +} + +// startOrJoinTaskRunVia is the one body behind every door above: the start +// lock, the join, the folder or the copy, the store and the first row. +func (a *Agent) startOrJoinTaskRunVia(ctx context.Context, id uint64, title, brief string, dependsOn []uint64, stand taskStand, question string, via *delegate.Delegate, asked ...string) (bool, error) { engine := chatRunEngine g := a.graph() if engine == nil || g == nil || g.planPath() == "" { @@ -357,6 +456,13 @@ func (a *Agent) startOrJoinTaskRun(ctx context.Context, id uint64, title, brief // out waits for the run to be over and then starts a fresh one of its own // ([joinOrWait] is the whole of the decision). // + // AND A PROGRAM NEVER JOINS A RUN AND NOTHING JOINS A PROGRAM'S. A program's + // run is a run of one task whose worker owns its whole folder for the hour; + // a second task beside it would be a bash worker typing in the tree the + // program is editing, and a program added under a live run would be a second + // worker beside the first. Both are refused with what is underway, and where + // ([joinOrWait] again). + // // ── ONE RUN PER BATCH ── // // STARTING A RUN IS ONE CRITICAL SECTION, from "is there a live run" to the @@ -372,7 +478,7 @@ func (a *Agent) startOrJoinTaskRun(ctx context.Context, id uint64, title, brief // as children, exactly as a hand-off made a minute later would. a.lockBeltStart() defer a.beltStartMu.Unlock() - live, err := a.joinOrWait(ctx, stand, id, title, brief, dependencies) + live, err := a.joinOrWait(ctx, stand, id, title, brief, dependencies, via) if err != nil { return false, err } @@ -394,20 +500,35 @@ func (a *Agent) startOrJoinTaskRun(ctx context.Context, id uint64, title, brief return true, nil } + // THE FOLDER IS READIED BEFORE THE STORE IS SEEDED, so a folder that refuses + // the run refuses it before anything is written ([Agent.readyRunFolder]). + folder, err := a.readyRunFolder(id, title, filepath.Dir(path), stand, via) + if err != nil { + return false, err + } plan, store, err := a.openBeltRunStore(g, path, storeID, title, brief, false) if err != nil { + folder.abandon() return false, err } if question = strings.TrimSpace(question); question != "" { if _, err := store.Revise(store.RootID(), plandb.TaskPatch{Question: &question}); err != nil { discardUnstartedRunStore(store) + folder.abandon() return false, err } } - tree, err := beltRunPrepare(ctx, a.config.Place, a.config.Workspace, a.journalID(), id, title, stand) - if err != nil { - discardUnstartedRunStore(store) - return false, err + tree, ground := folder.tree(), canonicalPath(stand.dir) + if folder != nil { + // A PROGRAM'S GROUND IS THE FOLDER IT WORKS IN, which is the + // repository's root when it was handed a folder inside one. + ground = canonicalPath(folder.Dir) + } else { + tree, err = beltRunPrepare(ctx, a.config.Place, a.config.Workspace, a.journalID(), id, title, stand) + if err != nil { + discardUnstartedRunStore(store) + return false, err + } } // THE COPY IS A SHELL WORKER'S, so its landing stages the tree's own status: // a run's workers edit through bash and fill no write ledger. @@ -420,17 +541,23 @@ func (a *Agent) startOrJoinTaskRun(ctx context.Context, id uint64, title, brief born := a.taskClockNow() run := &beltRun{ plan: plan, store: store, root: store.RootID(), row: id, title: title, - workspace: tree.dir, ground: canonicalPath(stand.dir), tree: tree, cut: cut, + workspace: tree.dir, ground: ground, tree: tree, cut: cut, born: born, over: make(chan struct{}), + delegate: via, folder: folder, asked: asked, } a.installBeltRun(g, run) // THE COPY IS WRITTEN DOWN IN THE SAME BREATH THE RUN IS PUBLISHED, because // the branch it names exists only in this variable until it is: the road that // cut it minted the name at random and wrote it nowhere ([runCopyOf] says the // whole of why). A run published without it is a run nobody can carry on. + // + // AND THE ROW SAYS WHICH PROGRAM HAS THE WORK from this first publish on, which + // is the one place both doors meet — a typed `/<name>` and an approved + // `via` — so every later publish carries it forward from here + // ([TaskNotice.Program], [Agent.publishRunRow]). a.publishRunRow(g, TaskNotice{ ID: id, Title: title, State: TaskRunning, StartedAt: born, - Copy: runCopyOf(tree), + Copy: runCopyOf(tree), Program: programName(via), // THE ROOT'S ROW NAMES THE STORE'S ROOT, which is this same number: the // store was seeded under `storeID` a few lines up, so the row the person // was answered with and the task the store drives are one identity said @@ -502,7 +629,11 @@ var beltJoinWaits func() // ending of a limit), and the store refuses a child under an ended task in the // same transaction that would have added it. That refusal is read as the run // being on its way out, never as the hand-off failing. -func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, title, brief string, dependencies []plandb.Dependency) (*beltRun, error) { +// +// A PROGRAM NEVER JOINS A RUN AND NOTHING JOINS A PROGRAM'S, while that run is +// still turning: both are refused with what is underway, and where. A run on +// its way out is waited for like any other. +func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, title, brief string, dependencies []plandb.Dependency, via *delegate.Delegate) (*beltRun, error) { storeID := strconv.FormatUint(id, 10) for { a.beltMu.Lock() @@ -513,6 +644,10 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } over := live.over if !live.ending && !live.closing && !live.stopped { + if refusal := programJoinRefusal(via, live); refusal != nil { + a.beltMu.Unlock() + return nil, refusal + } if canonicalPath(stand.dir) != live.ground { a.beltMu.Unlock() return nil, standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} @@ -548,6 +683,73 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } } +// readyRunFolder answers the folder a new run that edits files by a program's +// hand works in, readied, and what refuses a run its folder. It is the first +// thing a run does, so a folder that refuses refuses before a store is seeded +// or a row is published. sessionDir is the folder the run's store is in. +// +// - A PROGRAM THAT EDITS FILES WORKS IN THE FOLDER ITSELF (programfolder.go), +// readied here: refused over changes that are not committed or another +// program's run in or around it, and otherwise held for the run. +// - AN ORDINARY RUN IS REFUSED A FOLDER A PROGRAM'S RUN HOLDS +// (programhold.go), before a copy is cut from it. +// +// A program that only answers reads the folder where it is and changes +// nothing, so it is neither readied nor refused, and nil is its folder. +func (a *Agent) readyRunFolder(id uint64, title, sessionDir string, stand taskStand, via *delegate.Delegate) (*ProgramFolder, error) { + if via == nil { + if refusal := standHeldRefusal(stand, a.config.Workspace); refusal != "" { + return nil, errors.New(refusal) + } + return nil, nil + } + if !via.LandsTree() { + return nil, nil + } + return PrepareProgramFolder(ProgramFolderOrder{ + Program: *via, Dir: stand.dir, Title: title, Holder: taskStopName(id, title), + Keep: plandb.TaskDir(sessionDir, strconv.FormatUint(id, 10)), Instead: "say which folder the work is in, as ground", + Place: a.config.Place, SignModel: a.signsGitWork().namedModel(), + }) +} + +// programJoinRefusal is why a hand-off may not join the live run because a +// program is on one side of it, and nil when neither is a program's: a program +// never joins a run, and nothing joins a program's ([Agent.joinOrWait]). +func programJoinRefusal(via *delegate.Delegate, live *beltRun) error { + if via == nil && live.delegate == nil { + return nil + } + where := "in a copy of " + live.ground + if live.folder != nil { + where = "in " + live.ground + } + return errors.New("work is already underway " + where + + "; " + aloneName(via, live.delegate) + " runs alone, so propose it again when that work has ended") +} + +// aloneName is the program a refused join is about: the one asked for, or the +// one already running. +func aloneName(via, running *delegate.Delegate) string { + if via != nil { + return via.Name + } + if running != nil { + return running.Name + } + return "it" +} + +// programName is the name a run's rows carry for the program its worker is +// ([TaskNotice.Program]), and "" for the conversation's own bash worker, which +// is no program at all. +func programName(via *delegate.Delegate) string { + if via == nil { + return "" + } + return strings.TrimSpace(via.Name) +} + // beltRunSpec is what the engine is handed for a run of this conversation: its // seats, its bounds and the copy it works in. // @@ -591,7 +793,333 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { WorkModel: workSeat, PlanModel: planSeat, CompleterFor: func(string) Completer { return a.beltRunCompleter() }, + Serves: a.servesModel, + Conversation: a.runConversation(), + Delegate: run.delegate, + PlainFolder: run.folder != nil && run.folder.Plain(), + Crew: a.delegateCrew(run), + } +} + +// delegateCrew is the conversation's crew as a delegated run hands it to its +// program: the planning seat, the working seat and the light seat, read off the +// same role ladder this conversation's own planner and workers resolve through, +// with each seat's effort taken off, because a program's pool is a list of +// models and an effort is a knob of the request. Zero for a run no program works. +// +// THE PERSON'S CREW IS THE DEFAULT. A program handed an hour of work used to +// route on a list of its own the person never chose, while the crew they set +// sat unread beside it. +func (a *Agent) delegateCrew(run *beltRun) delegate.Crew { + if run.delegate == nil { + return delegate.Crew{} + } + source := roles.Source(a.config.RolesSource) + seat := func(tier roles.Tier) string { + value, _ := roles.TierModel(source, tier) + model, _ := roles.SplitEffort(strings.TrimSpace(value)) + return strings.TrimSpace(model) + } + return delegate.Crew{ + Brain: seat(roles.TierMastermind), Hands: seat(roles.TierWorker), Light: seat(roles.TierLow), + Asked: append([]string(nil), run.asked...), + } +} + +// programClosedSentence is the ending written on a program's run that codeaf +// closed under: the conversation or the engine ended while the program worked. +// It says what happened in plain words, because nobody decided anything and the +// work did not fail on its own. +func programClosedSentence(name string) string { + return "codeaf closed while " + name + " was running" +} + +// programEndedSentence is the ending written on a program's run that codeaf +// closed under AFTER the program had exited: its worker was still settling +// owed receipts, or the run was about to end. The program was not running, so +// the sentence does not say it was; what was not done is the run's ending in +// its folder, which the next codeaf to find the run settles without writing +// to git ([settleOwedProgramFolder]) and says under this line. +func programEndedSentence(name string) string { + return name + " had ended; codeaf closed before it could say where its work is" +} + +// runLimitSentence is the run engine's outcome word for a run a limit its +// person set ended (internal/run's OutcomeLimit, spelled here because this +// package may not reach that one). A program's run that ended on a limit +// carries it as its store's ending when no sentence of the program's own came +// back ([runEndingWords]), and a reopen reads the limit back out of it. +const runLimitSentence = "a limit you set stopped it" + +// endOrphanedProgramRun ends a program's run whose store was left open by a +// process that went away, at the run's last evidence of life, and settles the +// folder it worked in when that process went away before it could finish it +// ([settleOwedProgramFolder]), answering how it left the folder. It ends +// nothing in a store whose run has ended, or whose run no program worked (the +// task's record folder holds no program record, [delegate.ProgramFile]). +// +// THE ENDING IS WRITTEN WHEN THE RUN WAS LAST SEEN, NOT NOW. The process that +// finds the store can be hours later than the one that lost it, and the page +// counts a run's time to its ending ([plandb.Store.FailRootAt] says why). +// +// THE FOLDER IS SETTLED WHATEVER THE STORE SAYS. A run a person stopped, or +// one codeaf closed under, has its store's ending written before its folder is +// finished, so a process that went away in between leaves an ended store over +// a folder still on the program's branch with its last changes uncommitted — +// and those are left uncommitted, because nobody saw the run end +// ([ProgramFolder.settleGone]). +func endOrphanedProgramRun(store *plandb.Store) (ProgramFolderEnd, bool) { + rootID := store.RootID() + root := store.Task(rootID) + if root == nil { + return ProgramFolderEnd{}, false + } + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), rootID) + if record, ok := delegate.ReadProgram(taskDir); ok && !terminalStoreStatus(root.Status) { + endProgramRunClosed(store, record, lastEvidenceOfLife(store, root, taskDir, record)) + } + end, settled := settleOwedProgramFolder(taskDir) + if settled { + _, _ = store.AddNote(rootID, rootID, end.Sentence()) + } + return end, settled +} + +// endProgramRunClosed writes a program's run's ending when codeaf closed under +// it: the run's task failed with the plain sentence at the instant named (zero +// is now), and the same sentence as the task's newest note, which is the line +// its page carries — the store's error is a field no page draws, and a page +// that read `incomplete` with nothing beside it would send a person looking +// for a fault in the work. +// +// A PROGRAM WHOSE RECORD CARRIES ITS EXIT WAS NOT RUNNING. Its worker writes +// the exit before it settles the program's owed receipts, and the run lands +// only after that, so codeaf can close over a program that has already gone: +// that run is ended at the program's exit ([runClockEnd]'s instant), in the +// sentence that says so ([programEndedSentence]), and never in the one that +// claims codeaf closed under a program at work. +func endProgramRunClosed(store *plandb.Store, record delegate.ProgramRecord, at time.Time) { + sentence := programClosedSentence(record.Name) + if !record.EndedAt.IsZero() { + sentence, at = programEndedSentence(record.Name), record.EndedAt + } + if err := store.FailRootAt(sentence, at); err != nil { + return + } + if root := store.Task(store.RootID()); root == nil || root.Error != sentence { + // A run that had already ended keeps its own ending and its own words. + return + } + _, _ = store.AddNote(store.RootID(), store.RootID(), sentence) +} + +// lastEvidenceOfLife is the latest instant a program's run is known to have +// been working: its program's recorded exit when it has one, the end of its +// last model call (or the start of one that never came back), its last charge, +// and the store's own last write to its task. The zero time means none of them +// is known, which the ending reads as now. +func lastEvidenceOfLife(store *plandb.Store, root *plandb.Task, taskDir string, record delegate.ProgramRecord) time.Time { + latest := root.UpdatedAt + later := func(at time.Time) { + if at.After(latest) { + latest = at + } + } + later(record.StartedAt) + later(record.EndedAt) + later(store.LastSpendAt()) + if turns, err := delegate.ReadTurns(taskDir, 0); err == nil { + for _, turn := range turns { + later(turn.Started) + later(turn.Ended) + } + } + return latest +} + +// endInterruptedProgramRun is the restore's half of the same ending: a +// conversation read back from disk whose run row comes back interrupted over a +// program's store that is still open has its run ended there, at the run's +// last evidence of life. It runs once, as the conversation is opened, when the +// process opening it is the only one that holds it (the session file's lock), +// so no run of this conversation can be live anywhere. +// +// WITHOUT IT THE PAGE READ `running` UNTIL THE NEXT HAND-OFF. codeaf closing +// under a program's run left the store open, and a reopened conversation drew +// that run's page as running, offered to stop it, and counted its clock up from +// when it started for as long as the page stayed open. +func (a *Agent) endInterruptedProgramRun() { + if a.config.InTask { + return + } + // READ, NEVER BUILT: an interrupted row exists only where [Agent.recoverTasks] + // read a checkpoint back, and that already built the graph. A conversation + // with none is not given one by being opened. + g := a.tasker() + if g == nil || !g.holdsInterruptedRun() { + return + } + path := g.planPath() + if path == "" { + return + } + if info, err := os.Stat(path); err != nil || info.IsDir() { + return + } + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + return + } + defer store.Close() + row, err := strconv.ParseUint(store.RootID(), 10, 64) + if err != nil { + return + } + kept, found := runRowOf(g, row) + if !found || kept.State != TaskInterrupted { + return + } + end, settled := endOrphanedProgramRun(store) + if !settled { + // A FOLDER ENDED BY A PROCESS THAT DID NOT LIVE TO SETTLE THE ROW is read + // back from the run's record folder ([keptProgramFolderEnd]), and its + // page is told once where the work is. + taskDir := plandb.TaskDir(filepath.Dir(store.Path()), store.RootID()) + if end, settled = keptProgramFolderEnd(taskDir); settled && !storeSays(store, end.Sentence()) { + _, _ = store.AddNote(store.RootID(), store.RootID(), end.Sentence()) + } } + a.settleInterruptedProgramRow(g, store, kept, end, settled) +} + +// storeSays is whether a note on the store's root already says sentence. +func storeSays(store *plandb.Store, sentence string) bool { + for _, note := range store.Notes(store.RootID(), 0) { + if strings.Contains(note.Body, sentence) { + return true + } + } + return false +} + +// settleInterruptedProgramRow settles the row a reopen restored as interrupted +// once its program's run has ended in its store, whether this reopen ended it +// or the closing did first ([Agent.cutBeltRun]). +// +// A PROGRAM'S RUN IS ONE NOTHING CAN CARRY ON, so a row left interrupted — which +// the side list draws as waiting on a person — says something the page does +// not: the page reads it ended, in codeaf's sentence, with its time stopped. +// The row now says the same, not as a fault, ending where the store ended it. +// +// A RUN WHOSE TASK THE STORE CALLS DONE SETTLES DONE. Its program finished and +// codeaf closed before the row was published; the row used to be left +// interrupted, on the reading that the work was never landed and that was a +// person's call — but a program's work is never landed by anybody, it is left +// on its branch, and this reopen settles the folder too. So the row reads done, +// with the program's result and where the work is. +// +// THE ROW ENDS AT THE PROGRAM'S RECORDED EXIT when the record carries one, and +// at the store's ending otherwise ([runClockEnd]) — the pair every live settle +// reads ([Agent.beltRunEndedAt]). The store's ending can come after the exit +// by the whole wait for owed receipts, and that wait is not the run's time. +// +// AND IT SAYS WHERE THE WORK IS when this reopen settled the run's folder +// (settled): the folder's sentence under the ending, and the program's branch +// when it holds the work. +func (a *Agent) settleInterruptedProgramRow(g *TaskGraph, store *plandb.Store, kept TaskNotice, end ProgramFolderEnd, settled bool) { + root := store.Task(store.RootID()) + if root == nil || (root.Status != plandb.StatusFailed && root.Status != plandb.StatusCancelled && root.Status != plandb.StatusDone) { + return + } + record, ok := delegate.ReadProgram(plandb.TaskDir(filepath.Dir(store.Path()), store.RootID())) + if !ok { + return + } + row := kept + // AND WHAT IT CAME TO, read off the store's spend rows: the process that + // knew the run's total is gone ([Agent.publishRunRow] carries it live). + if row.CostUSD == 0 { + row.CostUSD = storeSpent(store) + } + if root.Status == plandb.StatusDone { + row.State, row.Ending, row.Stopped = TaskDone, "", false + row.Result = strings.TrimSpace(root.Result) + row.Report = row.Result + } else { + row.State = TaskFailed + row.Report, row.Ending, row.Stopped = interruptedProgramEnding(store, root, record) + } + row.EndedAt = runClockEnd(kept.StartedAt, record, root.CompletedAt) + row.Elapsed = 0 + if settled { + row.Report = strings.TrimSpace(row.Report + "\n" + end.Sentence()) + row.Changed = end.Changed + if end.Kept { + row.Branch, row.Merge = end.Folder.Branch, mergeKept + } + } + a.publishRunRow(g, row) +} + +// interruptedProgramEnding is how a program's run that a reopen settles ended, +// read off what its store and its record kept: the sentence the row carries, +// its ending, and whether a person stopped it. +// +// EACH ENDING IS THE ONE THE LIVE SETTLE WOULD HAVE DRAWN, because the row is +// the same row whichever process settles it. A CANCELLED ROOT IS A PERSON'S +// STOP (the stop road writes it before the program is ended, and a person who +// quits during that wait has still stopped it). THE LIMIT SENTENCE IS THE +// LIMIT, and which one is a fact of the run: the dollar ceiling it handed its +// program was reached, or else its time ran out. codeaf's own sentences, and +// every sentence of the program's, are read as the program's ending, whose +// reason is the sentence itself — so the side list reads `codeaf closed while +// senior-dev was running`, not the fixed words of a cut it cannot explain. +func interruptedProgramEnding(store *plandb.Store, root *plandb.Task, record delegate.ProgramRecord) (string, TaskEnding, bool) { + report := strings.TrimSpace(root.Error) + switch { + case root.Status == plandb.StatusCancelled: + return report, TaskEndingStopped, true + case report == runLimitSentence: + return report, interruptedLimitEnding(store, record), false + case report == "": + return programClosedSentence(record.Name), TaskEndingProgram, false + } + return report, TaskEndingProgram, false +} + +// storeSpent is every dollar a run's store holds spend rows for. +func storeSpent(store *plandb.Store) float64 { + spent := 0.0 + for _, total := range store.SpendSummary().ByRole { + spent += total.USD + } + return spent +} + +// interruptedLimitEnding is which limit ended a program's run, off the run's +// own facts: its spend against the dollar ceiling the run handed its program +// (the run's own ceiling, [delegate.ProgramRecord.CeilingUSD]) says the +// dollars ran out, and any other limit ending is the run's time. +func interruptedLimitEnding(store *plandb.Store, record delegate.ProgramRecord) TaskEnding { + if spent := storeSpent(store); record.CeilingUSD > 0 && spent >= record.CeilingUSD { + return TaskEndingCostLimit + } + return TaskEndingTimeLimit +} + +// holdsInterruptedRun says whether any run row this graph holds came back +// interrupted, so a conversation with none never opens its store to ask. +func (g *TaskGraph) holdsInterruptedRun() bool { + g.mu.Lock() + defer g.mu.Unlock() + for _, rows := range g.runs { + for _, row := range rows { + if row.State == TaskInterrupted { + return true + } + } + } + return false } // setBeltRunMachineHold keeps the rail's word tied to actual refused starts. @@ -705,11 +1233,17 @@ var errRunStoreGone = errors.New("this run's plan is no longer the conversation' // in flight that nothing will ever move. The word is the one its row already // wears ([TaskInterrupted]): nothing decided anything about the work, and every // step it took is kept. A store whose run had ended is moved as it ended. +// +// A PROGRAM'S RUN LEFT OPEN IS ENDED IN ITS OWN WORDS FIRST, at its last evidence +// of life ([endOrphanedProgramRun]): `codeaf closed while senior-dev was +// running`, never the bare word, because the program's page reads its ending +// and a program's run is never carried on. func setAsideRunStore(path string) error { existing, err := plandb.Open(path, "", "", "", "") if err != nil { return err } + _, _ = endOrphanedProgramRun(existing) if root := existing.Task(existing.RootID()); root != nil && !terminalStoreStatus(root.Status) { if err := existing.EndRoot(taskWordInterrupted); err != nil { _ = existing.Close() @@ -750,6 +1284,21 @@ func (a *Agent) installBeltRun(g *TaskGraph, run *beltRun) { // keeps that from depending on each of them remembering. A notice that names a // copy of its own wins, because it is the more recent reading. // +// THE PROGRAM IS CARRIED ACROSS HERE TOO, for the same reason and on its own +// test: only the first publish knows which program has the work +// ([TaskNotice.Program]), and a stop, a landing or a carry-on that published +// without it would take the program's badge off its row halfway through its +// life. A row that never had one — the conversation's own worker's — has +// nothing to carry. +// +// AND A ROW THAT HAS ENDED CARRIES HOW LONG IT RAN, worked out here from the +// one pair it carries ([runSpan]) so that no publisher can put a different +// figure beside the same two instants: the rail's clock, the card's span and +// the checkpoint's elapsed_ms all read it. Every row also reaches the project's +// index and, while it runs, this conversation's presence ([Agent.indexRunRow]), +// which is how the `@` list, another window and another conversation's tasks +// tool know the run is there at all. +// // AND THE STORE TASK IS CARRIED THE SAME WAY, for the same reason: which task // of the plan this row IS was settled when the row was minted and is true for // its whole life, so a settle or a stop that publishes a fresh notice must not @@ -771,8 +1320,36 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { break } } + notice.Program = keptRunProgram(g, notice) + if notice.Elapsed == 0 { + notice.Elapsed = runSpan(notice.StartedAt, notice.EndedAt) + } + // A SETTLED RUN'S OWN ROW SAYS WHAT IT CAME TO, the figure its index row + // carries ([Agent.beltRunSpent]), so the landed card and every page drawn + // from the row show the price. No book is summed from rows: the conversation's + // total comes from the calls themselves (task_run_money.go), so this is a + // label and never a second charge. + if notice.State.settled() && notice.CostUSD == 0 { + notice.CostUSD = a.beltRunSpent(notice.ID) + } a.emitTaskUpdate(notice) g.keepRunRows(notice.ID, []TaskNotice{notice}) + a.indexRunRow(notice) +} + +// keptRunProgram is the program a run row names: its own when it names one, +// and otherwise the one the row it replaces was published with +// ([Agent.publishRunRow] says why it is carried). +func keptRunProgram(g *TaskGraph, notice TaskNotice) string { + if notice.Program != "" { + return notice.Program + } + for _, kept := range g.runRows(notice.ID) { + if kept.ID == notice.ID && kept.Program != "" { + return kept.Program + } + } + return "" } // cutBeltRun ends the live run because the CONVERSATION is ending. It is what @@ -781,9 +1358,9 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { // // IT IS NOT A PERSON'S STOP AND MUST NOT BE MISTAKEN FOR ONE. A stop writes the // person's reason on the store's root and settles the row in their words -// (stoprun.go); this writes nothing and says nothing, because nobody asked for -// anything — the room simply closed. What the run did is in its store, which is -// where the next launch reads it from. +// (stoprun.go); this says nothing in the conversation, because nobody asked for +// anything — the room simply closed. What an ordinary run did is in its store, +// which is where the next launch reads it from, and its root is left open. // // AND THE RUN'S DRIVER IS TOLD SO BEFORE THE CONTEXT IS CUT ([beltRun.closing]), // because what a cut context means is otherwise ambiguous to it: the engine @@ -792,14 +1369,37 @@ func (a *Agent) publishRunRow(g *TaskGraph, notice TaskNotice) { // the row `failed` after the conversation had gone. The record then disagreed // with itself: the row the surface was sent said failed, and the row read back // tomorrow said interrupted. +// +// A PROGRAM'S RUN IS ENDED IN ITS STORE FIRST, THEN CUT, the order a stop takes. +// Nothing can carry a program's run on, and the ending the run writes for itself +// comes only after the engine has answered, which on an engine being shut down +// (a signal, `codeaf engine --stop`) is after the process has gone: the store +// then said `running` for ever, and the next hand-off ran inside it. Written +// here, the ending is on disk before anything is cut. WAITING instead — holding +// Close until the run had written its own ending — was the other road, and it +// is the weaker one: it holds a person's quit for the program's grace and the +// landing behind it, and a process killed during that wait writes nothing at +// all. A crash writes nothing either way; that store is ended by the next +// process to find it ([endOrphanedProgramRun]). func (a *Agent) cutBeltRun() { a.beltMu.Lock() + run := a.beltRun var cut context.CancelFunc - if a.beltRun != nil { - a.beltRun.closing = true - cut = a.beltRun.cut + stopped := false + if run != nil { + run.closing = true + cut, stopped = run.cut, run.stopped } a.beltMu.Unlock() + if run != nil && run.delegate != nil && !stopped { + // A run a person already stopped keeps the stop's ending and its words. + // A program that has not written its record yet is named by the run. + record := beltRunProgram(run) + if record.Name == "" { + record.Name = run.delegate.Name + } + endProgramRunClosed(run.store, record, time.Time{}) + } if cut != nil { cut() } @@ -811,30 +1411,38 @@ func (a *Agent) cutBeltRun() { // the row the run was published under settles. The store is closed and the run // cleared once the work is home, so the next `/task` seeds a fresh plan. func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun, spec RunSpec) { - var foldedUSD float64 - foldSpend := func(total float64) { - if total <= foldedUSD { - return - } - delta := total - foldedUSD - a.addFoldedUsage(&ai.Response{Usage: &ai.Usage{Cost: &delta}}, "", 0) - foldedUSD = total - } - spec.OnSpend = foldSpend + // THE RUN'S MONEY REACHES THE CONVERSATION'S BOOKS THROUGH ONE FOLD + // (task_run_money.go): each call whole as a program's model API meters it, + // and whatever the run's running total holds beyond those — a bash + // worker's spend, which arrives only as a total. + fold := &beltFold{agent: a} + spec.OnSpend = fold.total + spec.OnCharge = fold.charge summary := engine.Start(ctx, spec) - // THE RUN IS ON ITS WAY OUT FROM THE MOMENT ITS ENGINE ANSWERS. Nothing will - // run work added to its store after this line, so a hand-off arriving now - // waits for the run to be over instead of joining it ([Agent.joinOrWait]). - // The run is cleared off the Agent and its waiters released on every road - // out of here, which is what the deferred release says once. + // THE RUN'S WORK IS OVER THE MOMENT THE ENGINE ANSWERS, and that instant is + // taken now, before the landing, the summary refresh and the note — which + // can take a quarter of a minute between them and are not the work. + // + // AND THE RUN IS ON ITS WAY OUT FROM THAT MOMENT. Nothing will run work added + // to its store after this line, so a hand-off arriving now waits for the run + // to be over instead of joining it ([Agent.joinOrWait]). The run is cleared + // off the Agent and its waiters released on every road out of here, which is + // what the deferred release says once. a.beltMu.Lock() + run.ended, run.spent = a.taskClockNow(), summary.USD run.ending = true closing := run.closing a.beltMu.Unlock() defer a.releaseBeltRun(run) // The final receipt closes any gap between the last live reading and every // ending, before the person-stop road and the ordinary landing road split. - foldSpend(summary.USD) + fold.total(summary.USD) + // AND meta.json IS TOLD NOW, not at the next turn's seal. Home reads this + // conversation's bill as the larger of its stamped books and its index rows + // (tui3's homeFacts), which is exact only while the books on disk already + // hold every run the index names. A stamp that waited for the next turn left + // the card reading the run alone, with the conversation's own talking missing. + a.stampSpend() if run.cut != nil { defer run.cut() } @@ -844,7 +1452,7 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun a.settleStoppedBeltRun(run, why, summary.Cut) return } - if closing && summary.Outcome != beltRunOutcomeDone { + if closing && run.delegate == nil && summary.Outcome != beltRunOutcomeDone { // THE CONVERSATION CLOSED UNDER THE RUN, AND THAT IS NOBODY'S ENDING. The // run is not landed, its row is not settled and nothing is written on its // record: it is work nothing is driving any more, every step of it is in @@ -852,16 +1460,41 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun // it ([TaskInterrupted]). Landing it here put the work into the folder of // a person who had closed the window on it, and settling the row said // `failed` about work that had not failed. + // + // A PROGRAM'S RUN IS THE EXCEPTION: [Agent.cutBeltRun] already wrote its + // ending, nothing can carry it on, and its folder is finished on its own + // branch below — never merged into the person's. return } - // EVERY OTHER ENDING IS WRITTEN ON THE RUN'S OWN TASK. The engine writes the - // ending of a limit or a failed root worker itself; this is the same write - // made again from the door, which the store takes once and ignores after, so - // no engine can leave a run the next hand-off would find still open. - if summary.Outcome != beltRunOutcomeDone { - _ = run.store.EndRoot(summary.Outcome) + var landing RunLanding + if run.delegate != nil { + // A PROGRAM'S RUN IS OVER WHEN ITS PROGRAM IS, however it ended: it is + // a run of one task that nothing continues, so a store the engine left + // open — a program ended at a limit leaves it so — is closed here, or its + // page would read `running` and offer `stop it` for ever. A run that + // already ended is left as it ended. + // + // IT IS CLOSED BEFORE THE LANDING, NOT AFTER IT. The folder's last + // commit takes its time, and a page that went on reading `running` over + // a program that had already exited was a page claiming a present that + // was over — for the two limit endings alone, because every other ending + // is written by the engine at the program's exit. + if summary.Outcome != beltRunOutcomeDone { + words, _ := runEndingWords(summary) + _ = run.store.FailRoot(words) + } + landing = a.landDelegateRun(run, summary) + } else { + // EVERY OTHER ENDING IS WRITTEN ON THE RUN'S OWN TASK. The engine writes + // the ending of a limit or a failed root worker itself; this is the same + // write made again from the door, which the store takes once and ignores + // after, so no engine can leave a run the next hand-off would find still + // open. + if summary.Outcome != beltRunOutcomeDone { + _ = run.store.EndRoot(summary.Outcome) + } + landing = a.landBeltRun(ctx, engine, run) } - landing := a.landBeltRun(ctx, engine, run) // A LANDING GETS ONE LAST READING before its digest is composed. The call // owns the short beltRunSummaryDeadline: refusal, malformed output, or a // slow provider leaves the stored reading alone and cannot hold the run @@ -869,7 +1502,11 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun refreshCtx, cancelRefresh := context.WithTimeout(ctx, beltRunSummaryDeadline) a.RefreshRunSummary(refreshCtx, run.root, time.Time{}) cancelRefresh() - if _, err := run.store.AddNote(run.root, run.root, beltRunOutcomeNote(run.store, run.root, summary, landing)); err != nil { + note := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) + if run.delegate != nil { + note = programPageNote(programName(run.delegate), landing) + } + if _, err := run.store.AddNote(run.root, run.root, note); err != nil { if g := a.graph(); g != nil { g.planNote("the run's outcome note failed: " + err.Error()) } @@ -922,6 +1559,14 @@ func (a *Agent) landBeltRun(ctx context.Context, engine RunEngine, run *beltRun) } return RunLanding{} } + return a.bringBeltRunHome(run, landing) +} + +// bringBeltRunHome is the second half of a run's landing: the copy's branch +// merged into the ground it was cut from, the person's unfinished work carried +// across or the branch kept and the files named, the copy given back, and the +// homecoming written on the run's page. +func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { if run.tree.dir == "" { return landing } @@ -956,9 +1601,14 @@ func (a *Agent) landBeltRun(ctx context.Context, engine RunEngine, run *beltRun) } // deliverBeltRunLanding writes the run's digest into the conversation record. -// A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED. +// A LANDING SPEAKS ONLY WHEN AN ANSWER IS OWED — or when a program ended it, +// whose ending is always the conversation's to act on (program_outcome.go). func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { - line := beltRunOutcomeNote(run.store, run.root, summary, landing) + line := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) + if run.delegate != nil { + a.accept(delivery{origin: fromRuntime, kind: msgResult, note: a.programLandingNote(run, summary, line)}) + return + } if task := run.store.Task(run.root); landingOwesAnswer(task) { document := owedLandingDocument(task, line) note := wakeNote(document.text()) @@ -1006,7 +1656,7 @@ func owedLandingTier() roles.Tier { return roles.TierLow } // a surface draws. func (a *Agent) settleBeltRun(run *beltRun, summary RunSummary, landing RunLanding) { notice := a.beltRunNotice(run, summary, landing) - notice.EndedAt = a.taskClockNow() + notice.EndedAt = a.beltRunEndedAt(run) g := a.graph() if g == nil { a.emitTaskUpdate(notice) @@ -1100,9 +1750,16 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi if summary.Outcome != beltRunOutcomeDone { state = TaskFailed } - report := strings.TrimSpace(summary.Result) - if report == "" && summary.Outcome != beltRunOutcomeDone { - report = strings.TrimSpace(summary.Outcome) + outcome, result := runEndingWords(summary) + report := result + if summary.Outcome != beltRunOutcomeDone { + if summary.Program != nil { + // THE PROGRAM'S OWN SENTENCE LEADS, and its account follows: the + // reason line a surface draws is the report's first line. + report = strings.TrimSpace(outcome + "\n" + result) + } else if report == "" { + report = outcome + } } if line := beltLandingLine(landing); line != "" { if report != "" { @@ -1117,7 +1774,7 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi // ([TaskReasonOf]): the outcome word alone says only that one of them // fired. The ending comes from the summary's own fact and never out of // the outcome sentence. - Ending: beltRunLimitEnding(summary.Limit), + Ending: beltRunEnding(summary), Report: report, Result: summary.Result, Changed: landing.Changed, } @@ -1134,6 +1791,34 @@ func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLandi return notice } +// beltRunEnding is the run row's ending: a limit its person set, or how the +// program a delegated run was handed to ended it — a crash is the fault it is, +// and every other ending of the program's own is [TaskEndingProgram], whose +// reason is the program's sentence. Empty for every other run. +func beltRunEnding(summary RunSummary) TaskEnding { + if ending := beltRunLimitEnding(summary.Limit); ending != "" { + return ending + } + if ended := summary.Program; ended != nil && summary.Outcome != beltRunOutcomeDone { + if ended.Status == delegate.StatusCrashed { + return TaskEndingError + } + return TaskEndingProgram + } + return "" +} + +// runEndingWords is a run's ending in the two parts every drawing of it reads: +// the one sentence, and the account under it. A program that ended its run +// unfinished speaks for itself; every other run answers the engine's outcome +// word and the root's result. +func runEndingWords(summary RunSummary) (string, string) { + if ended := summary.Program; ended != nil && summary.Outcome != beltRunOutcomeDone { + return strings.TrimSpace(ended.Reason), strings.TrimSpace(ended.Result) + } + return summary.Outcome, strings.TrimSpace(summary.Result) +} + // beltRunLimitEnding is the run row's ending for a limit its person set, off // the summary's own fact. Empty, which no reading knows as an ending, is the answer for // every run that did not end on a bound, which is the reading those runs always @@ -1149,12 +1834,23 @@ func beltRunLimitEnding(limit RunLimit) TaskEnding { } // beltRunOutcomeNote is the one line a run's own page carries about how it -// ended: the engine's outcome word and where the work went, or the sentence that -// says why it did not. The last stored run reading supplies its Now sentence; -// without one this remains the landing digest that predates run summaries. -func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, landing RunLanding) string { - parts := []string{summary.Outcome} - if result := strings.TrimSpace(summary.Result); result != "" { +// ended: the engine's outcome word, how long the run took, and where the work +// went, or the sentence that says why it did not. The last stored run reading +// supplies its Now sentence; without one this remains the landing digest that +// predates run summaries. +// +// THE TIME IS THE RUN'S ONE PAIR ([Agent.beltRunSpan]), said as `ran 22m 51s` +// in the page's own spelling ([runSpanWord]) and said not at all under a +// second. The same line is what the conversation is handed when the run lands, +// and a conversation told only that a run was done could not say how long it +// had taken when it was asked. +func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, landing RunLanding, span time.Duration) string { + outcome, result := runEndingWords(summary) + parts := []string{outcome} + if ran := runSpanWord(span); ran != "" { + parts = append(parts, "ran "+ran) + } + if result != "" { parts = append(parts, result) } if line := beltLandingLine(landing); line != "" { @@ -1171,18 +1867,33 @@ func beltRunOutcomeNote(store *plandb.Store, rootID string, summary RunSummary, // beltLandingLine is what a landing is in one line: where the work went and how // much of it, or the refusal that says why it did not. It is empty only when // there is nothing to say — a landing with no branch and no refusal. +// +// A PROGRAM'S LANDING SAYS ITSELF ([RunLanding.Line]): where its work is, that +// its branch is checked out in the person's folder, and the two commands that +// go back to their own branch and bring the work in. This line is the one +// account of a landing the conversation's model is given, and a model told +// only `landed on task/x: 2 files` would tell the person a thing about their +// folder that nobody checked. func beltLandingLine(landing RunLanding) string { + if landing.Line != "" { + return landing.Line + } if landing.Refused != "" { return landing.Refused } if landing.Branch == "" { return "" } - files := "files" - if len(landing.Changed) == 1 { - files = "file" + return fmt.Sprintf("landed on %s: %s", landing.Branch, fileCount(len(landing.Changed))) +} + +// fileCount is a count of files in words, `1 file` and `2 files`, so every +// landing line that counts them counts them the same way. +func fileCount(n int) string { + if n == 1 { + return "1 file" } - return fmt.Sprintf("landed on %s: %d %s", landing.Branch, len(landing.Changed), files) + return strconv.Itoa(n) + " files" } func (a *Agent) missingRunDependencies(ids []uint64) []uint64 { diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index 62d6e2bcb8..53d48332de 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -63,6 +63,9 @@ type beltRunDouble struct { // for a run that did not finish. honoursStop bool early func(workspace string) + // leaveOpen makes the double end the way the real engine ends a run a + // limit or a program's own ending took down: its store's root left open. + leaveOpen bool } func newBeltRunDouble(result string) *beltRunDouble { @@ -115,7 +118,7 @@ func (d *beltRunDouble) Start(ctx context.Context, spec RunSpec) RunSummary { if d.work != nil { d.work(spec.Workspace) } - if spec.Store != nil { + if spec.Store != nil && !d.leaveOpen { _ = spec.Store.CompleteRoot(d.summary.Result) } close(d.finished) @@ -345,7 +348,7 @@ func TestStartTaskBashBeltStartsARunOnTheStore(t *testing.T) { if !anyNoteCarries(beltRunNotes(t, dir, rootID), "landed on "+home.Branch) { t.Fatalf("no note on the root carries the branch: %v", beltRunNotes(t, dir, rootID)) } - wantDigest := beltRunOutcomeNote(nil, "", double.summary, home) + wantDigest := beltRunOutcomeNote(nil, "", double.summary, home, 0) if !strings.Contains(wantDigest, "done") || !strings.Contains(wantDigest, "the run fixed the nil map") || !strings.Contains(wantDigest, "landed on "+home.Branch) { t.Fatalf("digest = %q, want outcome, root result, and work destination", wantDigest) @@ -636,7 +639,7 @@ func TestLandingDigestCarriesTheStoredNowSentence(t *testing.T) { got := beltRunOutcomeNote(store, planRootID, RunSummary{Outcome: beltRunOutcomeDone}, RunLanding{ Branch: "task/landing-digest", Changed: []string{"internal/session/task_run_belt.go"}, - }) + }, 0) want := "done · landed on task/landing-digest: 1 file · The focused landing tests pass." if got != want { t.Fatalf("landing digest = %q, want %q", got, want) @@ -651,7 +654,7 @@ func TestLandingDigestIsUnchangedWithoutAStoredSummary(t *testing.T) { defer store.Close() got := beltRunOutcomeNote(store, planRootID, RunSummary{Outcome: beltRunOutcomeDone}, RunLanding{ Branch: "task/landing-digest", Changed: []string{"internal/session/task_run_belt.go"}, - }) + }, 0) want := "done · landed on task/landing-digest: 1 file" if got != want { t.Fatalf("landing digest = %q, want byte-for-byte legacy digest %q", got, want) diff --git a/internal/session/task_run_clock.go b/internal/session/task_run_clock.go new file mode 100644 index 0000000000..0eb70939d4 --- /dev/null +++ b/internal/session/task_run_clock.go @@ -0,0 +1,290 @@ +package session + +// A RUN'S WALL TIME IS ONE PAIR OF INSTANTS, and this file is where the pair is +// decided, so that no surface decides it again. +// +// A hand-off's run is measured from the HAND-OFF — the instant its row is born +// and first reads running ([beltRun.born], the first notice's StartedAt) — to +// the instant the program it handed its task to was gone +// ([delegate.ProgramRecord.EndedAt], stamped by the run's worker); for a run no +// program worked, or whose program never recorded an exit, to the instant the +// run's engine answered ([beltRun.ended]). Its elapsed time is the one minus the +// other ([runSpan]). +// +// EVERY SURFACE READS THAT PAIR, and it used to read four. The task page counted +// from the store's seeding, which is before the copy is cut (sixteen seconds on +// a real run), to whichever moment each kind of ending happened to write the +// store; the row and the card counted to the row's settling, which is after the +// landing and the summary refresh (up to a quarter of a minute more); the tasks +// tool and the landing note said no time at all. The same run read `22m 51s` on +// its page and `22m44s` on its card. Now the settle notice (and so the rail, the +// card and the checkpoint), the page's row ([planRunClocks]), the project index, +// the tasks tool and the landing note all carry the one pair. The program's own +// spawn stays on its record beside it, as a fact of the record, and nothing +// counts from it. + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// runClockEnd is a run's ending instant: the instant its program's process was +// gone when the program record carries one, and ending otherwise. A recorded +// exit that comes before the run's own start is not this run's — two clocks +// that disagree about the order of events are not two readings of one pair — and +// falls back to ending the same way a missing one does. +func runClockEnd(started time.Time, record delegate.ProgramRecord, ending time.Time) time.Time { + if exited := record.EndedAt; !exited.IsZero() && (started.IsZero() || !exited.Before(started)) { + return exited + } + return ending +} + +// runSpan is the elapsed time between a pair, and zero for a pair that is not +// one — an end missing or before its start — which every surface draws as no +// time at all (the emptiness law). +func runSpan(started, ended time.Time) time.Duration { + if started.IsZero() || ended.IsZero() || ended.Before(started) { + return 0 + } + return ended.Sub(started) +} + +// beltRunProgram is the program record a run's worker wrote in the run's own +// task folder, and the zero record for a run no program worked, a run whose +// program has not been started, and a run with no store to hold a record. +func beltRunProgram(run *beltRun) delegate.ProgramRecord { + if run == nil || run.delegate == nil || run.store == nil { + return delegate.ProgramRecord{} + } + record, _ := delegate.ReadProgram(plandb.TaskDir(filepath.Dir(run.store.Path()), run.root)) + return record +} + +// beltRunEndedAt is the instant a run's row settles at: the end of the run's +// one pair ([runClockEnd]), off the program record and the instant the engine +// answered. A run whose engine has not been heard from — a row settled by a +// road that never drove one — ends now, which is the reading it always had. +// +// IT IS NEVER THE INSTANT THE ROW SETTLES. The row used to be stamped when it +// was published, which is after the landing's commit, the homecoming and a +// summary refresh that may wait six seconds for a model: none of that is the +// run's work, and all of it was counted as though it were. +func (a *Agent) beltRunEndedAt(run *beltRun) time.Time { + a.beltMu.Lock() + ending := run.ended + a.beltMu.Unlock() + if ending.IsZero() { + ending = a.taskClockNow() + } + return runClockEnd(run.born, beltRunProgram(run), ending) +} + +// beltRunSpan is how long a run took, off its one pair: the hand-off to the end +// [Agent.beltRunEndedAt] answers. +func (a *Agent) beltRunSpan(run *beltRun) time.Duration { + return runSpan(run.born, a.beltRunEndedAt(run)) +} + +// runSpanWord spells a finished span EXACTLY AS THE TASK PAGE DOES (internal/ +// tui3's countUpWord): seconds under a minute, then minutes and seconds, then +// hours and minutes, with a second rung of zero dropped — `22m 51s`, `1h 7m`, +// `5m` — and nothing at all under a second. It is restated here because the +// session cannot import a surface, and a run's time spelled one way on its page +// and another in the tasks tool or the landing note is two vocabularies for one +// fact. [taskSpanWord] keeps its own older spelling for the other rows it has +// always drawn. +func runSpanWord(d time.Duration) string { + if d < time.Second { + return "" + } + // ROUNDED TO THE SECOND, as the page and the card round it (tui3's + // taskNode.ranFor): the same 61.5-second run read `ran 1m 1s` in the note + // the chat was handed and `1m 2s` on the page it was reading about. + d = d.Round(time.Second) + rungs := func(big int, bigUnit string, small int, smallUnit string) string { + out := strconv.Itoa(big) + bigUnit + if small == 0 { + return out + } + return out + " " + strconv.Itoa(small) + smallUnit + } + switch { + case d < time.Minute: + return strconv.Itoa(int(d/time.Second)) + "s" + case d < time.Hour: + return rungs(int(d/time.Minute), "m", int(d%time.Minute/time.Second), "s") + default: + return rungs(int(d/time.Hour), "h", int(d%time.Hour/time.Minute), "m") + } +} + +// ── the page's row reads the same pair ────────────────────────────────────── + +// planRunClocks is what a listing needs to put a program's run on the one pair: +// the run rows this conversation keeps, by the store id each run's root task +// carries, and the root the live run in this process holds. It is read once +// for a whole listing, the way [Agent.planCarriedPrograms] is read beside it. +type planRunClocks struct { + rows map[string]TaskNotice + live string +} + +// planRunClocks reads the conversation's run rows and its live run. It builds +// no graph: a conversation that never handed work off has no rows to read. +func (a *Agent) planRunClocks() planRunClocks { + var clocks planRunClocks + a.beltMu.Lock() + if a.beltRun != nil { + clocks.live = a.beltRun.root + } + a.beltMu.Unlock() + g := a.tasker() + if g == nil { + return clocks + } + g.mu.Lock() + defer g.mu.Unlock() + for _, notice := range g.runRowsLocked() { + if notice.Run != "" || notice.Kind == TaskKindJob { + continue + } + if clocks.rows == nil { + clocks.rows = make(map[string]TaskNotice) + } + clocks.rows[strconv.FormatUint(notice.ID, 10)] = notice + } + return clocks +} + +// apply puts a PROGRAM's row on the run's one pair. Every other row keeps the +// store's own pair, which is what an ordinary task's page has always counted. +// +// THE ROW'S START IS THE HAND-OFF: the run row's StartedAt, and for a run whose +// row this conversation never kept, the program's own spawn off its record. Its +// end is the run row's EndedAt once it has settled, and before that the +// program's recorded exit — so a page read in the seconds between the program +// exiting and the row settling already stops its clock where the row will. A +// record from before the program's clock was written leaves the store's pair +// standing, because a guess at a better pair is worse than the pair it has. +// +// AND A PROGRAM'S RUN NOTHING HERE IS DRIVING IS NOT RUNNING. A store's root +// left open — codeaf closed or crashed while the program ran, and nothing wrote +// its ending — read `running` on its page for ever, offered a stop for a +// process that was long gone, and counted its clock up without bound (a page +// read `1h 10m` over a run of twenty-nine minutes, and `11h` the next morning). +// Such a row now reads `failed`, the store's own word that every surface draws +// as incomplete, ended at the run's last sign of life ([planLastActivity]), +// with no live step and no stage. It is asked of the store's ROOT alone, which +// is the one task a program's run hands its program; root is that task's id. +func (c planRunClocks) apply(row *PlanTaskRow, dir string, task *plandb.Task, root string) { + if row == nil || task == nil || row.Program == "" { + return + } + c.pair(row, dir, task) + if terminalStoreStatus(task.Status) { + planEndedRow(row, task) + return + } + if task.ID != root || c.live == task.ID { + return + } + planUndrivenRow(row, dir, task) +} + +// pair puts the run's one pair on the row: the hand-off, and the run row's +// settled end or, before it settles, the program's recorded exit. +func (c planRunClocks) pair(row *PlanTaskRow, dir string, task *plandb.Task) { + record, _ := delegate.ReadProgram(plandb.TaskDir(dir, task.ID)) + kept := c.rows[task.ID] + started := kept.StartedAt + if started.IsZero() { + started = record.StartedAt + } + if started.IsZero() { + return + } + row.Started = started + row.Ended = kept.EndedAt + if row.Ended.IsZero() { + row.Ended = runClockEnd(started, record, time.Time{}) + } +} + +// planEndedRow is a program's row the store has ended. +// +// A TASK THE STORE HAS ENDED IS IN NO STAGE, AND ITS CLOCK HAS STOPPED. +// Closing a conversation writes its program's ending before it cuts the +// program, and a program cut that way never lives to clear the stage it was +// in, so the page read `working` with no time over a run nothing was driving +// (found by killing the engine under a real senior-dev run). A live step on an +// ended task is left over, and when neither the row nor the program's record +// says when the run ended, the store's own ending does. +func planEndedRow(row *PlanTaskRow, task *plandb.Task) { + row.Live, row.Stage = plandb.LiveStep{}, "" + if !row.Started.IsZero() && row.Ended.IsZero() && !task.CompletedAt.Before(row.Started) { + row.Ended = task.CompletedAt + } +} + +// planUndrivenRow is a program's root the store still calls open while no run +// in this process holds it: ended at its last sign of life, with no stage. +func planUndrivenRow(row *PlanTaskRow, dir string, task *plandb.Task) { + row.Status = string(plandb.StatusFailed) + row.Live, row.Stage = plandb.LiveStep{}, "" + if row.Ended.IsZero() { + if last := planLastActivity(dir, task); !row.Started.IsZero() && !last.Before(row.Started) { + row.Ended = last + } + } +} + +// planLastActivity is the last sign of life a task's run left: the latest of +// the store's own last write to the task and the last write to its +// conversation log and its trajectory. It is read only for a run nothing is +// driving, which is the one moment the question is asked, and it reads the +// files' clocks rather than the files. +func planLastActivity(dir string, task *plandb.Task) time.Time { + last := task.UpdatedAt + folder := plandb.TaskDir(dir, task.ID) + for _, name := range []string{delegate.ConversationFile, planTrajectoryFile} { + if info, err := os.Stat(filepath.Join(folder, name)); err == nil && info.ModTime().After(last) { + last = info.ModTime() + } + } + return last +} + +// planRowSpanWord is a program's run's time as the tasks tool says it: +// `ran 22m 51s` for a run that has ended, `running for 3m 2s` for one that is +// running, and nothing for a run with no start. It reads the row's pair, which +// for a program's row is the run's one pair ([planRunClocks.apply]). +// +// EVERY OTHER ROW SAYS NO TIME, as it never has: its pair is the store's own, +// which counts a part from when it was added rather than from when anybody +// started it, and a figure the tool cannot stand behind is left out rather than +// said. +func planRowSpanWord(row PlanTaskRow, now time.Time) string { + if row.Program == "" || row.Started.IsZero() { + return "" + } + if !row.Ended.IsZero() { + if span := runSpanWord(runSpan(row.Started, row.Ended)); span != "" { + return "ran " + span + } + return "" + } + switch strings.TrimSpace(row.Status) { + case string(plandb.StatusClaimed), string(plandb.StatusRunning): + if span := runSpanWord(runSpan(row.Started, now)); span != "" { + return "running for " + span + } + } + return "" +} diff --git a/internal/session/task_run_clock_test.go b/internal/session/task_run_clock_test.go new file mode 100644 index 0000000000..952ae37280 --- /dev/null +++ b/internal/session/task_run_clock_test.go @@ -0,0 +1,515 @@ +package session + +// A run's wall time is one pair of instants (task_run_clock.go): the hand-off, +// and the instant the program it handed its task to was gone — or, for a run +// no program worked, the instant its engine answered. These tests pin the pair +// on every surface this package feeds: the row that settles the run and the +// checkpoint it is kept in, the task page's row, and a run nothing is driving. + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// runSpanWord is the page's own spelling of a finished span, restated here +// because the session cannot import the surface; these are the values the page +// draws for the same durations (internal/tui3's countUpWord). +func TestRunSpanWordSpellsASpanTheWayThePageDoes(t *testing.T) { + for _, tc := range []struct { + d time.Duration + want string + }{ + {0, ""}, + {999 * time.Millisecond, ""}, + {5 * time.Second, "5s"}, + {22*time.Minute + 51*time.Second, "22m 51s"}, + {5 * time.Minute, "5m"}, + {67*time.Minute + 34*time.Second, "1h 7m"}, + {2 * time.Hour, "2h"}, + {61*time.Second + 500*time.Millisecond, "1m 2s"}, + {59*time.Second + 600*time.Millisecond, "1m"}, + } { + if got := runSpanWord(tc.d); got != tc.want { + t.Errorf("runSpanWord(%v) = %q, want %q", tc.d, got, tc.want) + } + } +} + +// A PROGRAM'S RUN IS TIMED FROM THE HAND-OFF TO THE PROGRAM'S EXIT, on its row, +// in the checkpoint a reopened conversation reads, and on its page. The row used +// to settle at the instant it was published — after the landing and a summary +// refresh — and carry no elapsed time at all, and the page counted from the +// store's seeding, before the copy was cut; the same run read three different +// spans on three surfaces. +func TestAProgramsRunIsTimedFromTheHandOffToTheProgramsExit(t *testing.T) { + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + handoff := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + clock := &fakeClock{at: handoff} + agent.taskNow = clock.now + + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + key := strconv.FormatUint(id, 10) + + // WHILE IT RUNS the page counts from the hand-off, not from the store's + // seeding, which happened before the copy was cut. + page, ok := agent.PlanTaskPage(key) + if !ok || page.Row.Status != string(plandb.StatusRunning) || !page.Row.Started.Equal(handoff) || !page.Row.Ended.IsZero() { + t.Fatalf("the live page's row = %+v (%v), want running from the hand-off %v", page.Row, ok, handoff) + } + + // The program exits 22m 51s after the hand-off, and the run's landing and + // settling happen a good while later on the conversation's clock. + exited := handoff.Add(22*time.Minute + 51*time.Second) + record := delegate.ProgramRecord{Name: "fake", StartedAt: handoff.Add(2 * time.Second), EndedAt: exited} + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(spec.Store.Path()), spec.Store.RootID()), record); err != nil { + t.Fatal(err) + } + clock.advance(30 * time.Minute) + endBeltRun(t, agent, double) + + kept := agent.graph().runRows(id) + if len(kept) != 1 || !kept[0].StartedAt.Equal(handoff) || !kept[0].EndedAt.Equal(exited) || kept[0].Elapsed != 22*time.Minute+51*time.Second { + t.Fatalf("the settled row = %+v, want %v → %v and 22m51s", kept, handoff, exited) + } + page, ok = agent.PlanTaskPage(key) + if !ok || !page.Row.Started.Equal(handoff) || !page.Row.Ended.Equal(exited) { + t.Fatalf("the ended page's row = %v → %v (%v), want the row's own pair %v → %v", page.Row.Started, page.Row.Ended, ok, handoff, exited) + } + + // AND A CONVERSATION REOPENED TOMORROW READS THE SAME PAIR AND THE SAME SPAN. + journal := agent.file.journalPath() + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + reopened, err := newAgent(Config{Workspace: agent.config.Workspace, Model: "test/model", System: "SYSTEM", SessionFile: journal}, &scriptedCompleter{}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer reopened.Close() + back := reopened.graph().runRows(id) + if len(back) != 1 || !back[0].EndedAt.Equal(exited) || back[0].Elapsed != 22*time.Minute+51*time.Second { + t.Fatalf("the reopened row = %+v, want it ended at the program's exit with its span", back) + } +} + +// slowLanding is the run engine whose landing takes a minute of the +// conversation's clock, which is what the squash, the merge and the summary +// refresh of a real landing take in the run's time. +type slowLanding struct { + *beltRunDouble + clock *fakeClock +} + +func (s slowLanding) Land(ctx context.Context, store *plandb.Store, workspace, base, root string) (RunLanding, error) { + s.clock.advance(time.Minute) + return s.beltRunDouble.Land(ctx, store, workspace, base, root) +} + +// A RUN NO PROGRAM WORKED ENDS WHERE ITS ENGINE ANSWERED, never where its row +// settled: the landing is not the run's work, and a row stamped after it was a +// run that seemed to go on for as long as its homecoming took. +func TestARunWithNoProgramEndsWhereItsEngineAnswered(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run fixed the nil map") + start := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC) + clock := &fakeClock{at: start} + registerBeltRunEngine(t, slowLanding{beltRunDouble: double, clock: clock}) + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run fixed the nil map"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + }) + agent.taskNow = clock.now + id, _, _, err := agent.StartTask(context.Background(), "fix the nil map crash", false) + if err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + clock.advance(5 * time.Minute) + endBeltRun(t, agent, double) + if double.lands() != 1 { + t.Fatalf("the run landed %d times, want once", double.lands()) + } + kept := agent.graph().runRows(id) + if len(kept) != 1 || !kept[0].EndedAt.Equal(start.Add(5*time.Minute)) || kept[0].Elapsed != 5*time.Minute { + t.Fatalf("the settled row = %+v, want it ended where the engine answered, five minutes in", kept) + } +} + +// A RUN ROW KEEPS HOW IT ENDED AND WHERE ITS WORK IS ACROSS A REOPEN. The saved +// file dropped the ending, the branch and the merge: a program that judged its +// own work unfinished came back reading `a fault: …`, a run a limit ended lost +// which limit, and a kept branch was named nowhere. +func TestARunRowKeepsItsEndingBranchAndSpanAcrossAReopen(t *testing.T) { + began := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + rows := []TaskNotice{ + { + ID: 3, Title: "Implement happy-dom teardown", State: TaskFailed, Ending: TaskEndingProgram, + Report: "senior-dev did not finish: its tests fail\nsubmitted a change the tests do not pass", + Result: "submitted a change the tests do not pass", Branch: "task/happy-dom-1", Merge: mergeKept, + Changed: []string{"src/window.ts"}, StartedAt: began, EndedAt: began.Add(29*time.Minute + 8*time.Second), + }, + { + ID: 4, Title: "Port the parser", State: TaskFailed, Ending: TaskEndingTimeLimit, + Report: "a limit you set stopped it", Branch: "task/port-the-parser-2", Merge: mergeKept, + StartedAt: began, EndedAt: began.Add(time.Hour), + }, + { + ID: 5, Title: "Fix the nil map", State: TaskFailed, Ending: TaskEndingCostLimit, + Report: "a limit you set stopped it", Result: "half the guard", Branch: "task/fix-the-nil-map-3", Merge: mergeKept, + Changed: []string{"a.go", "a_test.go"}, StartedAt: began, EndedAt: began.Add(4*time.Minute + 23*time.Second), + }, + } + dir, workspace := t.TempDir(), t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + }) + g := agent.graph() + for i := range rows { + // Every row takes its number off the graph's one counter, the way a + // hand-off's row does, so the checkpoint's id counter covers it. + rows[i].ID = g.reserve() + agent.publishRunRow(g, rows[i]) + } + journal := agent.file.journalPath() + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + reopened, err := newAgent(Config{Workspace: workspace, Model: "test/model", System: "SYSTEM", SessionFile: journal}, &scriptedCompleter{}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + defer reopened.Close() + for _, row := range rows { + back := reopened.graph().runRows(row.ID) + if len(back) != 1 { + t.Fatalf("row %d came back as %+v", row.ID, back) + } + got := back[0] + if got.Ending != row.Ending || got.Branch != row.Branch || got.Merge != row.Merge || got.Result != row.Result || + !reflect.DeepEqual(got.Changed, row.Changed) { + t.Fatalf("row %d came back as %+v, want its ending, branch, merge, result and files kept", row.ID, got) + } + if want := runSpan(row.StartedAt, row.EndedAt); got.Elapsed != want { + t.Fatalf("row %d came back with span %v, want %v", row.ID, got.Elapsed, want) + } + if reason := TaskReasonOf(got.Ending, got.Report); reason != TaskReasonOf(row.Ending, row.Report) || strings.HasPrefix(reason, "a fault") { + t.Fatalf("row %d reads %q after the reopen, want %q", row.ID, reason, TaskReasonOf(row.Ending, row.Report)) + } + } + // The elapsed time is on the file itself, where the record says it is. + var document taskDocument + data, err := os.ReadFile(taskCheckpointPath(journal)) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &document); err != nil { + t.Fatal(err) + } + for _, run := range document.Runs { + if run.ElapsedMS <= 0 { + t.Fatalf("run %d was saved with no elapsed_ms: %+v", run.ID, run) + } + } +} + +// A PROGRAM'S RUN A LIMIT ENDED READS ENDED BEFORE ITS WORK LANDS. The engine +// leaves such a store open, and the run's task was ended only after its work +// was committed, so the page went on reading `running` over a program that +// had exited — for the two limit endings alone. +func TestALimitEndedProgramRunIsEndedBeforeItsWorkLands(t *testing.T) { + double := newBeltRunDouble("") + double.leaveOpen = true + double.summary = RunSummary{Outcome: "a limit you set stopped it", Limit: RunLimitCost} + double.work = func(workspace string) { + if err := os.WriteFile(filepath.Join(workspace, "one.txt"), []byte("one\n"), 0o644); err != nil { + t.Error(err) + } + } + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "change the project"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + endBeltRun(t, agent, double) + + store := beltRunStoreAt(t, filepath.Dir(spec.Store.Path())) + defer store.Close() + root := store.Task(store.RootID()) + notes := store.Notes(store.RootID(), 0) + if root == nil || root.Status != plandb.StatusFailed || len(notes) == 0 { + t.Fatalf("the run's task = %+v with notes %+v, want it failed with the landing noted", root, notes) + } + if !strings.Contains(notes[0].Body, "its work is on the branch ") { + t.Fatalf("the first note on the run = %q, want the one that says where its work is", notes[0].Body) + } + if !root.CompletedAt.Before(notes[0].At) { + t.Fatalf("the run's task ended at %v and its work landed at %v: it read running while its work landed", root.CompletedAt, notes[0].At) + } +} + +// A PROGRAM'S RUN NOTHING HERE IS DRIVING DOES NOT READ RUNNING. codeaf closed +// while the program ran, nothing wrote the store's ending, and the page read +// `running` with a clock that never stopped and a stage it was no longer in. +// It now reads failed — incomplete on every surface — ended at its last sign of +// life, with nothing live. +func TestAProgramsRunNothingIsDrivingEndsAtItsLastActivity(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, planStoreFilename) + store, err := plandb.Open(path, "the run", "3", "Implement happy-dom teardown", "the brief", "chat-a") + if err != nil { + t.Fatal(err) + } + if err := store.SetLive("3", 5, "senior-dev: implement · running"); err != nil { + t.Fatal(err) + } + root := store.Task("3") + _ = store.Close() + folder := plandb.TaskDir(dir, "3") + started := root.UpdatedAt.Add(-29 * time.Minute) + if err := delegate.WriteProgram(folder, delegate.ProgramRecord{Name: "senior-dev", StartedAt: started}); err != nil { + t.Fatal(err) + } + if err := delegate.AppendTurn(folder, delegate.Turn{Seq: 1, Started: started, Model: "vendor/model"}); err != nil { + t.Fatal(err) + } + last := root.UpdatedAt.Add(90 * time.Second) + if err := os.Chtimes(filepath.Join(folder, delegate.ConversationFile), last, last); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + + page, ok := agent.PlanTaskPage("3") + if !ok { + t.Fatal("the run's task answered no page") + } + if page.Row.Status != string(plandb.StatusFailed) || !page.Live.Empty() || page.Row.Stage != "" { + t.Fatalf("the page's row = %+v, want it ended with nothing live", page.Row) + } + if !page.Row.Started.Equal(started) || !page.Row.Ended.Equal(last) { + t.Fatalf("the page's row runs %v → %v, want the program's start %v to its last activity %v", page.Row.Started, page.Row.Ended, started, last) + } + if row := planRowFor(agent.PlanTasks(), planStoreID("3")); row == nil || row.Status != string(plandb.StatusFailed) || !row.Ended.Equal(last) { + t.Fatalf("the listing's row = %+v, want the page's reading", row) + } +} + +// THE CHAT'S tasks TOOL SEES A senior-dev RUN, AND SAYS HOW LONG IT TOOK. Its +// reader of the run's store was gated on the bash-belt switch, which a program's +// run never sets, so the owner's conversation was told `No task "3" in this +// project` over a run its rail was drawing; and nothing the model could read +// said how long a run had taken. The same span reaches the note the +// conversation is handed when the run lands. +func TestTheTasksToolSeesAProgramsRunAndSaysHowLongItTook(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "off") + if bashBeltAsked() { + t.Fatal("the switch is still on, so this test would prove nothing") + } + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + handoff := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + clock := &fakeClock{at: handoff} + agent.taskNow = clock.now + id, title, _, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + name := "#" + strconv.FormatUint(id, 10) + + clock.advance(3 * time.Minute) + read, failed := runTool(t, agent, "tasks", `{"id":"`+strconv.FormatUint(id, 10)+`"}`) + if failed || !strings.Contains(read, name+" · "+title+" · running · running for 3m") { + t.Fatalf("reading the live run answered %q (failed %v), want it running for three minutes", read, failed) + } + found, failed := runTool(t, agent, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, name+" · "+title+" · running") { + t.Fatalf("a search for the run answered %q (failed %v), want the run named", found, failed) + } + + exited := handoff.Add(22*time.Minute + 51*time.Second) + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(spec.Store.Path()), spec.Store.RootID()), delegate.ProgramRecord{Name: "fake", StartedAt: handoff, EndedAt: exited}); err != nil { + t.Fatal(err) + } + clock.advance(30 * time.Minute) + endBeltRun(t, agent, double) + + listing, failed := runTool(t, agent, "tasks", `{}`) + if failed || !strings.Contains(listing, name+" · "+title+" · done · ran 22m 51s") { + t.Fatalf("the listing answered %q (failed %v), want the run done with its time", listing, failed) + } + read, failed = runTool(t, agent, "tasks", `{"id":"`+name+`"}`) + if failed || !strings.HasPrefix(read, name+" · "+title+" · done · ran 22m 51s · via fake\n") { + t.Fatalf("reading the ended run answered %q (failed %v), want its time and its program on its first line", read, failed) + } + if conversationNotes(agent, "done · ran 22m 51s · submitted and verified") == 0 { + t.Fatal("the note the conversation was handed at the landing does not say how long the run took") + } +} + +// AN INTERRUPTED ROW SURVIVES THE NEXT CHECKPOINT. A row that was moving when +// its process went away comes back interrupted, and the next checkpoint wrote +// it down as `interrupted` — a state the reader refuses — so the reopen after +// that set the whole file aside and the conversation lost every task it had, +// the finished ones with it (found by killing the engine under a real +// senior-dev run, then opening the conversation twice). +func TestAnInterruptedRunRowSurvivesTheNextCheckpoint(t *testing.T) { + dir, workspace := t.TempDir(), t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + }) + g := agent.graph() + began := time.Date(2026, time.September, 24, 10, 35, 54, 0, time.UTC) + done := TaskNotice{ID: g.reserve(), Title: "the finished run", State: TaskDone, StartedAt: began, EndedAt: began.Add(time.Minute)} + moving := TaskNotice{ID: g.reserve(), Title: "the run codeaf closed under", State: TaskRunning, StartedAt: began.Add(2 * time.Minute)} + agent.publishRunRow(g, done) + agent.publishRunRow(g, moving) + journal := agent.file.journalPath() + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + reopen := func() *Agent { + t.Helper() + back, err := newAgent(Config{Workspace: workspace, Model: "test/model", System: "SYSTEM", SessionFile: journal}, &scriptedCompleter{}) + if err != nil { + t.Fatalf("reopen: %v", err) + } + return back + } + first := reopen() + if rows := first.graph().runRows(moving.ID); len(rows) != 1 || rows[0].State != TaskInterrupted { + t.Fatalf("the moving row came back as %+v, want interrupted", rows) + } + // Anything that writes the checkpoint again: here, one more row. + later := TaskNotice{ID: first.graph().reserve(), Title: "a later run", State: TaskDone, StartedAt: began.Add(time.Hour), EndedAt: began.Add(2 * time.Hour)} + first.publishRunRow(first.graph(), later) + if err := first.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + second := reopen() + defer second.Close() + for _, want := range []TaskNotice{done, moving, later} { + if rows := second.graph().runRows(want.ID); len(rows) != 1 || rows[0].Title != want.Title { + t.Fatalf("row %d (%s) came back as %+v after the second reopen", want.ID, want.Title, rows) + } + } + if rows := second.graph().runRows(moving.ID); rows[0].State != TaskInterrupted { + t.Fatalf("the interrupted row came back as %s, want interrupted still", rows[0].State) + } +} + +// A FILE AN EARLIER BUILD WROTE WITH AN INTERRUPTED ROW STILL LOADS, rather than +// being set aside whole: the row is read as the moving row it was. +func TestACheckpointHoldingAnInterruptedRunRowIsNotRefused(t *testing.T) { + document := taskDocument{Type: "tasks", Version: 1, Seq: 2, Runs: []runRecord{ + {ID: 1, Title: "done", State: TaskDone}, + {ID: 2, Title: "cut", State: TaskInterrupted}, + }} + content, err := json.Marshal(document) + if err != nil { + t.Fatal(err) + } + back, err := decodeTasks(content) + if err != nil { + t.Fatalf("a checkpoint with an interrupted run row was refused: %v", err) + } + if len(back.Runs) != 2 { + t.Fatalf("the checkpoint came back with %d rows, want both", len(back.Runs)) + } +} + +// A PROGRAM'S SETTLED ROW AND ITS OWN CONVERSATION'S tasks TOOL SAY WHAT IT +// COST. The row's notice carried no price, so the landed card drew none, and the +// tool's reader of the run's store printed no dollars. The figure is a label: the +// conversation's books take the run's calls once, through the fold, and a row +// that carries the total must not add it again. +func TestAProgramsRowAndTheTasksToolSayWhatItCost(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "off") + double := newBeltRunDouble("submitted and verified") + double.summary.USD = 2.30 + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: t.TempDir()} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, title, _, err := agent.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + if err := spec.Store.AddSpend(spec.Store.RootID(), "delegate/fake", "worker", 2.30, 100, 50); err != nil { + t.Fatal(err) + } + endBeltRun(t, agent, double) + + row, ok := runRowOf(agent.graph(), id) + if !ok || !row.State.settled() || row.CostUSD != 2.30 { + t.Fatalf("the run's settled row = %+v, want it to carry $2.30", row) + } + name := "#" + strconv.FormatUint(id, 10) + listing, failed := runTool(t, agent, "tasks", `{}`) + if failed || !strings.Contains(listing, name+" · "+title+" · done · ") || !strings.Contains(listing, " · done · $2.30 · ") { + t.Fatalf("the listing answered %q (failed %v), want the run's price after its state", listing, failed) + } + read, failed := runTool(t, agent, "tasks", `{"id":"`+name+`"}`) + if failed || !strings.HasSuffix(strings.SplitN(read, "\n", 2)[0], " · done · $2.30") { + t.Fatalf("reading the run answered %q (failed %v), want its price on its first line", read, failed) + } + if got := agent.Usage().CostUSD; got > 2.30+1e-9 { + t.Fatalf("the conversation's books hold $%.4f for a $2.30 run: the row's price was charged again", got) + } +} diff --git a/internal/session/task_run_continue.go b/internal/session/task_run_continue.go index 07980032b6..bb91715b2e 100644 --- a/internal/session/task_run_continue.go +++ b/internal/session/task_run_continue.go @@ -67,6 +67,13 @@ func (a *Agent) ContinueRun(ctx context.Context, row uint64) (string, error) { if !found { return "", fmt.Errorf("there is no run %d in this conversation", row) } + if why := runCannotContinue(kept.Copy, kept.Program); kept.Program != "" { + // A PROGRAM'S ROW IS REFUSED BEFORE ITS STATE IS READ. It carries no + // copy (the program works in the folder itself), so the copy road below + // would refuse it in a sentence about a copy, and nothing here seats the + // program itself: the run rebuilt would be codeaf's own workers. + return "", fmt.Errorf("%s", why) + } if kept.State != TaskInterrupted { // A run that finished, failed or was stopped has said its last word. // Only work nothing is driving is waiting to be picked up. diff --git a/internal/session/task_run_continue_test.go b/internal/session/task_run_continue_test.go index 7bf33381a7..7d2775aa09 100644 --- a/internal/session/task_run_continue_test.go +++ b/internal/session/task_run_continue_test.go @@ -194,7 +194,7 @@ func TestARunWithNoCopyIsRefusedInTheSameSentenceTheRowShows(t *testing.T) { // THE DOOR AND THE ROW SAY ONE SENTENCE, NOT TWO. A person reads the row's // words before they answer and the door's words after; two spellings of the // same fact is how a reading drifts from what actually happens. - why := runCannotContinue(nil) + why := runCannotContinue(nil, "") if why == "" { t.Fatal("the row shows no reason at all, so the offer would read as available") } @@ -307,3 +307,25 @@ func TestARunThatIsAlreadyGoingIsLeftAloneAndSaysSo(t *testing.T) { t.Fatal("the live run was replaced by the one that was refused") } } + +// A PROGRAM'S RUN IS NEVER CARRIED ON, and the door and its row say so in one +// sentence rather than blaming a copy it never had. +func TestAProgramsRunIsNeverCarriedOn(t *testing.T) { + agent, g, _, double := continueAgent(t) + agent.publishRunRow(g, TaskNotice{ + ID: 7, Title: "port the parser", State: TaskInterrupted, Program: "senior-dev", + StartedAt: agent.taskClockNow(), + }) + _, err := agent.ContinueRun(context.Background(), 7) + if err == nil { + t.Fatal("a program's run was carried on by codeaf's own workers") + } + if want := programNotCarriedOn("senior-dev"); err.Error() != want { + t.Fatalf("the door refuses with %q, want %q", err.Error(), want) + } + row, _ := runRowOf(g, 7) + if got := row.StatusFacts().CannotContinue; got != err.Error() { + t.Fatalf("the row says %q and the door %q", got, err.Error()) + } + nothingStarted(t, agent, double) +} diff --git a/internal/session/task_run_copy.go b/internal/session/task_run_copy.go index 9e2afb7bad..300be3e6b6 100644 --- a/internal/session/task_run_copy.go +++ b/internal/session/task_run_copy.go @@ -61,6 +61,9 @@ type TaskCopyRecord struct { // landing outlives the run that made the world. Rung GroundRung `json:"rung,omitempty"` Seal string `json:"seal,omitempty"` + // Continues says a program's run carries on on the branch an earlier run + // of it left ([ProgramFolder.Continues]), which its receipt says. + Continues bool `json:"continues,omitempty"` } // runCopyOf writes a live run's copy down. It is taken from the tree the run is @@ -81,6 +84,7 @@ func runCopyOf(tree taskTree) *TaskCopyRecord { CheckBase: tree.checkBase, Rung: tree.rung, Seal: tree.seal, + Continues: tree.continues, } } @@ -92,6 +96,12 @@ func runCopyOf(tree taskTree) *TaskCopyRecord { // destructive road wearing a helpful face. var errNoRunCopy = fmt.Errorf("this run's working copy was not written down when it started, so there is nothing to carry on from") +// programNotCarriedOn is the sentence a program's run is refused carrying on +// in, by the door and on its row alike. +func programNotCarriedOn(program string) string { + return program + "'s run is never carried on: its work is left where it ended, and a new hand-off starts a new run" +} + // runCannotContinue answers WHY a run cannot be carried on, in the words a // person reads, or the empty string when it can. // @@ -107,7 +117,14 @@ var errNoRunCopy = fmt.Errorf("this run's working copy was not written down when // a copy that WAS written down and is no longer there — is a fact about this // moment, so the door finds it out at the moment it matters ([runCopyTree]) and // says so then. -func runCannotContinue(record *TaskCopyRecord) string { +// +// A PROGRAM'S RUN IS NEVER CARRIED ON, whatever its record says: the program +// did its one run alone and left its work where it ended, and the next hand-off +// is a new run ([programNotCarriedOn]). +func runCannotContinue(record *TaskCopyRecord, program string) string { + if program = strings.TrimSpace(program); program != "" { + return programNotCarriedOn(program) + } if record == nil || strings.TrimSpace(record.Dir) == "" { return errNoRunCopy.Error() } diff --git a/internal/session/task_run_index.go b/internal/session/task_run_index.go new file mode 100644 index 0000000000..f4bd2a7378 --- /dev/null +++ b/internal/session/task_run_index.go @@ -0,0 +1,183 @@ +package session + +// A HAND-OFF'S RUN IS WORK THE PROJECT CAN SEE. A run's rows lived in this +// conversation's own graph and nowhere else: the project's index (tasks.jsonl) +// never took a row for one, and this conversation's presence never named one. +// So the `@` list, the hop's running count, the conversation list's roll-up, +// every other window and every other conversation's `tasks` tool were blind to +// a senior-dev run for its whole life and after it — the owner's project index +// held nothing of three runs that had taken an hour each. +// +// NOW EVERY ROW A RUN PUBLISHES REACHES THE INDEX, the way an adaptive run's +// does (orchestrate.go's family seam): a row saying running from the hand-off, +// and a row closing it when it settles, with its one pair and span +// (task_run_clock.go), its ending, its outcome, the branch its work was kept on +// and what it cost. The index keeps the last row per id ([lastPerNode]). A run +// left saying running by a process that went away is closed the next time this +// conversation opens ([Agent.closeInflightTaskIndexRows]), because a run's id is +// never one of the graph's own nodes. And while it runs it is named in this +// conversation's presence ([Agent.presenceBeltRuns]), which is what another +// window reads to know a running row in the index has something behind it. +// +// THIS CONVERSATION READS ITS OWN RUNS FROM THEIR STORE, not from the index +// ([Agent.withoutOwnRunRows]): the tasks tool lists them from the run's store +// by the numbers the rail shows, and a second copy of the same run from the +// index would be the same work named twice in one answer. + +import ( + "strconv" + "strings" +) + +// indexRunRow appends one published run row to the project's index. A job's +// row is not work and takes none (jobrow.go's law), and neither does a row of +// an adaptive run, whose family writes its own. +func (a *Agent) indexRunRow(notice TaskNotice) { + if notice.ID == 0 || notice.Kind == TaskKindJob || notice.Run != "" { + return + } + a.mu.Lock() + session := a.sessionID() + a.mu.Unlock() + entry := TaskIndexEntry{ + ID: strconv.FormatUint(notice.ID, 10), + Parent: taskIndexParent(notice.Parent), + Name: TaskSlug(notice.Title), + Label: taskLabel(notice.Title), + Title: strings.TrimSpace(notice.Title), + Status: string(notice.State), + Ending: notice.Ending, + Outcome: taskOutcome(notice.Report), + DurationMS: notice.Elapsed.Milliseconds(), + StartedAt: notice.StartedAt, + EndedAt: notice.EndedAt, + SessionID: session, + // THE KEPT BRANCH ONLY, from the row's own word for how its work came + // home ([keptBranchOf]): a run whose work was merged names none. + Branch: keptBranchOf(notice.Branch, notice.Merge), + // AND WHICH PROGRAM HAS IT, so every surface drawing this file can put the + // program's badge on the row ([TaskIndexEntry.Program]). + Program: notice.Program, + } + // A PERSON'S STOP IS THE ROW'S ENDING, as it is on a node's row + // ([TaskNode.endingLocked]): the stop road publishes the flag and no word. + if notice.Stopped && entry.Ending == "" { + entry.Ending = TaskEndingStopped + } + entry.Files, entry.FilesChanged = taskFileCitations(notice.Changed) + worktree := "" + if where := notice.Copy; where != nil { + worktree = where.Dir + entry.Where, entry.Ground, entry.Mode, entry.Rung = where.Dir, where.Ground, where.Mode, where.Rung + } + entry.ArtifactURI = taskArtifactURI(worktree, notice.Branch, notice.Merge) + if notice.State.settled() { + entry.Cost = notice.CostUSD + if entry.Cost == 0 { + entry.Cost = a.beltRunSpent(notice.ID) + } + } + a.recordTaskIndexEntry(entry) + // Another window learns the run started, or ended, now rather than at the + // next heartbeat. + a.nudgePresence() +} + +// beltRunSpent is what the live run whose own row id is this one came to, as +// its engine answered, and zero for every other row: a hand-off that joined +// the run has no figure of its own, and zero is drawn as no price. +func (a *Agent) beltRunSpent(id uint64) float64 { + a.beltMu.Lock() + defer a.beltMu.Unlock() + if run := a.beltRun; run != nil && run.row == id { + return run.spent + } + return 0 +} + +// presenceBeltRuns is the live run this conversation has out, and each hand-off +// that joined it and has not settled, one presence row each under the id its +// index row carries — the join another window makes to know the running row in +// the index is being worked ([SessionRow.Runs]). A run no longer live, and a row +// that has settled, is the index's to report. It reads the graph without +// building one, and takes the belt's lock and the graph's one after the other, +// never together. +func (a *Agent) presenceBeltRuns() []PresenceTask { + a.beltMu.Lock() + run := a.beltRun + var ids []uint64 + if run != nil { + ids = append([]uint64{run.row}, run.joined...) + } + a.beltMu.Unlock() + graph := a.tasker() + if run == nil || graph == nil { + return nil + } + var out []PresenceTask + for _, id := range ids { + for _, kept := range graph.runRows(id) { + if kept.ID != id || kept.State.settled() { + continue + } + row := PresenceTask{ + ID: strconv.FormatUint(id, 10), + Title: strings.TrimSpace(kept.Title), + State: string(kept.State), + StartedAt: kept.StartedAt, + } + if kept.Parent != 0 { + row.Parent = strconv.FormatUint(kept.Parent, 10) + } + out = append(out, row) + } + } + return out +} + +// ownRunRowIDs is every hand-off run row this conversation keeps, by id: the +// rows its tasks tool reads from the run's store and never from the index. An +// adaptive run's rows and a job's are not among them. +func (a *Agent) ownRunRowIDs() map[string]bool { + graph := a.tasker() + if graph == nil { + return nil + } + graph.mu.Lock() + defer graph.mu.Unlock() + var ids map[string]bool + for _, notice := range graph.runRowsLocked() { + if notice.Run != "" || notice.Kind == TaskKindJob { + continue + } + if ids == nil { + ids = make(map[string]bool) + } + ids[strconv.FormatUint(notice.ID, 10)] = true + } + return ids +} + +// withoutOwnRunRows is the index as this conversation's tasks tool reads it: +// every row but this conversation's own hand-off runs, which the tool reads +// from their store ([Agent.runPlanTasks]) under the numbers the rail shows. Left +// in, the same run would be listed twice in one answer, and a word for it — +// `say`, `continue` — would be answered as though it were the work of an +// earlier conversation. +func (a *Agent) withoutOwnRunRows(rows []TaskIndexEntry) []TaskIndexEntry { + own := a.ownRunRowIDs() + if len(own) == 0 { + return rows + } + a.mu.Lock() + session := a.sessionID() + a.mu.Unlock() + kept := make([]TaskIndexEntry, 0, len(rows)) + for _, row := range rows { + if row.SessionID == session && own[strings.TrimSpace(row.ID)] { + continue + } + kept = append(kept, row) + } + return kept +} diff --git a/internal/session/task_run_index_test.go b/internal/session/task_run_index_test.go new file mode 100644 index 0000000000..254a560019 --- /dev/null +++ b/internal/session/task_run_index_test.go @@ -0,0 +1,125 @@ +package session + +// A hand-off's run reaches the project's index and its conversation's presence +// (task_run_index.go), so work a senior-dev run is doing is visible to the `@` +// list, to other windows and to another conversation's tasks tool. + +import ( + "context" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// A SECOND CONVERSATION ON THE PROJECT FINDS A senior-dev RUN. The run's rows +// never reached the project's index, so every other conversation's `tasks` tool, +// the `@` list and the hop's count were blind to it for its whole life and after +// it; and its conversation's presence never named it, so a row that did reach +// the index would have read as nothing behind it. The run's own conversation +// reads it once, from its store, and not a second time from the index. +func TestAnotherConversationOnTheProjectFindsAProgramsRun(t *testing.T) { + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + bucket := t.TempDir() + workspace := newTestRepo(t) + conversation := func(name string) *Agent { + dir := filepath.Join(bucket, name) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + runner, other := conversation("runner"), conversation("other") + handoff := time.Date(2026, time.September, 24, 1, 14, 7, 0, time.UTC) + clock := &fakeClock{at: handoff} + runner.taskNow = clock.now + + id, title, _, err := runner.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + key := strconv.FormatUint(id, 10) + slug := TaskSlug(title) + + // WHILE IT RUNS: the index holds it running from the hand-off, the runner's + // presence names it, and the other conversation finds it by its words. + row := indexRowFor(t, runner, key) + if row.Status != string(TaskRunning) || !row.StartedAt.Equal(handoff) { + t.Fatalf("the index's row for the run = %+v, want it running from the hand-off", row) + } + var named bool + for _, task := range runner.presenceSnapshot(time.Now()).RunningTasks { + named = named || (task.ID == key && task.StartedAt.Equal(handoff)) + } + if !named { + t.Fatalf("the runner's presence does not name the run: %+v", runner.presenceSnapshot(time.Now()).RunningTasks) + } + found, failed := runTool(t, other, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, key+" · "+slug+" · working") { + t.Fatalf("the other conversation's search answered %q (failed %v), want the run working", found, failed) + } + // The runner's own listing names it once, from its store. + own, failed := runTool(t, runner, "tasks", `{}`) + if failed || !strings.Contains(own, "#"+key+" · "+title+" · running") || strings.Contains(own, key+" · "+slug) { + t.Fatalf("the runner's own listing = %q (failed %v), want the run once, by the number its rail shows", own, failed) + } + + exited := handoff.Add(22*time.Minute + 51*time.Second) + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(spec.Store.Path()), spec.Store.RootID()), delegate.ProgramRecord{Name: "fake", StartedAt: handoff, EndedAt: exited}); err != nil { + t.Fatal(err) + } + clock.advance(30 * time.Minute) + endBeltRun(t, runner, double) + + // ONCE IT HAS ENDED: the index closes it on the run's one pair, the + // runner's presence lets it go, and the other conversation reads the ending. + row = indexRowFor(t, runner, key) + if row.Status != string(TaskDone) || !row.EndedAt.Equal(exited) || row.Duration() != 22*time.Minute+51*time.Second { + t.Fatalf("the index's closing row = %+v, want it done at the program's exit, 22m 51s in", row) + } + for _, task := range runner.presenceSnapshot(time.Now()).RunningTasks { + if task.ID == key { + t.Fatalf("the runner's presence still names the ended run: %+v", task) + } + } + found, failed = runTool(t, other, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, key+" · "+slug+" · done") || !strings.Contains(found, "22m 51s") { + t.Fatalf("the other conversation's search answered %q (failed %v), want the run done with its time", found, failed) + } + var mentioned bool + for _, entry := range other.TaskIndex() { + mentioned = mentioned || (entry.ID == key && entry.Title == title) + } + if !mentioned { + t.Fatal("the `@` list of the other conversation does not carry the run") + } +} + +// indexRowFor is the project index's last word on one of this conversation's +// rows, read the way every reader of the index reads it. +func indexRowFor(t *testing.T, agent *Agent, id string) TaskIndexEntry { + t.Helper() + agent.mu.Lock() + session := agent.sessionID() + agent.mu.Unlock() + for _, entry := range ReadTaskIndex(agent.config.taskIndexFile()) { + if entry.ID == id && entry.SessionID == session { + return entry + } + } + t.Fatalf("the project's index holds no row %s for this conversation", id) + return TaskIndexEntry{} +} diff --git a/internal/session/task_run_money.go b/internal/session/task_run_money.go new file mode 100644 index 0000000000..368de42655 --- /dev/null +++ b/internal/session/task_run_money.go @@ -0,0 +1,101 @@ +package session + +// A run's money, on its way into the conversation's books. +// +// A run the conversation handed a task to spends beside the conversation, and +// the conversation's own books hold what its work cost, so the run's money is +// folded in as it is spent — through the fold door, which writes no row on the +// machine's ledger, because the run's own workers wrote one per call. +// +// TWO READINGS OF ONE ACCOUNT, FOLDED ONCE. A program's model API meters call +// by call and hands each call over whole ([RunCharge]); the run's supervisor +// hands over its reconciled running total, which is the only thing a bash +// worker reports. The fold keeps one figure — the dollars already in the books +// — and folds each call whole as it comes, then only the part of the total that +// is beyond that figure. A call is always told before the total that holds it +// (internal/run's delegateMeter.bank), so no dollar is folded by both. + +import ( + "strings" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// RunCharge is one priced call a run's worker made, as the run metered it: the +// model that answered, the tokens and the cached share, and what the provider +// charged — zero where the service reports no price, which is a call nobody +// could price and never a free one. +type RunCharge struct { + Model string + TokensIn int + TokensOut int + Cached int + USD float64 +} + +// foldDust is the smallest remainder of a run's total the fold will write as +// a line of its own. A running total reconciled from cumulative readings and +// the same calls summed one by one differ by float rounding — around 1e-17 — +// and a journal line for that is not money. +const foldDust = 1e-9 + +// beltFold is the conversation's side of one run's money. Its two doors are +// called from different goroutines — a call's charge from the model API, the +// total from the run's supervisor and then from the belt when the run +// returns — so the one figure they share is held under a lock. +type beltFold struct { + agent *Agent + mu sync.Mutex + folded float64 +} + +// charge folds one call whole: its tokens, its cached share, its model and +// its dollars, as ONE call, detached from whatever chat turn is running. +// +// It used to be a bare dollar figure per reading of the run's total, with no +// tokens, no model and no call, so a conversation whose program made 276 calls +// held none of them in its token and call totals; and a service that reports +// no price folded nothing at all. And it moved the running chat turn's share, +// so a turn abandoned while a run was spending was journaled with the run's +// dollars as its own. +func (f *beltFold) charge(charge RunCharge) { + if charge.TokensIn == 0 && charge.TokensOut == 0 && charge.USD == 0 { + return + } + f.mu.Lock() + defer f.mu.Unlock() + cost := charge.USD + f.agent.addDetachedFoldedUsage(&ai.Response{Usage: &ai.Usage{ + PromptTokens: charge.TokensIn, + CompletionTokens: charge.TokensOut, + CacheReadInputTokens: charge.Cached, + Cost: &cost, + }}, strings.TrimSpace(charge.Model), 1) + f.folded += charge.USD +} + +// total folds whatever the run's reconciled running total holds beyond what +// is already in the books: the whole of a bash worker's spend, and nothing +// for a program whose every call arrived through [beltFold.charge] first. +func (f *beltFold) total(total float64) { + f.mu.Lock() + defer f.mu.Unlock() + delta := total - f.folded + if delta <= foldDust { + return + } + f.agent.addDetachedFoldedUsage(&ai.Response{Usage: &ai.Usage{Cost: &delta}}, "", 0) + f.folded = total +} + +// runConversation is the conversation a run's ledger rows are filed under: +// the conversation at the root of this agent's family, which is this agent's +// own journal when it is the conversation — the same answer a task node's +// rows carry as their Root. +func (a *Agent) runConversation() string { + if root := strings.TrimSpace(a.config.rootSession); root != "" { + return root + } + return strings.TrimSpace(a.journalID()) +} diff --git a/internal/session/task_run_money_test.go b/internal/session/task_run_money_test.go new file mode 100644 index 0000000000..6578cec8fa --- /dev/null +++ b/internal/session/task_run_money_test.go @@ -0,0 +1,126 @@ +package session + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +// A RUN'S SPEND IS THE CONVERSATION'S, AND NOT THE RUNNING TURN'S. The run's +// dollars reach the conversation's books, but a run works beside the turns: a +// turn running (or abandoned) while the run spends is not the turn that spent +// it, so its share does not move. The run is handed the conversation's own id +// to file its ledger rows under. +func TestARunsSpendIsFoldedIntoTheConversationAndNotTheRunningTurn(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run ended") + double.summary.USD = 0.42 + registerBeltRunEngine(t, double) + + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run ended"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = Place{Dir: dir}.Transcript() + config.AskConsent = false + }) + agent.mu.Lock() + agent.turnSpend = Usage{Input: 5, Output: 2, Calls: 1, CostUSD: 0.11} + agent.mu.Unlock() + updates, stopUpdates := agent.WatchTaskUpdates() + defer stopUpdates() + + if _, _, _, err := agent.StartTask(context.Background(), "account for this run", false); err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + close(double.release) + lastTaskUpdate(t, updates) + + if got := agent.Usage().CostUSD; got != 0.42 { + t.Fatalf("conversation cost = %v, want the run's $0.42 once", got) + } + agent.mu.Lock() + running := agent.turnSpend + agent.mu.Unlock() + if running.CostUSD != 0.11 || running.Input != 5 || running.Calls != 1 { + t.Fatalf("the run's spend moved the running turn's share: %+v", running) + } + double.mu.Lock() + conversation := double.spec.Conversation + double.mu.Unlock() + if conversation == "" || conversation != agent.journalID() { + t.Fatalf("the run was handed conversation %q, want this conversation's own id %q", conversation, agent.journalID()) + } +} + +// A PROGRAM'S CALL IS FOLDED WHOLE, AND EACH DOLLAR ONCE. A call metered by a +// program's model API reaches the books with its tokens, its cached share and +// one call, even from a service that reports no price; the run's running total +// adds only what the calls did not already carry — a bash worker's spend — and +// float dust between two sums of the same calls is not a line of its own. The +// fold writes nothing to the machine's ledger: the run's worker wrote it. +func TestARunsCallsAreFoldedWholeAndEachDollarOnce(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.usageLedger = ledger + config.SessionFile = filepath.Join(t.TempDir(), "session.jsonl") + }) + fold := &beltFold{agent: agent} + fold.charge(RunCharge{Model: "deepseek/deepseek-v4-pro", TokensIn: 100, TokensOut: 10, Cached: 60, USD: 0.05}) + fold.total(0.05 + 1e-17) + // A service that reports no price: its tokens are still the conversation's. + fold.charge(RunCharge{Model: "gpt-5.6-sol", TokensIn: 200, TokensOut: 20}) + fold.total(0.05) + // A worker that reports only its total: the remainder, once. + fold.total(0.08) + fold.total(0.08) + + usage := agent.Usage() + if usage.Input != 300 || usage.Output != 30 || usage.CacheRead != 60 || usage.Calls != 2 || usage.Turns != 0 { + t.Fatalf("conversation usage = %+v, want both calls' tokens and two calls", usage) + } + if usage.CostUSD < 0.08-1e-12 || usage.CostUSD > 0.08+1e-12 { + t.Fatalf("conversation cost = %v, want $0.08: each dollar once", usage.CostUSD) + } + FlushUsage() + if lines, _ := ReadUsage(ledger, time.Time{}); len(lines) != 0 { + t.Fatalf("the fold wrote %d ledger rows, want none", len(lines)) + } +} + +// A RUN'S DOLLARS REACH THE CONVERSATION'S meta.json THE MOMENT THEY REACH ITS +// BOOKS, not at the next turn's seal. Home reads a conversation's bill from two +// places — the books stamped on meta.json and the run's own row in the +// project's index — and takes the larger, because the books already hold every +// run they were told about. That is only exact if the books on disk are told +// when the run settles: a stamp that waited for the next turn left a card +// reading the run alone while the conversation's own talking was missing. +func TestARunsDollarsAreStampedOnTheConversationWhenItSettles(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run ended") + double.summary.USD = 2.30 + registerBeltRunEngine(t, double) + + dir := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run ended"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = Place{Dir: dir}.Transcript() + config.AskConsent = false + }) + if _, _, _, err := agent.StartTask(context.Background(), "account for this run", false); err != nil { + t.Fatalf("StartTask: %v", err) + } + <-double.entered + endBeltRun(t, agent, double) + + meta, err := LoadMeta(dir) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.SpentUSD != 2.30 { + t.Fatalf("meta.json says the conversation spent %v with no turn sealed since the run, want the run's $2.30", meta.SpentUSD) + } +} diff --git a/internal/session/task_run_orphan_test.go b/internal/session/task_run_orphan_test.go new file mode 100644 index 0000000000..6073a1da95 --- /dev/null +++ b/internal/session/task_run_orphan_test.go @@ -0,0 +1,308 @@ +package session + +// A RUN NO PROCESS HOLDS IS A RECORD, NEVER MORE WORK. +// +// On 2026-09-24 a CSSTree `/senior-dev` ran inside the store an earlier +// happy-dom run had left open when codeaf went away under it: the program was +// handed happy-dom's brief in the CSSTree copy, its calls, spend, ceiling and +// ending were written into happy-dom's record folder, and the CSSTree task had +// no page. These tests pin the three doors that close that: the conversation +// closing under a program's run ends it before anything is cut, a new hand-off +// archives whatever store it finds and seeds its own, and a conversation read +// back from disk ends a program's run the last process left open — each at the +// run's last evidence of life, never at the moment somebody noticed. + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// orphanProgramStore writes, at the conversation's plan path, the store a +// program's run leaves when its process goes away mid-run: its run task still +// open, the program's record beside it, a charge on the ledger, and a model +// call that came back at lastSeen. It answers lastSeen. +func orphanProgramStore(t *testing.T, path, rootID, brief string, chat ...string) time.Time { + t.Helper() + store, err := plandb.Open(path, "the dead run", rootID, "the dead run", brief, chat...) + if err != nil { + t.Fatalf("seed the dead run's store: %v", err) + } + defer store.Close() + if err := store.AddSpend(rootID, "delegate/fake", "work", 0.25, 100, 20); err != nil { + t.Fatal(err) + } + taskDir := plandb.TaskDir(filepath.Dir(path), rootID) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", CeilingUSD: 3.56}); err != nil { + t.Fatal(err) + } + started := time.Now().UTC() + lastSeen := started.Add(time.Millisecond) + if err := delegate.AppendTurn(taskDir, delegate.Turn{Seq: 1, Started: started, Ended: lastSeen, Model: "vendor/m"}); err != nil { + t.Fatal(err) + } + return lastSeen +} + +// THE CRASH ROAD: a store a dead program's run left open is ended at its last +// evidence of life and archived, and the new hand-off runs in a store of its +// own, under its own number, on its own brief. +func TestANewHandOffNeverRunsInsideADeadProgramsStore(t *testing.T) { + double := newBeltRunDouble("") + registerBeltRunEngine(t, double) + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + path := agent.graph().planPath() + lastSeen := orphanProgramStore(t, path, "3", "the FIRST brief: implement happy-dom") + + id, _, _, err := agent.StartDelegate(context.Background(), "fake", "the SECOND brief: implement csstree") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + rootID := spec.Store.RootID() + if rootID != strconv.FormatUint(id, 10) { + t.Fatalf("the new run was seated on store root %q, want its own number %d", rootID, id) + } + if got := spec.Store.Task(rootID).Description; got != "the SECOND brief: implement csstree" { + t.Fatalf("the new run's task holds the brief %q, want its own", got) + } + if _, ok := agent.PlanTaskPage(strconv.FormatUint(id, 10)); !ok { + t.Fatal("the new run has no page of its own") + } + endBeltRun(t, agent, double) + + // THE DEAD RUN IS A RECORD NOW: archived, ended where it was last seen. + archived, err := plandb.Open(path+".1", "", "", "", "") + if err != nil { + t.Fatalf("the dead run's store was not archived: %v", err) + } + defer archived.Close() + root := archived.Task("3") + if root == nil || root.Status != plandb.StatusFailed || root.Error != "codeaf closed while fake was running" { + t.Fatalf("the dead run's task = %+v, want failed with the plain sentence", root) + } + if !root.CompletedAt.Equal(lastSeen) { + t.Fatalf("the dead run ended at %v, want its last model call's end %v", root.CompletedAt, lastSeen) + } + if record, ok := delegate.ReadProgram(plandb.TaskDir(place, "3")); !ok || record.CeilingUSD != 3.56 { + t.Fatalf("the dead run's record was written over: %+v %v", record, ok) + } +} + +// AN ORDINARY RUN A LIMIT ENDED IS ARCHIVED AS IT ENDED: its own task failed in +// the limit's words (the run road ends it by one road and names the limit), its +// brief kept; and the next hand-off runs its own brief under its own number +// rather than resuming the old one under the new title. +func TestALimitEndedRunIsArchivedAsItEndedAndTheNextHandOffRunsItsOwnBrief(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + conversation := beltRunCommittedRepo(t) + dir := t.TempDir() + double := newBeltRunDouble("") + double.real = true + double.leaveOpen = true + double.summary = RunSummary{Outcome: "a limit you set stopped it", Limit: RunLimitCost, Nodes: 1, Steps: 3, USD: 1.5} + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = conversation + config.Place = Place{Dir: dir} + }) + if err := agent.startKnownTaskRun(context.Background(), 91, "the limited run", "brief one", nil, taskStand{dir: conversation, mode: TaskModeWorktree}, ""); err != nil { + t.Fatal(err) + } + <-double.entered + endBeltRun(t, agent, double) + + second := newBeltRunDouble("second") + second.real = true + registerBeltRunEngine(t, second) + if err := agent.startKnownTaskRun(context.Background(), 92, "an unrelated change", "brief two", nil, taskStand{dir: conversation, mode: TaskModeWorktree}, ""); err != nil { + t.Fatal(err) + } + <-second.entered + second.mu.Lock() + store := second.spec.Store + second.mu.Unlock() + if root := store.Task(store.RootID()); store.RootID() != "92" || root.Description != "brief two" || store.Task("91") != nil { + t.Fatalf("the next hand-off runs on root %q with brief %q (holds 91: %v), want its own", store.RootID(), root.Description, store.Task("91") != nil) + } + endBeltRun(t, agent, second) + + archived, err := plandb.Open(filepath.Join(dir, planStoreFilename)+".1", "", "", "", "") + if err != nil { + t.Fatalf("the limited run's store was not archived: %v", err) + } + defer archived.Close() + if root := archived.Task("91"); root == nil || root.Status != plandb.StatusFailed || root.Error != "a limit you set stopped it" || root.Description != "brief one" { + t.Fatalf("the limited run's task = %+v, want it archived as it ended, in the limit's words", root) + } +} + +// THE CLOSE ROAD: the conversation (or the engine) closing under a program's +// run writes its ending before anything is cut, so the store never outlives +// the process saying `running`, and the next hand-off gets its own store. +func TestClosingUnderAProgramsRunEndsItInItsStoreFirst(t *testing.T) { + place := t.TempDir() + workspace := newTestRepo(t) + first := newBeltRunDouble("") + registerBeltRunEngine(t, first) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "the FIRST brief: implement happy-dom"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-first.entered + first.mu.Lock() + firstRoot := first.spec.Store.RootID() + first.mu.Unlock() + // The double plays a program still inside its grace when Close returns: + // the process that closed would be gone before the run wrote anything. + _ = agent.Close() + store := beltRunStoreAt(t, place) + root := store.Task(firstRoot) + _ = store.Close() + if root.Status != plandb.StatusFailed || root.Error != "codeaf closed while fake was running" || root.CompletedAt.IsZero() { + t.Fatalf("after Close the run's task = %s (%q, ended %v), want it ended in the store", root.Status, root.Error, root.CompletedAt) + } + // AND ITS PAGE SAYS WHY: the page draws the task's newest note. + if page, ok := agent.PlanTaskPage(firstRoot); !ok || page.Row.Note != "codeaf closed while fake was running" { + t.Fatalf("the closed run's page row = %+v (%v), want the plain sentence beside it", page.Row, ok) + } + + // THE SECOND HAND-OFF IS ON ANOTHER FOLDER: the first program is still in + // its grace in this process, and it holds its own folder until it has + // gone (programfolder.go's one run per folder). + second := newBeltRunDouble("") + registerBeltRunEngine(t, second) + again, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + id, _, _, err := again.StartDelegate(context.Background(), "fake", "the SECOND brief: implement csstree") + if err != nil { + t.Fatalf("second StartDelegate: %v", err) + } + <-second.entered + second.mu.Lock() + spec := second.spec + second.mu.Unlock() + if spec.Store.RootID() != strconv.FormatUint(id, 10) || spec.Store.Task(spec.Store.RootID()).Description != "the SECOND brief: implement csstree" { + t.Fatalf("the second run is on root %q with brief %q", spec.Store.RootID(), spec.Store.Task(spec.Store.RootID()).Description) + } + endBeltRun(t, again, second) + close(first.release) + <-first.finished +} + +// THE RESTORE ROAD: a conversation read back from disk whose run row comes +// back interrupted over a program's store the last process left open has that +// run ended at its last evidence of life, before anybody hands off again. +func TestAReopenedConversationEndsTheProgramRunItsLastProcessLeftOpen(t *testing.T) { + place := t.TempDir() + workspace := newTestRepo(t) + registerBeltRunEngine(t, newBeltRunDouble("")) + open := func() *Agent { + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: place} + config.SessionFile = filepath.Join(place, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + life := open() + g := life.graph() + id := g.reserve() + rootID := strconv.FormatUint(id, 10) + lastSeen := orphanProgramStore(t, g.planPath(), rootID, "the brief", g.planChat()) + // The row as the process that died last wrote it down: running. + life.publishRunRow(g, TaskNotice{ID: id, Title: "the dead run", State: TaskRunning, StartedAt: time.Now()}) + _ = life.Close() + + reopened := open() + // THE ROW SETTLES WITH ITS STORE. A program's run is one nothing can carry + // on, so the row the reopen restored as interrupted — which the side list + // drew as `?`, waiting on a person — is settled where the page already + // stood: ended, in codeaf's sentence, not a fault, at the instant it was + // last seen, with its span. The sentence is the row's reason, so its ending + // is the one whose reason is read from its report. + rows := reopened.graph().runRows(id) + if len(rows) != 1 || rows[0].State != TaskFailed || rows[0].Ending != TaskEndingProgram || + rows[0].Report != "codeaf closed while fake was running" || !rows[0].EndedAt.Equal(lastSeen) { + t.Fatalf("the row came back as %+v, want it ended in codeaf's sentence, at %v", rows, lastSeen) + } + page, ok := reopened.PlanTaskPage(rootID) + if !ok || page.Row.Status != string(plandb.StatusFailed) || page.Row.Stage != "" || !page.Row.Live.Empty() || + page.Row.Ended.IsZero() || !page.Row.Ended.Equal(lastSeen) { + t.Fatalf("the dead run's page row = %+v (%v), want it ended at %v with no live stage", page.Row, ok, lastSeen) + } + store := beltRunStoreAt(t, place) + defer store.Close() + root := store.Task(rootID) + if root.Status != plandb.StatusFailed || root.Error != "codeaf closed while fake was running" { + t.Fatalf("the reopened conversation left the dead run's task %s (%q)", root.Status, root.Error) + } + if !root.CompletedAt.Equal(lastSeen) { + t.Fatalf("the dead run ended at %v, want where it was last seen, %v", root.CompletedAt, lastSeen) + } + if _, err := os.Stat(filepath.Join(place, planStoreFilename+".1")); !os.IsNotExist(err) { + t.Fatalf("opening a conversation archived its store: %v", err) + } +} + +// A RUN CODEAF CLOSED UNDER READS ENDED ON ITS PAGE, NOT WORKING. Closing the +// conversation writes the run's ending before it cuts the program, and the +// program never lives to clear the stage it was in, so the page's line read +// `working` with no time over a run nothing was driving (found by killing the +// engine under a real senior-dev run). A task the store has ended has no live +// stage, and its time ends where the store says it ended. +func TestAProgramsTaskTheStoreHasEndedHasNoLiveStageAndStopsItsClock(t *testing.T) { + place := t.TempDir() + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: place} + config.Delegates = testPrograms("fake") + }) + g := agent.graph() + id := g.reserve() + rootID := strconv.FormatUint(id, 10) + orphanProgramStore(t, g.planPath(), rootID, "the brief", g.planChat()) + store := beltRunStoreAt(t, place) + if err := store.SetLive(rootID, 3, "fake: working"); err != nil { + t.Fatal(err) + } + endedAt := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond) + if err := store.FailRootAt("codeaf closed while fake was running", endedAt); err != nil { + t.Fatal(err) + } + _ = store.Close() + agent.publishRunRow(g, TaskNotice{ID: id, Title: "the dead run", State: TaskRunning, StartedAt: endedAt.Add(-5 * time.Minute)}) + page, ok := agent.PlanTaskPage(rootID) + if !ok || page.Row.Stage != "" || !page.Row.Live.Empty() { + t.Fatalf("an ended program task's page row = %+v (%v), want no live stage", page.Row, ok) + } + if page.Row.Ended.IsZero() { + t.Fatalf("an ended program task's page row has no end, want its clock stopped where the store ended it") + } +} diff --git a/internal/session/task_run_owed_test.go b/internal/session/task_run_owed_test.go index b47c148e79..17ad2586e2 100644 --- a/internal/session/task_run_owed_test.go +++ b/internal/session/task_run_owed_test.go @@ -61,7 +61,7 @@ func TestOwedRootLandingWakesOnceWithOnlyQuestionAndResult(t *testing.T) { question := "What did the repair find?" summary := RunSummary{Outcome: beltRunOutcomeDone, Result: "The parser now preserves quoted commas."} landing := RunLanding{} - wantDocument := question + "\n\n" + beltRunOutcomeNote(nil, "", summary, landing) + wantDocument := question + "\n\n" + beltRunOutcomeNote(nil, "", summary, landing, 0) completer := &scriptedCompleter{steps: []step{finalText("The repair preserved quoted commas.")}} agent, _ := newTestAgent(t, completer, func(config *Config) { @@ -147,7 +147,7 @@ func TestOwedLandingCompletionReaderSeesQuestionAsAskAndOutcomeAsEvidence(t *tes question := "What is the test's name once it lands?" summary := RunSummary{Outcome: beltRunOutcomeDone, Result: "Test function name: TestDouble."} landing := RunLanding{Branch: "main", Changed: []string{"double.go", "double_test.go"}} - line := beltRunOutcomeNote(nil, "", summary, landing) + line := beltRunOutcomeNote(nil, "", summary, landing, 0) completer := &scriptedCompleter{steps: []step{finalText("The test is TestDouble."), finalText(checkpointNothingLeft)}} agent, _ := newTestAgent(t, completer, func(config *Config) { diff --git a/internal/session/task_run_settle_test.go b/internal/session/task_run_settle_test.go new file mode 100644 index 0000000000..c45b129b4d --- /dev/null +++ b/internal/session/task_run_settle_test.go @@ -0,0 +1,249 @@ +package session + +// A RUN SETTLED AFTER ITS PROCESS WENT AWAY SAYS WHAT ENDED IT AND WHEN. +// +// The reopen road ([Agent.settleInterruptedProgramRow]) and the close road +// ([Agent.cutBeltRun]) each write a program's run's ending without the run +// there to say how it ended, and the project's index closes every run row its +// last process left running ([Agent.closeInflightTaskIndexRows]). These tests +// pin that each of them tells the truth the run left behind: a person's stop +// is a stop, a limit is the limit, a program that had already exited is not +// said to have been running, and every ending is dated by the run's own facts +// rather than by the moment somebody noticed. + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// reopenedWith seeds a program's run the way a process that went away leaves +// it, lets change end it in its store (or not), closes the conversation that +// held its running row, and opens it again. It answers the reopened agent, the +// run's row id and the instant the run was last seen working. +func reopenedWith(t *testing.T, change func(store *plandb.Store, taskDir string, lastSeen time.Time)) (*Agent, uint64, time.Time) { + t.Helper() + place := t.TempDir() + workspace := newTestRepo(t) + registerBeltRunEngine(t, newBeltRunDouble("")) + open := func() *Agent { + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: place} + config.SessionFile = filepath.Join(place, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + life := open() + g := life.graph() + id := g.reserve() + rootID := strconv.FormatUint(id, 10) + started := time.Now().UTC() + lastSeen := orphanProgramStore(t, g.planPath(), rootID, "the brief", g.planChat()) + store, err := plandb.Open(g.planPath(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + change(store, plandb.TaskDir(filepath.Dir(g.planPath()), rootID), lastSeen) + _ = store.Close() + life.publishRunRow(g, TaskNotice{ID: id, Title: "the dead run", State: TaskRunning, StartedAt: started}) + _ = life.Close() + return open(), id, lastSeen +} + +// reopenedRow is the one run row a reopened conversation holds under id. +func reopenedRow(t *testing.T, agent *Agent, id uint64) TaskNotice { + t.Helper() + rows := agent.graph().runRows(id) + if len(rows) != 1 { + t.Fatalf("the reopened conversation holds %d rows under %d, want one: %+v", len(rows), id, rows) + } + return rows[0] +} + +// A PERSON'S STOP IS A STOP AFTER A REOPEN TOO. The person stopped the run and +// quit codeaf while the stop was still settling; the reopened row used to read +// as the program ending itself (`did not finish · stopped: enough`). +func TestAReopenedRunAPersonStoppedSettlesAsTheirStop(t *testing.T) { + agent, id, _ := reopenedWith(t, func(store *plandb.Store, _ string, _ time.Time) { + if err := store.StopRoot("stopped: enough"); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.State != TaskFailed || !row.Stopped || row.Ending != TaskEndingStopped || row.Report != "stopped: enough" { + t.Fatalf("the stopped run came back as %+v, want it settled as the person's stop", row) + } +} + +// A LIMIT ITS PERSON SET IS THE ROW'S ENDING AFTER A REOPEN TOO, and which +// limit it was is read off the run's own facts: the spend against the ceiling +// the run handed its program. +func TestAReopenedRunALimitEndedKeepsWhichLimit(t *testing.T) { + for _, tc := range []struct { + name string + ceiling float64 + want TaskEnding + }{ + {"the dollars ran out", 0.25, TaskEndingCostLimit}, + {"the time ran out", 3.56, TaskEndingTimeLimit}, + } { + t.Run(tc.name, func(t *testing.T) { + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, _ time.Time) { + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", CeilingUSD: tc.ceiling}); err != nil { + t.Fatal(err) + } + if err := store.FailRoot(runLimitSentence); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.State != TaskFailed || row.Ending != tc.want || row.Stopped { + t.Fatalf("the limit-ended run came back as %+v, want the ending %q", row, tc.want) + } + }) + } +} + +// THE ROW CODEAF CLOSED UNDER READS CODEAF'S SENTENCE AS ITS REASON, which is +// what senior-dev.md promises beside `incomplete`; it used to read the fixed +// `was cut short from outside the work` of an ending machinery cut. +func TestAReopenedRunCodeafClosedUnderReadsItsSentence(t *testing.T) { + agent, id, _ := reopenedWith(t, func(*plandb.Store, string, time.Time) {}) + row := reopenedRow(t, agent, id) + if row.Ending != TaskEndingProgram || TaskReasonOf(row.Ending, row.Report) != "codeaf closed while fake was running" { + t.Fatalf("the closed run came back as %+v with the reason %q, want codeaf's sentence", row, TaskReasonOf(row.Ending, row.Report)) + } +} + +// THE PROGRAM'S RECORDED EXIT WINS OVER THE STORE'S LATER ENDING. A store +// ending written after the program had gone (the worker was settling owed +// receipts) used to date the reopened row, which counted the wait as run time. +func TestAReopenedRunEndsAtItsProgramsRecordedExit(t *testing.T) { + var exited time.Time + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, lastSeen time.Time) { + exited = lastSeen.Add(time.Millisecond) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", StartedAt: lastSeen.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + if err := store.FailRoot("fake did not finish: the tests fail"); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if !row.EndedAt.Equal(exited) || row.Ending != TaskEndingProgram { + t.Fatalf("the run came back ended at %v (%+v), want the program's exit %v", row.EndedAt, row, exited) + } +} + +// A PROGRAM THAT HAD ALREADY EXITED IS NOT SAID TO HAVE BEEN RUNNING. codeaf +// closed while the worker was settling the program's receipts, after the +// program was gone: the run is ended where the program ended, in a sentence +// that says the program had ended before codeaf could say where its work is. +func TestClosingAfterTheProgramExitedSaysItHadEnded(t *testing.T) { + place := t.TempDir() + double := newBeltRunDouble("") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: ""}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: place} + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + if _, _, _, err := agent.StartDelegate(context.Background(), "fake", "the brief"); err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + double.mu.Lock() + store := double.spec.Store + double.mu.Unlock() + rootID := store.RootID() + exited := time.Now().UTC() + if err := delegate.WriteProgram(plandb.TaskDir(filepath.Dir(store.Path()), rootID), delegate.ProgramRecord{Name: "fake", StartedAt: exited.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + _ = agent.Close() + kept := beltRunStoreAt(t, place) + root := kept.Task(rootID) + _ = kept.Close() + if root.Error != "fake had ended; codeaf closed before it could say where its work is" || !root.CompletedAt.Equal(exited) { + t.Fatalf("after Close the run's task = %s (%q, ended %v), want it ended at the program's exit %v in a true sentence", root.Status, root.Error, root.CompletedAt, exited) + } + close(double.release) + <-double.finished +} + +// AND THE SAME ON THE REOPEN ROAD: a store left open over a program that had +// exited is ended at that exit, in the same sentence. +func TestAReopenedRunWhoseProgramHadExitedSaysItHadEnded(t *testing.T) { + var exited time.Time + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, lastSeen time.Time) { + exited = lastSeen.Add(time.Millisecond) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", StartedAt: lastSeen.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + }) + row := reopenedRow(t, agent, id) + if row.Report != "fake had ended; codeaf closed before it could say where its work is" || !row.EndedAt.Equal(exited) || row.Ending != TaskEndingProgram { + t.Fatalf("the run came back as %+v, want it ended at the program's exit %v in a true sentence", row, exited) + } +} + +// A RUN ROW THE INDEX CLOSES ON REOPEN ENDS WHERE THE RUN DID, WITH ITS SPAN. +// A program that had finished (its store says done) but whose work codeaf +// closed before landing was closed in the project's index at the reopen +// instant, hours after the run, with no duration. +func TestTheIndexClosesAnInterruptedRunRowFromTheRunsOwnFacts(t *testing.T) { + var exited time.Time + agent, id, _ := reopenedWith(t, func(store *plandb.Store, taskDir string, lastSeen time.Time) { + exited = lastSeen.Add(time.Millisecond) + if err := delegate.WriteProgram(taskDir, delegate.ProgramRecord{Name: "fake", StartedAt: lastSeen.Add(-time.Second), EndedAt: exited}); err != nil { + t.Fatal(err) + } + if err := store.CompleteRoot("finished: the change is in"); err != nil { + t.Fatal(err) + } + }) + var row TaskIndexEntry + for _, entry := range lastPerNode(ReadTaskIndex(agent.config.taskIndexFile())) { + if entry.ID == strconv.FormatUint(id, 10) { + row = entry + } + } + if row.Live() || !row.EndedAt.Equal(exited) || row.DurationMS != exited.Sub(row.StartedAt).Milliseconds() || row.DurationMS <= 0 { + t.Fatalf("the index closed the run as %+v, want it ended at the program's exit %v with its span", row, exited) + } +} + +// AND AN ORDINARY RUN — no program — is closed at its store's last evidence of +// life, not at the reopen instant. +func TestTheIndexClosesAnOrdinaryRunRowAtItsLastEvidenceOfLife(t *testing.T) { + var seen time.Time + agent, id, _ := reopenedWith(t, func(_ *plandb.Store, taskDir string, lastSeen time.Time) { + if err := os.Remove(filepath.Join(taskDir, delegate.ProgramFile)); err != nil { + t.Fatal(err) + } + seen = lastSeen + // The reopen comes well after the run was last seen. + time.Sleep(300 * time.Millisecond) + }) + var row TaskIndexEntry + for _, entry := range lastPerNode(ReadTaskIndex(agent.config.taskIndexFile())) { + if entry.ID == strconv.FormatUint(id, 10) { + row = entry + } + } + if row.Live() || row.EndedAt.After(seen.Add(100*time.Millisecond)) || row.EndedAt.Before(seen) || row.DurationMS <= 0 { + t.Fatalf("the index closed the ordinary run as %+v, want it ended where it was last seen, %v", row, seen) + } +} diff --git a/internal/session/task_status.go b/internal/session/task_status.go index 74f22626ed..7a83c13b5e 100644 --- a/internal/session/task_status.go +++ b/internal/session/task_status.go @@ -557,7 +557,7 @@ func taskEndingIsFault(ending TaskEnding) bool { switch ending { case TaskEndingStopped, TaskEndingWire, TaskEndingUpstream, TaskEndingCircling, TaskEndingBlocked, TaskEndingSteps, TaskEndingNotes, TaskEndingRefused, TaskEndingStale, - TaskEndingInterrupted, TaskEndingTimeLimit, TaskEndingCostLimit: + TaskEndingInterrupted, TaskEndingTimeLimit, TaskEndingCostLimit, TaskEndingProgram: return false } return true @@ -703,7 +703,7 @@ func runRowCannotContinue(n TaskNotice) string { if n.Parent != 0 { return "" } - return runCannotContinue(n.Copy) + return runCannotContinue(n.Copy, n.Program) } // StatusFacts is one record row as the reading takes it. `held` is the caller's @@ -791,8 +791,11 @@ const ( // taskReasonGaps and taskReasonFault are the two the ending alone cannot // answer: what the check found, and what broke. Both read the landing's own // report, which is the only place either sentence exists. - taskReasonGaps = "the check found gaps: " - taskReasonFault = "a fault" + // taskReasonProgram is [TaskEndingProgram]'s reason when the program left + // no sentence, which the worker never lets happen. + taskReasonProgram = "the program it was handed to did not finish it" + taskReasonGaps = "the check found gaps: " + taskReasonFault = "a fault" ) // The six questions a your-call row can be asking, and the two answers each one @@ -869,32 +872,50 @@ const ( // TaskReasonOf is the incomplete reason sentence for one ending, in the person's // own words, and it is the ONE place that table is written down. // -// The report is read for the two endings whose reason is not knowable from the -// word alone: a check that named gaps, whose finding is the first line of its own -// report, and a fault, whose first line is the only account of what broke. A +// The report is read for the three endings whose reason is not knowable from +// the word alone: a check that named gaps, whose finding is the first line of its +// own report, a program's own ending, whose sentence is that first line, and a +// fault, whose first line is the only account of what broke. A // stop is not here at all — `stopped` is its own word, not a kind of incomplete. func TaskReasonOf(ending TaskEnding, report string) string { + if reason, fixed := taskReasonOfEnding(ending); fixed { + return reason + } + return taskReasonOfReport(ending, report) +} + +// taskReasonOfEnding is every ending whose reason is one fixed sentence, which +// the ending alone answers. +func taskReasonOfEnding(ending TaskEnding) (string, bool) { switch ending { case TaskEndingStopped: - return "" + return "", true case TaskEndingInterrupted: - return taskReasonInterrupted + return taskReasonInterrupted, true case TaskEndingWire: - return taskReasonWire + return taskReasonWire, true case TaskEndingUpstream: - return taskReasonUpstream + return taskReasonUpstream, true case TaskEndingCircling: - return taskReasonCircling + return taskReasonCircling, true case TaskEndingBlocked: - return taskReasonBlocked + return taskReasonBlocked, true case TaskEndingSteps: - return taskReasonSteps + return taskReasonSteps, true case TaskEndingNotes: - return taskReasonNotes + return taskReasonNotes, true case TaskEndingStale: - return taskReasonStale + return taskReasonStale, true case TaskEndingTimeLimit, TaskEndingCostLimit: - return taskLimitReason(ending) + return taskLimitReason(ending), true + } + return "", false +} + +// taskReasonOfReport is every ending whose reason is read out of the landing's +// own report, which is the only place the sentence exists. +func taskReasonOfReport(ending TaskEnding, report string) string { + switch ending { case TaskEndingRefused: // THE CHECK'S OWN FINDING OUTRANKS THE WORD FOR IT. "Refused" is the // engine's name for both a check that named gaps and a worker that would @@ -904,6 +925,13 @@ func TaskReasonOf(ending TaskEnding, report string) string { return taskReasonGaps + gaps } return taskReasonRefused + case TaskEndingProgram: + // THE PROGRAM'S OWN SENTENCE IS THE REASON, first line of the report + // (task_run_belt.go's [runEndingWords]), and never under "a fault". + if line := taskFirstLine(report); line != "" { + return line + } + return taskReasonProgram } // An error, or a node that named no ending at all. The gaps are read first // because a landing carrying them was looked at, whatever else went wrong diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 9178ef7faf..1158954a68 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -727,6 +727,12 @@ type runRecord struct { // log where a run's shows how its branch came home, and it refuses the ✕ that // a run's row offers (session's TaskKindJob, internal/tui3's task.go). Kind TaskKind `json:"kind,omitempty"` + // Program is the program the run was handed to ([TaskNotice.Program]). It + // survives for Kind's reason: it decides how the row is DRAWN — a program's + // row wears its badge — and a conversation reopened tomorrow redraws its + // runs from these records long before any plan row is read. Absent is every + // run no program had, and every record written before the field existed. + Program string `json:"program,omitempty"` State TaskState `json:"state"` Stopped bool `json:"stopped,omitempty"` @@ -757,12 +763,27 @@ type runRecord struct { // is all the place has. PlanTask string `json:"planTask,omitempty"` - // ElapsedMS is whatever age the row was last published with, frozen. A run's - // rows do not carry one today — the family publishes no Elapsed — so it is - // absent on every record this code writes, and it is here rather than left - // out because the record's job is to carry the notice, not to decide which - // half of it matters. Zero renders as nothing, which is the emptiness law. + // ElapsedMS is whatever age the row was last published with, frozen. A + // hand-off's run row carries its wall time from the row that settles it — + // the span from the hand-off to the instant its program was gone, or its + // engine answered ([runSpan]) — and an adaptive family's rows publish no + // Elapsed, so theirs is absent. Zero renders as nothing, which is the + // emptiness law. ElapsedMS int64 `json:"elapsed_ms,omitempty"` + + // Ending, Branch, Merge, Result and Changed are HOW THE ROW ENDED AND WHERE + // ITS WORK IS, which a settled run row carries and a conversation reopened + // tomorrow must still say. They were left out, and the drop was visible: a + // program that judged its own work unfinished came back as `a fault: …` — + // the failed-with-no-ending reading — instead of its own sentence, a run + // ended by a limit its person set lost which limit it was, and a row whose + // work was kept on a branch came back naming no branch at all. Each is + // omitted when empty, so an older file decodes exactly as it always did. + Ending TaskEnding `json:"ending,omitempty"` + Branch string `json:"branch,omitempty"` + Merge string `json:"merge,omitempty"` + Result string `json:"result,omitempty"` + Changed []string `json:"changed,omitempty"` } // taskDocument is the file: a type tag, a version, the id counter, the nodes in @@ -1060,6 +1081,16 @@ func (g *TaskGraph) documentLocked() taskDocument { // They are a pair and they are next to each other so that a field added to one // is missing from the other in the same eyeful. func runRowRecord(notice TaskNotice) runRecord { + // AN INTERRUPTED ROW IS WRITTEN DOWN AS THE MOVING ROW IT WAS. Interrupted + // is what a reader makes of a row that was moving when its process went + // away ([runRowNotice]); it is not a state this file holds, and the row was + // written back verbatim, so the next reopen refused the whole checkpoint and + // the conversation lost every task it had, finished ones included. Written + // as running, it comes back interrupted again, and an older build reads it. + state := notice.State + if state == TaskInterrupted { + state = TaskRunning + } return runRecord{ ID: notice.ID, Run: notice.Run, @@ -1067,7 +1098,8 @@ func runRowRecord(notice TaskNotice) runRecord { Parent: notice.Parent, Title: notice.Title, Kind: notice.Kind, - State: notice.State, + Program: notice.Program, + State: state, Stopped: notice.Stopped, Report: notice.Report, Model: notice.Model, @@ -1076,6 +1108,11 @@ func runRowRecord(notice TaskNotice) runRecord { StartedAt: notice.StartedAt, EndedAt: notice.EndedAt, Copy: notice.Copy, + Ending: notice.Ending, + Branch: notice.Branch, + Merge: notice.Merge, + Result: notice.Result, + Changed: append([]string(nil), notice.Changed...), PlanTask: notice.PlanTask, } } @@ -1105,6 +1142,7 @@ func runRowNotice(record runRecord) TaskNotice { Parent: record.Parent, Title: record.Title, Kind: record.Kind, + Program: record.Program, State: record.State, Stopped: record.Stopped, Report: record.Report, @@ -1115,6 +1153,11 @@ func runRowNotice(record runRecord) TaskNotice { StartedAt: record.StartedAt, EndedAt: record.EndedAt, Copy: record.Copy, + Ending: record.Ending, + Branch: record.Branch, + Merge: record.Merge, + Result: record.Result, + Changed: append([]string(nil), record.Changed...), PlanTask: record.PlanTask, } if !notice.State.settled() { @@ -1394,7 +1437,7 @@ func decodeTasks(content []byte) (taskDocument, error) { return taskDocument{}, fmt.Errorf("run row %d is also a node", record.ID) case drawn[record.ID]: return taskDocument{}, fmt.Errorf("run row %d appears twice", record.ID) - case !validTaskState(record.State): + case !validRunRowState(record.State): return taskDocument{}, fmt.Errorf("run row %d is in state %q", record.ID, record.State) case record.ElapsedMS < 0: return taskDocument{}, fmt.Errorf("run row %d has a negative elapsed", record.ID) @@ -1410,6 +1453,15 @@ func decodeTasks(content []byte) (taskDocument, error) { return document, nil } +// validRunRowState is a run row's state as a checkpoint may hold it: a node's +// states, and interrupted too. A FILE AN EARLIER BUILD WROTE WITH AN +// INTERRUPTED ROW is read, not set aside whole: that build wrote the row back +// as the reader had drawn it ([runRowRecord] says why that no longer happens), +// and refusing the file for it cost the conversation every task it had. +func validRunRowState(state TaskState) bool { + return validTaskState(state) || state == TaskInterrupted +} + func validTaskState(state TaskState) bool { switch state { case TaskQueued, TaskRunning, TaskDone, TaskFailed, TaskUnverified: diff --git a/internal/session/taskcost_test.go b/internal/session/taskcost_test.go index b8ba1fb5fd..886ce60ad1 100644 --- a/internal/session/taskcost_test.go +++ b/internal/session/taskcost_test.go @@ -319,3 +319,41 @@ func awaitIndexRow(t *testing.T, path string, id uint64, want func(TaskIndexEntr time.Sleep(5 * time.Millisecond) } } + +// A CLOSED NODE'S DOLLARS REACH THE CONVERSATION'S meta.json AS THEY REACH ITS +// BOOKS. Home reads a conversation's bill as the larger of the books stamped +// there and its rows in the project's index, which is exact only while the +// stamp already holds every node the index names. A stamp that waited for the +// next turn's seal left a card reading the work alone, with the talking that +// commissioned it missing until the person spoke again. +func TestAClosedNodesDollarsAreStampedOnTheConversation(t *testing.T) { + dir := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Place = Place{Dir: dir} + config.SessionFile = Place{Dir: dir}.Transcript() + }) + child := spentChild(t, 0.75) + + graph := agent.graph() + graph.mu.Lock() + graph.run = func(node *TaskNode) { + agent.foldTaskUsage(node, child) + node.finish("the greeting is written", []string{"greet.go"}, "", mergeInPlace) + node.graph.complete(node, TaskDone) + } + graph.mu.Unlock() + id := graph.reserve() + graph.admit(id, taskSpec{ + title: "Add the greeting", brief: "write greet.go", acceptance: "the file is there", + model: "vendor/worker", + }) + waitDoneNode(t, graph.node(id)) + + meta, err := LoadMeta(dir) + if err != nil { + t.Fatalf("LoadMeta: %v", err) + } + if meta.SpentUSD != 0.75 { + t.Fatalf("meta.json says the conversation spent %v once its node closed, want the node's $0.75", meta.SpentUSD) + } +} diff --git a/internal/session/taskelsewhere.go b/internal/session/taskelsewhere.go index 332068f785..9b82974085 100644 --- a/internal/session/taskelsewhere.go +++ b/internal/session/taskelsewhere.go @@ -163,6 +163,33 @@ func (a *Agent) ElsewhereExcept(others ...string) Elsewhere { return ReadElsewhere(bucket, time.Now(), append([]string{a.config.Place.ID()}, others...)...) } +// ElsewhereOf is [Agent.Elsewhere] asked by a surface that holds a +// conversation's TRANSCRIPT and not its agent: the reading of the bucket that +// conversation's folder is in, with that conversation left out. +// +// IT EXISTS BECAUSE THE ORDINARY WINDOW HOLDS NO AGENT. Bare `codeaf` is a +// surface talking to this workspace's engine over a socket, and what it holds is +// a connection ([remote.Agent]), which has no reading of the disk to offer. The +// engine is on THIS machine, though, and the presence files are on this +// machine's disk beside the transcript the surface was handed — so the answer is +// the same arithmetic [Agent.ElsewhereExcept] does on its [Place], done on the +// path: the transcript's folder is the session, and its parent is the bucket. +// +// A transcript with no folder of its own has no bucket to look in and no id to +// leave out, and answers the empty reading, as a memory-only agent does. +func ElsewhereOf(transcript string, now time.Time) Elsewhere { + transcript = strings.TrimSpace(transcript) + if transcript == "" { + return Elsewhere{Read: now} + } + dir := filepath.Dir(transcript) + bucket := filepath.Dir(dir) + if dir == "." || bucket == "" || bucket == "." || bucket == dir { + return Elsewhere{Read: now} + } + return ReadElsewhere(bucket, now, filepath.Base(dir)) +} + // Any reports whether another window is open on this project at all. It is the // cheapest form of the question and the one a surface asks before it decides // whether a section exists. diff --git a/internal/session/taskelsewhere_test.go b/internal/session/taskelsewhere_test.go index e39b1770b8..849923c19b 100644 --- a/internal/session/taskelsewhere_test.go +++ b/internal/session/taskelsewhere_test.go @@ -201,3 +201,29 @@ func TestElsewhereIsEmptyForASessionWithNoFolder(t *testing.T) { t.Fatal("a memory-only conversation found other windows on a project it is not in") } } + +// A SURFACE THAT HOLDS ONLY A TRANSCRIPT READS THE SAME OTHER WINDOWS its agent +// would. The ordinary window talks to its engine over a socket and holds no +// agent; the presence files are on this machine's disk beside the transcript it +// was handed, and the reading off that path is the reading off the [Place]. +func TestElsewhereOfATranscriptIsTheReadingItsAgentWouldTake(t *testing.T) { + bucket := t.TempDir() + writeWindow(t, bucket, "mine", "", time.Second, + PresenceTask{ID: "1", Title: "My own task", State: string(TaskRunning)}) + writeWindow(t, bucket, "theirs", "docs pass", time.Second, + PresenceTask{ID: "7", Title: "Their task", State: string(TaskRunning)}) + + away := ElsewhereOf(Place{Dir: filepath.Join(bucket, "mine")}.Transcript(), time.Now()) + tasks := away.Tasks() + if len(tasks) != 1 || tasks[0].Task.Title != "Their task" || tasks[0].SessionID != "theirs" { + t.Fatalf("the reading is %+v, want only the other window's work", tasks) + } + if got := tasks[0].Session; got != "docs pass" { + t.Fatalf("the other window is called %q", got) + } + for _, empty := range []string{"", "transcript.jsonl"} { + if ElsewhereOf(empty, time.Now()).Any() { + t.Fatalf("a transcript with no folder (%q) found other windows", empty) + } + } +} diff --git a/internal/session/taskmodel.go b/internal/session/taskmodel.go index f127a7fc60..7a60d2367f 100644 --- a/internal/session/taskmodel.go +++ b/internal/session/taskmodel.go @@ -36,9 +36,11 @@ package session // caller had before this file existed. import ( + "slices" "sort" "strings" + "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/roles" ) @@ -396,3 +398,112 @@ func taskModelMovedNote(model, next string) string { func taskModelMovedSentence(from, to string) string { return from + " stopped answering, so this ran again on " + to } + +// resolveProgramModels is [Agent.resolveTaskModel] for a program, which works +// with one model or several: a `model` naming more than one, separated by +// commas, is resolved word by word, and each word must name exactly one model. +// One word is resolved as any task's is, its shortlist and all. +// +// EVERY MODEL NAMED MUST BE ONE A CONNECTED SERVICE CAN SERVE. The list a word +// is matched against is the whole catalog, and a model none of the person's +// services can reach was handed to the program anyway and then answered, call +// after call, on the crew's working seat by the run's model API — the person +// asked for one model and was quietly given another. It is refused here, by +// name, before a card is shown. +// +// A WORD SPELLED WITH A CONNECTED SERVICE'S PREFIX IS TAKEN AS WRITTEN. The +// catalog a word is matched against lists no model of a service the person +// connected themselves (a local proxy, a box of their own), so `mybox/qwen3` +// matched nothing there while it is exactly how that service is asked; it is +// the person naming a service and a model, and is kept when the service can +// take a call. +func (a *Agent) resolveProgramModels(word string) taskModelChoice { + var words []string + for _, part := range strings.Split(word, ",") { + if part = strings.TrimSpace(part); part != "" { + words = append(words, part) + } + } + switch len(words) { + case 0: + return a.resolveTaskModel(word) + case 1: + return a.resolveProgramWord(words[0]) + } + var models []string + for _, part := range words { + choice := a.resolveProgramWord(part) + switch { + case choice.problem != "": + return choice + case len(choice.options) > 0: + return taskModelChoice{problem: taskModelVague(part, choice.options)} + } + if !slices.Contains(models, choice.model) { + models = append(models, choice.model) + } + } + return taskModelChoice{model: strings.Join(models, ",")} +} + +// resolveProgramWord is one word of [Agent.resolveProgramModels]: a model, a +// shortlist of the ones a service serves, or the refusal. +func (a *Agent) resolveProgramWord(word string) taskModelChoice { + sources := a.programSources() + if segment, bare := modelsource.Split(word, sources.Written()); segment != "" && bare != "" { + if !ServesModel(sources, word) { + return taskModelChoice{problem: programUnservedProblem(word)} + } + return taskModelChoice{model: word} + } + choice := a.resolveTaskModel(word) + serves := func(model string) bool { return sources.Empty() || ServesModel(sources, model) } + switch { + case choice.problem != "": + return choice + case len(choice.options) > 0: + choice.options = slices.DeleteFunc(choice.options, func(model string) bool { return !serves(model) }) + switch len(choice.options) { + case 0: + return taskModelChoice{problem: programUnservedProblem(word)} + case 1: + return taskModelChoice{model: choice.options[0]} + } + return choice + case !serves(choice.model): + return taskModelChoice{problem: programUnservedProblem(choice.model)} + } + return choice +} + +// programSources is this conversation's model services, read under the lock +// the surface moves them under. A conversation with none cannot be asked what +// they serve, and a model is not refused on that account. +func (a *Agent) programSources() modelsource.Set { + a.mu.Lock() + defer a.mu.Unlock() + return a.config.Sources.OrDefault(a.config.APIKey, a.config.BaseURL) +} + +// programUnservedProblem is the refusal for a model no connected service serves. +func programUnservedProblem(model string) string { + return "none of the model services connected here can serve " + model + + ", so a program cannot be handed it; name a model one of them serves, spelled with its service's prefix when it is not the default service's" +} + +// programAsked is the models a proposal asked its program to work with: what +// its `model` resolved to when the proposal named one, and nothing when it +// named none, so the run is handed the crew rather than the default a card +// shows for a task. +func programAsked(spec taskSpec) []string { + if strings.TrimSpace(spec.modelWord) == "" || strings.TrimSpace(spec.model) == "" { + return nil + } + var asked []string + for _, model := range strings.Split(spec.model, ",") { + if model = strings.TrimSpace(model); model != "" { + asked = append(asked, model) + } + } + return asked +} diff --git a/internal/session/taskpresence.go b/internal/session/taskpresence.go index dc87e1b2e3..022dfe69af 100644 --- a/internal/session/taskpresence.go +++ b/internal/session/taskpresence.go @@ -910,7 +910,10 @@ func (a *Agent) presenceSnapshot(now time.Time) SessionPresence { // would leave that window saying `waiting on you` with nothing after it. snapshot.Question = a.presenceAsk() } - snapshot.RunningTasks = append(a.presenceTasks(), a.presenceRuns()...) + // A HAND-OFF'S RUN IS WORK OUT TOO, and its row is in the project's index + // from its first breath (task_run_index.go); without it here every other + // window judged that row by the join and counted a live run as incomplete. + snapshot.RunningTasks = append(append(a.presenceTasks(), a.presenceRuns()...), a.presenceBeltRuns()...) snapshot.Jobs = a.presenceJobs() return snapshot } diff --git a/internal/session/taskprogram_test.go b/internal/session/taskprogram_test.go new file mode 100644 index 0000000000..93b6fc38dd --- /dev/null +++ b/internal/session/taskprogram_test.go @@ -0,0 +1,177 @@ +package session + +// A program's work says which program has it (task_contract.go's +// [TaskNotice.Program]): on the proposal a person answers, on the first row its +// run publishes and every row after it, in the checkpoint a reopened +// conversation redraws from, in the project's index every other reader draws +// from, and in the tasks tool's own words. The surfaces draw a badge from it +// (internal/tui3's programbadge.go); these tests hold the engine to carrying it. + +import ( + "context" + "encoding/json" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// THE PROPOSAL NAMES THE PROGRAM, so the card a person approves can say where the +// work is going; the question every reader of a proposal gets says it in words; +// and a task no program is handed asks exactly the sentence it always asked. +func TestAProposalNamesTheProgramItIsGoingTo(t *testing.T) { + spec := taskSpec{title: "rewrite the auth middleware", summary: "swap the session store", via: "senior-dev"} + question := newTaskQuestion(7, spec, "", time.Time{}, Config{}) + if question.notice.Program != "senior-dev" { + t.Fatalf("the proposal's notice names program %q, want senior-dev", question.notice.Program) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + asked := agent.proposalQuestion(7, question.notice) + if want := "wants to start a [senior-dev] task: rewrite the auth middleware"; asked.Head != want { + t.Fatalf("the proposal asks %q, want %q", asked.Head, want) + } + if asked.Subject.Name != "rewrite the auth middleware" { + t.Fatalf("the proposal's subject is %q, want the task's title alone", asked.Subject.Name) + } + + spec.via = "" + ordinary := newTaskQuestion(8, spec, "", time.Time{}, Config{}) + if ordinary.notice.Program != "" { + t.Fatalf("an ordinary proposal names program %q", ordinary.notice.Program) + } + if got := agent.proposalQuestion(8, ordinary.notice).Head; got != TaskProposalLead+"rewrite the auth middleware" { + t.Fatalf("an ordinary proposal asks %q", got) + } + if ProgramBadge(" ") != "" || ProgramBadge("doc-writer") != "[doc-writer]" { + t.Fatalf("the badge's spelling is %q / %q", ProgramBadge(" "), ProgramBadge("doc-writer")) + } +} + +// A TYPED `/<name>` RUN NAMES ITS PROGRAM FROM ITS FIRST ROW TO ITS LAST, and so +// does its row in the project's index: the hand-off's running row, and the row +// that settles it — which the landing publishes without restating the program, +// so it reaches the settled row only because [Agent.publishRunRow] carries it. +// Another conversation's tasks tool says it in words. +func TestAProgramsRunNamesItsProgramFromTheHandOffToTheIndex(t *testing.T) { + double := newBeltRunDouble("submitted and verified") + registerBeltRunEngine(t, double) + bucket := t.TempDir() + workspace := newTestRepo(t) + conversation := func(name string) *Agent { + dir := filepath.Join(bucket, name) + agent, _ := newTestAgent(t, beltRunCompleter{text: "submitted and verified"}, func(config *Config) { + config.Workspace = workspace + config.Place = Place{Dir: dir} + config.SessionFile = filepath.Join(dir, placeTranscript) + config.AskConsent = false + config.Delegates = testPrograms("fake") + }) + return agent + } + runner, other := conversation("runner"), conversation("other") + + id, _, _, err := runner.StartDelegate(context.Background(), "fake", "add two files to the project") + if err != nil { + t.Fatalf("StartDelegate: %v", err) + } + <-double.entered + key := strconv.FormatUint(id, 10) + row, ok := runRowOf(runner.graph(), id) + if !ok || row.Program != "fake" || row.State != TaskRunning { + t.Fatalf("the run's first row = %+v, want it running and naming fake", row) + } + if entry := indexRowFor(t, runner, key); entry.Program != "fake" { + t.Fatalf("the index's running row = %+v, want it naming fake", entry) + } + found, failed := runTool(t, other, "tasks", `{"query":"two files"}`) + if failed || !strings.Contains(found, "via fake") { + t.Fatalf("the other conversation's tasks tool answered %q (failed %v), want the program named", found, failed) + } + + endBeltRun(t, runner, double) + row, ok = runRowOf(runner.graph(), id) + if !ok || !row.State.settled() || row.Program != "fake" { + t.Fatalf("the run's settled row = %+v, want it still naming fake", row) + } + if entry := indexRowFor(t, runner, key); !TaskState(entry.Status).settled() || entry.Program != "fake" { + t.Fatalf("the index's closing row = %+v, want it settled and naming fake", entry) + } +} + +// EVERY PUBLISH AFTER THE FIRST CARRIES THE PROGRAM FORWARD, the way it carries +// the copy: a stop, a landing and a carry-on each publish a row that knows +// nothing about which program had the work, and the row they replace is the only +// place that fact was. A row that names a program of its own is not overruled, +// and a run no program had gains none. +func TestAPublishCarriesTheProgramForward(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + g := agent.graph() + agent.publishRunRow(g, TaskNotice{ID: 11, Title: "the program's run", State: TaskRunning, Program: "fake"}) + agent.publishRunRow(g, TaskNotice{ID: 11, Title: "the program's run", State: TaskDone}) + if row, _ := runRowOf(g, 11); row.Program != "fake" { + t.Fatalf("the settled row = %+v, want the program carried forward", row) + } + agent.publishRunRow(g, TaskNotice{ID: 12, Title: "a bash worker's run", State: TaskRunning}) + agent.publishRunRow(g, TaskNotice{ID: 12, Title: "a bash worker's run", State: TaskDone}) + if row, _ := runRowOf(g, 12); row.Program != "" { + t.Fatalf("a run no program had names %q", row.Program) + } +} + +// THE CHECKPOINT KEEPS THE PROGRAM, so a conversation reopened tomorrow redraws +// its program's run wearing its badge before any plan row is read; and a record +// written before the field existed reads back as no program at all. +func TestARunsRecordKeepsItsProgram(t *testing.T) { + notice := TaskNotice{ID: 7, Title: "rewrite the auth middleware", State: TaskDone, Program: "senior-dev"} + record := runRowRecord(notice) + encoded, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(encoded), `"program":"senior-dev"`) { + t.Fatalf("the record is %s, want the program written down", encoded) + } + var read runRecord + if err := json.Unmarshal(encoded, &read); err != nil { + t.Fatal(err) + } + if back := runRowNotice(read); back.Program != "senior-dev" { + t.Fatalf("the restored row = %+v, want it naming senior-dev", back) + } + var older runRecord + if err := json.Unmarshal([]byte(`{"id":7,"title":"x","state":"done"}`), &older); err != nil { + t.Fatal(err) + } + if runRowNotice(older).Program != "" { + t.Fatal("a record with no program read back as one") + } + if ordinary, _ := json.Marshal(runRowRecord(TaskNotice{ID: 8, State: TaskDone})); strings.Contains(string(ordinary), "program") { + t.Fatalf("an ordinary run's record is %s, which writes an empty program", ordinary) + } +} + +// THE TASKS TOOL SAYS WHICH PROGRAM HAS THE WORK, as the last fact on a row's +// first line, in the word propose_task hands work to one with — and says +// nothing of the kind about a task no program had. +func TestTheTasksToolSaysWhichProgramHasTheWork(t *testing.T) { + entry := TaskIndexEntry{ID: "7", Name: "rewrite-the-auth", Title: "rewrite the auth middleware", Status: string(TaskDone), Program: "senior-dev"} + first, _, _ := strings.Cut(taskRowText(entry), "\n") + if !strings.HasSuffix(first, " · via senior-dev") || !strings.HasPrefix(first, "7 · rewrite-the-auth · ") { + t.Fatalf("a program's row reads %q, want it ending in the program", first) + } + entry.Program = "" + if text := taskRowText(entry); strings.Contains(text, "via") { + t.Fatalf("an ordinary row reads %q", text) + } + + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + listing := agent.planTasksText([]PlanTaskRow{ + {ID: "t-7", Title: "rewrite the auth middleware", Status: "running", Program: "senior-dev"}, + {ID: "t-8", Title: "an ordinary task", Status: "done"}, + }, "") + lines := strings.Split(strings.TrimSpace(listing), "\n") + if len(lines) != 2 || !strings.HasSuffix(lines[0], "running · via senior-dev") || strings.Contains(lines[1], "via") { + t.Fatalf("the run's listing reads:\n%s", listing) + } +} diff --git a/internal/session/taskstands.go b/internal/session/taskstands.go index 27b1a643e6..65b26c50fa 100644 --- a/internal/session/taskstands.go +++ b/internal/session/taskstands.go @@ -64,6 +64,8 @@ import ( "strings" "github.com/Agent-Field/agentfield/sdk/go/ai" + + "github.com/Agent-Field/codeaf/internal/delegate" ) // taskStand is what the ladder came to: the ground, how this task stands on it, @@ -151,6 +153,25 @@ const taskGroundPathsRead = 400 // answer to the one question this file exists to have one answer to. func (a *Agent) resolveTaskGround(spec taskSpec) taskStand { workspace := canonicalPath(strings.TrimSpace(a.config.Workspace)) + if program, err := a.delegateFor(spec.via); err == nil { + return programGround(spec, workspace, program) + } + stand := a.ordinaryTaskGround(spec, workspace) + // AND NOTHING ELSE OF CODEAF'S WORKS IN A FOLDER A PROGRAM'S RUN HOLDS + // (programhold.go), so the card is never shown for work that could only + // be cut from the program's unfinished branch or land under it. + if stand.ask == "" && stand.refusal == "" { + if refusal := standHeldRefusal(stand, workspace); refusal != "" { + return taskStand{refusal: refusal} + } + } + return stand +} + +// ordinaryTaskGround is [Agent.resolveTaskGround] for work no program is handed: +// the placement a model asked for, the ladder, the brief's last word, and the +// mode. +func (a *Agent) ordinaryTaskGround(spec taskSpec, workspace string) taskStand { redirect := "" // A MODEL'S PLACEMENT IS EVIDENCE, NOT AUTHORITY, INSIDE A REPOSITORY. A // branch is the repository's isolation boundary even when `where` asked for @@ -309,18 +330,7 @@ func (a *Agent) taskGroundOrStandingIn(spec taskSpec) taskStand { // answered for. func (a *Agent) groundLadder(spec taskSpec, workspace string) taskStand { if said := strings.TrimSpace(spec.ground); said != "" { - dir, err := resolveTaskWhere(said, workspace) - if err != nil { - return taskStand{refusal: "this task names a folder it cannot work in: " + said} - } - if info, err := os.Stat(dir); err != nil || !info.IsDir() { - // A ground is a place that IS there. Unlike `where`, which is somebody - // saying where work should go and may name a folder to be made, this - // argument names the project the work is about — and a project nobody - // can find is a mistake worth saying out loud rather than creating. - return taskStand{refusal: "this task names a folder that is not there: " + dir} - } - return taskStand{dir: groundRoot(dir), rung: taskGroundSaid} + return saidGround(said, workspace) } // A PART STANDS WHERE ITS PARENT STANDS, and the rungs below are not climbed // for it. A sub-task's branch is cut from its parent's worktree and merges @@ -358,6 +368,77 @@ func (a *Agent) groundLadder(spec taskSpec, workspace string) taskStand { return taskStand{dir: workspace, rung: taskGroundNothing} } +// saidGround is the rung a proposal's own `ground` answers at. +func saidGround(said, workspace string) taskStand { + dir, err := resolveTaskWhere(said, workspace) + if err != nil { + return taskStand{refusal: "this task names a folder it cannot work in: " + said} + } + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + // A ground is a place that IS there. Unlike `where`, which is somebody + // saying where work should go and may name a folder to be made, this + // argument names the project the work is about — and a project nobody + // can find is a mistake worth saying out loud rather than creating. + return taskStand{refusal: "this task names a folder that is not there: " + dir} + } + return taskStand{dir: groundRoot(dir), rung: taskGroundSaid} +} + +// programGround is where a program works: the `ground` its proposal names, or +// this conversation's own folder when it names none. NOTHING ELSE IS READ. +// +// The ladder above weighs `where`, the brief and the paths this conversation +// touched, because a task of codeaf's own may be placed by any of them. A +// program is handed ONE folder for an hour, and it has to be the one the model +// said. The ladder's `in place` rung answered with the conversation's folder +// before `ground` was read, so a chat opened in the person's home folder that +// made ~/Desktop/pong, named it as ground and said `in place` handed senior-dev +// the whole home folder; senior-dev, finding no git history there, began to +// snapshot all of it and died on the first folder macOS keeps to itself +// (`open /Users/…/.Trash: operation not permitted`). So a program's placement +// is its own (programfolder.go) and `where` is not read for it. +// +// THE FOLDER IS READ BEFORE THE CARD, as the run will read it: snapped to its +// repository's root, a folder not there yet taken when it can be made, and +// refused for what would refuse the run — the home folder, a checkout with +// changes that are not committed or a merge half done, another program's run +// already in it — so nobody is asked to approve work that cannot start. +func programGround(spec taskSpec, workspace string, program delegate.Delegate) taskStand { + dir, rung := workspace, taskGroundHere + if said := strings.TrimSpace(spec.ground); said != "" { + resolved, err := resolveTaskWhere(said, workspace) + if err != nil { + return taskStand{refusal: "this task names a folder it cannot work in: " + said} + } + dir, rung = canonicalPath(resolved), taskGroundSaid + } + if refusal := programGroundRefusal(program, &dir); refusal != "" { + return taskStand{refusal: refusal} + } + placed := delegateStand(dir) + placed.rung = rung + return placed +} + +// programGroundRefusal reads the folder a program's proposal names the way +// [PrepareProgramFolder] will, and answers what would refuse it, "" when +// nothing would. It moves dir to the folder the program would work in, and it +// changes nothing on disk. +func programGroundRefusal(program delegate.Delegate, dir *string) string { + folder, repo, _, refusal := programFolderAt(program, *dir, "say which folder the work is in, as ground") + if refusal != "" || !program.LandsTree() { + return refusal + } + *dir = folder + if hold, busy := programHoldNear(canonicalPath(folder), ""); busy { + return programFolderBusy(folder, hold) + } + if repo { + return programCheckoutInTheWay(folder, program.Notes) + } + return "" +} + // groundPlainlyNamedByBrief reports the one ground that holds every existing // absolute place the contract writes down: the repository they are all inside, // or, where none is in a repository, the one named folder that holds them all. diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index ad562fd3d5..a383b05050 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -372,7 +372,9 @@ func (a *Agent) taskSearchText(query string, limit int, scope string) string { if limit <= 0 && a.config.taskID != 0 { limit = taskFanLimit } - out := taskRowsTextLimit(a.taskRows(), query, limit) + // THIS CONVERSATION'S OWN RUNS ARE LEFT TO THEIR STORE'S LISTING, which the + // tool puts above this one ([Agent.withoutOwnRunRows]). + out := taskRowsTextLimit(a.withoutOwnRunRows(a.taskRows()), query, limit) if !a.tellsElsewhere() { return a.taskConversationHint(out) } @@ -755,9 +757,10 @@ func (a *Agent) oneTask(ctx context.Context, token string, parsed tasksArguments if parsed.Stop && (parsed.Continue || parsed.Forward || strings.TrimSpace(parsed.Resolve) != "") { return "stop ends the task, so it cannot be combined with continue, resolve or forward; send one action at a time.", true, nil } - // A LIVE RUN HAS NO PROJECT-INDEX ROW YET. Its rows are kept beside the - // graph's nodes and are written to the finished-work index only when the run - // ends, so a stop must resolve that live owner before asking the index. Every + // A LIVE RUN IS STOPPED THROUGH ITS OWNER, NEVER THROUGH THE INDEX. Its rows + // are kept beside the graph's nodes, not among them, and the index row this + // conversation's own run writes is left out of this reader + // ([Agent.withoutOwnRunRows]), so a stop resolves the live run first. Every // other operation keeps its existing reader: run details come from the plan // store and ordinary tasks come from the graph and project index below. if parsed.Stop { @@ -765,7 +768,7 @@ func (a *Agent) oneTask(ctx context.Context, token string, parsed tasksArguments return a.stopOneTask(entry, id, true, parsed.Say) } } - rows := a.taskRows() + rows := a.withoutOwnRunRows(a.taskRows()) entry, found := a.taskByToken(rows, token) if !found { // CONTINUE ON A MISS IS NOT "NO TASK". The person named a number and @@ -1359,6 +1362,15 @@ func taskChildRowText(entry TaskIndexEntry, withURI bool) string { // Every clause that has nothing to say is DROPPED rather than written empty. A // row reading "· 0 files · · $0.00" is three facts this build does not have, // stated as though it did. +// +// A PROGRAM'S WORK SAYS WHICH PROGRAM HAS IT, as the last fact on its first +// line (`7 · rewrite-the-auth · working · running for 3m · via senior-dev`), in +// the word `propose_task` hands work to one with. The person's side list wears +// the program's badge on the same row, and a model that could not tell a +// program's work from its own worker's would answer "what is running?" wrongly +// about exactly the work the person can see is different. It trails the figures +// rather than parting the name from its state, which is the pair a reader of +// this line reads first. func taskRowText(entry TaskIndexEntry) string { parts := []string{entry.ID, entry.Name, taskEntryWord(entry)} if word := taskWhenWord(entry); word != "" { @@ -1370,8 +1382,11 @@ func taskRowText(entry TaskIndexEntry) string { if entry.DurationMS > 0 { parts = append(parts, taskSpanWord(entry.Duration())) } - if entry.Cost > 0 { - parts = append(parts, "$"+strconv.FormatFloat(entry.Cost, 'f', 2, 64)) + if cost := taskDollarWord(entry.Cost); cost != "" { + parts = append(parts, cost) + } + if via := taskViaWord(entry.Program); via != "" { + parts = append(parts, via) } out := strings.Join(parts, " · ") + "\n " + entry.Title if entry.Outcome != "" { @@ -1391,6 +1406,26 @@ func taskRowText(entry TaskIndexEntry) string { return out + "\n" } +// taskViaWord is the clause a program's work carries in this tool's text — +// `via senior-dev` — and "" for every task no program was handed, which says +// nothing rather than `via` and a blank. It is one word for the index's rows and +// the run's store rows alike, so the two listings name a program the same way. +func taskViaWord(program string) string { + if program = strings.TrimSpace(program); program == "" { + return "" + } + return "via " + program +} + +// taskDollarWord is a task's spend as the tasks tool spells it on every row, +// `$0.31`, and "" for none, which says nothing rather than `$0.00`. +func taskDollarWord(usd float64) string { + if usd <= 0 { + return "" + } + return "$" + strconv.FormatFloat(usd, 'f', 2, 64) +} + // taskWhereClauses is the trailing line a row may carry: where the work IS, the // record's own verdict when the work did not settle whole, and where the STORY // is. It is ONE builder for a root row and a queried child, so the two can never @@ -1510,12 +1545,19 @@ func TaskAgeWord(d time.Duration) string { // Told no task existed, the conversation set out to verify the work by running // the suite itself. The rows are read where the surface reads them // ([Agent.PlanTasks]), so the tool and the rail cannot disagree about what ran. +// +// AND IT WAS BLIND AGAIN TO EVERY senior-dev RUN (2026-09-24, the real +// binary): the reader was gated on the bash-belt switch, which a program's run +// never sets, so `tasks {"id":3}` answered `No task "3" in this project` over a +// run the rail was drawing, and the model went looking through unrelated older +// rows. It reads the plan the pages read ([TaskGraph.planForPages]), which is +// this conversation's store whatever the switch says and never makes one. func (a *Agent) runPlanTasks() []PlanTaskRow { g := a.graph() if g == nil { return nil } - plan := g.planIfArmed() + plan := g.planForPages() if plan == nil || plan.chat == "" { return nil } @@ -1547,9 +1589,16 @@ func planTaskLabels(rows []PlanTaskRow) map[string]string { } // planTasksText is the run's tasks as a listing: the name a person sees, the -// title, the state, the first line of what came back, and the newest note -// anybody left on it. Empty when there is no run or nothing in it matches, so -// the caller's own listing stands alone. +// title, the state, how long it ran, what it cost, the first line of what came +// back, and the newest note anybody left on it. Empty when there is no run or +// nothing in it matches, so the caller's own listing stands alone. +// +// A PROGRAM'S RUN SAYS HOW LONG IT TOOK AND WHAT IT COST, off the run's one pair +// ([planRowSpanWord], task_run_clock.go) and its spend rows ([taskDollarWord]): +// the tool said neither, and a model asked how long senior-dev took or what it +// cost could only guess. AND IT SAYS WHICH PROGRAM HAS IT, after the clock, in +// [taskViaWord]'s one spelling — the same place on the line [taskRowText] puts +// it. // // THE NOTE IS ON THE ROW BECAUSE NOBODY WAS READING IT. A note is the channel a // worker uses to say that another task's premise is wrong, and a person uses to @@ -1563,6 +1612,7 @@ func planTaskLabels(rows []PlanTaskRow) map[string]string { func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { query = strings.ToLower(strings.TrimSpace(query)) labels := planTaskLabels(rows) + now := a.taskClockNow() var b strings.Builder for _, row := range rows { page, _ := a.PlanTaskPage(row.ID) @@ -1578,6 +1628,15 @@ func (a *Agent) planTasksText(rows []PlanTaskRow, query string) string { if word != "" { fmt.Fprintf(&b, " · %s", word) } + if span := planRowSpanWord(row, now); span != "" { + fmt.Fprintf(&b, " · %s", span) + } + if cost := taskDollarWord(row.USD); cost != "" { + fmt.Fprintf(&b, " · %s", cost) + } + if via := taskViaWord(row.Program); via != "" { + fmt.Fprintf(&b, " · %s", via) + } if line := summaryFirstLine(page.Result, runAskLineChars); line != "" { fmt.Fprintf(&b, " · %s", line) } @@ -1733,11 +1792,20 @@ func (a *Agent) planTaskText(rows []PlanTaskRow, token string) (string, bool) { return "", false } var b strings.Builder - fmt.Fprintf(&b, "%s · %s", labels[id], cutChars(page.Row.Title, runAskLineChars)) + head := labels[id] + " · " + cutChars(page.Row.Title, runAskLineChars) if word := page.Row.StateWord(); word != "" { - fmt.Fprintf(&b, " · %s", word) + head += " · " + word + } + if span := planRowSpanWord(page.Row, a.taskClockNow()); span != "" { + head += " · " + span + } + if cost := taskDollarWord(page.Row.USD); cost != "" { + head += " · " + cost + } + if via := taskViaWord(page.Row.Program); via != "" { + head += " · " + via } - fmt.Fprintf(&b, "\n\nbrief:\n%s\n", cutChars(page.Description, runAskBodyChars)) + fmt.Fprintf(&b, "%s\n\nbrief:\n%s\n", head, cutChars(page.Description, runAskBodyChars)) if page.Result != "" { fmt.Fprintf(&b, "\nresult:\n%s\n", cutChars(page.Result, runAskBodyChars)) } diff --git a/internal/session/wakecause.go b/internal/session/wakecause.go index 2cb39ac064..d7392cc01e 100644 --- a/internal/session/wakecause.go +++ b/internal/session/wakecause.go @@ -71,6 +71,7 @@ const ( // places a message reaches the transcript — the turn's opening and the steering // drain — so a landing that arrives mid-turn is owed by the turn it lands in. func (a *Agent) rememberOwedLocked(user userMessage) { + a.rememberProgramOutcomeLocked(user) if question := strings.TrimSpace(user.landingQuestion); question != "" { a.oweLocked(owedAsk{text: question, from: owedByPerson}) if outcome := strings.TrimSpace(user.landingOutcome); outcome != "" { @@ -146,7 +147,9 @@ func (a *Agent) oweLocked(ask owedAsk) { // forgetOwedLocked clears the previous turn's owed asks and the results they // arrived with. Called once, where a turn opens. -func (a *Agent) forgetOwedLocked() { a.owedAsks, a.landingOutcomes, a.turnResults = nil, nil, nil } +func (a *Agent) forgetOwedLocked() { + a.owedAsks, a.landingOutcomes, a.turnResults, a.programOutcomeNow = nil, nil, nil, nil +} // turnAsk is the ask this turn's endings are read against. // diff --git a/internal/tui3/app.go b/internal/tui3/app.go index 3e0cdddaa5..994ea0a85d 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -895,6 +895,10 @@ type app struct { // states the whole contract, taskowner.go is the only caller). Nil is a // window with no engine road, which answers with the card instead. openTaskOwner func(TaskOwnerAsk) (TaskOwnerView, error) + // elsewhereOf reads the other conversations' presence off this machine's + // disk for a window whose agent cannot (tui3.go's [Options.Elsewhere]; + // taskview.go's [app.refreshElsewhere] is the only caller). + elsewhereOf func(transcript string, now time.Time) session.Elsewhere // taskOwnerGen numbers the attaches this window has asked for and taskOwnerAt // is the one still in flight. An answer carrying an older number is a view // nobody wants any more: it is CLOSED on arrival rather than drawn, which is @@ -1039,6 +1043,10 @@ type app struct { // answer belong to the replay that asked for it. historyLoading bool historyGen int + // roomPageAsked is the task whose stored page the door opening its room has + // just asked for and not found, so the room does not ask again + // ([app.roomProgramCheck]). Zero is every other opening. + roomPageAsked uint64 // unfolded holds the turns whose tool cluster is showing every call. unfolded map[int]bool // workOpen is the ephemeral expansion state of live and completed work. @@ -2858,6 +2866,7 @@ func newApp(ctx context.Context, opts Options) *app { open: opts.Open, engineAnswers: opts.EngineAnswers, openTaskOwner: opts.OpenTaskOwner, + elsewhereOf: opts.Elsewhere, anchorWorkspace: opts.AnchorWorkspace, errand: opts.Errand, standingRoot: opts.StandingRoot, @@ -3367,6 +3376,11 @@ func (a *app) Init() tea.Cmd { standing = append(standing, a.wake()) } } + // THE PROGRAM ROWS ARE ASKED FOR AT THE LAUNCH, off the loop, so the picker + // and /help list them from the first answer rather than the first keystroke + // (delegate.go). The list is the engine's, so a switch asks again + // ([app.attachConversation]). + standing = append(standing, a.installDelegates()) return tea.Batch(standing...) } @@ -4358,6 +4372,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd, took := a.roomTabPress(msg.Mouse().X, msg.Mouse().Y); took { return a, cmd } + if a.programBriefPress(msg.Mouse().X, msg.Mouse().Y) { + return a, nil + } // THE TASK STRIP IS READ BEFORE THE RAIL, because the strip spans the // WHOLE window and the rail claims every press in its own columns // whether or not one landed on a row (room.go) — asked the other way @@ -5162,6 +5179,10 @@ func (a *app) paint() tea.Cmd { if a.room != nil { a.room.dirty = true } + // A PROGRAM'S ROOM READS ITS STORED PAGE ON THE SAME CLOCK: a room left open + // on work that can still move follows its newest action, and a room on a + // settled run is not read at all ([app.programRoomFollow]). + kick = tea.Batch(kick, a.programRoomFollow()) // A TOOL THAT HAS JUST ENDED IS ASKED ABOUT ON THIS FRAME, not at the next // tenth ([app.usageOwed]) — the ask alone, because nothing else on this // beat has moved with it. ONLY WHILE THE WORK IS STILL RUNNING: the ask is @@ -5353,7 +5374,12 @@ func (a *app) paint() tea.Cmd { // and it is the fourth that can be the whole of what is happening: the // room follows a live edge the store writes from another process, and no // turn of ours runs while it moves (planroom.go's [app.planRoomPoll]). - a.planRoomRunning() + a.planRoomRunning() || + // AND A PROGRAM'S ROOM ON WORK THAT CAN STILL MOVE IS THE NINETEENTH, for + // the run's room's reason exactly: senior-dev writes its conversation from + // another process, and the room's age ticks on this clock + // (programroom.go's [app.programRoomFollow]). + a.programRoomFollows() // THE WAIT ON THE MODEL is the only term that can hold this clock while // the screen shows nothing but the spinner and the ellipsis, and a spinner // glyph only changes every spinnerStep-th paint (styles.go). A wait whose @@ -6995,6 +7021,8 @@ func (a *app) press(x, y int) (cmd tea.Cmd) { a.togglePictureAt(r.entry, r.pictureIndex) case hitBrief: a.toggleBriefFoldAt(r.entry) + case hitAction: + a.toggleProgramAction(int64(r.turn)) case hitTask: // A CLICK ON A SPAWN CARD IS THE DOOR INTO THE NODE. It used to open the // brief, which is the card's own text one fold down — and the question a @@ -7729,6 +7757,12 @@ func (a *app) slash(line string) tea.Cmd { if a.droppedLine(line) { return a.edited() } + // A PROGRAM CODEAF CARRIES IS A COMMAND OF ITS OWN (delegate.go). It is + // asked for last, after the literal table, so nothing a program is + // called can shadow a word this surface already answers to. + if isDelegateCommand(name) { + return a.runDelegateCommand(name, rest) + } a.note(unknownCommandWord(name)) return nil } diff --git a/internal/tui3/c295_rail_task_page_test.go b/internal/tui3/c295_rail_task_page_test.go index 3c031972ef..0de3dd0904 100644 --- a/internal/tui3/c295_rail_task_page_test.go +++ b/internal/tui3/c295_rail_task_page_test.go @@ -277,3 +277,39 @@ func TestALetterTypedWhileARailPageOpensNeverRaisesTheStopCard(t *testing.T) { t.Fatalf("the room's box holds %q, want every key typed while it opened: %q", got, "an example") } } + +// A PLACE OPENED WHILE A ROW'S PAGE IS ON ITS WAY WITHDRAWS THE PRESS. The +// person pressed a run's row in the side list and then went Home before the +// store answered — milliseconds here, seconds over a connection — and the +// answer opened the run's room under Home, or a program's room under it with the box pointed at the run while Home's box was on screen. +func TestAPlaceOpenedWhileARowsPageIsOnItsWayWithdrawsThePress(t *testing.T) { + for _, tc := range []struct { + name string + app func(t *testing.T) *app + id uint64 + }{ + {"a run's stored page", func(t *testing.T) *app { a, _ := railTaskPageApp(t, true); return a }, 2}, + {"a run with no stored page", func(t *testing.T) *app { a, _ := railTaskPageApp(t, false); return a }, 2}, + {"a program's row not yet held", func(t *testing.T) *app { + a, _ := programRoomApp(t, 120, 28) + a.planRows, a.planRowsRead = nil, false + return a + }, 7}, + } { + t.Run(tc.name, func(t *testing.T) { + a := tc.app(t) + cmd := a.openRailRoom(a.tasks[tc.id]) + if cmd == nil || a.railPlanPending.id == "" { + t.Fatalf("the press asked the store nothing: pending=%q", a.railPlanPending.id) + } + drain(t, a, a.openHome()) + drain(t, a, cmd) + if !a.at(pageHome) { + t.Fatal("the person is no longer at Home") + } + if a.room != nil || a.railPlanPending.id != "" { + t.Fatalf("the answer landed on Home: room=%v pending=%q", a.room != nil, a.railPlanPending.id) + } + }) + } +} diff --git a/internal/tui3/chattabs.go b/internal/tui3/chattabs.go index d97a525717..28ed8b24ee 100644 --- a/internal/tui3/chattabs.go +++ b/internal/tui3/chattabs.go @@ -388,6 +388,15 @@ func (a *app) tabList() []chatTab { } } if work, ok := a.workTab(); ok { + // ONE TAB IS DRAWN SELECTED. While the run's own room is up the work tab + // is the selected one, and the conversation's tab is the door back to the + // conversation ([app.tabPress] closes the room); both used to draw + // selected at once. + if work.here { + for i := range tabs { + tabs[i].here = false + } + } tabs = append(tabs, work) } return tabsCapped(tabs, a.prev) @@ -543,6 +552,11 @@ func (a *app) roomFactsRow() int { if a.roomHeadHeight(width) < a.roomHeadCount() { return -1 } + // A PROGRAM'S ROOM HAS ONE HEAD ROW WITH FACTS ON IT, its title row, and + // the brief's rows under it are the dropdown's (programroom.go). + if a.programHeadsRoom() { + return a.roomHeadRow() + } return a.roomHeadRow() + a.roomHeadCount() - 1 } @@ -1268,6 +1282,17 @@ func (a *app) tabGo(tab chatTab) (cmd tea.Cmd) { if tab.work { return a.openWorkTab() } + // THE CONVERSATION'S OWN TAB, PRESSED WHILE THE RUN'S TAB IS THE SELECTED + // ONE, IS THE WAY BACK TO IT: the run's room stands down and the + // conversation is what is drawn, exactly as `esc` would leave it. The press + // used to reach a switch to the conversation already in front, which did + // nothing. + if tab.key != "" && !tab.start && tab.key == a.frontTabKey() && a.room != nil { + a.tabReveal() + a.closeRoom() + a.touch() + return nil + } a.tabReveal() if tab.start { return a.openChatStart() diff --git a/internal/tui3/delegate.go b/internal/tui3/delegate.go new file mode 100644 index 0000000000..723b7da6f6 --- /dev/null +++ b/internal/tui3/delegate.go @@ -0,0 +1,153 @@ +package tui3 + +// THE PROGRAMS CODEAF CARRIES, ON THE SURFACE: one command row per program the +// engine's build carries — `/senior-dev <brief>` — generated at launch from the +// list the conversation holds (internal/delegate/builtin, handed in by the +// launch). A program's row runs like `/task`: the words after it are the +// brief, the same door opens, a run starts, the turn goes on. +// +// THE ROWS ARE APPENDED TO THE LIVE TABLE AND NEVER TO THE LITERAL. The static +// table keeps its static gate (manual_test.go walks it); these rows exist only +// in a build that carries their program, and over `--host` only when the FAR +// machine's build does — which is right, because the program runs there. + +import ( + "context" + "strconv" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// delegateAgent is what this surface asks a conversation about the programs: +// the list, and the door. +type delegateAgent interface { + Delegates() session.DelegateReport + StartDelegate(context.Context, string, string) (uint64, string, string, error) +} + +func (a *app) delegateSeam() (delegateAgent, bool) { + if a.agent == nil { + return nil, false + } + agent, ok := a.agent.(delegateAgent) + return agent, ok +} + +// delegateUsageWordTail is what a program's row says under a bare `/<name>`. +const delegateUsageWordTail = " <brief> · hands the whole task to that program" + +// baseCommands is the literal table as this file found it, so the live table +// can be rebuilt from it however many times a surface installs rows: a second +// install replaces the first rather than stacking on it. +var ( + baseCommands = append([]command(nil), commands...) + delegateRowsMu sync.Mutex + delegateRows map[string]bool +) + +// installDelegateCommands rebuilds the live command table as the literal plus +// one row per program. A name that collides with a row or alias of the literal +// table is left out and answered back, because [checkCommands]'s law holds for +// generated rows too: a word may not mean two things. The build's own test +// keeps any program from being named that way (cmd/codeaf), so this is the +// guard a far engine of another build would need. +func installDelegateCommands(rows []session.DelegateRow) []string { + delegateRowsMu.Lock() + defer delegateRowsMu.Unlock() + table := append([]command(nil), baseCommands...) + installed := map[string]bool{} + var refused []string + for _, row := range rows { + name := strings.TrimSpace(row.Name) + if name == "" { + continue + } + candidate := command{name: name, args: "<brief>", desc: row.Description} + if err := checkCommands(append(append([]command(nil), table...), candidate)); err != nil || baseNames()[name] { + refused = append(refused, name+": its name is already a command here — not added") + continue + } + table = append(table, candidate) + installed[name] = true + } + commands = table + delegateRows = installed + return refused +} + +// baseNames is every word the literal table answers to: names and aliases. +func baseNames() map[string]bool { + names := map[string]bool{} + for _, c := range baseCommands { + names[c.name] = true + for _, word := range c.alias { + names[word] = true + } + } + return names +} + +// isDelegateCommand says whether a typed word is one of the installed rows. +func isDelegateCommand(name string) bool { + delegateRowsMu.Lock() + defer delegateRowsMu.Unlock() + return delegateRows[name] +} + +// installDelegates asks the conversation for its programs OFF THE LOOP and, +// when the answer comes back, puts their rows on the table. It is asked at the +// launch and again when the conversation in front changes, because the list is +// the engine's — and over `--host` it is the far machine's build, which is +// right: the program and the run are there, and the door crosses the wire +// (internal/remote's Delegate.List). It rides [app.besideLine] because nobody +// pressed for it: a read that waited in the door line behind a person's gesture +// would be a row arriving after the keystroke that wanted it. +func (a *app) installDelegates() tea.Cmd { + agent, ok := a.delegateSeam() + if !ok { + installDelegateCommands(nil) + return nil + } + return a.besideLine(func() func(here bool) tea.Cmd { + report := agent.Delegates() + return func(here bool) tea.Cmd { + if here { + installDelegateCommands(report.Rows) + } + return nil + } + }) +} + +// runDelegateCommand is `/<name> <brief>`: the brief goes to that program +// through a door of its own — asked off the loop like every door — and the +// answer lands as a task start, on the message `/task` lands on. +func (a *app) runDelegateCommand(name, brief string) tea.Cmd { + brief = strings.TrimSpace(brief) + if brief == "" { + a.note("usage: /" + name + delegateUsageWordTail) + return nil + } + agent, ok := a.delegateSeam() + if !ok { + a.note("could not start the task · this session cannot hand work to /" + name) + return nil + } + ctx := a.ctx + conv := a.taskDoorNotes(brief) + return a.offLoop(func() func(here bool) tea.Cmd { + id, title, note, err := agent.StartDelegate(ctx, name, brief) + return func(bool) tea.Cmd { + return func() tea.Msg { + return taskStartedMsg{ + kind: "single", id: strconv.FormatUint(id, 10), title: title, + err: err, note: note, brief: brief, conv: conv, + } + } + } + }) +} diff --git a/internal/tui3/delegate_test.go b/internal/tui3/delegate_test.go new file mode 100644 index 0000000000..448b998362 --- /dev/null +++ b/internal/tui3/delegate_test.go @@ -0,0 +1,153 @@ +package tui3 + +import ( + "context" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// delegateFake is the scripted session with the program door on it: a list of +// rows, and a StartDelegate that records what it was asked. +type delegateFake struct { + *fakeAgent + report session.DelegateReport + started []string + fail error +} + +func (d *delegateFake) Delegates() session.DelegateReport { return d.report } + +func (d *delegateFake) StartDelegate(_ context.Context, name, brief string) (uint64, string, string, error) { + d.started = append(d.started, name+": "+brief) + if d.fail != nil { + return 0, "", "", d.fail + } + return 7, "the title", "", nil +} + +func newDelegateApp(t *testing.T, rows ...session.DelegateRow) (*app, *delegateFake) { + t.Helper() + fake := &delegateFake{fakeAgent: &fakeAgent{}, report: session.DelegateReport{Rows: rows}} + a := newTestApp(fake) + t.Cleanup(func() { installDelegateCommands(nil) }) + settleDoor(t, a, a.installDelegates()) + return a, fake +} + +// settleDoor runs one off-loop door to its answer and folds it in, the way the +// update loop would on the doorMsg: the command is run, the fold applied as +// though the window were still on the same conversation, and any command the +// fold hands back is run too, its message returned. +func settleDoor(t *testing.T, a *app, cmd tea.Cmd) tea.Msg { + t.Helper() + if cmd == nil { + return nil + } + msg, ok := cmd().(doorMsg) + if !ok { + t.Fatalf("the door did not answer on the door line: %T", cmd()) + } + if next := msg.fold(true); next != nil { + return next() + } + return nil +} + +func TestACarriedProgramIsACommandRowThatOpensTheDoor(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate", Lands: "tree"}) + if !isDelegateCommand("fake") { + t.Fatal("the program's row was not installed") + } + named := false + for _, c := range commands { + if c.name == "fake" && c.args == "<brief>" && c.desc == "a fake delegate" { + named = true + } + } + if !named { + t.Fatal("the live command table has no /fake <brief> row") + } + cmd := a.slash("/fake rewrite the auth middleware") + if cmd == nil { + t.Fatal("/fake <brief> opened no door") + } + if msg, ok := settleDoor(t, a, cmd).(taskStartedMsg); !ok || msg.id != "7" || msg.title != "the title" || msg.brief != "rewrite the auth middleware" { + t.Fatalf("the door answered %+v", msg) + } + if len(fake.started) != 1 || fake.started[0] != "fake: rewrite the auth middleware" { + t.Fatalf("StartDelegate was asked %v", fake.started) + } +} + +// THERE IS NO /delegate. "delegate" is a working title, and every program is +// reached by its own name; the word is not a command. +func TestThereIsNoSlashDelegate(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate"}) + a.width = 200 + if cmd := a.slash("/delegate fake do it"); cmd != nil { + settleDoor(t, a, cmd) + } + if len(fake.started) != 0 { + t.Fatalf("/delegate started work: %v", fake.started) + } + for _, c := range commands { + if c.name == "delegate" { + t.Fatal("the command table still has a /delegate row") + } + } +} + +func TestADelegateRowWithNoBriefSaysItsUsage(t *testing.T) { + a, fake := newDelegateApp(t, session.DelegateRow{Name: "fake", Description: "a fake delegate"}) + if cmd := a.slash("/fake"); cmd != nil { + t.Fatal("a bare delegate command started something") + } + if len(fake.started) != 0 { + t.Fatalf("StartDelegate was asked %v", fake.started) + } + if got := plain(frame(a)); !strings.Contains(got, "usage: /fake <brief>") { + t.Fatalf("no usage line:\n%s", got) + } +} + +func TestAProgramNamedLikeABuiltInCommandIsNotInstalled(t *testing.T) { + newDelegateApp(t, session.DelegateRow{Name: "task", Description: "an impostor"}) + if isDelegateCommand("task") { + t.Fatal("a program shadowed /task") + } + if refused := installDelegateCommands([]session.DelegateRow{{Name: "task", Description: "an impostor"}}); len(refused) != 1 || !strings.Contains(refused[0], "its name is already a command here") { + t.Fatalf("refused = %v, want the collision answered back", refused) + } + for _, c := range commands { + if c.name == "task" && c.desc == "an impostor" { + t.Fatal("the impostor row is on the table") + } + } +} + +// A HOSTED SURFACE LISTS AND RUNS THE FAR MACHINE'S PROGRAMS: the seam crosses +// the wire (internal/remote's Delegate.List and Delegate.Start), the rows are +// generated from what the engine machine's build carries, and the door starts +// the run there. Nothing is refused for being hosted. +func TestAHostedSurfaceInstallsTheFarMachinesDelegateRows(t *testing.T) { + fake := &delegateFake{fakeAgent: &fakeAgent{}, report: session.DelegateReport{Rows: []session.DelegateRow{{Name: "fake", Description: "a fake delegate"}}}} + a := newTestApp(fake) + t.Cleanup(func() { installDelegateCommands(nil) }) + a.host = "spark" + settleDoor(t, a, a.installDelegates()) + if !isDelegateCommand("fake") { + t.Fatal("a hosted surface did not install the far machine's delegate row") + } + if cmd := a.slash("/fake do it there"); cmd == nil { + t.Fatal("/fake opened no door on a hosted surface") + } else { + settleDoor(t, a, cmd) + } + if len(fake.started) != 1 || fake.started[0] != "fake: do it there" { + t.Fatalf("StartDelegate was asked %v", fake.started) + } +} diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index a4be4be1a6..bc3c71e238 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -648,7 +648,9 @@ func (a *app) attachConversation(conv Conversation, side *aside) tea.Cmd { // armed are parked on the previous one's channels and discard themselves by // generation (watching.go's [followingMsg]). cmds := []tea.Cmd{a.watchTasks(), a.watchWakes(), a.watchDesigns(), a.watchTitles(), a.watchRuns(), a.watchQuestions(), a.loadTasks(), - a.askHeld(), a.watchDriving(), a.watchFollowing()} + a.askHeld(), a.watchDriving(), a.watchFollowing(), + // The program rows are the engine's, so they follow the conversation (delegate.go). + a.installDelegates()} if side != nil { cmds = append(cmds, a.restoreAside(side)) diff --git a/internal/tui3/files.go b/internal/tui3/files.go index 5cf7d567bc..5246ce537c 100644 --- a/internal/tui3/files.go +++ b/internal/tui3/files.go @@ -587,7 +587,8 @@ func (c *completion) rows(width, n int, pal palette, hover int, headKey string) case line.chat >= 0: ok = fill.add(at, mentionChatLabel(c.chatHits[line.chat]), c.lineNote(at), at == c.selLine(), false) case line.task >= 0: - ok = fill.add(at, taskRowLabel(c.taskHits[line.task], pal), c.lineNote(at), at == c.selLine(), false) + note := c.lineNote(at) + ok = fill.add(at, taskRowLabel(c.taskHits[line.task], note, width, pal), note, at == c.selLine(), false) default: ok = fill.add(at, c.all[line.file], c.lineNote(at), at == c.selLine(), false) } diff --git a/internal/tui3/home.go b/internal/tui3/home.go index 53c766d9c9..55f13799e6 100644 --- a/internal/tui3/home.go +++ b/internal/tui3/home.go @@ -5385,14 +5385,11 @@ func homeBands(bands [][]string, room int) []string { // is three absences dressed as three facts — so each part appears only when // there is something to say, and a footer with nothing to say is not drawn. // -// THE SUM IS THE TALKING PLUS THE WORK IT COMMISSIONED, and it is added up -// here because it is written down in two places for two good reasons. The -// conversation's own turns are stamped on its meta.json by the session that -// held them ([session.SessionRow.Spend]); every task it started is a row of the -// project's index with its own bill ([session.TaskRollup.Spend]). A person -// looking at a card does not have that distinction in their head — they asked -// what this conversation cost — so the card answers with one figure, and the -// two halves stay separate everywhere they are recorded. +// THE FIGURE IS THE TALKING AND THE WORK IT COMMISSIONED, read from the two +// places it is written down ([conversationSpend] says how they are joined). A +// person looking at a card asked what this conversation cost, so the card +// answers with one figure, and the two records stay separate everywhere they +// are kept. // // AND THE FILES ARE HERE TOO, because nothing else on the card carries them and // it is the most physical number the index holds: tokens are what the work @@ -5402,10 +5399,10 @@ func homeFacts(row session.SessionRow, now time.Time) string { if files := homeFilesTouched(row); files > 0 { parts = append(parts, "touched "+itoa(files)+plural(" file", files)) } - if spend := row.Spend + row.Tasks.Spend; spend > 0 { + if spend := conversationSpend(row); spend > 0 { parts = append(parts, "spent "+dollars(spend)) } - if tokens := row.Tokens + row.Tasks.Tokens; tokens > 0 { + if tokens := conversationTokens(row); tokens > 0 { parts = append(parts, tokenWord(tokens)+" tokens") } // The later of "somebody spoke" and "work landed": both are this @@ -5420,6 +5417,30 @@ func homeFacts(row session.SessionRow, now time.Time) string { return strings.Join(parts, " · ") } +// conversationSpend is what one conversation cost, from the two places it is +// written down: the books the session stamps on its meta.json +// ([session.SessionRow.Spend]) and the bills on its rows in the project's index +// ([session.TaskRollup.Spend]). +// +// IT IS THE LARGER OF THE TWO AND NEVER THEIR SUM, for the reason the live +// surface's [app.spendShown] is. The books already hold every run and every +// closed task this conversation folded in, and the session stamps them the +// moment the fold lands (internal/session's driveBeltRun and foldTaskUsage), so +// adding the index's bills on top counted that work twice: a conversation whose +// only spend was a $2.30 senior-dev run read `spent $4.60`. The index is ahead +// only while work is still running and has not folded yet, and then its figure +// is the truer one. +func conversationSpend(row session.SessionRow) float64 { + return max(row.Spend, row.Tasks.Spend) +} + +// conversationTokens is [conversationSpend]'s rule for tokens, for its reason: +// a closed task's tokens are folded into the books with its dollars, and its +// index row carries them again. +func conversationTokens(row session.SessionRow) int { + return max(row.Tokens, row.Tasks.Tokens) +} + // homeHolding says whether a window has this conversation open right now and // what it is doing, and says nothing at all when nobody has it. // diff --git a/internal/tui3/homeband_projectfacts.go b/internal/tui3/homeband_projectfacts.go index e114aa3264..85810d6726 100644 --- a/internal/tui3/homeband_projectfacts.go +++ b/internal/tui3/homeband_projectfacts.go @@ -57,10 +57,12 @@ func projectFacts(project session.Project, now time.Time) string { var touched time.Time for _, row := range project.Sessions { tasks += row.Tasks.Total() - // The talking and the work it commissioned, added the way [homeFacts] - // adds them for one conversation: one figure, because one figure is what - // "what has this project cost" means. - spend += row.Spend + row.Tasks.Spend + // The talking and the work it commissioned, joined the way [homeFacts] + // joins them for one conversation ([conversationSpend]'s larger of the + // two, because the books already hold the work they folded), then summed + // across conversations: one figure, because one figure is what "what has + // this project cost" means. + spend += conversationSpend(row) // The later of "somebody spoke" and "work landed", exactly as // [homeFacts] takes it for one conversation: both are this project being // active, and the footer is asked when, not how. diff --git a/internal/tui3/homeband_work.go b/internal/tui3/homeband_work.go index ee2661a539..d6deaadf62 100644 --- a/internal/tui3/homeband_work.go +++ b/internal/tui3/homeband_work.go @@ -172,12 +172,21 @@ func homeWorkNodeRows(node homeWorkNode, row session.SessionRow, width int, now // homeWorkName is a task's first line: what it is CALLED, and how long ago it // landed, hard against the right edge. +// +// A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME (programbadge.go), fitted +// with the name before the row lays the two sides out, so the age can move to +// a line of its own and the badge is still never what gets cut. An ordinary +// task's name is handed over exactly as it always was. func homeWorkName(entry session.TaskIndexEntry, width int, now time.Time, pal palette) []string { label := strings.TrimSpace(entry.Label) if label == "" { label = strings.TrimSpace(entry.Title) } - return bandSides(width, homeWorkIndent, 8, label, sinceAt(entry.EndedAt, now), pal.muted, pal.dim) + ink := pal.muted + if strings.TrimSpace(entry.Program) != "" && label != "" { + label, ink = pal.programLabel(label, entry.Program, width, pal.muted) + } + return bandSides(width, homeWorkIndent, 8, label, sinceAt(entry.EndedAt, now), ink, pal.dim) } // homeWorkUnder is a task's outcome rows, and nil when there is nothing true to diff --git a/internal/tui3/homecell.go b/internal/tui3/homecell.go index e35c7fe4a0..9edc8c89e7 100644 --- a/internal/tui3/homecell.go +++ b/internal/tui3/homecell.go @@ -569,11 +569,16 @@ func (a *app) homeCellLead(cell *homeCell, at int, pal palette) string { // right-hand word, the age or the door word, is the last thing to go: only where // the title would keep fewer than [homeCellTitleFloor] cells beside it, and a // held word not even then. +// +// A PROGRAM'S BADGE IS MEASURED AS PART OF THE TITLE AND PAID FOR OUT OF IT +// ([homeCellWears]), so the note, the tag and the age give way to it exactly as +// they give way to the title, and a cut takes the title's tail and never the +// badge. A row with no program is measured and cut as it always was. func homeCellBody(cell *homeCell, width int, pal palette, lit bool) string { if width < 1 { return "" } - title, note, tag, right := cell.title, cell.note, cell.tag, cell.right + title, note, tag, right := cell.measured(), cell.note, cell.tag, cell.right pad := cell.pad if cell.path { title, pad = homeCellPathTitle(cell, width) @@ -588,13 +593,19 @@ func homeCellBody(cell *homeCell, width int, pal palette, lit bool) string { width < homeCellTitleFloor+homeCellWidth("", 0, "", "", right) { right = "" } - if over := homeCellWidth(title, pad, note, tag, right) - width; over > 0 { - keep := max(1, ansi.StringWidth(title)-over) - if cell.panel == panelRecent || cell.panel == panelSessions { - title = fitConversationTitle(title, keep) - } else { - title = fit(title, keep) - } + room := ansi.StringWidth(title) + over := homeCellWidth(title, pad, note, tag, right) - width + if over > 0 { + room = max(1, room-over) + } + wears, after := "", "" + switch badge := programBadge(cell.program); { + case badge.known(): + title, wears, after = homeCellWears(cell.title, badge, cell.after, room) + case over > 0 && (cell.panel == panelRecent || cell.panel == panelSessions): + title = fitConversationTitle(title, room) + case over > 0: + title = fit(title, room) } titleInk, factInk := pal.ink, pal.dim if cell.bold || lit { @@ -611,6 +622,14 @@ func homeCellBody(cell *homeCell, width int, pal palette, lit bool) string { line = pal.underline(line) } used := ansi.StringWidth(title) + if wears != "" { + line += pal.programAfter(wears) + used += programCells(wears) + } + if after != "" { + line += titleInk(after) + used += ansi.StringWidth(after) + } if note != "" { gap := max(0, pad-used) + len(homeCellGap) line += strings.Repeat(" ", gap) + factInk(note) @@ -637,6 +656,37 @@ func homeCellPathTitle(cell *homeCell, width int) (string, int) { return fit(cell.title, room), min(cell.pad, room) } +// measured is a row's title as the cell measures it: the title, its program's +// badge in the long spelling, and the words that follow the badge — which is +// the title alone on every row no program had. +func (c *homeCell) measured() string { + full := programBadge(c.program).full + if full == "" { + return c.title + c.after + } + return c.title + " " + full + c.after +} + +// homeCellWears fits a program's row into room cells: the title, the badge +// spelling it keeps, and the words after the badge, each as drawn. +// +// THE WORDS AFTER THE BADGE GIVE WAY FIRST, cut while more of them than their +// separator and a letter survives, because they say what the work came to and +// the badge says whose it was. THEN THE BADGE IS PAID FOR OUT OF THE TITLE +// ([programSpelling]): the long spelling while the title keeps +// [homeCellTitleFloor] cells beside it, the short one after that, none below +// it — the rule every other list of the work keeps ([palette.programTitled]). +func homeCellWears(title string, badge rowField, after string, room int) (string, string, string) { + if after != "" { + left := room - ansi.StringWidth(title) - programCells(badge.full) + if left >= min(ansi.StringWidth(after), ansi.StringWidth(rowSep)+2) { + return title, badge.full, fit(after, left) + } + } + spelling := programSpelling(badge, title, room, homeCellTitleFloor) + return fit(title, room-programCells(spelling)), spelling, "" +} + // homeCellDoor is the row UNDER THE CURSOR growing its held word into the door // it offers — `another window · enter brings it here` — where the whole title // still fits beside the whole clause, and never while a question this window @@ -648,7 +698,7 @@ func (a *app) homeCellDoor(cell *homeCell, at, width int) *homeCell { } grown := *cell grown.right = cell.door - if homeCellWidth(grown.title, grown.pad, "", grown.tag, grown.right) > width { + if homeCellWidth(grown.measured(), grown.pad, "", grown.tag, grown.right) > width { return cell } return &grown diff --git a/internal/tui3/homefacts_spend_test.go b/internal/tui3/homefacts_spend_test.go new file mode 100644 index 0000000000..d08e40204e --- /dev/null +++ b/internal/tui3/homefacts_spend_test.go @@ -0,0 +1,55 @@ +package tui3 + +import ( + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// A CONVERSATION'S BILL ON HOME IS COUNTED ONCE, read from real rows on disk. +// The session stamps its books on meta.json, and those books already hold every +// run and every closed task it folded in; the run's own row in the project's +// index carries the same dollars again. The card and the project's facts added +// the two, so a conversation whose only spend was a $2.30 senior-dev run read +// `spent $4.60`. +func TestHomeCountsARunsDollarsOnce(t *testing.T) { + lab := newHomeLab(t) + now := time.Now() + transcript := lab.session("-tmp-alpha", "aaaa000000000001", "hand it off", "/tmp/alpha", now.Add(-time.Hour)) + dir := filepath.Dir(transcript) + meta, err := session.LoadMeta(dir) + if err != nil { + t.Fatal(err) + } + // What the session's own stamp writes once the run has folded: the books + // hold the run, and they hold the talking beside it. + meta.SpentUSD, meta.Tokens = 2.30, 1200 + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + // What the run's own row in the index writes: the run's dollars alone. + lab.task("-tmp-alpha", session.TaskIndexEntry{ + ID: "1", Name: "hand-it-off", Label: "Hand it off", Title: "Hand it off", + Status: string(session.TaskDone), Cost: 2.30, SessionID: "aaaa000000000001", + StartedAt: now.Add(-50 * time.Minute), EndedAt: now.Add(-30 * time.Minute), + }) + + world := session.ReadWorld(lab.root) + if len(world.Projects) != 1 || len(world.Projects[0].Sessions) != 1 { + t.Fatalf("read %+v, want one project holding one conversation", world.Projects) + } + project := world.Projects[0] + row := project.Sessions[0] + if row.Spend != 2.30 || row.Tasks.Spend != 2.30 { + t.Fatalf("the rows read books %v and index %v, want $2.30 in both", row.Spend, row.Tasks.Spend) + } + if card := homeFacts(row, now); !strings.Contains(card, "spent $2.30") { + t.Fatalf("the card reads %q, want the run's $2.30 once", card) + } + if facts := projectFacts(project, now); !strings.Contains(facts, "spent $2.30") { + t.Fatalf("the project's facts read %q, want the run's $2.30 once", facts) + } +} diff --git a/internal/tui3/homegrid.go b/internal/tui3/homegrid.go index 1977b97852..6c0cf3fc0b 100644 --- a/internal/tui3/homegrid.go +++ b/internal/tui3/homegrid.go @@ -445,6 +445,15 @@ type homeCell struct { panel homePanelID mark homeCellMark title string + // program is the program a row's work was handed to — senior-dev — and "" + // for every other row. Its badge follows the title and is PAID FOR OUT OF + // THE TITLE ([homeCellWears]): the cell cuts a title from its right, and a + // badge written onto the end of the title was the first thing that cut took. + program string + // after is the words that follow a program's badge — what a landed task came + // to, its separator in front ([ledgerTaskParts]) — which a cut takes before + // it takes the badge. A row with no program carries them in its title. + after string // pad is the cells the title is padded to before the note, so a panel's // notes stand in one column (the projects panel's counts). pad int diff --git a/internal/tui3/homepanel_left.go b/internal/tui3/homepanel_left.go index 25d009946a..4ec9bcf0e8 100644 --- a/internal/tui3/homepanel_left.go +++ b/internal/tui3/homepanel_left.go @@ -53,9 +53,18 @@ func (leftPanel) rows(in *homeGridInput) homePanelRows { // panel — and both are the row's description now, drawn under the cursor // ([switcherRow.note]); the edge reads `3h`, `1h`, like every row of the field // (owner, 2026-09-15). +// +// A PROGRAM'S LANDING WEARS ITS BADGE BETWEEN ITS NAME AND WHAT IT CAME TO, so +// the cell cuts the outcome before the badge and pays for the badge out of the +// name ([homeCellWears]); every other line is its title whole, as it was. func leftLine(row switcherRow, now time.Time) homeLine { cell := &homeCell{panel: panelLeft, title: row.title, right: sinceAt(row.at, now), row: &row, sub: strings.TrimSpace(row.note), grows: strings.TrimSpace(row.note) != ""} + if row.task != nil { + if label, after, program := ledgerTaskParts(*row.task, row.session); program != "" { + cell.title, cell.after, cell.program = label, after, program + } + } return homeLine{kind: homeLedger, project: row.place, dir: leftKey(row), view: row.item, item: row.item.Item, cell: cell} } @@ -118,6 +127,15 @@ func ledgerLanded(world session.World, seen time.Time) []switcherRow { } // ledgerTaskLine is a landed task in one line: its label and what it came to. +func ledgerTaskLine(entry session.TaskIndexEntry, row session.SessionRow) string { + label, after, _ := ledgerTaskParts(entry, row) + return label + after +} + +// ledgerTaskParts is that line in the pieces a cell draws it in: the label, +// what the work came to with the separator in front of it ("" when there is +// nothing to say), and the program the work was handed to, whose badge stands +// between the two ([leftLine]). // // A TASK THAT STOPPED INCOMPLETE SAYS WHY IN THE OUTCOME'S PLACE. The first // sentence of a report is what the work came to only when the work finished; @@ -125,23 +143,26 @@ func ledgerLanded(world session.World, seen time.Time) []switcherRow { // news, in the rail's own words for it ([endingWord]). // // A ROW WITH NO NAME OF ITS OWN IS NAMED FOR ITS CONVERSATION rather than drawn -// as a bare outcome, so every line still says whose work it was. -func ledgerTaskLine(entry session.TaskIndexEntry, row session.SessionRow) string { - label := strings.TrimSpace(entry.Label) +// as a bare outcome, so every line still says whose work it was — and it wears +// no badge, because the name is the conversation's and not the work's. +func ledgerTaskParts(entry session.TaskIndexEntry, row session.SessionRow) (label, after, program string) { + label = strings.TrimSpace(entry.Label) if label == "" { label = strings.TrimSpace(entry.Title) } if label == "" { label = homeName(row) + } else { + program = strings.TrimSpace(entry.Program) } outcome := endingWord(entry.Ending) if outcome == "" { outcome = switcherFirstLine(entry.Outcome) } - if outcome == "" { - return label + if outcome != "" { + after = rowSep + outcome } - return label + rowSep + outcome + return label, after, program } // ledgerMade is a line per file a conversation made since the look stamp: diff --git a/internal/tui3/homepanel_needs.go b/internal/tui3/homepanel_needs.go index 02cd64921d..e3be91537f 100644 --- a/internal/tui3/homepanel_needs.go +++ b/internal/tui3/homepanel_needs.go @@ -326,6 +326,13 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta if title == "" { title = strings.TrimSpace(entry.Title) } + // A PROGRAM'S WORK SAYS WHOSE IT IS, with its badge after its name + // (programbadge.go), which the cell pays for out of the title + // ([homeCellWears]) — a name with no words gets none. + program := "" + if title != "" { + program = strings.TrimSpace(entry.Program) + } asked := needsCallAt(entry) // A LANDING WEARS NO MARK (law 8). The amber `?` means a thing has stopped // and will not move until somebody answers it; a landing has already @@ -334,7 +341,7 @@ func needsCall(project session.Project, row session.SessionRow, entry session.Ta // THE THREAD IT BELONGS TO HEADS THE DESCRIPTION (owner, 2026-09-17), // spelled as `threads` spells the same conversation ([homeName]); under // that title line come the files and what the work came to. - cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: title, right: sinceAt(asked, now), + cell := &homeCell{panel: panelNeeds, mark: cellMarkNeeds, title: title, program: program, right: sinceAt(asked, now), key: needsCallKey + entry.ID, thread: homeName(row), grows: true, sub: rowClauses(needsCallFiles(entry), needsCallSub(entry, status)), answers: needsCallAnswers(status)} line := homeLine{kind: homeSession, row: row, project: project.Name, diff --git a/internal/tui3/homeslash.go b/internal/tui3/homeslash.go index 8a4a90127d..4b4147ef2d 100644 --- a/internal/tui3/homeslash.go +++ b/internal/tui3/homeslash.go @@ -181,6 +181,11 @@ const ( // always did — `there is no command called /x · / lists them`, on home's line. func homeFate(word, rest string) string { rest = strings.TrimSpace(rest) + // A PROGRAM'S ROW IS `/task` WITH THE WORKER CHOSEN (delegate.go), and it + // needs what a /task with a brief needs: a conversation to start in. + if isDelegateCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { + return fateNeedsChat + } switch canonicalCommand(strings.ToLower(strings.TrimPrefix(word, "/"))) { case "model": return fateTargetModel diff --git a/internal/tui3/offlooplaw_test.go b/internal/tui3/offlooplaw_test.go index d6d4e38988..99cc75ec13 100644 --- a/internal/tui3/offlooplaw_test.go +++ b/internal/tui3/offlooplaw_test.go @@ -437,6 +437,7 @@ var doorsBesideTheLine = map[string]string{ "PlanTaskWork": "reads the run's working copy for the work tab; it changes nothing, and no later gesture waits on it", "PlanTasks": "reads the run's rows for the side list after a message; nobody pressed for it, and a verb's own read is asked only once the verb has landed", "RefreshRunSummary": "asks a model for the run's summary under a budget of seconds; nobody pressed for it and no gesture depends on it", + "Delegates": "reads the programs the engine machine's build carries to generate their command rows at the launch and on a switch (delegate.go); nobody pressed for it", "NameTeam": "asks a model for a suggested team name under a budget of seconds; it changes nothing on the engine, typing overrides it, and a message sent while it thinks must not wait behind it", "ProposeTeams": "asks a model for Organize's proposals under a budget of seconds; it changes nothing on the engine (Apply writes through the teams store), and nothing after it depends on the engine having seen it", } diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index efa0e15d55..2c5be5fc3f 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -1192,6 +1192,53 @@ func overlayNoteRoom(label string, width int) int { return room - floor - rowGutter } +// overlayRowRoom is the cells a one-line row gives its label, and the note as +// the row draws it beside that label ([overlayRowCore] is the row). +// +// THE NOTE IS CUT TO THE ROW BEFORE THE ROW IS BUDGETED AROUND IT. The label +// absorbs whatever the note leaves and the gap clamps at one cell, so a note +// longer than the terminal used to be appended WHOLE to an empty label — the +// row ran past the edge by however long the note was, and no amount of +// squeezing the label could pull it back. What it may take is everything but +// the lead and the gutter. Settings' `tool exceptions` is the row that found +// it: a value naming ten tools is 141 cells against a 60-cell terminal, which +// is LAW 1 (a place takes exactly the frame) broken by a value a person chose. +func overlayRowRoom(label, note string, width int) (int, string) { + room := width - 2 + if note != "" { + // AND THE LABEL KEEPS A FLOOR UNDER IT. The label used to absorb + // whatever the note left, which on a long note left it NOTHING: the row + // drew a full-width value with no name in front of it, and a person + // reading down the column could not tell which setting they were + // looking at. So the note may take the row's second half and no more — + // or all of it but the label's own width, when the label is the shorter + // of the two — and the label gives way only inside what is left. + // + // EVERY LIST THAT RANKS ITS FACTS HANDS US A NOTE THAT ALREADY FITS + // (rowfit.go drops whole facts rather than cutting one in half), so this + // is the floor under the lists that pass a note they did not budget. + note = fit(note, overlayNoteRoom(label, width)) + } + if note != "" { + room -= ansi.StringWidth(note) + rowGutter + } + return room, note +} + +// overlayLabelRoom is the cells a row's label is given in whichever shape the +// row is drawn: the line under the lead where the note takes a line of its own +// at [tierPhone] ([overlayLinesCore]), and what the note leaves it otherwise +// ([overlayRowRoom]). A list that pays for part of its label out of the rest — +// a program's badge out of a task's title (taskmention.go's [taskRowLabel]) — +// fits the label to this first, so the row's own cut never reaches that part. +func overlayLabelRoom(label, note string, width int) int { + if overlayItemLines(width, note) > 1 { + return width - 2 + } + room, _ := overlayRowRoom(label, note, width) + return room +} + // overlayMeasure is HOW WIDE A LABEL/TAIL PAIR IS LAID OUT, however wide the // frame is. It is a reading measure the way [teachMeasure] is one for prose, and // it is wider because a row carries structure a paragraph does not. @@ -1361,33 +1408,7 @@ func overlayRowHitTinted(label, note string, hit []int, tint noteInk, oncursor b // for the ones that are. hit is the search's emphasis, nil for none. func overlayRowCore(label, note string, hit []int, tint noteInk, oncursor bool, marked rowMark, hovered bool, width int, pal palette) string { lead := overlayLead(oncursor, hovered, pal) - // THE NOTE IS CUT TO THE ROW BEFORE THE ROW IS BUDGETED AROUND IT. The label - // absorbs whatever the note leaves and the gap below clamps at one cell, so a - // note longer than the terminal used to be appended WHOLE to an empty label — - // the row ran past the edge by however long the note was, and no amount of - // squeezing the label could pull it back. What it may take is everything but - // the lead and the gutter. Settings' `tool exceptions` is the row - // that found it: a value naming ten tools is 141 cells against a 60-cell - // terminal, which is LAW 1 (a place takes exactly the frame) broken by a - // value a person chose. - room := width - 2 - if note != "" { - // AND THE LABEL KEEPS A FLOOR UNDER IT. The label used to absorb - // whatever the note left, which on a long note left it NOTHING: the row - // drew a full-width value with no name in front of it, and a person - // reading down the column could not tell which setting they were - // looking at. So the note may take the row's second half and no more — - // or all of it but the label's own width, when the label is the shorter - // of the two — and the label gives way only inside what is left. - // - // EVERY LIST THAT RANKS ITS FACTS HANDS US A NOTE THAT ALREADY FITS - // (rowfit.go drops whole facts rather than cutting one in half), so this - // is the floor under the lists that pass a note they did not budget. - note = fit(note, overlayNoteRoom(label, width)) - } - if note != "" { - room -= ansi.StringWidth(note) + rowGutter - } + room, note := overlayRowRoom(label, note, width) label = fit(label, room) // lifted is whether this row wears a ground at all, which is the one thing diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index 96c88d9eb9..8ce4e639e6 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -580,6 +580,9 @@ func (a *app) taskSheetOwnRows() []session.TaskIndexEntry { SessionID: self, StartedAt: node.started, EndedAt: taskNodeEnded(node), + // The program the node's work was handed to, so the row drawn off it + // wears the badge the node's own row does (programbadge.go). + Program: a.nodeProgram(node), }) } return rows diff --git a/internal/tui3/planbeat_host_test.go b/internal/tui3/planbeat_host_test.go index 828483ef8c..232042331a 100644 --- a/internal/tui3/planbeat_host_test.go +++ b/internal/tui3/planbeat_host_test.go @@ -324,6 +324,19 @@ func TestHostedPlanPartsCrossTheWireAndFilterThePage(t *testing.T) { t.Fatalf("hosted page step = %#v, want the head withheld for a row that leaves out a record part", page.Steps[0]) } + // THE PAGE CARRIES ITS LIVE STEP, and the room draws it as a call in flight + // beside the recorded one, the two under one running line. The screen is + // read on the recorded step alone, so the live step is settled first. + store, err = plandb.Open(path, "", "", "", "") + if err != nil { + t.Fatal(err) + } + if err := store.ClearLive("root"); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } openHostedPage(t, a) screen := planRoomText(t, a) for _, never := range []string{workspace, "plandb task overview", "recorded-output"} { diff --git a/internal/tui3/planrail.go b/internal/tui3/planrail.go index 8f00e64b97..a7e7cf0e8e 100644 --- a/internal/tui3/planrail.go +++ b/internal/tui3/planrail.go @@ -103,6 +103,9 @@ func planRailNode(row session.PlanTaskRow) *taskNode { started: row.Started, ended: row.Ended, cost: row.USD, + // AND WHICH PROGRAM HAS THE WORK, so the row wears its badge + // (programbadge.go) like the node row it stands in for. + program: strings.TrimSpace(row.Program), } node.title = taskTitleOf(node.label, "", node.id) if row.Live.Step > 0 { diff --git a/internal/tui3/programbadge.go b/internal/tui3/programbadge.go new file mode 100644 index 0000000000..f18c22e50e --- /dev/null +++ b/internal/tui3/programbadge.go @@ -0,0 +1,188 @@ +package tui3 + +// THE PROGRAM'S BADGE: how a person tells work handed to a program codeaf +// carries — senior-dev — from a task this conversation's own worker does. +// +// The two looked the same everywhere a task is named. A senior-dev run and an +// ordinary `/task` drew the same first line on the side list — a state glyph, a +// title, a handle — and the card a person approved said `wants to start a +// task:` for both, so the one thing about the work that decides what it will do +// and how long it will take was not on the screen at all. The owner asked for a +// prominent badge like `[senior-dev]` on the task's row, none on an ordinary +// task's, and a badge of its own for every program that comes after it. +// +// SO THE BADGE IS THE PROGRAM'S NAME IN BRACKETS, AND NOTHING ELSE MAKES IT. A +// program's [session.TaskNotice.Program] is the name its own command row says +// out loud, and the delegate package keeps that name to one plain ASCII shape +// (internal/delegate's nameShape), so a name can be bracketed as it stands. A +// program added next year gets its badge by existing: there is no table here to +// forget to add it to. Its short spelling is the initials of the name's +// hyphen-separated parts — `[sd]`, `[dw]` for a doc-writer — which is what a +// twenty-four-cell column can afford. +// +// THE BRACKETS ARE ALWAYS DRAWN. The badge is painted in the accent, bold, +// because the owner asked for it to be prominent and the accent is this +// surface's one loud voice (docs/DESIGN-LANGUAGE.md's accent budget: it marks +// the live or chosen thing, and a program's work is the one row on a column that +// is a different kind of thing) — but ink is not always there to be read. A +// terminal with no colour draws none; a row somebody is standing in is laid on +// the selected ground, which swallows a chip's; and a screen reader reads words. +// The brackets are what is left in all three, so they are part of the badge and +// never decoration around it ([session.ProgramBadge] spells them once). +// +// IT IS NEVER A LEAD AND NEVER A TARGET. The badge stands after the title, in +// the row's trailing slot — never in front of it, where the column's law keeps +// one state glyph and nothing else (railclick_test.go's +// TestTheColumnLeadsWithStateAndSpendsNoCellOnIdentity) — and it is not a press +// target of its own: a press anywhere on a task's row opens that task. + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// programBadge is a program's badge in its spellings, longest first: the whole +// name in brackets and its initials in brackets. A name with nothing to say +// is the unknown field ([rowSay] with no spellings), which every caller draws +// as nothing — the emptiness law, and the whole of what an ordinary task gets. +func programBadge(name string) rowField { + name = strings.TrimSpace(name) + full := session.ProgramBadge(name) + if full == "" { + return rowField{} + } + short := session.ProgramBadge(programInitials(name)) + if short == full { + short = "" + } + return rowSay(full, short) +} + +// programInitials is the first character of each hyphen-separated part of a +// program's name: `senior-dev` is `sd`. The name's shape is the delegate +// package's guarantee — lowercase ASCII words joined by single hyphens — so a +// byte is a character here. +func programInitials(name string) string { + var out strings.Builder + for _, part := range strings.Split(name, "-") { + if part != "" { + out.WriteByte(part[0]) + } + } + return out.String() +} + +// programSpelling is the longest spelling of a badge that still leaves a title +// the floor of cells beside it — or the whole title, when that is shorter than +// the floor — in room cells, with one cell of air between them. It answers "" +// when no spelling does, which only a row indented deeper than any program's +// row ever sits can meet: the title is the row's identity, and a badge that cost +// it below the floor would be a mark that erased the thing it marks. +func programSpelling(badge rowField, title string, room, floor int) string { + need := min(ansi.StringWidth(title), floor) + for _, spelling := range [...]string{badge.full, badge.short} { + if spelling != "" && room-ansi.StringWidth(spelling)-1 >= need { + return spelling + } + } + return "" +} + +// programCells is what a chosen spelling costs a row: its own cells and the one +// cell of air in front of it, and nothing for no badge. +func programCells(spelling string) int { + if spelling == "" { + return 0 + } + return ansi.StringWidth(spelling) + 1 +} + +// programInk paints a badge spelling. It is the ONE painter every surface +// draws a program's badge with, so the badge reads the same on the side list, +// the strip, the card, a page's head and the tasks place. +func (p palette) programInk(spelling string) string { + return p.bold(p.accent(spelling)) +} + +// programAfter is a badge as it follows a title: one cell of air and the badge, +// painted, or nothing for no badge. +func (p palette) programAfter(spelling string) string { + if spelling == "" { + return "" + } + return " " + p.programInk(spelling) +} + +// programTitled is a title fitted into room cells with its program's badge +// after it, painted — the title in the row's own ink and the badge in its own. +// The badge keeps its long spelling while the title keeps [railTitleFloor] +// cells beside it, falls to its short one after that, and the title is cut into +// whatever is left; an ordinary task's title is simply fitted as it always was. +func (p palette) programTitled(title, program string, room int, ink func(string) string) string { + spelling := programSpelling(programBadge(program), title, room, railTitleFloor) + return ink(fit(title, room-programCells(spelling))) + p.programAfter(spelling) +} + +// programLabel is [palette.programTitled] for a row that paints its label in +// ONE call it does not own (home's [bandSides] is the one caller): the title +// and the badge as one string fitted into room cells, and an ink that paints +// the title with the row's own and the badge with [palette.programInk]. The +// label is fitted here, before the row sees it, so the row never has to cut it +// and the badge on its end is never what a cut takes. +func (p palette) programLabel(title, program string, room int, ink func(string) string) (string, func(string) string) { + spelling := programSpelling(programBadge(program), title, room, railTitleFloor) + if spelling == "" { + return title, ink + } + label := fit(title, room-programCells(spelling)) + " " + spelling + return label, func(s string) string { + if head, ok := strings.CutSuffix(s, " "+spelling); ok { + return ink(head) + p.programAfter(spelling) + } + return ink(s) + } +} + +// pageProgram is the program a stored page's task was handed to: the name the +// program's own record gives it, and the name its row carries while that record +// has not reached the disk — "" for every page no program was handed. +func pageProgram(page session.PlanTaskPage) string { + if page.Program != nil { + if name := strings.TrimSpace(page.Program.Name); name != "" { + return name + } + } + return strings.TrimSpace(page.Row.Program) +} + +// nodeProgram is the program a node's work was handed to, and "" for every +// ordinary task. It is the node's own fact, taken from the engine's notices +// ([taskNode.program]) — and, for a node whose notices named none because the +// engine that sent them predates the field, the name the run's own plan row +// carries ([app.railProgramRow]), which is a row the surface already holds and +// never a read made while drawing. +// +// ONLY THIS WINDOW'S OWN NODE IS LOOKED UP IN THIS WINDOW'S PLAN ROWS. Those +// rows are the conversation in front's, found by the bare number, and task ids +// restart with every conversation — so a guest page's node (taskowner.go's +// [taskGuestNode]), standing for another conversation's task 7, would take the +// badge of this conversation's own task 7 and wear `[senior-dev]` over work no +// program had. Such a node answers with its own fact and nothing else. +func (a *app) nodeProgram(node *taskNode) string { + if node == nil { + return "" + } + if node.program != "" { + return node.program + } + if a.tasks[node.id] != node { + return "" + } + if row, ok := a.railProgramRow(node); ok { + return strings.TrimSpace(row.Program) + } + return "" +} diff --git a/internal/tui3/programbadge_test.go b/internal/tui3/programbadge_test.go new file mode 100644 index 0000000000..a66168eac4 --- /dev/null +++ b/internal/tui3/programbadge_test.go @@ -0,0 +1,658 @@ +package tui3 + +// A program's work wears its badge everywhere a task is named, and an ordinary +// task wears nothing. These tests drive the badge the way a window meets it — +// off the engine's own notices, before any plan row has been read — and hold it +// to the column's laws: it never leads, it never costs the title its floor +// before the handle has gone, it is never a press target, and it is drawn +// without asking the agent anything. + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// programTitle is the task the fixtures hand to a program. +const programTitle = "rewrite the auth middleware" + +// programNotice is the first row a program's run publishes: running, from the +// hand-off, and naming the program that has the work. +func programNotice(program string) session.TaskNotice { + return session.TaskNotice{Program: program, StartedAt: taskFixtureNow.Add(-3 * time.Minute)} +} + +// programRailApp is a window holding one program's run, known from its notice +// alone — no plan row has been read, which is the first second of every run and +// the first frame after every conversation switch. +func programRailApp(t *testing.T, program string) *app { + t.Helper() + a, _, _ := taskApp(t) + a.height = 30 + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, programTitle, session.TaskRunning, programNotice(program))}) + if node := a.tasks[7]; node == nil || node.program != program { + t.Fatalf("the node did not keep its program: %+v", a.tasks[7]) + } + return a +} + +// programRailRow is the program's head line on the column at a frame width, +// widened or not, painted. +func programRailRow(t *testing.T, a *app, width int, wide bool) string { + t.Helper() + a.width, a.railWide = width, wide + for _, row := range a.railRows(a.viewHeight()) { + if strings.Contains(plain(row), "rewrite") { + return row + } + } + t.Fatalf("the program's row is not on the column at %d columns:\n%s", width, + strings.Join(railText(a, a.viewHeight()), "\n")) + return "" +} + +// THE BADGE AT EVERY WIDTH THE COLUMN STANDS AT, in the order the column gives +// its cells up: at thirty columns the whole badge and no handle, at twenty-four +// the short badge and the handle back, and at forty-six both whole. Every row +// fits its column, and the title still starts two cells past the seam. +func TestAProgramsRowWearsItsBadgeAtEveryWidthOfTheColumn(t *testing.T) { + a := programRailApp(t, "senior-dev") + for _, tier := range []struct { + name string + width int + wide bool + cols int + want, never []string + }{ + {"railCols", 120, false, railCols, []string{"[senior-dev]"}, []string{"#7", "[sd]"}}, + {"railSlimCols", 110, false, railSlimCols, []string{"[sd]", "#7"}, []string{"[senior-dev]"}}, + {"railWideCols", 120, true, railWideCols, []string{"[senior-dev]", "#7"}, []string{"[sd]"}}, + } { + row := plain(programRailRow(t, a, tier.width, tier.wide)) + t.Logf("%s: %q", tier.name, row) + for _, word := range tier.want { + if !strings.Contains(row, word) { + t.Fatalf("at %s the program's row is %q, want %q on it", tier.name, row, word) + } + } + for _, word := range tier.never { + if strings.Contains(row, word) { + t.Fatalf("at %s the program's row is %q, which should not carry %q", tier.name, row, word) + } + } + if cells := ansi.StringWidth(row); cells > tier.cols { + t.Fatalf("at %s the row is %d cells in a %d-cell column: %q", tier.name, cells, tier.cols, row) + } + if got, want := leadCells(t, row, "rewrit"), ansi.StringWidth(railSeam)+2; got != want { + t.Fatalf("at %s the title starts at cell %d, want %d — the badge must never lead:\n%q", tier.name, got, want, row) + } + // AND THE BADGE COMES AFTER THE TITLE, never before it. + if strings.Index(row, "[") < strings.Index(row, "rewrit") { + t.Fatalf("at %s the badge stands in front of the title: %q", tier.name, row) + } + } +} + +// AN ORDINARY TASK WEARS NO BADGE, at any width — the goldens in +// railtree_test.go and railwork_test.go hold its row to the byte; this holds the +// one fact they cannot, that nothing bracketed appears for work no program had. +func TestAnOrdinaryTasksRowWearsNoBadge(t *testing.T) { + a := programRailApp(t, "senior-dev") + a.taskUpdate(update(8, "Fix the loader nil-map", session.TaskRunning, session.TaskNotice{})) + for _, width := range []int{110, 120} { + a.width = width + row, ok := railRowFor(a, a.viewHeight(), "Fix the loader") + if !ok { + t.Fatalf("the ordinary task is not on the column at %d", width) + } + if strings.Contains(row, "[") || !strings.Contains(row, "#8") { + t.Fatalf("an ordinary task's row at %d columns is %q, want its handle and no badge", width, row) + } + } + if programBadge("").known() { + t.Fatal("no program answered a badge") + } +} + +// ANY PROGRAM GETS ITS OWN BADGE FROM ITS NAME, and nothing here names +// senior-dev: a program that ships next year needs no change to this surface. +func TestASecondProgramDrawsItsOwnBadge(t *testing.T) { + a := programRailApp(t, "doc-writer") + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[doc-writer]") || strings.Contains(row, "senior-dev") { + t.Fatalf("a doc-writer's row is %q, want its own badge", row) + } + if row := plain(programRailRow(t, a, 110, false)); !strings.Contains(row, "[dw]") { + t.Fatalf("a doc-writer's narrow row is %q, want its initials", row) + } + for name, want := range map[string]rowField{ + "senior-dev": {full: "[senior-dev]", short: "[sd]"}, + "doc-writer": {full: "[doc-writer]", short: "[dw]"}, + "gpt-5-writer": {full: "[gpt-5-writer]", short: "[g5w]"}, + "fake": {full: "[fake]", short: "[f]"}, + } { + if got := programBadge(name); got != want { + t.Fatalf("programBadge(%q) = %+v, want %+v", name, got, want) + } + } +} + +// THE BADGE IS NOT A TARGET. A press on it opens the program's task, the way a +// press anywhere else on the row does, and folds nothing: the only span a row +// reports beside its fold cell is a folded family's count. +func TestAPressOnTheBadgeOpensTheTaskAndFoldsNothing(t *testing.T) { + a, _, _ := roomApp(t) + a.width, a.height = 120, 30 + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskRunning, programNotice("senior-dev"))}) + if a.tasks[7].program != "senior-dev" { + t.Fatalf("a same-state row naming the program was thrown away by the de-dup: %+v", a.tasks[7]) + } + _, cell, count := a.railEntryRows(railEntry{node: a.tasks[7]}, a.railRoom()) + if cell.pressable() || count.pressable() { + t.Fatalf("the program's row reported a pressable span: cell %+v, count %+v", cell, count) + } + y := railRowY(t, a, 7) + row := railText(a, a.viewHeight())[y-a.bodyTop()] + at := strings.Index(row, "[senior-dev]") + if at < 0 { + t.Fatalf("the program's row wears no badge: %q", row) + } + opened := map[uint64]bool{} + for id, open := range a.railOpen { + opened[id] = open + } + railClick(t, a, a.bodyWidth()+ansi.StringWidth(row[:at])+1, y) + if !a.roomOpen() || roomID(a) != 7 { + t.Fatalf("a press on the badge did not open the task: room %d", roomID(a)) + } + for id, open := range a.railOpen { + if opened[id] != open { + t.Fatalf("a press on the badge folded row %d", id) + } + } +} + +// DRAWING THE BADGE READS NOTHING. It comes off the node the notice made, and +// for a node an older engine never named, off the plan rows the surface already +// holds — never a call to the agent while a frame is being drawn. +func TestDrawingAProgramsBadgeReadsNothingFromTheAgent(t *testing.T) { + row := programRow() + row.ID = "7" + a, fake := planAppWith(t, []session.PlanTaskRow{row}, nil) + counted := &railPlanCounter{planFake: fake} + a.agent = counted + a.width, a.height = 120, 30 + // AN OLDER ENGINE'S ROW, naming no program: the badge is read off the held + // plan row. + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, session.TaskRunning, session.TaskNotice{StartedAt: programRunBegan})}) + rows, pages := counted.rows, counted.pages + if drawn := plain(strings.Join(a.railRows(a.viewHeight()), "\n")); !strings.Contains(drawn, "[senior-dev]") { + t.Fatalf("a node named only by its held plan row wears no badge:\n%s", drawn) + } + a.stripRow(90) + a.taskSheetOwnRows() + if counted.rows != rows || counted.pages != pages { + t.Fatalf("drawing the badge read the agent: rows %d→%d, pages %d→%d", rows, counted.rows, pages, counted.pages) + } +} + +// THE BADGE IS THERE BEFORE ANY PLAN ROW IS READ AND AFTER A CONVERSATION +// SWITCH. It used to be learned only off the run's plan rows, which a window +// reads on a beat of its own and stops holding the moment the conversation in +// front changes; the notice carries it now, so neither gap takes it away. +func TestTheBadgeOutlivesTheGapsInThePlanRows(t *testing.T) { + a := programRailApp(t, "senior-dev") + if _, held := a.heldPlanRows(); held { + t.Fatal("the fixture holds plan rows, so this would prove nothing about the notice") + } + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("before any plan row was read the program's row is %q", row) + } + // A SWITCH AWAY AND BACK: the held rows belong to another front, and the + // roster is replayed from the run's kept rows, which carry the program. + a.frontGen++ + a.tasks, a.taskOrder, a.taskSeen = nil, nil, nil + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, programTitle, session.TaskRunning, programNotice("senior-dev"))}) + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("after a conversation switch the program's row is %q", row) + } +} + +// THE BRACKETS SURVIVE EVERY WAY OF DRAWING THE ROW: a terminal with no colour, +// the screen-reader tier, and the row a person is standing in, whose ground +// would swallow a chip. The painted badge is accent and bold where there is ink +// to paint with. +func TestTheBadgesBracketsSurviveEveryPalette(t *testing.T) { + a := programRailApp(t, "senior-dev") + a.width = 120 + painted := programRailRow(t, a, 120, false) + if want := a.pal.programInk("[senior-dev]"); !strings.Contains(painted, want) { + t.Fatalf("the badge is not painted by the one painter:\n%q\nwant it to carry %q", painted, want) + } + a.pal = newPalette(tokens.NoColor, false) + if row := programRailRow(t, a, 120, false); row != plain(row) || !strings.Contains(row, "[senior-dev]") { + t.Fatalf("with no colour the row is %q, want plain text carrying the brackets", row) + } + a.pal = newPalette(tokens.ANSI256, true) + a.linear = true + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("on the screen-reader tier the row is %q", row) + } + a.linear = false + a.pal = newPalette(tokens.ANSI256, false) + a.openRoom(7, programTitle) + if row := plain(programRailRow(t, a, 120, false)); !strings.Contains(row, "[senior-dev]") { + t.Fatalf("the row a person is standing in is %q, want the brackets on the selected ground", row) + } +} + +// THE ROOM'S OWN PANEL: the tree beside an open room is the same rows, and the +// title row over the room names the program too. +func TestTheRoomPanelAndItsTitleWearTheBadge(t *testing.T) { + a, _, _ := roomApp(t) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskRunning, programNotice("senior-dev"))}) + a.width, a.height = 120, 48 + a.openRoom(7, "Fix the nil-map crash") + a.railHold = false + if !a.roomPanelShowing(a.viewHeight()) { + t.Fatal("the room's panel is not showing, so this would prove nothing") + } + lines, _ := a.railView(a.viewHeight()) + var tree []string + for _, line := range lines { + if line.roomSection == roomPanelTree { + tree = append(tree, plain(line.text)) + } + } + if !strings.Contains(strings.Join(tree, "\n"), "[senior-dev]") { + t.Fatalf("the room panel's tree does not wear the badge:\n%s", strings.Join(tree, "\n")) + } + title := plain(a.roomTitleRow(a.width)) + if !strings.Contains(title, "Fix the nil-map crash [senior-dev]") { + t.Fatalf("the room's title row is %q, want the badge beside the title", title) + } + if cells := ansi.StringWidth(title); cells > a.width { + t.Fatalf("the room's title row is %d cells in a %d-cell row", cells, a.width) + } +} + +// A PAGE ONTO ANOTHER CONVERSATION'S WORK WEARS THAT WORK'S BADGE, AND NEVER THE +// ONE A LOCAL TASK OF THE SAME NUMBER WEARS. Task ids restart with every +// conversation, so the page onto their task 7 stands beside this window's own +// task 7 — here a senior-dev run, whose plan row the surface holds. The page's +// header used to fall through to that row by the bare number and draw +// `[senior-dev]` over work no program had; and when the owner said its own work +// was a program's, the page dropped it. +func TestAGuestPageWearsItsOwnWorksBadgeAndNeverTheLocalTasks(t *testing.T) { + a, _ := planAppWith(t, []session.PlanTaskRow{programRow()}, nil) + a.width, a.height = 120, 30 + a.taskUpdate(update(7, programTitle, session.TaskRunning, session.TaskNotice{})) + if drawn := plain(strings.Join(a.railRows(a.viewHeight()), "\n")); !strings.Contains(drawn, "[senior-dev]") { + t.Fatalf("this window's own task 7 wears no badge, so this would prove nothing:\n%s", drawn) + } + door := &guestDoor{} + door.watching() + a.openTaskOwner = door.open + a.away = elsewhereCache{read: true, at: a.now(), held: session.NewElsewhere(a.now(), + map[string]string{"the-other-window": "docs pass"}, + window("the-other-window", session.PresenceTask{ + ID: "7", Title: "Port the parser", State: string(session.TaskRunning)}))} + enterAway(t, a) + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser") || strings.Contains(title, "[") { + t.Fatalf("another conversation's ordinary task 7 reads %q, wearing the badge of this window's own task 7", title) + } + // AND WHEN ITS OWNER SAYS ITS WORK IS A PROGRAM'S, THE PAGE WEARS THAT BADGE. + ownerSays(t, a, session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 7, Title: "Port the parser", State: session.TaskRunning, Program: "doc-writer"}}) + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser [doc-writer]") { + t.Fatalf("the owner said its task 7 is doc-writer's and the page reads %q", title) + } + if node := a.tasks[7]; node == nil || node.program != "" { + t.Fatalf("the owner's notice named a program on this window's own task 7: %+v", node) + } +} + +// ANOTHER CONVERSATION'S PROGRAM WORK OPENS WEARING ITS BADGE, off the row the +// page was opened from and before its owner has said anything: the project's +// index names the program, and the row of that work another window has out +// keeps it, as it keeps the family it belongs to. +func TestAnotherConversationsProgramWorkOpensWearingItsBadge(t *testing.T) { + a, door := guestLab(t) + door.watching() + theirs := theirLiveSession + theirs.Tasks = session.TaskRollup{Rows: []session.TaskIndexEntry{{ + ID: "7", SessionID: theirs.ID, Label: "Port the parser", Title: "Port the parser", + Status: string(session.TaskRunning), Program: "senior-dev", + }}} + if !openTaskPlaceWithRows(a) { + t.Fatal("the tasks place opened with no rows on it") + } + // The walk of the disk, as this fixture's machine would have answered it, + // read the way the place's own rebuild reads it ([tasksPlace.regroup]). + p := &a.taskSheet + p.world = session.World{Projects: []session.Project{{Sessions: []session.SessionRow{theirs}}}} + p.reading = readTasks(p.world, p.mine, p.reading.win, p.order, p.reading.seen, p.reading.now) + r := a.tasksFiltered() + width, _ := a.size() + lines := r.lay(width) + found := false + for at := range lines { + if item, ok := r.at(lines, at); ok && item.away { + if item.entry.Program != "senior-dev" { + t.Fatalf("the row another window has out lost the program its index row names: %+v", item.entry) + } + a.taskSheet.cursor, found = at, true + break + } + } + if !found { + t.Fatalf("no row on the page belongs to another window:\n%s", taskSheetText(a)) + } + cmd := a.taskSheetEnter() + if cmd == nil { + t.Fatal("enter over another window's running work did nothing at all") + } + msg, ok := cmd().(taskOwnerMsg) + if !ok { + t.Fatalf("enter did not ask the engine for the owner: %T", cmd()) + } + a.tookTaskOwner(msg) + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser [senior-dev]") { + t.Fatalf("another conversation's senior-dev task opens reading %q, want its badge", title) + } + // AND AN OWNER'S NOTICE THAT NAMES NO PROGRAM — an engine older than the + // field — takes nothing away. + ownerSays(t, a, session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ + ID: 7, Title: "Port the parser", State: session.TaskRunning}}) + if title := plain(a.roomTitleRow(a.width)); !strings.Contains(title, "Port the parser [senior-dev]") { + t.Fatalf("a notice naming no program took the badge off the page: %q", title) + } +} + +// THE CARD A PERSON APPROVES NAMES THE PROGRAM: the block in the transcript +// wears the badge beside the name, and the question above the box says it in +// words — the same sentence the engine's own question object says. +func TestTheApprovalCardNamesTheProgram(t *testing.T) { + a, _, _ := taskApp(t) + ev := proposal(a, 7, 4*time.Second) + ev.Task.Program = "senior-dev" + drive(t, a, streamEventMsg{gen: a.gen, ev: ev}) + settleAsk(a) + text := taskText(a) + var drawn string + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), taskHeadCorner) { + drawn = line + } + } + if !strings.Contains(drawn, "Fix the nil-map") || !strings.Contains(drawn, "[senior-dev]") { + t.Fatalf("the card's head is %q, want its name and the program's badge:\n%s", drawn, text) + } + head := plain(a.taskHead(a.task, 80, false)) + if !strings.Contains(head, "[senior-dev]") || ansi.StringWidth(head) > 80 { + t.Fatalf("the card's head is %q, want the badge inside the frame", head) + } + ask := taskAsk(a) + if !strings.Contains(ask, "wants to start a [senior-dev] task: Fix the nil-map crash") { + t.Fatalf("the question does not name the program:\n%s", ask) + } + if got, want := a.taskQuestion(ev.Task).Head, session.TaskProposalHead(*ev.Task); got != want { + t.Fatalf("the surface asks %q and the engine %q — two questions about one proposal", got, want) + } + // AND THE RUN'S ROW, WHEN IT ARRIVES QUIET ABOUT ITS PROGRAM, KEEPS THE CARD'S. + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "Fix the nil-map crash", session.TaskRunning, session.TaskNotice{})}) + if node := a.tasks[7]; node == nil || node.program != "senior-dev" { + t.Fatalf("the approved node did not take the card's program: %+v", node) + } + // An ordinary proposal asks the sentence it always asked. + plainAsk := session.TaskProposalHead(session.TaskNotice{Title: "Fix the nil-map crash"}) + if plainAsk != session.TaskProposalLead+"Fix the nil-map crash" { + t.Fatalf("an ordinary proposal asks %q", plainAsk) + } +} + +// THE OTHER LISTS OF THE WORK: the strip that stands in for the column under a +// hundred columns wears the short badge, the `@` list and the tasks place wear +// the whole one, and home's work band too — and none of them moves a column or +// draws anything for an ordinary task. +func TestTheOtherListsOfTheWorkWearTheBadge(t *testing.T) { + a := programRailApp(t, "senior-dev") + chip, cols := a.stripLabel(a.tasks[7], 90) + if !strings.Contains(plain(chip), "[sd]") || ansi.StringWidth(chip) != cols { + t.Fatalf("the strip's chip is %q in %d cells, want the short badge and an honest width", plain(chip), cols) + } + a.taskUpdate(update(8, "Fix the loader nil-map", session.TaskRunning, session.TaskNotice{})) + if chip, _ := a.stripLabel(a.tasks[8], 90); strings.Contains(plain(chip), "[") { + t.Fatalf("an ordinary task's chip is %q", plain(chip)) + } + + entry := session.TaskIndexEntry{ID: "7", Label: programTitle, Title: programTitle, Status: "running", Program: "senior-dev"} + if label := plain(taskRowLabel(entry, "3m", 120, a.pal)); !strings.HasSuffix(label, programTitle+" [senior-dev]") { + t.Fatalf("the @ list's row is %q", label) + } + if block := taskPointerBlock(entry); !strings.Contains(block, "· via senior-dev") { + t.Fatalf("the mention's block does not say which program had the work:\n%s", block) + } + ordinary := entry + ordinary.Program = "" + if label := plain(taskRowLabel(ordinary, "3m", 120, a.pal)); strings.Contains(label, "[") { + t.Fatalf("an ordinary @ row is %q", label) + } + + withBadge := plain(tasksTableRow(" ", 2, programTitle, "senior-dev", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, "", "")) + without := plain(tasksTableRow(" ", 2, programTitle, "", rowSay("running"), rowSay("3m"), a.pal.dim, 120, tasksByAge, a.pal, false, "", "")) + if !strings.Contains(withBadge, programTitle+" [senior-dev]") { + t.Fatalf("the tasks place's row is %q", withBadge) + } + if ansi.StringWidth(withBadge) != ansi.StringWidth(without) || strings.Index(withBadge, "running") != strings.Index(without, "running") { + t.Fatalf("the badge moved the table's columns:\n%q\n%q", withBadge, without) + } + if head := plain(tasksCardHead(tasksItem{entry: entry}, 40, a.pal, false)); !strings.Contains(head, "[senior-dev]") || ansi.StringWidth(head) > 40 { + t.Fatalf("the tasks place's phone card head is %q", head) + } + + band := plain(strings.Join(homeWorkName(entry, 44, taskFixtureNow, a.pal), "\n")) + if !strings.Contains(band, programTitle+" [senior-dev]") { + t.Fatalf("home's work band reads %q", band) + } + if band := plain(strings.Join(homeWorkName(ordinary, 44, taskFixtureNow, a.pal), "\n")); strings.Contains(band, "[") { + t.Fatalf("home's work band reads %q for an ordinary task", band) + } +} + +// programLongLabel is a program's task named as long as the project's index +// names one (session's taskLabelLimit, fifty-six cells), which is the label a +// home cell or an `@` row is handed. +const programLongLabel = "Rewrite the auth middleware to use the new session store" + +// HOME'S `needs you` ROWS AND ITS `since you left` LINES PAY FOR THE BADGE OUT +// OF THE TITLE, as every other list of the work does. The cell cuts a title +// from its right to keep the row's age, and a badge written onto the end of the +// title was the first thing it took: a senior-dev landing with a long name read +// `…session store [seni… 1h` on a two-column home and wore no badge at all on a +// three-column one. A landed line keeps the badge between the name and what the +// work came to, so the outcome gives way before the badge does. +func TestHomesRowsPayForTheBadgeOutOfTheTitle(t *testing.T) { + pal := newTestPalette() + now := taskFixtureNow + entry := session.TaskIndexEntry{ + ID: "7", SessionID: "chat-1", Label: programLongLabel, Title: programLongLabel, + Status: string(session.TaskDone), Program: "senior-dev", + Outcome: "The middleware reads the new store.", EndedAt: now.Add(-time.Hour), + } + row := session.SessionRow{ID: "chat-1", Title: "the run", Tasks: session.TaskRollup{Rows: []session.TaskIndexEntry{entry}}} + needs := needsCall(session.Project{}, row, entry, session.TaskStatus{}, now).line.cell + landed := ledgerLanded(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}}, now.Add(-2*time.Hour)) + if len(landed) != 1 { + t.Fatalf("the landing is not a line of `since you left`: %+v", landed) + } + ledger := leftLine(landed[0], now).cell + if wide := plain(homeCellBody(ledger, 140, pal, false)); !strings.Contains(wide, programLongLabel+" [senior-dev] · The middleware reads the new store.") { + t.Fatalf("a wide landed line reads %q, want the badge between the name and the outcome", wide) + } + for _, probe := range []struct { + width int + want string + }{ + {80, "[senior-dev]"}, {66, "[senior-dev]"}, {55, "[senior-dev]"}, {40, "[senior-dev]"}, {21, "[sd]"}, + } { + for name, cell := range map[string]*homeCell{"needs you": needs, "since you left": ledger} { + drawn := plain(homeCellBody(cell, probe.width, pal, false)) + t.Logf("%s at %d: %q", name, probe.width, drawn) + if !strings.HasPrefix(drawn, "Rewrite the") || !strings.Contains(drawn, probe.want) || !strings.HasSuffix(drawn, " 1h") { + t.Fatalf("%s's row at %d cells reads %q, want the title, %s and the age", name, probe.width, drawn, probe.want) + } + if cells := ansi.StringWidth(drawn); cells > probe.width { + t.Fatalf("%s's row is %d cells in %d: %q", name, cells, probe.width, drawn) + } + } + } + // AN ORDINARY LANDING DRAWS WHAT IT ALWAYS DREW: its name and what it came to, + // cut from the right, and nothing bracketed. + entry.Program = "" + row.Tasks.Rows[0] = entry + plainNeeds := needsCall(session.Project{}, row, entry, session.TaskStatus{}, now).line.cell + plainLanded := ledgerLanded(session.World{Projects: []session.Project{{Sessions: []session.SessionRow{row}}}}, now.Add(-2*time.Hour)) + for _, width := range []int{140, 66, 40} { + if drawn := plain(homeCellBody(plainNeeds, width, pal, false)); strings.Contains(drawn, "[") { + t.Fatalf("an ordinary landing's needs row reads %q", drawn) + } + if drawn := plain(homeCellBody(leftLine(plainLanded[0], now).cell, width, pal, false)); strings.Contains(drawn, "[") { + t.Fatalf("an ordinary landed line reads %q", drawn) + } + } + if drawn := plain(homeCellBody(leftLine(plainLanded[0], now).cell, 140, pal, false)); !strings.HasPrefix(drawn, programLongLabel+" · The middleware") { + t.Fatalf("an ordinary landed line reads %q", drawn) + } +} + +// THE `@` LIST PAYS FOR THE BADGE OUT OF THE TITLE TOO. Its row cuts a label +// from the right to keep the age at its edge, so a badge written onto the end +// of the label was gone under about seventy columns. +func TestTheMentionListPaysForTheBadgeOutOfTheTitle(t *testing.T) { + a, _, _ := taskApp(t) + entry := pastTask("7", "rewrite-the-auth-middleware", programLongLabel, time.Hour) + entry.Program = "senior-dev" + a.comp.tasks = []session.TaskIndexEntry{entry} + drive(t, a, key("@"), key("r"), key("e")) + drive(t, a, filesLoadedMsg{}) + for _, probe := range []struct { + width int + want string + }{ + {100, "[senior-dev]"}, {60, "[senior-dev]"}, {45, "[senior-dev]"}, {28, "[sd]"}, + } { + found := "" + for _, row := range a.comp.rows(probe.width, completeRows, a.pal, -1, "") { + if line := plain(row); strings.Contains(line, "Rewrite") { + found = line + } + } + t.Logf("at %d: %q", probe.width, found) + if !strings.Contains(found, probe.want) || ansi.StringWidth(found) > probe.width { + t.Fatalf("the @ list's row at %d cells reads %q, want %s inside the frame", probe.width, found, probe.want) + } + } +} + +// THE BADGE NEVER CUTS A TITLE BELOW ITS FLOOR. It falls to its short spelling +// first, and where even that would leave the title less than the floor the row +// draws no badge at all rather than a badge standing for a name nobody can read. +func TestTheBadgeYieldsBeforeTheTitleFloor(t *testing.T) { + badge := programBadge("senior-dev") + for _, probe := range []struct { + room int + want string + }{ + {26, "[senior-dev]"}, + {25, "[senior-dev]"}, + {24, "[sd]"}, + {17, "[sd]"}, + {16, ""}, + } { + if got := programSpelling(badge, programTitle, probe.room, railTitleFloor); got != probe.want { + t.Fatalf("in %d cells the badge is %q, want %q", probe.room, got, probe.want) + } + } + // A title shorter than the floor asks only for its own cells. + if got := programSpelling(badge, "fix it", 19, railTitleFloor); got != "[senior-dev]" { + t.Fatalf("a short title left the badge %q, want the whole of it", got) + } +} + +// THE RUN'S OWN PLAN ROW WEARS THE BADGE TOO. A program's store root is the same +// work as its node's row and is normally left out beside it, so when it is the +// row the column draws — the node's row was dropped for it — it is the only +// place the program's work is named, and it has to say whose it is. An ordinary +// plan row is drawn as it always was. +func TestAProgramsPlanRowOnTheRailWearsTheBadge(t *testing.T) { + a, _, _ := taskApp(t) + width := railCols - ansi.StringWidth(railSeam) + row := programRow() + rows, _, _ := a.railEntryRows(railEntry{node: planRailNode(row)}, width) + if len(rows) == 0 { + t.Fatal("a program's plan row drew nothing") + } + drawn := plain(rows[0]) + t.Logf("the plan row: %q", drawn) + if !strings.Contains(drawn, "rewrite the") || !strings.Contains(drawn, "[s") { + t.Fatalf("a program's plan row on the rail reads %q, want the program's badge", drawn) + } + if cells := ansi.StringWidth(drawn); cells > width { + t.Fatalf("the plan row is %d cells in a %d-cell column: %q", cells, width, drawn) + } + row.Program, row.Stage = "", "" + ordinary, _, _ := a.railEntryRows(railEntry{node: planRailNode(row)}, width) + if len(ordinary) == 0 || strings.Contains(plain(ordinary[0]), "[") { + t.Fatalf("an ordinary plan row reads %q", ordinary) + } +} + +// A PROGRAM'S RUNNING WORK OFFERS NO STEER in the `@` block the model reads, +// because the program reads no messages; it names the stop instead. An ordinary +// running task keeps its steer. +func TestAProgramsMentionBlockOffersTheStopAndNoSteer(t *testing.T) { + entry := session.TaskIndexEntry{ID: "7", Label: programTitle, Title: programTitle, Status: "running", Program: "senior-dev"} + block := taskPointerBlock(entry) + if strings.Contains(block, "Steer:") || strings.Contains(block, " say ") { + t.Fatalf("a program's block offers a steer it would refuse:\n%s", block) + } + if !strings.Contains(block, "senior-dev reads no messages; stop it with tasks id 7 stop") { + t.Fatalf("a program's block does not name its one door:\n%s", block) + } + ordinary := entry + ordinary.Program = "" + if block := taskPointerBlock(ordinary); !strings.Contains(block, `Steer: tasks id 7 say "…"`) { + t.Fatalf("an ordinary running task lost its steer:\n%s", block) + } +} + +// A PROGRAM'S LANDED CARD, OPENED, SAYS WHAT THE RUN COST, from the price its settled +// row carries (session's publishRunRow) — the card drew none while the row +// carried none. +func TestAProgramsLandedCardSaysWhatItCost(t *testing.T) { + a := programRailApp(t, "senior-dev") + settled := programNotice("senior-dev") + settled.CostUSD, settled.EndedAt, settled.Report = 2.30, taskFixtureNow, "submitted and verified" + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, programTitle, session.TaskDone, settled)}) + at := a.doneEntryFor(7) + if at < 0 { + t.Fatal("the program's run drew no landed card") + } + done := a.entries[at].done + done.open = true + card := plain(strings.Join(a.doneRows(done, 120, false), "\n")) + if !strings.Contains(card, "$2.30") { + t.Fatalf("the program's landed card names no price:\n%s", card) + } +} diff --git a/internal/tui3/programcalls.go b/internal/tui3/programcalls.go new file mode 100644 index 0000000000..d88e54c49d --- /dev/null +++ b/internal/tui3/programcalls.go @@ -0,0 +1,382 @@ +package tui3 + +// programcalls.go draws a PROGRAM'S RAW CALLS: the dialogue between the program +// and the model that answered it, one call at a time, which is what a program's +// page used to open on. It opens on the program's actions now +// (taskconversation.go), and this is one key away ([programCallsKey]) — for a +// person debugging what the model was actually sent and what it said. +// +// senior-dev rewrite the auth middleware to use the new session store +// deepseek-v4-flash I'll read the middleware first. +// ▤ read internal/auth/middleware.go +// senior-dev read: package auth +// ◐ deepseek-v4-flash · 12s +// +// THE PAGE READS NOTHING, here as on the actions: every line is drawn from the +// page the surface already holds, with the frame's own clock for the call in +// flight. +// +// WHAT IS DRAWN IS THE PERSON'S, NEVER THE MACHINERY'S. A program's system +// prompt and the model's own words handed back to it are part of every call and +// say nothing new, so neither is ever a row; a program that summarized its own +// history is said in one line, not replayed. + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// programCallsKey turns a program's page between its actions and its raw +// calls, and back — on the stored page in the tasks place and in the program's +// room alike, named ONCE so both key rows and the manual say the one chord. +// +// WHY THIS ONE. The room has a message box, so the key must be a chord that +// types no character; the stored page takes the reading keys and `ctrl+o`; and +// every chord the conversation already spends is spent (keys.md's table). ctrl+y +// means nothing in either surface — its only other binding is the `/files` +// list's copy, a different page with a different keyboard — and it is a plain +// control byte every terminal delivers. +const programCallsKey = "ctrl+y" + +// The key row's words for it: what the next press shows. +const ( + programCallsWord = programCallsKey + " calls" + programActionsWord = programCallsKey + " actions" +) + +// programCallsHint is the key row's clause for a program's page: the view the +// next press of [programCallsKey] turns to. +func programCallsHint(calls bool) string { + if calls { + return programActionsWord + } + return programCallsWord +} + +const ( + // convSaidMost is how many of the program's messages one of its turns draws. + // A turn that answers eight tool calls sends eight results, and the eight + // calls are already drawn one row each on the model's side just above it. + convSaidMost = 3 + // convCodeaf is who answers a call codeaf refused. No model saw it, so the + // line under the program's is codeaf's own. + convCodeaf = "codeaf" +) + +// The words this page says in its own voice, each quoted in the manual as it is +// spelled here (worker-harness.md). +const ( + // convRestartedWord is the program's side of a call made after it rewrote its + // own history as a summary: what it sent is its whole history again, and that + // is one sentence rather than a replay. + convRestartedWord = "summarized its history so far" + // convFailedWord leads the line a call the model's side failed draws. + convFailedWord = "the call failed" + // convEarlierWord follows the count of calls the page does not carry. + convEarlierWord = "earlier calls" +) + +// convSide is one speaker's turn at talking: the name in the column and the +// lines it said, each already painted and not yet fitted. +type convSide struct { + name string + lines []convLine +} + +// convLine is one line a speaker said. lead is a painted mark drawn in front of +// the words and outside their fitting, so a narrow row gives up the tail of the +// words and never half a mark; text is the words, painted by ink. +type convLine struct { + lead string + text string + ink func(string) string +} + +// programCalls is a program's page as the raw calls it made: the brief the +// program was handed, then every call, each as the program's side and the side +// that answered it. It is one key away from the actions the page opens on +// ([programCallsKey]), for a person who wants to see what the model was sent +// and what it said. +// +// THE NAMES STAND IN A COLUMN OF THEIR OWN while the frame has the room, so the +// eye reads down the speakers and across to what each said; under +// [convTextLeast] cells of words each name stands on its own line instead. The +// column is as wide as the widest name on the page, so it does not move as the +// conversation grows by a call from the same model. +func (a *app) programCalls(page session.PlanTaskPage, width int, briefFull bool) []string { + if width < 1 { + return nil + } + program := convProgramOf(page) + pal := a.pal + speaker := convProgramName(page) + running := planStateWord(page.Row) == "running" + column, text := convColumns(convNames(program, speaker), width) + + var out []string + // THE BRIEF OPENS THE CONVERSATION. It is what the program was handed, in + // the person's own words, and it stands for the program's side of the first + // call — whose own words are the program's prompt around the same brief. + // Folded to the brief's own three lines, with the key that unfolds it, the + // way every other page folds a brief. + opening := convSide{name: speaker} + for _, line := range taskConversationBrief(page, text, briefFull) { + opening.lines = append(opening.lines, convLine{text: line, ink: pal.ink}) + } + if len(opening.lines) > 0 { + out = append(out, convDraw(opening, column, width, pal)...) + } + // THE CALLS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, never in + // a speaker's column, where the count read as something the program said. It + // is spelled the way every fold line on this surface is ([bandFoldWord]). + if program.Earlier > 0 { + out = append(out, pal.dim(fit(glyphMore+itoa(program.Earlier)+" "+convEarlierWord, width))) + } + briefHead := convBriefHead(page.Description) + for i, turn := range program.Turns { + first := i == 0 && program.Earlier == 0 + if said := a.convProgramSide(turn, speaker, briefHead, first); len(said.lines) > 0 { + out = append(out, convDraw(said, column, width, pal)...) + } + switch { + case convHead(turn.Refused) != "": + out = append(out, convDraw(convSide{name: convCodeaf, lines: []convLine{{ + text: taskPlanRefusedWord + railSep + convHead(turn.Refused), ink: pal.dim, + }}}, column, width, pal)...) + case convHead(turn.Failed) != "": + out = append(out, convDraw(convSide{name: convModelWord(turn), lines: []convLine{{ + text: convFailedWord + railSep + convHead(turn.Failed), ink: pal.dim, + }}}, column, width, pal)...) + case turn.InFlight(): + // THE CALL IN FLIGHT IS THE LIVE EDGE, and it is drawn only while the + // task can still be waiting on it. A call whose ending never reached the + // log before the run ended is not in flight on a page about work that + // is over: it draws no line at all rather than a clock that never stops. + if running { + if line := a.convInFlight(turn, width); line != "" { + out = append(out, line) + } + } + default: + if answer := a.convModelSide(turn); len(answer.lines) > 0 { + out = append(out, convDraw(answer, column, width, pal)...) + } + } + } + return out +} + +// convProgramSide is what the program said on one call, in the lines a person +// reads for it: nothing new on the first call, whose words the brief above +// already stands for; one sentence on a call made after it summarized its own +// history; and otherwise its newest messages, each as its first line — a tool's +// result as `<tool>: <line>` and its own words as they were. +// +// NEITHER A PROMPT NOR AN ECHO IS A ROW. A `system` message is the program +// instructing its model, and an `assistant` one is the model's last answer +// handed back to it, which the model's own side has already drawn; and a +// message that is the brief again says nothing the opening has not. +func (a *app) convProgramSide(turn delegate.Turn, speaker, briefHead string, first bool) convSide { + pal := a.pal + side := convSide{name: speaker} + if turn.Restarted { + side.lines = append(side.lines, convLine{text: convRestartedWord, ink: pal.dim}) + return side + } + if first { + return side + } + var said []string + for _, message := range turn.Sent { + line := convHead(message.Text) + switch strings.TrimSpace(message.Role) { + case "system", "assistant": + continue + case "tool": + if tool := strings.TrimSpace(message.Tool); tool != "" { + line = tool + ": " + line + } + default: + if convRepeatsBrief(line, briefHead) { + continue + } + } + if strings.TrimSpace(line) != "" { + said = append(said, line) + } + } + shown := said + if len(shown) > convSaidMost { + shown = shown[:convSaidMost] + } + for _, line := range shown { + side.lines = append(side.lines, convLine{text: line, ink: pal.dim}) + } + if more := len(said) - len(shown); more > 0 { + side.lines = append(side.lines, convLine{text: "+" + itoa(more) + " more", ink: pal.dim}) + } + return side +} + +// convModelSide is what the model answered on one call: the first line of its +// words, and every tool it asked the program to run, one dim row each behind +// that tool's action mark — the same family marks the conversation's own steps +// wear ([app.actionMarkFor]), so a person who has learned `✎` for an edit there +// reads it here. +func (a *app) convModelSide(turn delegate.Turn) convSide { + pal := a.pal + side := convSide{name: convModelWord(turn)} + if reply := convHead(turn.Reply); reply != "" { + side.lines = append(side.lines, convLine{text: reply, ink: pal.ink}) + } + for _, call := range turn.Calls { + name := convHead(call.Name) + if name == "" { + continue + } + words := name + if about := convHead(convCallAbout(call.Args)); about != "" { + words += " " + about + } + mark := a.actionMarkFor(session.ActionCategoryForTool(name)) + side.lines = append(side.lines, convLine{lead: pal.dim(mark) + " ", text: words, ink: pal.dim}) + } + return side +} + +// convInFlight is the call in flight: the running mark, the model it went to, +// and how long it has been out — one line, the last on the page, gone the moment +// its ending reaches the log. The mark comes off the vocabulary's own door, so +// the line gets this terminal's repertoire; the clock is the frame's and says +// nothing for the call's first second. +func (a *app) convInFlight(turn delegate.Turn, width int) string { + mark := a.icon(tokens.GStepRunning) + room := width - ansi.StringWidth(mark) - 1 + if room < 1 { + return "" + } + var words []string + if model := convModelWordOf(turn.Model); model != "" { + words = append(words, model) + } + if !turn.Started.IsZero() { + if clock := countUpWord(a.now().Sub(turn.Started)); clock != "" { + words = append(words, clock) + } + } + return a.pal.ink(mark) + " " + a.pal.dim(fit(strings.Join(words, railSep), room)) +} + +// convDraw lays one side out: its name in the column on its first line and its +// words beside it, or — where the frame is too narrow for a column — its name +// on a line of its own and its words hung under it. EVERY ROW IS FITTED TO THE +// WIDTH: the name is cut in the middle when it must be ([rowTrim]), a mark in +// front of the words is kept whole, and the words give up their tail. +func convDraw(side convSide, column, width int, pal palette) []string { + var out []string + if column == 0 { + if name := strings.TrimSpace(side.name); name != "" { + label, _ := rowTrim(name, width, false) + out = append(out, pal.muted(label)) + } + indent := strings.Repeat(" ", convIndent) + for _, line := range side.lines { + out = append(out, indent+convWords(line, width-convIndent)) + } + return out + } + gap := strings.Repeat(" ", convGap) + blank := strings.Repeat(" ", column) + for i, line := range side.lines { + cell := blank + if i == 0 && strings.TrimSpace(side.name) != "" { + label, _ := rowTrim(side.name, column, false) + cell = padTo(pal.muted(label), column) + } + out = append(out, cell+gap+convWords(line, width-column-convGap)) + } + return out +} + +// convWords is one line's words at their width, behind its mark when it has +// one. A width too small to hold the mark draws the words alone. +func convWords(line convLine, width int) string { + if width < 1 { + return "" + } + ink := line.ink + if ink == nil { + ink = func(s string) string { return s } + } + lead := line.lead + if lead != "" { + if cells := ansi.StringWidth(ansi.Strip(lead)); cells < width { + return lead + ink(fit(line.text, width-cells)) + } + } + return ink(fit(line.text, width)) +} + +// convColumns decides the page's two widths from the names on it: the column +// the names stand in, and the room their words get beside it. A column of zero +// is the narrow layout, where every name stands on its own line and the words +// hang [convIndent] cells under it. +func convColumns(names []string, width int) (int, int) { + widest := 0 + for _, name := range names { + if cells := ansi.StringWidth(strings.TrimSpace(name)); cells > widest { + widest = cells + } + } + column := widest + if column > convLabelMost { + column = convLabelMost + } + if third := width / 3; column > third { + column = third + } + if column < 1 || width-column-convGap < convTextLeast { + return 0, width - convIndent + } + return column, width - column - convGap +} + +// convNames is every name the page will draw in its column: the program's, +// codeaf's when a call was refused, and the model of every call on the page. +func convNames(program *session.PlanProgram, speaker string) []string { + names := []string{speaker} + for _, turn := range program.Turns { + if strings.TrimSpace(turn.Refused) != "" { + names = append(names, convCodeaf) + continue + } + if !turn.InFlight() { + names = append(names, convModelWord(turn)) + } + } + return names +} + +// convBriefHead is the brief's first line as a program's own message would +// carry it, so a message that is the brief again can be told from one that +// says something new. +func convBriefHead(description string) string { return convHead(description) } + +// convRepeatsBrief reports whether a message's first line is the brief's again. +// The page carries a message's head cut at a couple of hundred bytes with the +// cut marked, so a long brief repeated is the brief's own line up to that mark. +func convRepeatsBrief(line, briefHead string) bool { + if line == "" || briefHead == "" { + return false + } + if line == briefHead { + return true + } + cut := strings.TrimSuffix(line, glyphMore) + return cut != line && cut != "" && strings.HasPrefix(briefHead, cut) +} diff --git a/internal/tui3/programroom.go b/internal/tui3/programroom.go new file mode 100644 index 0000000000..e7fa24cc46 --- /dev/null +++ b/internal/tui3/programroom.go @@ -0,0 +1,566 @@ +package tui3 + +// programroom.go opens a PROGRAM'S TASK the way every other task opens: as a +// room inside the conversation's own tab. +// +// A task handed to a program codeaf carries (senior-dev) has no worker +// transcript. What the program did is its conversation with codeaf, on the +// task's stored page ([session.PlanTaskPage.Program]), and taskconversation.go +// draws it. That page used to be drawn by the tasks place's machinery OVER the +// conversation — a full frame with no tab strip, reached from the side list, +// the card, a task link, the task strip, the home panel and the sessions place +// — and the strip's hit map under it kept answering presses nobody could see; +// the strip also offered the run a tab of its own that drew itself selected +// beside the conversation's, which a press on the conversation's tab never +// left, and Home opened underneath it and vanished from the strip. The owner +// met all of it on the first senior-dev run of 2026-09-24. +// +// SO IT IS A [taskRoom] NOW, the way an adaptive run's graph is (roomorch.go): +// the conversation's own strip stays over it with the conversation's tab the +// one selected tab, the trail and the rail stay beside it, and `esc`, a press +// on the conversation's tab and a press on Home leave it exactly as they leave +// any room. What fills the body is the program's conversation, the facts row +// is the line the stored page pins under its title, and `x` stops the run +// through the store's own door, as the stored page's `x` does. +// +// THE BOX SENDS NOTHING. A program reads no message — nothing a person types +// reaches senior-dev once it is running — so the placeholder says so, and +// enter over a sentence says it again on the page and keeps the sentence in the +// box, where the refusal's door (the conversation) can still take it. + +import ( + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// programRoom is what a program's room holds instead of a lane: the stored page +// as last read, when it was read and whether a read is out, the room's own fold +// of the brief, the width its body was last laid out at (which `ctrl+o` +// measures the brief against), and the lines the page itself has said. +type programRoom struct { + page session.PlanTaskPage + readAt time.Time + reading bool + // briefFull says the head's dropdown is open: the whole brief the program + // was handed, drawn between the head's rules ([app.programHeadBriefRows]). + // It opens shut, and `ctrl+o` or a press on the dropdown turns it. + briefFull bool + // briefSpan is where the dropdown was drawn on the title row, for the + // press that turns it ([app.programBriefPress]). + briefSpan hudSpan + // open is the actions whose whole step is shown under their one line, by + // the moment each was received ([app.toggleProgramAction]). + open map[int64]bool + // calls says the room shows the program's raw calls instead of its actions + // ([programCallsKey]); a room opens on the actions. + calls bool + inner int + said []string +} + +// programRoomRefusal is what a program's room says about its box: the fact, and +// the one place the words can still go ([refusal]'s law). The fact names the +// program when the page knows its name ([app.programRoomRefusal]). +var programRoomRefusal = refusal{ + what: "this task's program reads no messages", + shortWhat: "reads no messages", + door: refusalMainDoor, +} + +// programRoomNoMessages is the fact's tail after the program's own name. +const programRoomNoMessages = " reads no messages" + +// programOf is the open room's program, and nil on every other page. +func (a *app) programOf() *programRoom { + if a.room == nil { + return nil + } + return a.room.program +} + +// programTask reports whether the surface holds this conversation's task as a +// program's run: its held row names the program. It reads only what is held, +// never the store, because it is asked on the loop at a key or a click. +func (a *app) programTask(id uint64) bool { + row, ok := a.heldProgramRow(id) + return ok && strings.TrimSpace(row.Program) != "" +} + +// heldProgramRow is the row the surface holds for one of this conversation's +// tasks, if it holds one. +func (a *app) heldProgramRow(id uint64) (session.PlanTaskRow, bool) { + rows, ok := a.heldPlanRows() + if !ok { + return session.PlanTaskRow{}, false + } + want := strconv.FormatUint(id, 10) + for _, row := range rows { + if planTaskIDWord(row.ID) == want { + return row, true + } + } + return session.PlanTaskRow{}, false +} + +// planTaskIDWord is a store id in the spelling a node's number has: the store +// answers `7` or `t-7` for the run rooted at task 7. +func planTaskIDWord(id string) string { + return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(id), "t-")) +} + +// programRoomFor is the task a stored page opens a program's room on: the +// page's own number, when the page is a program's. A program's run is always +// its store's root, rooted at the task's own number ([app.programRowNode]). +func (a *app) programRoomFor(page session.PlanTaskPage) (uint64, bool) { + if pageProgram(page) == "" { + return 0, false + } + id, err := strconv.ParseUint(planTaskIDWord(page.Row.ID), 10, 64) + if err != nil || id == 0 { + return 0, false + } + return id, true +} + +// heldProgramPage is the page a door can open on before the store answers: the +// row the surface holds for the task, and nothing else yet. +func (a *app) heldProgramPage(id uint64) session.PlanTaskPage { + row, _ := a.heldProgramRow(id) + return session.PlanTaskPage{Row: row} +} + +// programRowNode is the node of this conversation a program's store row is +// about, or nil for a row that is not a program's or not this conversation's. +// A program's run is always its store's root, and the root is rooted at the +// task's own number (session's startKnownTaskRun), so the id is the node's. +func (a *app) programRowNode(row session.PlanTaskRow) *taskNode { + if strings.TrimSpace(row.Program) == "" { + return nil + } + n, err := strconv.ParseUint(planTaskIDWord(row.ID), 10, 64) + if err != nil || n == 0 { + return nil + } + return a.tasks[n] +} + +// openProgramRoom opens the program's room on the page the caller holds — the +// store's page when a gesture already read it, the held row alone otherwise — +// and asks the store for the whole page off the loop. +// +// IT NEEDS NO ROOM DOORS. There is no lane to subscribe to and no journal to +// read, so the room opens on a hosted conversation exactly as on a local one: +// the one door it reads through, [session.Agent.PlanTaskPage], is on the wire. +func (a *app) openProgramRoom(id uint64, title string, page session.PlanTaskPage) { + if a.startingChat() { + a.parkChatStart() + } + room := a.newRoom(id, firstNonEmpty(title, page.Row.Title, taskIDWord(id))) + room.program = &programRoom{page: page} + a.room = room + room.done = a.programRoomDone() + // AND THE BOX POINTS AT THIS TASK, as every room's does (recipient.go): what + // was being written for the conversation is stashed under its own reader and + // comes back with the conversation. Nothing typed here is sent anywhere. + a.retargetComposer(taskRecipient(id)) + // The rail's clock stops being reported while the person is looking into + // the work, as it does for every room (task.go's [app.taskNow]). + a.freezeNode(id) + a.sel = -1 + a.dropHover() + a.touch() + a.roomPump = tea.Batch(a.programRoomRead(), a.wake()) +} + +// programRoomDone is whether the open program room's work is over: by the +// conversation's own row when it holds one, and by the stored page's state +// only when it does not. A room whose node this window never saw is not taken +// for finished on that absence alone ([roomRowDone] answers true for no node +// at all). +// +// THE ROW OUTRANKS THE STORE WHENEVER THE WINDOW HOLDS ONE. The engine ends +// the store's root at the program's exit and writes the landing — where the +// work went, how to bring it in — only after it, and the row settles last. A +// room that took the store's ending for the end stopped reading in that gap, +// and its landing never reached the page. +func (a *app) programRoomDone() bool { + p := a.programOf() + if p == nil { + return false + } + if node := a.roomNode(); node != nil { + return roomRowDone(node) + } + return planEnded(p.page.Row) +} + +// programRoomRead re-reads the open program room's page off the loop. The +// answer lands only on the room that asked. +// +// A PAGE READ THROUGH ANOTHER CONVERSATION READS THE OWNER'S STORE, through +// its own view ([app.guestPageRead]). This window's store holds this +// conversation's task of the same number, and reading it here would draw that +// task's actions under the owner's name. +func (a *app) programRoomRead() tea.Cmd { + if a.roomIsGuest() { + if a.room.program == nil { + return nil + } + return a.guestPageRead() + } + agent, ok := a.planReader() + room := a.room + if !ok || room == nil || room.program == nil || room.program.reading || room.id == 0 { + return nil + } + id, gen, p := strconv.FormatUint(room.id, 10), room.gen, room.program + p.reading = true + p.readAt = a.now() + return a.offLoop(func() func(bool) tea.Cmd { + page, found := agent.PlanTaskPage(id) + return func(here bool) tea.Cmd { + p.reading = false + if !here || !found || a.room != room || room.gen != gen { + return nil + } + p.page = page + if room.title == "" || room.title == taskIDWord(room.id) { + room.title = firstNonEmpty(page.Row.Title, room.title) + } + // A READ NEVER ENDS A ROOM WHOSE ROW THIS WINDOW HOLDS. The node's + // landing is what ends it, in [app.programRoomFollow], which reads the + // page once more from that moment; a read that was already out when + // the row landed answers with the page from before the landing, and + // had it ended the room here the last read would never be made. A room + // with no node has only the store to go by, and this read is it. + if a.roomNode() == nil { + room.done = a.programRoomDone() + } + room.dirty = true + a.touch() + return nil + } + }) +} + +// programRoomFollows reports whether the open program room is on work that can +// still move, which is when the paint clock keeps turning for it: the room's +// age ticks and its page is read on a beat. +func (a *app) programRoomFollows() bool { + p := a.programOf() + if p == nil || a.room.done { + return false + } + switch planStateWord(p.page.Row) { + case "queued", "running": + return true + } + // A page whose read has not come back yet names no state, and a node that is + // still running is work that can still move. + node := a.roomNode() + return node != nil && !roomRowDone(node) +} + +// programRoomFollow is the paint clock's read, on the stored page's own beat +// ([app.taskPlanFollow], [elsewhereEvery]). +// +// THE LANDING IS READ ONCE MORE. The conversation's row settles on a notice, +// and the page it closes over is the page as it was a beat ago — without the +// notes the run left or the state its store ended on. So the moment the room +// learns the work is over it reads the page one last time, and after that +// never again. +func (a *app) programRoomFollow() tea.Cmd { + p := a.programOf() + if p == nil || p.reading { + return nil + } + if done := a.programRoomDone(); done != a.room.done { + a.room.done = done + a.room.dirty = true + if done { + return a.programRoomRead() + } + } + if !a.programRoomFollows() || a.now().Sub(p.readAt) < elsewhereEvery { + return nil + } + return a.programRoomRead() +} + +// programRoomRows is the room's body: the program's conversation, the notes +// the run left, what the page itself has said, and the foot a landed task's +// room draws — laid out inside the reading gutter the conversation keeps. +func (a *app) programRoomRows(width int) []row { + p := a.programOf() + if p == nil { + return nil + } + inner := gutterInner(width) + p.inner = inner + pal := a.pal + var out []row + // THE BRIEF IS THE HEAD'S (its dropdown), so the actions open the body; and + // every action with more to show is a press that opens its whole step. + lines, keys := a.programBodyRows(p.page, inner, p.briefFull, p.calls, !a.programHeadsRoom(), p.open) + for i, line := range lines { + r := row{text: line, entry: -1} + if keys[i] != 0 { + r.hit, r.turn = hitAction, int(keys[i]) + } + out = append(out, r) + } + if len(p.said) > 0 { + out = append(out, row{entry: -1}) + for _, said := range p.said { + for _, line := range railWrap(said, inner) { + out = append(out, row{text: pal.dim(line), entry: -1}) + } + } + } + // A PAGE READ THROUGH ANOTHER CONVERSATION says what is true of the reading + // under what it read, exactly as its journal page does (room.go's + // [app.roomGuestTail]): that the conversation under it was replaced, that + // it cannot ask the owner what the work is doing now, or that the owner is + // waiting on somebody. + var tail []row + if guest := a.roomGuest(); guest != nil && guest.lost { + tail = append(tail, row{text: pal.dim(fit(taskGuestGoneWord, inner)), entry: -1}) + } + if tail = append(tail, a.roomGuestTail(inner)...); len(tail) > 0 { + if len(out) > 0 { + out = append(out, row{entry: -1}) + } + out = append(out, tail...) + } + if a.room.done && !a.roomLandingAsking() { + if len(out) > 0 { + out = append(out, row{entry: -1}) + } + out = append(out, row{text: pal.dim(a.roomDoneRefusal().fit(inner)), entry: -1}) + } + gutterPass(out, width) + a.hoverPass(out, width) + return out +} + +// programSay puts one line on the program's page, below its conversation. It +// is the room's own note ([app.roomNote]) for a page whose body is not a +// transcript, and like it a line identical to the one before it is not said +// twice. +func (p *programRoom) programSay(text string) { + if n := len(p.said); n > 0 && p.said[n-1] == text { + return + } + p.said = append(p.said, text) +} + +// programRoomRefusal is [programRoomRefusal] with the program named, when the +// page knows what to call it. +func (a *app) programRoomRefusal() refusal { + out := programRoomRefusal + if p := a.programOf(); p != nil { + if name := convProgramName(p.page); name != "" && name != convProgramFallback { + out.what = name + programRoomNoMessages + } + } + return out +} + +// programFactsWord is the room's facts row on a program's page: the line the +// stored page pins under its title — the stage, the spend of the ceiling, the +// calls, the age — with the age read off the node the room stands on, the +// clock the rail and the landed card read. It answers how many of its leading +// cells are the lead word, which the row paints in the node's own ink. +func (a *app) programFactsWord(width int) (string, int) { + p := a.programOf() + if p == nil { + return "", 0 + } + line := strings.TrimSpace(a.programPinned(p.page, width, a.programRoomClock())) + lead, _, _ := strings.Cut(line, rowSep) + if a.roomNode() == nil { + return line, 0 + } + return line, ansi.StringWidth(lead) +} + +// programRoomClock is the age the program room's facts row draws: the span +// the program ran once its process has ended, the node's clock when this +// conversation holds one for it, and the stored page's own stamps otherwise. +func (a *app) programRoomClock() string { + p := a.programOf() + if p != nil { + if word, ok := programExitClock(p.page.Row, a.roomNode()); ok { + return word + } + } + if word, ok := a.nodeClock(a.roomNode()); ok { + return word + } + if p != nil { + return a.taskPlanAge(p.page.Row) + } + return "" +} + +// programExitClock is the span a program's run ran for when its process has +// ended and the conversation's row has not yet settled: the page's own pair, +// which session puts on the hand-off and the program's recorded exit, rounded +// as every finished span is ([taskNode.ranFor]). +// +// THE CLOCK STOPS AT THE PROGRAM'S EXIT, NOT AT THE LANDING. After the process +// ends the engine waits for the receipts of calls it still owes — up to +// seventy seconds on a cut call — and lands the work, and only then settles +// the row; a room and a page that went on reading the row's running clock +// counted through all of that and jumped back when it settled. A row that has +// settled has its own span, and a run still working has no exit to stop at. +func programExitClock(row session.PlanTaskRow, node *taskNode) (string, bool) { + if node == nil || node.state != session.TaskRunning || strings.TrimSpace(row.Program) == "" { + return "", false + } + if row.Started.IsZero() || row.Ended.IsZero() || row.Ended.Before(row.Started) { + return "", false + } + return countUpWord(row.Ended.Sub(row.Started).Round(time.Second)), true +} + +// programStopTarget is what `x`, `/stop` and the room's Stop end on a +// program's room: the run's own task, through the store's own door +// ([session.Agent.PlanCancel]) — the target the stored page's `x` has always +// raised ([app.taskPlanStop]), which ends a live run and a run whose process +// is already gone alike. Only work that can still stop is offered. +func (a *app) programStopTarget() stopTarget { + p := a.programOf() + if p == nil || a.room.done { + return stopTarget{} + } + if _, ok := a.planReader(); !ok { + return stopTarget{} + } + row := p.page.Row + if strings.TrimSpace(row.ID) == "" { + // The page has not been read and the surface holds no row for it: the + // store's id is the task's own number, as it is for every program's run. + row.ID = strconv.FormatUint(a.room.id, 10) + } + if planEnded(row) { + return stopTarget{} + } + return stopTarget{plan: row.ID, noun: stopTaskNoun, detail: stopTaskDetail} +} + +// programRoomKey is what a program's room takes before the room's own keys: +// `ctrl+o` folds and unfolds the brief when it is long enough to fold, +// [programCallsKey] turns the page between the program's actions and its raw +// calls, and the thinking chord is taken and does nothing, because a program's +// run has no thinking level this surface can move. Everything else is the +// room's. +func (a *app) programRoomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { + p := a.programOf() + if p == nil { + return nil, false + } + switch msg.String() { + case "ctrl+o": + // THE KEY TURNS THE HEAD'S DROPDOWN, whatever the brief's length: the + // brief is drawn whole up there or not at all ([app.programHeadBriefRows]). + // On the raw calls it still unfolds the brief those draw in their body. + p.briefFull = !p.briefFull + a.room.dirty = true + a.touch() + return nil, true + case programCallsKey: + p.calls = !p.calls + a.room.dirty = true + a.touch() + return nil, true + case effortKey: + return nil, true + } + return nil, false +} + +// ── THE HEAD: ONE TITLE, AND THE BRIEF BEHIND A DROPDOWN ───────────────────── +// +// A program's room used to open under two titles: the trail's crumb — the +// conversation's name, which a conversation named after its work spells the +// same as the task — and the task's own bold title under it, and a third in the +// box's `Reading:` label. The owner asked on 2026-09-25 for one: the head is +// the title row alone (the task's name, its badge, the dropdown, the pinned +// facts), and the brief the program was handed is behind the dropdown, drawn +// whole in the dim ink between the head's rules. The way back is `esc`, named +// on the key line, and the side list's `‹ Back to main`. + +// programBriefChevron is the dropdown on the title row: shut, or open. +func programBriefChevron(open bool) string { + if open { + return glyphOpen + " brief" + } + return glyphShut + " brief" +} + +// programHeadsRoom says the open room is a program's in the frame that draws +// the head as its own rows ([app.roomOrganized]): the one layout the dropdown +// lives in. A frame too short for it keeps the compact trail, which names the +// task already. +func (a *app) programHeadsRoom() bool { + return a.programOf() != nil && a.roomOrganized() +} + +// programHeadBriefRows is the brief, whole, between the head's rules while the +// dropdown is open, and nothing while it is shut. It is pinned with the head, +// so a brief longer than half the frame gives up its tail to a count rather +// than the body its rows. +func (a *app) programHeadBriefRows(width int) []string { + p := a.programOf() + if p == nil || !p.briefFull || !a.roomOrganized() { + return nil + } + text := max(width-headLabelAt-2, 1) + lines := planBriefRows(p.page.Description, text) + if len(lines) == 0 { + return nil + } + _, height := a.size() + if most := max(height/2, 3); len(lines) > most { + cut := len(lines) - (most - 1) + lines = append(append([]string(nil), lines[:most-1]...), bandFoldWord(cut, briefFoldWhat, true)) + } + rows := make([]string, len(lines)) + for i, line := range lines { + rows[i] = strings.Repeat(" ", headLabelAt) + a.pal.dim(fit(line, text)) + } + return rows +} + +// programBriefPress turns the dropdown when the press landed on it. +func (a *app) programBriefPress(x, y int) bool { + p := a.programOf() + if p == nil || !a.programHeadsRoom() || a.headHeight() == 0 || y != a.roomHeadRow() || !p.briefSpan.holds(x) { + return false + } + p.briefFull = !p.briefFull + a.room.dirty = true + a.touch() + return true +} + +// toggleProgramAction opens or shuts one action's whole step under its line. +func (a *app) toggleProgramAction(key int64) { + p := a.programOf() + if p == nil { + return + } + if p.open == nil { + p.open = map[int64]bool{} + } + p.open[key] = !p.open[key] + a.room.dirty = true + a.touch() +} diff --git a/internal/tui3/programroom_test.go b/internal/tui3/programroom_test.go new file mode 100644 index 0000000000..f0fc39bf40 --- /dev/null +++ b/internal/tui3/programroom_test.go @@ -0,0 +1,502 @@ +package tui3 + +// A program's room (programroom.go), driven through the surface's own loop: the +// box that sends nothing and says so, the brief's fold, the page that follows +// the run on the paint clock and stops when it settles, the stop that goes +// through the store's own door, the room at a phone's width, and the one clock +// the room, the rail and the landed card all read for one run. + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/session" +) + +// programRoomAgent is the plan fixture with the room doors on it and a count of +// every page read and every line sent to a node, so a test can say what a +// gesture asked of the engine. +type programRoomAgent struct { + *railPlanCounter + steered []string +} + +func (f *programRoomAgent) SteerTask(_ uint64, text string) (session.SteerReceipt, error) { + f.steered = append(f.steered, text) + return session.SteerReceipt{}, nil +} + +// programRoomApp is a window in the conversation "the run" whose task 7 was +// handed to senior-dev. The rows are spelled the way the store spells them +// (`t-7`, session's planStoreID); the page is keyed by the number the room +// reads it by. The rail's row for the node started at [programRunBegan]. +func programRoomApp(t *testing.T, width, height int) (*app, *programRoomAgent) { + t.Helper() + row := programRow() + page := programPage(row, programTurns()) + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{"7": page}) + agent := &programRoomAgent{railPlanCounter: &railPlanCounter{planFake: fake}} + a.agent = agent + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = width, height + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, session.TaskRunning, session.TaskNotice{StartedAt: programRunBegan})}) + readPlanRows(t, a) + return a, agent +} + +// openProgramRoomNow opens task 7's room by the card's door and lets its first +// read come back. +func openProgramRoomNow(t *testing.T, a *app) { + t.Helper() + a.openRoomFor(7, "rewrite the auth middleware") + drain(t, a, a.takeRoomPump()) + if a.programOf() == nil { + t.Fatalf("the card did not open the program's room: room=%v", a.room != nil) + } +} + +// THE BOX SENDS NOTHING AND SAYS SO. A program reads no message: the +// placeholder says it with the door the words can go through, and enter over a +// sentence sends it nowhere — not to the node, not to the store as a note — +// says the same line on the page, and leaves the sentence in the box. +func TestAProgramsRoomSendsNothingAndSaysSo(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + said := "senior-dev" + programRoomNoMessages + refusalGap + refusalMainDoor + if frame, _, _ := a.frame(); !strings.Contains(plain(frame), said) { + t.Fatalf("the empty box does not say %q:\n%s", said, plain(frame)) + } + for _, r := range "pause it" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(agent.steered) != 0 || len(agent.noted) != 0 { + t.Fatalf("enter on a program's room sent %v to the node and %v to the store", agent.steered, agent.noted) + } + if got := a.input.String(); got != "pause it" { + t.Fatalf("enter took the sentence out of the box: %q", got) + } + if !strings.Contains(roomText(a), said) { + t.Fatalf("enter did not say %q on the page:\n%s", said, roomText(a)) + } + // AND A SECOND ENTER DOES NOT SAY IT TWICE. + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if n := strings.Count(roomText(a), said); n != 1 { + t.Fatalf("the page says the refusal %d times", n) + } +} + +// THE ROOM NAMES ITS TASK ONCE, AND ITS BRIEF IS BEHIND A DROPDOWN. The head is +// the title row alone — no trail crumb repeating the conversation's name, no +// `Reading:` label over the box — with `▸ brief` after the badge; the brief is +// not in the body, and ctrl+o or a press on the dropdown draws it whole between +// the head's rules, and shuts it again. +func TestAProgramRoomNamesItsTaskOnceAndHidesItsBriefBehindADropdown(t *testing.T) { + a, agent := programRoomApp(t, 120, 40) + page := agent.planFake.pages["7"] + page.Description = strings.Repeat("the store interface changes and every caller moves with it. ", 12) + "THE LAST WORDS" + agent.planFake.pages["7"] = page + openProgramRoomNow(t, a) + width, _ := a.size() + head := func() []string { + var out []string + for _, line := range a.roomHeadRows(width) { + out = append(out, plain(line)) + } + return out + } + shut := head() + if len(shut) < 1 || !strings.Contains(shut[0], "rewrite the auth middleware") || !strings.Contains(shut[0], programBriefChevron(false)) { + t.Fatalf("the head's first row is not the title with its dropdown: %q", shut) + } + if strings.Contains(strings.Join(shut, "\n"), "THE LAST WORDS") || strings.Contains(roomText(a), "THE LAST WORDS") { + t.Fatal("the brief is drawn while its dropdown is shut") + } + if a.roomRecipientHeight() != 0 { + t.Fatal("the box still carries a `Reading:` label naming the task a third time") + } + drive(t, a, key("ctrl+o")) + open := strings.Join(head(), "\n") + if !strings.Contains(open, "THE LAST WORDS") || !strings.Contains(open, programBriefChevron(true)) { + t.Fatalf("ctrl+o did not draw the whole brief in the head:\n%s", open) + } + if strings.Contains(roomText(a), "THE LAST WORDS") { + t.Fatal("the open brief is drawn in the body as well as the head") + } + p := a.programOf() + if !a.programBriefPress(p.briefSpan.from, a.roomHeadRow()) || strings.Contains(strings.Join(head(), "\n"), "THE LAST WORDS") { + t.Fatal("a press on the dropdown did not shut the brief") + } +} + +// A STEP OPENS TO ITS WHOLE SELF. An action with more to show is a press on +// the room: it draws the step's command and what came back under its line, and +// the same press shuts it. +func TestAProgramsStepOpensToItsWholeStepAndShutsAgain(t *testing.T) { + a, agent := programRoomApp(t, 120, 40) + page := agent.planFake.pages["7"] + program := *page.Program + program.Actions = append([]delegate.Shown(nil), program.Actions...) + at := program.Actions[len(program.Actions)-1].At.Add(time.Second) + program.Actions = append(program.Actions, delegate.Shown{At: at, Step: "explore", Text: "ran go test ./...", + Outcome: "fails · exit 1", Detail: "bash: go test ./...\n\n--- FAIL: TestTheWholeOutput"}) + page.Program = &program + agent.planFake.pages["7"] = page + openProgramRoomNow(t, a) + var target row + for _, r := range a.roomRows(a.bodyWidth()) { + if strings.Contains(plain(r.text), "ran go test ./...") { + target = r + } + } + if target.hit != hitAction { + t.Fatalf("the step is not a press: %+v", target) + } + if strings.Contains(roomText(a), "TestTheWholeOutput") { + t.Fatal("the step's whole output is drawn before it was opened") + } + a.toggleProgramAction(int64(target.turn)) + if !strings.Contains(roomText(a), "TestTheWholeOutput") { + t.Fatalf("opening the step did not draw its whole step:\n%s", roomText(a)) + } + a.toggleProgramAction(int64(target.turn)) + if strings.Contains(roomText(a), "TestTheWholeOutput") { + t.Fatal("the same press did not shut the step") + } +} + +// THE ROOM FOLLOWS THE RUN ON THE PAINT CLOCK AND STOPS WHEN IT SETTLES. While +// the run works, the page is read once a beat and no more; the landing is read +// once, so the room ends on the page the store ended on; after that no beat +// reads it again, and the clock that carried the reads stops turning for it. +func TestAProgramRoomFollowsWhileRunningAndStopsAfterItSettles(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + if !a.programRoomFollows() { + t.Fatal("a room on a running program is not on the paint clock") + } + reads := agent.railPlanCounter.pages + for range 5 { + drive(t, a, frameMsg{}) + } + if agent.railPlanCounter.pages != reads { + t.Fatalf("frames inside one beat read the page %d times", agent.railPlanCounter.pages-reads) + } + for beat := 1; beat <= 3; beat++ { + planBeat(t, a) + if got := agent.railPlanCounter.pages - reads; got != beat { + t.Fatalf("after %d beats the page was read %d times, want once a beat", beat, got) + } + } + // THE RUN LANDS: the store ends its root and the conversation's row settles. + page := agent.planFake.pages["7"] + page.Row.Status, page.Row.Ended = "done", a.now() + page.Notes = []session.PlanTaskNote{{Body: "the work landed on branch senior-dev/auth"}} + agent.planFake.pages["7"] = page + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, page.Row.Title, session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: a.now()})}) + before := agent.railPlanCounter.pages + drive(t, a, frameMsg{}) + if agent.railPlanCounter.pages != before+1 { + t.Fatalf("the landing was read %d times, want once", agent.railPlanCounter.pages-before) + } + if !strings.Contains(roomText(a), "the work landed on branch senior-dev/auth") { + t.Fatalf("the room did not end on the page the store ended on:\n%s", roomText(a)) + } + if !strings.Contains(roomText(a), roomFinishedRefusal.what) { + t.Fatalf("a landed program's room draws no foot:\n%s", roomText(a)) + } + if a.programRoomFollows() { + t.Fatal("a settled program's room keeps the paint clock turning") + } + settled := agent.railPlanCounter.pages + for range 3 { + planBeat(t, a) + } + if agent.railPlanCounter.pages != settled { + t.Fatalf("a settled program's room was read %d more times", agent.railPlanCounter.pages-settled) + } +} + +// THE STORE ENDS BEFORE THE ROW LANDS, AND THE ROOM KEEPS READING. The engine +// ends the store's root at the program's exit and writes the landing — where +// the work went and how to bring it in — after it, and only then settles the +// conversation's row. A read in that gap came back ended, took the room off the +// clock, and the landing's own notice found nothing left to read: the landed +// room never showed the note. The node's landing is what ends the room, and a +// read that was still out when it landed is not the last one. +func TestAProgramRoomReadsTheLandingTheStoreEndedAhead(t *testing.T) { + for _, tc := range []struct { + name string + out bool // a beat's read is still out when the landing arrives + }{ + {"the store's ending read on a beat", false}, + {"a read still out when the row lands", true}, + } { + t.Run(tc.name, func(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + page := agent.planFake.pages["7"] + page.Row.Status, page.Row.Ended = "done", a.now() + agent.planFake.pages["7"] = page + planBeat(t, a) + if a.room.done || !a.programRoomFollows() { + t.Fatalf("a store that ended ahead of the row took the room off the clock: done=%v", a.room.done) + } + var out tea.Cmd + if tc.out { + at := a.now().Add(elsewhereEvery) + a.clock = func() time.Time { return at } + out = a.programRoomFollow() + if out == nil { + t.Fatal("the beat issued no read") + } + } + const landing = "the work landed on branch senior-dev/auth" + page.Notes = []session.PlanTaskNote{{Body: landing}} + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, page.Row.Title, session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: a.now()})}) + if tc.out { + // The read that was out answers with the page before the landing. + stale := page + stale.Notes = nil + agent.planFake.pages["7"] = stale + drain(t, a, out) + } + agent.planFake.pages["7"] = page + for range 2 { + drive(t, a, frameMsg{}) + } + if !strings.Contains(roomText(a), landing) { + t.Fatalf("the landed room never shows the landing note:\n%s", roomText(a)) + } + if a.programRoomFollows() { + t.Fatal("a landed program's room keeps the paint clock turning") + } + }) + } +} + +// STOP ON A PROGRAM'S ROOM IS THE RUN'S STOP, through the store's own door. `x` +// over an empty box and /stop both raise the card aimed at the run's own task +// by the store's id, the target the stored page's `x` has always raised, and +// saying yes cancels that task in the store — never a node cancel the run is +// not a node of. +func TestStopOnAProgramsRoomRaisesThePlanCard(t *testing.T) { + for _, way := range []struct { + name string + press func(t *testing.T, a *app) + }{ + {"x over an empty box", func(t *testing.T, a *app) { drive(t, a, key(stopRaiseKey)) }}, + {"/stop", func(t *testing.T, a *app) { + for _, r := range "/stop" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + }}, + } { + t.Run(way.name, func(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + if frame, _, _ := a.frame(); !strings.Contains(plain(frame), roomStopMark) && !strings.Contains(plain(frame), roomStopMarkASCII) { + t.Fatalf("the facts row offers no Stop:\n%s", plain(frame)) + } + way.press(t, a) + if a.stop == nil { + t.Fatal("no stop card was raised") + } + if got := a.stop.target; got.plan != "t-7" || got.id != "" || got.noun != stopTaskNoun { + t.Fatalf("the card is aimed at %+v, want the run's own task by the store's id", got) + } + drain(t, a, a.stopTake(0)) + if len(agent.cancelled) != 1 || agent.cancelled[0] != "t-7" { + t.Fatalf("saying yes cancelled %v in the store, want [t-7]", agent.cancelled) + } + }) + } + // A LANDED RUN OFFERS NO STOP. + a, _ := programRoomApp(t, 120, 28) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: a.now()})}) + openProgramRoomNow(t, a) + if target := a.stopHere(); !target.empty() { + t.Fatalf("a landed program's room offers a stop: %+v", target) + } +} + +// THE ROOM AT A PHONE'S WIDTH keeps the conversation's strip and the trail, its +// facts row keeps the step and the spend, the actions stand each step's word on +// a line of its own with its actions hung under it, and no row of the frame is +// wider than the frame. +func TestAProgramsRoomAtFortyFourColumns(t *testing.T) { + a, _ := programRoomApp(t, 44, 30) + openProgramRoomNow(t, a) + frame, _, _ := a.frame() + text := plain(frame) + for _, want := range []string{pageHome.word(), "the run", "implement · $1.24", "IMPLEMENT", "edited internal/auth/"} { + if !strings.Contains(text, want) { + t.Fatalf("the room at 44 columns lost %q:\n%s", want, text) + } + } + for i, line := range strings.Split(frame, "\n") { + if cells := ansi.StringWidth(line); cells > 44 { + t.Fatalf("row %d is %d cells in a 44-cell frame: %q", i, cells, plain(line)) + } + } +} + +// ── ONE CLOCK FOR ONE RUN ─────────────────────────────────────────────────── +// +// runclock_test.go holds the rail's anchor and the landed card's span on their +// own; this is the three surfaces read side by side for a program's run. + +// AND THE ROOM, THE RAIL AND THE CARD READ ONE FIGURE. Opened while the run +// works, the room's facts row reads the rail's clock, anchored at the record's +// start, and not the store's stamps, which bracket other events (the store is +// seeded before the run's copy is made); once the run has landed the facts row, +// the stored page's pinned line and the landed card read one span. +func TestAProgramRunReadsOneFigureOnTheRoomTheRailAndTheCard(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + // The store was seeded sixteen seconds before the run's hand-off. + page := agent.planFake.pages["7"] + page.Row.Started = programRunBegan.Add(-16 * time.Second) + agent.planFake.pages["7"] = page + rail := strings.Split(plain(a.railTelemetry(a.tasks[7], 40)), railSep)[0] + openProgramRoomNow(t, a) + facts, _ := a.programFactsWord(120) + if !strings.HasSuffix(facts, rowSep+rail) || rail != "14m 3s" { + t.Fatalf("the room's facts read %q and the rail %q, want both 14m 3s", facts, rail) + } + // THE RUN LANDS, its process gone twenty-nine minutes and eight seconds after + // the hand-off. + ended := programRunBegan.Add(29*time.Minute + 8*time.Second) + now := ended.Add(3 * time.Second) + a.clock = func() time.Time { return now } + page.Row.Status, page.Row.Ended = "done", ended.Add(2*time.Second) + agent.planFake.pages["7"] = page + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, page.Row.Title, session.TaskDone, + session.TaskNotice{StartedAt: programRunBegan, EndedAt: ended, Elapsed: ended.Sub(programRunBegan)})}) + drive(t, a, frameMsg{}) + facts, _ = a.programFactsWord(120) + if !strings.HasSuffix(facts, rowSep+"29m 8s") { + t.Fatalf("the landed room's facts read %q, want the span 29m 8s", facts) + } + if pinned := a.programPinned(agent.planFake.pages["7"], 120, a.taskPlanAge(agent.planFake.pages["7"].Row)); !strings.HasSuffix(pinned, rowSep+"29m 8s") { + t.Fatalf("the stored page pins %q, want the span 29m 8s", pinned) + } + var card *taskDone + for i := len(a.entries) - 1; i >= 0 && card == nil; i-- { + card = a.doneCardAt(i) + } + if card == nil || !strings.Contains(plain(a.doneTail(card)), "29m08s") { + t.Fatalf("the landed card does not read the span 29m08s: %+v", card) + } +} + +// THE CLOCK STOPS AT THE PROGRAM'S EXIT, NOT AT THE LANDING. After senior-dev's +// process ends the engine waits for the receipts of calls it still owes (up to +// seventy seconds on a cut call) and lands the work, and only then settles the +// conversation's row; the stored page's row already carries the exit. The room +// and the stored page counted on through that wait — `21m 5s` over a run of +// twenty minutes — and jumped back when the row settled. +func TestAProgramRoomsClockStopsAtTheProgramsExit(t *testing.T) { + a, agent := programRoomApp(t, 120, 28) + openProgramRoomNow(t, a) + exit := programRunBegan.Add(20 * time.Minute) + page := agent.planFake.pages["7"] + page.Row.Ended = exit + agent.planFake.pages["7"] = page + now := exit.Add(65 * time.Second) + a.clock = func() time.Time { return now } + drain(t, a, a.programRoomRead()) + if a.tasks[7].state != session.TaskRunning { + t.Fatalf("the fixture's row has settled: %s", a.tasks[7].state) + } + facts, _ := a.programFactsWord(120) + if !strings.HasSuffix(facts, rowSep+"20m") { + t.Fatalf("the room's facts read %q sixty-five seconds after a twenty-minute run's exit, want 20m", facts) + } + if pinned := a.programPinned(page, 120, a.taskPlanAge(page.Row)); !strings.HasSuffix(pinned, rowSep+"20m") { + t.Fatalf("the stored page pins %q after the program exited, want 20m", pinned) + } + // A RUN STILL WORKING COUNTS ON, whatever the store's row says about the end + // of a run it has not been told of. + page.Row.Ended = time.Time{} + if got := a.taskPlanAge(page.Row); got != "21m 5s" { + t.Fatalf("a running program's page reads %q, want 21m 5s", got) + } +} + +// THE ROOM TURNS TO THE RAW CALLS AND BACK ON ONE KEY, and its key row says +// which: the calls while the room shows the actions, and the actions while it +// shows the calls — beside the stop while there is work to stop. +func TestAProgramRoomTurnsToItsRawCallsAndBack(t *testing.T) { + a, _ := programRoomApp(t, 120, 30) + openProgramRoomNow(t, a) + if hint := a.roomHint(); hint != roomStopHint+railSep+programCallsWord { + t.Fatalf("the room's key row reads %q, want the stop and the calls", hint) + } + if text := roomText(a); strings.Contains(text, "I'll read the middleware") || !strings.Contains(text, programTabSaid) { + t.Fatalf("the room does not open on the actions:\n%s", text) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") || !strings.Contains(text, "deepseek-v4-flash") { + t.Fatalf("the key did not turn the room to its calls:\n%s", text) + } + if hint := a.roomHint(); !strings.HasSuffix(hint, programActionsWord) { + t.Fatalf("the room's key row reads %q, want the way back to the actions", hint) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); strings.Contains(text, "I'll read the middleware") || !strings.Contains(text, programTabSaid) { + t.Fatalf("the key did not turn the room back to its actions:\n%s", text) + } +} + +// A STEP THAT CHANGED A FILE WEARS git's `+N,-M` at its right edge, the added +// lines in the diff's green and the removed in its red; a step with no count +// wears none. +func TestAStepThatChangedAFileWearsItsLinesInTheDiffsColours(t *testing.T) { + a, agent := programRoomApp(t, 120, 40) + page := agent.planFake.pages["7"] + program := *page.Program + program.Actions = append([]delegate.Shown(nil), program.Actions...) + at := program.Actions[len(program.Actions)-1].At.Add(time.Second) + program.Actions = append(program.Actions, + delegate.Shown{At: at, Step: "implement", Text: "edited internal/auth/middleware.go", Lines: true, Added: 123, Removed: 21}, + delegate.Shown{At: at.Add(time.Second), Step: "implement", Text: "read internal/auth/store.go"}) + page.Program = &program + agent.planFake.pages["7"] = page + openProgramRoomNow(t, a) + var edited, read string + for _, r := range a.roomRows(a.bodyWidth()) { + switch { + case strings.Contains(plain(r.text), "edited internal/auth/middleware.go"): + edited = r.text + case strings.Contains(plain(r.text), "read internal/auth/store.go"): + read = r.text + } + } + if !strings.HasSuffix(strings.TrimSpace(plain(edited)), "+123,-21") { + t.Fatalf("the edit's row = %q, want +123,-21 at its edge", plain(edited)) + } + if !strings.Contains(edited, a.pal.add("+123")) || !strings.Contains(edited, a.pal.del("-21")) { + t.Fatalf("the edit's lines are not in the diff's colours: %q", edited) + } + if strings.Contains(plain(read), "+") { + t.Fatalf("a read wears lines: %q", plain(read)) + } +} + +// planBeat moves the clock one follow beat on and draws a frame, which is when +// a room on work that can still move reads its page again. +func planBeat(t *testing.T, a *app) { + t.Helper() + at := a.now().Add(elsewhereEvery) + a.clock = func() time.Time { return at } + drive(t, a, frameMsg{}) +} diff --git a/internal/tui3/programtab_test.go b/internal/tui3/programtab_test.go new file mode 100644 index 0000000000..83a04654ee --- /dev/null +++ b/internal/tui3/programtab_test.go @@ -0,0 +1,143 @@ +package tui3 + +// A PROGRAM'S TASK OPENS INSIDE THE CONVERSATION'S TAB, and the tab bar keeps +// working while it is open. These tests drive the owner's report of 2026-09-24 +// through the surface's own Update loop: open senior-dev's task by every door a +// person has onto it, then leave it by `esc`, by a press on the conversation's +// tab, and by a press on Home, and read what the FRAME shows after each — how +// many tabs are drawn selected, whether Home is on the bar, and whether the +// program's page is still covering whatever the press chose (tabbar_test.go +// holds the readings and the ordinary room they are held against). + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// programTabSaid is a line only the program's page draws: one of its actions in +// [programActions]. Its presence on the frame is the program's page being on +// screen. +const programTabSaid = "wrote your brief down as its spec" + +// newProgramTabLab is a window in one conversation, "the run", that handed a +// task to senior-dev: the task is node 7 of its graph and the run's root in +// its store, and the store answers the program's page for it. Home is +// reachable, so the strip carries its Home door. +func newProgramTabLab(t *testing.T, status string) *tabLab { + t.Helper() + row := programRow() + row.ID, row.Status = "7", status + pages := map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())} + a, fake := planAppWith(t, []session.PlanTaskRow{row}, pages) + // The room doors (a lane, a journal, a steer), so an ordinary room and a + // room reopened on the program's task can both open. + a.agent = &railPlanCounter{planFake: fake} + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + state := session.TaskRunning + if status == "done" { + state = session.TaskDone + } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, state, session.TaskNotice{})}) + readPlanRows(t, a) + lab := &tabLab{a: a} + // Two frames: the strip settles on the second. + lab.shoot() + if shot := lab.shoot(); !shot.drawn || len(shot.selected) != 1 || !shot.home { + t.Fatalf("the conversation's own frame is not the baseline: drawn=%v selected=%q home=%v\n%s", + shot.drawn, shot.selected, shot.home, shot.text) + } + return lab +} + +// programTabDoors is every door onto the program's task a test can drive +// through the loop, each named for what a person does. +var programTabDoors = []struct { + name string + open func(t *testing.T, l *tabLab) +}{ + {"a press on its rail row", func(t *testing.T, l *tabLab) { clickRail(t, l.a, 0) }}, + {"enter on its rail row", func(t *testing.T, l *tabLab) { + l.a.railWhere, l.a.railHold = railSpot{id: 7}, true + drive(t, l.a, tea.KeyPressMsg{Code: tea.KeyEnter}) + }}, + // The card in the transcript, a task link, the task strip, the home panel + // and the sessions place's own row all come through this one door. + {"its card (openRoomFor)", func(t *testing.T, l *tabLab) { + l.a.openRoomFor(7, "rewrite the auth middleware") + drain(t, l.a, l.a.takeRoomPump()) + }}, + // A switch back to a held conversation, the new-chat page's way back, the → + // key and the landing's `tell` reopen a room by id. + {"a room reopened on it (openRoom)", func(t *testing.T, l *tabLab) { + l.a.openRoom(7, "rewrite the auth middleware") + drain(t, l.a, l.a.takeRoomPump()) + }}, +} + +// THE PAGE IS INSIDE THE CONVERSATION'S TAB. Whatever door opened it, the +// frame still draws the conversation's strip, with exactly one tab selected — +// the conversation's — and Home on it; and the program's conversation is what +// the body shows. +func TestAProgramsTaskOpensInsideTheConversationsTab(t *testing.T) { + for _, status := range []string{"running", "done"} { + for _, door := range programTabDoors { + t.Run(status+"/"+door.name, func(t *testing.T) { + l := newProgramTabLab(t, status) + door.open(t, l) + shot := l.shoot() + if !strings.Contains(shot.text, programTabSaid) { + t.Fatalf("the door did not put the program's conversation on screen:\n%s", shot.text) + } + if !shot.drawn { + t.Errorf("SYMPTOM: the page took the whole frame and no tab strip is drawn (room=%v)", l.a.roomOpen()) + return + } + if len(shot.selected) != 1 { + t.Errorf("SYMPTOM: %d tabs are drawn selected, %q, want only the conversation's", len(shot.selected), shot.selected) + } + if !shot.home { + t.Errorf("SYMPTOM: Home is not on the strip") + } + }) + } + } +} + +// AND THE THREE WAYS OUT ALL LEAVE IT: `esc`, a press on the conversation's +// tab, and a press on Home. After the first two the conversation is on screen +// under its own strip; after the third, Home is — and in no case is the +// program's page still drawn over what the gesture chose. +func TestEveryWayOutOfAProgramsTaskLeavesIt(t *testing.T) { + for _, status := range []string{"running", "done"} { + for _, door := range programTabDoors { + for _, way := range tabWaysOut { + t.Run(status+"/"+door.name+"/"+way.name, func(t *testing.T) { + l := newProgramTabLab(t, status) + door.open(t, l) + l.shoot() + way.leave(t, l) + checkLeft(t, l, way.name, way.home, programTabSaid) + }) + } + } + } +} + +// A PROGRAM'S RUN IS NOT A TAB. The strip names conversations; a run that the +// belt switch drives had a tab of its own beside its conversation, and once a +// program's store became readable with the switch off (028247170) every +// senior-dev run grew one too, named after its task — the "new task tab" of +// the report. A program's task opens inside its conversation's tab instead. +func TestAProgramsRunOffersNoTabOfItsOwn(t *testing.T) { + l := newProgramTabLab(t, "running") + for _, hit := range l.last { + if hit.tab.work { + t.Fatalf("SYMPTOM: the strip offers the program's run a tab of its own, %q", hit.tab.word) + } + } +} diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 5f36dc2add..f98b183c6a 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -67,6 +67,10 @@ const ( // row nothing in the conversation produced — the line is drawn between two // blocks, and it exists only while the mode is up. hitRewind + // hitAction is one action on a program's room (programroom.go): a press + // opens its whole step under its line, and a press on it again — or on the + // step it opened — shuts it. The action's key rides in [row.turn]. + hitAction // hitThread is a line of a thread card (teamthreadcard.go): the words of a // manager's message or of a member's answer, which a press lays out in // full and a second press folds again. The row's open field says whose. diff --git a/internal/tui3/room.go b/internal/tui3/room.go index 8e6a2bf89b..ce67ef70f4 100644 --- a/internal/tui3/room.go +++ b/internal/tui3/room.go @@ -208,7 +208,9 @@ func (a *app) taskModelDoors() (taskModelDoor, bool) { // Ordinary settled tasks save continuation settings through the same picker. // Adaptive runs and guest pages remain outside this task model door. func (a *app) roomModelMovable() bool { - if a.room == nil || a.room.orch != nil || a.room.plan != nil { + // A PROGRAM'S RUN IS NOT A NODE OF THE GRAPH, so the task model door has + // nothing to move: the program chooses its own models (programroom.go). + if a.room == nil || a.room.orch != nil || a.room.program != nil || a.room.plan != nil { return false } // AND A PAGE READ THROUGH SOMEBODY ELSE'S CONVERSATION MOVES NOTHING. The door @@ -384,6 +386,11 @@ type taskRoom struct { // for both to BE a room. orch *orchRun + // program is set when this page is a PROGRAM'S TASK (programroom.go): the + // same room again, for the same reason as [taskRoom.orch], whose body is the + // program's conversation with codeaf read from the task's stored page + // instead of a worker's transcript. + program *programRoom // plan is set when this page is A RUN'S TASK, read from the run's plan store // rather than from a node's journal and lane (planroom.go). It is a field on // this struct for [taskRoom.orch]'s reason: there is one page type, and @@ -688,6 +695,16 @@ func (a *app) openRoom(id uint64, title string) { if a.startingChat() { a.parkChatStart() } + // A TASK THE SURFACE ALREADY HOLDS AS A PROGRAM'S OPENS THE PROGRAM'S ROOM AT + // ONCE (programroom.go), from every door that opens a room by its id — the + // card, a task link, the task strip, the home panel, a held conversation + // brought forward, the new-chat page's way back, the → key, the landing's + // `tell`. An ordinary room on it would be a blank page for the one read + // [app.roomProgramCheck] takes to learn what the held row already says. + if a.programTask(id) { + a.openProgramRoom(id, title, a.heldProgramPage(id)) + return + } doors, ok := a.roomDoors() if !ok { // Local engine windows use this same door without a remote host label. @@ -792,11 +809,53 @@ func (a *app) openRoom(id uint64, title string) { // what is asked ([roomRowDone]). room.done = roomRowDone(a.tasks[id]) room.resolveUnfinished() - a.roomPump = tea.Batch(prefetch, a.wake()) + a.roomPump = tea.Batch(prefetch, a.wake(), a.roomProgramCheck(id)) return } room.lane, room.stop = lane, stop - a.roomPump = tea.Batch(waitRoom(lane, room.gen), prefetch, a.wake()) + a.roomPump = tea.Batch(waitRoom(lane, room.gen), prefetch, a.wake(), a.roomProgramCheck(id)) +} + +// roomProgramCheck asks the store, off the loop, whether the task a room was +// just opened on is a program's, and if it is, turns the room into the +// program's room (programroom.go). +// +// EVERY DOOR ENDS HERE, SO THE QUESTION IS ASKED HERE. [app.openRoom] asks the +// rows this conversation holds, which answers without a frame of room, but a +// door that brings a conversation forward and reopens the room it was on (the +// sessions place, a switch back to a held conversation) opens it before that +// conversation's rows have been read, and an ordinary room on a program's task +// is a blank page that says it will fill in: the program has no worker +// transcript, and its conversation with codeaf is on the stored page. Nothing +// is changed when the person has already left the room, when it is another +// conversation's, or when it is already the program's. +func (a *app) roomProgramCheck(id uint64) tea.Cmd { + // A DOOR THAT ALREADY ASKED THE STORE, and was told there is no page, is + // not asked again: the rail reads the store once, at the gesture. + if asked := a.roomPageAsked; asked != 0 { + a.roomPageAsked = 0 + if asked == id { + return nil + } + } + agent, ok := a.planReader() + if !ok || id == 0 { + return nil + } + key := strconv.FormatUint(id, 10) + return a.offLoop(func() func(bool) tea.Cmd { + page, found := agent.PlanTaskPage(key) + return func(here bool) tea.Cmd { + if !here || !found || (page.Program == nil && strings.TrimSpace(page.Row.Program) == "") { + return nil + } + if a.room == nil || a.roomIsGuest() || a.room.id != id || a.room.program != nil { + return nil + } + a.openProgramRoom(id, a.room.title, page) + return a.takeRoomPump() + } + }) } // openFarRoom opens a hosted node immediately and asks the engine for its @@ -868,7 +927,11 @@ func (a *app) readRoomRecord() tea.Cmd { } if guest := a.room.guest; guest != nil { read, id, gen := guest.room, a.room.id, a.room.gen - if read == nil || guest.lost { + // A PROGRAM'S TASK HAS NO JOURNAL TO READ. What it did is its page in the + // owner's store, which the program room reads on its own beat + // ([app.guestPageRead]); asking the owner for a journal as well would be + // four calls a second for nothing. + if read == nil || guest.lost || a.room.program != nil { return nil } return func() tea.Msg { @@ -1116,6 +1179,16 @@ func (a *app) roomStandingOn(node *taskNode) bool { // openRoomFor toggles compact task controls and transcript links within the same // conversation. Sidebar rows use openRailRoom so a repeated click stays inside. // A guest with the same task number belongs to a different conversation. +// +// A PROGRAM'S TASK OPENS THE PROGRAM'S ROOM (programroom.go). A task handed to +// a program codeaf carries (senior-dev) has no worker transcript: the program +// talks to codeaf through the run's model API, and what it said is on the +// task's stored page. The card in the conversation, a transcript link, the +// task strip, the home panel and the sessions place all come through here to +// [app.openRoom], which opens a row the surface already holds as a program's +// as the program's room at once, and turns a room on a row it does not hold +// yet into the program's when the store's answer says so +// ([app.roomProgramCheck]). func (a *app) openRoomFor(id uint64, title string) { if a.room != nil && !a.roomIsGuest() && a.room.id == id { a.closeRoom() @@ -1138,6 +1211,13 @@ func (a *app) openRailRoom(node *taskNode) tea.Cmd { return nil } id, title, run, part := node.id, node.title, node.run, node.node + // A PROGRAM'S TASK OPENS ITS ROOM AT ONCE, on the row the surface holds, and + // reads its page from there (programroom.go). No key is held for a page on + // its way, because the room is up from this press on. + if run == "" && a.programTask(id) { + a.openProgramRoom(id, title, a.heldProgramPage(id)) + return a.takeRoomPump() + } // THE STORE TASK IS THE ONE THE ROW NAMES, and a row that names none is asked // for under its own number, which is the number a run's door gives the // store task it seeds ([session.TaskNotice.PlanTask]). @@ -1155,6 +1235,7 @@ func (a *app) openRailRoom(node *taskNode) tea.Cmd { if run != "" && !hasPlan { a.openOrchRoom(run, part) } else { + a.roomPageAsked = id a.openRoom(id, title) } return a.takeRoomPump() @@ -1201,6 +1282,14 @@ func (a *app) openRailPlan(id string, missing func() tea.Cmd) tea.Cmd { } return nil } + // A PROGRAM'S TASK OPENS THE PROGRAM'S ROOM, whichever door it came + // through — the tasks place, a crumb, a row the surface did not yet + // hold as a program's: its page is the program's actions and calls, + // not a worker's steps, and it reads no note (programroom.go). + if program, ok := a.programRoomFor(page); ok { + a.openProgramRoom(program, page.Row.Title, page) + return a.takeRoomPump() + } cmd := a.openPlanRoom(page) for _, key := range keys { a.railPlanReplay(key) @@ -1390,6 +1479,15 @@ func (a *app) roomNote(text string) { return } if text = strings.TrimSpace(text); text != "" { + // A PROGRAM'S PAGE IS NOT A TRANSCRIPT (programroom.go), so a line the + // room says is kept on the page's own list and drawn under its + // conversation, where a note in the transcript would never be drawn. + if p := a.room.program; p != nil { + p.programSay(text) + a.room.dirty = true + a.touch() + return + } a.room.note(text) } } @@ -1489,10 +1587,20 @@ func (a *app) steer() tea.Cmd { if room.plan != nil { return a.planRoomSteer(line) } + // A PAGE READ THROUGH ANOTHER CONVERSATION SAYS IT IS READING, a program's + // page included: the program's refusal names this window's main as the door, + // and the words belong in the conversation that owns the work. if a.roomIsGuest() { a.roomNote(roomGuestReadingWord) return nil } + // A PROGRAM READS NO MESSAGE (programroom.go). Nothing is sent and nothing is + // taken out of the box: the page says so, names where the words can go, and + // leaves the sentence where the person can carry it there. + if room.program != nil { + a.roomNote(a.programRoomRefusal().line()) + return nil + } if room.done { a.raiseGuard(line, "") return nil @@ -1973,6 +2081,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return cmd, true } } + if cmd, taken := a.programRoomKey(msg); taken { + return cmd, true + } switch msg.String() { case "esc": // ESC IN HERE IS THE DOOR AND IT IS NEVER A STOP — stop.go's standing law, @@ -2010,7 +2121,9 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, false case "enter": - if !a.roomIsGuest() && a.room.plan == nil && strings.TrimSpace(a.input.String()) == "" { + // A PROGRAM'S RUN IS NOT A NODE THE RETRY DOOR CAN REOPEN, so enter over an + // empty box on its room offers nothing (programroom.go). + if !a.roomIsGuest() && a.room.program == nil && a.room.plan == nil && strings.TrimSpace(a.input.String()) == "" { entry := a.roomRetryEntry() if a.taskCanRetry(entry) { return a.retryTask(entry), true @@ -2098,7 +2211,7 @@ func (a *app) roomKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { // would be the surface repeating itself in the one place a person reads for the // next keystroke. func (a *app) roomHint() string { - if a.room != nil && !a.roomIsGuest() && !a.guarding() && !a.asking() && !a.stopping() { + if a.room != nil && !a.roomIsGuest() && a.room.program == nil && !a.guarding() && !a.asking() && !a.stopping() { entry := a.roomRetryEntry() if hint := a.taskRetryHint(entry); hint != "" { return hint @@ -2116,7 +2229,7 @@ func (a *app) roomHint() string { case a.recalling(): return roomRecallHint case a.stopOffered(): - if a.roomOrganized() { + if a.roomOrganized() && a.programOf() == nil { return "/model · /stop · esc main" } // THE ROOM'S ANSWER TO "HOW DO I STOP THIS". It is the honest counterpart @@ -2124,6 +2237,9 @@ func (a *app) roomHint() string { // card and never through the dismiss key (stop.go), so this is the key a // person reaching for esc actually wants. It is drawn only while there is // something to stop, which is the emptiness law applied to a hint. + if p := a.programOf(); p != nil { + return roomStopHint + railSep + programCallsHint(p.calls) + } return roomStopHint case a.roomLandingAsking(): // THE ROOM'S ANSWER TO "IT SAYS LOOK IT OVER, NOW WHAT". The node has @@ -2137,6 +2253,11 @@ func (a *app) roomHint() string { // question ([app.landingHintAt]). return a.landingHintAt(a.room.id, a.width, "") } + // A PROGRAM'S ROOM WITH NOTHING TO STOP still turns between its actions and + // its raw calls, and says the key that does it. + if p := a.programOf(); p != nil { + return programCallsHint(p.calls) + } return "" } @@ -2593,7 +2714,10 @@ func (a *app) roomHeadRows(width int) []string { return []string{a.roomTrailRow(width)} } head := []string{a.roomTrailRow(width), a.roomFactsLine(width)} - if a.roomOrganized() { + switch { + case a.programHeadsRoom(): + head = append([]string{a.roomTitleRow(width)}, a.programHeadBriefRows(width)...) + case a.roomOrganized(): head = []string{a.roomTrailRow(width), a.roomTitleRow(width)} } if rows > a.roomHeadCount() { @@ -2614,6 +2738,10 @@ const roomHeadRowCount = 2 // Compact frames already name the task in their navigation row. func (a *app) roomHeadCount() int { + if a.programHeadsRoom() { + width, _ := a.size() + return 1 + len(a.programHeadBriefRows(width)) + } if a.roomOrganized() { return roomHeadRowCount } @@ -2712,7 +2840,7 @@ func (a *app) roomFactsLine(width int) string { if mark != "" && a.hoveringRoomStop() { shown = a.pal.ink(mark) } - if node != nil && !a.orchOpen() { + if node != nil && !a.orchOpen() && a.programOf() == nil { if line, ok := a.roomGroupedFacts(node, width, shown); ok { if mark != "" { cols := ansi.StringWidth(mark) @@ -2763,6 +2891,11 @@ func (a *app) roomFactsWord(node *taskNode, width int) (string, int) { if a.orchOpen() { return a.orchHeadWord(room), 0 } + // A PROGRAM'S PAGE ANSWERS WITH THE LINE ITS STORED PAGE PINS: the stage, the + // spend of the ceiling, the calls and the age (programroom.go). + if a.programOf() != nil { + return a.programFactsWord(room) + } if node == nil { return "", 0 } @@ -3205,6 +3338,41 @@ func (a *app) roomNodeModel() string { return strings.TrimSpace(node.model) } +// roomGuestTail is what a page read through another conversation says under +// whatever it read, and nothing on every other page. Both of a guest page's +// bodies end with it — the owner's journal, and a program's actions read out +// of the owner's store (programroom.go) — because both lines are about the +// READING, not about what was read. +func (a *app) roomGuestTail(inner int) []row { + var out []row + // A READING PAGE WITH NO WAY TO ASK ITS OWNER SAYS SO, once, under whatever + // it did read. It is not a refusal and not an error — the transcript above it + // is real — it is the one thing the page cannot know, said rather than + // papered over with a state word that stopped being true (taskowner.go's + // [app.roomGuestStale]). + if a.roomGuestStale() { + out = append(out, row{text: a.pal.dim(fit(roomGuestStaleWord, inner)), entry: -1}) + } + // AND A CONVERSATION THAT HAS STOPPED AND IS WAITING ON SOMEBODY SAYS SO, + // under what it has done so far. The roster cannot say it — a node sitting on + // a question is still `running` — so a page reading somebody else's work drew + // a clock over work that had not moved since somebody was asked something + // (taskowner.go's questions lane). + // + // IT IS DIM AND NOT AMBER, AND THAT IS THE HUE LAW RATHER THAN AN OVERSIGHT. + // Amber is waiting on YOU and nothing else (docs/design/questions/DESIGN.md); + // this question is waiting on the window that owns the work, this page has no + // key that would answer it, and a row here in the colour that means "press + // something" would be asking a person for a keystroke that does not exist. + if asked, waiting := a.roomGuest().waiting(); waiting { + if head := strings.TrimSpace(asked.Head); head != "" { + line := a.icon(tokens.GNeedsHuman) + " " + head + railSep + roomGuestAskedWord + out = append(out, row{text: a.pal.dim(fit(line, inner)), entry: -1}) + } + } + return out +} + func (a *app) roomNode() *taskNode { if a.room == nil { return nil @@ -3388,13 +3556,29 @@ const ( roomDoneWord = "done" ) -// roomClock is the node's age: counting up while it runs, frozen at what the -// update that ended it reported. +// roomClock is the node's age: counting up while it runs, and once it has +// landed the span the landed card draws ([taskNode.ranFor]) — the record's +// own start and end, so the page and the card read one number for one run. func (a *app) roomClock(node *taskNode) string { + word, _ := a.nodeClock(node) + return word +} + +// nodeClock is [app.roomClock] with whether this window holds any clock for +// the node at all, which is what lets a page that has another source for the +// figure ([app.taskPlanAge]'s store stamps) fall back to it only when the rail +// has nothing to say. +func (a *app) nodeClock(node *taskNode) (string, bool) { + if node == nil { + return "", false + } if node.state == session.TaskRunning && !node.began.IsZero() { - return countUpWord(a.now().Sub(node.began)) + return countUpWord(a.now().Sub(node.began)), true + } + if span := node.ranFor(); span > 0 { + return countUpWord(span), true } - return countUpWord(node.elapsed) + return "", false } // roomSpend is what this node has cost, or "" when nobody has published a price @@ -3547,6 +3731,13 @@ func (a *app) roomRows(width int) []row { room.rows, room.width, room.height, room.dirty = out, width, height, false return out } + // AND A PROGRAM'S PAGE IS ITS CONVERSATION WITH CODEAF (programroom.go), + // branched here for the run's reason: only what fills the room differs. + if room.program != nil { + out := a.programRoomRows(width) + room.rows, room.width, room.height, room.dirty = out, width, height, false + return out + } // THE READING GUTTER IS TAKEN OUT FIRST AND GIVEN BACK LAST, exactly as in // the conversation (gutter.go, and render.go's [app.layout] states the law). // A task's page is a transcript and is read as one; it stood flush against @@ -3612,31 +3803,7 @@ func (a *app) roomRows(width int) []row { if call, ok := a.roomCallRow(inner); ok { out = append(out, call) } - // AND A READING PAGE WITH NO WAY TO ASK ITS OWNER SAYS SO, once, under - // whatever it did read. It is not a refusal and not an error — the transcript - // above it is real — it is the one thing the page cannot know, said rather - // than papered over with a state word that stopped being true (taskowner.go's - // [app.roomGuestStale]). - if a.roomGuestStale() { - out = append(out, row{text: a.pal.dim(fit(roomGuestStaleWord, inner)), entry: -1}) - } - // AND A CONVERSATION THAT HAS STOPPED AND IS WAITING ON SOMEBODY SAYS SO, - // under what it has done so far. The roster cannot say it — a node sitting on - // a question is still `running` — so a page reading somebody else's work drew - // a clock over work that had not moved since somebody was asked something - // (taskowner.go's questions lane). - // - // IT IS DIM AND NOT AMBER, AND THAT IS THE HUE LAW RATHER THAN AN OVERSIGHT. - // Amber is waiting on YOU and nothing else (docs/design/questions/DESIGN.md); - // this question is waiting on the window that owns the work, this page has no - // key that would answer it, and a row here in the colour that means "press - // something" would be asking a person for a keystroke that does not exist. - if asked, waiting := a.roomGuest().waiting(); waiting { - if head := strings.TrimSpace(asked.Head); head != "" { - line := a.icon(tokens.GNeedsHuman) + " " + head + railSep + roomGuestAskedWord - out = append(out, row{text: a.pal.dim(fit(line, inner)), entry: -1}) - } - } + out = append(out, a.roomGuestTail(inner)...) if room.done { // THE FOOT. A room on a node that has landed says so once, at the bottom, // where the next thing would have appeared — which is the place a person @@ -4092,7 +4259,12 @@ func (a *app) roomSteerLaneRows(rows []string, width int) []string { // listening" is the question it exists to answer. lane = orchSteerLane + roomSteerBack } - if a.room.plan != nil { + if a.room.program != nil { + // A PROGRAM READS NO MESSAGE, so the box does not offer to steer it: it + // says the fact and the place the words can go, the same line enter over a + // sentence says (programroom.go). + lane = a.programRoomRefusal().fit(room) + } else if a.room.plan != nil { // A RUN'S TASK TAKES THE WORDS AS A NOTE (planroom.go), and the box says // so: the worker reads a note at its next step, not the instant it is sent. lane = taskPlanNoteWord + roomSteerBack diff --git a/internal/tui3/roompanel.go b/internal/tui3/roompanel.go index 2cc3b80be1..48a030a4df 100644 --- a/internal/tui3/roompanel.go +++ b/internal/tui3/roompanel.go @@ -153,7 +153,9 @@ func (a *app) roomControlRows(width int) []railLine { if host, ok := a.agent.(interface{ TaskSetupSupported() bool }); ok && !host.TaskSetupSupported() && !a.roomIsGuest() { out = append(out, railLine{text: a.pal.dim(fit("Engine update needed", width)), entry: -1}) } - if node := a.roomNode(); node != nil && !a.roomIsGuest() { + // A PROGRAM'S RUN HAS NO THINKING LEVEL THIS SURFACE CAN MOVE, so its room + // draws no row for one (programroom.go). + if node := a.roomNode(); node != nil && !a.roomIsGuest() && a.programOf() == nil { movable := a.taskRungMovable(node) rung := a.taskRung(node.id).String() if movable || rung != "" { @@ -180,7 +182,10 @@ func (a *app) roomControlRows(width int) []railLine { } } if target := a.stopHere(); !target.empty() { - if _, ok := a.stopDoors(); ok { + // A run's own task stops through the store's door and not the stop door + // ([stopTarget.plan]), and its target is only offered when that door is + // there (programroom.go's [app.programStopTarget]). + if _, ok := a.stopDoors(); ok || target.plan != "" { out = append(out, railLine{entry: -1}, row("Stop "+target.noun+"…", "stop")) } } @@ -288,15 +293,56 @@ func (a *app) roomModelCommand(rest string) { func (a *app) roomTitleRow(width int) string { left := a.roomHereWord() right, painted := "", "" - if node := a.roomNode(); node != nil { + if node := a.roomNode(); node != nil && a.programOf() == nil { f := a.roomFactsOf(node) right = rowAll([]rowField{f.state, f.live, f.clock, f.spend, f.model, f.tokens}) state := rowAll([]rowField{f.state}) painted = a.taskStateInk(node)(state) + a.pal.muted(strings.TrimPrefix(right, state)) + } else if a.programOf() != nil { + // A PROGRAM'S ROOM PINS ITS STORED PAGE'S LINE BESIDE THE TITLE: the stage, + // the spend of the ceiling, the calls and the age (programroom.go). It is + // given at most half the row, so the title keeps its half. + line, lead := a.programFactsWord(max((width-headLabelAt-2)/2, 1)) + right = line + painted = a.pal.muted(line) + if node != nil && lead > 0 { + painted = a.taskStateInk(node)(ansi.Cut(line, 0, lead)) + a.pal.muted(ansi.Cut(line, lead, ansi.StringWidth(line))) + } } room := max(width-headLabelAt-2-ansi.StringWidth(right)-3, 1) - left = fit(left, room) - return strings.Repeat(" ", headLabelAt) + a.pal.bold(a.pal.ink(left)) + strings.Repeat(" ", max(width-headLabelAt-2-ansi.StringWidth(left)-ansi.StringWidth(right), 1)) + painted + " " + // A PROGRAM'S ROOM HANGS THE BRIEF'S DROPDOWN AFTER THE BADGE, paid for + // before the title is fitted (programroom.go's [app.programHeadBriefRows]). + chevron := "" + if p := a.programOf(); p != nil && a.programHeadsRoom() { + chevron = " " + programBriefChevron(p.briefFull) + room -= ansi.StringWidth(chevron) + } + // A PROGRAM'S TASK WEARS ITS PROGRAM'S BADGE BESIDE ITS TITLE, the one its row + // wears on the side list (programbadge.go), paid for out of the title's half + // of the row and never the facts'. An ordinary task spends nothing on it. + wears := programSpelling(programBadge(a.roomProgram()), left, max(room, 1), railTitleFloor) + left = fit(left, max(room-programCells(wears), 1)) + used := headLabelAt + ansi.StringWidth(left) + programCells(wears) + shownChevron := "" + if chevron != "" { + p := a.programOf() + p.briefSpan = hudSpan{from: used + 1, to: used + ansi.StringWidth(chevron)} + shownChevron = a.pal.dim(chevron) + used += ansi.StringWidth(chevron) + } + return strings.Repeat(" ", headLabelAt) + a.pal.bold(a.pal.ink(left)) + a.pal.programAfter(wears) + shownChevron + strings.Repeat(" ", max(width-used-2-ansi.StringWidth(right), 1)) + painted + " " +} + +// roomProgram is the program the open room's task was handed to: the name its +// stored page gives it on a program's room, and the node's own otherwise — "" +// for every ordinary task. +func (a *app) roomProgram() string { + if p := a.programOf(); p != nil { + if name := pageProgram(p.page); name != "" { + return name + } + } + return a.nodeProgram(a.roomNode()) } // The expanded layout is a height decision independent of the body's measured @@ -316,7 +362,9 @@ func (a *app) roomRecipientWord() string { } return "Conversation model" } - if a.roomIsGuest() { + // A PROGRAM'S ROOM IS READ AND NEVER WRITTEN TO (programroom.go), so the box's + // label says what a borrowed page's does rather than naming a recipient. + if a.roomIsGuest() || a.programOf() != nil { return "Reading: " + a.roomHereWord() } return "To: " + a.roomHereWord() @@ -336,6 +384,12 @@ func (a *app) roomAncestorParts(width int) (string, []crumbHit) { // Rendering and height accounting share the recipient row's one predicate. func (a *app) roomRecipientHeight() int { + // A PROGRAM'S ROOM NAMES ITS TASK ONCE, on its title row: the box's + // `Reading: <title>` label was the third spelling of it on one screen, and + // its placeholder already says the program reads no messages. + if a.programOf() != nil { + return 0 + } if a.roomOrganized() && a.breathingRows() > 0 && !a.welcomeHolds() { return 1 } diff --git a/internal/tui3/roomtabs.go b/internal/tui3/roomtabs.go index 8ae2430991..6fc39d3629 100644 --- a/internal/tui3/roomtabs.go +++ b/internal/tui3/roomtabs.go @@ -37,9 +37,11 @@ const ( var roomTabWords = []string{roomTabTranscriptWord, roomTabWorkWord} // roomHasTabs reports whether the page on screen is a task's page. A run's -// graph is a page of its own kind and draws no tabs. +// graph is a page of its own kind and draws no tabs, and so is a program's +// room (programroom.go): its page is the program's actions and calls, turned +// by its own key, and its work is on the branch its ending names. func (a *app) roomHasTabs() bool { - return a.room != nil && a.room.orch == nil + return a.room != nil && a.room.orch == nil && a.room.program == nil } // roomTabTo opens one tab and asks for what it draws. diff --git a/internal/tui3/runclock_test.go b/internal/tui3/runclock_test.go new file mode 100644 index 0000000000..0a0290b599 --- /dev/null +++ b/internal/tui3/runclock_test.go @@ -0,0 +1,172 @@ +package tui3 + +// ONE CLOCK FOR ONE RUN. A run's rows publish the record's start and, when it +// lands, its end, and every surface that draws how long the run has taken reads +// those two instants: the side list's clock counts from the start whenever this +// window met the run, and the landed card's span is the end less the start. + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// A RUN'S ROW COUNTS FROM THE RECORD'S START, NOT FROM WHEN THIS WINDOW MET IT. +// A run's rows carry no age (session's task_run_belt.go), and a window that +// attached twenty minutes into senior-dev's run started the rail's clock at +// that moment: at twenty-eight and a half minutes the rail read `8m 30s`. +func TestARunsRailClockCountsFromTheRecordsStart(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + now := started.Add(20 * time.Minute) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskRunning, + session.TaskNotice{StartedAt: started})}) + now = started.Add(28*time.Minute + 30*time.Second) + node := a.tasks[7] + if got := plain(a.railTelemetry(node, 40)); !strings.HasPrefix(got, "28m 30s") { + t.Fatalf("the rail reads %q twenty-eight and a half minutes into the run, want 28m 30s", got) + } + // A START STAMPED BY A CLOCK AHEAD OF THIS ONE IS NOT TRUSTED over the age the + // update reported, which needs no agreement between two clocks. + b, _ := planAppWith(t, nil, nil) + at := taskFixtureNow + b.clock = func() time.Time { return at } + drive(t, b, streamEventMsg{gen: b.gen, ev: update(8, "ahead", session.TaskRunning, + session.TaskNotice{StartedAt: at.Add(time.Minute), Elapsed: 5 * time.Second})}) + if got := b.tasks[8].began; !got.Equal(at.Add(-5 * time.Second)) { + t.Fatalf("a start from a clock ahead anchored the row at %s, want the reported age", got) + } +} + +// THE LANDED CARD MEASURES THE RECORD'S OWN SPAN. The same window, met twenty +// minutes late, landed a twenty-nine-minute run as `9m08s` under a card whose +// own stamps said 29 minutes; and with the age the session now reports, the +// card and the stamps agree. +func TestALandedCardMeasuresTheRecordsOwnSpan(t *testing.T) { + started := taskFixtureNow + ended := started.Add(29*time.Minute + 8*time.Second + 400*time.Millisecond) + for _, tc := range []struct { + name string + elapsed time.Duration + }{ + {"stamps alone", 0}, + {"stamps and the reported age", ended.Sub(started)}, + } { + t.Run(tc.name, func(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + now := started.Add(20 * time.Minute) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskRunning, + session.TaskNotice{StartedAt: started})}) + now = ended.Add(2 * time.Second) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskDone, + session.TaskNotice{StartedAt: started, EndedAt: ended, Elapsed: tc.elapsed})}) + card := a.doneCardAt(len(a.entries) - 1) + if card == nil { + t.Fatal("the run's landing drew no card") + } + if tail := plain(a.doneTail(card)); !strings.Contains(tail, "29m08s") { + t.Fatalf("the landed card reads %q, want the record's span 29m08s", tail) + } + if got := a.roomClock(a.tasks[7]); got != "29m 8s" { + t.Fatalf("the landed run's clock reads %q, want 29m 8s", got) + } + }) + } +} + +// A FINISHED SPAN READ OFF THE STORE IS ROUNDED LIKE EVERY OTHER. A page whose +// run this window holds no node for reads the row's own pair, and it cut a +// 61.5-second run to `1m 1s` while the card and the note the chat was handed +// said `1m 2s`. +func TestAStoredRowsFinishedSpanIsRoundedLikeTheCards(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + row := session.PlanTaskRow{ID: "t-9", Program: "senior-dev", Status: "done", + Started: started, Ended: started.Add(61*time.Second + 500*time.Millisecond)} + if got := a.taskPlanAge(row); got != "1m 2s" { + t.Fatalf("a finished 61.5-second run reads %q, want 1m 2s", got) + } +} + +// AN ORDINARY TASK SETTLED AGAIN KEEPS THE AGE ITS WORK TOOK. The engine moves +// a node's end to the moment a person accepts it (and to every later round that +// settles it again) and keeps the age it reported at the landing, so a task that +// worked five minutes and was accepted an hour later read `1h 5m` on its room +// and `1h05m` on its card when the two stamps outranked the age. The stamps are +// what is left for a row that reports no age — a run's row — and there they +// still measure the run. +func TestASettledTasksClockIsTheAgeItReported(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + now := started.Add(time.Minute) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "tidy the parser", session.TaskRunning, + session.TaskNotice{StartedAt: started, Elapsed: time.Minute})}) + now = started.Add(5 * time.Minute) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "tidy the parser", session.TaskUnverified, + session.TaskNotice{StartedAt: started, EndedAt: now, Elapsed: 5*time.Minute + 300*time.Millisecond})}) + if got := a.roomClock(a.tasks[4]); got != "5m" { + t.Fatalf("the landed task's clock reads %q, want 5m", got) + } + now = started.Add(time.Hour + 5*time.Minute) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(4, "tidy the parser", session.TaskDone, + session.TaskNotice{StartedAt: started, EndedAt: now, Elapsed: 5*time.Minute + 300*time.Millisecond})}) + if got := a.roomClock(a.tasks[4]); got != "5m" { + t.Fatalf("a task that worked 5m and was accepted an hour later reads %q, want 5m", got) + } + card := a.doneCardAt(len(a.entries) - 1) + if card == nil { + t.Fatal("the accepted task drew no card") + } + if tail := plain(a.doneTail(card)); !strings.Contains(tail, "5m00s") { + t.Fatalf("the accepted task's card reads %q, want 5m00s", tail) + } +} + +// A REPORTED AGE OUTRANKS ANOTHER MACHINE'S START. An engine on another +// machine stamps a node's start on its own clock, and one running ninety +// seconds behind this window's made a node ten seconds into its work read +// `1m 40s` on the rail — and jump back when it landed. The age an update +// reports needs no agreement between two clocks; the start is the anchor only +// for a row that reports no age, which is a run's. +func TestARunningClockTrustsTheReportedAgeOverAnotherMachinesStart(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + now := taskFixtureNow + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(5, "behind", session.TaskRunning, + session.TaskNotice{StartedAt: now.Add(-10*time.Second - 90*time.Second), Elapsed: 10 * time.Second})}) + if got := a.tasks[5].began; !got.Equal(now.Add(-10 * time.Second)) { + t.Fatalf("a start from a clock behind anchored the row at %s, want the reported age", got) + } + if got := plain(a.railTelemetry(a.tasks[5], 40)); !strings.HasPrefix(got, "10s") { + t.Fatalf("the rail reads %q ten seconds into the work, want 10s", got) + } +} + +// A ROW WHOSE ROOM IS OPEN DRAWS NO CLOCK, RATHER THAN ONE STOPPED AT THE CLICK. +// Standing in senior-dev's room froze its row's age at the second the room +// opened: the side list read `2s` for a minute and more beside a page whose +// header read `1m 21s`. The row now drops its clock while the room is open and +// reads the whole true age again the moment the person leaves. +func TestARowWhoseRoomIsOpenDrawsNoStoppedClock(t *testing.T) { + a, _ := planAppWith(t, nil, nil) + started := taskFixtureNow + now := started.Add(2 * time.Second) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, "rewrite the auth middleware", session.TaskRunning, + session.TaskNotice{StartedAt: started, CostUSD: 0.04})}) + a.freezeNode(7) + now = started.Add(81 * time.Second) + got := plain(a.railTelemetry(a.tasks[7], 40)) + if strings.Contains(got, "2s") || strings.Contains(got, "1m") || !strings.Contains(got, "$0.04") { + t.Fatalf("a row whose room is open reads %q, want its spend and no clock", got) + } + a.thawNode(7) + if got := plain(a.railTelemetry(a.tasks[7], 40)); !strings.HasPrefix(got, "1m 21s") { + t.Fatalf("the row read %q once its room closed, want the whole age 1m 21s", got) + } +} diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index e14e740df9..2a533c05e7 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -1372,6 +1372,9 @@ func (a *app) standDownRest() { a.closeRewindSheet(true) } a.closeJobPage() + // AND A ROW'S PAGE STILL ON ITS WAY, which would open under the place + // (worktab.go's [app.withdrawRailPlan]). + a.withdrawRailPlan() } func (s *sheet) searching() bool { return strings.TrimSpace(s.query.String()) != "" } diff --git a/internal/tui3/settleboundary_test.go b/internal/tui3/settleboundary_test.go index 9835bf16e3..9ec1ebd4e5 100644 --- a/internal/tui3/settleboundary_test.go +++ b/internal/tui3/settleboundary_test.go @@ -140,18 +140,19 @@ func TestAnEndOfTurnNoteIsNotWorkTheAnswerWaitedFor(t *testing.T) { } // THE SHAPE MEASURED AGAINST A REAL MODEL (#178): the turn carries a task -// proposal, which blocks the fold (workfold.go's `blocked`), so the forward walk -// is the only classifier — and the turn's own `⟲ … cached` line is then the -// entry it finds after the answer. On this branch's parent that answer was drawn -// plain and indented, headings, bold and table pipes and all. +// proposal, and the turn's own `⟲ … cached` line is the entry after the answer. +// On this branch's parent that answer was drawn plain and indented, headings, +// bold and table pipes and all. The proposal used to block the whole turn's +// fold; it now stands between two folds (workfold.go's asks), and the answer +// must still be drawn as an answer either way. func TestATurnWhoseFoldIsBlockedKeepsItsAnswerRendered(t *testing.T) { a := newTestApp(&fakeAgent{model: "m"}) a.width, a.height = 80, 40 a.entries = []entry{ {kind: entryUser, text: "propose a task, then answer in markdown", turn: 1}, {kind: entryThinking, text: "which task", turn: 1, settled: true}, - // The card is what blocks the fold, and blocking it is correct — a - // decision may never disappear into a chip (workfold.go). + // The card stands: a decision may never disappear into a chip + // (workfold.go). {kind: entryTask, text: "Summarise this chat", turn: 1}, {kind: entryTool, tool: "propose_task", text: "Summarise this chat", turn: 1, status: toolOK}, {kind: entryAssistant, text: boundaryAnswer, turn: 1, settled: true}, @@ -160,8 +161,10 @@ func TestATurnWhoseFoldIsBlockedKeepsItsAnswerRendered(t *testing.T) { a.touch() rows(a) - if len(a.deckFolds(a.conversation())) != 0 { - t.Fatal("the proposal did not block the fold, so this is no longer the measured shape") + for _, f := range a.deckFolds(a.conversation()) { + if f.start <= 2 && 2 < f.answer { + t.Fatalf("the proposal was folded away: %+v", f) + } } if a.entries[4].demoted { t.Fatal("the answer of a turn that cannot fold was demoted by its own cost line") diff --git a/internal/tui3/stop.go b/internal/tui3/stop.go index 7f0927d001..6c6129138d 100644 --- a/internal/tui3/stop.go +++ b/internal/tui3/stop.go @@ -466,6 +466,13 @@ func (a *app) stopHere() stopTarget { if a.roomIsGuest() { return stopTarget{} } + // A PROGRAM'S ROOM STOPS THE RUN THROUGH THE STORE'S OWN DOOR, the target + // its stored page's `x` has always raised (programroom.go's + // [app.programStopTarget]): the run is not a node of the graph, so the + // node's cancel has nothing to end. + if a.room.program != nil { + return a.programStopTarget() + } // A RUN'S TASK IS STOPPED THROUGH THE PLAN'S DOOR (planroom.go). if a.room.plan != nil { return a.planRoomStopTarget() diff --git a/internal/tui3/tabbar_test.go b/internal/tui3/tabbar_test.go new file mode 100644 index 0000000000..5b28da6b84 --- /dev/null +++ b/internal/tui3/tabbar_test.go @@ -0,0 +1,263 @@ +package tui3 + +// THE TAB BAR KEEPS WORKING WHATEVER IS OPEN UNDER IT. These tests drive the +// surface's own Update loop and read what the FRAME shows after each gesture: +// how many tabs are drawn selected, whether Home is on the bar, and whether a +// page is still covering whatever the gesture chose. +// +// An ordinary task's room is the contrast: it is drawn under the +// conversation's own strip, with that conversation's tab the one selected tab +// and Home beside it, and `esc`, a press on the conversation's tab and a press +// on Home all leave it. The rooms the belt switch (CODEAF_TASK_BELT) opens — the +// work tab's, and a run's task opened from the side list — used to be pages +// drawn over the conversation, and are held to the same laws here. A +// program's task is a room of its own and has its own file +// (programtab_test.go). + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// tabShot is one frame as a person reads its top: whether the conversation's +// strip was drawn on THIS frame, which of its tabs are drawn selected, whether +// Home is on it, and the whole frame's text. +type tabShot struct { + drawn bool + selected []string + home bool + hits []tabHit + text string +} + +// shootTabs draws one frame and reads the strip the frame itself laid out. The +// strip's hit map is cleared first, so a frame that drew no strip reads as +// none rather than as the last strip some earlier frame drew — and it is put +// back as it was when no strip was drawn, because the surface itself never +// clears it and a press is answered against whatever it holds. +func shootTabs(a *app) tabShot { + held := a.chatTabHits + a.chatTabHits = nil + f, _, _ := a.frame() + shot := tabShot{text: plain(f), hits: append([]tabHit(nil), a.chatTabHits...)} + shot.drawn = len(shot.hits) > 0 + if !shot.drawn { + a.chatTabHits = held + } + for _, hit := range shot.hits { + switch hit.kind { + case tabHere: + shot.selected = append(shot.selected, hit.tab.word) + case tabHome: + shot.home = true + } + } + return shot +} + +// tabLab is a window in one conversation, "the run", with the strip as a +// person last saw it drawn. +type tabLab struct { + a *app + last []tabHit +} + +func (l *tabLab) shoot() tabShot { + shot := shootTabs(l.a) + if shot.drawn { + l.last = shot.hits + } + return shot +} + +// press clicks the strip where a person sees the piece `want` picks out: on the +// strip this frame drew, or, when the frame drew none, where the last drawn +// strip had it — which is where a person who just watched it vanish aims. +func (l *tabLab) press(t *testing.T, want func(tabHit) bool) { + t.Helper() + for _, hit := range l.last { + if want(hit) { + x := hit.span.from + (hit.span.to-hit.span.from)/2 + drive(t, l.a, tea.MouseClickMsg{X: x, Y: placeTabRow, Button: tea.MouseLeft}) + drive(t, l.a, tea.MouseReleaseMsg{X: x, Y: placeTabRow, Button: tea.MouseLeft}) + return + } + } + t.Fatalf("no such piece on the strip: %+v", l.last) +} + +// conversationTab is the conversation's own tab — selected while nothing is +// drawn over it, and a door back to it while something is. Every fixture here +// names the conversation "the run". +func conversationTab(hit tabHit) bool { + return !hit.tab.work && (hit.kind == tabHere || hit.kind == tabOther) && hit.tab.word == "the run" +} +func homeTab(hit tabHit) bool { return hit.kind == tabHome } +func workTabPiece(hit tabHit) bool { return hit.tab.work && hit.kind != tabClose } + +// escOut is `esc` the way a person leaves a room: once, and once more when the +// first one only handed the keyboard back from the side list, which is what an +// ordinary task's room opened by `enter` on its row asks for too. +func escOut(t *testing.T, a *app) { + t.Helper() + held := a.railHold + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) + if held { + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEscape}) + } +} + +// tabWaysOut are the three gestures that leave any page inside a conversation. +var tabWaysOut = []struct { + name string + leave func(t *testing.T, l *tabLab) + home bool +}{ + {"esc", func(t *testing.T, l *tabLab) { escOut(t, l.a) }, false}, + {"a press on the conversation's tab", func(t *testing.T, l *tabLab) { l.press(t, conversationTab) }, false}, + {"a press on Home", func(t *testing.T, l *tabLab) { l.press(t, homeTab) }, true}, +} + +// checkLeft asserts what the frame shows after a way out: Home drawn as Home, +// or the conversation under its own strip — and the page's words gone. +func checkLeft(t *testing.T, l *tabLab, way string, home bool, said string) { + t.Helper() + shot := l.shoot() + if strings.Contains(shot.text, said) { + t.Errorf("SYMPTOM: the page is still drawn after %s (page=%v room=%v)", way, l.a.page, l.a.roomOpen()) + } + if home { + if !l.a.at(pageHome) { + t.Errorf("the press on Home did not open Home (page=%v)", l.a.page) + } + if shot.drawn && !shot.home { + t.Errorf("SYMPTOM: the conversation's strip is still drawn and Home has left it:\n%s", firstRows(shot.text, 4)) + } + if l.a.tabRow != placeTabRow { + t.Errorf("SYMPTOM: Home is not what is drawn; the frame drew no place bar:\n%s", firstRows(shot.text, 4)) + } + return + } + if l.a.pageShowing() { + t.Errorf("%s landed on place %v, want the conversation", way, l.a.page) + } + if !shot.drawn || len(shot.selected) != 1 || !shot.home { + t.Errorf("SYMPTOM: after %s the strip is drawn=%v with selected=%q and home=%v, want the conversation's own strip:\n%s", + way, shot.drawn, shot.selected, shot.home, firstRows(shot.text, 4)) + } +} + +func firstRows(text string, n int) string { + rows := strings.Split(text, "\n") + if len(rows) > n { + rows = rows[:n] + } + return strings.Join(rows, "\n") +} + +// AN ORDINARY TASK IS THE CONTRAST, and it holds: its room is drawn under the +// conversation's strip with one selected tab and Home, and `esc`, the tab press +// and Home all leave it. +func TestAnOrdinaryTasksRoomKeepsTheConversationsTab(t *testing.T) { + for _, way := range tabWaysOut { + t.Run(way.name, func(t *testing.T) { + a, _ := railTaskPageApp(t, false) + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + l := &tabLab{a: a} + l.shoot() + clickRail(t, a, 0) + if !a.roomOpen() { + t.Fatal("the ordinary task did not open its room") + } + if shot := l.shoot(); !shot.drawn || len(shot.selected) != 1 || !shot.home { + t.Fatalf("an ordinary room's strip: drawn=%v selected=%q home=%v", shot.drawn, shot.selected, shot.home) + } + way.leave(t, l) + if !way.home && a.roomOpen() { + t.Fatalf("%s did not leave the room", way.name) + } + checkLeft(t, l, way.name, way.home, "esc/← main") + }) + } +} + +// ── THE ROOMS THE BELT SWITCH OPENS ───────────────────────────────────────── + +// beltTabLab is [workTabFixture] under the strip a person sees. +func beltTabLab(t *testing.T) *tabLab { + t.Helper() + a, _ := workTabFixture(t) + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + l := &tabLab{a: a} + l.shoot() + l.shoot() + return l +} + +// THE WORK TAB IS THE ONE SELECTED TAB WHILE IT IS UP, and the strip leaves it: +// the conversation's own tab goes back to the conversation and Home goes Home. +// It drew both tabs selected, a press on the conversation's tab did nothing, +// and Home opened under the work tab, which then drew the strip without Home. +func TestTheWorkTabIsTheOneSelectedTabAndTheStripLeavesIt(t *testing.T) { + l := beltTabLab(t) + l.press(t, workTabPiece) + if l.a.roomPlan() == nil { + t.Fatal("the work tab did not open") + } + if shot := l.shoot(); len(shot.selected) != 1 || !shot.home { + t.Errorf("SYMPTOM: with the work tab up the strip draws selected=%q home=%v, want one selected tab and Home", shot.selected, shot.home) + } + for _, way := range tabWaysOut { + t.Run(way.name, func(t *testing.T) { + l := beltTabLab(t) + l.press(t, workTabPiece) + l.shoot() + way.leave(t, l) + checkLeft(t, l, way.name, way.home, taskPlanNoteWord) + }) + } +} + +// LEAVING IS NEVER MODAL (input.go's first rung), and the work tab was read +// above that law: ctrl+c on it was handed to the page, which took nothing. +func TestCtrlCIsNeverTakenByTheWorkTab(t *testing.T) { + l := beltTabLab(t) + l.press(t, workTabPiece) + if l.a.roomPlan() == nil { + t.Fatal("the work tab did not open") + } + control := beltTabLab(t) + want := control.a.key(key("ctrl+c")) != nil + if got := l.a.key(key("ctrl+c")) != nil; got != want { + t.Errorf("SYMPTOM: ctrl+c on the work tab answered a command=%v, on the conversation %v", got, want) + } +} + +// A PRESS NEVER REACHES WHAT A TASK'S PAGE COVERS. A run's part opened from the +// rail took the whole frame and drew no strip, but the pointer was still +// answered against the strip the conversation last drew: a press where that +// strip's ✕ had been closed the conversation's tab and moved the window to +// Home, under a page that went on covering both. +func TestAPressNeverReachesTheStripATaskPageCovers(t *testing.T) { + a, _ := railTaskPageApp(t, true) + a.resume = func(string) (Agent, error) { return nil, nil } + a.width, a.height = 160, 40 + l := &tabLab{a: a} + l.shoot() + clickRail(t, a, 0) + if a.roomPlan() == nil { + t.Fatal("the run's row did not open its room") + } + if shot := l.shoot(); shot.drawn { + t.Skip("the page draws the strip, so a press on it is a press on something drawn") + } + l.press(t, func(hit tabHit) bool { return hit.kind == tabClose && !hit.tab.work }) + if len(a.tabShut) != 0 || a.at(pageHome) || a.closingTab() { + t.Errorf("SYMPTOM: a press on a strip nobody can see closed tabs %v, moved to place %v, raised the close card %v", + a.tabShut, a.page, a.closingTab()) + } +} diff --git a/internal/tui3/task.go b/internal/tui3/task.go index a355bdc44b..fe3ec5be85 100644 --- a/internal/tui3/task.go +++ b/internal/tui3/task.go @@ -78,6 +78,13 @@ type taskCard struct { // OWED, and until it lands the way to ask for another is to say so in the // words `c change` takes. model string + // program is the program this work is going to — senior-dev — and "" for a + // task this conversation's own worker will do (session's + // TaskNotice.Program). The head wears its badge beside the name + // ([app.taskHead]), because who the work is being handed to is the one thing + // about it a person approving it cannot find out afterwards and do anything + // about. + program string // elsewhere is the one dim line saying which of this brief's files another // window's work is already in, as the engine wrote it (session's // TaskNotice.Elsewhere), and "" when there was nothing to say. @@ -230,9 +237,10 @@ type taskNode struct { // (taskending.go). ending session.TaskEnding // started and ended are the record's own instants, carried by the engine on - // every node update when it has them. began is the older live fallback, - // derived once from the update's own Elapsed so the clock is the frame's and - // not the event's. met is when this surface first heard of the node at all, + // every node update when it has them. began is what the live clock counts + // from, anchored once ([app.noticeBegan]) — from the update's own Elapsed, + // or from the record's start for a row that reports no age — so the clock + // is the frame's and not the event's. met is when this surface first heard of the node at all, // which is the honest spawn time for a node that never reached running IN THIS // WINDOW — see [taskNode.restored] for the case where it is not. started, ended time.Time @@ -329,6 +337,15 @@ type taskNode struct { // that is true before it starts and after it lands, and it is what keeps a // card from promising a branch to a node that could never have one. kind session.TaskKind + // program is the program this node's work was handed to — senior-dev — as + // the engine names it (session's TaskNotice.Program), and "" for an ordinary + // task. It is what the node's badge is drawn from (programbadge.go), and it + // is kept on [taskNode.kind]'s rule: written once, from the proposal's card or + // the first notice that names it, and never cleared, because which program has + // the work is settled before the work starts and nothing afterwards moves it. + // Read it through [app.nodeProgram], which also answers for a node an older + // engine never named. + program string // doing is the phase this node is in, in its own kind's plain words — // "designing", "awaiting your look" — and empty for an ordinary task, which // has no phases (session's TaskNotice.Doing). @@ -516,6 +533,60 @@ func (n *taskNode) spawnedAt() time.Time { return n.met } +// ranFor is how long this node's work took once it has landed: the age the +// landing reported when it reported one, the record's own two stamps when it +// did not, and zero — which every surface draws as nothing — when neither is +// known. +// +// THE REPORTED AGE OUTRANKS THE STAMPS, AND BOTH OUTRANK THIS WINDOW'S CLOCK. +// The engine moves a settled node's end to the moment it is settled again — a +// person's accept, a second look, a merge round — and keeps the age its work +// took, so the stamps of a task that worked five minutes and was accepted an +// hour later span an hour and five. A run's row reports the span of its own +// stamps as its age (session's publishRunRow), so it reads the same figure +// either way; the stamps are for a row that reports no age at all, which a +// run's rows once did, when a window met twenty minutes into a senior-dev run +// read a twenty-nine-minute run as nine off its own moments. +// +// IT IS WHOLE SECONDS, ROUNDED, because the three surfaces that draw a landed +// node's span spell it through two formatters — the landed card's +// [taskSpanWord], which rounds, and the room's and the page's [countUpWord], +// which cuts — and a span handed to both unrounded read `22m52s` on the card +// and `22m 51s` on the page for one run. +func (n *taskNode) ranFor() time.Duration { + if n.elapsed > 0 { + return n.elapsed.Round(time.Second) + } + if !n.started.IsZero() && n.ended.After(n.started) { + return n.ended.Sub(n.started).Round(time.Second) + } + return 0 +} + +// noticeBegan is the instant a node's running clock counts from, anchored +// from the first running update this window receives about it. +// +// THE REPORTED AGE IS THE ANCHOR WHEN THE UPDATE CARRIES ONE, because it needs +// no agreement between two clocks: an engine on another machine stamps a +// node's start on its own clock, and one running ninety seconds behind this +// window's made a node ten seconds into its work read `1m 40s`. +// +// THE RECORD'S START IS THE ANCHOR WHEN THE UPDATE REPORTS NO AGE. A run's rows +// report none while the run works (session's task_run_belt.go publishes +// StartedAt and a zero Elapsed), and a run's row is replayed to a window that +// attaches mid-run as it was first published — so an anchor taken from the age +// alone started the rail's clock at the moment this window opened, and a +// window reopened while senior-dev worked read the run as however long the +// window had been open. A start stamped later than this window's own clock is +// another machine's clock running ahead, and the clock counts from now. +func (a *app) noticeBegan(notice session.TaskNotice) time.Time { + now := a.now() + if notice.Elapsed <= 0 && !notice.StartedAt.IsZero() && !notice.StartedAt.After(now) { + return notice.StartedAt + } + return now.Add(-notice.Elapsed) +} + // spent is what this node has cost, in dollars, from whichever of its two lanes // knows the most — the engine's published figure, or the pilot's running sum. // Zero means nobody has priced it, which is not "it was free", and a surface @@ -1225,6 +1296,7 @@ func (a *app) proposeTask(ev session.Event) { ident: identFor(notice.ID), dependsOn: notice.DependsOn, model: strings.TrimSpace(notice.Model), + program: strings.TrimSpace(notice.Program), elsewhere: strings.TrimSpace(notice.Elsewhere), deadline: notice.Deadline, born: a.now(), @@ -1341,7 +1413,7 @@ func (a *app) taskQuestion(notice *session.TaskNotice) session.Question { Ask: session.AskPermission, Form: session.FormCard, Asker: session.Asker{Kind: session.AskerModel}, - Head: session.TaskProposalLead + strings.TrimSpace(notice.Title), + Head: session.TaskProposalHead(*notice), Reason: strings.TrimSpace(notice.Summary), Subject: session.SubjectRef{Kind: session.SubjectNode, ID: notice.ID, Name: strings.TrimSpace(notice.Title)}, Options: session.AnswerOptions(session.QuestionTask), @@ -1883,10 +1955,17 @@ func (a *app) taskCardRows(card *taskCard, width int, sel bool) []string { if card.where != "" { out = append(out, stem+a.pal.dim(fit("where: "+card.where, room))) } - if point := a.taskBranchPoint(); point != "" { + if point := a.taskBranchPoint(); point != "" && card.program == "" { // The branch point is the last of the facts about the work, and it is the // one thing on the card a person cannot find out afterwards without // reading a merge. + // + // A PROGRAM'S CARD HAS NONE. A program works in the folder itself, on a + // branch of its own cut from the commit the checkout is on, and a checkout + // with work not committed is refused before any card goes up + // (internal/session's programfolder.go): `unsaved edits included` was the + // copy's sentence, and on a program's card it was false twice over. Its + // `where:` line above already says the folder and its branch. out = append(out, stem+a.pal.dim(fit(point, room))) } if meta := a.taskMetaWord(card, room); meta != "" { @@ -1968,14 +2047,21 @@ func (a *app) taskHead(card *taskCard, width int, sel bool) string { // rail in four seconds and on the card that lands in eleven minutes. head := corner + " " + a.icon(tokens.GNeedsHuman) + " " mark := a.taskMarkSel(card.ident, sel) + " " - title := fit(card.name, width-ansi.StringWidth(head)-3) + // AND WORK GOING TO A PROGRAM WEARS THAT PROGRAM'S BADGE BESIDE ITS NAME, the + // same badge its row will wear on the side list in four seconds + // (programbadge.go), so the card a person approves says who the work is being + // handed to. It is paid for out of the title's cells, never the frame's. + room := width - ansi.StringWidth(head) - 3 + badge := programSpelling(programBadge(card.program), card.name, room, railTitleFloor) + title := fit(card.name, room-programCells(badge)) line := paint(head) + mark if card.settled() { line += a.pal.muted(title) } else { line += a.pal.askBold(title) } - if fill := width - ansi.StringWidth(head) - ansi.StringWidth(title) - 3; fill > 0 { + line += a.pal.programAfter(badge) + if fill := width - ansi.StringWidth(head) - ansi.StringWidth(title) - programCells(badge) - 3; fill > 0 { line += paint(" " + strings.Repeat(rule, fill)) } return line @@ -4680,6 +4766,7 @@ func railPack(segs []string, width, rooms int) []string { // │ └─ ◌ Cut the goldens #4 // └─ ◌ Wire the seam #5 // ⠙ Port the parser ▸ +7 the same family, folded +// ⠙ rewrite the… [sd] #8 work handed to a program, wearing its badge // // EVERY ROW OPENS WITH ONE GLYPH AND IT IS THE STATE. A flat row used to lead // with two — the state and the node's own ◆ — and the second bought nothing @@ -4732,6 +4819,15 @@ func (a *app) railEntryRows(e railEntry, width int) ([]string, hudSpan, hudSpan) } glyph, lead, folds := a.railLead(e) room := width - at - ansi.StringWidth(lead) + // A PROGRAM'S WORK WEARS ITS BADGE FIRST IN THE TRAILING SLOT, straight after + // the title (programbadge.go). It is spoken for before the handle is, because + // it is the one fact on this line that tells two rows apart by what they ARE + // while the handle only tells them apart by number — so as the column narrows + // the handle goes first, then the badge falls to its short spelling, and only + // then is the title cut, down to [railTitleFloor]. An ordinary task has no + // badge, spends nothing here, and draws exactly the row it always drew. + wears := programSpelling(programBadge(a.nodeProgram(node)), node.title, room, railTitleFloor) + room -= programCells(wears) // The trailing slot: a folded root says how much it is standing for, every // other row says its handle, and both stand down when the title cannot afford // them. @@ -4753,7 +4849,10 @@ func (a *app) railEntryRows(e railEntry, width int) ([]string, hudSpan, hudSpan) if at > 0 && whole > room && whole <= room+railWideGain { a.railCramped = true } - line := prefix + lead + a.railTitle(node, title) + // A PROGRAM'S BADGE IS DRAWN AND NEVER RECORDED AS A TARGET. The badge span + // returned below is the folded count's, which a press reads as "expand"; a + // program's badge is part of the row, and the row is the node's door. + line := prefix + lead + a.railTitle(node, title) + a.pal.programAfter(wears) badge := hudSpan{} if meta != "" { if pad := room - ansi.StringWidth(title) + 1; pad > 0 { @@ -5200,7 +5299,14 @@ func (a *app) railUnder(node *taskNode, width int) []string { // specific true thing there is about it, and the rows below would each // say something less: a call it is inside of, a hold that is not holding // it, or a clock. It takes the row for [app.railDoing]'s reason. + // + // AND A PROGRAM'S RUN SAYS THE STAGE ITS PROGRAM IS IN, next after a + // named phase and for the same reason: it is what the node is doing, in + // the only vocabulary the program has ([app.railStage]). rows := a.railDoing(node, width) + if len(rows) == 0 { + rows = a.railStage(node, width) + } if len(rows) == 0 { rows = a.railPhase(node, width) } @@ -5442,7 +5548,7 @@ func planUnderRows(item tasksItem, width int, pal palette) []string { return nil } rows := make([]string, 0, railUnderRows) - if line := planLiveRow(item.plan.Live.Command, item.plan.LiveParts, width, pal); line != "" { + if line := planLiveLine(*item.plan, width, pal); line != "" { rows = append(rows, line) } if figures := planFigures(item.plan); figures != "" { @@ -5454,6 +5560,28 @@ func planUnderRows(item tasksItem, width int, pal palette) []string { return rows } +// planLiveLine is a plan row's live line, whichever kind of worker it has. A +// PROGRAM'S LIVE STEP IS ITS STAGE AND NOT A COMMAND: the worker publishes the +// program's phase on the same live row a bash worker publishes its command on +// (internal/run's delegateSink.Stage), and drawn behind the shell's `$` it read +// as a command somebody typed — `$ senior-dev: implement · running`. So a +// program's row draws the running mark and the stage its row carries +// ([session.PlanTaskRow.Stage]), and every other row draws its command; a +// program's row with no stage to name draws what it always drew, so the line +// the layout counted is always a line with something on it. +func planLiveLine(row session.PlanTaskRow, width int, pal palette) string { + if strings.TrimSpace(row.Program) != "" { + if stage := strings.TrimSpace(row.Stage); stage != "" { + lead := pal.glyph(tokens.GStepRunning) + " " + if room := width - ansi.StringWidth(lead); room > 0 { + return lead + pal.dim(fit(stage, room)) + } + return "" + } + } + return planLiveRow(row.Live.Command, row.LiveParts, width, pal) +} + // planLiveRow is the live step's own line: the running step's glyph, the shell // lead, and the command the step is running. The glyph and the lead are drawn // OUTSIDE the fitting — they are two whole cells and a command never gets to @@ -5495,6 +5623,65 @@ func (a *app) railDoing(node *taskNode, width int) []string { return []string{a.pal.dim(fit(node.doing, width))} } +// railProgramRow is the run's own plan row for a node whose run was handed to +// a program, read out of the rows the surface already holds +// ([app.heldPlanRows]) — never out of the store, because this is asked on every +// frame the column is drawn. A run's row and its store's root are one piece of +// work under one number (the store is rooted at the task's own id), so the row +// is found by that number, and only a row that names a program answers. +func (a *app) railProgramRow(node *taskNode) (session.PlanTaskRow, bool) { + if node == nil || node.id == 0 { + return session.PlanTaskRow{}, false + } + rows, ok := a.heldPlanRows() + if !ok { + return session.PlanTaskRow{}, false + } + id := itoa(int(node.id)) + for _, row := range rows { + if strings.TrimPrefix(strings.TrimSpace(row.ID), "t-") == id && strings.TrimSpace(row.Program) != "" { + return row, true + } + } + return session.PlanTaskRow{}, false +} + +// railStage is the row a program's run wears while it runs: the stage its +// program says it is in, alone, the way a named phase is drawn ([app.railDoing]). +// +// implement senior-dev writing the change +// verification and checking it +// +// A program's run used to wear only its clock here, because nothing the run +// publishes on its row says what the program is doing: the stage lives on the +// store's live step, which the side list reads on its own beat. It is nil +// between stages and for every other node. +func (a *app) railStage(node *taskNode, width int) []string { + row, ok := a.railProgramRow(node) + if !ok { + return nil + } + stage := fit(strings.TrimSpace(row.Stage), width) + if stage == "" { + return nil + } + return []string{a.pal.dim(stage)} +} + +// railSpent is what a node has cost so far, for the telemetry under it. It is +// [taskNode.spent] for every node, and for a program's run the larger of that +// and what the run's own spend rows carry: the run publishes no price on its row +// until it lands, while the model API banks a row per call as it goes. THE TWO +// ARE THE SAME MONEY AND ARE NEVER ADDED — the larger is the more recent reading +// of one bill, the rule [taskNode.spent] already keeps for its own two lanes. +func (a *app) railSpent(node *taskNode) float64 { + spent := node.spent() + if row, ok := a.railProgramRow(node); ok && row.USD > spent { + spent = row.USD + } + return spent +} + // railMending is the row a node wears while it is closing a named gap in work it // has otherwise finished, or nil when there is no gap being closed. // @@ -5578,8 +5765,11 @@ func (a *app) railTelemetry(node *taskNode, width int) string { segs := make([]string, 0, 5) // A NODE NOBODY DATED HAS NO CLOCK. A store task whose store never said when // it started is lent a node with no start ([planRailNode]), and the age of - // the zero instant is a number of hours that is nobody's. - if !node.began.IsZero() { + // the zero instant is a number of hours that is nobody's. And a node whose + // room is open draws none either ([app.taskNow]): the room's own header + // carries the live figure, and a number stopped at the moment of the click + // read `2s` beside a senior-dev page reading `1m 21s`. + if !node.began.IsZero() && node.froze.IsZero() { if clock := countUpWord(a.taskNow(node).Sub(node.began)); clock != "" { segs = append(segs, clock) } @@ -5591,7 +5781,7 @@ func (a *app) railTelemetry(node *taskNode, width int) string { // difference between keeping the price and dropping it. segs = append(segs, tokenWord(node.tokens)) } - if spent := node.spent(); spent > 0 { + if spent := a.railSpent(node); spent > 0 { segs = append(segs, dollars(spent)) } if model := railModelWord(node); model != "" { @@ -5619,7 +5809,7 @@ func (a *app) railTelemetry(node *taskNode, width int) string { // that says how that is going. Both are empty under [taskToolFloor]: a call // that has just started is a call nobody is waiting on yet. func (a *app) taskClock(node *taskNode) (string, func(string) string) { - if node.toolBegan.IsZero() { + if node.toolBegan.IsZero() || !node.froze.IsZero() { return "", nil } age := a.taskNow(node).Sub(node.toolBegan) @@ -5644,6 +5834,13 @@ func (a *app) taskClock(node *taskNode) (string, func(string) string) { // is the opposite of what a person reading needs. It thaws when they leave, at // the value it would have had all along, because nothing here stops the clock // so much as stops reporting it. +// +// AND A ROW DRAWN AGAINST A FROZEN CLOCK DRAWS NO CLOCK AT ALL. Stopping the +// report is not the same as reporting the stopped value: an age that stays at +// the second of the click is a wrong measurement sitting beside the room's +// right one, and a run's time is a figure the person reads to the second +// ([app.railTelemetry], [app.taskClock] and [app.railPhase] each leave theirs +// out while this is set). func (a *app) taskNow(node *taskNode) time.Time { if !node.froze.IsZero() { return node.froze @@ -5845,6 +6042,12 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { // [session.TaskNotice.Decider] — so a guard that only ever looked at the // state would throw away the event that puts the chips back on the card // somebody is waiting in front of (taskdone.go's [app.handedBackCard]). + // + // AND A PROGRAM THE NODE HAS NOT BEEN TOLD OF IS NEWS, on the brief's + // terms: an empty one says nothing, and one the node already carries is the + // second copy. A row first drawn from an older record that named none and + // then published again, in the same state, by the run that knows its + // program must not lose the badge to this guard. node := a.tasks[notice.ID] if node == nil || (notice.CostUSD <= node.cost && taskLiveLines(notice) == node.liveLines() && !taskRenames(notice, node) && @@ -5852,7 +6055,8 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { !taskPauses(notice, node) && !taskReasks(notice, node) && notice.Decider == node.decider && notice.NextModel == node.nextModel && notice.Thinking == node.thinking && (notice.Brief == "" || notice.Brief == node.brief) && - (notice.Acceptance == "" || notice.Acceptance == node.acceptance)) { + (notice.Acceptance == "" || notice.Acceptance == node.acceptance) && + (notice.Program == "" || notice.Program == node.program)) { return nil } } @@ -5891,6 +6095,9 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { node.label = card.title node.assignment = firstNonEmpty(card.summary, card.brief) node.brief, node.acceptance, node.where = card.brief, card.acceptance, card.where + // And which program it went to, so the row wears the badge the card + // wore even when the notice that made it said nothing about it. + node.program = card.program } a.tasks[notice.ID] = node a.taskOrder = append(a.taskOrder, notice.ID) @@ -6060,6 +6267,13 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { if notice.Kind != "" { node.kind = notice.Kind } + // AND SO IS THE PROGRAM THE WORK WAS HANDED TO, on the kind's own rule: it is + // on every row a program's run publishes, so a row drawn for the first time + // after a conversation switch wears its badge from that first frame, and an + // update quiet about it has not taken the work off the program. + if program := strings.TrimSpace(notice.Program); program != "" { + node.program = program + } // AND SO IS THE WORKING CONTEXT, on the same rule and for the same reason: a // node that named one is in it for the rest of its life, and an update quiet // about it has not taken the person out of it. A better name replaces the one @@ -6076,10 +6290,11 @@ func (a *app) taskUpdate(ev session.Event) tea.Cmd { if !notice.EndedAt.IsZero() { node.ended = notice.EndedAt } - // The clock is anchored ONCE, from the age the update reported, so the row + // The clock is anchored ONCE, from the age the update reported or, for a row + // that reports none, the record's start ([app.noticeBegan]), so the row // counts on the frame tick instead of standing still between events. if notice.State == session.TaskRunning && node.began.IsZero() { - node.began = a.now().Add(-notice.Elapsed) + node.began = a.noticeBegan(*notice) } // A node that started is a proposal that was approved, whatever answered it: // the card stops asking here for the case where the engine's clock, and not diff --git a/internal/tui3/taskbranch_test.go b/internal/tui3/taskbranch_test.go index bfff05b9c8..39804321aa 100644 --- a/internal/tui3/taskbranch_test.go +++ b/internal/tui3/taskbranch_test.go @@ -55,3 +55,19 @@ func TestASettledProposalDropsTheBranchPoint(t *testing.T) { t.Fatalf("a settled card is still naming its branch point:\n%s", text) } } + +// A PROGRAM'S CARD NAMES NO BRANCH POINT. senior-dev works in the folder itself, +// on a branch of its own cut from the checkout's commit, and a checkout with +// work not committed is refused before its card goes up: the copy's sentence, +// `unsaved edits included`, was on its card and was false. +func TestAProgramsProposalNamesNoBranchPoint(t *testing.T) { + a, _, _ := taskApp(t) + a.branch = "work" + ev := proposal(a, 7, 4*time.Second) + ev.Task.Program = "senior-dev" + drive(t, a, streamEventMsg{gen: a.gen, ev: ev}) + + if text := taskText(a); strings.Contains(text, taskBranchPointWord) { + t.Fatalf("a program's proposal names the copy's branch point:\n%s", text) + } +} diff --git a/internal/tui3/taskcardhost_test.go b/internal/tui3/taskcardhost_test.go index 2b3b81bed1..6cc27de83c 100644 --- a/internal/tui3/taskcardhost_test.go +++ b/internal/tui3/taskcardhost_test.go @@ -412,3 +412,60 @@ func TestAHostedTaskCardSaysItsPathsAreTheFarMachines(t *testing.T) { t.Fatalf("the card lost its branch row:\n%s", text) } } + +// A STALE FAR ROW NEVER UNDOES A LANDING THE STREAM DELIVERED. The far world a +// hosted window reads is the one it last fetched, and a run's row reaches its +// index as `running` at the hand-off: the roster read the landing's own notice +// asks for adopted that row, put the landed run back to running with no age, +// and its clock climbed with no end — `39m` ten minutes after a twenty-nine +// minute run, a spinner and `1 running` on the side list. +func TestAStaleFarRowNeverUndoesTheStreamsLanding(t *testing.T) { + a := hostedPlaceLab(t) + born := time.Now().Add(-30 * time.Minute) + ended := born.Add(29*time.Minute + 8*time.Second) + now := ended.Add(2 * time.Second) + a.clock = func() time.Time { return now } + drive(t, a, streamEventMsg{gen: a.gen, ev: update(9, "widening the pipe", session.TaskRunning, + session.TaskNotice{StartedAt: born})}) + drive(t, a, streamEventMsg{gen: a.gen, ev: update(9, "widening the pipe", session.TaskDone, + session.TaskNotice{StartedAt: born, EndedAt: ended, Elapsed: ended.Sub(born), CostUSD: 1.61})}) + held := farCardEntry(now) + held.Status, held.EndedAt, held.StartedAt, held.DurationMS, held.Cost = string(session.TaskRunning), time.Time{}, born, 0, 1.24 + a.adoptFarTaskRows([]session.TaskIndexEntry{held}) + node := a.tasks[9] + if node.state != session.TaskDone { + t.Fatalf("the held far row put the landed run back to %s", node.state) + } + now = now.Add(10 * time.Minute) + if got := a.roomClock(node); got != "29m 8s" { + t.Fatalf("ten minutes after the landing the run reads %q, want 29m 8s", got) + } + if node.cost != 1.61 { + t.Fatalf("the held far row put the landed run's spend back to %.2f", node.cost) + } +} + +// A LIVE FAR ROW THE STREAM HAS NOT NAMED COUNTS FROM THE ROW'S OWN START. A +// node adopted from a live row had no anchor at all, and the side list counted +// from the zero instant: `2562047h 47m`. +func TestALiveFarRowCountsFromItsOwnStart(t *testing.T) { + a := hostedPlaceLab(t) + now := time.Now() + a.clock = func() time.Time { return now } + live := farCardEntry(now) + live.Status, live.EndedAt, live.TranscriptURI = string(session.TaskRunning), time.Time{}, "" + live.StartedAt, live.DurationMS = now.Add(-3*time.Minute), int64(time.Minute/time.Millisecond) + a.adoptFarTaskRows([]session.TaskIndexEntry{live}) + if got := strings.Split(plain(a.railTelemetry(a.tasks[9], 40)), railSep)[0]; got != "3m" { + t.Fatalf("a live far row three minutes in reads %q on the side list, want 3m", got) + } + // AND ONE THAT NAMES NO START DRAWS NO AGE rather than one counted from the + // zero instant. + b := hostedPlaceLab(t) + b.clock = func() time.Time { return now } + live.StartedAt = time.Time{} + b.adoptFarTaskRows([]session.TaskIndexEntry{live}) + if got := plain(b.railTelemetry(b.tasks[9], 40)); strings.Contains(got, "h") { + t.Fatalf("a live far row with no start reads %q on the side list", got) + } +} diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 4e29b7e005..3c0ec962ab 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -129,7 +129,22 @@ func (a *app) runTaskCommand(arg string) tea.Cmd { // naming a pause that no longer exists would be the surface describing // machinery rather than work. func (a *app) startTaskDoor(door taskCommandAgent, brief string, solo bool) tea.Cmd { - ctx := a.ctx + return a.startTaskDoorVia(brief, func(ctx context.Context) (uint64, string, string, error) { + return door.StartTask(ctx, brief, solo) + }) +} + +// startTaskDoorVia is [app.startTaskDoor] with the door itself handed in: the +// notes said before the spend and the start message are the same whichever +// door opens — the conversation's own worker or a delegate (delegate.go) — and +// two copies of the preflight would be two places for one line to drift. +// taskDoorNotes says the two lines every task door says before the spend — who +// else is in these files, and what unsaved edits are about to travel — and +// answers which conversation is speaking, read HERE rather than when the answer +// lands ([app.adoptTypedBrief] is where that matters). It is one function +// because the conversation's own door and a delegate's (delegate.go) say the +// same two lines, and two copies would be two places for one line to drift. +func (a *app) taskDoorNotes(brief string) string { // WHICH CONVERSATION IS SAYING THIS, read HERE rather than when the answer // lands: the door is opened on a goroutine and the window may have moved on // by the time it answers ([app.adoptTypedBrief] is where that matters). @@ -164,8 +179,14 @@ func (a *app) startTaskDoor(door taskCommandAgent, brief string, solo bool) tea. if line := session.UnsavedEditsNote(a.workspace); line != "" { a.note(line) } + return conv +} + +func (a *app) startTaskDoorVia(brief string, start func(context.Context) (uint64, string, string, error)) tea.Cmd { + ctx := a.ctx + conv := a.taskDoorNotes(brief) return func() tea.Msg { - id, title, note, err := door.StartTask(ctx, brief, solo) + id, title, note, err := start(ctx) return taskStartedMsg{ kind: "single", id: strconv.FormatUint(id, 10), title: title, err: err, note: note, brief: brief, conv: conv, diff --git a/internal/tui3/taskconversation.go b/internal/tui3/taskconversation.go new file mode 100644 index 0000000000..03b68793bb --- /dev/null +++ b/internal/tui3/taskconversation.go @@ -0,0 +1,942 @@ +package tui3 + +// taskconversation.go draws a PROGRAM'S task page as the actions it took. +// +// A task a run handed to a program codeaf carries (senior-dev first) is a +// process with steps of its own, and the page shows the program doing it: each +// thing it did, under the step of its process that action served, with how it +// came out at the right edge — not a dialogue between the program and a model, +// which made a pipeline of spec, exploration, a pinned check, a checklist, an +// implementation, a hand-in and its own check of the tree read like one chat. +// +// BRIEF rewrite the auth middleware to use the new session store +// SETUP set up its workspace git +// SPEC wrote your brief down as its spec +// EXPLORE read internal/auth/middleware.go +// ran go test ./internal/auth/... fails · exit 1 +// IMPLEMENT edited internal/auth/middleware.go +// compacted its memory +// ◐ thinking · 12s +// +// THE ACTIONS ARE THE PROGRAM'S, IN ITS OWN WORDS. The run keeps every stage, +// step and ending the program reported, stamped as codeaf received it, and the +// program's own vocabulary reads them (internal/delegate's Present; senior-dev's +// is internal/seniordev's actions.go) before the page ever holds them. What only +// the program's calls to a model know is merged in by time: a history rewritten +// as a summary is `compacted its memory` (once, when the program said so too), a +// change of the model answering is `switched to <model>` with the program's +// reason when it gave one, and a call refused or failed is one plain line. A +// model is named nowhere else on the page; the cost and the clock stay on the +// pinned line. The raw calls are one key away (programcalls.go, +// [programCallsKey]). +// +// THE PAGE READS NOTHING. Every line here is drawn from the page the surface +// already holds ([session.PlanTaskPage.Program], read off the loop on the page's +// own beat), with the frame's own clock for the two figures that tick — the +// run's age and the call in flight's. The texts arrive cut to their first line +// with the run's copy taken out of their paths (internal/session's +// plandb_program.go), so a row here is a choice of which line to show and never +// a reading of the record. + +import ( + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +const ( + // convLabelMost is the most cells a speaker's name is given in the column + // the names stand in. A model id past it is cut in the middle, keeping the + // end that tells one model of a family from another ([rowTrim]). + convLabelMost = 22 + // convTextLeast is the least room the words beside the names keep. Under it + // the column costs more than it buys, and each name stands on a line of its + // own with its words under it. + convTextLeast = 28 + // convGap is the air between a name and its words: the row fitter's own + // gutter, because this is the same shape of row — a name and what it says. + convGap = rowGutter + // convIndent is how far a speaker's words hang under its name when the names + // stand on lines of their own: the two-cell lead every piece of this + // surface's machinery keeps. + convIndent = 2 + // convProgramFallback names the program's side in the one case its name is + // unknown: a conversation log with no program record beside it. + convProgramFallback = "program" +) + +// convProgramOf is the page's program, never nil on a program's page: a page +// whose read has not come back yet is a program with nothing said, named off +// its row. +func convProgramOf(page session.PlanTaskPage) *session.PlanProgram { + if page.Program != nil { + return page.Program + } + return &session.PlanProgram{Name: strings.TrimSpace(page.Row.Program)} +} + +// programPinned is the one line a program's room pins under its title, which +// no scroll moves: where the program is, what it has spent, how many calls it +// has made, and how long it has been going. +// +// implement · $1.24 · 38 calls · 14m 3s +// +// THE LEAD IS THE STAGE, and the task's own state word when there is no stage — +// `running` in the seconds before the program names one, and `done`, +// `incomplete` or `stopped` once it has ended — so the line always says where +// the work stands. EVERY OTHER FIGURE IS DRAWN ONLY WHEN IT IS SOMETHING: no +// `$0.00`, no `0 calls`, no clock under a second. The spend is the store's +// spend rows for this task — the run's own money, which the status line's +// conversation total is not — and it names the ceiling beside it only when the +// page knows the ceiling. The line is ranked and fitted by [rowTail], so a +// narrow frame gives up the clock before the calls and the calls before the +// money. The room ([app.programFactsWord]) hands in the clock of the node it +// is standing on, which is the clock the rail and the landed card read, so the +// one run reads one figure wherever it is drawn. +func (a *app) programPinned(page session.PlanTaskPage, width int, clock string) string { + if (page.Program == nil && strings.TrimSpace(page.Row.Program) == "") || width < 1 { + return "" + } + program := convProgramOf(page) + lead := strings.TrimSpace(page.Row.Stage) + if lead == "" { + lead = planStateWord(page.Row) + } + fields := []rowField{rowSay(lead)} + if usd := planSpendWord(page.Row.USD); usd != "" { + if program.CeilingUSD > 0 { + fields = append(fields, rowSay(usd+" of "+dollars(program.CeilingUSD), usd)) + } else { + fields = append(fields, rowSay(usd)) + } + } + if n := program.Calls; n > 0 { + fields = append(fields, rowSay(itoa(n)+" "+plural("call", n))) + } + fields = append(fields, rowSay(clock)) + return rowTail(fields, width) +} + +// taskPlanAge is how long a task has been going: from when it was made to when +// it ended, or to now while it runs. A task that has ended without a moment +// recorded for its ending draws no age rather than one that keeps climbing. +// +// A PROGRAM'S RUN READS THE CLOCK THE RAIL READS whenever this conversation +// holds one for it ([app.nodeClock]). The store's two stamps bracket other +// events than the run's notices do — the store is seeded before the run's copy +// is made, and it is ended by the supervisor rather than when the program's +// process is gone — so a page reading them and a rail and a landed card reading +// the notices drew three different figures for one run. The store's stamps are +// what is left for a run this conversation has no row for — and for the span +// between the program's exit and the row settling, when the page's pair has +// already stopped where the row will ([programExitClock]). +func (a *app) taskPlanAge(row session.PlanTaskRow) string { + if node := a.programRowNode(row); node != nil { + if word, ok := programExitClock(row, node); ok { + return word + } + if word, ok := a.nodeClock(node); ok { + return word + } + } + if row.Started.IsZero() { + return "" + } + end := row.Ended + if end.IsZero() { + if planEnded(row) { + return "" + } + end = a.now() + } + if end.Before(row.Started) { + return "" + } + span := end.Sub(row.Started) + // A FINISHED SPAN IS ROUNDED TO THE SECOND, as the card and the room round + // it ([taskNode.ranFor]) and as the note the chat is handed does, so a + // 61.5-second run reads `1m 2s` wherever it is read; a running clock counts + // whole seconds up. + if !row.Ended.IsZero() { + span = span.Round(time.Second) + } + return countUpWord(span) +} + +// programBody is what a program's room draws under its head (programroom.go): +// the program's actions, or its raw calls when the room's own +// [programCallsKey] asked for them, and the notes. briefFull and calls are the +// room's own, because it folds its brief and turns to its calls with its own +// keys. +// +// A RUN FROM BEFORE ITS CALLS WERE LOGGED still has the steps its program +// reported: the actions draw them in their own shape, and the calls, which +// have none to draw, list them under their own heading as they always did. +func (a *app) programBody(page session.PlanTaskPage, width int, briefFull, calls bool) []string { + out, _ := a.programBodyRows(page, width, briefFull, calls, true, nil) + return out +} + +// programBodyRows is [app.programBody] with the two things only the program's +// room asks for: whether the actions open under the brief (the room draws the +// brief behind its head's dropdown instead, programroom.go), and which actions +// are open to their whole step. Beside every line it answers the action the +// line belongs to — its key, zero for a line that belongs to none — so the +// room can make the line a press that opens or shuts that action. +func (a *app) programBodyRows(page session.PlanTaskPage, width int, briefFull, calls, brief bool, open map[int64]bool) ([]string, []int64) { + pal := a.pal + var out []string + var keys []int64 + if calls { + out = a.programCalls(page, width, briefFull) + if len(convProgramOf(page).Turns) == 0 && len(page.Steps) > 0 { + out = append(out, "", pal.dim("steps")) + for _, step := range page.Steps { + if step.NotRun { + continue + } + if command := planDisplayCommand(step.Command, step.Parts); command != "" { + out = append(out, pal.ink(itoa(step.Step)+" "+command)) + } + } + } + } else { + out, keys = a.taskConversationRows(page, width, briefFull, brief, open) + } + if len(page.Notes) > 0 { + if len(out) > 0 { + out = append(out, "") + } + out = append(out, pal.dim("notes")) + out = append(out, a.taskPlanNoteRows(page.Notes, width)...) + } + for len(keys) < len(out) { + keys = append(keys, 0) + } + return out, keys +} + +// The words this page says in its own voice, each quoted in the manual as it is +// spelled here (senior-dev.md, worker-harness.md). +const ( + // actBriefWord leads the brief, in the column the step words stand in: the + // brief is what the program was handed, before any step of its own. + actBriefWord = "brief" + // actCompactedWord is a program's history rewritten as a summary, said once. + actCompactedWord = "compacted its memory" + // actSwitchedWord leads the line a change of the model answering draws. + actSwitchedWord = "switched to" + // actRefusedWord and actFailedWord lead the line a call codeaf refused, or + // the model's side failed, draws. + actRefusedWord = "codeaf refused a call" + actFailedWord = "a call to its model failed" + // actThinkingWord is what is in flight while a call to the model is out. + actThinkingWord = "thinking" + // actEarlierWord follows the count of actions the page does not carry. + actEarlierWord = "earlier actions" +) + +// actNear is how far apart in time a line from the calls and the program's own +// line about the same thing — a compaction, a switch of model — may be and +// still be the one event. The program reports a compaction the moment it is +// decided, between the summary call and the call after it; a switch, when the +// call it was made for has come back. +const actNear = 10 * time.Second + +// actLine is one line of the actions, before it is laid out: when it happened, +// the step's word it belongs under ("" for whatever step is under way), its +// words and how it came out, and whether it is the program steering its own +// model or a line from the calls, which are drawn quieter. +type actLine struct { + at time.Time + step string + text string + outcome string + steer bool + quiet bool + // detail is the whole of the step ([delegate.Shown.Detail]) and key the + // action's identity for opening it — the moment it was received — both + // empty for a line with nothing more to show. + detail string + key int64 + // lines says the action changed a file and counted how: added and removed + // are drawn `+N,-M` at its right edge in the diff's own colours + // ([delegate.Shown.Lines]). + lines bool + added, removed int +} + +// taskConversation is a program's page where an ordinary page draws its steps: +// the brief the program was handed, then every action it took, merged by time +// with what only its calls know, each under the step of its process it served. +// +// THE STEP'S WORD STANDS IN A COLUMN OF ITS OWN while the frame has the room, +// printed on the first action of each run of actions in one step and blank for +// the rest, so the eye reads down the steps and across to what was done in +// each; under [convTextLeast] cells of words each step's word stands on its own +// line instead, and its actions hang under it. The column is as wide as the +// widest word on the page, so it does not move as the run goes on. +func (a *app) taskConversation(page session.PlanTaskPage, width int, briefFull bool) []string { + out, _ := a.taskConversationRows(page, width, briefFull, true, nil) + return out +} + +// taskConversationRows is [app.taskConversation] with the brief left out when +// brief is false, the actions in open drawn with their whole step under them, +// and beside each line the key of the action it belongs to (zero for none). +func (a *app) taskConversationRows(page session.PlanTaskPage, width int, briefFull, brief bool, open map[int64]bool) ([]string, []int64) { + if width < 1 { + return nil, nil + } + program := convProgramOf(page) + pal := a.pal + lines := actLines(page) + column, text := actColumns(lines, width) + + var out []string + var keys []int64 + // THE BRIEF OPENS THE PAGE, under its own word: it is what the program was + // handed, in the person's own words, folded to the brief's own three lines + // with the key that unfolds it, the way every other page folds a brief. The + // program's room draws it behind its head's dropdown instead. + if brief { + for i, line := range taskConversationBrief(page, text, briefFull) { + word := "" + if i == 0 { + word = actBriefWord + } + out = append(out, actRow(pal, word, pal.ink(fit(line, text)), column, width)...) + } + } + // THE ACTIONS THE PAGE LEAVES OUT ARE COUNTED AT THE PAGE'S OWN EDGE, spelled + // the way every fold line on this surface is ([bandFoldWord]). + // + // A PAGE DRAWN FROM ITS CALLS COUNTS THE CALLS IT LEAVES OUT THERE INSTEAD. A + // run from before the action log — or one read off a --host engine too old to + // send its actions — has no actions to count, and its page is the newest calls + // the store carries ([actFromCalls]); without the count its first kept call + // stood straight under the brief, and the page read as though the run began + // there. + switch { + case program.EarlierActions > 0: + out = append(out, pal.dim(fit(glyphMore+itoa(program.EarlierActions)+" "+actEarlierWord, width))) + case len(program.Actions) == 0 && program.Earlier > 0: + out = append(out, pal.dim(fit(glyphMore+itoa(program.Earlier)+" "+convEarlierWord, width))) + } + for len(keys) < len(out) { + keys = append(keys, 0) + } + current := "" + for _, line := range lines { + word := "" + if line.step != "" && line.step != current { + word, current = line.step, line.step + } + key := int64(0) + if strings.TrimSpace(line.detail) != "" { + key = line.key + } + for _, drawn := range actRow(pal, word, a.actBody(line, text), column, width) { + out, keys = append(out, drawn), append(keys, key) + } + // AN OPEN ACTION SHOWS ITS WHOLE STEP UNDER ITS LINE, in the dim ink, + // hung where the actions' words start; the same press shuts it. + if key != 0 && open[key] { + for _, detail := range actDetailRows(line.detail, text) { + for _, drawn := range actRow(pal, "", pal.dim(detail), column, width) { + out, keys = append(out, drawn), append(keys, key) + } + } + } + } + // THE CALL IN FLIGHT IS THE LIVE EDGE, drawn only while the task can still be + // waiting on it: a call whose ending never reached the log before the run + // ended is not in flight on a page about work that is over. + if planStateWord(page.Row) == "running" { + if n := len(program.Turns); n > 0 && program.Turns[n-1].InFlight() { + if line := a.actInFlight(program.Turns[n-1], text); line != "" { + out = append(out, actRow(pal, "", line, column, width)...) + } + } + } + for len(keys) < len(out) { + keys = append(keys, 0) + } + return out, keys +} + +// actDetailMost is how many rows one open action may spend; the rest is +// counted, the way every capped expansion on this surface is. +const actDetailMost = 40 + +// actDetailRows is an action's whole step laid out at the room the actions' +// words get: each line of it wrapped, a blank line kept, and a step longer +// than [actDetailMost] rows cut with the count of what was left out. +func actDetailRows(detail string, width int) []string { + var rows []string + for _, line := range strings.Split(strings.TrimRight(detail, "\n"), "\n") { + line = strings.TrimRight(line, " \t\r") + if line == "" { + rows = append(rows, "") + continue + } + rows = append(rows, railWrap(line, max(width, 1))...) + } + if len(rows) > actDetailMost { + cut := len(rows) - (actDetailMost - 1) + rows = append(rows[:actDetailMost-1], bandFoldWord(cut, "lines", true)) + } + return rows +} + +// actRow lays one row out: the step's word in the column and the body beside +// it, or — where the frame is too narrow for a column — the word on a line of +// its own and the body hung [convIndent] cells under it. The word is the page's +// structure, not its signal: bold and upper-case in the muted ink, never the +// accent, which a screen spends on the one live thing ([DESIGN-LANGUAGE.md]'s +// accent budget). +func actRow(pal palette, word, body string, column, width int) []string { + label := "" + if word = strings.TrimSpace(word); word != "" { + room := column + if column == 0 { + room = width + } + trimmed, _ := rowTrim(strings.ToUpper(word), room, false) + label = pal.bold(pal.muted(trimmed)) + } + if column == 0 { + var out []string + if label != "" { + out = append(out, label) + } + return append(out, strings.Repeat(" ", convIndent)+body) + } + cell := strings.Repeat(" ", column) + if label != "" { + cell = padTo(label, column) + } + return []string{cell + strings.Repeat(" ", convGap) + body} +} + +// actBody is one action's words at the room they get, painted: the program's +// own work in ink, its steering of its model in the note's ink, a line from the +// calls dim, and how it came out dim at the right edge. When the room will not +// hold both at the edge, the outcome follows the words after a separator and +// the row gives up its tail. +func (a *app) actBody(line actLine, width int) string { + pal := a.pal + ink := pal.ink + switch { + case line.quiet: + ink = pal.dim + case line.steer: + ink = pal.narr + } + text, outcome := convHead(line.text), convHead(line.outcome) + painted := pal.dim(outcome) + if line.lines { + // THE LINES A CHANGE ADDED AND REMOVED, git's `+N,-M`, in the diff's own + // green and red: the one figure on the page a person reads for how much + // the work moved at each step. + plus, minus := "+"+itoa(line.added), "-"+itoa(line.removed) + badge := plus + "," + minus + painted = pal.add(plus) + pal.dim(",") + pal.del(minus) + if outcome != "" { + painted = pal.dim(outcome+railSep) + painted + outcome += railSep + badge + } else { + outcome = badge + } + } + if outcome == "" { + return ink(fit(text, width)) + } + cells := ansi.StringWidth(outcome) + if room := width - cells - convGap; room >= convTextLeast/2 { + words, measured := fitWidth(text, room) + return ink(words) + strings.Repeat(" ", width-measured-cells) + painted + } + return ink(fit(text+railSep+outcome, width)) +} + +// actInFlight is the call in flight: the running mark and how long the program +// has been waiting on its model — one line, the last on the page, gone the +// moment the call's ending reaches the log. The mark comes off the vocabulary's +// own door, so the line gets this terminal's repertoire; the clock is the +// frame's and says nothing for the call's first second. +func (a *app) actInFlight(turn delegate.Turn, width int) string { + mark := a.icon(tokens.GStepRunning) + room := width - ansi.StringWidth(mark) - 1 + if room < 1 { + return "" + } + words := []string{actThinkingWord} + if !turn.Started.IsZero() { + if clock := countUpWord(a.now().Sub(turn.Started)); clock != "" { + words = append(words, clock) + } + } + return a.pal.ink(mark) + " " + a.pal.dim(fit(strings.Join(words, railSep), room)) +} + +// actColumns decides the page's two widths from the step words on it: the +// column the words stand in and the room the actions get beside it. A column +// of zero is the narrow layout, where every word stands on its own line and +// the actions hang [convIndent] cells under it. +func actColumns(lines []actLine, width int) (int, int) { + widest := ansi.StringWidth(actBriefWord) + for _, line := range lines { + if cells := ansi.StringWidth(strings.TrimSpace(line.step)); cells > widest { + widest = cells + } + } + column := min(min(widest, convLabelMost), width/3) + if column < 1 || width-column-convGap < convTextLeast { + return 0, width - convIndent + } + return column, width - column - convGap +} + +// actLines is everything the page draws under the brief, in the order it +// happened: the program's actions as its own vocabulary read them, and what +// only its calls know — a compaction the program did not report, a change of +// the model answering, a call refused or failed. +// +// A RUN FROM BEFORE THE ACTION LOG has only its calls, and its page is drawn +// from them in the same shape: each tool its model asked for as an action, and +// no step's word, because nothing recorded which step it served. A run whose +// calls were never logged either draws the steps its program reported. +func actLines(page session.PlanTaskPage) []actLine { + program := convProgramOf(page) + var lines []actLine + for _, shown := range program.Actions { + if strings.TrimSpace(shown.Model) != "" { + // A SWITCH IS READ WITH THE CALLS BELOW, which say the same move from + // the model's side; the program's line gives it its reason. + continue + } + lines = append(lines, actLine{at: shown.At, step: shown.Step, text: shown.Text, outcome: shown.Outcome, steer: shown.Steer, + detail: shown.Detail, key: shown.At.UnixNano(), + lines: shown.Lines, added: shown.Added, removed: shown.Removed}) + } + lines = append(lines, actFromCalls(program)...) + if len(program.Actions) == 0 && len(program.Turns) == 0 { + for _, step := range page.Steps { + if command := planDisplayCommand(step.Command, step.Parts); command != "" && !step.NotRun { + lines = append(lines, actLine{text: command}) + } + } + } + // THE ACTIONS THE PAGE CUT ARE CUT FROM THE CALLS TOO: a line from a call + // older than the first action the page carries would stand above the count + // of the ones it left out. + if program.EarlierActions > 0 && len(program.Actions) > 0 { + first := program.Actions[0].At + kept := lines[:0] + for _, line := range lines { + if !line.at.Before(first) { + kept = append(kept, line) + } + } + lines = kept + } + sort.SliceStable(lines, func(i, j int) bool { return lines[i].at.Before(lines[j].at) }) + return lines +} + +// actFromCalls is what the page draws from the program's calls: the compactions +// the program did not report itself, every change of the model answering its +// work — with the program's reason when it gave one, and the program's own line +// for a move the calls do not show — and every call refused or failed; and for +// a run with no action log at all, every tool its model asked for. +func actFromCalls(program *session.PlanProgram) []actLine { + turns := program.Turns + var lines []actLine + memory := func(from, to time.Time) bool { + for _, shown := range program.Actions { + if shown.Memory && !shown.At.Before(from.Add(-actNear)) && !shown.At.After(to.Add(actNear)) { + return true + } + } + return false + } + switches := map[int]bool{} + switchFor := func(model string, from, to time.Time) (string, bool) { + for i, shown := range program.Actions { + if switches[i] || strings.TrimSpace(shown.Model) == "" || !actSameModel(convModelWordOf(shown.Model), model) { + continue + } + if !shown.At.Before(from.Add(-actNear)) && !shown.At.After(to.Add(actNear)) { + switches[i] = true + return shown.Reason, true + } + } + return "", false + } + previous, previousAt := "", time.Time{} + compacted := -1 + for i := 0; i < len(turns); i++ { + turn := turns[i] + switch { + case convHead(turn.Refused) != "": + lines = append(lines, actLine{at: turn.Started, text: actRefusedWord + railSep + convHead(turn.Refused), quiet: true}) + continue + case convHead(turn.Failed) != "": + lines = append(lines, actLine{at: turn.Started, text: actFailedWord + railSep + convHead(turn.Failed), quiet: true}) + continue + } + if turn.Restarted && i > compacted { + // ONE COMPACTION IS A RUN OF RESTARTED CALLS: the summary itself and + // the call after it both rewrite the history, and a person reads one + // line for them. + last := i + for last+1 < len(turns) && turns[last+1].Restarted { + last++ + } + compacted = last + end := turns[last].Ended + if end.IsZero() { + end = turns[last].Started + } + if !memory(turn.Started, end) { + lines = append(lines, actLine{at: turn.Started, text: actCompactedWord}) + } + } + if turn.InFlight() { + continue + } + // THE SUMMARY CALL IS NOT THE WORK. A restarted call that asked for no + // tool, followed by another restarted call, is the history being + // summarized — on a cheaper model, often — and its model is not a change + // of the model doing the work. + if turn.Restarted && len(turn.Calls) == 0 && i+1 < len(turns) && turns[i+1].Restarted { + continue + } + model := convModelWord(turn) + if previous != "" && model != "" && !actSameModel(model, previous) { + reason, _ := switchFor(model, previousAt, turn.Ended) + text := actSwitchedWord + " " + model + if reason != "" { + text += railSep + reason + } + lines = append(lines, actLine{at: turn.Started, text: text}) + } + if model != "" { + previous, previousAt = model, turn.Started + } + if len(program.Actions) == 0 { + for _, call := range turn.Calls { + if text := actCallText(call); text != "" { + lines = append(lines, actLine{at: turn.Ended, text: text}) + } + } + } + } + // A MOVE THE CALLS DO NOT SHOW is still the program's to say: the router + // moved the work and the model API answered on the model it moved to. + for i, shown := range program.Actions { + if strings.TrimSpace(shown.Model) == "" || switches[i] { + continue + } + text := actSwitchedWord + " " + convModelWordOf(shown.Model) + if reason := strings.TrimSpace(shown.Reason); reason != "" { + text += railSep + reason + } + lines = append(lines, actLine{at: shown.At, step: shown.Step, text: text}) + } + return lines +} + +// actSameModel reports whether two short model words name one model: the same +// word, or one a dated build or variant of the other (`deepseek-v4-flash` and +// `deepseek-v4-flash-0731`), which is how the model a program asked for and the +// one the service answered with are often spelled. +func actSameModel(a, b string) bool { + return a == b || strings.HasPrefix(a, b+"-") || strings.HasPrefix(b, a+"-") +} + +// actCallText is one tool a model asked for, as an action a person reads, for a +// run whose program kept no action log: the verb and what it was about. +func actCallText(call delegate.ToolUse) string { + name := convHead(call.Name) + about := convHead(convCallAbout(call.Args)) + if name == "" { + return "" + } + verb := map[string]string{ + "read": "read", "edit": "edited", "write": "wrote", "apply_patch": "patched", + "bash": "ran", "grep": "searched", "glob": "listed", "webfetch": "fetched", + "websearch": "searched the web for", + }[name] + switch { + case name == "submit": + return "handed in its work" + case verb == "" || about == "": + return strings.TrimSpace(name + " " + about) + } + return verb + " " + about +} + +// taskConversationBrief is the brief as the page opens with it: the description +// through the reader every page draws a brief with ([planBriefRows]), at the +// width the words get beside the step words, folded to [briefFoldLines] with +// the line that says how many more and which key opens them. +func taskConversationBrief(page session.PlanTaskPage, text int, briefFull bool) []string { + lines := planBriefRows(page.Description, text) + if briefFull || len(lines) <= briefFoldLines { + return lines + } + return append(append([]string(nil), lines[:briefFoldLines]...), + bandFoldWord(len(lines)-briefFoldLines, briefFoldWhat, true)+railSep+briefFoldKey) +} + +// convBriefFolds is whether a program's brief folds when its room is drawn at +// this width — as its actions, or as its calls — the measure the room asks +// before its `ctrl+o` opens or closes it. +func convBriefFolds(page session.PlanTaskPage, width int, calls bool) bool { + _, text := actColumns(actLines(page), width) + if calls { + _, text = convColumns(convNames(convProgramOf(page), convProgramName(page)), width) + } + return len(planBriefRows(page.Description, text)) > briefFoldLines +} + +// convProgramName is the name the program's side wears: the program record's, +// then its row's, and a plain noun only when neither said. +func convProgramName(page session.PlanTaskPage) string { + if page.Program != nil { + if name := strings.TrimSpace(page.Program.Name); name != "" { + return name + } + } + if name := strings.TrimSpace(page.Row.Program); name != "" { + return name + } + return convProgramFallback +} + +// convModelWord is the model that answered a call: the one that served it +// when codeaf's router answered with another than the program asked for, and +// otherwise the one asked for. +func convModelWord(turn delegate.Turn) string { + if served := strings.TrimSpace(turn.Served); served != "" { + return convModelWordOf(served) + } + return convModelWordOf(turn.Model) +} + +// convModelWordOf is a model id as a speaker's name: the part after the last +// vendor, which is the part that names the model rather than who sells it — +// the rail's own reading of a model ([railModelWord]). +func convModelWordOf(id string) string { + id = convHead(id) + if at := strings.LastIndexByte(id, '/'); at >= 0 && at+1 < len(id) { + id = id[at+1:] + } + return id +} + +// convHead is the first line of a text that says anything, CLEANED TO BE ONE +// ROW. The page's texts arrive already cut to their heads (internal/session's +// plandb_program.go), and this is still the one door every text on the page is +// drawn through, for two reasons: a page built any other way — a test's, an +// older engine's — must not draw a second line into one row, and what a +// program sends is a tool's raw output, which carries tabs a row cannot measure +// and escape sequences a terminal would obey. So the line is stripped of every +// escape sequence, a tab becomes a space and any other control character goes. +func convHead(text string) string { + for _, line := range strings.Split(text, "\n") { + if line = strings.TrimSpace(convClean(line)); line != "" { + return line + } + } + return "" +} + +// convClean is one line of a program's words with nothing a terminal would act +// on left in it. +func convClean(line string) string { + return strings.Map(func(r rune) rune { + switch { + case r == '\t': + return ' ' + case r < 0x20, r == 0x7f, r >= 0x80 && r < 0xa0: + return -1 + } + return r + }, ansi.Strip(line)) +} + +// convAboutKeys are the arguments that say what a call was about, most telling +// first: the command a shell ran, the pattern a search looked for (a search +// names where it looked too, and the pattern is the part a person reads it +// for), the address a fetch went to, then the file a call opened or wrote. They +// are the names the programs' tools use for them, in both spellings those tools +// use. +var convAboutKeys = []string{ + "command", "cmd", "pattern", "query", "url", "filePath", "file_path", "path", "description", "prompt", +} + +// convCallAbout is what a call was about, in the fewest words that say it. A +// program's tool call carries its arguments as one line of JSON, cut at a +// couple of hundred bytes when it is long — an edit carries the text it +// replaces — so it is READ FORGIVINGLY rather than parsed: the most telling +// argument that has a string value, then the first string value there is. A +// line that is not an object is drawn as it was written, and an object that +// holds no string at all says nothing a row can carry. +func convCallAbout(args string) string { + args = strings.TrimSpace(args) + if !strings.HasPrefix(args, "{") { + return args + } + for _, key := range convAboutKeys { + if value, ok := convArgNamed(args, key); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + for at := 0; at < len(args); { + colon := strings.IndexByte(args[at:], ':') + if colon < 0 { + break + } + at += colon + 1 + if rest := strings.TrimLeft(args[at:], " \t"); strings.HasPrefix(rest, `"`) { + if value := strings.TrimSpace(convJSONString(rest[1:])); value != "" { + return value + } + } + } + return "" +} + +// convArgNamed is the string value of one argument, when the line names it +// with a string value; a value cut off at the line's end is read to where it +// was cut. +func convArgNamed(args, key string) (string, bool) { + name := `"` + key + `"` + for from := 0; ; { + at := strings.Index(args[from:], name) + if at < 0 { + return "", false + } + from += at + len(name) + rest := strings.TrimLeft(args[from:], " \t") + if !strings.HasPrefix(rest, ":") { + continue + } + rest = strings.TrimLeft(rest[1:], " \t") + if !strings.HasPrefix(rest, `"`) { + return "", false + } + return convJSONString(rest[1:]), true + } +} + +// convJSONString reads a JSON string's body up to its closing quote, or to the +// end of a line that was cut inside it, with its escapes read as the characters +// they stand for and a line break or a tab as a space. +func convJSONString(body string) string { + var out strings.Builder + for i := 0; i < len(body); i++ { + c := body[i] + switch { + case c == '"': + return out.String() + case c != '\\': + out.WriteByte(c) + continue + } + if i+1 >= len(body) { + break + } + i++ + switch body[i] { + case 'n', 't', 'r': + out.WriteByte(' ') + case 'u': + if i+4 < len(body) { + if code, err := strconv.ParseUint(body[i+1:i+5], 16, 32); err == nil && utf8.ValidRune(rune(code)) { + out.WriteRune(rune(code)) + i += 4 + continue + } + } + out.WriteByte('u') + default: + // `\"`, `\\` and `\/` stand for the character after the backslash. + out.WriteByte(body[i]) + } + } + return out.String() +} + +// planNoteWho is the one word a note's author is drawn as: `you` for a note a +// person left, and nothing for every other author, because the store holds +// those as ids (the run's number, a worker's handle) and no internal name goes +// on a person's screen. +func planNoteWho(note session.PlanTaskNote) string { + if note.Person { + return "you" + } + return "" +} + +// taskPlanNoteRows is every note on a program's task as its room draws them +// under its `notes` heading: each one's author and moment on a dim line, then +// its words. +func (a *app) taskPlanNoteRows(notes []session.PlanTaskNote, width int) []string { + pal := a.pal + var out []string + for _, note := range notes { + // AN AUTHOR IS DRAWN ONLY AS A WORD A PERSON WOULD RECOGNISE ([planNoteWho]). + // The moment is kept and the id is never drawn. + who := planNoteWho(note) + when := sinceAt(note.At, a.now()) + switch { + case who != "" && when != "": + out = append(out, pal.dim(who+railSep+when)) + case who != "": + out = append(out, pal.dim(who)) + case when != "": + out = append(out, pal.dim(when)) + } + for _, para := range strings.Split(note.Body, "\n") { + if strings.TrimSpace(para) == "" { + continue + } + for _, line := range wrap(strings.TrimSpace(para), width) { + out = append(out, pal.ink(line)) + } + } + } + return out +} + +func planBriefLines(text string, width int) []string { + var lines []string + for _, para := range strings.Split(text, "\n") { + if para = strings.TrimSpace(para); para != "" { + lines = append(lines, wrap(para, width)...) + } + } + return lines +} + +// planBriefRows is a program's brief as its room reads it: the work order +// drawn through the reader the conversation's transcript already uses +// ([requestDisplayFor]) before the room wraps it, so every surface that holds +// this text gives a person the same reading of it, and the fold counts the +// lines this function draws. A description that is not the generated document +// is wrapped as it was always wrapped. +func planBriefRows(desc string, width int) []string { + return planBriefLines(requestDisplayFor(strings.TrimSpace(desc)), width) +} diff --git a/internal/tui3/taskconversation_test.go b/internal/tui3/taskconversation_test.go new file mode 100644 index 0000000000..20674a0ed0 --- /dev/null +++ b/internal/tui3/taskconversation_test.go @@ -0,0 +1,760 @@ +package tui3 + +// A program's task page, drawn from a scripted page the way the store answers +// one ([session.PlanTaskPage.Program]): the pinned line, the program's actions +// under the steps of its process in place of the steps, the call in flight, the +// raw calls one key away, and the foot with no box. Nothing here seeds a store +// or runs a program; the page is the fake's, and every reading the surface +// makes of it is the one a real window makes. + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +// programRunBegan is when the fixture's run started: fourteen minutes and three +// seconds before the frame's own clock, so the pinned age is a figure a test +// can spell. +var programRunBegan = taskFixtureNow.Add(-(14*time.Minute + 3*time.Second)) + +// programRow is the run's root as the store answers it mid-way: running, +// handed to senior-dev, implementing, with a dollar and a quarter of spend rows +// banked by the model API. +func programRow() session.PlanTaskRow { + return session.PlanTaskRow{ + ID: "t-7", Title: "rewrite the auth middleware", Status: "running", + Program: "senior-dev", Stage: "implement", USD: 1.24, Started: programRunBegan, + Live: plandb.LiveStep{Step: 3, Command: "senior-dev: implement", Since: programRunBegan}, + } +} + +// programTurns is a conversation three calls long: two answered, each with the +// tools the model asked for, and the third still out, twelve seconds in. +func programTurns() []delegate.Turn { + model := "deepseek/deepseek-v4-flash" + return []delegate.Turn{ + {Seq: 1, Started: programRunBegan, Ended: programRunBegan.Add(4 * time.Second), Model: model, + Sent: []delegate.Said{{Role: "system", Text: "you are senior-dev"}, {Role: "user", Text: "rewrite the auth middleware to use the new session store"}}, + Reply: "I'll read the middleware and the store first.", + Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"internal/auth/middleware.go"}`}, {Name: "grep", Args: `{"pattern":"SessionStore","path":"internal"}`}}, + CostUSD: 0.4}, + {Seq: 2, Started: programRunBegan.Add(5 * time.Second), Ended: programRunBegan.Add(9 * time.Second), Model: model, + Sent: []delegate.Said{ + {Role: "assistant", Text: "I'll read the middleware and the store first."}, + {Role: "tool", Tool: "read", Text: "package auth"}, + {Role: "tool", Tool: "grep", Text: "internal/auth/store.go:12: type SessionStore interface {"}, + }, + Reply: "The store interface is small; I'll change the handler.", + Calls: []delegate.ToolUse{{Name: "edit", Args: `{"filePath":"internal/auth/middleware.go","oldString":"func Middleware(`}}, + CostUSD: 0.5}, + {Seq: 3, Started: taskFixtureNow.Add(-12 * time.Second), Model: model, + Sent: []delegate.Said{{Role: "tool", Tool: "edit", Text: "applied 1 edit"}}}, + } +} + +// programActions is what senior-dev's own words make of the same run's action +// log (internal/seniordev's actions.go): set up, the brief written down, the +// reading, the test that fails before the change, and the change. +func programActions() []delegate.Shown { + return []delegate.Shown{ + {At: programRunBegan.Add(-2 * time.Second), Step: "setup", Text: "set up its workspace", Outcome: "git"}, + {At: programRunBegan.Add(-time.Second), Step: "spec", Text: "wrote your brief down as its spec"}, + {At: programRunBegan.Add(4 * time.Second), Step: "explore", Text: "read internal/auth/middleware.go"}, + {At: programRunBegan.Add(4 * time.Second), Step: "explore", Text: "searched internal"}, + {At: programRunBegan.Add(4 * time.Second), Step: "explore", Text: "ran go test ./internal/auth/...", Outcome: "fails · exit 1"}, + {At: programRunBegan.Add(9 * time.Second), Step: "implement", Text: "edited internal/auth/middleware.go"}, + } +} + +// programPage is the page the fixture's row opens. +func programPage(row session.PlanTaskRow, turns []delegate.Turn) session.PlanTaskPage { + return session.PlanTaskPage{ + Row: row, + Description: "rewrite the auth middleware to use the new session store", + Live: row.Live, + Program: &session.PlanProgram{ + Name: "senior-dev", Stages: []string{"intake", "implement", "verification"}, + Turns: turns, Calls: len(turns), Actions: programActions(), + }, + } +} + +// programPageApp opens the program's task from the tasks place at a width and +// a height, the two keys a person presses, on a surface whose clock is the +// fixture's: it opens the program's room (programroom.go). +func programPageApp(t *testing.T, page session.PlanTaskPage, width, height int) (*app, *planFake) { + t.Helper() + // The room reads its page by the task's own number, and the tasks place + // asks under the store's id; the store answers to both. + a, fake := planAppWith(t, []session.PlanTaskRow{page.Row}, map[string]session.PlanTaskPage{page.Row.ID: page, planTaskIDWord(page.Row.ID): page}) + a.width, a.height = width, height + openPlanPage(t, a) + if a.programOf() == nil { + t.Fatalf("the tasks place did not open the program's room: room=%v", a.room != nil) + } + return a, fake +} + +// openPlanPage opens the tasks place over the plan's rows and presses enter on +// the first, which opens its task's room. +func openPlanPage(t *testing.T, a *app) { + t.Helper() + if !openTaskPlaceWithRows(a) { + t.Fatal("the place refused to open over a plan") + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + drain(t, a, a.takeRoomPump()) +} + +// programPinnedLine is the room's facts row as drawn — the line its stored page +// pins — without the rule it rides on: the row under the trail to the task. +func programPinnedLine(t *testing.T, lines []string) string { + t.Helper() + for i, line := range lines { + if strings.Contains(line, "▸ rewrite the auth") && i+1 < len(lines) { + facts := strings.TrimSpace(lines[i+1]) + facts = strings.TrimPrefix(facts, "─ ") + if cut := strings.Index(facts, " ─"); cut >= 0 { + facts = facts[:cut] + } + return facts + } + } + t.Fatalf("no trail row leads to the task:\n%s", strings.Join(lines, "\n")) + return "" +} + +// programPageLines is the room as drawn, one plain string per screen row: its +// head — the trail to the task and the pinned line under it — and its body, down +// to the rule over the box, which is the conversation's and not the page's. +func programPageLines(a *app) []string { + frame, _, _ := a.frame() + var out []string + for _, line := range strings.Split(frame, "\n") { + line = plain(line) + if len(out) == 0 && !strings.Contains(line, "rewrite the auth middleware") { + continue + } + if len(out) > 0 && strings.HasPrefix(strings.TrimSpace(line), "─ room") { + break + } + out = append(out, line) + } + return out +} + +// saidBy reports whether a row of the page has a speaker's name in the names' +// column and these words beside it, whatever width the column came to — the +// raw calls' reading, and the actions' too, where the name is a step's word. +func saidBy(lines []string, name, words string) bool { + for _, line := range lines { + rest, ok := strings.CutPrefix(strings.TrimSpace(line), name) + if ok && strings.HasPrefix(rest, " ") && strings.HasPrefix(strings.TrimLeft(rest, " "), words) { + return true + } + } + return false +} + +// underStep reports whether the page draws an action under a step: the step's +// word leads the row whose words begin so, or leads an earlier row of the same +// run of the step with nothing but blank column between them. +func underStep(lines []string, step, words string) bool { + current := "" + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // A STEP'S WORD IS UPPER CASE AND HAS THE COLUMN'S AIR AFTER IT (or the + // row to itself, at a narrow width); an action never starts so. + head, rest, _ := strings.Cut(trimmed, " ") + if head != "" && head == strings.ToUpper(head) && strings.ToLower(head) != head { + current, trimmed = head, strings.TrimSpace(rest) + } + if current == strings.ToUpper(step) && strings.HasPrefix(trimmed, words) { + return true + } + } + return false +} + +// A PROGRAM'S PAGE IS THE ACTIONS IT TOOK, EACH UNDER THE STEP OF ITS PROCESS. +// The brief opens it under its own word; each step's word leads its first +// action and stands blank for the rest; how an action came out is at the right +// edge; the call still out is the last line, `thinking` with its clock; no model +// is named anywhere; and the line under the title is pinned with the step, the +// spend, the calls and the age. +func TestAProgramsPageDrawsItsActionsUnderItsSteps(t *testing.T) { + a, _ := programPageApp(t, programPage(programRow(), programTurns()), 80, 30) + lines := programPageLines(a) + page := strings.Join(lines, "\n") + t.Logf("a program's page, mid-way:\n%s", page) + + if pinned := programPinnedLine(t, lines); pinned != "implement · $1.24 · 3 calls · 14m 3s" { + t.Fatalf("the pinned line is %q, want the step, the spend, the calls and the age", pinned) + } + for _, said := range []struct{ step, words string }{ + {actBriefWord, "rewrite the auth middleware to use the new session store"}, + {"setup", "set up its workspace"}, + {"spec", "wrote your brief down as its spec"}, + {"explore", "read internal/auth/middleware.go"}, + {"explore", "ran go test ./internal/auth/..."}, + {"implement", "edited internal/auth/middleware.go"}, + } { + if !underStep(lines, said.step, said.words) { + t.Fatalf("the page does not draw %q under %s:\n%s", said.words, strings.ToUpper(said.step), page) + } + } + // A STEP'S WORD IS PRINTED ONCE PER RUN OF ITS ACTIONS. + if n := strings.Count(page, "EXPLORE"); n != 1 { + t.Fatalf("EXPLORE is printed %d times for one run of three actions:\n%s", n, page) + } + // HOW IT CAME OUT IS AT THE RIGHT EDGE. + for _, line := range lines { + if strings.Contains(line, "ran go test ./internal/auth/...") && !strings.HasSuffix(line, "fails · exit 1") { + t.Fatalf("the failing command's row is %q, want its outcome at the right edge", line) + } + } + // THE CALL IN FLIGHT IS THE PAGE'S LAST LINE. + last := "" + for _, line := range lines { + if strings.TrimSpace(line) != "" && !strings.Contains(line, "─") && !strings.Contains(line, taskCardBackWord) { + last = line + } + } + if !strings.Contains(last, a.icon(tokens.GStepRunning)+" "+actThinkingWord+" · 12s") { + t.Fatalf("the last line of the page is %q, want the call in flight", last) + } + // NO MODEL IS NAMED, AND NEITHER A PROMPT NOR A REPLY IS A ROW: the actions + // are the program's, and what its model said is one key away. + for _, never := range []string{"deepseek", "you are senior-dev", "I'll read the middleware"} { + if strings.Contains(page, never) { + t.Fatalf("the actions draw %q:\n%s", never, page) + } + } + if strings.Count(page, "rewrite the auth middleware to use the new session store") != 1 { + t.Fatalf("the brief is drawn more than once:\n%s", page) + } + // EVERY ROW FITS THE FRAME. + for i, line := range lines { + if cells := ansi.StringWidth(line); cells > 80 { + t.Fatalf("row %d is %d cells in an 80-cell frame: %q", i, cells, line) + } + } +} + +// A PROGRAM'S ROOM TAKES NO NOTE. A program reads no note, so a sentence typed +// into its room and sent is never a note written to the store, and the room +// stays up. +func TestAProgramsPageHasNoNoteBoxAndTakesNoNote(t *testing.T) { + a, fake := programPageApp(t, programPage(programRow(), programTurns()), 80, 30) + page := strings.Join(programPageLines(a), "\n") + if strings.Contains(page, taskPlanPickupWord) { + t.Fatalf("a program's room promises a note is read:\n%s", page) + } + for _, r := range "pause it" { + drive(t, a, key(string(r))) + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyEnter}) + if len(fake.noted) != 0 || len(fake.paused) != 0 { + t.Fatalf("typing into a program's room wrote notes %v, paused %v", fake.noted, fake.paused) + } + if a.programOf() == nil { + t.Fatal("typing into a program's room closed it") + } +} + +// THE CALL IN FLIGHT IS GONE WHEN IT RETURNS. The page follows the task on its +// beat; the read after the call's ending reached the log draws no running mark +// anywhere, and the action the call led to in its place. +func TestTheCallInFlightLeavesWhenItReturns(t *testing.T) { + row := programRow() + a, fake := programPageApp(t, programPage(row, programTurns()), 80, 30) + flying := a.icon(tokens.GStepRunning) + " " + actThinkingWord + if page := strings.Join(programPageLines(a), "\n"); !strings.Contains(page, flying) { + t.Fatalf("the call in flight is not drawn:\n%s", page) + } + back := programPage(row, programTurns()) + back.Program.Turns[2].Ended = taskFixtureNow + back.Program.Actions = append(back.Program.Actions, delegate.Shown{At: taskFixtureNow, Step: "implement", Text: "ran go test ./internal/auth/...", Outcome: "passes"}) + fake.pages[row.ID], fake.pages[planTaskIDWord(row.ID)] = back, back + planBeat(t, a) + lines := programPageLines(a) + page := strings.Join(lines, "\n") + if strings.Contains(page, flying) { + t.Fatalf("the call that returned is still drawn in flight:\n%s", page) + } + if !strings.Contains(page, "ran go test ./internal/auth/...") || !strings.Contains(page, "passes") { + t.Fatalf("the action after the call is not drawn:\n%s", page) + } +} + +// NOTHING IS DRAWN FOR NOTHING. A program that has spent nothing, made no call +// and named no step wears its state word alone on the pinned line — no +// `$0.00`, no `0 calls` — its page opens on its brief, and a call left open in +// the log of a run that has ended is not in flight on a page about work that is +// over. +func TestAProgramsPageDrawsNothingForZeroOrUnknown(t *testing.T) { + row := programRow() + row.USD, row.Stage, row.Started, row.Live = 0, "", time.Time{}, plandb.LiveStep{} + page := programPage(row, nil) + page.Program.Calls, page.Program.Actions = 0, nil + a, _ := programPageApp(t, page, 80, 20) + lines := programPageLines(a) + if pinned := programPinnedLine(t, lines); pinned != "running" { + t.Fatalf("the pinned line of a program that has said nothing is %q, want its state word alone", pinned) + } + text := strings.Join(lines, "\n") + for _, never := range []string{"$0.00", "0 calls", "0s", actEarlierWord} { + if strings.Contains(text, never) { + t.Fatalf("the page drew %q for a figure nobody has:\n%s", never, text) + } + } + if !underStep(lines, actBriefWord, "rewrite the auth middleware to use the new session store") { + t.Fatalf("a program that has done nothing yet does not open on its brief:\n%s", text) + } + + // AN ENDED RUN'S OPEN CALL IS NO CALL IN FLIGHT. + ended := programRow() + ended.Status, ended.Stage, ended.Live, ended.Ended = "done", "", plandb.LiveStep{}, taskFixtureNow.Add(-time.Minute) + b, _ := programPageApp(t, programPage(ended, programTurns()), 80, 30) + done := strings.Join(programPageLines(b), "\n") + if strings.Contains(done, b.icon(tokens.GStepRunning)) { + t.Fatalf("an ended run's page draws a call in flight:\n%s", done) + } + if pinned := programPinnedLine(t, programPageLines(b)); pinned != "done · $1.24 · 3 calls · 13m 3s" { + t.Fatalf("an ended run's pinned line is %q, want its state, spend, calls and the age it ended at", pinned) + } +} + +// WHAT ONLY THE CALLS KNOW IS MERGED IN BY TIME, ONE PLAIN LINE EACH. A call +// codeaf refused and a call the model's side failed are each one quiet line; a +// history rewritten as a summary — the summary call and the call after it — is +// `compacted its memory` once, and not at all when the program said so itself; +// and every line stands where it happened among the program's own actions. +func TestWhatOnlyTheCallsKnowIsMergedInByTime(t *testing.T) { + model := "deepseek/deepseek-v4-flash" + at := programRunBegan.Add(time.Minute) + turns := append(programTurns()[:2], + delegate.Turn{Seq: 3, Started: at, Ended: at, Model: model, Failed: "upstream 503\nretry later"}, + delegate.Turn{Seq: 4, Started: at.Add(time.Second), Ended: at.Add(2 * time.Second), Model: model, Restarted: true, + Sent: []delegate.Said{{Role: "user", Text: "<conversation>"}}, Reply: "## Working State"}, + delegate.Turn{Seq: 5, Started: at.Add(3 * time.Second), Ended: at.Add(4 * time.Second), Model: model, Restarted: true, + Reply: "Carrying on from the summary.", Calls: []delegate.ToolUse{{Name: "read", Args: `{"filePath":"a.go"}`}}}, + delegate.Turn{Seq: 6, Started: at.Add(5 * time.Second), Model: model, Refused: "the run's dollar ceiling is reached"}, + ) + page := programPage(programRow(), turns) + page.Program.Actions = append(page.Program.Actions, delegate.Shown{At: at.Add(4 * time.Second), Step: "implement", Text: "read a.go"}) + a, _ := programPageApp(t, page, 80, 40) + lines := programPageLines(a) + text := strings.Join(lines, "\n") + order := []string{ + "edited internal/auth/middleware.go", + actFailedWord + " · upstream 503", + actCompactedWord, + "read a.go", + actRefusedWord + " · the run's dollar ceiling is reached", + } + from := 0 + for _, want := range order { + at := strings.Index(text[from:], want) + if at < 0 { + t.Fatalf("the page does not have %q after the lines before it:\n%s", want, text) + } + from += at + len(want) + } + if strings.Count(text, actCompactedWord) != 1 || strings.Contains(text, "retry later") || strings.Contains(text, "Working State") { + t.Fatalf("a compaction or a failure drew more than its one line:\n%s", text) + } + // THE PROGRAM SAID IT COMPACTED, SO THE CALLS DO NOT SAY IT AGAIN. + said := programPage(programRow(), turns) + said.Program.Actions = append(said.Program.Actions, delegate.Shown{At: at.Add(2 * time.Second), Text: actCompactedWord, Outcome: "kept its own record", Memory: true}) + b, _ := programPageApp(t, said, 80, 40) + if got := strings.Join(programPageLines(b), "\n"); strings.Count(got, actCompactedWord) != 1 || !strings.Contains(got, "kept its own record") { + t.Fatalf("a compaction the program reported is drawn %d times:\n%s", strings.Count(got, actCompactedWord), got) + } +} + +// A CHANGE OF THE MODEL ANSWERING IS ITS OWN LINE, WITH THE PROGRAM'S REASON. A +// model is named nowhere else on the page: the first model is not said at all, +// a later call answered by another is `switched to <model>`, the program's own +// line gives it its reason, and a move the program reported that the calls do +// not show is still drawn. A summary on a cheaper model is not a switch. +func TestAChangeOfModelIsItsOwnLineWithItsReason(t *testing.T) { + one, two := "deepseek/deepseek-v4-flash", "moonshotai/kimi-k3" + at := programRunBegan.Add(time.Minute) + turns := []delegate.Turn{ + {Seq: 1, Started: programRunBegan, Ended: programRunBegan.Add(time.Second), Model: one, Calls: []delegate.ToolUse{{Name: "read"}}}, + {Seq: 2, Started: at, Ended: at.Add(time.Second), Model: two, Calls: []delegate.ToolUse{{Name: "read"}}}, + {Seq: 3, Started: at.Add(2 * time.Second), Ended: at.Add(3 * time.Second), Model: "vendor/cheap-summary", Restarted: true}, + {Seq: 4, Started: at.Add(4 * time.Second), Ended: at.Add(5 * time.Second), Model: two, Restarted: true, Calls: []delegate.ToolUse{{Name: "read"}}}, + } + page := programPage(programRow(), turns) + page.Program.Actions = append(page.Program.Actions, + delegate.Shown{At: at.Add(time.Second), Text: "switched to kimi-k3", Model: "openrouter/moonshotai/kimi-k3", Reason: "the last one was rate-limited"}, + delegate.Shown{At: at.Add(10 * time.Minute), Text: "switched to glm-5.3-flash", Model: "openrouter/z-ai/glm-5.3-flash", Reason: "the last one was busy"}, + ) + page.Row.Status = "done" + a, _ := programPageApp(t, page, 100, 40) + text := strings.Join(programPageLines(a), "\n") + for _, want := range []string{ + actSwitchedWord + " kimi-k3 · the last one was rate-limited", + actSwitchedWord + " glm-5.3-flash · the last one was busy", + } { + if strings.Count(text, want) != 1 { + t.Fatalf("the page draws %q %d times, want once:\n%s", want, strings.Count(text, want), text) + } + } + for _, never := range []string{"deepseek", "cheap-summary", actSwitchedWord + " deepseek"} { + if strings.Contains(text, never) { + t.Fatalf("the page names %q:\n%s", never, text) + } + } +} + +// A LONG RUN'S PAGE SAYS HOW MANY ACTIONS IT LEAVES OUT, the ceiling beside the +// spend when the page knows it, and at forty columns every step's word stands +// on a line of its own with its actions hung under it — every row still inside +// the frame. +func TestAProgramsPageAtNarrowWidthAndWithEarlierActions(t *testing.T) { + page := programPage(programRow(), programTurns()) + page.Program.EarlierActions, page.Program.Calls, page.Program.CeilingUSD = 142, 145, 5 + a, _ := programPageApp(t, page, 40, 40) + lines := programPageLines(a) + text := strings.Join(lines, "\n") + if !strings.Contains(text, "142 "+actEarlierWord) { + t.Fatalf("the page does not say how many earlier actions it leaves out:\n%s", text) + } + if pinned := a.programPinned(page, 80, ""); !strings.HasPrefix(pinned, "implement · $1.24 of $5.00") { + t.Fatalf("the pinned line is %q, want the ceiling beside the spend", pinned) + } + // THE STEP WORDS STAND ON LINES OF THEIR OWN where the words beside them + // would have too little room. + stacked := strings.Join(a.programBody(page, 38, false, false), "\n") + if stacked = plain(stacked); !strings.Contains(stacked, "\nEXPLORE\n") || !strings.Contains(stacked, "\n read internal/auth/") { + t.Fatalf("in thirty-eight columns the step words do not stand on lines of their own:\n%s", stacked) + } + for i, line := range lines { + if cells := ansi.StringWidth(line); cells > 40 { + t.Fatalf("row %d is %d cells in a 40-cell frame: %q", i, cells, line) + } + } +} + +// A RUN FROM BEFORE THE ACTION LOG IS DRAWN IN THE SAME SHAPE FROM WHAT IT HAS: +// each tool its model asked for as an action, from its calls, and never an empty +// page for a run that has calls; and a run whose calls were never logged either +// draws the steps its program reported. +func TestARunFromBeforeTheActionLogIsDrawnFromItsCalls(t *testing.T) { + page := programPage(programRow(), programTurns()) + page.Program.Actions = nil + a, _ := programPageApp(t, page, 80, 30) + text := strings.Join(programPageLines(a), "\n") + for _, want := range []string{"read internal/auth/middleware.go", "searched SessionStore", "edited internal/auth/middleware.go"} { + if !strings.Contains(text, want) { + t.Fatalf("a run with no action log does not draw %q from its calls:\n%s", want, text) + } + } + older := programPage(programRow(), nil) + older.Program.Actions, older.Program.Calls = nil, 0 + older.Steps = []session.PlanStep{{Step: 1, Command: "bash: go test ./..."}} + b, _ := programPageApp(t, older, 80, 30) + if text := strings.Join(programPageLines(b), "\n"); !strings.Contains(text, "bash: go test ./...") || strings.Contains(text, "steps") { + t.Fatalf("a run with neither log does not draw its steps as actions:\n%s", text) + } +} + +// A LONG RUN FROM BEFORE THE ACTION LOG SAYS HOW MANY CALLS ITS PAGE LEAVES OUT. +// Its page is drawn from the newest calls the store carries, so the count of the +// ones it cut is a count of calls, and it stands between the brief and the +// first action drawn from them — without it the page read as though the run +// began at the first call it kept. A page whose actions were logged counts its +// actions there and never its calls. +func TestALongRunFromBeforeTheActionLogSaysHowManyCallsItLeavesOut(t *testing.T) { + page := programPage(programRow(), programTurns()) + page.Program.Actions, page.Program.Earlier, page.Program.Calls = nil, 150, 153 + page.Row.Status = "done" + a, _ := programPageApp(t, page, 80, 40) + lines := programPageLines(a) + text := strings.Join(lines, "\n") + fold, first := -1, -1 + for i, line := range lines { + if fold < 0 && strings.Contains(line, "150 "+convEarlierWord) { + fold = i + } + if first < 0 && strings.Contains(line, "read internal/auth/middleware.go") { + first = i + } + } + if fold < 0 || first < 0 || fold > first || strings.Contains(text, actEarlierWord) { + t.Fatalf("a run drawn from its calls does not count the 150 calls it leaves out above its first action:\n%s", text) + } + logged := programPage(programRow(), programTurns()) + logged.Program.Earlier = 150 + b, _ := programPageApp(t, logged, 80, 40) + if text := strings.Join(programPageLines(b), "\n"); strings.Contains(text, convEarlierWord) { + t.Fatalf("a page drawn from its action log counts the calls it leaves out:\n%s", text) + } +} + +// THE RAW CALLS ARE ONE KEY AWAY. The key row names the key; the key turns the +// page to the dialogue between the program and its model — the model named, its +// words and its calls — and the key row then names the way back; the same key +// turns it back to the actions. +func TestTheRawCallsAreOneKeyAway(t *testing.T) { + a, _ := programPageApp(t, programPage(programRow(), programTurns()), 80, 30) + // The key row is the room's foot, under the box. + keys := func() string { frame, _, _ := a.frame(); return plain(frame) } + page := strings.Join(programPageLines(a), "\n") + if !strings.Contains(keys(), programCallsWord) || strings.Contains(page, "I'll read the middleware") { + t.Fatalf("the actions do not offer the calls, or draw them:\n%s", page) + } + drive(t, a, key(programCallsKey)) + lines := programPageLines(a) + calls := strings.Join(lines, "\n") + if !saidBy(lines, "deepseek-v4-flash", "I'll read the middleware and the store first.") || !strings.Contains(keys(), programActionsWord) { + t.Fatalf("the key did not turn the page to its calls:\n%s", calls) + } + drive(t, a, key(programCallsKey)) + if back := strings.Join(programPageLines(a), "\n"); strings.Contains(back, "I'll read the middleware") || !strings.Contains(back, "EXPLORE") { + t.Fatalf("the key did not turn the page back to its actions:\n%s", back) + } +} + +// DRAWING THE PAGE READS NOTHING. However many frames are drawn, as actions or +// as calls, the surface asks the store for no page and no row it did not ask for +// on its beat. +func TestDrawingAProgramsPageReadsNothing(t *testing.T) { + row := programRow() + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) + counted := &railPlanCounter{planFake: fake} + a.agent = counted + a.width, a.height = 80, 30 + openPlanPage(t, a) + rows, pages := counted.rows, counted.pages + for i := 0; i < 3; i++ { + programPageLines(a) + a.programOf().calls = !a.programOf().calls + } + if counted.rows != rows || counted.pages != pages { + t.Fatalf("drawing the page read the agent: rows %d→%d, pages %d→%d", rows, counted.rows, pages, counted.pages) + } +} + +// THE RAIL ROW OF A PROGRAM'S RUN SAYS ITS STAGE AND WHAT IT HAS SPENT SO FAR, +// where it used to say only its clock. Both come off the run's plan row the +// surface already holds, never off a read the frame makes. +func TestTheRailRowOfAProgramsRunSaysItsStageAndSpend(t *testing.T) { + row := programRow() + row.ID = "7" + a, fake := planAppWith(t, []session.PlanTaskRow{row}, nil) + counted := &railPlanCounter{planFake: fake} + a.agent = counted + a.width, a.height = 120, 30 + drive(t, a, streamEventMsg{gen: a.gen, ev: update(7, row.Title, session.TaskRunning, session.TaskNotice{StartedAt: programRunBegan})}) + node := a.tasks[7] + if node == nil { + t.Fatal("the run's row never reached the rail") + } + reads := counted.rows + under := plain(strings.Join(a.railUnder(node, 40), "\n")) + if !strings.Contains(under, "implement") || !strings.Contains(under, "$1.24") { + t.Fatalf("the rail row of a program's run reads %q, want its stage and its spend", under) + } + if counted.rows != reads || counted.pages != 0 { + t.Fatalf("drawing the rail row read the agent: rows %d→%d, pages %d", reads, counted.rows, counted.pages) + } + // A RUN NO PROGRAM WAS HANDED IS UNCHANGED. + row.Program, row.Stage = "", "" + fake.plan[0] = row + a.planRows = fake.plan + plainUnder := plain(strings.Join(a.railUnder(node, 40), "\n")) + if strings.Contains(plainUnder, "implement") || strings.Contains(plainUnder, "$1.24") { + t.Fatalf("an ordinary run's rail row reads %q, which is a program's", plainUnder) + } +} + +// A PROGRAM'S PLAN ROW DRAWS ITS STAGE, NEVER A COMMAND. The worker publishes a +// program's phase on the live row a bash worker publishes its command on, and +// behind the shell's `$` it read as a command somebody typed. +func TestAProgramsPlanRowDrawsItsStageAndNotACommand(t *testing.T) { + text := planTextFor(t, []session.PlanTaskRow{programRow()}) + if strings.Contains(text, "$ senior-dev") || strings.Contains(text, "running · running") { + t.Fatalf("a program's plan row drew its stage as a command:\n%s", text) + } + if !strings.Contains(text, "implement") { + t.Fatalf("a program's plan row does not name its stage:\n%s", text) + } +} + +// A PROGRAM'S RUN IS OFFERED NO TAB, SO NO TAB OFFERS IT A BOX. The run's tab +// used to open the stored page with the tab's own keyboard, which had to be +// kept from drawing a box for a program; a program's run has no tab now, and +// its task opens in the conversation's own tab, whose box sends a program +// nothing ([TestAProgramsRoomSendsNothingAndSaysSo]). Its held rows are no work +// tab's rows either, so the tab cannot be opened on them by any door. +func TestAProgramsRunOpensNoWorkTab(t *testing.T) { + row := programRow() + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) + a.width, a.height = 120, 28 + a.taskSheet.mine.plan = fake.plan + if tab, ok := a.workTab(); ok { + t.Fatalf("the program's run is offered a tab of its own, %q", tab.word) + } + if cmd := a.openWorkTab(); cmd != nil || a.roomPlan() != nil { + t.Fatal("the work tab opened on a program's run") + } +} + +// A PROGRAM'S WORDS ARE ONE CLEAN ROW. What a program sends is a tool's raw +// output: the first line that says anything is drawn, with every escape +// sequence a terminal would obey taken out, a tab as a space, and no other +// control character left in it. +func TestAProgramsWordsAreOneCleanRow(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"\n\n \x1b[31mFAIL\x1b[0m\tpkg\x07 0.3s\nmore", "FAIL pkg 0.3s"}, + {"\x1b]0;a title\x07ok", "ok"}, + {"\r\n\r\nplain\r\n", "plain"}, + {"", ""}, + } { + if got := convHead(tc.in); got != tc.want { + t.Errorf("convHead(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// THE ARGUMENTS SAY WHAT A CALL WAS ABOUT, read forgivingly: the most telling +// argument, then the first string there is, a line cut inside a string read to +// its cut, and a line that is not an object drawn as written. +func TestACallsArgumentsAreReadForWhatTheCallWasAbout(t *testing.T) { + for _, tc := range []struct{ args, want string }{ + {`{"command":"go test ./...","description":"Runs tests"}`, "go test ./..."}, + {`{"description":"Runs tests","command":"go vet ./..."}`, "go vet ./..."}, + {`{"filePath":"internal/auth/middleware.go","oldString":"func M`, "internal/auth/middleware.go"}, + {`{"oldString":"a \"quoted\" line\nand more`, `a "quoted" line and more`}, + {`{"limit":5,"name":"x"}`, "x"}, + {`{"limit":5}`, ""}, + {`go test ./...`, "go test ./..."}, + {`{"url":"https://example.com/a:b"}`, "https://example.com/a:b"}, + } { + if got := convCallAbout(tc.args); got != tc.want { + t.Errorf("convCallAbout(%s) = %q, want %q", tc.args, got, tc.want) + } + } +} + +// EVERY DOOR INTO A PROGRAM'S TASK OPENS ITS ACTIONS, AS A ROOM. The card +// in the conversation, a transcript link, the task strip and the home panel all +// come through [app.openRoomFor], which used to open an ordinary room — a blank +// page, because a program has no worker transcript — and then a full-frame page +// over the conversation with no tab strip. A held row that names its program +// opens the program's room at once, inside the conversation's tab, with the +// program's actions as its body — whichever way the row's id is spelled: +// the store answers `t-7`, and a comparison against the bare number missed +// every real row. +func TestEveryDoorIntoAProgramsTaskOpensItsActions(t *testing.T) { + for _, id := range []string{"7", "t-7"} { + t.Run(id, func(t *testing.T) { + row := programRow() + row.ID = id + a, fake := planAppWith(t, []session.PlanTaskRow{row}, map[string]session.PlanTaskPage{"7": programPage(row, programTurns())}) + a.width, a.height = 120, 28 + a.openRoomFor(7, row.Title) + if a.programOf() == nil { + t.Fatalf("the door did not open the program's room: room=%v", a.room != nil) + } + cmd := a.takeRoomPump() + if cmd == nil { + t.Fatal("nothing asked the store for the program's page") + } + drain(t, a, cmd) + if len(fake.noted) != 0 { + t.Fatalf("opening the room wrote notes %v", fake.noted) + } + if text := roomText(a); !strings.Contains(text, "wrote your brief down as its spec") { + t.Fatalf("the room does not show the program's actions:\n%s", text) + } + }) + } +} + +// AND A TASK THAT IS NOT A PROGRAM'S STILL OPENS ITS ROOM, at once and without +// asking the store: the redirect is for a program's run and no other. +func TestADoorIntoAnOrdinaryTaskStillOpensItsRoom(t *testing.T) { + row := programRow() + row.ID, row.Program, row.Stage = "7", "", "" + a, _ := planAppWith(t, []session.PlanTaskRow{row}, nil) + if a.programTask(7) { + t.Fatal("an ordinary task was taken for a program's") + } + a.openRoomFor(7, row.Title) + if a.railPlanPending.id != "" { + t.Fatal("an ordinary task's door asked the store for a page instead of opening its room") + } +} + +// A PROGRAM'S RUNS ARE NO TAB, AND A BELT RUN BESIDE THEM KEEPS ITS OWN. With +// two of senior-dev's runs in one conversation the strip used to offer a tab +// named after one of them; a program's task opens in the conversation's own +// tab now, so neither is a tab — and a run the belt switch drives beside them +// is still the tab, named after itself and never after a program's run. +func TestAProgramsRunsAreNoTabAndABeltRunKeepsItsOwn(t *testing.T) { + landed := programRow() + landed.ID, landed.Title, landed.Status, landed.Stage = "t-1", "Implement true-myth", "done", "" + working := programRow() + working.ID, working.Title = "t-2", "Implement happy-dom" + a, fake := planAppWith(t, []session.PlanTaskRow{landed, working}, nil) + a.width, a.height = 120, 30 + a.taskSheet.mine.plan = fake.plan + if tab, ok := a.workTab(); ok { + t.Fatalf("a program's run is offered a tab, %q", tab.word) + } + belt := session.PlanTaskRow{ID: "t-3", Title: "Fix the flake", Status: "running"} + a.taskSheet.mine.plan = append(append([]session.PlanTaskRow(nil), fake.plan...), belt) + if tab, ok := a.workTab(); !ok || tab.word != belt.Title { + t.Fatalf("the belt run's tab is %q, want %q", tab.word, belt.Title) + } +} + +// A ROOM OPENED ON A PROGRAM'S TASK BECOMES THE PROGRAM'S ROOM. The sessions +// place brings a conversation forward and reopens the room it was aimed at +// before that conversation's rows are read, so the row check at the door cannot +// see the program; the room asks the store itself, and becomes the program's +// room — still a room in the conversation's tab, never a page drawn over it. +func TestARoomOpenedOnAProgramsTaskBecomesItsRoom(t *testing.T) { + row := programRow() + row.ID = "7" + a, _ := planAppWith(t, nil, map[string]session.PlanTaskPage{row.ID: programPage(row, programTurns())}) + a.width, a.height = 120, 28 + a.room = a.newRoom(7, row.Title) + cmd := a.roomProgramCheck(7) + if cmd == nil { + t.Fatal("the room did not ask whether its task is a program's") + } + drive(t, a, cmd()) + if a.programOf() == nil || a.room.id != 7 { + t.Fatalf("the room did not become the program's room: room=%v", a.room != nil) + } + if text := roomText(a); !strings.Contains(text, "wrote your brief down as its spec") { + t.Fatalf("the room does not show the program's actions:\n%s", text) + } + // AND AN ORDINARY TASK KEEPS ITS ROOM. + plain := row + plain.Program, plain.Stage = "", "" + b, _ := planAppWith(t, nil, map[string]session.PlanTaskPage{"8": {Row: plain}}) + b.room = b.newRoom(8, "ordinary") + drive(t, b, b.roomProgramCheck(8)()) + if b.room == nil || b.programOf() != nil { + t.Fatal("an ordinary task's room was changed") + } +} diff --git a/internal/tui3/taskdone.go b/internal/tui3/taskdone.go index 9adefb7caf..7df8570343 100644 --- a/internal/tui3/taskdone.go +++ b/internal/tui3/taskdone.go @@ -51,6 +51,11 @@ import ( type taskDone struct { id uint64 ident taskIdent + // program is the program the work was handed to, "" for codeaf's own. + // Its ending is the conversation's to act on (session's program_outcome.go), + // so its card says it ended and that the chat has the rest + // ([doneProgramUnder]), never the program's own status. + program string // title and subtitle are the identity (taskident.go), frozen at landing. title, subtitle string // status is THE READING, taken once at landing from the node's own facts @@ -225,10 +230,11 @@ func (a *app) landedCard(node *taskNode) { card := &taskDone{ id: node.id, ident: node.ident, + program: a.nodeProgram(node), title: title, subtitle: taskSubtitleOf(title, node.assignment), status: session.ProjectTask(doneNodeFacts(node)), - span: node.elapsed, + span: node.ranFor(), started: node.spawnedAt(), landed: landed, outcome: firstProseLine(node.report), @@ -483,6 +489,11 @@ func (a *app) doneMark(card *taskDone) string { // A person's own stop is not a finding, so it is neither a tick nor a cross. return a.pal.dim(mark) case session.TaskPresenceIncomplete: + // A PROGRAM'S ENDING IS NEVER PAINTED AS A FAULT: the chat acts on it + // and says what became of the work. + if card.program != "" { + return a.pal.dim(mark) + } // THE CROSS IS DIM UNLESS SOMETHING BROKE. Running out of steps, losing the // wire and a check that named gaps are all work that did not finish, and // colouring them as failures reports a fault nobody found @@ -511,6 +522,9 @@ func (a *app) doneMark(card *taskDone) string { func (a *app) doneTail(card *taskDone) string { tail := "" if word := strings.TrimSpace(card.status.Word); word != "" { + if card.program != "" && card.status.Presence == session.TaskPresenceIncomplete { + word = doneProgramEnded + } tail = " · " + word } // ONE SEPARATOR MEANS ONE THING ON THIS ROW. The span used to be joined to @@ -629,6 +643,9 @@ func (a *app) doneUnder(card *taskDone, width int) string { } return "" } + if card.program != "" { + return a.doneProgramUnder(card, width) + } // AND AN INCOMPLETE LANDING'S SECOND ROW IS WHY, dim, in the engine's own // sentence ([session.TaskReasonOf] spells the table once). It stands INSTEAD // of the quoted report and never beside it: two accounts of one landing on one @@ -1246,3 +1263,23 @@ func (a *app) rollupRow(card *taskDone, width int, sel bool) string { } return lead + a.pal.ink(fit(card.title, room)) + a.pal.dim(tail) } + +// doneProgramEnded is the head's word for a program's run that did not finish: +// it ended, and what became of the work is the chat's to say. +const doneProgramEnded = "ended" + +// doneProgramUnder is a program's card's second row: that its ending went to +// the chat, which acts on it and says where the work stands, and where the +// whole of it is. THE PROGRAM'S STATUS IS NOT ON IT. It is codeaf's to act on +// (session's program_outcome.go), and the person reads the chat's summary of +// what came of it; the program's own words are one key away. +func (a *app) doneProgramUnder(card *taskDone, width int) string { + said := card.program + "'s ending went to the chat" + if !card.started.IsZero() { + said += " · " + doneStartWord + card.started.Format("15:04") + } + if a.doneHasDetail(card) && !card.open { + said += " · " + doneOutputKey + } + return a.pal.dim(" " + fit(said, width-4)) +} diff --git a/internal/tui3/taskending.go b/internal/tui3/taskending.go index ec9676155d..2568df5ee0 100644 --- a/internal/tui3/taskending.go +++ b/internal/tui3/taskending.go @@ -34,6 +34,7 @@ const ( endingWordRefused = "not accepted" endingWordStale = "its world did not match" endingWordError = "ended with an error" + endingWordProgram = "did not finish" ) // endingWord is the two-or-three-word reason a failed node's row leads with, @@ -61,6 +62,8 @@ func endingWord(ending session.TaskEnding) string { return endingWordStale case session.TaskEndingError: return endingWordError + case session.TaskEndingProgram: + return endingWordProgram } return "" } diff --git a/internal/tui3/taskguest_test.go b/internal/tui3/taskguest_test.go index 2d18c852be..41c1519bc7 100644 --- a/internal/tui3/taskguest_test.go +++ b/internal/tui3/taskguest_test.go @@ -74,6 +74,13 @@ type guestDoor struct { asking chan session.Event // leftAsking counts that lane's way out being taken. leftAsking int + // pages is the OWNER'S STORE, keyed by the id a page is read by, and nil + // until a test arms it — a door that cannot read the owner's task pages is a + // real door ([TaskOwnerView.TaskPage]). pageErr is what that read answers + // with instead, and pageAsked is every id it was asked for. + pages map[string]session.PlanTaskPage + pageErr error + pageAsked []string } // watching arms this door with the owner's task lane and hands the test the end @@ -123,6 +130,16 @@ func (d *guestDoor) open(ask TaskOwnerAsk) (TaskOwnerView, error) { } } } + if d.pages != nil { + view.TaskPage = func(id string) (session.PlanTaskPage, bool, error) { + d.pageAsked = append(d.pageAsked, id) + if d.pageErr != nil { + return session.PlanTaskPage{}, false, d.pageErr + } + page, found := d.pages[id] + return page, found, nil + } + } if d.asking != nil { lane := d.asking view.Questions = func() (<-chan session.Event, func()) { diff --git a/internal/tui3/taskguestprogram_test.go b/internal/tui3/taskguestprogram_test.go new file mode 100644 index 0000000000..ec4d4ec2c9 --- /dev/null +++ b/internal/tui3/taskguestprogram_test.go @@ -0,0 +1,261 @@ +package tui3 + +// ── A PROGRAM'S TASK READ THROUGH SOMEBODY ELSE'S CONVERSATION ────────────── +// +// A task handed to senior-dev has no worker journal: what the program did is +// its conversation with codeaf, on the task's stored page, and the page a +// person reads is its actions (taskconversation.go). A guest page onto such a +// task used to read the one thing it knew how to read — the owner's journal — +// and drew a header wearing `[senior-dev]` over a body with nothing in it. +// +// These tests hold the page to the program's room this window draws for its +// own run, READ-ONLY: the actions under their steps, `ctrl+y` to the raw calls, +// no stop, no steer, and every read made through the owner's view and never +// through this window's own store, whose task of the same number is different +// work. + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// enterAwayPumped presses the away row and settles the attach AND the page's +// first reads, which [enterAway] leaves unrun. +func enterAwayPumped(t *testing.T, a *app) { + t.Helper() + awayRowOf(t, a) + cmd := a.taskSheetEnter() + if cmd == nil { + t.Fatal("enter over another window's running work did nothing at all") + } + msg, ok := cmd().(taskOwnerMsg) + if !ok { + t.Fatalf("enter did not ask the engine for the owner: %T", cmd()) + } + drain(t, a, a.tookTaskOwner(msg)) +} + +// guestProgramLab is [guestLab] with the owner's task 7 handed to senior-dev: +// its store answers the program's page for it. +func guestProgramLab(t *testing.T) (*app, *guestDoor) { + t.Helper() + a, door := guestLab(t) + door.pages = map[string]session.PlanTaskPage{"7": programPage(programRow(), programTurns())} + return a, door +} + +// A PROGRAM'S TASK OPENED FROM ANOTHER WINDOW IS ITS ACTIONS, and the page is +// read-only exactly as every other guest page is. +func TestAGuestPageOntoAProgramsTaskDrawsItsActions(t *testing.T) { + a, door := guestProgramLab(t) + enterAwayPumped(t, a) + + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if a.programOf() == nil { + t.Fatalf("a program's task opened from another window is not its actions:\n%s", roomText(a)) + } + // THE OWNER'S STORE WAS ASKED, BY THE TASK'S OWN NUMBER. + if len(door.pageAsked) == 0 || door.pageAsked[0] != "7" { + t.Fatalf("the owner's store was asked for %v, want task 7", door.pageAsked) + } + lines := strings.Split(roomText(a), "\n") + if !underStep(lines, "explore", "read internal/auth/middleware.go") { + t.Fatalf("the page does not draw the program's reading under EXPLORE:\n%s", strings.Join(lines, "\n")) + } + if !strings.Contains(roomText(a), programTabSaid) { + t.Fatalf("the page does not open on the actions:\n%s", roomText(a)) + } + // AND IT STOPS ASKING FOR A JOURNAL THE PROGRAM NEVER WROTE. + if a.readRoomRecord() != nil { + t.Fatal("a program's guest page still reads the owner's journal on its beat") + } + if a.room.loading { + t.Fatal("a program's guest page still says it is loading a conversation") + } + + // THE PAGE STAYS A READING. No stop, no steer, and the box says what it is. + if !a.stopHere().empty() { + t.Fatal("a program's guest page offers a stop, which would end this window's own task 7") + } + box, _, _ := a.inputBlock(80) + if lane := plain(strings.Join(a.roomSteerLaneRows(box, 80), "")); !strings.Contains(lane, roomGuestLane) { + t.Fatalf("a program's guest page's box offers %q", lane) + } + a.input.setText("pause it") + a.steer() + if got := a.input.String(); got != "pause it" { + t.Fatalf("a program's guest page spent the words: %q", got) + } + if body := roomText(a); !strings.Contains(body, roomGuestReadingWord) { + t.Fatalf("enter on a program's guest page does not say it is reading:\n%s", body) + } + if trail := a.roomTrail(); !strings.HasPrefix(trail, roomGuestOwnerWord) { + t.Fatalf("the page's trail hangs off this conversation: %q", trail) + } + + // `ctrl+y` TURNS IT TO THE RAW CALLS AND BACK, AND THE KEY ROW SAYS SO. + a.input.setText("") + if hint := a.roomHint(); hint != programCallsWord { + t.Fatalf("the key row reads %q, want %q", hint, programCallsWord) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); !strings.Contains(text, "I'll read the middleware and the store first.") { + t.Fatalf("ctrl+y did not turn the page to the calls:\n%s", text) + } + drive(t, a, key(programCallsKey)) + if text := roomText(a); !strings.Contains(text, programTabSaid) { + t.Fatalf("ctrl+y did not turn the page back to the actions:\n%s", text) + } + + // THE LOCAL TASK 7 IS NOT A PROGRAM'S AND WAS NOT TOUCHED. + if node := a.tasks[7]; node == nil || node.state != session.TaskRunning || node.program != "" { + t.Fatalf("the local task 7 was changed by a page that was never about it: %+v", node) + } + a.closeRoom() + if door.closed != 1 { + t.Fatalf("closing the page released the view %d times", door.closed) + } +} + +// AN ORDINARY TASK OPENED FROM ANOTHER WINDOW IS THE PAGE IT ALWAYS WAS. The +// owner's store answering a page with no program on it — or a door with no +// store reader at all — leaves the journal reading exactly where it was. +func TestAGuestPageOntoAnOrdinaryTaskIsUnchanged(t *testing.T) { + for _, tc := range []struct { + name string + pages map[string]session.PlanTaskPage + }{ + {"no store reader", nil}, + {"nothing stored", map[string]session.PlanTaskPage{}}, + {"stored without a program", map[string]session.PlanTaskPage{"7": {Row: session.PlanTaskRow{ID: "t-7", Title: "Port the parser", Status: "running"}}}}, + } { + t.Run(tc.name, func(t *testing.T) { + a, door := guestLab(t) + door.pages = tc.pages + enterAwayPumped(t, a) + if !a.roomIsGuest() { + t.Fatal("the row opened no reading page") + } + if a.programOf() != nil { + t.Fatal("an ordinary task opened from another window was turned into a program's page") + } + if door.read == 0 { + t.Fatal("an ordinary guest page did not read the owner's journal") + } + if a.readRoomRecord() == nil { + t.Fatal("an ordinary guest page stopped reading the owner's journal") + } + if body := roomText(a); !strings.Contains(body, roomGuestStaleWord) { + t.Fatalf("an ordinary guest page with no owner lane lost its caveat:\n%s", body) + } + }) + } +} + +// A PROGRAM'S GUEST PAGE WHOSE CONVERSATION WAS REPLACED KEEPS WHAT IT READ AND +// STOPS, as every guest page does: the engine's refusal arrives on the store's +// read instead of the journal's, and it is the same final answer. +func TestAProgramsGuestPageWhoseConversationWasReplacedStops(t *testing.T) { + a, door := guestProgramLab(t) + door.watching() + enterAwayPumped(t, a) + if a.programOf() == nil { + t.Fatal("no program's guest page to lose") + } + door.pageErr = errors.New("engine: that conversation is not open here any more") + a.programOf().readAt = a.now().Add(-elsewhereEvery) + drain(t, a, a.programRoomRead()) + + if !a.room.guest.lost { + t.Fatal("the page did not take the engine's final answer") + } + if door.left != 1 { + t.Fatalf("the owner's lane was released %d times at the final answer, want once", door.left) + } + body := roomText(a) + if !strings.Contains(body, taskGuestGoneWord) || !strings.Contains(body, programTabSaid) { + t.Fatalf("the lost page does not keep what it read and say what happened:\n%s", body) + } + asked := len(door.pageAsked) + if cmd := a.programRoomRead(); cmd != nil { + drain(t, a, cmd) + } + if len(door.pageAsked) != asked { + t.Fatal("a lost page went on asking the owner's store") + } + a.closeRoom() + if door.left != 1 || door.closed != 1 { + t.Fatalf("leaving released the lane %d times and the connection %d times, want once each", door.left, door.closed) + } +} + +// THE OWNER'S NOTICE SETTLES A PROGRAM'S GUEST PAGE, and the foot names the +// owner rather than this window's main. +func TestAProgramsGuestPageSettlesOnTheOwnersWord(t *testing.T) { + a, door := guestProgramLab(t) + door.watching() + enterAwayPumped(t, a) + if a.programOf() == nil { + t.Fatal("no program's guest page") + } + // THE STORE HAS ENDED THE RUN BY THE TIME THE OWNER SAYS SO, and the page + // reads it once more at that moment: the landing is on that last page. + landed := programRow() + landed.Status = "done" + door.pages["7"] = programPage(landed, programTurns()) + asked := len(door.pageAsked) + drain(t, a, ownerSays(t, a, session.Event{ + Kind: session.EventTaskUpdate, + Task: &session.TaskNotice{ID: 7, Title: "rewrite the auth middleware", State: session.TaskDone, Program: "senior-dev"}, + })) + if !a.room.done { + t.Fatal("the owner said its work landed and the page went on saying it was running") + } + if len(door.pageAsked) != asked+1 || a.programOf().page.Row.Status != "done" { + t.Fatalf("the landing was not read from the owner's store: %d reads after it, page %q", + len(door.pageAsked)-asked, a.programOf().page.Row.Status) + } + body := roomText(a) + if !strings.Contains(body, refusalOwnerLead+"docs pass") || strings.Contains(body, refusalMainDoor) { + t.Fatalf("the landed program's guest page does not name the owner's door:\n%s", body) + } + a.closeRoom() +} + +// A WINDOW ON THE ENGINE ROAD DRAWS THE OTHER CONVERSATIONS' WORK AND OPENS IT. +// Bare `codeaf` holds a connection to its engine, and a connection has no +// reading of the disk ([elsewhereAgent] is the agent's own), so the rows of work +// another conversation was running were never drawn there — and the door behind +// them, [app.openOwnerRoom], could not be reached from any real window. The +// launch now hands the surface the reading ([Options.Elsewhere]), asked with the +// transcript this window is drawing, and the row opens its reading page. +func TestAnEngineWindowReadsTheOtherConversationsOffTheDiskAndOpensThem(t *testing.T) { + a, door := guestLab(t) + door.pages = map[string]session.PlanTaskPage{"7": programPage(programRow(), programTurns())} + // NOTHING READ YET, and the agent under this window answers no reading. + a.away = elsewhereCache{} + if _, answers := a.agent.(elsewhereAgent); answers { + t.Fatal("the fixture's agent reads the disk itself, so this test would prove nothing") + } + var asked []string + a.elsewhereOf = func(file string, now time.Time) session.Elsewhere { + asked = append(asked, file) + return session.NewElsewhere(now, map[string]string{"the-other-window": "docs pass"}, + window("the-other-window", session.PresenceTask{ + ID: "7", Title: "Port the parser", State: string(session.TaskRunning)})) + } + enterAwayPumped(t, a) + if len(asked) == 0 || asked[0] != a.file { + t.Fatalf("the reading was asked for %q, want this window's own transcript %q", asked, a.file) + } + if !a.roomIsGuest() || a.programOf() == nil { + t.Fatalf("the other conversation's program task did not open its reading page:\n%s", roomText(a)) + } + a.closeRoom() +} diff --git a/internal/tui3/taskmention.go b/internal/tui3/taskmention.go index c96ac0a642..ad1478abf7 100644 --- a/internal/tui3/taskmention.go +++ b/internal/tui3/taskmention.go @@ -46,6 +46,7 @@ import ( "time" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/session" ) @@ -171,6 +172,14 @@ func (a *app) tasksLoaded(rows []session.TaskIndexEntry, known ...bool) tea.Cmd // the far world. It does not invent live controls: these nodes remain records, // and the room behind one is read-only because the remote agent deliberately // implements none of the local room-action interfaces. +// +// A LIVE ROW IS A STALE ROW ONCE THE STREAM HAS LANDED ITS NODE. The far world +// is the one this window last fetched, and a run's row reaches its index as +// `running` at the hand-off: the roster read a landing's own notice asks for +// adopted that row, put the landed run back to running with no age and its +// spend from before the landing, and its clock climbed with no end. So a live +// row never touches a node that has settled, and its age — the node's age at +// the instant the row was built — is never taken for how long the work ran. func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { if a.tasks == nil { a.tasks = map[uint64]*taskNode{} @@ -181,6 +190,9 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { continue } node := a.tasks[id] + if node != nil && row.Live() && farNodeSettled(node) { + continue + } if node == nil { node = &taskNode{id: id, ident: identFor(id), met: row.EndedAt} a.tasks[id] = node @@ -190,7 +202,11 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { node.label = firstNonEmpty(strings.TrimSpace(row.Title), strings.TrimSpace(row.Label)) node.title = taskTitleOf(node.label, "", id) node.state = session.TaskState(row.Status) - node.elapsed = time.Duration(row.DurationMS) * time.Millisecond + if row.Live() { + a.anchorFarLiveNode(node, row) + } else { + node.elapsed = time.Duration(row.DurationMS) * time.Millisecond + } node.cost, node.tokens, node.model = row.Cost, row.Tokens, strings.TrimSpace(row.Model) node.report, node.changed = strings.TrimSpace(row.Outcome), append([]string(nil), row.Files...) node.transcript = strings.TrimSpace(row.TranscriptURI) @@ -203,6 +219,31 @@ func (a *app) adoptFarTaskRows(rows []session.TaskIndexEntry) { } } +// farNodeSettled is whether a node this window holds has landed, whichever +// road told it so. A node with no state yet is not settled: nothing has said +// anything about its work. +func farNodeSettled(node *taskNode) bool { + return node.state != "" && node.state != session.TaskRunning && node.state != session.TaskQueued +} + +// anchorFarLiveNode gives a node adopted from a live far row the instant its +// clock counts from, when the stream has not already given it one: the row's +// own start, which does not go stale the way the age it carries does +// ([session.TaskIndexEntry.Duration]). A start later than this window's clock +// is another machine's clock running ahead, and the node counts from now. +// Without an anchor the side list counted from the zero instant. +func (a *app) anchorFarLiveNode(node *taskNode, row session.TaskIndexEntry) { + if !node.began.IsZero() || row.StartedAt.IsZero() { + return + } + now := a.now() + if row.StartedAt.After(now) { + node.began = now + return + } + node.began = row.StartedAt +} + // refreshTasks says the snapshot is stale and reads it again if anybody is // looking. It is called where a node changes state (task.go): a task that has // just started belongs under "running" the next time the list is opened, and a @@ -374,14 +415,39 @@ const ( // // › ✓ ⧉ Fix the nil-map crash 3h // ◐ ⧉ Sweep the deprecated call sites 4m -func taskRowLabel(entry session.TaskIndexEntry, pal palette) string { +// ◐ ⧉ Rewrite the auth middleware [senior-dev] 9m +// +// A PROGRAM'S WORK SAYS WHOSE IT IS, with the badge its row wears on the side +// list (programbadge.go) after the words. It is the brackets alone here and not +// the badge's ink, for this list's own reason: the overlay paints a row by what +// it IS — under the cursor, hovered, dim — and a word carrying colour of its own +// would fight that paint, so the badge says itself in the row's ink like every +// other word on it. +// +// AND IT IS PAID FOR OUT OF THE WORDS. The row cuts a label from its right to +// keep the note at its edge, so the label is fitted here first, to the room the +// row will give it beside note in width cells ([overlayLabelRoom]): the badge +// keeps its long spelling while the words keep [railTitleFloor] cells, its short +// one after that, and the words are cut into what is left. An ordinary task's +// row is handed over exactly as it always was. +func taskRowLabel(entry session.TaskIndexEntry, note string, width int, pal palette) string { // Label is the title already cut to a row's width (session.taskLabel), and // the uncut title stands in for a row written before that field existed. words := entry.Label if words == "" { words = entry.Title } - return taskStatusGlyph(entry, pal) + " " + mentionMark(pal.ascii) + " " + words + lead := taskStatusGlyph(entry, pal) + " " + mentionMark(pal.ascii) + " " + badge := programBadge(entry.Program) + if !badge.known() || strings.TrimSpace(words) == "" { + return lead + words + } + room := overlayLabelRoom(lead+words+" "+badge.full, note, width) - ansi.StringWidth(lead) + spelling := programSpelling(badge, words, room, railTitleFloor) + if spelling == "" { + return lead + words + } + return lead + fit(words, room-programCells(spelling)) + " " + spelling } func mentionMark(ascii bool) string { @@ -550,6 +616,13 @@ func mentionTokens(text string) []string { // offer nobody can take, because there is nothing left running to send words to. func taskPointerBlock(entry session.TaskIndexEntry) string { head := "[Task reference: " + entry.Title + " — id " + entry.ID + " · " + entry.Status + // A PROGRAM'S WORK SAYS WHICH PROGRAM HAD IT, in the word the model hands + // work to one with (`propose_task`'s `via`), because the model reading this + // block is deciding what to say about work the person can see was not its + // own worker's. + if program := strings.TrimSpace(entry.Program); program != "" { + head += " · via " + program + } if when := mentionWhenWord(entry); when != "" { head += " · " + when } @@ -576,7 +649,12 @@ func taskPointerBlock(entry session.TaskIndexEntry) string { if entry.TranscriptURI != "" { where = append(where, "Transcript: "+entry.TranscriptURI) } - if entry.Live() { + if program := strings.TrimSpace(entry.Program); program != "" && entry.Live() { + // A PROGRAM'S RUNNING WORK OFFERS NO STEER, because the program reads no + // messages ([programRoomNoMessages]) and a `say` would be refused. The + // one door it has is the stop. + where = append(where, program+programRoomNoMessages+`; stop it with tasks id `+entry.ID+` stop`) + } else if entry.Live() { // TWO DOORS ON ONE RUNNING NODE, and the block is read by the model, so // it names the model's first: `tasks id N say "…"` reaches the node's // loop exactly as the person's own line does (session.SteerTask). The diff --git a/internal/tui3/taskmodel_test.go b/internal/tui3/taskmodel_test.go index ab4d71bc30..0eb6928767 100644 --- a/internal/tui3/taskmodel_test.go +++ b/internal/tui3/taskmodel_test.go @@ -131,7 +131,8 @@ func TestTheModelFollowsTheNodeOntoTheRailAndTheLandedCard(t *testing.T) { t.Fatalf("the node did not keep its model: %+v", node) } // THE MODEL NEVER BUYS ITS CELLS FROM THE NAME. The first line is the state - // glyph, the title and the handle — nothing else — and the model rides the + // glyph, the title and the handle — and, for work handed to a program, that + // program's badge (programbadge.go); nothing else — and the model rides the // telemetry row under it (task.go's [app.railTelemetry]), which is a row that // gives up its own tail rather than the title's cells. full := plain(strings.Join(a.railNodeRows(node, railCols), "\n")) diff --git a/internal/tui3/taskowner.go b/internal/tui3/taskowner.go index 2f4d66f1d0..7f4d9e6993 100644 --- a/internal/tui3/taskowner.go +++ b/internal/tui3/taskowner.go @@ -32,7 +32,9 @@ package tui3 // closed, and both conversations go on running. // - ATTACH — a conversation the ENGINE is running that this window can join // as a second view ([Options.OpenTaskOwner]). The page is that task's own -// transcript, live, with `esc` back to where the person was. +// transcript, live, with `esc` back to where the person was — or, for a +// task handed to a program, which writes no transcript, the program's +// actions off the owner's store ([app.guestPageRead]). // // AND WHERE THERE IS NO REACH AT ALL — no capability, an engine that refused, a // conversation on no list this machine keeps — the card says the one short thing @@ -52,6 +54,7 @@ package tui3 import ( "path/filepath" + "strconv" "strings" tea "charm.land/bubbletea/v2" @@ -309,6 +312,14 @@ type taskGuest struct { asking []session.Question questions <-chan session.Event stopAsking func() + + // page reads the task's stored page in the OWNER'S store + // ([TaskOwnerView.TaskPage]), and pageReading says a read is out. It is + // how a page onto a program's task finds out that it is one, and then the + // only thing that page reads: a program writes no worker journal, and what + // it did is its conversation with codeaf on that page ([app.guestPageRead]). + page func(id string) (session.PlanTaskPage, bool, error) + pageReading bool } // waiting is the question this page says the owner is stopped on: the oldest, @@ -453,6 +464,12 @@ func (a *app) tookGuestNotice(msg taskGuestNoticeMsg) tea.Cmd { if title := strings.TrimSpace(notice.Title); title != "" { guest.node.title = title } + // THE PROGRAM THE OWNER NAMES IS THE PAGE'S BADGE, and a notice naming + // none — from an engine older than the field — takes nothing away, the + // rule the rail keeps for this window's own nodes. + if program := strings.TrimSpace(notice.Program); program != "" { + guest.node.program = program + } if notice.Elapsed > 0 { guest.node.elapsed = notice.Elapsed } @@ -472,6 +489,13 @@ func (a *app) tookGuestNotice(msg taskGuestNoticeMsg) tea.Cmd { // nothing else would ever ask that conversation for another line. return tea.Batch(next, farRoomTick(room.gen)) } + if !was && room.done && room.program != nil { + // AND A PROGRAM'S PAGE READS THE OWNER'S STORE ONCE MORE WHEN THE OWNER + // SAYS THE WORK LANDED, for the reason this window's own program room + // does ([app.programRoomFollow]): the page the last beat read is the page + // from before the landing, and nothing reads it after a room is over. + return tea.Batch(next, a.guestPageRead()) + } return next } @@ -845,6 +869,7 @@ func (a *app) tookTaskOwner(msg taskOwnerMsg) tea.Cmd { // way to say what this piece of work was cut out of. trail: a.taskGuestTrail(ask.item), room: msg.view.Room, + page: msg.view.TaskPage, close: msg.view.Close, } if ask.trail != nil { @@ -901,9 +926,93 @@ func (a *app) tookTaskOwner(msg taskOwnerMsg) tea.Cmd { if guest.questions != nil { a.roomPump = tea.Batch(a.roomPump, waitGuestQuestions(guest.questions, room.gen)) } + // AND THE OWNER'S STORE IS ASKED, ONCE, WHETHER THIS IS A PROGRAM'S TASK — + // the question this window's own rooms put to its own store + // ([app.roomProgramCheck]), put to the owner's. The row cannot answer it: + // another window's presence names no program, and the owner's notice that + // does is a badge, not a page. + a.roomPump = tea.Batch(a.roomPump, a.guestPageRead()) return a.takeRoomPump() } +// guestPageRead reads the owner's stored page for the task a guest page is on, +// through the view, and folds it in: the first answer naming a program turns +// the page into that program's room (programroom.go), and every answer after +// it is that room's next page. +// +// IT READS THROUGH THE VIEW AND NEVER THROUGH [app.planReader]. That is THIS +// window's store, where the same number is this conversation's own task — the +// crossover [app.roomIsGuest] exists to prevent, one layer down. And it is +// asked BESIDE this window's line of doors rather than in it: the line keeps +// this window's gestures in order on this window's engine, and a read on +// another conversation's connection is neither a gesture nor on that engine. +// +// THE PAGE IS NOT A NEW ROOM. The room was built by [app.tookTaskOwner] with +// the guest on it, and it keeps the guest — its trail, its lanes, its +// connection, its read-only doors — and gains the program's body. Nothing here +// freezes a node of this window's rail or points the box at a task of it. +// +// A REFUSAL NAMING THE REPLACED CONVERSATION IS THE PAGE'S FINAL ANSWER, as it +// is on a journal read ([app.tookGuestRecord]): the page keeps what it read, +// says why it stopped, and gives the owner's lanes back. Any other failure +// changes nothing — an engine too old to let a reading window read a page +// answers with a refusal of its own, and the page is the journal reading it +// always was. +func (a *app) guestPageRead() tea.Cmd { + room := a.room + if room == nil || room.guest == nil || room.id == 0 { + return nil + } + guest := room.guest + read := guest.page + if read == nil || guest.lost || guest.pageReading { + return nil + } + id, gen, asked := strconv.FormatUint(room.id, 10), room.gen, a.now() + guest.pageReading = true + if p := room.program; p != nil { + p.readAt = asked + } + return a.besideLine(func() func(bool) tea.Cmd { + page, found, err := read(id) + return func(here bool) tea.Cmd { + guest.pageReading = false + if !here || a.room != room || room.gen != gen || room.guest != guest || guest.lost { + return nil + } + if err != nil { + if strings.Contains(err.Error(), taskGuestGoneMark) { + guest.lost = true + guest.dropWatch() + room.dirty = true + a.roomTouched() + a.touch() + } + return nil + } + if !found { + return nil + } + if room.program == nil { + if page.Program == nil && strings.TrimSpace(page.Row.Program) == "" { + return nil + } + // THE JOURNAL IS GIVEN UP WITH THE WORD THAT PROMISED IT. A program + // has none, so `loading` would never be answered, and the beat that + // reads it stops at its next turn ([app.readRoomRecord]). + room.program = &programRoom{page: page, readAt: asked} + room.loading, room.readFailed = false, false + } else { + room.program.page = page + } + room.dirty = true + a.roomTouched() + a.touch() + return nil + } + }) +} + // taskGuestTrail is the work above one row of the record, inside ITS OWN // conversation, outermost first. // @@ -963,6 +1072,10 @@ func (a *app) taskGuestTrail(item tasksItem) []string { // THE MODEL IS EMPTY BECAUSE NOBODY HERE KNOWS IT. The presence file the row was // minted from carries a title and a state, not a model, and the emptiness law // says an unknown draws as nothing rather than as this conversation's own. +// +// THE PROGRAM IS THE ROW'S OWN, and the only place the page's badge may come +// from until the owner says otherwise ([app.tookGuestNotice]): this window's +// plan rows are another conversation's numbering ([app.nodeProgram]). func taskGuestNode(item tasksItem) *taskNode { id := taskSheetEntryID(item.entry.ID) title := strings.TrimSpace(item.entry.Title) @@ -974,6 +1087,7 @@ func taskGuestNode(item tasksItem) *taskNode { ident: identFor(id), title: title, label: strings.TrimSpace(item.entry.Label), + program: strings.TrimSpace(item.entry.Program), state: session.TaskState(strings.TrimSpace(item.entry.Status)), ended: item.entry.EndedAt, met: item.entry.EndedAt, diff --git a/internal/tui3/taskphase.go b/internal/tui3/taskphase.go index 56e6026744..63c33f4dc4 100644 --- a/internal/tui3/taskphase.go +++ b/internal/tui3/taskphase.go @@ -136,16 +136,17 @@ func taskPhaseLine(node *taskNode) string { // deliberate rather than an oversight. This row is drawn against [app.taskNow], // which FREEZES while somebody is standing in the node's room — a number // climbing in the corner of the screen is pressure applied to a person who has -// already gone to look — and the room's own row ([app.roomCallRow]) counts on -// [app.now], because inside the room the seconds this request has been out are -// exactly what they went there to see. +// already gone to look — so while it is frozen the row says the phase and leaves +// the request's figures out rather than draw them stopped; the room's own row +// ([app.roomCallRow]) counts on [app.now], because inside the room the seconds +// this request has been out are exactly what they went there to see. func (a *app) railPhase(node *taskNode, width int) []string { word := taskPhaseLine(node) line := fit(word, width) if line == "" { return nil } - if call := node.phaseCall; call != nil { + if call := node.phaseCall; call != nil && node.froze.IsZero() { fields := append([]rowField{rowSay(word)}, a.callFields(call, a.taskNow(node))...) if said := rowLed(fields, width); said != "" { line = said diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 16b21ba1f4..1da97cb44f 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -329,6 +329,10 @@ func planItem(row session.PlanTaskRow, chat string, kin planKin) tasksItem { Cost: row.USD, StartedAt: row.Started, EndedAt: row.Ended, + // The program the store's root was handed to, so every row drawn off + // this item — the rail's, the tasks place's — wears the badge the + // node's own row does (programbadge.go). + Program: row.Program, }, runs: planRunning(row.Status), live: &status, @@ -800,7 +804,13 @@ func (a *app) taskPlanStopTaken(id string) tea.Cmd { return nil } if err != nil { - a.note(err.Error()) + // A stop that could not be given is said where the person is: on + // the program's room when that is what they stopped it from. + if a.programOf() != nil { + a.roomNote(err.Error()) + } else { + a.note(err.Error()) + } } else { a.railStamp++ } diff --git a/internal/tui3/taskretry.go b/internal/tui3/taskretry.go index c337bbfbb4..5adb9d4894 100644 --- a/internal/tui3/taskretry.go +++ b/internal/tui3/taskretry.go @@ -31,6 +31,11 @@ func (a *app) taskCanRetry(entry session.TaskIndexEntry) bool { if node == nil || node.state != session.TaskFailed || node.run != "" || a.taskSheet.awayOwner.on { return false } + // A PROGRAM'S TASK IS NEVER RETRIED: the engine's retry reopens one of its + // own nodes, and a program's run is not one (session's programNotCarriedOn). + if strings.TrimSpace(entry.Program) != "" || node.program != "" { + return false + } switch node.kind { case session.TaskKindJob, session.TaskKindAdaptive: return false diff --git a/internal/tui3/taskretry_test.go b/internal/tui3/taskretry_test.go index b9fa9aa838..b60f5d9f53 100644 --- a/internal/tui3/taskretry_test.go +++ b/internal/tui3/taskretry_test.go @@ -156,3 +156,22 @@ func TestTaskRetryOffersEveryStoredRunnerAndUnsuccessfulEnding(t *testing.T) { } } } + +// A PROGRAM'S FAILED TASK OFFERS NO RETRY, because the engine's retry reopens +// one of its own nodes and a program's run is not one. +func TestAProgramsFailedTaskOffersNoRetry(t *testing.T) { + a, _, entry := retryFixture(t) + if !a.taskCanRetry(entry) { + t.Fatal("the fixture's ordinary failed task is not offered a retry") + } + a.taskUpdate(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Title: "Repair export", State: session.TaskFailed, Report: "old failure", Program: "senior-dev", EndedAt: time.Now()}}) + entry.Program = "senior-dev" + a.taskSheet.detail = entry + if a.taskCanRetry(entry) { + t.Fatal("a program's failed task is offered a retry the engine refuses") + } + rows, _, _, _ := a.taskCardFrame(120, 40) + if text := ansi.Strip(strings.Join(rows, "\n")); strings.Contains(text, taskRetryWord) { + t.Fatalf("the card offers %q on a program's task:\n%s", taskRetryWord, text) + } +} diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 1e8b3740bd..d4d93e41cf 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -336,7 +336,11 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by Status: task.Task.State, SessionID: task.SessionID, StartedAt: task.Task.StartedAt, } key := tasksKeyOf(entry) - entry.Parent = held[key].entry.Parent + // THE FAMILY AND THE PROGRAM COME OFF THE INDEX ROW OF THE SAME WORK, where + // there is one: presence carries neither, and a row that dropped the + // program would open a page with no badge over a program's work + // ([taskGuestNode]). + entry.Parent, entry.Program = held[key].entry.Parent, held[key].entry.Program put(key, tasksItem{ entry: entry, row: tasksRowFor(world, mine, entry), runs: true, away: true, window: task.Session, here: mine.here[strings.TrimSpace(task.SessionID)], @@ -1939,7 +1943,7 @@ func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string } bullet := conversationBullet(pal, chat.working, chat.unread, chat.question, pal.glyph(tokens.GWorking)) lead := tasksBareLead + pal.dim(tasksTreeLead(line, width, pal)) + bullet + " " - return tasksTableRow(lead, ansi.StringWidth(lead), name, + return tasksTableRow(lead, ansi.StringWidth(lead), name, "", tasksChatStateField(chat), tasksKeyField(by.key, line.rank, now), tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, project, tasksFoldMark(line, pal)) } @@ -1978,7 +1982,7 @@ func tasksRow(line tasksLine, width int, now time.Time, by tasksSort, pal palett if item.plan != nil && item.plan.Total > 0 { label += " " + planProgress(*item.plan, width, pal) } - return tasksTableRow(lead, cells, label, + return tasksTableRow(lead, cells, label, item.entry.Program, state, second, tasksKeyInk(by.key, lit, pal), width, by.key, pal, lit, "", tasksFoldMark(line, pal)) } @@ -2012,6 +2016,11 @@ func tasksCardHead(item tasksItem, width int, pal palette, lit bool) string { if item.plan != nil && item.plan.Total > 0 { label += " " + planProgress(*item.plan, width, pal) } + // A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME, as its wide row does + // ([tasksTableRow]); an ordinary row is fitted exactly as it always was. + if program := strings.TrimSpace(item.entry.Program); program != "" { + return lead + pal.programTitled(label, program, room, func(s string) string { return placeSubject(s, lit, pal) }) + } return lead + placeSubject(fit(label, room), lit, pal) } diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index a56236fa5d..802f75277d 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -178,7 +178,12 @@ func tasksChatStateField(chat tasksChat) rowField { // would put them in a different place on every line of the page — a table whose // columns move is a tail with extra steps. Only the NAME flexes (rowfit.go law // 1), and the lead eats into the name. -func tasksTableRow(lead string, leadCells int, name string, state, second rowField, +// +// A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME, inside the name's own column +// (programbadge.go): program is the name of the program the row's work was +// handed to, "" for every other row, and the badge is paid for out of the name +// the way the lead is, so no column moves for it. +func tasksTableRow(lead string, leadCells int, name, program string, state, second rowField, secondInk func(string) string, width int, key tasksSortKey, pal palette, lit bool, project, fold string) string { stateCells, secondCells, nameCells := tasksColumns(width, key) nameCells = max(nameCells-leadCells, 1) @@ -186,12 +191,14 @@ func tasksTableRow(lead string, leadCells int, name string, state, second rowFie if fold != "" { foldCells = 1 + ansi.StringWidth(fold) } - said := fit(name, max(nameCells-foldCells-tasksColumnAir, 1)) - out := lead + placeSubject(said, lit, pal) + room := max(nameCells-foldCells-tasksColumnAir, 1) + wears := programSpelling(programBadge(program), name, room, railTitleFloor) + said := fit(name, max(room-programCells(wears), 1)) + out := lead + placeSubject(said, lit, pal) + pal.programAfter(wears) if fold != "" { out += " " + pal.dim(fold) } - out += pad(nameCells - ansi.StringWidth(said) - foldCells) + out += pad(nameCells - ansi.StringWidth(said) - programCells(wears) - foldCells) if cells := tasksProjectCells(width); cells > 0 { word := fit(project, cells-1) out += placeFactInk(lit, pal)(word) + pad(cells-ansi.StringWidth(word)) diff --git a/internal/tui3/taskstrip.go b/internal/tui3/taskstrip.go index 7b3d0dd417..a53d0c0960 100644 --- a/internal/tui3/taskstrip.go +++ b/internal/tui3/taskstrip.go @@ -425,9 +425,17 @@ func (a *app) stripLabel(node *taskNode, width int) (string, int) { // page under the row — the band on the room a person is standing in — because // this is a tab bar, and a tab bar that did not say which tab you are on would // be a row of identical doors. +// +// A PROGRAM'S WORK WEARS ITS BADGE AFTER THE NAME, in the short spelling — `[sd]` +// — because this row stands in for the side list under a hundred columns and a +// chip is a name cut to [stripTitleCap] cells, where the whole program's name +// would outweigh the work's (programbadge.go). An ordinary task's chip is +// unchanged to the cell. func (a *app) stripChip(node *taskNode, glyph, title string) (string, int) { - cols := ansi.StringWidth(glyph) + 1 + ansi.StringWidth(title) + stripPadCols - chip := stripPad + glyph + " " + a.stripTitle(node, title) + stripPad + badge := programBadge(a.nodeProgram(node)) + wears := firstNonEmpty(badge.short, badge.full) + cols := ansi.StringWidth(glyph) + 1 + ansi.StringWidth(title) + programCells(wears) + stripPadCols + chip := stripPad + glyph + " " + a.stripTitle(node, title) + a.pal.programAfter(wears) + stripPad // WHICH ROW IS THE PAGE YOU ARE ON IS ASKED IN ONE PLACE (room.go's // [app.roomStandingOn]), because the roster marks the same fact with the same // band and a tab bar that disagreed with the column beside it would be two diff --git a/internal/tui3/taskview.go b/internal/tui3/taskview.go index 51bb949915..e85b3c684f 100644 --- a/internal/tui3/taskview.go +++ b/internal/tui3/taskview.go @@ -398,6 +398,18 @@ func (a *app) refreshElsewhere() { a.away = elsewhereCache{at: a.now(), read: true} agent, ok := a.agent.(elsewhereAgent) if !ok { + // A WINDOW WHOSE AGENT IS A CONNECTION READS THE DISK THROUGH THE LAUNCH. + // That is every ordinary window: bare `codeaf` talks to this workspace's + // engine over a socket, and a connection has no reading to give — so + // without this the other conversations' work was never drawn, and the + // door behind those rows ([app.openOwnerRoom]) could not be reached at + // all. The launch knows the disk is the engine's and says so by binding + // the reader (tui3.go's [Options.Elsewhere]); it is asked about the + // conversation on screen, which it leaves out, exactly as the agent's + // own reading does. + if a.elsewhereOf != nil { + a.away.held = a.elsewhereOf(a.file, a.now()) + } return } // THE WHOLE READING, MINUS THIS CONVERSATION. The engine leaves the session diff --git a/internal/tui3/tui3.go b/internal/tui3/tui3.go index f0b6381813..f7c88f0045 100644 --- a/internal/tui3/tui3.go +++ b/internal/tui3/tui3.go @@ -388,6 +388,21 @@ type TaskOwnerView struct { // Nil is a door that cannot offer it. The page then says exactly what it said // before, which is what a capability that cannot work is owed. Questions func() (<-chan session.Event, func()) + // TaskPage reads ONE TASK'S STORED PAGE in the owner's own store — the page + // a program's task is drawn from, since a program writes no worker journal + // for [TaskOwnerView.Room] to read ([session.PlanTaskPage.Program]). It is + // the same read this window's own program room makes of its own store + // ([session.Agent.PlanTaskPage]), made on this view's connection so the id + // is answered in the owner's numbering and never in this window's. + // + // IT KEEPS THE ENGINE'S REFUSAL, where the agent's own read folds every + // failure into "not found": a page this window is reading learns that the + // conversation under it was replaced from exactly this error + // ([remote.ErrJoinedGone]), and a program's page reads nothing else. + // + // Nil is a door that cannot offer it, and every page opened through that + // door is the journal reading it always was. + TaskPage func(id string) (session.PlanTaskPage, bool, error) // Close gives back THIS VIEW'S connection and nothing else. The conversation // goes on running, the window that owns it keeps its keyboard, and the // engine is untouched. @@ -608,6 +623,23 @@ type Options struct { // the far machine. Every one of them keeps the road it had. EngineAnswers func(workspace string) bool + // Elsewhere reads what the project's OTHER conversations have out right + // now — the presence files beside the transcript this window is drawing — + // for a window whose agent cannot answer that itself + // ([session.ElsewhereOf] is the shape). + // + // IT IS THE HALF OF THE TASKS PAGE THE ENGINE ROAD HAD LOST. The rows of + // work another conversation is running are minted from that reading + // ([app.refreshElsewhere]), and it was asked of the agent alone: the + // in-process agent reads its own disk, and the connection bare `codeaf` + // holds to its engine does not ([remote.Agent] has no such method). So on + // the ordinary launch no such row was ever drawn, and [Options.OpenTaskOwner] + // — the door behind exactly those rows — could never be reached. + // + // Nil is a window whose disk is not the engine's (--host) or whose agent + // answers for itself (the in-process door); both keep the road they had. + Elsewhere func(transcript string, now time.Time) session.Elsewhere + // OpenTaskOwner attaches a SECOND VIEW onto a conversation that is ALREADY // RUNNING, for as long as one task page is on screen: a reader for that // task's journal, and the close that gives the view back. diff --git a/internal/tui3/workfold.go b/internal/tui3/workfold.go index 877d6c9825..59904df8c9 100644 --- a/internal/tui3/workfold.go +++ b/internal/tui3/workfold.go @@ -249,6 +249,7 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { // machine the router refuses — and a chip that hid it left them with a // pin that disappeared and no sentence anywhere saying why. blocked, stopped := false, false + var asks []int for i := lo; i < hi; i++ { if es[i].kind == entryAssistant && strings.TrimSpace(es[i].text) != "" { answer = i @@ -256,8 +257,10 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { if es[i].cut { stopped = true } - if es[i].kind == entryTask || es[i].kind == entryConnect || es[i].kind == entryStanding || - (es[i].kind == entryNote && (es[i].told || strings.HasPrefix(es[i].text, "cancel"))) { + if es[i].kind == entryTask || es[i].kind == entryConnect || es[i].kind == entryStanding { + asks = append(asks, i) + } + if es[i].kind == entryNote && (es[i].told || strings.HasPrefix(es[i].text, "cancel")) { blocked = true } // A SEAM IS NEVER FOLDED AWAY. A chip hides the machinery between a @@ -276,6 +279,37 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { blocked = true } } + // AN ASK STANDS, AND THE WORK BEFORE IT STILL FOLDS. A task proposal, a + // sign-in or a standing card is a thing the work could not decide alone, + // so no chip may cover it. It used to keep the WHOLE turn open instead: + // the moment a turn ended on `/senior-dev`'s approval card, every thought + // and call above the card unfolded at once, a screenful of machinery + // arriving exactly when the person had one question to answer. So a turn + // with asks in it is cut at each of them: the settled work between two + // asks folds behind a chip that ends at the ask, the ask stands, and the + // stretch after the last ask folds by the ordinary rule, up to its answer. + // A running turn, a stopped one and a blocked one are unchanged below. + if len(asks) > 0 && !blocked && !stopped && (runningTurn == 0 || es[lo].turn != runningTurn) { + from := lo + for _, at := range asks { + if askSegmentSettled(es, from, at) { + f := workfold{key: es[lo].turn, turn: es[lo].turn, start: -1, answer: at} + if countWork(es, from, at, &f); f.start >= 0 { + out[f.start] = f + } + } + from = at + 1 + } + if answer >= from && es[answer].settled { + f := workfold{key: es[lo].turn, turn: es[lo].turn, start: -1, answer: answer} + if countWork(es, from, answer, &f); f.start >= 0 { + out[f.start] = f + } + } + lo = hi + continue + } + blocked = blocked || len(asks) > 0 // THE END OF WHAT THE CHIP SWALLOWS. An ordinary fold stops at the answer // and leaves it standing; a stopped turn's fold runs to the end of the // group, because there is nothing in it that was said TO the person. @@ -331,6 +365,26 @@ func deriveWorkfolds(es []entry, runningTurn int) map[int]workfold { return out } +// askSegmentSettled reports whether the rows before an ask are settled work a +// chip may cover: no prose still streaming, no call still running or waiting on +// the person, and no call that failed, because only failure speaks here. +func askSegmentSettled(es []entry, from, to int) bool { + for i := from; i < to; i++ { + e := &es[i] + switch e.kind { + case entryAssistant: + if e.provisional && !e.settled { + return false + } + case entryTool: + if e.status != toolOK { + return false + } + } + } + return true +} + // Only a settled tail owned by a confirmed response may fold without a later // answer. Unknown work, live reasoning, failed tools and new responses cannot. func confirmedReasoningTail(es []entry) bool { diff --git a/internal/tui3/workfold_test.go b/internal/tui3/workfold_test.go index 08277d8bfd..6490b766d9 100644 --- a/internal/tui3/workfold_test.go +++ b/internal/tui3/workfold_test.go @@ -214,3 +214,46 @@ func TestWorkfoldNeverHidesNewsAboutAPersonsOwnRow(t *testing.T) { t.Fatalf("an ordinary note stopped the chip forming at all: %#v", got) } } + +// AN APPROVAL CARD STANDS, AND THE WORK ABOVE IT STAYS FOLDED. A turn that ended +// on `/senior-dev`'s proposal card used to derive no fold at all, so every +// thought and call of the turn unfolded the moment the card arrived: a +// screenful of machinery exactly when the person had one question to answer. +// The work before the card folds behind a chip that stops at the card; the +// card itself and the answer after it are drawn. +func TestAnApprovalCardDoesNotUnfoldTheWorkAboveIt(t *testing.T) { + base := time.Unix(100, 0) + es := []entry{ + {kind: entryUser, text: "solve it with senior-dev", turn: 1}, + {kind: entryThinking, text: "reading the task", turn: 1, open: true, settled: true, began: base, ended: base.Add(4 * time.Second)}, + {kind: entryAssistant, text: "I found the task; reading its contract.", turn: 1, settled: true}, + {kind: entryTool, tool: "read", turn: 1, status: toolOK, began: base.Add(4 * time.Second), ended: base.Add(5 * time.Second)}, + {kind: entryThinking, text: "writing the brief", turn: 1, open: true, settled: true, began: base.Add(5 * time.Second), ended: base.Add(9 * time.Second)}, + {kind: entryTool, tool: "propose_task", turn: 1, status: toolOK}, + {kind: entryTask, text: "Solve true-myth", turn: 1}, + {kind: entryAssistant, text: "senior-dev will take it once you approve.", turn: 1, settled: true}, + } + folds := deriveWorkfolds(es, 0) + covered := map[int]bool{} + for _, f := range folds { + for i := f.start; i < f.answer; i++ { + covered[i] = true + } + } + for _, i := range []int{1, 3, 4, 5} { + if !covered[i] { + t.Fatalf("row %d (kind %d) above the card is not folded: %+v", i, es[i].kind, folds) + } + } + if covered[6] { + t.Fatal("the approval card was folded away") + } + if covered[7] { + t.Fatal("the answer after the card was folded away") + } + // AND WHILE THE TURN IS STILL RUNNING nothing here folds it: the live + // policy owns a running turn (livesteps.go). + if got := deriveWorkfolds(es, 1); len(got) != 0 { + t.Fatalf("a running turn derived settled folds: %+v", got) + } +} diff --git a/internal/tui3/worktab.go b/internal/tui3/worktab.go index 4761d8c81a..46126b1f2d 100644 --- a/internal/tui3/worktab.go +++ b/internal/tui3/worktab.go @@ -4,6 +4,8 @@ import ( "strings" tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" ) func (a *app) workTab() (chatTab, bool) { @@ -11,7 +13,13 @@ func (a *app) workTab() (chatTab, bool) { // rows are the ones the task sheet already carries ([tasksMine.plan], read // off the loop); asking the agent here opened the plan store twice on // every frame of a conversation with a run in it. - rows := a.taskSheet.mine.plan + // + // A PROGRAM'S RUN HAS NO TAB. Its task opens inside this conversation's own + // tab, as a room, from its row, its card and every other door + // (programroom.go); a tab of its own drew itself selected beside the + // conversation's, and a press on the conversation's tab never left it. Only + // the rows of a run the belt switch drives make the tab. + rows := beltRows(a.taskSheet.mine.plan) if len(rows) == 0 { return chatTab{}, false } @@ -25,16 +33,28 @@ func (a *app) workTab() (chatTab, bool) { if !live && a.workTabStable() { return chatTab{}, false } - word := strings.TrimSpace(rows[0].Title) + word := strings.TrimSpace(workTabRow(rows).Title) if word == "" { return chatTab{}, false } - return chatTab{key: a.frontTabKey() + "#work", file: a.file, word: word, full: word, here: a.workTabHere(rows[0].ID), held: true, work: true}, true + return chatTab{key: a.frontTabKey() + "#work", file: a.file, word: word, full: word, here: a.workTabHere(workTabRow(rows).ID), held: true, work: true}, true +} + +// beltRows is the rows of runs the belt switch drives, which are the only runs +// with a tab of their own: every row that names no program. +func beltRows(rows []session.PlanTaskRow) []session.PlanTaskRow { + var out []session.PlanTaskRow + for _, row := range rows { + if strings.TrimSpace(row.Program) == "" { + out = append(out, row) + } + } + return out } func (a *app) workTabStable() bool { var sig strings.Builder - for _, row := range a.taskSheet.mine.plan { + for _, row := range beltRows(a.taskSheet.mine.plan) { if planRunning(row.Status) { a.workTabSettled = "" return false @@ -54,12 +74,26 @@ func (a *app) workTabStable() bool { // ([app.openRailPlan]). func (a *app) openWorkTab() tea.Cmd { rows, ok := a.heldPlanRows() + rows = beltRows(rows) if !ok || len(rows) == 0 { return nil } a.chatTabBar = tabBar{} a.touch() - return a.openRailPlan(rows[0].ID, nil) + return a.openRailPlan(workTabRow(rows).ID, nil) +} + +// workTabRow is the row the run's tab is about: the first one still working, +// and the first row when none is. A conversation that handed senior-dev two +// tasks holds two runs' rows, and a tab named after the one that had already +// landed opened on it while the other was the work in front of the person. +func workTabRow(rows []session.PlanTaskRow) session.PlanTaskRow { + for _, row := range rows { + if planRunning(row.Status) { + return row + } + } + return rows[0] } // workTabHere reports whether the run's tab is the page on screen: the room is @@ -68,3 +102,14 @@ func (a *app) workTabHere(root string) bool { plan := a.roomPlan() return plan != nil && plan.id == strings.TrimSpace(root) } + +// withdrawRailPlan withdraws a row's page still on its way ([railPlanPending]). +// +// EVERY DOOR OUT OF THE CONVERSATION'S FRAME CALLS IT: every place opens +// through [app.standDownRest]. A person who pressed a run's row and then went +// Home has left the press behind, and its answer — milliseconds later here, +// seconds over a connection — opened the run's room under Home, with the box +// pointed at the run while Home's box was the one on screen. +func (a *app) withdrawRailPlan() { + a.railPlanPending = railPlanPending{} +} diff --git a/scripts/clean-run.sh b/scripts/clean-run.sh new file mode 100755 index 0000000000..68773e75c8 --- /dev/null +++ b/scripts/clean-run.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# clean-run.sh opens bin/codeaf as though it had never run on this machine, +# except that it still knows who you are: your settings (models, crew, +# services) and your keys are copied into a fresh state root, and nothing else +# is. No conversation, project, task, memory, standing order or notice from +# ~/.codeaf is there, so every try of a new build starts from the same place. +# +# The state root is moved with CODEAF_HOME (internal/home), which moves every +# file codeaf writes and leaves HOME alone, so git, your shell and caches +# outside codeaf behave exactly as they do every day. The copy is left behind +# after codeaf exits, and its path is printed, so a run can be looked at later. +# +# scripts/clean-run.sh [codeaf arguments...] +# CLEAN_FROM=~/.codeaf where the settings and keys are copied from +# CLEAN_INTO=<dir> use this folder instead of a fresh one (emptied first) +# CODEAF_BIN=<path> the binary to open (default: bin/codeaf beside this script) +set -eu + +here=$(cd "$(dirname "$0")/.." && pwd) +from=${CLEAN_FROM:-$HOME/.codeaf} +bin=${CODEAF_BIN:-$here/bin/codeaf} + +if [ ! -x "$bin" ]; then + echo "clean-run: no binary at $bin; run make build first" >&2 + exit 1 +fi +if [ -n "${CLEAN_INTO:-}" ]; then + into=$CLEAN_INTO + rm -rf "$into" + mkdir -p "$into" +else + # /tmp AND NOT $TMPDIR: macOS's $TMPDIR is a long path under /var/folders, + # and codeaf's session host listens on a unix socket under this folder, + # whose path may weigh at most 103 bytes (internal/enginehost). A root + # there left no room for the socket. + into=$(mktemp -d /tmp/codeaf-clean.XXXXXX) +fi + +# WHAT MAKES IT YOURS, AND NOTHING THAT MAKES IT USED: the settings file, the +# keys, the model services you connected, the tool servers you added, and the +# model catalog cache (a copy only saves the first launch a fetch). Every +# conversation, project and ledger lives elsewhere under the root and stays out. +for name in config.json credentials.json connections.json toolservers.json model-catalog.json model-quirks.json; do + if [ -f "$from/$name" ]; then + cp -p "$from/$name" "$into/$name" + fi +done +chmod 700 "$into" + +echo "clean-run: codeaf state in $into (settings and keys from $from)" >&2 +status=0 +CODEAF_HOME=$into "$bin" "$@" || status=$? +echo "clean-run: that run's state is kept in $into" >&2 +exit $status diff --git a/scripts/install.sh b/scripts/install.sh index ee503684fb..84f690001c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -7,14 +7,6 @@ LEGACY_REPOSITORY="Agent-Field/aforge-v2" # Remove after the one-release reposit CHANNEL="${CHANNEL:-stable}" INSTALL_NAME="${CODEAF_INSTALL_NAME:-codeaf}" VERSION="${VERSION:-}" -# The installer's three-line telemetry notice, verbatim from docs/TELEMETRY.md. -# The binary prints the full notice before the first session's events leave; -# the installer says the fact, the inspector and the switch. It writes a local -# install marker and prints this text, never sends telemetry, and makes no -# request that the download steps did not already make. -TELEMETRY_NOTICE='codeaf shares anonymous performance data with AgentField -codeaf does NOT share your prompts, code, files, or any private information -see what is shared: codeaf telemetry info · turn off: CODEAF_TELEMETRY=off' VERBOSE="${VERBOSE:-0}" NO_MODIFY_PATH="${CODEAF_NO_MODIFY_PATH:-${AFORGE_NO_MODIFY_PATH:-0}}" # legacy-name INSTALL_DIR="${CODEAF_INSTALL_DIR:-${AFORGE_INSTALL_DIR:-${HOME}/.codeaf/bin}}" # legacy-name @@ -51,7 +43,6 @@ Environment: CODEAF_NO_MODIFY_PATH, VERBOSE GITHUB_TOKEN or GH_TOKEN: GitHub answers anonymous API calls sixty times an hour per address; a token raises that. CODEAF_GITHUB_API and CODEAF_GITHUB_DOWNLOAD for mirrors and tests - CODEAF_TELEMETRY=off, or DO_NOT_TRACK=1, turns the anonymous usage counts off EOF } @@ -66,18 +57,6 @@ usage_error() { exit 2 } -telemetry_off() { - local value - case "${DO_NOT_TRACK:-}" in - 1|[Tt]|[Tt][Rr][Uu][Ee]) return 0 ;; - esac - value=$(echo "${CODEAF_TELEMETRY:-}" | tr '[:upper:]' '[:lower:]') - case "$value" in - off|0|false) return 0 ;; - esac - return 1 -} - # The install marker is a local record only: it names how and when this # machine's codeaf was installed so the usage counts can bucket by channel, # never who installed it. Nothing here contacts the network. @@ -92,22 +71,19 @@ write_install_marker() { stable|rc|staging|dev) channel="$CHANNEL" ;; *) channel="unknown" ;; esac + # The marker is background bookkeeping: a failure skips it without a word. if ! mkdir -p "$directory/telemetry" 2>/dev/null; then - printf 'codeaf: could not create %s/telemetry; skipping the install marker\n' "$directory" >&2 return 0 fi chmod 0700 "$directory/telemetry" file="$directory/telemetry/install.json" if ! { printf '{"install_method":"script","channel":"%s","installed_at":"%s"}' \ "$channel" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"; } > "$file" 2>/dev/null; then - printf 'codeaf: could not write %s; skipping the install marker\n' "$file" >&2 return 0 fi chmod 0600 "$file" } -# Printed once, at the very end of a successful install. The binary repeats it -# before the first session's counts are ever sent. # The one line a person still has to paste, printed last of all, between a # blank line above and a blank line below, bold green on a terminal. Bare # `export PATH=...` and nothing else, so it can be selected and pasted without @@ -124,14 +100,6 @@ print_path_hint() { printf '\n%s%s%s\n\n' "$on" "$hint" "$off" } -print_telemetry_notice() { - if telemetry_off; then - printf 'codeaf: anonymous usage counts are off (CODEAF_TELEMETRY=off or DO_NOT_TRACK=1)\n' >&2 - return 0 - fi - printf '\n%s\n' "$TELEMETRY_NOTICE" >&2 -} - while [[ $# -gt 0 ]]; do case "$1" in --stable) CHANNEL="stable"; shift ;; @@ -509,7 +477,7 @@ append_path_line() { } # The PATH line is not printed here. It is the last thing the installer says, -# after `codeaf version` and the telemetry notice, so the one line a person +# after `codeaf version`, so the one line a person # has to paste sits at the bottom of the screen where their eye already is. PATH_HINT="" if [[ "$OS" != "windows" ]] && ! path_has_dir; then @@ -552,5 +520,4 @@ printf 'installed %s\n' "$version_line" if [[ "$RUN_BOOT_ADOPTION" == "1" || -d "$STATE_ROOT" ]]; then write_install_marker "$STATE_ROOT" fi -print_telemetry_notice print_path_hint "$PATH_HINT" diff --git a/test/installer-telemetry.sh b/test/installer-telemetry.sh index 2a4bd12f0e..28a1e17427 100755 --- a/test/installer-telemetry.sh +++ b/test/installer-telemetry.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # THE INSTALLER'S TELEMETRY DUTIES, PROVED WITHOUT A NETWORK. # -# The notice text lives once, byte for byte, in docs/TELEMETRY.md: the full -# form quoted by the binary and the README, and the three-line form the -# installer prints. Only a test notices when one of them drifts. The installer's main body +# The installer writes the local install marker and prints no telemetry notice: +# the binary shows the notice, quoted byte for byte in docs/TELEMETRY.md and the +# README, before the first session's events are sent. The installer's main body # downloads a release, so this test never sources it whole: it lifts out the -# three telemetry functions and runs them against a temporary state root. +# marker and PATH functions and runs them against a temporary state root. # Nothing here opens a socket. set -euo pipefail cd "$(git rev-parse --show-toplevel)" @@ -14,11 +14,8 @@ script=scripts/install.sh doc=docs/TELEMETRY.md test -f "$doc" || { echo "docs/TELEMETRY.md is missing; this test reads the notice from it"; exit 1; } -eval "$(awk '/^TELEMETRY_NOTICE=/{f=1} /^VERBOSE=/{f=0} f' "$script")" -eval "$(sed -n '/^telemetry_off()/,/^}/p; /^write_install_marker()/,/^}/p; /^print_telemetry_notice()/,/^}/p; /^print_path_hint()/,/^}/p' "$script")" -type telemetry_off >/dev/null +eval "$(sed -n '/^write_install_marker()/,/^}/p; /^print_path_hint()/,/^}/p' "$script")" type write_install_marker >/dev/null -type print_telemetry_notice >/dev/null type print_path_hint >/dev/null pass=0 @@ -78,56 +75,24 @@ CHANNEL='we"ird' write_install_marker "$tmp/state" ok "unexpected channel written as unknown" 'grep -q "\"channel\":\"unknown\"" "$f"' ok "unexpected-channel marker is valid JSON" 'json_valid' ok "unwritable state root does not fail the install" 'write_install_marker /proc/nonexistent-root' +ok "an unwritable state root says nothing" '[ -z "$(write_install_marker /proc/nonexistent-root 2>&1)" ]' -# --- the notice ------------------------------------------------------------- +# --- no notice from the installer -------------------------------------------- -notice="$tmp/notice.txt" -print_telemetry_notice 2> "$notice" -# The first fenced block under "The notice" is the binary's full notice, the -# second is the installer's three-line form; awk counts fences to tell them apart. +# The binary shows the notice at the first session; the installer shows none. +ok "installer prints no telemetry notice" '! grep -qE "anonymous (performance data|usage counts)|TELEMETRY_NOTICE|print_telemetry_notice" "$script"' +ok "no printed line in the installer mentions telemetry" '! grep -E "printf|echo" "$script" | grep -qi telemetry' expected=$(awk ' /^## The notice$/ {f=1; next} f && /^```$/ {f++; next} f == 2 {print} ' "$doc") -installer_expected=$(awk ' - /^## The notice$/ {f=1; next} - f && /^```$/ {f++; next} - f == 4 {print} -' "$doc") -body=$(sed 1d "$notice") -ok "one blank line before the notice" '[ -z "$(head -n 1 "$notice")" ]' -ok "installer notice matches docs/TELEMETRY.md verbatim" '[ "$body" = "$installer_expected" ]' -ok "installer notice is three lines" '[ "$(printf "%s\n" "$body" | wc -l | tr -d " ")" = 3 ]' -ok "installer notice names the inspector and the switch" 'case "$body" in *"codeaf telemetry info"*CODEAF_TELEMETRY=off*) true;; *) false;; esac' -ok "installer notice names what is never shared" 'case "$body" in *"does NOT share your prompts, code, files"*) true;; *) false;; esac' readme_block=$(awk ' /^```text$/ {f = 1; buf = ""; next} /^```$/ {if (f && buf ~ /codeaf sends anonymous usage counts/) {print buf; exit} f = 0; next} f {buf = buf $0 "\n"} ' README.md) ok "README quotes the notice verbatim" '[ -n "$readme_block" ] && [ "$(printf "%s\n" "$expected")" = "$readme_block" ]' -ok "notice goes to stderr, nothing to stdout" '[ -z "$(print_telemetry_notice 2>/dev/null)" ]' - -for v in off 0 false OFF False; do - export CODEAF_TELEMETRY="$v" - out=$( print_telemetry_notice 2>&1 ) - ok "CODEAF_TELEMETRY=$v opts out" 'case "$out" in *"off"*) true;; *) false;; esac' - ok "opt-out prints no notice body" 'case "$out" in *"anonymous performance data"*) false;; *) true;; esac' - unset CODEAF_TELEMETRY -done -for v in 1 true TRUE; do - export DO_NOT_TRACK="$v" - out=$( print_telemetry_notice 2>&1 ) - ok "DO_NOT_TRACK=$v opts out" 'case "$out" in *"off"*) true;; *) false;; esac' - unset DO_NOT_TRACK -done -out=$( print_telemetry_notice 2>&1 ) -ok "unset prints the notice" 'case "$out" in *"anonymous performance data with AgentField"*) true;; *) false;; esac' -export CODEAF_TELEMETRY=1 -out=$( print_telemetry_notice 2>&1 ) -ok "CODEAF_TELEMETRY=1 prints the notice" 'case "$out" in *"anonymous performance data with AgentField"*) true;; *) false;; esac' -unset CODEAF_TELEMETRY # --- the PATH line comes last ------------------------------------------------- # The line a person has to paste is the installer's final word: bare, after a