diff --git a/README.md b/README.md index 46d26098..70ba3b7b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Lightcode uses the OpenAI Chat Completions shape with streaming and tool calls. Provider compatibility still varies: some OpenAI-compatible models stream text correctly but do not reliably support streamed tool calls. Test a new provider/model with a real tool call before relying on it. -**Tools** — `read_file` · `write_file` · `edit_file` · `apply_patch` · `run_command` · `process` · `sleep` · `diagnostics` · `workspace_symbol` · `task` +**Tools** — `read_file` · `write_file` · `edit_file` · `apply_patch` · `run_command` · `process` · `sleep` · `task` **Snapshots** — File edits are captured copy-based before they happen, grouped per admitted operation. Revert code at any owned input or fork the conversation from it. No git dependency. @@ -41,8 +41,6 @@ Lightcode uses the OpenAI Chat Completions shape with streaming and tool calls. **Context compaction** — Automatic single-request summarization over the frozen conversation when provider-reported usage would exceed the model's context window; `/compact` triggers it manually. -**LSP** — Diagnostics and symbol search across Go, Python, TypeScript/JS, Rust, C/C++, C#. Auto-detected; servers auto-installed where supported. - **Subagents** — Delegate a task to a background child session with its own context, tool surface, and model; concurrent child sessions are bounded by configuration. --- @@ -144,7 +142,7 @@ Shell-exported environment variables take precedence over values in `~/.lightcod #### Permissions -Every tool call is checked before execution against one automatic policy and never prompts. The built-in policy allows file reads and writes inside the workspace and denies sensitive file basenames (`.env`, private keys, credential files) even there. Its target permissions are scoped: `command.run`, `child.launch`, and `process.own` allow any target, `workspace.inspect` allows only the workspace root itself, and `sleep` only its fixed target; anything undeclared is denied. Child sessions are gated by the same policy. Pair targets are canonical: a resolved file path for the file pairs, one parsed shell segment (or the complete normalized command) for `command.run`, the workspace root for `workspace.inspect`, `*` for `sleep`, the resolved Agent-type identity for `child.launch`, and `/` for `process.own` read/kill (or `*` for its list action). +Every tool call is checked before execution against one automatic policy and never prompts. The built-in policy allows file reads and writes inside the workspace and denies sensitive file basenames (`.env`, private keys, credential files) even there. Its target permissions are scoped: `command.run`, `child.launch`, and `process.own` allow any target, and `sleep` only its fixed target; anything undeclared is denied. Child sessions are gated by the same policy. Pair targets are canonical: a resolved file path for the file pairs, one parsed shell segment (or the complete normalized command) for `command.run`, `*` for `sleep`, the resolved Agent-type identity for `child.launch`, and `/` for `process.own` read/kill (or `*` for its list action). Optional rule lists refine that policy: global rules live in the `permissions` member of `~/.lightcode/config.json`, per-workspace rules in `~/.lightcode/projects/p-/permissions.json` (that directory name is a storage key for the permission file, not a durable project record): @@ -160,7 +158,7 @@ Optional rule lists refine that policy: global rules live in the `permissions` m } ``` -Rule targets are globs matched against the pair's canonical target — for example a resolved file path, a parsed `command.run` segment or complete command text, an Agent-type identity, a `/` job reference, or the fixed `*` (the rules above are illustrative). The last matching rule at a level wins, the workspace level decides before the global level, and a level's `default` applies when no rule matches. Permission names are `file.read`, `file.write`, `command.run`, `workspace.inspect`, `sleep`, `child.launch`, and `process.own`. A malformed block discards both user levels for that resolution, leaving the built-in policy. +Rule targets are globs matched against the pair's canonical target — for example a resolved file path, a parsed `command.run` segment or complete command text, an Agent-type identity, a `/` job reference, or the fixed `*` (the rules above are illustrative). The last matching rule at a level wins, the workspace level decides before the global level, and a level's `default` applies when no rule matches. Permission names are `file.read`, `file.write`, `command.run`, `sleep`, `child.launch`, and `process.own`. A malformed block discards both user levels for that resolution, leaving the built-in policy. #### Run diff --git a/agent/public_isolation_test.go b/agent/public_isolation_test.go index 76c84a68..1f71394b 100644 --- a/agent/public_isolation_test.go +++ b/agent/public_isolation_test.go @@ -57,6 +57,7 @@ var retiredPackageRoots = []string{ "internal/project", "internal/permission", "internal/memory", + "plugins/jobs", } // retiredRoot reports the retired legacy package tree that one module-relative @@ -129,13 +130,12 @@ func checkTrackedGoFile(rel string, imports []string, std map[string]bool) []str agentsPkg = publicModule + "/internal/agents" catalogPkg = publicModule + "/internal/catalog" atomicfsPkg = publicModule + "/internal/atomicfs" - promptPkg = publicModule + "/internal/prompt" + promptPkg = publicModule + "/prompt" adaptationPkg = publicModule + "/internal/adaptation" pluginsPkg = publicModule + "/internal/plugins" shellparsePkg = publicModule + "/internal/shellparse" - lspPkg = publicModule + "/internal/lsp" cmdoutputPkg = publicModule + "/internal/cmdoutput" - jobsPkg = publicModule + "/plugins/jobs" + jobsPkg = publicModule + "/internal/jobs" tasksPkg = publicModule + "/internal/plugins/tasks" toolargsPkg = publicModule + "/internal/toolargs" testassertPkg = publicModule + "/internal/testassert" @@ -175,8 +175,8 @@ func checkTrackedGoFile(rel string, imports []string, std map[string]bool) []str if dir != "internal/storage" && strings.HasPrefix(dir, "internal/storage/") { return []string{rel + ": internal/storage must remain one package without backend subpackages"} } - if dir != "plugins/jobs" && strings.HasPrefix(dir, "plugins/jobs/") { - return []string{rel + ": plugins/jobs must remain one package without subpackages"} + if dir != "internal/jobs" && strings.HasPrefix(dir, "internal/jobs/") { + return []string{rel + ": internal/jobs must remain one package without subpackages"} } for _, imp := range imports { // The retired-tree import classification runs before exact roles: one @@ -220,48 +220,37 @@ func checkTrackedGoFile(rel string, imports []string, std map[string]bool) []str if imp != modelPkg && !std[imp] { problems = append(problems, rel+": agent package imports "+imp+"; agent may import only the standard library and "+modelPkg) } + case "prompt": + if !std[imp] { + problems = append(problems, rel+": prompt package imports "+imp+"; prompt may import only the standard library") + } case "harness": if imp != modelPkg && imp != agentPkg && !std[imp] { problems = append(problems, rel+": harness package imports "+imp+"; harness may import only the standard library, "+modelPkg+", and "+agentPkg) } case "runtime": - if imp != modelPkg && imp != harnessPkg && imp != protocolPkg && imp != configPkg && imp != agentsPkg && imp != catalogPkg && imp != atomicfsPkg && imp != promptPkg && imp != adaptationPkg && imp != snapshotPkg && imp != safefsPkg && imp != pathutilPkg && !std[imp] { - problems = append(problems, rel+": runtime package imports "+imp+"; runtime may import only the standard library, "+modelPkg+", "+harnessPkg+", "+protocolPkg+", and the retained "+configPkg+", "+agentsPkg+", "+catalogPkg+", "+atomicfsPkg+", "+promptPkg+", "+adaptationPkg+", "+snapshotPkg+", "+safefsPkg+", and "+pathutilPkg+" helpers, never "+storagePkg+", any concrete plugin under "+pluginsPkg+", or the SQLite driver") + if imp != modelPkg && imp != harnessPkg && imp != protocolPkg && imp != configPkg && imp != agentsPkg && imp != catalogPkg && imp != atomicfsPkg && imp != promptPkg && imp != adaptationPkg && imp != snapshotPkg && imp != safefsPkg && imp != pathutilPkg && imp != storagePkg && imp != toolPkg && imp != editprePkg && imp != shellparsePkg && imp != toolargsPkg && imp != jobsPkg && !std[imp] { + problems = append(problems, rel+": runtime package imports "+imp+"; runtime may import only the standard library, "+modelPkg+", "+harnessPkg+", "+protocolPkg+", and the retained "+configPkg+", "+agentsPkg+", "+catalogPkg+", "+atomicfsPkg+", "+promptPkg+", "+adaptationPkg+", "+snapshotPkg+", "+safefsPkg+", "+pathutilPkg+", "+storagePkg+", "+toolPkg+", "+editprePkg+", "+shellparsePkg+", "+toolargsPkg+", and "+jobsPkg+" helpers, never any concrete plugin under "+pluginsPkg+" or the SQLite driver") } case "internal/catalog": if imp != modelPkg && imp != atomicfsPkg && !std[imp] { problems = append(problems, rel+": internal/catalog imports "+imp+"; the catalog may import only the standard library, "+atomicfsPkg+", and the public "+modelPkg+" identity it forwards") } case "internal/agents": - if imp != modelPkg && !std[imp] { - problems = append(problems, rel+": internal/agents imports "+imp+"; the agents definitions may import only the standard library and the public "+modelPkg+" identity parsing") + if imp != modelPkg && imp != promptPkg && !std[imp] { + problems = append(problems, rel+": internal/agents imports "+imp+"; the agents definitions may import only the standard library, the public "+modelPkg+" identity parsing, and the shared "+promptPkg+" default summarizer bytes") } case "internal/storage": if imp != harnessPkg && imp != sqliteDriverPkg && !std[imp] { problems = append(problems, rel+": internal/storage imports "+imp+"; internal/storage may import only the standard library, "+harnessPkg+", and "+sqliteDriverPkg) } - case "internal/plugins/sqlite": - if imp != runtimePkg && imp != harnessPkg && imp != storagePkg && !std[imp] { - problems = append(problems, rel+": internal/plugins/sqlite imports "+imp+"; the SQLite plugin may import only the standard library, "+runtimePkg+", "+harnessPkg+", and "+storagePkg) - } - case "internal/plugins/tools": - if imp != runtimePkg && imp != harnessPkg && imp != modelPkg && imp != configPkg && - imp != toolPkg && imp != snapshotPkg && imp != editprePkg && imp != pathutilPkg && - imp != shellparsePkg && imp != jobsPkg && imp != toolargsPkg && !std[imp] { - problems = append(problems, rel+": internal/plugins/tools imports "+imp+"; the native tools plugin may import only the standard library, "+runtimePkg+", "+harnessPkg+", "+modelPkg+", "+jobsPkg+", "+toolargsPkg+", and the retained "+toolPkg+", "+snapshotPkg+", "+editprePkg+", "+configPkg+", "+pathutilPkg+", and "+shellparsePkg+" helpers") + case "internal/jobs": + if imp != harnessPkg && imp != cmdoutputPkg && !std[imp] { + problems = append(problems, rel+": internal/jobs imports "+imp+"; the jobs service may import only the standard library, "+harnessPkg+", and "+cmdoutputPkg+" — the Runtime owner consumes it, never the other way round") } case "internal/plugins/adaptation": - if imp != runtimePkg && imp != modelPkg && imp != adaptationPkg && !std[imp] { - problems = append(problems, rel+": internal/plugins/adaptation imports "+imp+"; the adaptation plugin may import only the standard library, "+runtimePkg+", "+modelPkg+", and "+adaptationPkg) - } - case "internal/plugins/lsp": - if imp != runtimePkg && imp != harnessPkg && imp != modelPkg && imp != lspPkg && - imp != snapshotPkg && imp != pathutilPkg && imp != toolargsPkg && !std[imp] { - problems = append(problems, rel+": internal/plugins/lsp imports "+imp+"; the LSP plugin may import only the standard library, "+runtimePkg+", "+harnessPkg+", "+modelPkg+", "+toolargsPkg+", and the retained "+lspPkg+", "+snapshotPkg+", and "+pathutilPkg+" helpers") - } - case "plugins/jobs": - if imp != harnessPkg && imp != runtimePkg && imp != cmdoutputPkg && !std[imp] { - problems = append(problems, rel+": plugins/jobs imports "+imp+"; the jobs plugin may import only the standard library, "+harnessPkg+", "+runtimePkg+", and "+cmdoutputPkg) + if imp != runtimePkg && imp != modelPkg && imp != adaptationPkg && imp != promptPkg && !std[imp] { + problems = append(problems, rel+": internal/plugins/adaptation imports "+imp+"; the adaptation plugin may import only the standard library, "+runtimePkg+", "+modelPkg+", "+promptPkg+", and "+adaptationPkg) } case "internal/plugins/tasks": if imp != modelPkg && imp != harnessPkg && imp != runtimePkg && imp != toolargsPkg && !std[imp] { @@ -276,8 +265,8 @@ func checkTrackedGoFile(rel string, imports []string, std map[string]bool) []str problems = append(problems, rel+": internal/testassert imports "+imp+"; the shared test assertion helper may import only the standard library") } case "internal/plugins/builtin": - if imp != runtimePkg && imp != pluginsPkg+"/sqlite" && imp != pluginsPkg+"/tools" && imp != pluginsPkg+"/adaptation" && imp != pluginsPkg+"/lsp" && imp != jobsPkg && imp != tasksPkg && !std[imp] { - problems = append(problems, rel+": internal/plugins/builtin imports "+imp+"; the shipped registration set may import only the standard library, "+runtimePkg+", and the six sibling plugins it registers") + if imp != runtimePkg && imp != pluginsPkg+"/adaptation" && imp != tasksPkg && !std[imp] { + problems = append(problems, rel+": internal/plugins/builtin imports "+imp+"; the shipped registration set may import only the standard library, "+runtimePkg+", and the two sibling plugins it registers — the store and the standard tools are directly constructed by the owner") } case "protocol": if imp != oapiRuntimePkg && !std[imp] { @@ -329,9 +318,8 @@ func checkTrackedGoFile(rel string, imports []string, std map[string]bool) []str problems = append(problems, rel+": production file imports "+imp+"; the shared startup, scalar projection, and typed invocation helpers and the desktop host and ACP bridge are reserved for their exact target consumers, and no legacy, root, or adapter file may start a Runtime or adopt target view or invocation rules before the coordinated cutover") } else if imp == modelPkg || imp == agentPkg || imp == harnessPkg || imp == storagePkg || imp == sqliteDriverPkg || imp == runtimePkg || imp == pluginsPkg || strings.HasPrefix(imp, pluginsPkg+"/") || - imp == publicModule+"/plugins" || strings.HasPrefix(imp, publicModule+"/plugins/") || imp == toolargsPkg || imp == protocolPkg || imp == testassertPkg { - problems = append(problems, rel+": production file imports "+imp+"; no existing package may consume the public foundation before its cutover phase, no legacy, root, or adapter file may import the target runtime, its concrete plugins, or the protocol contracts, the SQLite driver is confined to "+storagePkg+", the shared tool-argument decoder is confined to its three plugin consumers, and the shared test assertion helper is confined to test files") + problems = append(problems, rel+": production file imports "+imp+"; no existing package may consume the public foundation before its cutover phase, no legacy, root, or adapter file may import the target runtime, its concrete plugins, or the protocol contracts, the SQLite driver is confined to "+storagePkg+", the shared tool-argument decoder is confined to the Runtime owner and the child-session tool plugin, and the shared test assertion helper is confined to test files") } } } @@ -357,6 +345,16 @@ func TestDependencyRulesRejectNonStdlibDotlessImports(t *testing.T) { problems := checkTrackedGoFile("agent/x.go", []string{"fmt", publicModule + "/model"}, std) testassert.Check(t, len(problems) == 0, "allowed agent imports flagged: %v", problems) } + { + problems := checkTrackedGoFile("prompt/x.go", []string{"errors", "fmt", "os", "path/filepath", "strings", "time"}, std) + testassert.Check(t, len(problems) == 0, "stdlib imports flagged in prompt: %v", problems) + } + for _, imp := range []string{publicModule + "/model", publicModule + "/internal/prompt", publicModule + "/internal/adaptation"} { + { + problems := checkTrackedGoFile("prompt/x.go", []string{imp}, std) + testassert.Check(t, len(problems) == 1, "prompt importing %q: %d problems, want 1: %v", imp, len(problems), problems) + } + } { problems := checkTrackedGoFile("harness/x.go", []string{"fmt", "encoding/json"}, std) testassert.Check(t, len(problems) == 0, "stdlib imports flagged in harness: %v", problems) @@ -385,47 +383,12 @@ func TestDependencyRulesRejectNonStdlibDotlessImports(t *testing.T) { testassert.Check(t, len(problems) == 1, "internal/storage importing %q: %d problems, want 1: %v", imp, len(problems), problems) } } - // The SQLite plugin declares the ordinary Runtime-scoped plugin over the - // public runtime and harness contracts and the storage backend it opens; - // the legacy owner, the driver, and sibling plugins never enter it. - { - problems := checkTrackedGoFile("internal/plugins/sqlite/x.go", []string{"context", "path/filepath", publicModule + "/runtime", publicModule + "/harness", publicModule + "/internal/storage"}, std) - testassert.Check(t, len(problems) == 0, "allowed internal/plugins/sqlite imports flagged: %v", problems) - } - for _, imp := range []string{publicModule + "/agent", publicModule + "/internal/agent", sqliteDriverPkg, publicModule + "/internal/plugins/memory"} { - { - problems := checkTrackedGoFile("internal/plugins/sqlite/x.go", []string{imp}, std) - testassert.Check(t, len(problems) == 1, "internal/plugins/sqlite importing %q: %d problems, want 1: %v", imp, len(problems), problems) - } - } - // The native tools plugin declares the four file tools plus run_command, - // process, and sleep over the public runtime and harness contracts, the - // public model types, the jobs capability it requires, and the retained - // shared file-preparation, snapshot, preview, config, path, and - // shell-parse helpers it composes; the legacy owner, the driver, storage, - // and the other sibling plugins never enter it. - { - problems := checkTrackedGoFile("internal/plugins/tools/x.go", []string{ - "bytes", "context", "encoding/json", "errors", "fmt", "io", "math", "path/filepath", "strings", "time", - publicModule + "/model", publicModule + "/harness", publicModule + "/runtime", - publicModule + "/internal/config", publicModule + "/internal/tool", publicModule + "/internal/snapshot", - publicModule + "/internal/editpreview", publicModule + "/internal/pathutil", - publicModule + "/internal/shellparse", publicModule + "/internal/toolargs", publicModule + "/plugins/jobs", - }, std) - testassert.Check(t, len(problems) == 0, "allowed internal/plugins/tools imports flagged: %v", problems) - } - for _, imp := range []string{publicModule + "/agent", publicModule + "/internal/agent", publicModule + "/internal/storage", sqliteDriverPkg, publicModule + "/internal/plugins/memory", publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins"} { - { - problems := checkTrackedGoFile("internal/plugins/tools/x.go", []string{imp}, std) - testassert.Check(t, len(problems) == 1, "internal/plugins/tools importing %q: %d problems, want 1: %v", imp, len(problems), problems) - } - } // The adaptation plugin declares the single Runtime-scoped ModelAdaptation - // export over the public runtime and model contracts and the bundled - // internal/adaptation binding table it resolves; the legacy owner, the - // prompt assembler, storage, the driver, and sibling plugins never enter it. + // export over the public runtime, model, and prompt contracts and the + // bundled internal/adaptation binding table it resolves; the legacy owner, + // storage, the driver, and sibling plugins never enter it. { - problems := checkTrackedGoFile("internal/plugins/adaptation/x.go", []string{"context", publicModule + "/model", publicModule + "/runtime", publicModule + "/internal/adaptation"}, std) + problems := checkTrackedGoFile("internal/plugins/adaptation/x.go", []string{"context", publicModule + "/model", publicModule + "/runtime", publicModule + "/internal/adaptation", publicModule + "/prompt"}, std) testassert.Check(t, len(problems) == 0, "allowed internal/plugins/adaptation imports flagged: %v", problems) } for _, imp := range []string{publicModule + "/agent", publicModule + "/internal/agent", publicModule + "/internal/storage", sqliteDriverPkg, publicModule + "/internal/prompt", publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tools"} { @@ -434,46 +397,29 @@ func TestDependencyRulesRejectNonStdlibDotlessImports(t *testing.T) { testassert.Check(t, len(problems) == 1, "internal/plugins/adaptation importing %q: %d problems, want 1: %v", imp, len(problems), problems) } } - // The LSP plugin declares the diagnostics and workspace_symbol tools over - // the public runtime, harness, and model contracts and the retained - // internal/lsp, snapshot, and path helpers it composes; the legacy owner, - // the legacy tools, storage, the driver, and sibling plugins never enter it. - { - problems := checkTrackedGoFile("internal/plugins/lsp/x.go", []string{ - "context", "encoding/json", "errors", "os", "path/filepath", "strings", "sync", - publicModule + "/model", publicModule + "/harness", publicModule + "/runtime", - publicModule + "/internal/lsp", publicModule + "/internal/snapshot", publicModule + "/internal/pathutil", - publicModule + "/internal/toolargs", - }, std) - testassert.Check(t, len(problems) == 0, "allowed internal/plugins/lsp imports flagged: %v", problems) - } - for _, imp := range []string{publicModule + "/agent", publicModule + "/internal/agent", publicModule + "/internal/storage", sqliteDriverPkg, publicModule + "/internal/tool", publicModule + "/internal/prompt", publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tools", publicModule + "/internal/plugins/adaptation", publicModule + "/internal/plugins"} { - { - problems := checkTrackedGoFile("internal/plugins/lsp/x.go", []string{imp}, std) - testassert.Check(t, len(problems) == 1, "internal/plugins/lsp importing %q: %d problems, want 1: %v", imp, len(problems), problems) - } - } - // The jobs plugin declares the background jobs capability over the public - // runtime and harness contracts and the internal/cmdoutput capture helper - // it composes; the legacy owner, the process manager, storage, the driver, - // and sibling plugins never enter it. + // The jobs service declares the background process capability over the + // harness job contract and the internal/cmdoutput capture helper it + // composes; the runtime owner it serves, the legacy owner, the process + // manager, storage, the driver, the standard tools' helpers, and sibling + // plugins never enter it. { - problems := checkTrackedGoFile("plugins/jobs/x.go", []string{ + problems := checkTrackedGoFile("internal/jobs/x.go", []string{ "bytes", "context", "crypto/rand", "encoding/hex", "encoding/json", "fmt", "os", "os/exec", - "path/filepath", "sort", "sync", "syscall", "time", - publicModule + "/harness", publicModule + "/runtime", publicModule + "/internal/cmdoutput", + "path/filepath", "sort", "strings", "sync", "syscall", "time", + publicModule + "/harness", publicModule + "/internal/cmdoutput", }, std) - testassert.Check(t, len(problems) == 0, "allowed plugins/jobs imports flagged: %v", problems) + testassert.Check(t, len(problems) == 0, "allowed internal/jobs imports flagged: %v", problems) } for _, imp := range []string{ - publicModule + "/internal/process", publicModule + "/internal/tool", publicModule + "/model", + publicModule + "/runtime", publicModule + "/internal/process", publicModule + "/internal/tool", + publicModule + "/internal/toolargs", publicModule + "/internal/shellparse", publicModule + "/model", publicModule + "/agent", publicModule + "/internal/agent", publicModule + "/internal/storage", sqliteDriverPkg, publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins", - publicModule + "/plugins/tasks", + publicModule + "/internal/plugins/tasks", } { { - problems := checkTrackedGoFile("plugins/jobs/x.go", []string{imp}, std) - testassert.Check(t, len(problems) == 1, "plugins/jobs importing %q: %d problems, want 1: %v", imp, len(problems), problems) + problems := checkTrackedGoFile("internal/jobs/x.go", []string{imp}, std) + testassert.Check(t, len(problems) == 1, "internal/jobs importing %q: %d problems, want 1: %v", imp, len(problems), problems) } } // The child-session tool plugin declares the task tool over the public @@ -501,8 +447,8 @@ func TestDependencyRulesRejectNonStdlibDotlessImports(t *testing.T) { // The shared tool-argument decoder is the strict JSON-object data-domain // helper over the standard library only; the foundation contracts, the // target runtime and harness, the plugin tree, its sibling helpers, and - // durable storage never enter it, and its only consumers are the tools, - // tasks and LSP plugins. + // durable storage never enter it, and its only consumers are the tools + // and tasks plugins. { problems := checkTrackedGoFile("internal/toolargs/x.go", []string{"bytes", "encoding/json", "errors", "fmt", "io"}, std) testassert.Check(t, len(problems) == 0, "allowed internal/toolargs imports flagged: %v", problems) @@ -517,13 +463,13 @@ func TestDependencyRulesRejectNonStdlibDotlessImports(t *testing.T) { testassert.Check(t, len(problems) == 1, "internal/toolargs importing %q: %d problems, want 1: %v", imp, len(problems), problems) } } - // The tools, tasks and LSP plugin rows hold the only allowed edges into - // the shared decoder; the nearest forbidden siblings — the other - // concrete plugins, the runtime owner, and any unrelated package — - // importing it stay rejected exactly once. + // The runtime owner and the child-session tool plugin hold the only + // allowed edges into the shared decoder; the nearest forbidden siblings — + // the other concrete plugins, the jobs service, and any unrelated package + // — importing it stay rejected exactly once. for _, rel := range []string{ - "internal/plugins/sqlite/x.go", "internal/plugins/adaptation/x.go", "plugins/jobs/x.go", - "internal/plugins/builtin/x.go", "runtime/x.go", "internal/anything/x.go", + "internal/plugins/adaptation/x.go", "internal/jobs/x.go", + "internal/plugins/builtin/x.go", "internal/anything/x.go", } { { problems := checkTrackedGoFile(rel, []string{publicModule + "/internal/toolargs"}, std) @@ -568,37 +514,41 @@ func TestDependencyRulesRejectNonStdlibDotlessImports(t *testing.T) { problems := checkTrackedGoFile("internal/agents/x.go", []string{publicModule + "/internal/atomicfs"}, std) testassert.Check(t, len(problems) == 1, "internal/agents importing the atomic file helper: %d problems, want 1: %v", len(problems), problems) } - // The runtime target layer consumes the public model, harness, and - // protocol contracts and the retained internal configuration, snapshot, - // safefs and path helpers only; the removed legacy trees (already - // rejected uniformly above), durable storage, concrete plugins, the - // public agent package and the driver never enter it. + // The runtime owner consumes the public model, harness, and protocol + // contracts and the retained helpers it composes directly: configuration, + // agents and catalog reading, atomic files, the public prompt assembler, the + // bundled adaptation table matcher, the durable storage backend, the + // snapshot store, the shared tool engine with its preview, shell-parse, and + // argument-decoder helpers, safe filesystem access, path helpers, and the + // background jobs service. The removed legacy trees (already rejected + // uniformly above), concrete plugins, the public agent package, and the + // SQLite driver never enter it. { - problems := checkTrackedGoFile("runtime/x.go", []string{"fmt", "encoding/json", publicModule + "/model", publicModule + "/harness", publicModule + "/protocol", publicModule + "/internal/config", publicModule + "/internal/agents", publicModule + "/internal/catalog", publicModule + "/internal/atomicfs", publicModule + "/internal/prompt", publicModule + "/internal/adaptation", publicModule + "/internal/snapshot", publicModule + "/internal/safefs", publicModule + "/internal/pathutil"}, std) + problems := checkTrackedGoFile("runtime/x.go", []string{"fmt", "encoding/json", publicModule + "/model", publicModule + "/harness", publicModule + "/protocol", publicModule + "/internal/config", publicModule + "/internal/agents", publicModule + "/internal/catalog", publicModule + "/internal/atomicfs", publicModule + "/prompt", publicModule + "/internal/adaptation", publicModule + "/internal/snapshot", publicModule + "/internal/safefs", publicModule + "/internal/pathutil", publicModule + "/internal/storage", publicModule + "/internal/tool", publicModule + "/internal/editpreview", publicModule + "/internal/shellparse", publicModule + "/internal/toolargs", publicModule + "/internal/jobs"}, std) testassert.Check(t, len(problems) == 0, "allowed runtime imports flagged: %v", problems) } - for _, imp := range []string{publicModule + "/agent", publicModule + "/internal/agent", publicModule + "/internal/storage", sqliteDriverPkg, publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tasks"} { + for _, imp := range []string{publicModule + "/agent", publicModule + "/internal/agent", sqliteDriverPkg, publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tools", publicModule + "/internal/plugins/tasks", publicModule + "/plugins/jobs", publicModule + "/plugins"} { { problems := checkTrackedGoFile("runtime/x.go", []string{imp}, std) testassert.Check(t, len(problems) == 1, "runtime importing %q: %d problems, want 1: %v", imp, len(problems), problems) } } // The shipped registration set imports exactly the runtime contract and - // the six sibling plugins it registers; every sibling helper, the legacy - // owner, storage, the driver, and the public agent package never enter it. + // the two plugins it still registers; the directly constructed store, + // standard tools, and jobs service, every sibling helper, the legacy owner, + // storage, the driver, and the public agent package never enter it. { problems := checkTrackedGoFile("internal/plugins/builtin/x.go", []string{ "context", publicModule + "/runtime", - publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tools", - publicModule + "/internal/plugins/adaptation", publicModule + "/internal/plugins/lsp", - publicModule + "/plugins/jobs", publicModule + "/internal/plugins/tasks", + publicModule + "/internal/plugins/adaptation", publicModule + "/internal/plugins/tasks", }, std) testassert.Check(t, len(problems) == 0, "allowed internal/plugins/builtin imports flagged: %v", problems) } for _, imp := range []string{ publicModule + "/agent", publicModule + "/internal/agent", publicModule + "/internal/storage", sqliteDriverPkg, publicModule + "/internal/tool", publicModule + "/internal/prompt", - publicModule + "/internal/adaptation", publicModule + "/internal/lsp", publicModule + "/internal/plugins", + publicModule + "/internal/adaptation", publicModule + "/internal/jobs", publicModule + "/internal/plugins", + publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tools", publicModule + "/plugins/jobs", } { { problems := checkTrackedGoFile("internal/plugins/builtin/x.go", []string{imp}, std) @@ -748,6 +698,7 @@ func TestDependencyRulesRejectRetiredPackages(t *testing.T) { "internal/project", "internal/permission", "internal/memory", + "plugins/jobs", } { imp := publicModule + "/" + root { @@ -1089,14 +1040,14 @@ func TestDependencyRulesRootEntryRoles(t *testing.T) { func TestDependencyRulesCheckEveryTrackedDirectory(t *testing.T) { std := standardLibraryImports(t) for _, rel := range []string{".hidden/pkg/x.go", "_scaffold/pkg/x.go", "frontend/bindata.go", "node_modules/pkg/x.go", "vendor/pkg/x.go", "rootmisc.go", "internal/anything/x.go", "cmd/desktopclient/x.go", "cmd/desktopclient/sub/x.go"} { - for _, imp := range []string{publicModule + "/model", publicModule + "/agent", publicModule + "/harness", publicModule + "/internal/storage", publicModule + "/runtime", publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tools", publicModule + "/internal/plugins/adaptation", publicModule + "/internal/plugins/lsp", publicModule + "/internal/plugins/builtin", publicModule + "/plugins/jobs", publicModule + "/internal/toolargs", publicModule + "/protocol", publicModule + "/internal/clienthost", publicModule + "/internal/clientview", publicModule + "/internal/clientops", publicModule + "/internal/acpclient", publicModule + "/internal/cliclient", publicModule + "/internal/desktopclient", sqliteDriverPkg} { + for _, imp := range []string{publicModule + "/model", publicModule + "/agent", publicModule + "/harness", publicModule + "/internal/storage", publicModule + "/runtime", publicModule + "/internal/plugins/sqlite", publicModule + "/internal/plugins/tools", publicModule + "/internal/plugins/adaptation", publicModule + "/internal/plugins/builtin", publicModule + "/plugins/jobs", publicModule + "/internal/toolargs", publicModule + "/protocol", publicModule + "/internal/clienthost", publicModule + "/internal/clientview", publicModule + "/internal/clientops", publicModule + "/internal/acpclient", publicModule + "/internal/cliclient", publicModule + "/internal/desktopclient", sqliteDriverPkg} { { problems := checkTrackedGoFile(rel, []string{imp}, std) testassert.Check(t, len(problems) == 1, "tracked %q importing %q: %d problems, want 1: %v", rel, imp, len(problems), problems) } } } - for _, rel := range []string{"model/sub/x.go", "agent/sub/x.go", "harness/sub/x.go", "internal/storage/sub/x.go", "plugins/jobs/sub/x.go"} { + for _, rel := range []string{"model/sub/x.go", "agent/sub/x.go", "harness/sub/x.go", "internal/storage/sub/x.go", "internal/jobs/sub/x.go"} { { problems := checkTrackedGoFile(rel, []string{"fmt"}, std) testassert.Check(t, len(problems) == 1, "subpackage file %q: %d problems, want 1: %v", rel, len(problems), problems) diff --git a/client/client.go b/client/client.go index 08621295..1509b712 100644 --- a/client/client.go +++ b/client/client.go @@ -102,10 +102,10 @@ func Connect(ctx context.Context, discoveryPath string) (*Client, error) { if health.JSON200.ProtocolVersion == "" { return nil, fmt.Errorf("authenticated health carries no protocol version: %w", ErrNoRuntime) } - if live := health.JSON200; live.ProtocolVersion != protocol.N6 { + if live := health.JSON200; live.ProtocolVersion != protocol.N8 { return nil, fmt.Errorf("live protocol version %q: %w", live.ProtocolVersion, ErrIncompatibleProtocol) } - if record.ProtocolVersion != string(protocol.N6) { + if record.ProtocolVersion != string(protocol.N8) { return nil, fmt.Errorf("discovery protocol version %q disagrees with the authenticated live owner: %w", record.ProtocolVersion, ErrNoRuntime) } return &Client{ClientWithResponses: transport, instance: record.InstanceId}, nil diff --git a/client/client_test.go b/client/client_test.go index 84da5da5..311a4e11 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -29,7 +29,7 @@ const ( testCredential = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" // testVersion is the current wire identity every compatible fixture // speaks; the incompatible rows keep the literal old "1". - testVersion = string(protocol.N6) + testVersion = string(protocol.N8) ) // recordContent builds one private four-field discovery document. diff --git a/commands_test.go b/commands_test.go index f2fde417..d4e8220c 100644 --- a/commands_test.go +++ b/commands_test.go @@ -1188,8 +1188,8 @@ func TestRemovedOwnerTransportSymbols(t *testing.T) { // the shutdown endpoint and its trigger, the adapter lease/attachment // machinery, the HTTP service and its handlers, the serve/stop command // bodies, and the transport package with its client. Unqualified Client - // and NewClient stay out of the list: they name the surviving provider and - // LSP clients, which have nothing to do with this transport. + // and NewClient stay out of the list: they name the surviving generated + // protocol clients, which have nothing to do with this transport. symbols := []string{ "owner.lock", "IsStale", diff --git a/frontend/protocol-mounted.test.js b/frontend/protocol-mounted.test.js index e7791a91..9f83e2ad 100644 --- a/frontend/protocol-mounted.test.js +++ b/frontend/protocol-mounted.test.js @@ -42,7 +42,7 @@ mounted('generated SDK round-trips special identity query values over the mounte // 0600 mode; these are the record-shape checks it does not duplicate. expect(discovery.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(discovery.instance_id).toMatch(/^[0-9a-f]{32}$/); - expect(discovery.protocol_version).toBe('6'); + expect(discovery.protocol_version).toBe('8'); // The credential is validated without an assertion diff: a mismatch must // never print the value this check exists to protect. if (typeof discovery.credential !== 'string' || !/^[0-9a-f]{64}$/.test(discovery.credential)) { @@ -52,7 +52,7 @@ mounted('generated SDK round-trips special identity query values over the mounte const client = createClient({ baseUrl: discovery.endpoint, auth: discovery.credential }); const health = await getHealth({ client }); expect(health.error).toBeUndefined(); - expect(health.data).toEqual({ instance_id: discovery.instance_id, protocol_version: '6' }); + expect(health.data).toEqual({ instance_id: discovery.instance_id, protocol_version: '8' }); // The open warning source crosses the generated TypeScript boundary: the // mounted owner's real Runtime-scoped reporter plugin attributes its report @@ -67,31 +67,47 @@ mounted('generated SDK round-trips special identity query values over the mounte message: 'mounted plugin warning', }); - // The settings round-trip carries each compiled plugin's own document as - // one opaque string: the int64 lexeme never passes through a JS number, - // the real tools validator accepts the document, and the read returns the - // same values. Whitespace is normalized because the owning writer keeps - // its existing pretty-printed raw-root bytes — value fidelity, not a new - // byte-formatting contract. + // The settings round-trip carries the top-level tools object with an + // UNKNOWN exact int64 lexeme never passing through a JS number; known + // members remain ordinary valid values. Raw HTTP result/re-read text is + // inspected for the exact lexeme because the generated SDK's JSON.parse + // necessarily rounds numbers beyond 2^53. const canonical = (text) => text.replace(/\s+/g, ''); - const toolsDocument = - '{"command_timeout":60,"max_output_bytes":4096,"read_line_max_chars":3000,"read_max_lines":100,"opaque":9007199254740993}'; + const toolsDocument = '{"command_timeout":60,"max_output_bytes":4096,"read_line_max_chars":3000,"read_max_lines":100,"opaque":9007199254740993}'; + const toolsObject = JSON.parse(toolsDocument, (_key, value, context) => + typeof value === 'number' && context !== undefined && typeof context.source === 'string' + ? JSON.rawJSON(context.source) + : value); const initial = await getConfiguration({ client }); expect(initial.error).toBeUndefined(); - const written = await updateConfigurationSettings({ - client, - body: { settings: { sessions: initial.data.settings.sessions, plugins: { tools: toolsDocument } } }, - }); + const settingsBody = { + sessions: initial.data.settings.sessions, + tools: toolsObject, + jobs: {}, + compaction: {}, + plugins: {}, + }; + const written = await updateConfigurationSettings({ client, body: { settings: settingsBody } }); expect(written.error).toBeUndefined(); - const writtenDocument = written.data?.result.plugins.tools; - expect(writtenDocument).toBeTypeOf('string'); - expect(canonical(writtenDocument)).toBe(canonical(toolsDocument)); - const reread = await getConfiguration({ client }); - expect(reread.error).toBeUndefined(); - const rereadDocument = reread.data?.settings.plugins.tools; - expect(rereadDocument).toBeTypeOf('string'); - expect(rereadDocument).toContain('9007199254740993'); - expect(canonical(rereadDocument)).toBe(canonical(toolsDocument)); + expect(written.data?.result.tools.max_output_bytes).toBe(4096); + expect(written.data?.result.tools.command_timeout).toBe(60); + + // Raw result and re-read text keep the unknown lexeme exact. + const authHeaders = { Authorization: `Bearer ${discovery.credential}` }; + const rawPut = await fetch(`${discovery.endpoint}/v1/configuration/settings`, { + method: 'PUT', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: settingsBody }), + }); + expect(rawPut.status).toBe(200); + const rawResultText = await rawPut.text(); + expect(canonical(rawResultText)).toContain('"opaque":9007199254740993'); + expect(canonical(rawResultText)).toContain('"max_output_bytes":4096'); + const rawRead = await fetch(`${discovery.endpoint}/v1/configuration`, { headers: authHeaders }); + expect(rawRead.status).toBe(200); + const rawReadText = await rawRead.text(); + expect(canonical(rawReadText)).toContain('"opaque":9007199254740993'); + expect(canonical(rawReadText)).toContain('"max_output_bytes":4096'); const window = 4096; for (const id of ['.', '..', '?', '#', '%']) { diff --git a/frontend/src/client/App.svelte b/frontend/src/client/App.svelte index a27f6c01..27697e99 100644 --- a/frontend/src/client/App.svelte +++ b/frontend/src/client/App.svelte @@ -1160,7 +1160,7 @@ on:close={() => closeModal('viewer')} /> {/if} {#if $modal === 'snapshots'} - stampFor('', 'root')()} {workspace} sessionID={rootID} + stampFor('', 'root')()} sessionID={rootID} on:note={(e) => { outcomeNote = e.detail; }} on:close={() => closeModal('snapshots')} /> {/if} diff --git a/frontend/src/client/SettingsEditor.svelte b/frontend/src/client/SettingsEditor.svelte index 7e948416..cbef7bf6 100644 --- a/frontend/src/client/SettingsEditor.svelte +++ b/frontend/src/client/SettingsEditor.svelte @@ -26,10 +26,22 @@ export let stamp; // () => ReaderStamp const dispatch = createEventDispatcher(); - // First-party numeric limit controls; every other plugin entry is raw inspect/edit/remove. - const PLUGIN_FIELDS = { + // Top-level baseline controls: exact JSON members of the tools, jobs and + // compaction objects. Every other plugin entry stays raw + // inspect/edit/remove; tasks remains an optional plugin control. + const BASELINE_FIELDS = { tools: ['max_output_bytes', 'read_max_lines', 'read_line_max_chars', 'command_timeout'], jobs: ['max_background_processes', 'max_output_bytes', 'read_line_max_chars'], + compaction: ['model', 'prompt'], + }; + // Ordinary string controls inside the baseline objects: plain text is + // displayed and JSON.stringify restores the string value without requiring + // quotes or escape sequences from the user. Every other baseline member is + // a raw-JSON token control. + const BASELINE_STRING_FIELDS = { + compaction: ['model', 'prompt'], + }; + const PLUGIN_FIELDS = { tasks: ['max_concurrent', 'max_output_bytes'], }; const SESSION_FIELDS = ['archive_after_days', 'delete_after_archive_days']; @@ -100,6 +112,41 @@ }); } + // A baseline edit patches one member inside its top-level object, then + // places that exact object back into the settings document. + function patchBaseline(section, member, valueText) { + applyEdit((current) => { + const value = parseSettingsObject(current)[section]; + const object = value !== null && typeof value === 'object' && !Array.isArray(value) && !JSON.isRawJSON(value) ? value : {}; + const next = replaceObjectMembers(JSON.stringify(object), composeMemberPatch(member, valueText)); + return replaceObjectMembers(current, composeMemberPatch(section, next)); + }); + } + + function isBaselineString(section, member) { + return Object.hasOwn(BASELINE_STRING_FIELDS, section) && BASELINE_STRING_FIELDS[section].includes(member); + } + + function editBaseline(section, member, enteredText) { + patchBaseline(section, member, isBaselineString(section, member) ? JSON.stringify(enteredText) : enteredText); + } + + // Replace only one top-level section with supplied raw value text through + // the exact member patch, validating the supplied value as one JSON object + // first. Raw-document editing and the reset action share this setter. + function replaceBaselineSection(section, valueText) { + applyEdit((current) => { + parseSettingsObject(valueText); + return replaceObjectMembers(current, composeMemberPatch(section, valueText)); + }); + } + + // Reset one baseline section to its absent-member defaults: only that + // object becomes {}, never a per-member edit. + function resetBaseline(section) { + replaceBaselineSection(section, '{}'); + } + // A plugin edit patches the member, then places the document back into `plugins`. function patchPlugin(pluginID, member, valueText) { applyEdit((current) => { @@ -174,6 +221,26 @@ return member in values ? JSON.stringify(values[member]) : ''; } + function baselineValues(section) { + const value = parsed ? parsed[section] : null; + return value !== null && typeof value === 'object' && !Array.isArray(value) && !JSON.isRawJSON(value) ? value : {}; + } + + function baselineMemberValue(section, member) { + const values = baselineValues(section); + return member in values ? JSON.stringify(values[member]) : ''; + } + + function baselineMemberText(section, member) { + const values = baselineValues(section); + if (!(member in values)) return ''; + return typeof values[member] === 'string' ? values[member] : JSON.stringify(values[member]); + } + + function baselineDocumentText(section) { + return JSON.stringify(baselineValues(section)); + } + async function save() { if (disposed || pending !== 'idle') return; // Capture the text once; inputs are gated for the whole save unit. @@ -247,6 +314,31 @@ {/each} + {#each Object.keys(BASELINE_FIELDS) as section (section)} +
{section}
+ {#if Object.keys(baselineValues(section)).length === 0} +
Uses defaults
+ {/if} + {#each BASELINE_FIELDS[section] as member (member)} + + {/each} + +
+ +
+ {/each} + {#each Object.keys(PLUGIN_FIELDS) as pluginID (pluginID)}
{pluginID} plugin
{#if documentText(pluginID) === null} diff --git a/frontend/src/client/SnapshotsDialog.svelte b/frontend/src/client/SnapshotsDialog.svelte index 968c4089..171918bf 100644 --- a/frontend/src/client/SnapshotsDialog.svelte +++ b/frontend/src/client/SnapshotsDialog.svelte @@ -1,16 +1,13 @@ Retained pre-cutover snapshots -
- {workspace || '(no workspace)'} - { if (e.key === 'Enter') void loadRetained(); }} /> - -
- {#if retainedError} -
{retainedError}
- {:else if loadedRetained && loadedRetained.turns.length > 0} -
Loaded retained target: {loadedRetained.workspace} · {loadedRetained.sessionID}
-
-
- Retained snapshot boundary after turn 0 (initial) - -
-
- {#each loadedRetained.turns as turn (turn.turnText)} -
-
- Retained snapshot boundary after turn {turn.turnText} - -
- {#each turn.files as file (file.path)} -
{file.path}{file.existed ? 'existed' : 'created'}
- {/each} -
- {/each} - {/if} - {#if restoreText.length > 0}
Restore outcome
@@ -206,10 +120,6 @@ .file { display:flex; justify-content:space-between; gap:8px; padding:2px 0; font-family:var(--font-mono); font-size:11px; } .path { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text); } .flag { color:var(--text-dim); flex-shrink:0; } - .retained-row { display:flex; align-items:center; gap:6px; } - .ws { flex:0 1 auto; max-width:38%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--text-dim); font-family:var(--font-mono); font-size:11px; } - .id { flex:1; min-width:0; padding:5px 8px; border:1px solid var(--border-button); border-radius:var(--radius); background:var(--bg-input); color:var(--text); font-family:var(--font-mono); font-size:12px; outline:none; } - .id:focus { border-color:var(--accent); } .ghost { padding:4px 10px; border:1px solid var(--border-button); border-radius:var(--radius); background:none; color:var(--text-dim); font-family:var(--font-ui); font-size:11px; cursor:pointer; flex-shrink:0; } .ghost:hover:not(:disabled) { color:var(--accent); border-color:var(--accent); } .ghost:disabled { opacity:.45; cursor:default; } diff --git a/frontend/src/client/app.test.ts b/frontend/src/client/app.test.ts index 0c5414ba..dadd66fe 100644 --- a/frontend/src/client/app.test.ts +++ b/frontend/src/client/app.test.ts @@ -59,7 +59,6 @@ const sdk: Record> = { ForkSession: vi.fn(), GetConfiguration: vi.fn(), GetProviderDetail: vi.fn(), - GetRetainedCodeSnapshots: vi.fn(), GetSessionCodeSnapshots: vi.fn(), GetSessionHistory: vi.fn(), GetSessionHydration: vi.fn(), @@ -74,7 +73,6 @@ const sdk: Record> = { ReopenSession: vi.fn(), ResetProviderField: vi.fn(), ResetProviderModelField: vi.fn(), - RevertRetainedCode: vi.fn(), RevertSessionCode: vi.fn(), SetAgentTypeModel: vi.fn(), SetSessionAgentType: vi.fn(), @@ -1497,11 +1495,35 @@ describe('repair', () => { const RAW_CONFIGURATION = '{"configuration_revision":{"instance_id":"' + INSTANCE + '","generation":"1"},' + '"settings":{"sessions":{"auto_archive":false,"archive_after_days":-9223372036854775808,"delete_after_archive_days":1},' - + '"plugins":{"tools":"{\\"max_output_bytes\\":9007199254740993}"}},' + + '"tools":{"max_output_bytes":9007199254740993},"jobs":{},"compaction":{},"plugins":{}},' + '"agents":[],"providers":[]}'; const EXACT_SETTINGS_TEXT = '{"sessions":{"auto_archive":false,"archive_after_days":-9223372036854775808,"delete_after_archive_days":1},' - + '"plugins":{"tools":"{\\"max_output_bytes\\":9007199254740993}"}}'; + + '"tools":{"max_output_bytes":9007199254740993},"jobs":{},"compaction":{},"plugins":{}}'; + +// One authored baseline shape for the section-reset oracles: every top-level +// object carries a custom value, the tools object carries the exact int64 +// lexeme, and one optional plugin document stays a string. +const RESET_SETTINGS_TEXT = '{"sessions":{"auto_archive":false,"archive_after_days":3,"delete_after_archive_days":2},' + + '"tools":{"max_output_bytes":9007199254740993,"command_timeout":60},' + + '"jobs":{"max_background_processes":5},' + + '"compaction":{"model":"prov/m2","prompt":"custom prompt"},' + + '"plugins":{"tasks":"{\\"max_concurrent\\":2}"}}'; + +const RESET_CONFIGURATION = '{"configuration_revision":{"instance_id":"' + INSTANCE + '","generation":"1"},' + + '"settings":' + RESET_SETTINGS_TEXT + ',"agents":[],"providers":[]}'; + +// Raw-document editing fixture: every baseline object carries an exact int64 +// unknown member plus ordinary siblings, so deleting one known member can be +// observed without any other token moving. +const RAW_EDIT_SETTINGS_TEXT = '{"sessions":{"auto_archive":false,"archive_after_days":3,"delete_after_archive_days":2},' + + '"tools":{"command_timeout":60,"max_output_bytes":4096,"read_max_lines":100,"unknown_tools":9007199254740993},' + + '"jobs":{"max_background_processes":5,"read_line_max_chars":4000,"unknown_jobs":9007199254740993},' + + '"compaction":{"model":"prov/m2","prompt":"custom prompt","unknown_compaction":9007199254740993},' + + '"plugins":{"tasks":"{\\"max_concurrent\\":2}"}}'; + +const RAW_EDIT_CONFIGURATION = '{"configuration_revision":{"instance_id":"' + INSTANCE + '","generation":"1"},' + + '"settings":' + RAW_EDIT_SETTINGS_TEXT + ',"agents":[],"providers":[]}'; function inputItem(itemID: string, origin: 'user' | 'runtime' | 'plugin', operationID?: string) { return { @@ -1643,14 +1665,6 @@ async function openSnapshots(target: HTMLElement) { (target.querySelector('[aria-label="Snapshots"]') as HTMLElement).click(); } -// readRetainedSnapshots targets one retained session through the loaded -// Workspace; the caller's settles, held replies and draft retargets stay in -// the case. -async function readRetainedSnapshots(target: HTMLElement, id: string) { - await changeInput(target, 'Retained session id', id); - (target.querySelector('[aria-label="Read retained snapshots"]') as HTMLElement).click(); -} - // The richer Fork fixture: four inputs and three owned operations, plus the // plain Fork success default the restore cases rely on. Per-case overrides and // scripts stay with their cases. @@ -2603,7 +2617,7 @@ describe('settings editor', () => { const connection = settingsRawConnection(); const { target } = await mountApp(connection); await openSettings(target); - expect(target.textContent).toContain('Uses plugin defaults'); + expect(target.querySelector('[aria-label="jobs uses defaults"]')).not.toBeNull(); const readsBeforeSave = vi.mocked(sdk.GetConfiguration).mock.calls.length; (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); await settle(); @@ -2616,21 +2630,21 @@ describe('settings editor', () => { expect(vi.mocked(sdk.GetConfiguration).mock.calls.length).toBeGreaterThanOrEqual(readsBeforeSave + 1); }); - it('authors an absent plugin document on its first member edit, one member at a time', async () => { + it('authors an absent baseline section on its first member edit, one member at a time', async () => { const connection = settingsRawConnection(); const { target } = await mountApp(connection); await openSettings(target); - expect(target.textContent).toContain('Uses plugin defaults'); + expect(target.querySelector('[aria-label="jobs uses defaults"]')).not.toBeNull(); await changeInput(target, 'jobs max_background_processes', '3'); await changeInput(target, 'jobs max_output_bytes', '9'); (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); await settle(); await settle(); const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); - // The absent document became {}-plus-its-edited-members, composed one member - // at a time, and the unedited tools document keeps its exact lexeme. - expect(body).toContain('"jobs":"{\\"max_background_processes\\":3,\\"max_output_bytes\\":9}"'); - expect(body).toContain('"tools":"{\\"max_output_bytes\\":9007199254740993}"'); + // The absent object became {}-plus-its-edited-members, composed one member + // at a time, and the unedited tools object keeps its exact lexeme. + expect(body).toContain('"jobs":{"max_background_processes":3,"max_output_bytes":9}'); + expect(body).toContain('"tools":{"max_output_bytes":9007199254740993}'); }); it('gates every authored control while a save is pending and sends the complete body with no precondition', async () => { @@ -2661,6 +2675,179 @@ describe('settings editor', () => { expect((target.querySelector('[aria-label="Save settings"]') as HTMLButtonElement).disabled).toBe(false); }); + it('edits compaction settings and the compact Agent model independently', async () => { + const connection = settingsRawConnection( + '{"configuration_revision":{"instance_id":"' + INSTANCE + '","generation":"1"},' + + '"settings":{"sessions":{"auto_archive":false,"archive_after_days":1,"delete_after_archive_days":1},' + + '"tools":{"max_output_bytes":9007199254740993},"jobs":{},"compaction":{},"plugins":{}},' + + '"agents":[{"name":"primary","description":"Primary","subagent":false,"model":"","readonly":false,"write_dir":"","tools":[],"capabilities":[]},' + + '{"name":"compact","description":"Compaction","subagent":false,"model":"","readonly":true,"write_dir":"","tools":[],"capabilities":[]}],' + + '"providers":[]}', + ); + let selectedCompact = false; + vi.mocked(sdk.GetSessionHydration).mockImplementation(async () => json(200, hydration({ + session: { ...session('root1', NEWER), agent_type: selectedCompact ? 'compact' : 'primary' }, + }))); + vi.mocked(sdk.SetSessionAgentType).mockResolvedValue(json(200, { ...session('root1', NEWER), agent_type: 'compact' })); + vi.mocked(sdk.ListModels).mockResolvedValue(json(200, { + configuration_revision: { instance_id: INSTANCE, generation: '1' }, + models: [{ ref: 'prov/compact-model', provider: 'prov', provider_name: 'Provider', model: 'compact-model', display_name: 'Compact model', context_window: 1000, max_output_tokens: 10, hidden: false, provider_hidden: false, incomplete: false, source: 'user' }], + })); + vi.mocked(sdk.SetAgentTypeModel).mockResolvedValue(json(200, { + configuration_revision: { instance_id: INSTANCE, generation: '2' }, + result: { name: 'compact', description: 'Compaction', subagent: false, model: 'prov/compact-model', readonly: true, write_dir: '', tools: [], capabilities: [] }, + })); + const { target } = await mountApp(connection); + await openSettings(target); + // Ordinary string controls: the model ref and a real multiline prompt are + // entered as plain text, never as quoted JSON or escape sequences. + await changeInput(target, 'compaction model', 'prov/compact-model'); + await changeInput(target, 'compaction prompt', 'first line\nsecond line'); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); + // The baseline compaction object carries the entered strings exactly and + // leaves every other settings member intact; the independently selectable + // compact Agent is a configuration-view roster entry, not this object. + expect(JSON.parse(body).settings.compaction).toEqual({ + model: 'prov/compact-model', + prompt: 'first line\nsecond line', + }); + expect(body).toContain('"tools":{"max_output_bytes":9007199254740993}'); + expect(body).toContain('"plugins":{}'); + expect(vi.mocked(sdk.SetAgentTypeModel)).not.toHaveBeenCalled(); + + // The actual compact Agent selection and its type-wide model action use + // SetSessionAgentType and SetAgentTypeModel only; they never write the + // baseline settings owner. + (target.querySelector('[aria-label="Agent type"]') as HTMLElement).click(); + await settle(); + selectedCompact = true; + (target.querySelector('[aria-label="Set session agent type compact"]') as HTMLElement).click(); + await settle(); + await settle(); + expect(vi.mocked(sdk.SetSessionAgentType)).toHaveBeenCalledWith('{"session_id":"root1","body":{"agent_type":"compact"}}'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'm', ctrlKey: true })); + await settle(); + expect(target.textContent).toContain('compact'); + (target.querySelector('.mp-btn') as HTMLElement).click(); + await settle(); + (target.querySelector('.mp .item') as HTMLElement).click(); + await settle(); + expect(vi.mocked(sdk.SetAgentTypeModel)).toHaveBeenCalledWith('{"query":{"agent_type":"compact"},"body":{"model":"prov/compact-model"}}'); + expect(vi.mocked(sdk.UpdateConfigurationSettings)).toHaveBeenCalledTimes(1); + }); + + it('resets only the tools section to defaults', async () => { + const connection = settingsRawConnection(RESET_CONFIGURATION); + const { target } = await mountApp(connection); + await openSettings(target); + (target.querySelector('[aria-label="Reset tools to defaults"]') as HTMLElement).click(); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); + const settings = JSON.parse(body).settings; + expect(settings.tools).toEqual({}); + expect(settings.jobs).toEqual({ max_background_processes: 5 }); + expect(settings.compaction).toEqual({ model: 'prov/m2', prompt: 'custom prompt' }); + expect(settings.plugins).toEqual({ tasks: '{"max_concurrent":2}' }); + }); + + it('resets only the jobs section to defaults', async () => { + const connection = settingsRawConnection(RESET_CONFIGURATION); + const { target } = await mountApp(connection); + await openSettings(target); + (target.querySelector('[aria-label="Reset jobs to defaults"]') as HTMLElement).click(); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); + const settings = JSON.parse(body).settings; + expect(settings.jobs).toEqual({}); + expect(settings.tools).toEqual({ max_output_bytes: 9007199254740992, command_timeout: 60 }); + expect(body).toContain('"max_output_bytes":9007199254740993'); + expect(settings.compaction).toEqual({ model: 'prov/m2', prompt: 'custom prompt' }); + expect(settings.plugins).toEqual({ tasks: '{"max_concurrent":2}' }); + }); + + it('resets only the compaction section to defaults', async () => { + const connection = settingsRawConnection(RESET_CONFIGURATION); + const { target } = await mountApp(connection); + await openSettings(target); + (target.querySelector('[aria-label="Reset compaction to defaults"]') as HTMLElement).click(); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); + const settings = JSON.parse(body).settings; + expect(settings.compaction).toEqual({}); + expect(settings.tools).toEqual({ max_output_bytes: 9007199254740992, command_timeout: 60 }); + expect(body).toContain('"max_output_bytes":9007199254740993'); + expect(settings.jobs).toEqual({ max_background_processes: 5 }); + expect(settings.plugins).toEqual({ tasks: '{"max_concurrent":2}' }); + }); + + it('raw-edits one baseline object and deletes only the named members', async () => { + const connection = settingsRawConnection(RAW_EDIT_CONFIGURATION); + const { target } = await mountApp(connection); + await openSettings(target); + // Delete only prompt from compaction, read_max_lines from tools and + // read_line_max_chars from jobs; each replacement is the exact object + // text, so unknown large lexemes and sibling members stay untouched. + await changeInput(target, 'compaction raw document', '{"model":"prov/m2","unknown_compaction":9007199254740993}'); + await changeInput(target, 'tools raw document', '{"command_timeout":60,"max_output_bytes":4096,"unknown_tools":9007199254740993}'); + await changeInput(target, 'jobs raw document', '{"max_background_processes":5,"unknown_jobs":9007199254740993}'); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); + expect(body).toContain('"compaction":{"model":"prov/m2","unknown_compaction":9007199254740993}'); + expect(body).not.toContain('custom prompt'); + expect(body).toContain('"tools":{"command_timeout":60,"max_output_bytes":4096,"unknown_tools":9007199254740993}'); + expect(body).not.toContain('read_max_lines'); + expect(body).toContain('"jobs":{"max_background_processes":5,"unknown_jobs":9007199254740993}'); + expect(body).not.toContain('read_line_max_chars'); + expect(body).toContain('"plugins":{"tasks":"{\\"max_concurrent\\":2}"}'); + }); + + it('raw-edits compaction to omit model independently with prompt retained', async () => { + const connection = settingsRawConnection(RAW_EDIT_CONFIGURATION); + const { target } = await mountApp(connection); + await openSettings(target); + await changeInput(target, 'compaction raw document', '{"prompt":"custom prompt","unknown_compaction":9007199254740993}'); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); + expect(body).toContain('"compaction":{"prompt":"custom prompt","unknown_compaction":9007199254740993}'); + expect(body).not.toContain('"model":"prov/m2"'); + expect(body).toContain('"tools":{"command_timeout":60,"max_output_bytes":4096,"read_max_lines":100,"unknown_tools":9007199254740993}'); + expect(body).toContain('"jobs":{"max_background_processes":5,"read_line_max_chars":4000,"unknown_jobs":9007199254740993}'); + }); + + it('rejects a non-object raw section draft and keeps the prior exact object', async () => { + const connection = settingsRawConnection(RAW_EDIT_CONFIGURATION); + const { target } = await mountApp(connection); + await openSettings(target); + await changeInput(target, 'compaction raw document', '[]'); + expect(target.textContent).toContain('Edit rejected'); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const first = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); + expect(first).toContain('"compaction":{"model":"prov/m2","prompt":"custom prompt","unknown_compaction":9007199254740993}'); + + await changeInput(target, 'compaction raw document', '{"model":"prov/m3","unknown_compaction":9007199254740993}'); + (target.querySelector('[aria-label="Save settings"]') as HTMLElement).click(); + await settle(); + await settle(); + const second = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[1][0] as never); + expect(second).toContain('"compaction":{"model":"prov/m3","unknown_compaction":9007199254740993}'); + expect(second).not.toContain('custom prompt'); + }); + it('displays a known settings save rejection and retains the editor draft', async () => { const connection = settingsRawConnection(); vi.mocked(sdk.UpdateConfigurationSettings).mockResolvedValueOnce( @@ -3510,55 +3697,7 @@ describe('code snapshots', () => { expect(target.textContent).toContain('Restore failed: partial failure'); }); - it('reads retained turns as exact text and sends after_turn as an exact token', async () => { - const connection = fakeConnection(); - basicMocks(connection); - vi.mocked(sdk.GetRetainedCodeSnapshots).mockResolvedValue(native(200, - '{"turns":[{"turn":9007199254740993,"files":[{"path":"legacy.ts","existed":true}]}]}', - false)); - vi.mocked(sdk.RevertRetainedCode).mockRejectedValue(new Error('retained rewind lost')); - const { target } = await mountApp(connection); - await openSnapshots(target); - await settle(); - await readRetainedSnapshots(target, 'abcd1234'); - await settle(); - await settle(); - expect(target.textContent).toContain('after turn 9007199254740993'); - (target.querySelector('[aria-label="Revert after retained turn 9007199254740993"]') as HTMLElement).click(); - await settle(); - await settle(); - const call = vi.mocked(sdk.RevertRetainedCode).mock.calls[0][0] as string; - expect(bodyOf(call)).toBe('{"workspace":"/ws","session_id":"abcd1234","after_turn":9007199254740993}'); - expect(target.textContent).toContain('Rewind outcome unknown'); - }); - it('offers the initial retained boundary 0 for a loaded target with only later turns', async () => { - const connection = fakeConnection(); - basicMocks(connection); - vi.mocked(sdk.GetRetainedCodeSnapshots).mockResolvedValue(native(200, - '{"turns":[{"turn":1,"files":[{"path":"legacy.ts","existed":true}]}]}', - false)); - vi.mocked(sdk.RevertRetainedCode).mockRejectedValue(new Error('retained rewind lost')); - const { target } = await mountApp(connection); - await openSnapshots(target); - await settle(); - await readRetainedSnapshots(target, 'abcd1234'); - await settle(); - await settle(); - // The existing per-turn boundary row keeps its exact lexeme. - expect(target.querySelector('[aria-label="Revert after retained turn 1"]')).not.toBeNull(); - // The draft retarget must not retarget the initial boundary either. - await changeInput(target, 'Retained session id', 'ffffffff'); - const initial = target.querySelector('[aria-label="Revert to initial retained boundary"]'); - expect(initial).not.toBeNull(); - (initial as HTMLElement).click(); - await settle(); - await settle(); - const call = vi.mocked(sdk.RevertRetainedCode).mock.calls[0][0] as string; - expect(bodyOf(call)).toBe('{"workspace":"/ws","session_id":"abcd1234","after_turn":0}'); - expect(target.textContent).toContain('Retained snapshot boundary after turn 0'); - expect(target.textContent).toContain('Rewind outcome unknown'); - }); it('reports a current rewind outcome addressed to its loaded Session', async () => { const connection = fakeConnection(); @@ -3584,27 +3723,6 @@ describe('code snapshots', () => { expect(note).toContain('Restore failed: partial failure'); }); - it('reports a retained rewind outcome addressed to its loaded Workspace and ID', async () => { - const connection = fakeConnection(); - basicMocks(connection); - vi.mocked(sdk.GetRetainedCodeSnapshots).mockResolvedValue(native(200, - '{"turns":[{"turn":1,"files":[{"path":"legacy.ts","existed":true}]}]}', - false)); - vi.mocked(sdk.RevertRetainedCode).mockResolvedValue(json(200, { restored: ['legacy.ts'], skipped: [] })); - const { target } = await mountApp(connection); - await openSnapshots(target); - await settle(); - await readRetainedSnapshots(target, 'abcd1234'); - await settle(); - await settle(); - (target.querySelector('[aria-label="Revert to initial retained boundary"]') as HTMLElement).click(); - await settle(); - await settle(); - const note = target.querySelector('.note.outcome')?.textContent ?? ''; - expect(note).toContain('/ws'); - expect(note).toContain('abcd1234'); - expect(note).toContain('Restored: legacy.ts'); - }); it('reports a pending rewind after a repair unmounts the dialog, without retransmitting', async () => { const connection = fakeConnection(); @@ -4278,12 +4396,12 @@ describe('read lifetime boundaries', () => { await settle(); await settle(); expect(target.querySelector('[aria-label="Close settings"]')).toBeNull(); - expect(target.textContent).not.toContain('Uses plugin defaults'); + expect(target.querySelector('[aria-label="jobs uses defaults"]')).toBeNull(); // A reopen is a fresh read, not the superseded body. (target.querySelector('[aria-label="Settings"]') as HTMLElement).click(); await settle(); await settle(); - expect(target.textContent).toContain('Uses plugin defaults'); + expect(target.querySelector('[aria-label="jobs uses defaults"]')).not.toBeNull(); }); it('a reopened model dialog discards an older same-stamp read and keeps the newer loading', async () => { @@ -4327,7 +4445,7 @@ describe('read lifetime boundaries', () => { // --- First-review correction regressions: settings edit tail and documents ---- const EDITED_SESSIONS_TEXT = '{"sessions":{"auto_archive":false,"archive_after_days":5,"delete_after_archive_days":1},' - + '"plugins":{"tools":"{\\"max_output_bytes\\":9007199254740993}"}}'; + + '"tools":{"max_output_bytes":9007199254740993},"jobs":{},"compaction":{},"plugins":{}}'; function settingsRawConnection(raw = RAW_CONFIGURATION) { const connection = fakeConnection(); @@ -4336,7 +4454,10 @@ function settingsRawConnection(raw = RAW_CONFIGURATION) { vi.mocked(sdk.GetConfiguration).mockImplementation(async () => native(200, raw, false)); vi.mocked(sdk.UpdateConfigurationSettings).mockResolvedValue(json(200, { configuration_revision: { instance_id: INSTANCE, generation: '2' }, - result: { sessions: { auto_archive: false, archive_after_days: 1, delete_after_archive_days: 1 }, plugins: {} }, + result: { + sessions: { auto_archive: false, archive_after_days: 1, delete_after_archive_days: 1 }, + tools: {}, jobs: {}, compaction: {}, plugins: {}, + }, })); return connection; } @@ -4361,7 +4482,7 @@ describe('settings local edit tail', () => { expect(bodyOf(call)).toContain('"delete_after_archive_days":7'); }); - it('keeps every unedited numeric lexeme of a local plugin edit exact', async () => { + it('keeps every unedited numeric lexeme of a local baseline edit exact', async () => { const connection = settingsRawConnection(); const { target } = await mountApp(connection); await openSettings(target); @@ -4371,8 +4492,8 @@ describe('settings local edit tail', () => { await settle(); const body = bodyOf(vi.mocked(sdk.UpdateConfigurationSettings).mock.calls[0][0] as never); expect(body).toContain('"archive_after_days":-9223372036854775808'); - expect(body).toContain('"tools":"{\\"max_output_bytes\\":9007199254740993}"'); - expect(body).toContain('"jobs":"{\\"max_background_processes\\":3}"'); + expect(body).toContain('"tools":{"max_output_bytes":9007199254740993}'); + expect(body).toContain('"jobs":{"max_background_processes":3}'); }); it('a rejected local edit shows its error, leaves the document unchanged and a later edit recovers', async () => { @@ -4431,48 +4552,7 @@ describe('loaded snapshot identity', () => { expect(vi.mocked(sdk.GetSessionCodeSnapshots).mock.calls.at(-1)?.[0]).toEqual('{"session_id":"root2"}'); }); - it('retained revert uses the loaded Workspace/ID and boundary after the draft ID changes', async () => { - const connection = fakeConnection(); - basicMocks(connection); - vi.mocked(sdk.GetRetainedCodeSnapshots).mockResolvedValue(native(200, - '{"turns":[{"turn":9007199254740993,"files":[{"path":"legacy.ts","existed":true}]}]}', - false)); - vi.mocked(sdk.RevertRetainedCode).mockResolvedValue(json(200, { restored: ['legacy.ts'], skipped: [] })); - const { target } = await mountApp(connection); - await openSnapshots(target); - await settle(); - await readRetainedSnapshots(target, 'abcd1234'); - await settle(); - await settle(); - expect(target.textContent).toContain('Loaded retained target: /ws · abcd1234'); - // A draft retarget must not silently retarget the displayed rows/actions. - await changeInput(target, 'Retained session id', 'ffffffff'); - (target.querySelector('[aria-label="Revert after retained turn 9007199254740993"]') as HTMLElement).click(); - await settle(); - await settle(); - const call = vi.mocked(sdk.RevertRetainedCode).mock.calls[0][0] as string; - expect(bodyOf(call)).toBe('{"workspace":"/ws","session_id":"abcd1234","after_turn":9007199254740993}'); - }); - it('invalidates a pending retained read when its draft target changes', async () => { - const connection = fakeConnection(); - basicMocks(connection); - const { promise: pending, resolve: releaseRead } = Promise.withResolvers(); - vi.mocked(sdk.GetRetainedCodeSnapshots).mockImplementationOnce(() => pending as never); - const { target } = await mountApp(connection); - await openSnapshots(target); - await settle(); - await readRetainedSnapshots(target, 'abcd1234'); - await settle(); - // The draft target changes while the read is parked. - await changeInput(target, 'Retained session id', 'ffffffff'); - releaseRead(native(200, '{"turns":[{"turn":1,"files":[]}]}', false)); - await settle(); - await settle(); - // The stale completion publishes nothing. - expect(target.textContent).not.toContain('after turn 1'); - expect(target.textContent).not.toContain('Loaded retained target'); - }); }); describe('workspace and empty-selection repair', () => { diff --git a/frontend/src/client/connection.test.ts b/frontend/src/client/connection.test.ts index 55c654db..e94b9c1b 100644 --- a/frontend/src/client/connection.test.ts +++ b/frontend/src/client/connection.test.ts @@ -40,7 +40,7 @@ describe('RuntimeConnection.open', () => { vi.mocked(NextEvent).mockImplementation(() => new Promise(() => {})); vi.mocked(Call).mockResolvedValue({ status: 200, - body: '{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"6"}', + body: '{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"8"}', invalid: false, }); const connection = new RuntimeConnection(); diff --git a/frontend/src/client/exactjson.test.ts b/frontend/src/client/exactjson.test.ts index fa6273c9..9edbdb76 100644 --- a/frontend/src/client/exactjson.test.ts +++ b/frontend/src/client/exactjson.test.ts @@ -1,7 +1,8 @@ // The exact-JSON editor helpers: source-context parsing keeps every unedited // numeric lexeme (including signed int64 tokens beyond Number precision) -// exactly as the document carried it, plugin documents stay their exact string -// values, and the composed patches/fork bodies carry raw user tokens verbatim. +// exactly as the document carried it, top-level baseline objects stay raw +// objects, optional plugin documents stay their exact string values, and the +// composed patches/fork bodies carry raw user tokens verbatim. import { describe, expect, it } from 'vitest'; import { @@ -14,14 +15,13 @@ import { parseExactJSON, parseSettingsObject, presentNumber, - rawJSONText, replaceObjectMembers, - readRetainedTurnsText, } from './exactjson'; const CONFIGURATION = '{"configuration_revision":{"instance_id":"0123456789abcdef0123456789abcdef","generation":"1"},' + '"settings":{"sessions":{"auto_archive":false,"archive_after_days":-9223372036854775808,"delete_after_archive_days":1e400},' - + '"plugins":{"tools":"{\\"max_output_bytes\\":9007199254740993,\\"command_timeout\\":1e400}"}},"agents":[],"providers":[]}'; + + '"tools":{"max_output_bytes":9007199254740993,"command_timeout":1e400},"jobs":{},"compaction":{},' + + '"plugins":{"tasks":"{\\"max_concurrent\\":2}"}},"agents":[],"providers":[]}'; describe('parseExactJSON', () => { it('preserves exact numeric source tokens through the ordinary round trip', () => { @@ -32,8 +32,6 @@ describe('parseExactJSON', () => { it('exposes the raw token and renders booleans and strings ordinarily', () => { const parsed = parseExactJSON('{"n":-9223372036854775808,"flag":true,"s":"x"}') as Record; - expect(rawJSONText(parsed.n)).toBe('-9223372036854775808'); - expect(rawJSONText(parsed.flag)).toBeNull(); expect(JSON.stringify(parsed.n)).toBe('-9223372036854775808'); expect(JSON.stringify(parsed.flag)).toBe('true'); expect(JSON.stringify(parsed.s)).toBe('"x"'); @@ -99,14 +97,16 @@ describe('replaceObjectMembers', () => { describe('settings extraction', () => { it('keeps the exact settings subtree, numeric tokens and plugin document values', () => { const settingsText = extractSettingsText(CONFIGURATION); - // The unedited interval tokens survive byte-for-byte. + // The unedited interval and baseline-section tokens survive byte-for-byte. expect(settingsText).toContain('-9223372036854775808'); expect(settingsText).toContain('1e400'); expect(settingsText).toContain('9007199254740993'); const parsed = parseSettingsObject(settingsText); expect(parsed.sessions.auto_archive).toBe(false); - // The plugin document is carried as its exact string value. - expect(parsed.plugins.tools).toBe('{"max_output_bytes":9007199254740993,"command_timeout":1e400}'); + // The baseline tools object is carried raw, never as a document string. + expect(JSON.stringify(parsed.tools)).toBe('{"max_output_bytes":9007199254740993,"command_timeout":1e400}'); + // The optional plugin document is carried as its exact string value. + expect(parsed.plugins.tasks).toBe('{"max_concurrent":2}'); }); it('rejects a document without a settings member', () => { @@ -158,7 +158,7 @@ describe('patch composition', () => { }); }); -describe('fork body and retained turns', () => { +describe('fork body', () => { it('composes the Fork body from the immutable IDs and the exact replacement text', () => { const body = composeForkBody('item-1', 'abcdef', '[{"kind":"text","text":"x"}]'); expect(JSON.parse(body)).toEqual({ @@ -170,32 +170,4 @@ describe('fork body and retained turns', () => { expect(composeForkBody('i', 'o', '[not json')).toBe('{"boundary_item_id":"i","operation_id":"o","content":[not json}'); }); - it('reads retained turns with their exact boundary tokens', () => { - const document = parseExactJSON('{"turns":[{"turn":9007199254740993,"files":[{"path":"a","existed":true}]}]}'); - const turns = readRetainedTurnsText(document); - expect(turns).toEqual([{ turnText: '9007199254740993', files: [{ path: 'a', existed: true }] }]); - }); - - it('rejects a retained turn without a numeric boundary or files', () => { - expect(() => readRetainedTurnsText({ turns: [{ files: [] }] })).toThrow(); - expect(() => readRetainedTurnsText({ turns: [{ turn: 1 }] })).toThrow(); - expect(() => readRetainedTurnsText({})).toThrow(); - }); - - it('returns the retained files array and its file objects by reference', () => { - const document = parseExactJSON('{"turns":[{"turn":9007199254740993,"files":[{"path":"a","existed":true,"extra":1}]}]}') as { - turns: Array<{ files: unknown[] }>; - }; - const turns = readRetainedTurnsText(document); - expect(turns[0].turnText).toBe('9007199254740993'); - // The consumed shape is validated in place; the original array and objects - // are returned, never rebuilt (the uninterpreted extra member survives). - expect(turns[0].files).toBe(document.turns[0].files); - expect(turns[0].files[0]).toBe(document.turns[0].files[0]); - // Malformed consumed shapes still throw. - expect(() => readRetainedTurnsText({ turns: [{ turn: 1, files: [null] }] })).toThrow(); - expect(() => readRetainedTurnsText({ turns: [{ turn: 1, files: [{}] }] })).toThrow(); - expect(() => readRetainedTurnsText({ turns: [{ turn: {}, files: [] }] })).toThrow(); - expect(() => readRetainedTurnsText({ turns: [{ turn: '5', files: [] }] })).toThrow(); - }); }); diff --git a/frontend/src/client/exactjson.ts b/frontend/src/client/exactjson.ts index ed49f277..07627655 100644 --- a/frontend/src/client/exactjson.ts +++ b/frontend/src/client/exactjson.ts @@ -1,7 +1,7 @@ // The exact-JSON text helpers: parse source access plus JSON.rawJSON keep every // numeric lexeme exactly as carried, and object edits are local member // replacement over that parser. -import type { JsonObject, SnapshotFile } from '../generated/protocol/types.gen'; +import type { JsonObject } from '../generated/protocol/types.gen'; // Parse one document, replacing every number with its exact source token. export function parseExactJSON(text: string): unknown { @@ -13,14 +13,6 @@ export function parseExactJSON(text: string): unknown { }); } -export function rawJSONText(value: unknown): string | null { - if (value === null || typeof value !== 'object') { - return null; - } - const candidate = value as { rawJSON?: unknown }; - return typeof candidate.rawJSON === 'string' ? candidate.rawJSON : null; -} - // Presentation-only numeric interpretation at the typed display boundaries: // one exact raw token becomes its ordinary Number value, and every other value // (including an ordinary object with a rawJSON member) is returned unchanged. @@ -108,51 +100,3 @@ export function composeForkBody(boundaryItemID: string, operationID: string, con ['content', contentText], ]); } - -// One retained turn: exact numeric boundary token plus the generated file -// objects returned by reference. -export interface RetainedTurnText { - turnText: string; - files: SnapshotFile[]; -} - -export function readRetainedTurnsText(document: unknown): RetainedTurnText[] { - if (document === null || typeof document !== 'object') { - throw new Error('retained snapshot document is not a JSON object'); - } - const turns = (document as { turns?: unknown }).turns; - if (!Array.isArray(turns)) { - throw new Error('retained snapshot document carries no turns array'); - } - return turns.map((entry) => { - if (entry === null || typeof entry !== 'object') { - throw new Error('retained turn is not a JSON object'); - } - const turn = (entry as { turn?: unknown }).turn; - // The accepted number/raw-token rule, then native JSON.stringify derives the - // exact boundary text (JSON.rawJSON included). - if (typeof turn !== 'number' && rawJSONText(turn) === null) { - throw new Error('retained turn boundary is not a JSON number'); - } - const turnText = JSON.stringify(turn); - if (turnText === undefined) { - throw new Error('retained turn boundary is not a JSON number'); - } - const files = (entry as { files?: unknown }).files; - if (!Array.isArray(files)) { - throw new Error('retained turn carries no files array'); - } - // Validate the consumed shape in place; the original array and file objects - // are returned by reference, never rebuilt. - for (const file of files) { - if (file === null || typeof file !== 'object') { - throw new Error('retained snapshot file is not a JSON object'); - } - const candidate = file as { path?: unknown; existed?: unknown }; - if (typeof candidate.path !== 'string' || typeof candidate.existed !== 'boolean') { - throw new Error('retained snapshot file is malformed'); - } - } - return { turnText, files: files as SnapshotFile[] }; - }); -} diff --git a/frontend/src/client/nativecontract.test.ts b/frontend/src/client/nativecontract.test.ts index b48d5089..f146bbb9 100644 --- a/frontend/src/client/nativecontract.test.ts +++ b/frontend/src/client/nativecontract.test.ts @@ -96,10 +96,7 @@ describe('native invocation contract', () => { rejects('GetSessionHistory', '{"session_id":"root1","query":{"cursor":5}}'); rejects('GetSessionHistory', '{"session_id":"root1","query":{"bogus":1}}'); // The envelope's session_id belongs only to the operations that declare a - // session path; the retained query's own session_id is a query member. - rejects('GetRetainedCodeSnapshots', '{"session_id":"root1","query":{"workspace":"/ws"}}'); - rejects('GetRetainedCodeSnapshots', '{"query":{"workspace":"/ws"}}'); - allows('GetRetainedCodeSnapshots', '{"query":{"workspace":"/ws","session_id":"0123456789abcdef0123456789abcdef"}}'); + // session path. }); it('checks every recorded call, so one violating envelope fails the whole history', () => { diff --git a/frontend/src/client/stack.test.ts b/frontend/src/client/stack.test.ts index e3772f90..4976fbb4 100644 --- a/frontend/src/client/stack.test.ts +++ b/frontend/src/client/stack.test.ts @@ -88,13 +88,13 @@ function bridge(script: Script = {}) { } return json(200, { configuration_revision: { instance_id: INSTANCE, generation: '1' }, - settings: { sessions: { auto_archive: false, archive_after_days: 1, delete_after_archive_days: 1 }, plugins: {} }, + settings: { sessions: { auto_archive: false, archive_after_days: 1, delete_after_archive_days: 1 }, tools: {}, jobs: {}, compaction: {}, plugins: {} }, agents: [], providers: [], }); } if (operation === 'UpdateConfigurationSettings') { - return json(200, { configuration_revision: { instance_id: INSTANCE, generation: '2' }, result: { sessions: {}, plugins: {} } }); + return json(200, { configuration_revision: { instance_id: INSTANCE, generation: '2' }, result: { sessions: {}, tools: {}, jobs: {}, compaction: {}, plugins: {} } }); } if (operation === 'ListProviders') { return json(200, { @@ -220,7 +220,7 @@ async function mountReal(script: Script = {}) { function configurationView(generation: string, agentName: string) { return { configuration_revision: { instance_id: INSTANCE, generation }, - settings: { sessions: { auto_archive: false, archive_after_days: 1, delete_after_archive_days: 1 }, plugins: {} }, + settings: { sessions: { auto_archive: false, archive_after_days: 1, delete_after_archive_days: 1 }, tools: {}, jobs: {}, compaction: {}, plugins: {} }, agents: [{ name: agentName, description: agentName, subagent: false, model: '', readonly: false, write_dir: '', tools: [], capabilities: [] }], providers: [], }; @@ -564,7 +564,7 @@ describe('real generated stack over the byte bridge', () => { describe('Step 8 real generated captures over the byte bridge', () => { const RAW_CONFIGURATION = '{"configuration_revision":{"instance_id":"' + INSTANCE + '","generation":"1"},' + '"settings":{"sessions":{"auto_archive":true,"archive_after_days":-9223372036854775808,"delete_after_archive_days":1e400},' - + '"plugins":{"tools":"{\\"max_output_bytes\\":9007199254740993}"}},"agents":[],"providers":[]}'; + + '"tools":{"max_output_bytes":9007199254740993},"jobs":{},"compaction":{},"plugins":{}},"agents":[],"providers":[]}'; const forkInput = { item_id: 'i1', @@ -608,7 +608,7 @@ describe('Step 8 real generated captures over the byte bridge', () => { const put = calls.find((call) => call.operation === 'UpdateConfigurationSettings'); expect(put?.params).toBe( '{"body":{"settings":{"sessions":{"auto_archive":true,"archive_after_days":-9223372036854775808,"delete_after_archive_days":1e400},' - + '"plugins":{"tools":"{\\"max_output_bytes\\":9007199254740993}"}}}}', + + '"tools":{"max_output_bytes":9007199254740993},"jobs":{},"compaction":{},"plugins":{}}}}', ); }); diff --git a/frontend/src/generated/native-invocations.json b/frontend/src/generated/native-invocations.json index 5cf0562e..9c25c234 100644 --- a/frontend/src/generated/native-invocations.json +++ b/frontend/src/generated/native-invocations.json @@ -138,22 +138,6 @@ } } }, - "GetRetainedCodeSnapshots": { - "session_id": "forbidden", - "field": "forbidden", - "query": "required", - "body": "forbidden", - "query_members": { - "session_id": { - "required": true, - "type": "string" - }, - "workspace": { - "required": true, - "type": "string" - } - } - }, "GetSessionCodeSnapshots": { "session_id": "required", "field": "forbidden", @@ -297,13 +281,6 @@ } } }, - "RevertRetainedCode": { - "session_id": "forbidden", - "field": "forbidden", - "query": "forbidden", - "body": "required", - "query_members": {} - }, "RevertSessionCode": { "session_id": "required", "field": "forbidden", diff --git a/frontend/src/generated/protocol/index.ts b/frontend/src/generated/protocol/index.ts index a53206ca..f15c1f07 100644 --- a/frontend/src/generated/protocol/index.ts +++ b/frontend/src/generated/protocol/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { archiveSession, compactSession, connectProvider, createProvider, createSession, deleteProviderDetail, deleteProviderModel, deleteSession, disconnectProvider, discoverProviderCandidates, discoverProviderModelCandidates, forkSession, getConfiguration, getEvents, getHealth, getProviderDetail, getRetainedCodeSnapshots, getSessionCodeSnapshots, getSessionHistory, getSessionHydration, getWarnings, interruptSession, listModels, listProviderModels, listProviders, listSessions, listWorkspaces, type Options, readWorkspaceFile, reloadConfiguration, reopenSession, resetProviderField, resetProviderModelField, revertRetainedCode, revertSessionCode, setAgentTypeModel, setSessionAgentType, stopSession, submitSession, updateConfigurationSettings, updateProviderDetail, updateProviderModel } from './sdk.gen'; -export type { Agent, AgentMutation, AgentScope, ArchiveSessionData, ArchiveSessionError, ArchiveSessionErrors, ArchiveSessionResponse, ArchiveSessionResponses, AssistantItem, BackgroundMember, CheckpointItem, ClientOptions, CodeRevertError, CodeRevertResult, CodeSnapshotGroup, CompactRequest, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, ConfigurationChangedEvent, ConfigurationRevision, ConfigurationView, ConnectProviderData, ConnectProviderError, ConnectProviderErrors, ConnectProviderResponse, ConnectProviderResponses, ConnectRequest, ContentPart, ConversationItem, ConversationPage, Cost, CreateProviderData, CreateProviderError, CreateProviderErrors, CreateProviderRequest, CreateProviderResponse, CreateProviderResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionRequest, CreateSessionResponse, CreateSessionResponses, DeleteProviderDetailData, DeleteProviderDetailError, DeleteProviderDetailErrors, DeleteProviderDetailResponse, DeleteProviderDetailResponses, DeleteProviderModelData, DeleteProviderModelError, DeleteProviderModelErrors, DeleteProviderModelResponse, DeleteProviderModelResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, DisconnectProviderData, DisconnectProviderError, DisconnectProviderErrors, DisconnectProviderResponse, DisconnectProviderResponses, DiscoveredModelCandidate, DiscoverProviderCandidatesData, DiscoverProviderCandidatesError, DiscoverProviderCandidatesErrors, DiscoverProviderCandidatesResponse, DiscoverProviderCandidatesResponses, DiscoverProviderModelCandidatesData, DiscoverProviderModelCandidatesError, DiscoverProviderModelCandidatesErrors, DiscoverProviderModelCandidatesResponse, DiscoverProviderModelCandidatesResponses, DiscoveryRequest, Error, Event, ExecutionEvidence, ForkRequest, ForkResult, ForkSessionData, ForkSessionError, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, GetConfigurationData, GetConfigurationError, GetConfigurationErrors, GetConfigurationResponse, GetConfigurationResponses, GetEventsData, GetEventsError, GetEventsErrors, GetEventsResponse, GetEventsResponses, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetProviderDetailData, GetProviderDetailError, GetProviderDetailErrors, GetProviderDetailResponse, GetProviderDetailResponses, GetRetainedCodeSnapshotsData, GetRetainedCodeSnapshotsError, GetRetainedCodeSnapshotsErrors, GetRetainedCodeSnapshotsResponse, GetRetainedCodeSnapshotsResponses, GetSessionCodeSnapshotsData, GetSessionCodeSnapshotsError, GetSessionCodeSnapshotsErrors, GetSessionCodeSnapshotsResponse, GetSessionCodeSnapshotsResponses, GetSessionHistoryData, GetSessionHistoryError, GetSessionHistoryErrors, GetSessionHistoryResponse, GetSessionHistoryResponses, GetSessionHydrationData, GetSessionHydrationError, GetSessionHydrationErrors, GetSessionHydrationResponse, GetSessionHydrationResponses, GetWarningsData, GetWarningsError, GetWarningsErrors, GetWarningsResponse, GetWarningsResponses, Health, HistoryPage, Hydration, ImageUrlPart, InputItem, InputModality, InputOrigin, InterruptSessionData, InterruptSessionError, InterruptSessionErrors, InterruptSessionResponse, InterruptSessionResponses, JobScope, JsonObject, LifecycleQuery, ListModelsData, ListModelsError, ListModelsErrors, ListModelsResponse, ListModelsResponses, ListProviderModelsData, ListProviderModelsError, ListProviderModelsErrors, ListProviderModelsResponse, ListProviderModelsResponses, ListProvidersData, ListProvidersError, ListProvidersErrors, ListProvidersResponse, ListProvidersResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, ListWorkspacesData, ListWorkspacesError, ListWorkspacesErrors, ListWorkspacesResponse, ListWorkspacesResponses, Message, MessageToolCall, ModelEdit, ModelField, ModelIdQuery, ModelList, ModelListEntry, ModelMutation, ModelRef, ModelSource, ModelUsage, ModelView, OpaquePart, Operation, OperationEndItem, OperationScope, PendingInput, PendingQueues, PluginConfigDocument, PluginsSettings, ProtocolMetadata, Provider, ProviderDetail, ProviderEdit, ProviderField, ProviderIdQuery, ProviderList, ProviderModelList, ProviderMutation, ReadFileRequest, ReadFileResult, ReadWorkspaceFileData, ReadWorkspaceFileError, ReadWorkspaceFileErrors, ReadWorkspaceFileResponse, ReadWorkspaceFileResponses, RefusalDeltaEvent, ReloadConfigurationData, ReloadConfigurationError, ReloadConfigurationErrors, ReloadConfigurationResponse, ReloadConfigurationResponses, ReopenSessionData, ReopenSessionError, ReopenSessionErrors, ReopenSessionResponse, ReopenSessionResponses, ResetProviderFieldData, ResetProviderFieldError, ResetProviderFieldErrors, ResetProviderFieldResponse, ResetProviderFieldResponses, ResetProviderModelFieldData, ResetProviderModelFieldError, ResetProviderModelFieldErrors, ResetProviderModelFieldResponse, ResetProviderModelFieldResponses, RetainedRevertRequest, RetainedTurn, RetainedTurns, RevertCodeRequest, RevertRetainedCodeData, RevertRetainedCodeError, RevertRetainedCodeErrors, RevertRetainedCodeResponse, RevertRetainedCodeResponses, RevertSessionCodeData, RevertSessionCodeError, RevertSessionCodeErrors, RevertSessionCodeResponse, RevertSessionCodeResponses, RevisionResponse, RuntimeScope, Scope, ScopeEvent, Session, SessionChangedEvent, SessionId, SessionLifecycle, SessionRevision, SessionScope, SessionsSettings, SetAgentTypeModelData, SetAgentTypeModelError, SetAgentTypeModelErrors, SetAgentTypeModelRequest, SetAgentTypeModelResponse, SetAgentTypeModelResponses, SetSessionAgentTypeData, SetSessionAgentTypeError, SetSessionAgentTypeErrors, SetSessionAgentTypeRequest, SetSessionAgentTypeResponse, SetSessionAgentTypeResponses, Settings, SettingsMutation, SignalItem, SkippedFile, SnapshotFile, StopSessionData, StopSessionError, StopSessionErrors, StopSessionResponse, StopSessionResponses, SubmitRequest, SubmitResult, SubmitSessionData, SubmitSessionError, SubmitSessionErrors, SubmitSessionResponse, SubmitSessionResponses, SystemRole, TextDeltaEvent, TextPart, ToolCallStatus, ToolCallView, ToolFinishedEvent, ToolMetadata, ToolStartedEvent, UpdateConfigurationSettingsData, UpdateConfigurationSettingsError, UpdateConfigurationSettingsErrors, UpdateConfigurationSettingsResponse, UpdateConfigurationSettingsResponses, UpdateProviderDetailData, UpdateProviderDetailError, UpdateProviderDetailErrors, UpdateProviderDetailRequest, UpdateProviderDetailResponse, UpdateProviderDetailResponses, UpdateProviderModelData, UpdateProviderModelError, UpdateProviderModelErrors, UpdateProviderModelRequest, UpdateProviderModelResponse, UpdateProviderModelResponses, UpdateSettingsRequest, UsageContext, UsageCount, UsageProjection, UsageTotals, Warning, WarningChangedEvent, WarningsRevision, WarningsSnapshot, Workspace, WorkspaceQuery, WorkspaceScope } from './types.gen'; +export { archiveSession, compactSession, connectProvider, createProvider, createSession, deleteProviderDetail, deleteProviderModel, deleteSession, disconnectProvider, discoverProviderCandidates, discoverProviderModelCandidates, forkSession, getConfiguration, getEvents, getHealth, getProviderDetail, getSessionCodeSnapshots, getSessionHistory, getSessionHydration, getWarnings, interruptSession, listModels, listProviderModels, listProviders, listSessions, listWorkspaces, type Options, readWorkspaceFile, reloadConfiguration, reopenSession, resetProviderField, resetProviderModelField, revertSessionCode, setAgentTypeModel, setSessionAgentType, stopSession, submitSession, updateConfigurationSettings, updateProviderDetail, updateProviderModel } from './sdk.gen'; +export type { Agent, AgentMutation, AgentScope, ArchiveSessionData, ArchiveSessionError, ArchiveSessionErrors, ArchiveSessionResponse, ArchiveSessionResponses, AssistantItem, BackgroundMember, CheckpointItem, ClientOptions, CodeRevertError, CodeRevertResult, CodeSnapshotGroup, CompactionSettings, CompactRequest, CompactSessionData, CompactSessionError, CompactSessionErrors, CompactSessionResponse, CompactSessionResponses, ConfigurationChangedEvent, ConfigurationRevision, ConfigurationView, ConnectProviderData, ConnectProviderError, ConnectProviderErrors, ConnectProviderResponse, ConnectProviderResponses, ConnectRequest, ContentPart, ConversationItem, ConversationPage, Cost, CreateProviderData, CreateProviderError, CreateProviderErrors, CreateProviderRequest, CreateProviderResponse, CreateProviderResponses, CreateSessionData, CreateSessionError, CreateSessionErrors, CreateSessionRequest, CreateSessionResponse, CreateSessionResponses, DeleteProviderDetailData, DeleteProviderDetailError, DeleteProviderDetailErrors, DeleteProviderDetailResponse, DeleteProviderDetailResponses, DeleteProviderModelData, DeleteProviderModelError, DeleteProviderModelErrors, DeleteProviderModelResponse, DeleteProviderModelResponses, DeleteSessionData, DeleteSessionError, DeleteSessionErrors, DeleteSessionResponse, DeleteSessionResponses, DisconnectProviderData, DisconnectProviderError, DisconnectProviderErrors, DisconnectProviderResponse, DisconnectProviderResponses, DiscoveredModelCandidate, DiscoverProviderCandidatesData, DiscoverProviderCandidatesError, DiscoverProviderCandidatesErrors, DiscoverProviderCandidatesResponse, DiscoverProviderCandidatesResponses, DiscoverProviderModelCandidatesData, DiscoverProviderModelCandidatesError, DiscoverProviderModelCandidatesErrors, DiscoverProviderModelCandidatesResponse, DiscoverProviderModelCandidatesResponses, DiscoveryRequest, Error, Event, ExecutionEvidence, ForkRequest, ForkResult, ForkSessionData, ForkSessionError, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, GetConfigurationData, GetConfigurationError, GetConfigurationErrors, GetConfigurationResponse, GetConfigurationResponses, GetEventsData, GetEventsError, GetEventsErrors, GetEventsResponse, GetEventsResponses, GetHealthData, GetHealthError, GetHealthErrors, GetHealthResponse, GetHealthResponses, GetProviderDetailData, GetProviderDetailError, GetProviderDetailErrors, GetProviderDetailResponse, GetProviderDetailResponses, GetSessionCodeSnapshotsData, GetSessionCodeSnapshotsError, GetSessionCodeSnapshotsErrors, GetSessionCodeSnapshotsResponse, GetSessionCodeSnapshotsResponses, GetSessionHistoryData, GetSessionHistoryError, GetSessionHistoryErrors, GetSessionHistoryResponse, GetSessionHistoryResponses, GetSessionHydrationData, GetSessionHydrationError, GetSessionHydrationErrors, GetSessionHydrationResponse, GetSessionHydrationResponses, GetWarningsData, GetWarningsError, GetWarningsErrors, GetWarningsResponse, GetWarningsResponses, Health, HistoryPage, Hydration, ImageUrlPart, InputItem, InputModality, InputOrigin, InterruptSessionData, InterruptSessionError, InterruptSessionErrors, InterruptSessionResponse, InterruptSessionResponses, JobScope, JobsSettings, JsonObject, LifecycleQuery, ListModelsData, ListModelsError, ListModelsErrors, ListModelsResponse, ListModelsResponses, ListProviderModelsData, ListProviderModelsError, ListProviderModelsErrors, ListProviderModelsResponse, ListProviderModelsResponses, ListProvidersData, ListProvidersError, ListProvidersErrors, ListProvidersResponse, ListProvidersResponses, ListSessionsData, ListSessionsError, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, ListWorkspacesData, ListWorkspacesError, ListWorkspacesErrors, ListWorkspacesResponse, ListWorkspacesResponses, Message, MessageToolCall, ModelEdit, ModelField, ModelIdQuery, ModelList, ModelListEntry, ModelMutation, ModelRef, ModelSource, ModelUsage, ModelView, OpaquePart, Operation, OperationEndItem, OperationScope, PendingInput, PendingQueues, PluginConfigDocument, PluginsSettings, ProtocolMetadata, Provider, ProviderDetail, ProviderEdit, ProviderField, ProviderIdQuery, ProviderList, ProviderModelList, ProviderMutation, ReadFileRequest, ReadFileResult, ReadWorkspaceFileData, ReadWorkspaceFileError, ReadWorkspaceFileErrors, ReadWorkspaceFileResponse, ReadWorkspaceFileResponses, RefusalDeltaEvent, ReloadConfigurationData, ReloadConfigurationError, ReloadConfigurationErrors, ReloadConfigurationResponse, ReloadConfigurationResponses, ReopenSessionData, ReopenSessionError, ReopenSessionErrors, ReopenSessionResponse, ReopenSessionResponses, ResetProviderFieldData, ResetProviderFieldError, ResetProviderFieldErrors, ResetProviderFieldResponse, ResetProviderFieldResponses, ResetProviderModelFieldData, ResetProviderModelFieldError, ResetProviderModelFieldErrors, ResetProviderModelFieldResponse, ResetProviderModelFieldResponses, RevertCodeRequest, RevertSessionCodeData, RevertSessionCodeError, RevertSessionCodeErrors, RevertSessionCodeResponse, RevertSessionCodeResponses, RevisionResponse, RuntimeScope, Scope, ScopeEvent, Session, SessionChangedEvent, SessionId, SessionLifecycle, SessionRevision, SessionScope, SessionsSettings, SetAgentTypeModelData, SetAgentTypeModelError, SetAgentTypeModelErrors, SetAgentTypeModelRequest, SetAgentTypeModelResponse, SetAgentTypeModelResponses, SetSessionAgentTypeData, SetSessionAgentTypeError, SetSessionAgentTypeErrors, SetSessionAgentTypeRequest, SetSessionAgentTypeResponse, SetSessionAgentTypeResponses, Settings, SettingsMutation, SignalItem, SkippedFile, SnapshotFile, StopSessionData, StopSessionError, StopSessionErrors, StopSessionResponse, StopSessionResponses, SubmitRequest, SubmitResult, SubmitSessionData, SubmitSessionError, SubmitSessionErrors, SubmitSessionResponse, SubmitSessionResponses, SystemRole, TextDeltaEvent, TextPart, ToolCallStatus, ToolCallView, ToolFinishedEvent, ToolMetadata, ToolsSettings, ToolStartedEvent, UpdateConfigurationSettingsData, UpdateConfigurationSettingsError, UpdateConfigurationSettingsErrors, UpdateConfigurationSettingsResponse, UpdateConfigurationSettingsResponses, UpdateProviderDetailData, UpdateProviderDetailError, UpdateProviderDetailErrors, UpdateProviderDetailRequest, UpdateProviderDetailResponse, UpdateProviderDetailResponses, UpdateProviderModelData, UpdateProviderModelError, UpdateProviderModelErrors, UpdateProviderModelRequest, UpdateProviderModelResponse, UpdateProviderModelResponses, UpdateSettingsRequest, UsageContext, UsageCount, UsageProjection, UsageTotals, Warning, WarningChangedEvent, WarningsRevision, WarningsSnapshot, Workspace, WorkspaceQuery, WorkspaceScope } from './types.gen'; diff --git a/frontend/src/generated/protocol/sdk.gen.ts b/frontend/src/generated/protocol/sdk.gen.ts index e57cfd45..e1d24c7d 100644 --- a/frontend/src/generated/protocol/sdk.gen.ts +++ b/frontend/src/generated/protocol/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { ArchiveSessionData, ArchiveSessionErrors, ArchiveSessionResponses, CompactSessionData, CompactSessionErrors, CompactSessionResponses, ConnectProviderData, ConnectProviderErrors, ConnectProviderResponses, CreateProviderData, CreateProviderErrors, CreateProviderResponses, CreateSessionData, CreateSessionErrors, CreateSessionResponses, DeleteProviderDetailData, DeleteProviderDetailErrors, DeleteProviderDetailResponses, DeleteProviderModelData, DeleteProviderModelErrors, DeleteProviderModelResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DisconnectProviderData, DisconnectProviderErrors, DisconnectProviderResponses, DiscoverProviderCandidatesData, DiscoverProviderCandidatesErrors, DiscoverProviderCandidatesResponses, DiscoverProviderModelCandidatesData, DiscoverProviderModelCandidatesErrors, DiscoverProviderModelCandidatesResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetConfigurationData, GetConfigurationErrors, GetConfigurationResponses, GetEventsData, GetEventsErrors, GetEventsResponse, GetEventsResponses, GetHealthData, GetHealthErrors, GetHealthResponses, GetProviderDetailData, GetProviderDetailErrors, GetProviderDetailResponses, GetRetainedCodeSnapshotsData, GetRetainedCodeSnapshotsErrors, GetRetainedCodeSnapshotsResponses, GetSessionCodeSnapshotsData, GetSessionCodeSnapshotsErrors, GetSessionCodeSnapshotsResponses, GetSessionHistoryData, GetSessionHistoryErrors, GetSessionHistoryResponses, GetSessionHydrationData, GetSessionHydrationErrors, GetSessionHydrationResponses, GetWarningsData, GetWarningsErrors, GetWarningsResponses, InterruptSessionData, InterruptSessionErrors, InterruptSessionResponses, ListModelsData, ListModelsErrors, ListModelsResponses, ListProviderModelsData, ListProviderModelsErrors, ListProviderModelsResponses, ListProvidersData, ListProvidersErrors, ListProvidersResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ListWorkspacesData, ListWorkspacesErrors, ListWorkspacesResponses, ReadWorkspaceFileData, ReadWorkspaceFileErrors, ReadWorkspaceFileResponses, ReloadConfigurationData, ReloadConfigurationErrors, ReloadConfigurationResponses, ReopenSessionData, ReopenSessionErrors, ReopenSessionResponses, ResetProviderFieldData, ResetProviderFieldErrors, ResetProviderFieldResponses, ResetProviderModelFieldData, ResetProviderModelFieldErrors, ResetProviderModelFieldResponses, RevertRetainedCodeData, RevertRetainedCodeErrors, RevertRetainedCodeResponses, RevertSessionCodeData, RevertSessionCodeErrors, RevertSessionCodeResponses, SetAgentTypeModelData, SetAgentTypeModelErrors, SetAgentTypeModelResponses, SetSessionAgentTypeData, SetSessionAgentTypeErrors, SetSessionAgentTypeResponses, StopSessionData, StopSessionErrors, StopSessionResponses, SubmitSessionData, SubmitSessionErrors, SubmitSessionResponses, UpdateConfigurationSettingsData, UpdateConfigurationSettingsErrors, UpdateConfigurationSettingsResponses, UpdateProviderDetailData, UpdateProviderDetailErrors, UpdateProviderDetailResponses, UpdateProviderModelData, UpdateProviderModelErrors, UpdateProviderModelResponses } from './types.gen'; +import type { ArchiveSessionData, ArchiveSessionErrors, ArchiveSessionResponses, CompactSessionData, CompactSessionErrors, CompactSessionResponses, ConnectProviderData, ConnectProviderErrors, ConnectProviderResponses, CreateProviderData, CreateProviderErrors, CreateProviderResponses, CreateSessionData, CreateSessionErrors, CreateSessionResponses, DeleteProviderDetailData, DeleteProviderDetailErrors, DeleteProviderDetailResponses, DeleteProviderModelData, DeleteProviderModelErrors, DeleteProviderModelResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DisconnectProviderData, DisconnectProviderErrors, DisconnectProviderResponses, DiscoverProviderCandidatesData, DiscoverProviderCandidatesErrors, DiscoverProviderCandidatesResponses, DiscoverProviderModelCandidatesData, DiscoverProviderModelCandidatesErrors, DiscoverProviderModelCandidatesResponses, ForkSessionData, ForkSessionErrors, ForkSessionResponses, GetConfigurationData, GetConfigurationErrors, GetConfigurationResponses, GetEventsData, GetEventsErrors, GetEventsResponse, GetEventsResponses, GetHealthData, GetHealthErrors, GetHealthResponses, GetProviderDetailData, GetProviderDetailErrors, GetProviderDetailResponses, GetSessionCodeSnapshotsData, GetSessionCodeSnapshotsErrors, GetSessionCodeSnapshotsResponses, GetSessionHistoryData, GetSessionHistoryErrors, GetSessionHistoryResponses, GetSessionHydrationData, GetSessionHydrationErrors, GetSessionHydrationResponses, GetWarningsData, GetWarningsErrors, GetWarningsResponses, InterruptSessionData, InterruptSessionErrors, InterruptSessionResponses, ListModelsData, ListModelsErrors, ListModelsResponses, ListProviderModelsData, ListProviderModelsErrors, ListProviderModelsResponses, ListProvidersData, ListProvidersErrors, ListProvidersResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, ListWorkspacesData, ListWorkspacesErrors, ListWorkspacesResponses, ReadWorkspaceFileData, ReadWorkspaceFileErrors, ReadWorkspaceFileResponses, ReloadConfigurationData, ReloadConfigurationErrors, ReloadConfigurationResponses, ReopenSessionData, ReopenSessionErrors, ReopenSessionResponses, ResetProviderFieldData, ResetProviderFieldErrors, ResetProviderFieldResponses, ResetProviderModelFieldData, ResetProviderModelFieldErrors, ResetProviderModelFieldResponses, RevertSessionCodeData, RevertSessionCodeErrors, RevertSessionCodeResponses, SetAgentTypeModelData, SetAgentTypeModelErrors, SetAgentTypeModelResponses, SetSessionAgentTypeData, SetSessionAgentTypeErrors, SetSessionAgentTypeResponses, StopSessionData, StopSessionErrors, StopSessionResponses, SubmitSessionData, SubmitSessionErrors, SubmitSessionResponses, UpdateConfigurationSettingsData, UpdateConfigurationSettingsErrors, UpdateConfigurationSettingsResponses, UpdateProviderDetailData, UpdateProviderDetailErrors, UpdateProviderDetailResponses, UpdateProviderModelData, UpdateProviderModelErrors, UpdateProviderModelResponses } from './types.gen'; export type Options = Options2 & { /** @@ -160,22 +160,6 @@ export const readWorkspaceFile = (options: } }); -export const getRetainedCodeSnapshots = (options: Options): RequestResult => (options.client ?? client).get({ - security: [{ scheme: 'bearer', type: 'http' }], - url: '/v1/retained-code-snapshots', - ...options -}); - -export const revertRetainedCode = (options: Options): RequestResult => (options.client ?? client).post({ - security: [{ scheme: 'bearer', type: 'http' }], - url: '/v1/retained-code-snapshots/revert', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - export const getConfiguration = (options?: Options): RequestResult => (options?.client ?? client).get({ security: [{ scheme: 'bearer', type: 'http' }], url: '/v1/configuration', diff --git a/frontend/src/generated/protocol/types.gen.ts b/frontend/src/generated/protocol/types.gen.ts index cd0a1675..75964d9f 100644 --- a/frontend/src/generated/protocol/types.gen.ts +++ b/frontend/src/generated/protocol/types.gen.ts @@ -51,7 +51,7 @@ export type WarningsRevision = { export type Health = { instance_id: string; - protocol_version: '6'; + protocol_version: '8'; }; /** @@ -447,25 +447,31 @@ export type CodeRevertResult = { export type CodeRevertError = CodeRevertResult | Error; -export type RetainedTurn = { - turn: number; - files: Array; +export type SessionsSettings = { + auto_archive: boolean; + archive_after_days: number; + delete_after_archive_days: number; }; -export type RetainedTurns = { - turns: Array; +/** + * The baseline standard tools' configuration object, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults the tools apply at consumption. + */ +export type ToolsSettings = { + [key: string]: unknown; }; -export type RetainedRevertRequest = { - workspace: string; - session_id: string; - after_turn: number; +/** + * The baseline background jobs configuration object, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults the jobs service applies at consumption. + */ +export type JobsSettings = { + [key: string]: unknown; }; -export type SessionsSettings = { - auto_archive: boolean; - archive_after_days: number; - delete_after_archive_days: number; +/** + * The baseline compaction configuration object: an optional independent summarizer model reference and an optional custom nonempty prompt, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults apply at consumption. + */ +export type CompactionSettings = { + [key: string]: unknown; }; /** @@ -482,6 +488,9 @@ export type PluginsSettings = { export type Settings = { sessions: SessionsSettings; + tools: ToolsSettings; + jobs: JobsSettings; + compaction: CompactionSettings; plugins: PluginsSettings; }; @@ -1307,60 +1316,6 @@ export type ReadWorkspaceFileResponses = { export type ReadWorkspaceFileResponse = ReadWorkspaceFileResponses[keyof ReadWorkspaceFileResponses]; -export type GetRetainedCodeSnapshotsData = { - body?: never; - path?: never; - query: { - workspace: string; - session_id: string; - }; - url: '/v1/retained-code-snapshots'; -}; - -export type GetRetainedCodeSnapshotsErrors = { - /** - * One schema-defined typed error. - */ - default: Error; -}; - -export type GetRetainedCodeSnapshotsError = GetRetainedCodeSnapshotsErrors[keyof GetRetainedCodeSnapshotsErrors]; - -export type GetRetainedCodeSnapshotsResponses = { - /** - * The legacy per-turn snapshot groups of one pre-cutover Session. - */ - 200: RetainedTurns; -}; - -export type GetRetainedCodeSnapshotsResponse = GetRetainedCodeSnapshotsResponses[keyof GetRetainedCodeSnapshotsResponses]; - -export type RevertRetainedCodeData = { - body: RetainedRevertRequest; - path?: never; - query?: never; - url: '/v1/retained-code-snapshots/revert'; -}; - -export type RevertRetainedCodeErrors = { - /** - * A traversal I/O failure after earlier turns changed returns the same result DTO with accumulated restored/skipped members and the error member; every other failure, including a pre-traversal storage failure, returns the schema error. - * - */ - default: CodeRevertError; -}; - -export type RevertRetainedCodeError = RevertRetainedCodeErrors[keyof RevertRetainedCodeErrors]; - -export type RevertRetainedCodeResponses = { - /** - * The completed legacy restore result. - */ - 200: CodeRevertResult; -}; - -export type RevertRetainedCodeResponse = RevertRetainedCodeResponses[keyof RevertRetainedCodeResponses]; - export type GetConfigurationData = { body?: never; path?: never; diff --git a/harness/permission.go b/harness/permission.go index 9d762b32..95395a99 100644 --- a/harness/permission.go +++ b/harness/permission.go @@ -10,22 +10,21 @@ import ( ) // PermissionFileRead, PermissionFileWrite, PermissionCommandRun, -// PermissionWorkspaceInspect, PermissionSleep, PermissionChildLaunch and -// PermissionProcessOwn are the published semantic permission names of the -// fixed automatic policy. They are exact strings; plugins may define new -// namespaced names but cannot contribute rules to the built-in policy, and -// these built-in names are not a closed plugin namespace. +// PermissionSleep, PermissionChildLaunch and PermissionProcessOwn are the +// published semantic permission names of the fixed automatic policy. They are +// exact strings; plugins may define new namespaced names but cannot contribute +// rules to the built-in policy, and these built-in names are not a closed +// plugin namespace. // // The access values stay private: access strings and policy parsing are the // Harness's own. const ( - PermissionFileRead = "file.read" - PermissionFileWrite = "file.write" - PermissionCommandRun = "command.run" - PermissionWorkspaceInspect = "workspace.inspect" - PermissionSleep = "sleep" - PermissionChildLaunch = "child.launch" - PermissionProcessOwn = "process.own" + PermissionFileRead = "file.read" + PermissionFileWrite = "file.write" + PermissionCommandRun = "command.run" + PermissionSleep = "sleep" + PermissionChildLaunch = "child.launch" + PermissionProcessOwn = "process.own" permissionAccessAllow = "allow" permissionAccessDeny = "deny" @@ -113,8 +112,8 @@ func ResolvePermissionPolicy(global, workspace json.RawMessage) PermissionPolicy // default if present, then the last matching global rule, then the global // default if present, then the built-in rules in their fixed order, then the // built-in default deny. canonicalRoot is the call's prepared canonical -// Workspace root: evaluation input for the built-in file and inspect rules, -// never policy state. +// Workspace root: evaluation input for the built-in file rules, never policy +// state. func (p PermissionPolicy) evaluate(canonicalRoot string, req PermissionRequest) bool { if allow, decided := p.workspace.decide(req); decided { return allow @@ -204,7 +203,6 @@ func buildBuiltinRules() []builtinRule { } return append(rules, builtinRule{permission: PermissionCommandRun, matches: anyTarget, allow: true}, - builtinRule{permission: PermissionWorkspaceInspect, matches: equalsWorkspaceRoot, allow: true}, builtinRule{permission: PermissionSleep, matches: equalsFixedTarget, allow: true}, builtinRule{permission: PermissionChildLaunch, matches: anyTarget, allow: true}, builtinRule{permission: PermissionProcessOwn, matches: anyTarget, allow: true}, @@ -220,10 +218,6 @@ func insideWorkspace(canonicalRoot, target string) bool { return containsPath(canonicalRoot, target) } -func equalsWorkspaceRoot(canonicalRoot, target string) bool { - return target == canonicalRoot -} - func anyTarget(_, _ string) bool { return true } // equalsFixedTarget matches only the exact fixed target "*" that diff --git a/harness/permission_test.go b/harness/permission_test.go index 3d0b9022..7f7e0046 100644 --- a/harness/permission_test.go +++ b/harness/permission_test.go @@ -120,6 +120,12 @@ func TestResolvePermissionPolicyFallbackChain(t *testing.T) { PermissionRequest{PermissionFileRead, "/w/.env"}, true}, {"zero policy is the built-in policy", "", "", "/w", PermissionRequest{PermissionCommandRun, "echo hi"}, true}, + {"workspace rule explicitly decides workspace.inspect with allow", "", + levelJSON(rulesJSON(ruleJSON("workspace.inspect", "/w", permissionAccessAllow))), "/w", + PermissionRequest{"workspace.inspect", "/w"}, true}, + {"global rule explicitly decides workspace.inspect with deny over an allow default", + levelJSON(rulesJSON(ruleJSON("workspace.inspect", "*", permissionAccessDeny)), `"default":"allow"`), "", "/w", + PermissionRequest{"workspace.inspect", "/w"}, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -230,18 +236,15 @@ func TestResolvePermissionPolicyBuiltins(t *testing.T) { t.Run("root path metacharacters stay literal path data", func(t *testing.T) { root := "/w/[x]" testassert.Check(t, policy.evaluate(root, PermissionRequest{PermissionFileRead, root + "/a.txt"}), "%v", "file under a metacharacter root denied, want allow") - testassert.Check(t, policy.evaluate(root, PermissionRequest{PermissionWorkspaceInspect, root}), "%v", "inspect of the metacharacter root denied, want allow") - testassert.Check(t, !policy.evaluate(root, PermissionRequest{PermissionWorkspaceInspect, "/w/y"}), "%v", "inspect of a different root allowed, want deny") }) t.Run("command run allows any canonical command target", func(t *testing.T) { for _, target := range []string{"*", "git push origin main", "rm -rf /tmp/x", "echo\nls"} { testassert.Check(t, policy.evaluate("/w", PermissionRequest{PermissionCommandRun, target}), "built-in command.run of %q denied, want allow", target) } }) - t.Run("workspace inspect allows only the canonical root", func(t *testing.T) { - testassert.Check(t, policy.evaluate("/w", PermissionRequest{PermissionWorkspaceInspect, "/w"}), "%v", "built-in inspect of the root denied, want allow") - for _, target := range []string{"/w/keys", "/etc", "*", ""} { - testassert.Check(t, !policy.evaluate("/w", PermissionRequest{PermissionWorkspaceInspect, target}), "built-in inspect of %q allowed, want deny", target) + t.Run("workspace.inspect carries no built-in rule and falls to the final deny", func(t *testing.T) { + for _, target := range []string{"/w", "/w/keys", "/etc", "*", ""} { + testassert.Check(t, !policy.evaluate("/w", PermissionRequest{"workspace.inspect", target}), "built-in inspect of %q allowed, want deny", target) } }) t.Run("sleep allows the fixed target only", func(t *testing.T) { @@ -526,16 +529,15 @@ func TestPermissionLevelDecideSemantics(t *testing.T) { // boundary as literals, so the constants may never drift from them. func TestPermissionNamesKeepPublishedLiterals(t *testing.T) { want := map[string]string{ - PermissionFileRead: "file.read", - PermissionFileWrite: "file.write", - PermissionCommandRun: "command.run", - PermissionWorkspaceInspect: "workspace.inspect", - PermissionSleep: "sleep", - PermissionChildLaunch: "child.launch", - PermissionProcessOwn: "process.own", + PermissionFileRead: "file.read", + PermissionFileWrite: "file.write", + PermissionCommandRun: "command.run", + PermissionSleep: "sleep", + PermissionChildLaunch: "child.launch", + PermissionProcessOwn: "process.own", } // distinct literals: a duplicated value would collapse the map. - testassert.Equal(t, len(want), 7) + testassert.Equal(t, len(want), 6) for name, literal := range want { testassert.Check(t, name == literal, "permission constant %q = %q, want the published literal", name, literal) } diff --git a/harness/schema.go b/harness/schema.go index b9e2f00b..067a910f 100644 --- a/harness/schema.go +++ b/harness/schema.go @@ -252,7 +252,7 @@ type StrategyCapture struct { } // CompactCapture is the effective compaction configuration of one capture: -// the model the compaction summarizer runs on, the compact type's system +// the model the compaction summarizer runs on, the effective summarizer // prompt, and that final effective model's captured pricing — the // conversation model's pricing exactly when the fallback kept the // conversation model and its transport. Equality of two compact captures is diff --git a/internal/acpclient/repair_test.go b/internal/acpclient/repair_test.go index 68dc2d4a..1094efe8 100644 --- a/internal/acpclient/repair_test.go +++ b/internal/acpclient/repair_test.go @@ -87,7 +87,7 @@ func newFrameFixture(t *testing.T, instance string, parkHydration bool) *frameFi } } w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"6"}`, f.instance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"8"}`, f.instance) }) mux.HandleFunc("/v1/events", f.serveEvents) mux.HandleFunc("/v1/sessions", func(w http.ResponseWriter, r *http.Request) { @@ -267,7 +267,7 @@ func (f *frameFixture) marker(open int) chan string { // writeRecord publishes this fixture's live discovery record. func (f *frameFixture) writeRecord(t *testing.T, dataDir string) { t.Helper() - record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"6","credential":%q}`, + record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"8","credential":%q}`, f.server.URL, f.instance, frameCredential) testassert.NoError(t, os.WriteFile(filepath.Join(dataDir, "runtime.json"), []byte(record), 0o600)) } diff --git a/internal/acpclient/runner_test.go b/internal/acpclient/runner_test.go index 96f9a664..bada01bf 100644 --- a/internal/acpclient/runner_test.go +++ b/internal/acpclient/runner_test.go @@ -266,7 +266,7 @@ func startBridge(t *testing.T, f *fixture, workspace string) *bridge { func startBridgeWith(t *testing.T, f *fixture, workspace string, writer io.Writer) *bridge { t.Helper() dataDir := t.TempDir() - record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"6","credential":%q}`, + record := fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"8","credential":%q}`, f.server.URL, testInstance, testCredential) testassert.NoError(t, os.WriteFile(filepath.Join(dataDir, "runtime.json"), []byte(record), 0o600)) return startRunner(t, clienthost.Options{ @@ -493,7 +493,7 @@ func sseFrame(document string) string { } func healthBody() string { - return fmt.Sprintf(`{"instance_id":%q,"protocol_version":"6"}`, testInstance) + return fmt.Sprintf(`{"instance_id":%q,"protocol_version":"8"}`, testInstance) } func hydrationBody(sessionID, workspace string) string { @@ -896,7 +896,7 @@ func TestACPEventsAndRepair(t *testing.T) { connected := b.awaitFrame(t, methodFrame("runtime/connected")) var health map[string]any testassert.NoError(t, json.Unmarshal(connected["params"], &health)) - testassert.Require(t, health["instance_id"] == testInstance && health["protocol_version"] == "6", "runtime/connected params = %v, want the generated Health", health) + testassert.Require(t, health["instance_id"] == testInstance && health["protocol_version"] == "8", "runtime/connected params = %v, want the generated Health", health) resync := b.awaitFrame(t, methodFrame("runtime/resync_required")) { params, ok := resync["params"] diff --git a/internal/adaptation/adaptation.go b/internal/adaptation/adaptation.go index 34c73f07..6e8d5ea3 100644 --- a/internal/adaptation/adaptation.go +++ b/internal/adaptation/adaptation.go @@ -1,13 +1,15 @@ // Package adaptation defines per-model treatment. A single binding table maps // a model id to an Adaptation that selects which tools the model is shown, -// adds coaching blocks and per-section additions to its system prompt, and -// overrides model-facing tool description text. +// appends ordered parts to its prompt text, and overrides model-facing tool +// description text. // // The binding table in this package is the only place in the codebase that // inspects the model-id string. Consumers stay model-blind: they receive a // resolved *Adaptation (nil = baseline) and apply their own lever. package adaptation +import "strings" + // Adaptation bundles the levers a per-model treatment can pull. A nil // *Adaptation means baseline (no adaptation); every field is optional, and a // zero-value Adaptation is a valid no-op. @@ -15,23 +17,30 @@ type Adaptation struct { // ExcludeTools lists registered tool names hidden from this model. ExcludeTools []string - // IncludeTools lists registered tool names surfaced to this model, - // including tools hidden from the baseline advertisement. An include of a - // name that is not registered is a silent no-op. - IncludeTools []string - - // Blocks are coaching paragraphs appended to the system prompt after the - // built-in sections and before user rules. - Blocks []string - - // Additions are system-owned per-section prompt additions keyed by the - // user-overridable section ids ("safety", "tone", "task_execution", - // "language"). An addition for a section renders after the section main, - // even when a user rules heading overrides that section. Nil or missing - // entries mean no addition for that section. - Additions map[string]string + // Append lists prompt parts appended to the incoming prompt in declared + // order, joined verbatim by one blank line. Empty and whitespace parts are + // preserved. + Append []string - // ToolDescriptionReplacements overrides default placeholder replacements - // applied to model-facing tool descriptions. + // ToolDescriptionReplacements rewrites the model-facing tool description + // text: inside each line, every occurrence of each key is replaced by its + // value, and a key naming a whole line with an empty value removes that + // line. The rules are text-only — the same input line gets the same + // transformation whichever tool carries it. ToolDescriptionReplacements map[string]string } + +// TransformPrompt appends every ordered part to text, joined verbatim by one +// blank line. The incoming text is exact, and a nil or zero-value adaptation +// returns it unchanged. +func TransformPrompt(text string, adapt *Adaptation) string { + if adapt == nil { + return text + } + parts := make([]string, 0, len(adapt.Append)+1) + if text != "" { + parts = append(parts, text) + } + parts = append(parts, adapt.Append...) + return strings.Join(parts, "\n\n") +} diff --git a/internal/adaptation/bundled_test.go b/internal/adaptation/bundled_test.go index aedaf181..0cad4953 100644 --- a/internal/adaptation/bundled_test.go +++ b/internal/adaptation/bundled_test.go @@ -51,9 +51,8 @@ func TestProductionBundledDataIsValid(t *testing.T) { for i, e := range doc.Entries { testassert.Check(t, e.Name != "", "row %d (%q): fixture row has no authoring name", i, e.Pattern) testassert.Check(t, tbl[i].pattern == e.Pattern, "row %d: loader pattern %q != fixture %q", i, tbl[i].pattern, e.Pattern) - testassert.Check(t, maps.Equal(tbl[i].adapt.Additions, e.Additions) && + testassert.Check(t, slices.Equal(tbl[i].adapt.Append, e.Append) && slices.Equal(tbl[i].adapt.ExcludeTools, e.ExcludeTools) && - slices.Equal(tbl[i].adapt.IncludeTools, e.IncludeTools) && maps.Equal(tbl[i].adapt.ToolDescriptionReplacements, e.ToolDescriptionReplacements), "row %d (%q): loaded adaptation outputs %+v != fixture row %+v", i, e.Pattern, tbl[i].adapt, e) } } @@ -86,40 +85,37 @@ func TestProductionBundledDataHasNoDuplicateRow(t *testing.T) { } } -// TestProductionBundledDataAdditionsUseValidSectionIDs walks every -// addition in the production data and confirms the key is one of the -// four user-overridable section ids. -func TestProductionBundledDataAdditionsUseValidSectionIDs(t *testing.T) { +// TestProductionBundledDataAppendParagraphsAreNonEmpty walks every append +// paragraph in the production data and confirms it carries text. +func TestProductionBundledDataAppendParagraphsAreNonEmpty(t *testing.T) { doc := loadFixture(t) for _, e := range doc.Entries { - for k := range e.Additions { - testassert.Check(t, validSectionIDs[k], "entry %q has additions key %q outside {safety, tone, task_execution, language}", e.Pattern, k) + for i, paragraph := range e.Append { + testassert.Check(t, strings.TrimSpace(paragraph) != "", "entry %q has an empty append[%d]", e.Pattern, i) } } } -func TestProductionBundledDataHasToolDescriptionDefaults(t *testing.T) { +func TestProductionBundledDataReplacementKeysAreLiteralNonEmpty(t *testing.T) { doc := loadFixture(t) - testassert.Require(t, len(doc.Defaults.ToolDescriptionReplacements) != 0, "fixture defaults.tool_description_replacements is empty") - for _, key := range []string{""} { - { - _, ok := doc.Defaults.ToolDescriptionReplacements[key] - testassert.Require(t, ok, "fixture missing default tool description replacement %q", key) + matched := false + for _, e := range doc.Entries { + for key, value := range e.ToolDescriptionReplacements { + matched = true + testassert.Check(t, key != "", "entry %q has an empty tool_description_replacements key", e.Pattern) + testassert.Check(t, value != "", "entry %q tool_description_replacements[%q] is empty", e.Pattern, key) } } + testassert.Require(t, matched, "no fixture row carries a tool_description_replacements override") } -func TestBundledEntryRejectsUnknownToolDescriptionReplacement(t *testing.T) { +func TestBundledEntryRejectsEmptyToolDescriptionReplacementKey(t *testing.T) { err := validateBundledEntry(0, bundledEntry{ - Pattern: "model-*", - Name: "model", - ToolDescriptionReplacements: map[string]string{ - "": "value", - }, - }, bundledDefaults{ToolDescriptionReplacements: map[string]string{ - "": "value", - }}) - testassert.Require(t, err != nil && strings.Contains(err.Error(), "has no default replacement"), "validateBundledEntry err = %v, want unknown replacement key error", err) + Pattern: "model-*", + Name: "model", + ToolDescriptionReplacements: map[string]string{"": "value"}, + }) + testassert.Require(t, err != nil && strings.Contains(err.Error(), "key is empty"), "validateBundledEntry err = %v, want the empty replacement key rejection", err) } func indexOf(doc bundledDoc, want bundledEntry) int { diff --git a/internal/adaptation/data/bundled.json b/internal/adaptation/data/bundled.json index 89ebde60..56932b70 100644 --- a/internal/adaptation/data/bundled.json +++ b/internal/adaptation/data/bundled.json @@ -1,64 +1,65 @@ { "version": 1, - "defaults": { - "tool_description_replacements": { - "": "edit_file or write_file" - } - }, "entries": [ { "pattern": "*gpt-5.5*", "name": "gpt-codex", - "exclude_tools": ["edit_file", "write_file"], - "include_tools": ["apply_patch"], + "exclude_tools": [ + "edit_file", + "write_file" + ], "tool_description_replacements": { - "": "apply_patch" + "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.": "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." }, - "additions": { - "task_execution": "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." - } + "append": [ + "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." + ] }, { "pattern": "*gpt-5.4*", "name": "gpt-codex", - "exclude_tools": ["edit_file", "write_file"], - "include_tools": ["apply_patch"], + "exclude_tools": [ + "edit_file", + "write_file" + ], "tool_description_replacements": { - "": "apply_patch" + "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.": "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." }, - "additions": { - "task_execution": "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." - } + "append": [ + "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." + ] }, { "pattern": "*gpt-5*", "name": "gpt-apply-patch", - "exclude_tools": ["edit_file", "write_file"], - "include_tools": ["apply_patch"], + "exclude_tools": [ + "edit_file", + "write_file" + ], "tool_description_replacements": { - "": "apply_patch" + "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.": "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." } }, { "pattern": "*grok-build-0.1", "name": "gpt-task-execution", - "additions": { - "task_execution": "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." - } + "append": [ + "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." + ] }, { "pattern": "*gemini-3*", "name": "google-task-execution", - "additions": { - "task_execution": "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." - } + "append": [ + "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." + ] }, { "pattern": "*gemma-4*", "name": "google-task-execution", - "additions": { - "task_execution": "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." - } + "append": [ + "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." + ] } ] } diff --git a/internal/adaptation/loader.go b/internal/adaptation/loader.go index 5d432d90..fe7c806e 100644 --- a/internal/adaptation/loader.go +++ b/internal/adaptation/loader.go @@ -5,7 +5,6 @@ import ( "embed" "encoding/json" "fmt" - "sort" ) //go:embed data/bundled.json @@ -15,34 +14,18 @@ var bundledFS embed.FS // schema: no model ids, no vendor names, no adaptation names. Adding a new // model adaptation is a data-only change to data/bundled.json; no Go edit. type bundledDoc struct { - Version int `json:"version"` - Defaults bundledDefaults `json:"defaults"` - Entries []bundledEntry `json:"entries"` -} - -type bundledDefaults struct { - ToolDescriptionReplacements map[string]string `json:"tool_description_replacements,omitempty"` + Version int `json:"version"` + Entries []bundledEntry `json:"entries"` } type bundledEntry struct { Pattern string `json:"pattern"` Name string `json:"name"` ExcludeTools []string `json:"exclude_tools,omitempty"` - IncludeTools []string `json:"include_tools,omitempty"` - Additions map[string]string `json:"additions,omitempty"` + Append []string `json:"append,omitempty"` ToolDescriptionReplacements map[string]string `json:"tool_description_replacements,omitempty"` } -// validSectionIDs lists the user-overridable section ids the prompt -// assembler accepts. An additions key outside this set is rejected at -// startup so a typo in the data file is caught immediately. -var validSectionIDs = map[string]bool{ - "safety": true, - "tone": true, - "task_execution": true, - "language": true, -} - // loadBundledTable reads, decodes, and validates the bundled adaptation // data, returning the generic []entry the matcher walks. Called once at // package init; a malformed data file is a developer bug and panics. @@ -63,14 +46,10 @@ func loadBundledTable() []entry { if len(doc.Entries) == 0 { panic("adaptation: bundled data has no entries") } - if err := validateBundledDefaults(doc.Defaults); err != nil { - panic(fmt.Sprintf("adaptation: bundled data: %v", err)) - } - defaultToolDescriptionReplacements = cloneStringMap(doc.Defaults.ToolDescriptionReplacements) out := make([]entry, 0, len(doc.Entries)) seen := make(map[string]bool) for i, e := range doc.Entries { - if err := validateBundledEntry(i, e, doc.Defaults); err != nil { + if err := validateBundledEntry(i, e); err != nil { panic(fmt.Sprintf("adaptation: bundled data: %v", err)) } key := e.Pattern + "\x00" + e.Name @@ -80,27 +59,14 @@ func loadBundledTable() []entry { seen[key] = true out = append(out, entry{pattern: e.Pattern, adapt: Adaptation{ ExcludeTools: append([]string(nil), e.ExcludeTools...), - IncludeTools: append([]string(nil), e.IncludeTools...), - Additions: cloneStringMap(e.Additions), + Append: append([]string(nil), e.Append...), ToolDescriptionReplacements: cloneStringMap(e.ToolDescriptionReplacements), }}) } return out } -func validateBundledDefaults(d bundledDefaults) error { - for k, v := range d.ToolDescriptionReplacements { - if err := validatePlaceholderKey(k); err != nil { - return fmt.Errorf("defaults.tool_description_replacements: %w", err) - } - if v == "" { - return fmt.Errorf("defaults.tool_description_replacements[%q] is empty", k) - } - } - return nil -} - -func validateBundledEntry(i int, e bundledEntry, defaults bundledDefaults) error { +func validateBundledEntry(i int, e bundledEntry) error { if e.Pattern == "" { return fmt.Errorf("entry %d: pattern is empty", i) } @@ -115,46 +81,8 @@ func validateBundledEntry(i int, e bundledEntry, defaults bundledDefaults) error return fmt.Errorf("entry %d (%q): exclude_tools[%d] is empty", i, e.Pattern, j) } } - for j, t := range e.IncludeTools { - if t == "" { - return fmt.Errorf("entry %d (%q): include_tools[%d] is empty", i, e.Pattern, j) - } - } - keys := make([]string, 0, len(e.Additions)) - for k := range e.Additions { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - if !validSectionIDs[k] { - return fmt.Errorf("entry %d (%q): additions key %q is not a user-overridable section id (want safety/tone/task_execution/language)", i, e.Pattern, k) - } - if e.Additions[k] == "" { - return fmt.Errorf("entry %d (%q): additions[%q] is empty", i, e.Pattern, k) - } - } - keys = keys[:0] - for k := range e.ToolDescriptionReplacements { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - if err := validatePlaceholderKey(k); err != nil { - return fmt.Errorf("entry %d (%q): tool_description_replacements key %q is invalid: %w", i, e.Pattern, k, err) - } - if _, ok := defaults.ToolDescriptionReplacements[k]; !ok { - return fmt.Errorf("entry %d (%q): tool_description_replacements key %q has no default replacement", i, e.Pattern, k) - } - } - return nil -} - -func validatePlaceholderKey(k string) error { - if k == "" { - return fmt.Errorf("placeholder key is empty") - } - if len(k) < 3 || k[0] != '<' || k[len(k)-1] != '>' { - return fmt.Errorf("placeholder must be wrapped in <...>") + if _, empty := e.ToolDescriptionReplacements[""]; empty { + return fmt.Errorf("entry %d (%q): tool_description_replacements key is empty", i, e.Pattern) } return nil } diff --git a/internal/adaptation/match.go b/internal/adaptation/match.go index dda95dc9..2a74608a 100644 --- a/internal/adaptation/match.go +++ b/internal/adaptation/match.go @@ -16,10 +16,6 @@ type entry struct { adapt Adaptation } -// defaultToolDescriptionReplacements is populated from bundled data and applies -// to model-facing tool descriptions when no active adaptation overrides a key. -var defaultToolDescriptionReplacements map[string]string - // Match returns the adaptation bound to modelID, or nil for baseline. It is the // production Resolver and expects the model component of the active model ref; // aggregator model IDs may still contain slashes. The table is loaded from @@ -28,31 +24,15 @@ func Match(modelID string) *Adaptation { return matchIn(bundledTable, modelID) } -// SectionAddition returns the nonempty addition for section from the active -// adaptation, or "" when the adaptation or the entry is absent. section is one -// of the four user-overridable section ids: "safety", "tone", "task_execution", -// "language". -func SectionAddition(adapt *Adaptation, section string) string { - if adapt == nil { - return "" - } - return adapt.Additions[section] -} - -// RenderToolDescription applies bundled placeholder replacements to a -// model-facing tool description. Active adaptations override default -// replacement values. A line that consists only of a placeholder is removed when -// that placeholder resolves to an empty string. +// RenderToolDescription applies the active adaptation's literal replacement +// overrides to a model-facing tool description; a nil adaptation returns the +// description unchanged. A line that consists only of a replaced value is +// removed when that value resolves to an empty string. func RenderToolDescription(description string, adapt *Adaptation) string { - replacements := maps.Clone(defaultToolDescriptionReplacements) - if replacements == nil { - replacements = map[string]string{} - } - if adapt != nil { - for k, v := range adapt.ToolDescriptionReplacements { - replacements[k] = v - } + if adapt == nil || len(adapt.ToolDescriptionReplacements) == 0 { + return description } + replacements := adapt.ToolDescriptionReplacements lines := strings.Split(description, "\n") for i := 0; i < len(lines); i++ { @@ -82,9 +62,7 @@ func matchIn(t []entry, modelID string) *Adaptation { if globMatch(strings.ToLower(t[i].pattern), id) { a := t[i].adapt a.ExcludeTools = slices.Clone(a.ExcludeTools) - a.IncludeTools = slices.Clone(a.IncludeTools) - a.Blocks = slices.Clone(a.Blocks) - a.Additions = maps.Clone(a.Additions) + a.Append = slices.Clone(a.Append) a.ToolDescriptionReplacements = maps.Clone(a.ToolDescriptionReplacements) return &a } diff --git a/internal/adaptation/match_test.go b/internal/adaptation/match_test.go index 33eed37c..e3ded3f5 100644 --- a/internal/adaptation/match_test.go +++ b/internal/adaptation/match_test.go @@ -12,12 +12,12 @@ import ( func TestMatchInOrderingMostSpecificFirst(t *testing.T) { // gpt-oss* is listed before gpt-* so the more specific family wins on overlap. tbl := []entry{ - {pattern: "gpt-oss*", adapt: Adaptation{Additions: map[string]string{"tone": "gpt-oss-addition"}}}, - {pattern: "gpt-*", adapt: Adaptation{Additions: map[string]string{"tone": "gpt-addition"}}}, + {pattern: "gpt-oss*", adapt: Adaptation{Append: []string{"gpt-oss-addition"}}}, + {pattern: "gpt-*", adapt: Adaptation{Append: []string{"gpt-addition"}}}, } cases := []struct { id string - want string // tone addition marker, "" means nil + want string // first append marker, "" means nil }{ {"gpt-oss-20b", "gpt-oss-addition"}, {"gpt-oss-120b", "gpt-oss-addition"}, // first match wins on overlap @@ -28,13 +28,13 @@ func TestMatchInOrderingMostSpecificFirst(t *testing.T) { t.Run(tc.id, func(t *testing.T) { got := matchIn(tbl, tc.id) testassert.Require(t, got != nil, "matchIn(%q) = nil, want %q", tc.id, tc.want) - testassert.Require(t, got.Additions["tone"] == tc.want, "matchIn(%q).Additions[tone] = %q, want %q", tc.id, got.Additions["tone"], tc.want) + testassert.Require(t, got.Append[0] == tc.want, "matchIn(%q).Append[0] = %q, want %q", tc.id, got.Append[0], tc.want) }) } } func TestMatchInAbstention(t *testing.T) { - tbl := []entry{{pattern: "gpt-*", adapt: Adaptation{Additions: map[string]string{"tone": "gpt-addition"}}}} + tbl := []entry{{pattern: "gpt-*", adapt: Adaptation{Append: []string{"gpt-addition"}}}} for _, id := range []string{"claude-opus-4-8", "qwen3.7-plus", "", "gp"} { { got := matchIn(tbl, id) @@ -44,29 +44,29 @@ func TestMatchInAbstention(t *testing.T) { } func TestMatchInCaseInsensitive(t *testing.T) { - tbl := []entry{{pattern: "GPT-OSS*", adapt: Adaptation{Additions: map[string]string{"tone": "gpt-oss-addition"}}}} + tbl := []entry{{pattern: "GPT-OSS*", adapt: Adaptation{Append: []string{"gpt-oss-addition"}}}} // Both the pattern (upper) and the input (mixed) are folded before matching. for _, id := range []string{"gpt-oss-20b", "GPT-OSS-20B", "Gpt-Oss-20b"} { got := matchIn(tbl, id) - testassert.Require(t, got != nil && got.Additions["tone"] == "gpt-oss-addition", "matchIn(%q) = %+v, want the gpt-oss row", id, got) + testassert.Require(t, got != nil && got.Append[0] == "gpt-oss-addition", "matchIn(%q) = %+v, want the gpt-oss row", id, got) } } func TestMatchInSlashContainingBareID(t *testing.T) { // A bare model id can itself contain slashes (coremodel.Parse cuts only the // first '/'), so the wildcard must cross '/'. - tbl := []entry{{pattern: "deepseek*", adapt: Adaptation{Additions: map[string]string{"tone": "deepseek-addition"}}}} + tbl := []entry{{pattern: "deepseek*", adapt: Adaptation{Append: []string{"deepseek-addition"}}}} got := matchIn(tbl, "deepseek/deepseek-chat") - testassert.Require(t, got != nil && got.Additions["tone"] == "deepseek-addition", "matchIn(deepseek/deepseek-chat) = %+v, want the deepseek row", got) + testassert.Require(t, got != nil && got.Append[0] == "deepseek-addition", "matchIn(deepseek/deepseek-chat) = %+v, want the deepseek row", got) } func TestMatchInIsAnchoredNotSubstring(t *testing.T) { // The resolver is always fed the bare model id; a provider-prefixed string // must not match a pattern that the bare id matches (documents "feed ref.Model"). - tbl := []entry{{pattern: "gpt-5*", adapt: Adaptation{Additions: map[string]string{"tone": "gpt5-addition"}}}} + tbl := []entry{{pattern: "gpt-5*", adapt: Adaptation{Append: []string{"gpt5-addition"}}}} { got := matchIn(tbl, "gpt-5.4") - testassert.Require(t, got != nil && got.Additions["tone"] == "gpt5-addition", "matchIn(gpt-5.4) = %+v, want the gpt-5 row", got) + testassert.Require(t, got != nil && got.Append[0] == "gpt5-addition", "matchIn(gpt-5.4) = %+v, want the gpt-5 row", got) } { got := matchIn(tbl, "openai/gpt-5.4") @@ -77,85 +77,99 @@ func TestMatchInIsAnchoredNotSubstring(t *testing.T) { func TestMatchInReturnsCopyNotAlias(t *testing.T) { tbl := []entry{{pattern: "gpt-*", adapt: Adaptation{ ExcludeTools: []string{"edit_file"}, - IncludeTools: []string{"apply_patch"}, - Blocks: []string{"coach"}, - Additions: map[string]string{"tone": "tone-add"}, - ToolDescriptionReplacements: map[string]string{"": "apply_patch"}, + Append: []string{"coach"}, + ToolDescriptionReplacements: map[string]string{"edit_file or write_file": "apply_patch"}, }}} got := matchIn(tbl, "gpt-5.4") testassert.Require(t, got != nil, "matchIn returned nil") testassert.Require(t, got != &tbl[0].adapt, "matchIn returned a pointer aliasing the table entry") // Mutating every field of the result must not touch the table entry. got.ExcludeTools[0] = "mutated" - got.IncludeTools[0] = "mutated" - got.Blocks[0] = "mutated" - got.Blocks = append(got.Blocks, "extra") - got.Additions["tone"] = "mutated" - got.Additions["safety"] = "mutated" - got.ToolDescriptionReplacements[""] = "mutated" + got.Append[0] = "mutated" + got.Append = append(got.Append, "extra") + got.ToolDescriptionReplacements["edit_file or write_file"] = "mutated" testassert.Equal(t, tbl[0].adapt.ExcludeTools[0], "edit_file") - testassert.Equal(t, tbl[0].adapt.IncludeTools[0], "apply_patch") - testassert.Require(t, len(tbl[0].adapt.Blocks) == 1 && tbl[0].adapt.Blocks[0] == "coach", "Blocks leaked into the table: %v", tbl[0].adapt.Blocks) - testassert.Require(t, len(tbl[0].adapt.Additions) == 1 && tbl[0].adapt.Additions["tone"] == "tone-add", "Additions leaked into the table: %v", tbl[0].adapt.Additions) - testassert.Require(t, len(tbl[0].adapt.ToolDescriptionReplacements) == 1 && tbl[0].adapt.ToolDescriptionReplacements[""] == "apply_patch", "ToolDescriptionReplacements leaked into the table: %v", tbl[0].adapt.ToolDescriptionReplacements) + testassert.Require(t, len(tbl[0].adapt.Append) == 1 && tbl[0].adapt.Append[0] == "coach", "Append leaked into the table: %v", tbl[0].adapt.Append) + testassert.Require(t, len(tbl[0].adapt.ToolDescriptionReplacements) == 1 && tbl[0].adapt.ToolDescriptionReplacements["edit_file or write_file"] == "apply_patch", "ToolDescriptionReplacements leaked into the table: %v", tbl[0].adapt.ToolDescriptionReplacements) } -func TestSectionAdditionResolution(t *testing.T) { +func TestTransformPromptNoOpReturnsIncomingBytes(t *testing.T) { + incoming := "## Identity\n\nbaseline text\n\n## Rules\n\nuser rules\n" + for _, adapt := range []*Adaptation{nil, {}} { + testassert.Equal(t, TransformPrompt(incoming, adapt), incoming) + } +} + +func TestTransformPromptOrderedAppend(t *testing.T) { cases := []struct { - name string - adapt *Adaptation - section string - want string + name string + incoming string + adapt *Adaptation + want string }{ - {"nil adapt", nil, "safety", ""}, - {"active non-empty key", &Adaptation{Additions: map[string]string{"tone": "ACTIVE_TONE"}}, "tone", "ACTIVE_TONE"}, - {"active key absent", &Adaptation{Additions: map[string]string{"safety": "ACTIVE_SAFETY"}}, "tone", ""}, - {"nil Additions map", &Adaptation{}, "tone", ""}, + { + name: "first-line indentation", + incoming: "BASE", + adapt: &Adaptation{Append: []string{" indented\nbody", "after"}}, + want: "BASE\n\n indented\nbody\n\nafter", + }, + { + name: "trailing whitespace and newlines", + incoming: "BASE", + adapt: &Adaptation{Append: []string{"first \n\n", "last\t"}}, + want: "BASE\n\nfirst \n\n\n\nlast\t", + }, + { + name: "empty and whitespace entries between nonempty", + incoming: "BASE", + adapt: &Adaptation{Append: []string{"first", "", " ", "second"}}, + want: "BASE\n\nfirst\n\n\n\n \n\nsecond", + }, + { + name: "empty base with leading empty append", + incoming: "", + adapt: &Adaptation{Append: []string{"", "after"}}, + want: "\n\nafter", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := SectionAddition(tc.adapt, tc.section) - testassert.Require(t, got == tc.want, "SectionAddition(%v, %q) = %q, want %q", tc.adapt, tc.section, got, tc.want) + testassert.Equal(t, TransformPrompt(tc.incoming, tc.adapt), tc.want) }) } } -func TestShippedToolDescriptionDefaults(t *testing.T) { - want := map[string]string{ - "": "edit_file or write_file", - } - for key, value := range want { - { - got := defaultToolDescriptionReplacements[key] - testassert.Require(t, got == value, "defaultToolDescriptionReplacements[%q] = %q, want %q", key, got, value) - } - } +func TestTransformPromptEqualTextAndTransformProduceEqualOutput(t *testing.T) { + // Two differently assembled texts that are equal after assembly take the + // same transformation regardless of headings or provenance. + first := "## Identity\n\nprompt\n\n## Rules\n\nrules" + second := strings.Join([]string{"## Identity", "", "prompt", "", "## Rules", "", "rules"}, "\n") + testassert.Equal(t, first, second) + adapt := &Adaptation{Append: []string{"appended"}} + testassert.Equal(t, TransformPrompt(first, adapt), TransformPrompt(second, adapt)) } func TestRenderToolDescriptionReplacements(t *testing.T) { - orig := defaultToolDescriptionReplacements - defer func() { defaultToolDescriptionReplacements = orig }() - defaultToolDescriptionReplacements = map[string]string{ - "": "edit_file or write_file", - "": "- removed by adaptation", - } - description := strings.Join([]string{ - "use ", - "", + "use edit_file or write_file", + "- removed by adaptation", "done", }, "\n") + // With no active adaptation the description is the tool's own text. baseline := RenderToolDescription(description, nil) - testassert.Require(t, !strings.Contains(baseline, "<") && strings.Contains(baseline, "use edit_file or write_file") && strings.Contains(baseline, "- removed by adaptation"), "baseline RenderToolDescription = %q", baseline) + testassert.Equal(t, baseline, description) + // The active adaptation's literal overrides apply: one rewrites the + // naming phrase inside its line, the other resolves to empty and removes + // the line that is only that value. adapted := RenderToolDescription(description, &Adaptation{ ToolDescriptionReplacements: map[string]string{ - "": "apply_patch", - "": "", + "edit_file or write_file": "apply_patch", + "- removed by adaptation": "", }, }) - testassert.Require(t, !strings.Contains(adapted, "<") && !strings.Contains(adapted, "removed by adaptation"), "adapted RenderToolDescription = %q", adapted) + testassert.Require(t, !strings.Contains(adapted, "removed by adaptation"), "adapted RenderToolDescription = %q", adapted) { want := "use apply_patch\ndone" testassert.Equal(t, adapted, want) @@ -163,26 +177,29 @@ func TestRenderToolDescriptionReplacements(t *testing.T) { } // TestShippedTableMatchesBundledGPTCodex asserts that the bundled data's -// GPT-Codex row (5.4 / 5.5) gets the combined adaptation: prompt addition -// plus the apply_patch tool swap. The expected text is sourced from the -// fixture JSON — no Go constant for the addition text. +// GPT-Codex row (5.4 / 5.5) gets the combined adaptation: prompt append plus +// the apply_patch tool swap. The expected text is sourced from the fixture +// JSON — no Go constant for the append text. +// gptGuidanceLine and gptGuidanceReplacement are the complete literal standard +// run_command guidance line the bundled GPT rows replace and its GPT form: the +// data names the whole line, so unrelated prose carrying the same phrase is +// never rewritten. +const gptGuidanceLine = "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file." +const gptGuidanceReplacement = "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." + func TestShippedTableMatchesBundledGPTCodex(t *testing.T) { fx := loadFixture(t) codex := fixtureRow(fx, "gpt-codex") want := "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." - testassert.Equal(t, codex.Additions["task_execution"], want) + testassert.Require(t, slices.Equal(codex.Append, []string{want}), "fixture append = %q, want the one task-execution paragraph", codex.Append) for _, id := range []string{ "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "openai/gpt-5.4-mini", "openai/gpt-5.5-pro", } { t.Run(id, func(t *testing.T) { got := Match(id) testassert.Require(t, got != nil, "Match(%q) = nil, want gpt-codex", id) - testassert.Require(t, reflect.DeepEqual(got.Additions, codex.Additions), "Match(%q).Additions = %v, want the %s row additions", id, got.Additions, codex.Name) - { - add := SectionAddition(got, "task_execution") - testassert.Require(t, add == want, "SectionAddition(Match(%q), task_execution) differs from canonical text", id) - } - testassert.Check(t, slices.Contains(got.IncludeTools, "apply_patch"), "Match(%q).IncludeTools = %v, want apply_patch", id, got.IncludeTools) + testassert.Require(t, slices.Equal(got.Append, codex.Append), "Match(%q).Append = %v, want the %s row append", id, got.Append, codex.Name) + testassert.Require(t, reflect.DeepEqual(got.ToolDescriptionReplacements, map[string]string{gptGuidanceLine: gptGuidanceReplacement}), "Match(%q).ToolDescriptionReplacements = %q, want the one literal guidance-line rule", id, got.ToolDescriptionReplacements) }) } } @@ -196,12 +213,9 @@ func TestShippedTableMatchesBundledGPTApplyPatchBroad(t *testing.T) { t.Run(id, func(t *testing.T) { got := Match(id) testassert.Require(t, got != nil, "Match(%q) = nil, want gpt-apply-patch", id) - testassert.Require(t, reflect.DeepEqual(got.IncludeTools, ap.IncludeTools), "Match(%q).IncludeTools = %v, want the %s row include_tools", id, got.IncludeTools, ap.Name) - testassert.Check(t, slices.Contains(got.IncludeTools, "apply_patch"), "Match(%q).IncludeTools = %v, want apply_patch", id, got.IncludeTools) - { - v, ok := got.Additions["task_execution"] - testassert.Check(t, !ok || v == "", "Match(%q).Additions[task_execution] = %q, want absent (broader gpt-5 has no prompt addition)", id, v) - } + testassert.Require(t, reflect.DeepEqual(got.ExcludeTools, ap.ExcludeTools), "Match(%q).ExcludeTools = %v, want the %s row exclude_tools", id, got.ExcludeTools, ap.Name) + testassert.Check(t, slices.Contains(got.ExcludeTools, "write_file"), "Match(%q).ExcludeTools = %v, want the file tools the V4A row replaces", id, got.ExcludeTools) + testassert.Check(t, len(got.Append) == 0, "Match(%q).Append = %q, want absent (broader gpt-5 has no prompt addition)", id, got.Append) }) } } @@ -213,11 +227,8 @@ func TestShippedTableMatchesBundledGrok(t *testing.T) { t.Run(id, func(t *testing.T) { got := Match(id) testassert.Require(t, got != nil, "Match(%q) = nil, want adaptation", id) - { - add := SectionAddition(got, "task_execution") - testassert.Require(t, add != "" && add == ge.Additions["task_execution"], "Match(%q).Additions[task_execution] = %q, want the nonempty %s row addition", id, add, ge.Name) - } - testassert.Check(t, !slices.Contains(got.IncludeTools, "apply_patch"), "grok should not include apply_patch (not V4A): %v", got.IncludeTools) + testassert.Require(t, len(got.Append) != 0 && slices.Equal(got.Append, ge.Append), "Match(%q).Append = %q, want the nonempty %s row append", id, got.Append, ge.Name) + testassert.Require(t, len(got.ExcludeTools) == 0 && len(got.ToolDescriptionReplacements) == 0, "grok adaptation carries tool levers %+v, want the append-only row", got) }) } } @@ -232,10 +243,7 @@ func TestShippedTableMatchesBundledGoogle(t *testing.T) { t.Run(id, func(t *testing.T) { got := Match(id) testassert.Require(t, got != nil, "Match(%q) = nil, want google-task-execution", id) - { - add := SectionAddition(got, "task_execution") - testassert.Require(t, add != "" && add == ge.Additions["task_execution"], "Match(%q).Additions[task_execution] = %q, want the nonempty %s row addition", id, add, ge.Name) - } + testassert.Require(t, len(got.Append) != 0 && slices.Equal(got.Append, ge.Append), "Match(%q).Append = %q, want the nonempty %s row append", id, got.Append, ge.Name) }) } } diff --git a/internal/adaptation/testdata/bundled.json b/internal/adaptation/testdata/bundled.json index 89ebde60..56932b70 100644 --- a/internal/adaptation/testdata/bundled.json +++ b/internal/adaptation/testdata/bundled.json @@ -1,64 +1,65 @@ { "version": 1, - "defaults": { - "tool_description_replacements": { - "": "edit_file or write_file" - } - }, "entries": [ { "pattern": "*gpt-5.5*", "name": "gpt-codex", - "exclude_tools": ["edit_file", "write_file"], - "include_tools": ["apply_patch"], + "exclude_tools": [ + "edit_file", + "write_file" + ], "tool_description_replacements": { - "": "apply_patch" + "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.": "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." }, - "additions": { - "task_execution": "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." - } + "append": [ + "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." + ] }, { "pattern": "*gpt-5.4*", "name": "gpt-codex", - "exclude_tools": ["edit_file", "write_file"], - "include_tools": ["apply_patch"], + "exclude_tools": [ + "edit_file", + "write_file" + ], "tool_description_replacements": { - "": "apply_patch" + "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.": "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." }, - "additions": { - "task_execution": "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." - } + "append": [ + "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." + ] }, { "pattern": "*gpt-5*", "name": "gpt-apply-patch", - "exclude_tools": ["edit_file", "write_file"], - "include_tools": ["apply_patch"], + "exclude_tools": [ + "edit_file", + "write_file" + ], "tool_description_replacements": { - "": "apply_patch" + "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.": "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." } }, { "pattern": "*grok-build-0.1", "name": "gpt-task-execution", - "additions": { - "task_execution": "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." - } + "append": [ + "Use more tool calls when they can improve the response, provide useful context, or help plan or complete the task.\n\nIf you encounter a new or unexpected challenge before the active task is complete, try to resolve it yourself first. If a tool call fails or returns incomplete or unhelpful output, try another tool or a different approach." + ] }, { "pattern": "*gemini-3*", "name": "google-task-execution", - "additions": { - "task_execution": "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." - } + "append": [ + "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." + ] }, { "pattern": "*gemma-4*", "name": "google-task-execution", - "additions": { - "task_execution": "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." - } + "append": [ + "Use absolute paths when calling tools or running commands.\n\nDo not run commands that require interaction, such as `y`/`yes` confirmation prompts. Run them in non-interactive mode when available, such as with `-y`, `--yes`, or `--non-interactive`.\n\nAlways provide a brief explanation BEFORE running commands that modify the filesystem." + ] } ] } diff --git a/internal/agents/builtin.go b/internal/agents/builtin.go index b2b9266b..f0b79bb8 100644 --- a/internal/agents/builtin.go +++ b/internal/agents/builtin.go @@ -1,26 +1,6 @@ package agents -// defaultSummarizerPrompt is the compact built-in's summarizer prompt. -const defaultSummarizerPrompt = `You are summarizing a coding agent conversation so the agent can continue working with reduced context. Your summary must preserve everything the agent needs to continue without re-reading files or re-asking the user. - -Produce a summary with these sections: - -## Goal -What the user asked for, in their words. Include exact quotes of key instructions. - -## Files -Which files were read, created, or modified. For modified files: what changed and why. - -## Current state -What has been done so far. What works, what doesn't. - -## Unfinished work -Tasks started but not completed. Errors not yet resolved. Things the agent said it would do next. - -## Decisions -Choices the user made or confirmed. Constraints they stated. Things they rejected. - -Be specific — include file paths, function names, error messages, line numbers. Do not generalize. If the user said "don't use React," write that exactly, not "user has framework preferences."` +import "github.com/MMinasyan/lightcode/prompt" var builtinOrder = []string{"primary", "secondary", "explore", "review", "compact"} @@ -29,7 +9,6 @@ var builtins = map[string]Definition{ SystemPrompt: SystemPromptFull, Prompt: stringPtr(""), Tools: toolsPtr(StandardTools), - LSP: boolPtr(true), Readonly: boolPtr(false), WriteDir: stringPtr(""), Description: stringPtr(""), @@ -38,7 +17,6 @@ var builtins = map[string]Definition{ "secondary": { SystemPrompt: SystemPromptSimple, Prompt: stringPtr(secondaryPrompt), - LSP: boolPtr(true), Readonly: boolPtr(false), Description: stringPtr(secondaryDescription), Subagent: boolPtr(true), @@ -56,9 +34,8 @@ var builtins = map[string]Definition{ }, "compact": { SystemPrompt: SystemPromptNone, - Prompt: stringPtr(defaultSummarizerPrompt), + Prompt: stringPtr(prompt.DefaultSummarizerPrompt), Tools: toolsPtr(nil), - LSP: boolPtr(false), Readonly: boolPtr(true), WriteDir: stringPtr(""), Description: stringPtr(""), diff --git a/internal/agents/loader.go b/internal/agents/loader.go index e103b606..23db7d8d 100644 --- a/internal/agents/loader.go +++ b/internal/agents/loader.go @@ -261,9 +261,6 @@ func applyDefinition(out *Resolved, def Definition) { out.Capabilities = append([]string(nil), (*def.Capabilities)...) } } - if def.LSP != nil { - out.LSP = *def.LSP - } if def.Readonly != nil { out.Readonly = *def.Readonly } @@ -353,9 +350,6 @@ func copyDefinition(def Definition) Definition { if def.Capabilities != nil { def.Capabilities = toolsPtr(*def.Capabilities) } - if def.LSP != nil { - def.LSP = boolPtr(*def.LSP) - } if def.Readonly != nil { def.Readonly = boolPtr(*def.Readonly) } diff --git a/internal/agents/loader_test.go b/internal/agents/loader_test.go index 63fb0564..859e708b 100644 --- a/internal/agents/loader_test.go +++ b/internal/agents/loader_test.go @@ -43,7 +43,7 @@ func TestResolveBuiltinsInheritThroughPrimary(t *testing.T) { explore, err := cfg.Resolve("explore") testassert.NoError(t, err) - testassert.Require(t, explore.Model == "test/secondary-model" && explore.SystemPrompt == SystemPromptSimple && explore.LSP && explore.Subagent && explore.Readonly, "got %v, want %v; got %v, want %v; explore booleans = lsp:%v subagent:%v readonly:%v", explore.Model, "test/secondary-model", explore.SystemPrompt, SystemPromptSimple, explore.LSP, explore.Subagent, explore.Readonly) + testassert.Require(t, explore.Model == "test/secondary-model" && explore.SystemPrompt == SystemPromptSimple && explore.Subagent && explore.Readonly, "got %v, want %v; got %v, want %v; explore booleans = subagent:%v readonly:%v", explore.Model, "test/secondary-model", explore.SystemPrompt, SystemPromptSimple, explore.Subagent, explore.Readonly) review, err := cfg.Resolve("review") testassert.NoError(t, err) @@ -136,11 +136,11 @@ func TestBuiltinsExposeConcreteRootAndCodeOnlyCompact(t *testing.T) { primary, err := cfg.Resolve("primary") testassert.NoError(t, err) - testassert.Require(t, primary.SystemPrompt == SystemPromptFull && reflect.DeepEqual(primary.Tools, StandardTools) && primary.LSP && !primary.Readonly && !primary.Subagent, "primary = %+v, want full prompt and standard tools; primary booleans = lsp:%v readonly:%v subagent:%v", primary, primary.LSP, primary.Readonly, primary.Subagent) + testassert.Require(t, primary.SystemPrompt == SystemPromptFull && reflect.DeepEqual(primary.Tools, StandardTools) && !primary.Readonly && !primary.Subagent, "primary = %+v, want full prompt and standard tools; primary booleans = readonly:%v subagent:%v", primary, primary.Readonly, primary.Subagent) compactAgent, err := cfg.Resolve("compact") testassert.NoError(t, err) - testassert.Require(t, compactAgent.SystemPrompt == SystemPromptNone && compactAgent.Prompt == retainedSummarizerPrompt && len(compactAgent.Tools) == 0 && !compactAgent.LSP && compactAgent.Readonly && !compactAgent.Subagent, "compact prompt = system:%q body:%q; compact capability fields = %+v", compactAgent.SystemPrompt, compactAgent.Prompt, compactAgent) + testassert.Require(t, compactAgent.SystemPrompt == SystemPromptNone && compactAgent.Prompt == retainedSummarizerPrompt && len(compactAgent.Tools) == 0 && compactAgent.Readonly && !compactAgent.Subagent, "compact prompt = system:%q body:%q; compact capability fields = %+v", compactAgent.SystemPrompt, compactAgent.Prompt, compactAgent) } func TestToolsPresenceControlsInheritanceEmptyAndSubset(t *testing.T) { diff --git a/internal/agents/types.go b/internal/agents/types.go index 058d5242..cb8776ea 100644 --- a/internal/agents/types.go +++ b/internal/agents/types.go @@ -28,7 +28,6 @@ type Definition struct { Prompt *string `json:"prompt,omitempty"` Tools *[]string `json:"tools,omitempty"` Capabilities *[]string `json:"capabilities"` - LSP *bool `json:"lsp,omitempty"` Readonly *bool `json:"readonly,omitempty"` WriteDir *string `json:"write_dir,omitempty"` Description *string `json:"description,omitempty"` @@ -43,7 +42,6 @@ type Resolved struct { Prompt string Tools []string Capabilities []string - LSP bool Readonly bool WriteDir string Description string diff --git a/internal/cliclient/commands.go b/internal/cliclient/commands.go index b1d41092..1d60aee4 100644 --- a/internal/cliclient/commands.go +++ b/internal/cliclient/commands.go @@ -690,11 +690,7 @@ func (c *CLI) command(text string) { if read && err != nil { text = terminal.RenderErrorMsg(err.Error()) } - label := invocation[0] - if strings.Contains(label, "Retained") { - label += " — retained snapshot boundaries, not conversation turns" - } - text = terminal.RenderSystemMsg(label) + text + text = terminal.RenderSystemMsg(invocation[0]) + text c.report(s, text, !read) if errors.Is(err, clientops.ErrInvalidParams) { return diff --git a/internal/cliclient/contracts_test.go b/internal/cliclient/contracts_test.go index c46285f0..3dab6759 100644 --- a/internal/cliclient/contracts_test.go +++ b/internal/cliclient/contracts_test.go @@ -593,7 +593,7 @@ func TestFailedResumeLeavesTheSourceLiveUnderAHeldHydration(t *testing.T) { testassert.Require(t, c.rootID == rootA && c.root != nil && c.root.hydration != nil && c.root.hydration.SessionRevision.DurableRevision == "3", "the retained source was left stale after a failed resume") } -func TestRuntimeCurrentRetainedSnapshotsAndPartialRestoreRemainReachable(t *testing.T) { +func TestRuntimeCurrentSnapshotsAndPartialRestoreRemainReachable(t *testing.T) { c, transport := fixture(t) settledReader(t, c, transport) c.command(`/runtime GetSessionCodeSnapshots {"session_id":"` + rootA + `"}`) @@ -602,15 +602,9 @@ func TestRuntimeCurrentRetainedSnapshotsAndPartialRestoreRemainReachable(t *test current.respond([]protocol.CodeSnapshotGroup{{OperationId: "owned-operation", GroupItemId: "group", Files: []protocol.SnapshotFile{}}}) settleTasks(t, c) testassert.Require(t, strings.Contains(outputs(c), "owned-operation"), "Operation snapshot boundary dropped") - c.command(`/runtime GetRetainedCodeSnapshots {"query":{"workspace":"/workspace","session_id":"1234abcd"}}`) - retained := receive(t, transport.requests) - testassert.Require(t, retained.req.URL.Query().Get("workspace") == "/workspace" && retained.req.URL.Query().Get("session_id") == "1234abcd", "retained snapshot address changed") - retained.respond(protocol.RetainedTurns{Turns: []protocol.RetainedTurn{{Turn: 7, Files: []protocol.SnapshotFile{}}}}) - settleTasks(t, c) - testassert.Require(t, strings.Contains(outputs(c), "retained snapshot boundaries"), "retained boundary labelled as conversation turn") - c.command(`/runtime RevertRetainedCode {"body":{"workspace":"/workspace","session_id":"1234abcd","after_turn":7}}`) + c.command(`/runtime RevertSessionCode {"session_id":"` + rootA + `","body":{"boundary_operation_id":"owned-operation"}}`) restore := receive(t, transport.requests) - testassert.Require(t, strings.Contains(string(restore.body), `"after_turn":7`), "retained restore boundary rewritten") + testassert.Require(t, strings.Contains(string(restore.body), `"boundary_operation_id":"owned-operation"`), "canonical restore boundary rewritten") restore.reject(409, protocol.CodeRevertResult{Restored: []string{"one.go"}, Skipped: []protocol.SkippedFile{{Path: "two.go", Reason: "changed"}}, Error: &protocol.Error{Code: "conflict", Message: "partial restore"}}) receive(t, transport.requests).respond(hydration(rootA, "2", inputs(1, 1), nil)) settleTasks(t, c) diff --git a/internal/cliclient/globals_test.go b/internal/cliclient/globals_test.go index c039de43..8580a8e1 100644 --- a/internal/cliclient/globals_test.go +++ b/internal/cliclient/globals_test.go @@ -261,7 +261,7 @@ func TestConnectionRepairClearsOldFloorsBeforeNewOwnerHydration(t *testing.T) { // unused opaque extra_body members carry the overflow numeric lexemes as // exact bytes, plus its malformed-delivery sibling body. const opaqueOverflowConfiguration = `{"configuration_revision":{"instance_id":"` + instance + `","generation":"2"},` + - `"settings":{"plugins":{}},"agents":[{"name":"primary","description":"primary agent"},{"name":"planner","description":"planning agent"}],` + + `"settings":{"sessions":{"auto_archive":true,"archive_after_days":7,"delete_after_archive_days":7},"tools":{},"jobs":{},"compaction":{},"plugins":{}},"agents":[{"name":"primary","description":"primary agent"},{"name":"planner","description":"planning agent"}],` + `"providers":[{"id":"prov","name":"Prov","connected":true,` + `"extra_body":{"n":9007199254740993,"big":1e400},` + `"models":[{"id":"m","name":"M","context_window":4096,"source":"user",` + diff --git a/internal/cliclient/reader_test.go b/internal/cliclient/reader_test.go index a1dbb21c..bb67a49d 100644 --- a/internal/cliclient/reader_test.go +++ b/internal/cliclient/reader_test.go @@ -119,13 +119,13 @@ func discovery(t *testing.T, handler http.Handler) (clienthost.Options, *client. // discoveryRecord builds one private discovery document for the given owner. func discoveryRecord(endpoint, owner string) []byte { - raw, _ := json.Marshal(map[string]string{"endpoint": endpoint, "instance_id": owner, "protocol_version": string(protocol.N6), "credential": strings.Repeat("1", 64)}) + raw, _ := json.Marshal(map[string]string{"endpoint": endpoint, "instance_id": owner, "protocol_version": string(protocol.N8), "credential": strings.Repeat("1", 64)}) return raw } func health(w http.ResponseWriter, owner string) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(protocol.Health{InstanceId: owner, ProtocolVersion: protocol.N6}) + _ = json.NewEncoder(w).Encode(protocol.Health{InstanceId: owner, ProtocolVersion: protocol.N8}) } func fixture(t *testing.T) (*CLI, *scriptedTransport) { diff --git a/internal/clienthost/process_test.go b/internal/clienthost/process_test.go index 7552bb67..8530dbda 100644 --- a/internal/clienthost/process_test.go +++ b/internal/clienthost/process_test.go @@ -33,7 +33,6 @@ import ( "github.com/MMinasyan/lightcode/client" "github.com/MMinasyan/lightcode/internal/atomicfs" "github.com/MMinasyan/lightcode/internal/clienthost" - "github.com/MMinasyan/lightcode/internal/plugins/sqlite" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/protocol" "github.com/MMinasyan/lightcode/runtime" @@ -167,22 +166,35 @@ func runShapedChild(dataDir, configPath, version string, park bool) { awaitStopAndClose(r) } -// openRuntime opens the production sqlite plugin declaration through the -// public Runtime constructor, wrapping its test-only factory callback with the -// per-attempt marker. No controlled preparation or private constructor is -// used. -func openRuntime(dataDir, configPath string) (*runtime.Runtime, error) { - plugins := []runtime.Plugin{sqlite.Plugin()} - for index := range plugins { - plugin := plugins[index] - open := plugin.Open - plugin.Open = func(ctx context.Context, info runtime.ScopeInfo, bindings runtime.Bindings) (runtime.Instance, error) { - markFactory(plugin.ID) - return open(ctx, info, bindings) - } - plugins[index] = plugin +// attemptMarkerPlugin is one inert Runtime-scoped plugin: its factory runs +// only after the ownership lock and the initial publication are acquired, so a +// losing attempt's zero factories stay provable per attempt. The store and the +// standard tools are directly constructed by the owner and declare no factory. +func attemptMarkerPlugin() runtime.Plugin { + return runtime.Plugin{ + ID: "attempt", + Scope: runtime.ScopeRuntime, + Provides: []runtime.CapabilitySpec{runtime.Spec[attemptCloser]("attempt.closer")}, + Open: func(context.Context, runtime.ScopeInfo, runtime.Bindings) (runtime.Instance, error) { + markFactory("attempt") + return runtime.Instance{Values: map[string]any{"attempt.closer": attemptCloser{}}}, nil + }, } - return runtime.Open(context.Background(), runtime.Options{DataDir: dataDir, ConfigPath: configPath, Plugins: plugins}) +} + +// attemptCloser is the marker plugin's exported value type: a closer that +// does nothing but keeps the declaration a real capability. +type attemptCloser struct{} + +// openRuntime opens one real Runtime through the public constructor with the +// per-attempt factory marker. No controlled preparation or private constructor +// is used. +func openRuntime(dataDir, configPath string) (*runtime.Runtime, error) { + return runtime.Open(context.Background(), runtime.Options{ + DataDir: dataDir, + ConfigPath: configPath, + Plugins: []runtime.Plugin{attemptMarkerPlugin()}, + }) } // shapeHealth starts one test-only health server over the record's published @@ -615,7 +627,7 @@ func TestClientHostConcurrentStartup(t *testing.T) { t.Fatalf("client instance %q, record %q", clients[0].InstanceID(), record.InstanceID) } health := authenticatedHealth(t, record.Endpoint, record.Credential) - testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N6, "authenticated health = %s/%s, want the record's instance and wire version", health.InstanceId, health.ProtocolVersion) + testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N8, "authenticated health = %s/%s, want the record's instance and wire version", health.InstanceId, health.ProtocolVersion) winners := 0 for _, l := range []*launched{first, second} { @@ -670,7 +682,7 @@ func TestClientHostIncompatibleIncumbent(t *testing.T) { testassert.Require(t, bytes.Equal(after, before), "the incompatible record changed across the probe") } health := authenticatedHealth(t, originalEndpoint, record.Credential) - testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N6, "the incumbent's real health = %s/%s", health.InstanceId, health.ProtocolVersion) + testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N8, "the incumbent's real health = %s/%s", health.InstanceId, health.ProtocolVersion) incumbent.stopAndJoin(t) } @@ -740,7 +752,7 @@ func TestClientHostCancellationDoesNotKillOwner(t *testing.T) { record := readRecord(t, filepath.Join(s.dataDir, "runtime.json")) testassert.Equal(t, record.Endpoint, endpoint) health := authenticatedHealth(t, record.Endpoint, record.Credential) - testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N6, "the surviving owner health = %s/%s", health.InstanceId, health.ProtocolVersion) + testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N8, "the surviving owner health = %s/%s", health.InstanceId, health.ProtocolVersion) pid := attemptPID(t, attempts[0]) testassert.Require(t, aliveProcessByPID(pid), "the canceled Connect killed owner process %d", pid) { @@ -773,7 +785,7 @@ func TestClientHostHealthTimeoutPreservesHolder(t *testing.T) { testassert.Require(t, errors.Is(err, context.DeadlineExceeded), "Connect against a parked health holder = %v, want the caller deadline", err) health := authenticatedHealth(t, originalEndpoint, record.Credential) - testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N6, "the preserved holder health = %s/%s", health.InstanceId, health.ProtocolVersion) + testassert.Require(t, health.InstanceId == record.InstanceID && health.ProtocolVersion == protocol.N8, "the preserved holder health = %s/%s", health.InstanceId, health.ProtocolVersion) { after := readFile(t, recordPath) testassert.Require(t, bytes.Equal(after, before), "the parked record changed across the failed probes") diff --git a/internal/clienthost/runmodels_test.go b/internal/clienthost/runmodels_test.go index 51855c43..f5296417 100644 --- a/internal/clienthost/runmodels_test.go +++ b/internal/clienthost/runmodels_test.go @@ -37,7 +37,7 @@ func modelsEndpoint(t *testing.T, status int, body []byte) clienthost.Options { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/v1/health": - _ = json.NewEncoder(w).Encode(map[string]string{"instance_id": instance, "protocol_version": string(protocol.N6)}) + _ = json.NewEncoder(w).Encode(map[string]string{"instance_id": instance, "protocol_version": string(protocol.N8)}) case "/v1/configuration": w.WriteHeader(status) _, _ = w.Write(body) @@ -55,7 +55,7 @@ func modelsEndpoint(t *testing.T, status int, body []byte) clienthost.Options { testassert.Check(t, reflect.DeepEqual(requests, want), "requests = %v, want exactly %v", requests, want) }) dir := t.TempDir() - record, err := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N6), "credential": credential}) + record, err := json.Marshal(map[string]string{"endpoint": server.URL, "instance_id": instance, "protocol_version": string(protocol.N8), "credential": credential}) testassert.NoError(t, err) testassert.NoError(t, os.WriteFile(filepath.Join(dir, "runtime.json"), record, 0o600)) return clienthost.Options{DataDir: dir, Executable: filepath.Join(dir, "must-not-launch")} diff --git a/internal/clientops/clientops_test.go b/internal/clientops/clientops_test.go index 14ad6b79..3a26e1e1 100644 --- a/internal/clientops/clientops_test.go +++ b/internal/clientops/clientops_test.go @@ -102,7 +102,7 @@ type dispatchCase struct { var dispatchCases = []dispatchCase{ { name: "GetHealth", operation: "GetHealth", - status: 200, response: `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"6"}`, + status: 200, response: `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"8"}`, method: "GET", path: "/v1/health", }, { @@ -231,20 +231,6 @@ var dispatchCases = []dispatchCase{ method: "POST", path: "/v1/workspaces/files/read", body: `{"workspace":"/ws","path":"a.go"}`, }, - { - name: "GetRetainedCodeSnapshots", operation: "GetRetainedCodeSnapshots", - params: `{"query":{"workspace":"/ws","session_id":"0123456789abcdef0123456789abcdef"}}`, - status: 200, response: `{"turns":[]}`, - method: "GET", path: "/v1/retained-code-snapshots", - rawQuery: "workspace=%2Fws&session_id=0123456789abcdef0123456789abcdef", - }, - { - name: "RevertRetainedCode", operation: "RevertRetainedCode", - params: `{"body":{"session_id":"0123456789abcdef0123456789abcdef","turn":1}}`, - status: 200, response: `{"restored":["a.go"],"skipped":[]}`, - method: "POST", path: "/v1/retained-code-snapshots/revert", - body: `{"session_id":"0123456789abcdef0123456789abcdef","turn":1}`, - }, { name: "GetConfiguration with opaque overflow extra_body numbers", operation: "GetConfiguration", status: 200, response: `{"configuration_revision":{"instance_id":"0123456789abcdef0123456789abcdef","generation":"1"},"providers":[{"id":"prov","connected":true,"extra_body":{"n":9007199254740993,"big":1e400},"models":[{"id":"m","context_window":8192,"extra_body":{"n":9007199254740993},"source":"user"}]}]}`, @@ -257,10 +243,10 @@ var dispatchCases = []dispatchCase{ }, { name: "UpdateConfigurationSettings", operation: "UpdateConfigurationSettings", - params: `{"body":{"plugins":{"raw":"{\"n\":9007199254740993}"},"sessions":{}}}`, - status: 200, response: `{"configuration_revision":{"instance_id":"0123456789abcdef0123456789abcdef","generation":"3"}}`, + params: `{"body":{"sessions":{"auto_archive":false,"archive_after_days":1,"delete_after_archive_days":0},"tools":{},"jobs":{},"compaction":{},"plugins":{"raw":"{\"n\":9007199254740993}"}}}`, + status: 200, response: `{"configuration_revision":{"instance_id":"0123456789abcdef0123456789abcdef","generation":"3"},"result":{"sessions":{"auto_archive":false,"archive_after_days":1,"delete_after_archive_days":0},"tools":{},"jobs":{},"compaction":{},"plugins":{"raw":"{\"n\":9007199254740993}"}}}`, method: "PUT", path: "/v1/configuration/settings", - body: `{"plugins":{"raw":"{\"n\":9007199254740993}"},"sessions":{}}`, + body: `{"sessions":{"auto_archive":false,"archive_after_days":1,"delete_after_archive_days":0},"tools":{},"jobs":{},"compaction":{},"plugins":{"raw":"{\"n\":9007199254740993}"}}`, }, { name: "SetAgentTypeModel", operation: "SetAgentTypeModel", @@ -430,7 +416,7 @@ func TestCallAcceptsEmptyEnvelopePositives(t *testing.T) { for _, params := range []json.RawMessage{nil, json.RawMessage(`{}`)} { c, doer := newScriptFixture(t) doer.respond = func(*http.Request) (*http.Response, error) { - return scriptedResponse(http.StatusOK, `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"6"}`), nil + return scriptedResponse(http.StatusOK, `{"instance_id":"0123456789abcdef0123456789abcdef","protocol_version":"8"}`), nil } result, err := clientops.Call(context.Background(), c, "GetHealth", params) testassert.Require(t, err == nil, "Call(GetHealth, %q) = %v", params, err) @@ -478,7 +464,6 @@ func TestCallRejectsForbiddenEnvelope(t *testing.T) { {"optional query has an unknown member", "GetSessionHistory", `{"session_id":"s1","query":{"bogus":1}}`}, {"query has an unknown member", "ListSessions", `{"query":{"workspace":"/ws","lifecycle":"open","x":1}}`}, {"query member has the wrong type", "ListModels", `{"query":{"all":"yes"}}`}, - {"retained session_id is not the outer envelope member", "GetRetainedCodeSnapshots", `{"session_id":"s1","query":{"workspace":"/ws","session_id":"s1"}}`}, {"undeclared body", "GetHealth", `{"body":{}}`}, {"missing body", "CreateSession", `{}`}, {"null body", "CreateSession", `{"body":null}`}, diff --git a/internal/clientops/operations.gen.go b/internal/clientops/operations.gen.go index 87f7408c..7a0894bf 100644 --- a/internal/clientops/operations.gen.go +++ b/internal/clientops/operations.gen.go @@ -165,17 +165,6 @@ var operationDescriptors = map[string]descriptor{ return c.GetProviderDetail(ctx, &query) }, }, - "GetRetainedCodeSnapshots": { - query: memberRequired, - status: 200, - call: func(ctx context.Context, c protocol.ClientInterface, env envelope) (*http.Response, error) { - var query protocol.GetRetainedCodeSnapshotsParams - if err := bindQuery(env.query, []string{"workspace", "session_id"}, &query); err != nil { - return nil, err - } - return c.GetRetainedCodeSnapshots(ctx, &query) - }, - }, "GetSessionCodeSnapshots": { session: memberRequired, status: 200, @@ -334,13 +323,6 @@ var operationDescriptors = map[string]descriptor{ return c.ResetProviderModelField(ctx, pathValue, &query) }, }, - "RevertRetainedCode": { - body: memberRequired, - status: 200, - call: func(ctx context.Context, c protocol.ClientInterface, env envelope) (*http.Response, error) { - return c.RevertRetainedCodeWithBody(ctx, contentTypeJSON, bytes.NewReader(env.body)) - }, - }, "RevertSessionCode": { session: memberRequired, body: memberRequired, diff --git a/internal/desktopclient/coordinated_test.go b/internal/desktopclient/coordinated_test.go index 2527d7d1..77e1f089 100644 --- a/internal/desktopclient/coordinated_test.go +++ b/internal/desktopclient/coordinated_test.go @@ -362,7 +362,7 @@ func TestCoordinatedHostsNativeWorkDepartureAndOwnerRepair(t *testing.T) { }() configPath := filepath.Join(dataDir, "config.json") writeServiceFile(t, configPath, fmt.Sprintf(`{"providers":{"prov":{"transport":{"base_url":%q,"api_key_env":"DESKTOP_TEST_KEY"},"discovery":false,"models":{"root":{"context_window":65536,"max_output_tokens":512},"child":{"context_window":65536,"max_output_tokens":512}}}}}`, modelServer.URL+"/v1")) - writeServiceFile(t, agents.PathForConfig(configPath), `{"primary":{"model":"prov/root","system_prompt":"simple","tools":["run_command","task"],"lsp":false},"secondary":{"model":"prov/child","system_prompt":"simple","tools":[],"lsp":false}}`) + writeServiceFile(t, agents.PathForConfig(configPath), `{"primary":{"model":"prov/root","system_prompt":"simple","tools":["run_command","task"]},"secondary":{"model":"prov/child","system_prompt":"simple","tools":[]}}`) var owner *runtime.Runtime openOwner := func() string { var err error diff --git a/internal/desktopclient/host_test.go b/internal/desktopclient/host_test.go index 66bbb9f3..f4fc62c2 100644 --- a/internal/desktopclient/host_test.go +++ b/internal/desktopclient/host_test.go @@ -41,7 +41,7 @@ const ( // recordContent builds one private discovery document pointing at the // fixture endpoint. func recordContent(endpoint string) string { - return fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"6","credential":%q}`, + return fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":"8","credential":%q}`, endpoint, testInstance, testCredential) } @@ -70,7 +70,7 @@ func newFixture(t *testing.T, health, events http.HandlerFunc, extra ...map[stri func jsonHealth() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"6"}`, testInstance) + fmt.Fprintf(w, `{"instance_id":%q,"protocol_version":"8"}`, testInstance) } } @@ -1059,7 +1059,7 @@ func TestDesktopHostMountedRuntime(t *testing.T) { testassert.Equal(t, response.Status, http.StatusOK) var health protocol.Health testassert.NoError(t, json.Unmarshal([]byte(response.Body), &health)) - testassert.Require(t, health.InstanceId == info.InstanceID && health.ProtocolVersion == protocol.N6, "health = %+v, want the connected owner", health) + testassert.Require(t, health.InstanceId == info.InstanceID && health.ProtocolVersion == protocol.N8, "health = %+v, want the connected owner", health) create, err := host.Call(info.Generation, "CreateSession", `{"body":{"workspace":"/ws/smoke","agent_type":"integrated"}}`) testassert.NoError(t, err) diff --git a/plugins/jobs/harness_test.go b/internal/jobs/harness_test.go similarity index 100% rename from plugins/jobs/harness_test.go rename to internal/jobs/harness_test.go diff --git a/plugins/jobs/jobs.go b/internal/jobs/jobs.go similarity index 84% rename from plugins/jobs/jobs.go rename to internal/jobs/jobs.go index e87b0212..163fbcdc 100644 --- a/plugins/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -1,16 +1,14 @@ -// Package jobs declares the background jobs capability: reservation-native -// job identities, per-Session attribution over real background processes, -// bounded output capture under the session artifact tree, per-Session limits, -// and kill escalation with a total StopJob over every state. It is an -// ordinary Runtime-scoped composition unit providing two declared views of -// one instance — the Jobs contract for tool-facing calls and the Harness -// job-stop seam at its lowest consumer — with no additional Runtime -// authority and no legacy process-manager wrapper. +// Package jobs is the background-jobs service the Runtime owner constructs +// directly: reservation-native job identities, per-Session attribution over +// real background processes, bounded output capture under the session artifact +// tree, per-Session limits, and kill escalation with a total StopJob over +// every state. It carries no composition-plugin authority: the owner supplies +// the data root, consumes the Jobs contract for tool-facing calls, and hands +// the same instance to the Harness as the job-stop seam. package jobs import ( "bytes" - "context" "crypto/rand" "encoding/hex" "encoding/json" @@ -26,11 +24,8 @@ import ( "github.com/MMinasyan/lightcode/harness" "github.com/MMinasyan/lightcode/internal/cmdoutput" - "github.com/MMinasyan/lightcode/runtime" ) -const pluginID = "jobs" - // Exit reasons: first reason wins, recorded by whichever of the timeout or // kill paths gets there before the exit goroutine settles the real result. const ( @@ -63,7 +58,7 @@ type ExitResult struct { } // StartRequest is one reserved job's launch input. Config is the opaque -// plugins.jobs section the caller forwards; Env is the exact child +// top-level jobs section the caller forwards; Env is the exact child // environment; TimeoutSec 0 means no timeout. type StartRequest struct { JobID, SessionID, Workspace, Command string @@ -74,8 +69,9 @@ type StartRequest struct { } // Jobs is the capability contract: a reservation-native identity mint, -// per-Session start/read/kill/list/live over background processes, and the -// total StopJob (also exported separately as the Harness job-stop seam). +// per-Session start/read/kill/list/live over background processes, and +// Abort's reservation release. The concrete Manager's StopJob is the Harness +// job-stop seam. type Jobs interface { Reserve() (string, error) // mint an 8-hex job ID (crypto/rand, the newProcessID shape) and register it reserved Abort(jobID string) // resolve a reservation that will not start; idempotent @@ -86,8 +82,8 @@ type Jobs interface { Live(sessionID string) []string // the session's running job IDs, sorted } -// settings is the plugin's private configuration, decoded with json.Unmarshal -// from the plugins.jobs section. Nil input and missing or null-valued members +// settings is the service's private configuration, decoded with json.Unmarshal +// from the top-level jobs section. Nil input and missing or null-valued members // keep the defaults; unknown members are ignored; every consumed field is // range-checked against the retained bounds. type settings struct { @@ -100,7 +96,7 @@ func defaultSettings() settings { return settings{MaxBackgroundProcesses: 10, MaxOutputBytes: 15360, ReadLineMaxChars: 5000} } -// decodeSettings decodes one supplied plugins.jobs section on top of the +// decodeSettings decodes one supplied top-level jobs section on top of the // defaults and enforces the retained ranges. func decodeSettings(raw json.RawMessage) (settings, error) { s := defaultSettings() @@ -120,6 +116,33 @@ func decodeSettings(raw json.RawMessage) (settings, error) { return s, nil } +// ValidateSettings validates one captured top-level jobs section on top of the +// defaults; the Runtime publisher calls it directly for every publication. +func ValidateSettings(raw json.RawMessage) error { + _, err := decodeSettings(raw) + return err +} + +// Manager is one constructed jobs service: the owner data root, the job map, +// the closed flag, and the work group covering every exit/timeout goroutine +// (and therefore every admitted callback and all capture/record cleanup). +type Manager struct { + mu sync.Mutex + jobs map[string]*jobRecord + closed bool + wg sync.WaitGroup + dataDir string +} + +var _ Jobs = (*Manager)(nil) +var _ harness.JobStopper = (*Manager)(nil) + +// New constructs one Manager rooted at the owner data directory the session +// output directories derive from. Close is the service disposal. +func New(dataDir string) *Manager { + return &Manager{jobs: make(map[string]*jobRecord), dataDir: dataDir} +} + // jobRecord is one job's state. Immutable fields (id, command, capture, // onExit, session identity, process handle, captured settings, and the done // channel created when the reservation becomes running) are written once @@ -167,20 +190,6 @@ func (r *jobRecord) claimKilledLocked() { } } -// instance is one Open's constructed state: the owner data root, the job map, -// the closed flag, and the work group covering every exit/timeout goroutine -// (and therefore every admitted callback and all capture/record cleanup). -type instance struct { - mu sync.Mutex - jobs map[string]*jobRecord - closed bool - wg sync.WaitGroup - dataDir string -} - -var _ Jobs = (*instance)(nil) -var _ harness.JobStopper = (*instance)(nil) - // waitCommand is the one cmd.Wait seam for background jobs: the Wait-returned // point of every job's exit goroutine routes through it, so tests drive the // reaping boundary deterministically. It is nil-free in production and simply @@ -188,7 +197,7 @@ var _ harness.JobStopper = (*instance)(nil) var waitCommand = func(cmd *exec.Cmd) error { return cmd.Wait() } // Reserve mints an 8-hex job identity and registers it reserved. -func (j *instance) Reserve() (string, error) { +func (j *Manager) Reserve() (string, error) { id, err := newJobID() if err != nil { return "", err @@ -204,7 +213,7 @@ func (j *instance) Reserve() (string, error) { // Abort removes a reservation that will not start; it is idempotent and a // no-op over running, exited, or absent records. -func (j *instance) Abort(jobID string) { +func (j *Manager) Abort(jobID string) { j.mu.Lock() defer j.mu.Unlock() rec, ok := j.jobs[jobID] @@ -227,7 +236,7 @@ func (j *instance) Abort(jobID string) { // the running registration, so a child is either registered before a close or // never starts after it. The process is not cancellable through its start: only // its timeout or an explicit stop ends it. -func (j *instance) Start(req StartRequest) error { +func (j *Manager) Start(req StartRequest) error { j.mu.Lock() if j.closed { j.mu.Unlock() @@ -315,7 +324,7 @@ func (j *instance) Start(req StartRequest) error { // invokes it outside the capability mutex with the formatted output, then // removes the record and closes the capture before the work-group Done. Nil // callbacks skip invocation but not cleanup. -func (j *instance) exitLoop(rec *jobRecord, cmd *exec.Cmd, capture *cmdoutput.Capture) { +func (j *Manager) exitLoop(rec *jobRecord, cmd *exec.Cmd, capture *cmdoutput.Capture) { defer j.wg.Done() waitErr := waitCommand(cmd) @@ -369,7 +378,7 @@ func (j *instance) exitLoop(rec *jobRecord, cmd *exec.Cmd, capture *cmdoutput.Ca // timeoutLoop retains the timer/SIGTERM/500ms/SIGKILL escalation: once the // timer fires and the first-reason claim succeeds, the process group is // converged; the exit goroutine's reaped notification ends the wait. -func (j *instance) timeoutLoop(rec *jobRecord, cmd *exec.Cmd, timeoutSec int) { +func (j *Manager) timeoutLoop(rec *jobRecord, cmd *exec.Cmd, timeoutSec int) { defer j.wg.Done() timer := time.NewTimer(time.Duration(timeoutSec) * time.Second) defer timer.Stop() @@ -390,7 +399,7 @@ func (j *instance) timeoutLoop(rec *jobRecord, cmd *exec.Cmd, timeoutSec int) { // Read returns the retained output text for one owned record: unknown-ID for // absent or foreign identities (reservations are invisible), the retained // empty placeholder for a silent capture, and the bounded format otherwise. -func (j *instance) Read(sessionID, jobID string) (string, error) { +func (j *Manager) Read(sessionID, jobID string) (string, error) { j.mu.Lock() rec, ok := j.jobs[jobID] j.mu.Unlock() @@ -415,7 +424,7 @@ func (j *instance) Read(sessionID, jobID string) (string, error) { // nil (termination, not reaping, is Kill's contract), and a running job gets // the group SIGTERM/500ms/SIGKILL escalation with the legacy 5-second // diagnose-and-abandon outcome returning nil. -func (j *instance) Kill(sessionID, jobID string) error { +func (j *Manager) Kill(sessionID, jobID string) error { j.mu.Lock() rec, ok := j.jobs[jobID] j.mu.Unlock() @@ -459,7 +468,7 @@ func (j *instance) Kill(sessionID, jobID string) error { // (Start and StopJob stay serialized), returning once the child is reaped; // the ordinary exit callback then delivers the stopped result. Exited, // absent, or foreign identities return. -func (j *instance) StopJob(sessionID, jobID string) { +func (j *Manager) StopJob(sessionID, jobID string) { j.mu.Lock() rec, ok := j.jobs[jobID] if !ok { @@ -505,7 +514,7 @@ func reapGroup(pid int, done <-chan struct{}) { // List returns one line per started record of the session (reservations are // invisible; unspecified order), with the retained running and exited suffix // formats, the trailing newline stripped, and the retained empty text. -func (j *instance) List(sessionID string) string { +func (j *Manager) List(sessionID string) string { j.mu.Lock() defer j.mu.Unlock() var result string @@ -535,7 +544,7 @@ func (j *instance) List(sessionID string) string { // Live returns the session's running job IDs, sorted; reservations and exited // records are excluded. -func (j *instance) Live(sessionID string) []string { +func (j *Manager) Live(sessionID string) []string { j.mu.Lock() defer j.mu.Unlock() ids := make([]string, 0, len(j.jobs)) @@ -559,7 +568,7 @@ func (j *instance) Live(sessionID string) []string { // callbacks, timeout goroutines, and all capture/record cleanup included), // and finally removing the leftover reservations. Callbacks execute outside // the mutex. -func (j *instance) Close() error { +func (j *Manager) Close() error { type runningJob struct { pid int done <-chan struct{} @@ -595,47 +604,6 @@ func (j *instance) Close() error { return nil } -// Plugin returns the Runtime-scoped plugin providing the two declared views -// of one jobs instance: Spec[Jobs]("jobs") for tool-facing calls and -// Spec[harness.JobStopper]("job-stopper") for the Harness consumer. It has -// no dependencies. ValidateConfig validates the owned plugins.jobs section; -// Open captures the owner data root the session output directories derive -// from, and the instance's Close is the scope disposal. -func Plugin() runtime.Plugin { - return runtime.Plugin{ - ID: pluginID, - Scope: runtime.ScopeRuntime, - Provides: []runtime.CapabilitySpec{ - runtime.Spec[Jobs]("jobs"), - runtime.Spec[harness.JobStopper]("job-stopper"), - }, - ValidateConfig: func(raw json.RawMessage) error { - _, err := decodeSettings(raw) - return err - }, - Open: open, - } -} - -// open checks the scope context and captures the scope identity's owner data -// root; one instance backs both declared views. -func open(ctx context.Context, info runtime.ScopeInfo, _ runtime.Bindings) (runtime.Instance, error) { - if err := ctx.Err(); err != nil { - return runtime.Instance{}, err - } - inst := &instance{ - jobs: make(map[string]*jobRecord), - dataDir: info.DataDir, - } - return runtime.Instance{ - Values: map[string]any{ - "jobs": Jobs(inst), - "job-stopper": harness.JobStopper(inst), - }, - Close: inst.Close, - }, nil -} - func newJobID() (string, error) { var b [4]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/plugins/jobs/jobs_test.go b/internal/jobs/jobs_test.go similarity index 93% rename from plugins/jobs/jobs_test.go rename to internal/jobs/jobs_test.go index 520962ed..0a37a4df 100644 --- a/plugins/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -1,7 +1,6 @@ package jobs import ( - "context" "fmt" "io" "os" @@ -15,7 +14,6 @@ import ( "time" "github.com/MMinasyan/lightcode/internal/testassert" - "github.com/MMinasyan/lightcode/runtime" ) // unknownIDText is the retained unknown-ID error for one job identity. @@ -25,29 +23,28 @@ func unknownIDText(id string) string { var jobIDShape = regexp.MustCompile(`^[0-9a-f]{8}$`) -// openJobs opens the real plugin over a fresh data root, asserts the two-view -// declaration, and registers the instance's Close with the test. -func openJobs(t *testing.T, dataDir string) *instance { +// openJobs opens one real jobs service over a fresh data root and registers +// its Close — which joins the monitor and callback work — with the test. +func openJobs(t *testing.T, dataDir string) *Manager { t.Helper() - p := Plugin() - testassert.Require(t, p.ID == "jobs" && p.Scope == runtime.ScopeRuntime && p.ValidateConfig != nil && p.Open != nil && len(p.Requires) == 0 && len(p.Provides) == 2, "plugin declaration = %+v, want the Runtime-scoped jobs plugin with a validator and no dependencies; got %v, want %v", p, len(p.Provides), 2) - inst, err := p.Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dataDir}, runtime.Bindings{}) - testassert.NoError(t, err) - testassert.Require(t, inst.Close != nil && len(inst.Values) == 2, "instance = %d exports with a nil Close %v, want exactly the two views and a closer", len(inst.Values), inst.Close == nil) - jobsView, ok := inst.Values["jobs"].(Jobs) - testassert.Require(t, ok, "jobs export supplies %T, not a Jobs", inst.Values["jobs"]) - stopper, ok := inst.Values["job-stopper"].(interface{ StopJob(string, string) }) - testassert.Require(t, ok, "job-stopper export supplies %T, not a job stopper", inst.Values["job-stopper"]) - core := jobsView.(*instance) - testassert.Require(t, stopper.(*instance) == core, "the two declared views do not share one instance") - t.Cleanup(func() { _ = inst.Close() }) - return core + manager := New(dataDir) + t.Cleanup(func() { _ = manager.Close() }) + return manager +} + +// TestManagerSatisfiesTheOwnerSeams proves the one construction supplies both +// consumers: the tools' jobs contract and the Harness job-stop seam. +func TestManagerSatisfiesTheOwnerSeams(t *testing.T) { + var jobsValue any = New(t.TempDir()) + _, isJobs := jobsValue.(Jobs) + _, isStopper := jobsValue.(interface{ StopJob(string, string) }) + testassert.Require(t, isJobs && isStopper, "Manager implements Jobs=%v job stopper=%v, want both", isJobs, isStopper) } // armTestReaper replaces the wait seam with one parked until the returned -// release function runs. Cleanup releases the barrier, closes the instance so +// release function runs. Cleanup releases the barrier, closes the service so // every reaper goroutine leaves the seam, and only then restores the seam. -func armTestReaper(t *testing.T, inst *instance) (release func()) { +func armTestReaper(t *testing.T, inst *Manager) (release func()) { t.Helper() orig := waitCommand gate := make(chan struct{}) @@ -66,7 +63,7 @@ func armTestReaper(t *testing.T, inst *instance) (release func()) { } // start runs one reserved start, failing the test on error. -func start(t *testing.T, j *instance, req StartRequest) string { +func start(t *testing.T, j *Manager, req StartRequest) string { t.Helper() { err := j.Start(req) @@ -76,7 +73,7 @@ func start(t *testing.T, j *instance, req StartRequest) string { } // reserveStart reserves one identity and starts it. -func reserveStart(t *testing.T, j *instance, req StartRequest) string { +func reserveStart(t *testing.T, j *Manager, req StartRequest) string { t.Helper() id, err := j.Reserve() testassert.NoError(t, err) @@ -98,7 +95,7 @@ func waitExit(t *testing.T, ch <-chan ExitResult) ExitResult { // waitRemoved polls until the identified record leaves the map, which the exit // goroutine does after the callback and before its work-group Done. -func waitRemoved(t *testing.T, j *instance, id string) { +func waitRemoved(t *testing.T, j *Manager, id string) { t.Helper() deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { @@ -114,7 +111,7 @@ func waitRemoved(t *testing.T, j *instance, id string) { } // stateOf reads one record's lifecycle state. -func stateOf(j *instance, id string) (jobState, bool) { +func stateOf(j *Manager, id string) (jobState, bool) { j.mu.Lock() rec, ok := j.jobs[id] j.mu.Unlock() @@ -416,9 +413,9 @@ func TestStartRejectedAtSessionLimit(t *testing.T) { // failures name the jobs field and range, and malformed JSON, non-object // input, and wrong consumed-field types wrap the retained invalid-settings // text. -func TestValidateConfigSettingsRangesAndShapes(t *testing.T) { +func TestValidateSettingsRangesAndShapes(t *testing.T) { validate := func(raw string) error { - return Plugin().ValidateConfig([]byte(raw)) + return ValidateSettings([]byte(raw)) } for _, raw := range []string{ "", " ", "{}", "null", @@ -434,7 +431,7 @@ func TestValidateConfigSettingsRangesAndShapes(t *testing.T) { } { { err := validate(raw) - testassert.Check(t, err == nil, "ValidateConfig(%s) = %v, want accepted", raw, err) + testassert.Check(t, err == nil, "ValidateSettings(%s) = %v, want accepted", raw, err) } } for _, row := range []struct{ raw, want string }{ @@ -447,7 +444,7 @@ func TestValidateConfigSettingsRangesAndShapes(t *testing.T) { } { { err := validate(row.raw) - testassert.Check(t, err != nil && err.Error() == row.want, "ValidateConfig(%s) = %v, want %q", row.raw, err, row.want) + testassert.Check(t, err != nil && err.Error() == row.want, "ValidateSettings(%s) = %v, want %q", row.raw, err, row.want) } } for _, raw := range []string{ @@ -458,7 +455,7 @@ func TestValidateConfigSettingsRangesAndShapes(t *testing.T) { `[]`, `"jobs"`, `5`, } { err := validate(raw) - testassert.Check(t, err != nil && strings.HasPrefix(err.Error(), "jobs: invalid settings: "), "ValidateConfig(%s) = %v, want the jobs: invalid settings wrap", raw, err) + testassert.Check(t, err != nil && strings.HasPrefix(err.Error(), "jobs: invalid settings: "), "ValidateSettings(%s) = %v, want the jobs: invalid settings wrap", raw, err) } } diff --git a/internal/lsp/client.go b/internal/lsp/client.go deleted file mode 100644 index 79ee0fc7..00000000 --- a/internal/lsp/client.go +++ /dev/null @@ -1,275 +0,0 @@ -package lsp - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "time" - - "github.com/MMinasyan/lightcode/internal/lsp/protocol" -) - -type Client struct { - mgr *Manager -} - -func NewClient(mgr *Manager) *Client { - return &Client{mgr: mgr} -} - -// WorkspaceSymbol fans one query out over every mapped instance. An instance -// that decoded any reply — including a valid empty one — counts as responding; -// a readiness, call or decode failure simply omits that instance. With no -// mapped instance at all, and equally when no existing instance answered, the -// entry returns its plain unavailability error. A successful reply yields the -// merged, deduplicated symbols (or, for a valid empty reply, the no-symbols -// text), with unavailable or failed servers omitted. -func (c *Client) WorkspaceSymbol(ctx context.Context, query string) (string, error) { - instances := c.mgr.AllInstances() - if len(instances) == 0 { - return "", errors.New("No language servers available.") - } - - params := protocol.WorkspaceSymbolParams{Query: query} - type key struct { - name string - uri string - line int - } - seen := make(map[key]bool) - var all []protocol.SymbolInformation - replied := 0 - - for _, inst := range instances { - if err := inst.waitReady(ctx); err != nil { - // An observed caller cancellation returns the symbols collected - // so far with the cancellation error instead of swallowing it; - // without cancellation the failed server is simply omitted. - if ctx.Err() != nil { - return formatSymbols(all), ctx.Err() - } - continue - } - result, err := inst.call(ctx, "workspace/symbol", params) - if err != nil { - if ctx.Err() != nil { - return formatSymbols(all), ctx.Err() - } - continue - } - var syms []protocol.SymbolInformation - if err := json.Unmarshal(result, &syms); err != nil { - continue - } - replied++ - for _, s := range syms { - k := key{s.Name, s.Location.URI, s.Location.Range.Start.Line} - if seen[k] { - continue - } - seen[k] = true - all = append(all, s) - } - } - - if replied == 0 { - return "", errors.New("No language servers available for the symbol query.") - } - - var b strings.Builder - if len(all) == 0 { - // Only the responding servers can speak for their own results: an - // empty successful subset is not evidence that unavailable servers - // found nothing. - b.WriteString("No symbols were reported by the responding language servers.") - } else { - b.WriteString(formatSymbols(all)) - } - return b.String(), nil -} - -// formatSymbols is the shared symbol formatting: the 20-symbol cap and the -// retained showing-count line. -func formatSymbols(all []protocol.SymbolInformation) string { - cap := 20 - total := len(all) - if len(all) > cap { - all = all[:cap] - } - - var b strings.Builder - for _, s := range all { - kind := protocol.SymbolKindName[s.Kind] - if kind == "" { - kind = "Symbol" - } - path := protocol.PathFromURI(s.Location.URI) - line := s.Location.Range.Start.Line + 1 - fmt.Fprintf(&b, "%s (%s) — %s:%d\n", s.Name, kind, path, line) - } - if total > cap { - fmt.Fprintf(&b, "\nShowing %d of %d total.", cap, total) - } - return strings.TrimRight(b.String(), "\n") -} - -// GetDiagnosticsCanonical is the one diagnostics read: it groups the paths by -// serving instance, synchronizes every group through the canonical no-follow -// read — a replaced symlink leaf and non-regular or hardlinked files are -// refused, and no substituted content is ever sent — and renders every grouped -// path's captured diagnostic cache. Unsupported paths, readiness failures and -// document-open refusals become per-file could-not-check diagnostics; when -// none synchronizes, those collected failures are the ordinary error, returned -// without the sample wait, and only an entirely empty collection (no path at -// all) falls back to the plain unavailability text. A group with no -// synchronized path has nothing a live server can speak for, and a group whose -// current attempt is no longer live when the sample is taken cannot supply -// success either — its restart-cleared cache is not evidence; both contribute -// their synchronized paths as could-not-check failures, and when no group -// remains eligible the collected failures are the outcome. -func (c *Client) GetDiagnosticsCanonical(ctx context.Context, paths []string) (string, error) { - type serverPaths struct { - inst *instance - paths []string - synced []string - sample [][]protocol.Diagnostic - } - groups := make(map[string]*serverPaths) - var refused []string - - for _, p := range paths { - inst := c.mgr.ForFile(p) - if inst == nil { - refused = append(refused, fmt.Sprintf("%s: no language server for this file", p)) - continue - } - key := inst.def.Name - if groups[key] == nil { - groups[key] = &serverPaths{inst: inst} - } - groups[key].paths = append(groups[key].paths, p) - } - - synced := 0 - for _, g := range groups { - if err := g.inst.waitReady(ctx); err != nil { - for _, p := range g.paths { - refused = append(refused, fmt.Sprintf("%s: %v", p, err)) - } - continue - } - for _, p := range g.paths { - if err := g.inst.openFileCanonical(ctx, p); err != nil { - refused = append(refused, fmt.Sprintf("%s: %v", p, err)) - continue - } - g.synced = append(g.synced, p) - synced++ - } - } - - if synced == 0 { - return refusedOutcome(ctx, refused) - } - - select { - case <-time.After(500 * time.Millisecond): - case <-ctx.Done(): - // The observed caller cancellation returns the could-not-check - // content accumulated so far with the cancellation error instead of - // swallowing it. - return couldNotCheckContent(refused), ctx.Err() - } - - // Only a group with at least one synchronized path can supply a sample, - // and only while its current attempt is still up: a group with nothing - // synchronized has nothing the live server can speak for, and a crashed, - // retired or restarting instance has no live attempt behind its cache. - // Either way its synced paths join the could-not-check collection, and - // when nothing eligible remains the collected failures are the outcome. - // Each eligible group's existing paths are captured once, in order, and - // only those owned snapshots are rendered — including a refused path's - // cached diagnostics when its group also has a live synchronized path. - liveGroups := make([]*serverPaths, 0, len(groups)) - for _, g := range groups { - if len(g.synced) == 0 { - continue - } - sample, ok := g.inst.captureDiagnostics(g.paths) - if !ok { - for _, p := range g.synced { - refused = append(refused, fmt.Sprintf("%s: %s is unavailable", p, g.inst.def.Name)) - } - continue - } - g.sample = sample - liveGroups = append(liveGroups, g) - } - if len(liveGroups) == 0 { - return refusedOutcome(ctx, refused) - } - - var b strings.Builder - if len(refused) > 0 { - b.WriteString(couldNotCheckLines(refused)) - } - totalErrors := 0 - for _, g := range liveGroups { - for i, p := range g.paths { - if totalErrors >= 50 { - break - } - fileErrors := 0 - for _, d := range g.sample[i] { - if d.Severity == nil || *d.Severity != protocol.SeverityError { - continue - } - if fileErrors >= 20 || totalErrors >= 50 { - break - } - fmt.Fprintf(&b, "%s:%d: %s\n", p, d.Range.Start.Line+1, d.Message) - fileErrors++ - totalErrors++ - } - } - } - - if b.Len() == 0 { - return "No errors found.", nil - } - return strings.TrimRight(b.String(), "\n"), nil -} - -// refusedOutcome is the collected could-not-check outcome shared by the -// nothing-synchronized and nothing-live-to-sample gates: an observed caller -// cancellation returns the accumulated partial content with its error, an -// entirely empty collection falls back to the plain unavailability text, and -// anything else is the collected failure as the ordinary error. -func refusedOutcome(ctx context.Context, refused []string) (string, error) { - partial := couldNotCheckContent(refused) - if err := ctx.Err(); err != nil { - return partial, err - } - if partial == "" { - return "", errors.New("No language servers available for the modified files.") - } - return "", errors.New(partial) -} - -// couldNotCheckLines renders the per-file refused-path diagnostics: one -// "path: error (could not check)" line each. -func couldNotCheckLines(refused []string) string { - var b strings.Builder - for _, r := range refused { - fmt.Fprintf(&b, "%s (could not check)\n", r) - } - return b.String() -} - -// couldNotCheckContent is the same rendering without the trailing newline, -// the shape error and interrupted outcomes carry. -func couldNotCheckContent(refused []string) string { - return strings.TrimRight(couldNotCheckLines(refused), "\n") -} diff --git a/internal/lsp/client_test.go b/internal/lsp/client_test.go deleted file mode 100644 index 50077867..00000000 --- a/internal/lsp/client_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package lsp - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MMinasyan/lightcode/internal/lsp/server" - "github.com/MMinasyan/lightcode/internal/testassert" -) - -// TestWorkspaceSymbolAllFailedInstancesIsError: when every mapped instance is -// unavailable, the fan-out returns the one fixed unavailability error, never a -// clean no-symbols result that reads as an answer. -func TestWorkspaceSymbolAllFailedInstancesIsError(t *testing.T) { - m := NewManager(t.TempDir(), t.TempDir()) - def := server.ForExtension(".go") - inst := newInstance(def, m.projectRoot, m.home, context.Background(), nil) - inst.mu.Lock() - inst.state = stateFailed - inst.mu.Unlock() - m.mu.Lock() - m.instances[def.Name] = inst - m.mu.Unlock() - - content, err := NewClient(m).WorkspaceSymbol(context.Background(), "x") - testassert.Require(t, err != nil && content == "" && err.Error() == "No language servers available for the symbol query.", "WorkspaceSymbol with only failed servers = (%q, %v), want the fixed unavailability error", content, err) -} - -// TestClientNoInstancesMessages: with no mapped instance at all, the symbol -// entry returns the retained text as an ordinary error, while the diagnostics -// entry follows the uniform per-path rule — every requested path becomes its -// could-not-check line, and the plain unavailability text is only the empty -// input's fallback. -func TestClientNoInstancesMessages(t *testing.T) { - client := NewClient(NewManager(t.TempDir(), t.TempDir())) - got, err := client.WorkspaceSymbol(context.Background(), "x") - testassert.Require(t, err != nil && got == "" && err.Error() == "No language servers available.", "WorkspaceSymbol no instances = %q, %v", got, err) - got, err = client.GetDiagnosticsCanonical(context.Background(), []string{"main.go"}) - testassert.Require(t, err != nil && got == "" && err.Error() == "main.go: no language server for this file (could not check)", "GetDiagnosticsCanonical unsupported path = %q, %v", got, err) - got, err = client.GetDiagnosticsCanonical(context.Background(), nil) - testassert.Require(t, err != nil && got == "" && err.Error() == "No language servers available for the modified files.", "GetDiagnosticsCanonical empty input = %q, %v", got, err) -} - -// TestGetDiagnosticsCanonicalDeadServerIsNotCleanResult pins the sample-time -// liveness rule: a server that accepted the document sync and then died — its -// crash loop exhausting the restart budget, every restart clearing the -// diagnostic cache — is not sampled as a healthy empty result. Its synced path -// becomes a could-not-check failure and, with nothing live left to sample, the -// collected failure is the ordinary error, never the clean no-error text. A -// distinct pyright instance that is live but has no synchronized path at all — -// its only requested path is a missing leaf refused before any RPC — cannot -// supply a successful sample either: a live server can only speak for the -// documents it was actually given. -func TestGetDiagnosticsCanonicalDeadServerIsNotCleanResult(t *testing.T) { - reqLog := filepath.Join(t.TempDir(), "requests.log") - t.Setenv("FAKE_LSP_REQUESTS_LOG", reqLog) - log := filepath.Join(t.TempDir(), "launches.log") - // The first launch serves the sync and dies on the didOpen it receives; - // every later launch crashes before readiness, so the instance can never - // serve the sample. - inst := newFakeServerInstance(t, log, "", 0, 0, "after-open", "") - testassert.NoError(t, inst.waitReady(context.Background())) - m := NewManager(t.TempDir(), t.TempDir()) - def := server.ForExtension(".go") - m.mu.Lock() - m.instances[def.Name] = inst - m.mu.Unlock() - - // The live-but-unsynchronized sibling: ready with an open attempt - // channel but no process of its own and no synchronized document. - pyDef := server.ForExtension(".py") - pyright := newInstance(pyDef, m.projectRoot, m.home, context.Background(), nil) - pyright.mu.Lock() - pyright.state = stateReady - pyright.procDone = make(chan struct{}) - pyright.mu.Unlock() - m.mu.Lock() - m.instances[pyDef.Name] = pyright - m.mu.Unlock() - - healthy := filepath.Join(t.TempDir(), "healthy.go") - testassert.NoError(t, os.WriteFile(healthy, []byte("package healthy\n"), 0o600)) - missingPy := filepath.Join(t.TempDir(), "missing.py") - - content, err := NewClient(m).GetDiagnosticsCanonical(context.Background(), []string{healthy, missingPy}) - testassert.Require(t, err != nil && content == "", "GetDiagnosticsCanonical with a live but unsynchronized server = (%q, %v), want the could-not-check error, never a successful sample", content, err) - testassert.Require(t, strings.Contains(err.Error(), "(could not check)") && strings.Contains(err.Error(), "unavailable") && strings.Contains(err.Error(), "missing.py") && strings.Contains(err.Error(), "healthy.go"), "GetDiagnosticsCanonical error = %q, want the unavailable could-not-check lines; GetDiagnosticsCanonical error = %q, want both the refused pyright leaf and the dead server's synced path", err, err) - - // The crash loop really ran to exhaustion over the served sync: the - // document reached the first launch, and every later launch died before - // readiness. - data, err := os.ReadFile(reqLog) - testassert.Require(t, err == nil && strings.Contains(string(data), "textDocument/didOpen"), "the document sync never reached the server (log %q, err %v)", data, err) - waitForState(t, inst, stateFailed) - testassert.Equal(t, countLaunches(log), maxRestarts+1) -} diff --git a/internal/lsp/instance.go b/internal/lsp/instance.go deleted file mode 100644 index 63a2ab98..00000000 --- a/internal/lsp/instance.go +++ /dev/null @@ -1,714 +0,0 @@ -package lsp - -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "sync" - "time" - - "github.com/MMinasyan/lightcode/internal/lsp/jsonrpc" - "github.com/MMinasyan/lightcode/internal/lsp/protocol" - "github.com/MMinasyan/lightcode/internal/lsp/server" - "github.com/MMinasyan/lightcode/internal/safefs" -) - -const ( - stateIdle = iota - stateStarting - stateReady - stateFailed - stateShutdown - - maxRestarts = 3 - idleTimeout = 30 * time.Minute -) - -// readyTimeout and shutdownWait are variables so tests can shorten the waits; -// they never change outside tests. -var ( - readyTimeout = 30 * time.Second - shutdownWait = 5 * time.Second -) - -type instance struct { - def *server.Definition - projectRoot string - home string - // lifetime is the context this instance was started under: every restart - // derives its bounded start context from it, so an owner whose lifetime - // is canceled loses the instance's serviceability instead of - // resurrecting the server from a Background context. - lifetime context.Context - - mu sync.Mutex - state int - rpc *jsonrpc.Client - cmd *exec.Cmd - procDone chan struct{} - restarts int - readyCh chan struct{} - idleTimer *time.Timer - openedVer map[string]int - - diagMu sync.RWMutex - diagnostics map[string][]protocol.Diagnostic - - onCrash func(name string) -} - -func newInstance(def *server.Definition, projectRoot, home string, lifetime context.Context, onCrash func(string)) *instance { - return &instance{ - def: def, - projectRoot: projectRoot, - home: home, - lifetime: lifetime, - diagnostics: make(map[string][]protocol.Diagnostic), - openedVer: make(map[string]int), - readyCh: make(chan struct{}), - onCrash: onCrash, - } -} - -// waitCommand is the one cmd.Wait seam for instance-owned processes: every -// instance-owned wait routes through it, so tests can count exactly one -// waiter on each side of the procDone/watcher ownership handoff. It is -// nil-free in production and simply delegates to cmd.Wait. -var waitCommand = func(cmd *exec.Cmd) error { return cmd.Wait() } - -// startProbe is a test seam invoked after cmd.Start and before the -// post-launch attempt-identity recheck, so tests can deterministically -// interleave a permanent close between the process launch and the check. -// nil in production. -var startProbe = func() {} - -// start claims a fresh launch and returns the ready channel of the attempt it -// claimed (or of the current attempt when it refuses to claim). The returned -// channel is the one the caller must wait on. -func (inst *instance) start(ctx context.Context) (chan struct{}, error) { - inst.mu.Lock() - switch inst.state { - case stateStarting, stateFailed, stateReady: - // Another launch is already under way, the instance failed and is not - // startable again, or a launch is already running: do not launch. - ch := inst.readyCh - inst.mu.Unlock() - return ch, nil - } - inst.state = stateStarting - inst.readyCh = make(chan struct{}) - readyCh := inst.readyCh - inst.mu.Unlock() - - // The supplied start context is already done: refuse the attempt before - // binary resolution, pipes or spawn, so no process is created for a launch - // that cannot complete. endAttempt leaves the instance claimable. - if err := ctx.Err(); err != nil { - inst.endAttempt(readyCh) - return nil, err - } - - binary := server.ResolveBinary(inst.home, inst.def) - if binary == "" { - inst.endAttempt(readyCh) - return nil, fmt.Errorf("binary not found: %s", inst.def.Command) - } - - cmd := exec.Command(binary, inst.def.Args...) - cmd.Dir = inst.projectRoot - cmd.Env = os.Environ() - - stdin, err := cmd.StdinPipe() - if err != nil { - inst.endAttempt(readyCh) - return nil, fmt.Errorf("stdin pipe: %w", err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - inst.endAttempt(readyCh) - return nil, fmt.Errorf("stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - inst.endAttempt(readyCh) - return nil, fmt.Errorf("stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - inst.endAttempt(readyCh) - return nil, fmt.Errorf("start %s: %w", inst.def.Name, err) - } - - startProbe() - - inst.mu.Lock() - if inst.readyCh != readyCh || inst.state != stateStarting { - // Permanent manager close invalidated this attempt between the - // process launch and the handle installation (or a newer attempt - // replaced it). This start goroutine is the sole cmd.Wait owner for - // this process: kill and reap it here, so no launched process is ever - // left untracked, then report unavailable. - inst.mu.Unlock() - _ = cmd.Process.Kill() - _ = waitCommand(cmd) - return nil, fmt.Errorf("%s is unavailable", inst.def.Name) - } - inst.cmd = cmd - inst.procDone = make(chan struct{}) - procDone := inst.procDone - inst.openedVer = make(map[string]int) - // The cache reset shares the hold that installs this attempt's handles: - // a capture can only ever see this attempt's state together with this - // attempt's diagnostics, never one without the other. - inst.diagMu.Lock() - inst.diagnostics = make(map[string][]protocol.Diagnostic) - inst.diagMu.Unlock() - inst.mu.Unlock() - - rpc := jsonrpc.NewClient(stdin, stdout, stderr, func(method string, params json.RawMessage) { - // Bind the handler to this launch's ready channel so a notification - // from an attempt that has since been replaced can never mark a newer - // attempt ready. - inst.handleNotification(method, params, readyCh) - }) - rpc.Start() - - inst.mu.Lock() - inst.rpc = rpc - inst.mu.Unlock() - - pid := os.Getpid() - initParams := buildInitializeParams(pid, inst.projectRoot) - - result, err := rpc.Call(ctx, "initialize", initParams) - if err != nil { - rpc.Close() - _ = cmd.Process.Kill() - _ = waitCommand(cmd) - close(procDone) - inst.endAttempt(readyCh) - return nil, fmt.Errorf("initialize %s: %w", inst.def.Name, err) - } - _ = result - - if err := rpc.Notify("initialized", struct{}{}); err != nil { - rpc.Close() - _ = cmd.Process.Kill() - _ = waitCommand(cmd) - close(procDone) - inst.endAttempt(readyCh) - return nil, fmt.Errorf("initialized notification: %w", err) - } - - inst.resetIdle() - - // #nosec G118 -- watchProcess is tied to the LSP process lifetime (cmd.Wait), not the start ctx; cleanup is via instance shutdown. - go inst.watchProcess(cmd, procDone) - - return readyCh, nil -} - -func buildInitializeParams(pid int, projectRoot string) protocol.InitializeParams { - rootURI := protocol.URIFromPath(projectRoot) - return protocol.InitializeParams{ - ProcessID: &pid, - RootURI: rootURI, - Capabilities: protocol.ClientCapabilities{ - Workspace: protocol.WorkspaceClientCapabilities{WorkspaceFolders: true}, - Window: protocol.WindowClientCapabilities{WorkDoneProgress: true}, - }, - WorkspaceFolders: []protocol.WorkspaceFolder{ - {URI: rootURI, Name: filepath.Base(projectRoot)}, - }, - } -} - -func (inst *instance) handleNotification(method string, params json.RawMessage, readyCh chan struct{}) { - switch method { - case "textDocument/publishDiagnostics": - var p protocol.PublishDiagnosticsParams - if err := json.Unmarshal(params, &p); err != nil { - return - } - inst.diagMu.Lock() - inst.diagnostics[p.URI] = p.Diagnostics - inst.diagMu.Unlock() - - case "$/progress": - var p protocol.ProgressParams - if err := json.Unmarshal(params, &p); err != nil { - return - } - var val protocol.WorkDoneProgressValue - if err := json.Unmarshal(p.Value, &val); err != nil { - return - } - if val.Kind == "end" { - inst.markReady(readyCh) - } - - case "window/logMessage", "window/showMessage": - // ignore - } -} - -// markReady marks the launch that owns readyCh as ready. Under one lock hold -// it verifies the launch is still the current one and only then sets ready and -// closes the channel, so a notification from a replaced, failed or already -// finished attempt does nothing. -func (inst *instance) markReady(readyCh chan struct{}) { - inst.mu.Lock() - defer inst.mu.Unlock() - if inst.readyCh != readyCh || inst.state != stateStarting { - return - } - inst.state = stateReady - close(readyCh) -} - -// setTerminal records the given state and releases any caller waiting on the -// current ready channel. A failed start attempt ends by returning the instance -// to idle, keeping it claimable. The failed state is reserved for crash-budget -// exhaustion and is final: no later transition overwrites it, so shutdown -// cannot turn a crashed instance back into a restartable one. -func (inst *instance) setTerminal(state int) { - inst.mu.Lock() - defer inst.mu.Unlock() - inst.setTerminalLocked(state) -} - -// setTerminalLocked performs the transition while inst.mu is held, closing the -// current ready channel when the previous state was starting. -func (inst *instance) setTerminalLocked(state int) { - prev := inst.state - if prev == stateFailed { - return - } - inst.state = state - if prev == stateStarting { - close(inst.readyCh) - } -} - -// endAttempt returns the instance to idle, ending the launch that installed -// readyCh. It is a no-op unless that launch is still the current one and still -// starting, so it can neither clobber a shutdown nor a newer attempt. The -// identity check and the transition share one lock hold. -func (inst *instance) endAttempt(readyCh chan struct{}) { - inst.mu.Lock() - defer inst.mu.Unlock() - if inst.readyCh != readyCh || inst.state != stateStarting { - return - } - inst.setTerminalLocked(stateIdle) -} - -// closeAdmission is the nonblocking permanent-close admission: under the -// instance mutex it stops the idle timer, invalidates the current start -// attempt, and moves every nonterminal state to stateFailed, which is final -// and unclaimable. A start already in flight sees the failed state at its -// post-launch identity recheck and reaps its own process; every later -// ordinary request, watcher restart, or idle restart refuses instead of -// launching another process. Idle retirement keeps its own restartable -// stateShutdown; only permanent manager close lands here. -func (inst *instance) closeAdmission() { - inst.mu.Lock() - if inst.idleTimer != nil { - inst.idleTimer.Stop() - inst.idleTimer = nil - } - if inst.state != stateFailed { - prev := inst.state - inst.state = stateFailed - if prev == stateStarting { - close(inst.readyCh) - } - } - inst.mu.Unlock() -} - -func stateName(state int) string { - switch state { - case stateIdle: - return "idle" - case stateStarting: - return "starting" - case stateReady: - return "ready" - case stateFailed: - return "failed" - case stateShutdown: - return "shutdown" - } - return fmt.Sprintf("unknown (%d)", state) -} - -// watchProcess waits for the process of the attempt it was bound to at -// creation and applies that attempt's crash accounting. A watcher whose -// attempt has since been replaced is inert: it closes only the procDone it was -// bound to and touches nothing else. It is the sole process waiter after the -// procDone/watcher handoff; every instance-owned wait routes through -// waitCommand so tests can prove exactly one waiter on each side. -func (inst *instance) watchProcess(cmd *exec.Cmd, procDone chan struct{}) { - _ = waitCommand(cmd) - - inst.mu.Lock() - close(procDone) - - // The attempt this watcher launched must still be the current one: if a - // shutdown and restart replaced it, the watcher is superseded and must - // not touch state, count a crash, or restart anything. The check and - // everything it guards happen in this one hold. - if inst.procDone != procDone { - inst.mu.Unlock() - return - } - if inst.state == stateFailed || inst.state == stateShutdown { - inst.mu.Unlock() - return - } - if rpc := inst.rpc; rpc != nil && !rpc.IsClosed() { - rpc.Close() - } - if inst.restarts >= maxRestarts { - // Budget exhausted: the terminal transition lands in the same hold - // that checked the budget, so a shutdown cannot slip in between and - // leave the state claimable. - inst.setTerminalLocked(stateFailed) - inst.mu.Unlock() - if inst.onCrash != nil { - inst.onCrash(inst.def.Name) - } - return - } - inst.restarts++ - // The attempt that just died is over: return the instance to idle so its - // waiters are released and the restart below can claim it. Failed cannot - // be used here — it is final and unclaimable by design. - inst.setTerminalLocked(stateIdle) - inst.mu.Unlock() - - ctx, cancel := context.WithTimeout(inst.lifetime, 60*time.Second) - defer cancel() - if _, err := inst.start(ctx); err != nil { - // The restart attempt failed; start() already ended it by returning - // the instance to idle, so it stays claimable. - if inst.onCrash != nil { - inst.onCrash(inst.def.Name) - } - } -} - -func (inst *instance) waitReady(ctx context.Context) error { - inst.mu.Lock() - switch inst.state { - case stateReady: - inst.mu.Unlock() - return nil - case stateStarting: - ch := inst.readyCh - inst.mu.Unlock() - return inst.waitForReady(ctx, ch) - case stateFailed: - // Failed is final: the restart budget is spent, so an ordinary wait - // must not launch another process. - inst.mu.Unlock() - return fmt.Errorf("%s is unavailable", inst.def.Name) - case stateShutdown: - // Starting an instance that was shut down is an explicit restart and - // gets a fresh crash budget; crash-restarts keep counting toward the - // limit. - inst.restarts = 0 - } - inst.mu.Unlock() - - // Idle or shutdown: start a fresh launch. The start context is decoupled - // from the caller so cancelling this wait does not abort the launch for - // other callers, and bounded like the crash-restart path — both restart - // derivations come from the instance's lifetime, never Background. - startCtx, cancel := context.WithTimeout(inst.lifetime, 60*time.Second) - defer cancel() - ch, err := inst.start(startCtx) - if err != nil { - return fmt.Errorf("%s start failed: %w", inst.def.Name, err) - } - return inst.waitForReady(ctx, ch) -} - -func (inst *instance) waitForReady(ctx context.Context, ch chan struct{}) error { - timer := time.NewTimer(readyTimeout) - defer timer.Stop() - - select { - case <-ch: - case <-timer.C: - // Report the state as it reads now rather than success; the attempt - // is left alone — if the server announces readiness later the attempt - // completes normally, and if it never does the idle path reaps it. - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - return fmt.Errorf("%s: timed out waiting for the server to become ready (state: %s)", inst.def.Name, stateName(state)) - case <-ctx.Done(): - return ctx.Err() - } - - // The channel this caller waited on must still be current: if a newer - // launch replaced it, the tracked launch ended without becoming ready and - // the newer launch's state is not this caller's outcome. - inst.mu.Lock() - state := inst.state - current := inst.readyCh - inst.mu.Unlock() - if state == stateReady && current == ch { - return nil - } - return fmt.Errorf("%s is unavailable", inst.def.Name) -} - -func (inst *instance) call(ctx context.Context, method string, params any) (json.RawMessage, error) { - inst.mu.Lock() - rpc := inst.rpc - state := inst.state - inst.mu.Unlock() - - if state == stateFailed || state == stateShutdown || rpc == nil { - return nil, fmt.Errorf("%s is unavailable", inst.def.Name) - } - - inst.resetIdle() - - result, err := rpc.Call(ctx, method, params) - if err != nil { - if rpc.IsClosed() { - return nil, fmt.Errorf("%s connection lost: %w", inst.def.Name, err) - } - return nil, err - } - return result, nil -} - -// readCanonicalFile reads path through safefs.OpenExisting and its returned -// descriptor: the no-follow open refuses a replaced symlink leaf and -// non-regular or hardlinked files, so no substituted content can ever be -// sent to the server. -func readCanonicalFile(path string) ([]byte, error) { - f, err := safefs.OpenExisting(path, os.O_RDONLY) - if err != nil { - return nil, err - } - defer f.Close() - return io.ReadAll(f) -} - -// openFileCanonical is the one document-sync body: the terminal admission -// check for the retained instance handle, the canonical content read, and the -// didOpen/didChange versioned sync. -func (inst *instance) openFileCanonical(ctx context.Context, absPath string) error { - inst.mu.Lock() - ver := inst.openedVer[absPath] - rpc := inst.rpc - state := inst.state - inst.mu.Unlock() - - // Permanent close is terminal admission for retained instance handles: a - // failed/shutdown instance refuses before reading the file or sending - // didOpen, matching call and the terminal-admission rule for retained - // instance handles. - if state == stateFailed || state == stateShutdown || rpc == nil { - return fmt.Errorf("%s is unavailable", inst.def.Name) - } - - content, err := readCanonicalFile(absPath) - if err != nil { - return err - } - - if ver > 0 { - newVer := ver + 1 - if err := rpc.Notify("textDocument/didChange", map[string]any{ - "textDocument": map[string]any{ - "uri": protocol.URIFromPath(absPath), - "version": newVer, - }, - "contentChanges": []map[string]any{ - {"text": string(content)}, - }, - }); err != nil { - return err - } - inst.mu.Lock() - inst.openedVer[absPath] = newVer - inst.mu.Unlock() - return nil - } - - params := protocol.DidOpenTextDocumentParams{ - TextDocument: protocol.TextDocumentItem{ - URI: protocol.URIFromPath(absPath), - LanguageID: inst.def.LanguageID, - Version: 1, - Text: string(content), - }, - } - if err := rpc.Notify("textDocument/didOpen", params); err != nil { - return err - } - - inst.mu.Lock() - inst.openedVer[absPath] = 1 - inst.mu.Unlock() - return nil -} - -func (inst *instance) fileDiagnostics(uri string) []protocol.Diagnostic { - inst.diagMu.RLock() - defer inst.diagMu.RUnlock() - return inst.diagnostics[uri] -} - -// captureDiagnostics takes an owned sample of the current attempt's -// diagnostics for the given paths. The liveness requirement and the sample -// share one instance-mutex hold: the instance must be ready with its current -// process still running, and only then are the requested URIs' cached slices -// copied out under the diagnostics lock, so a transition — a new attempt's -// reset or a teardown's clear — can never land between the liveness decision -// and the sample. The bool reports whether a live attempt supplied the sample; -// false is the unavailable refusal. -func (inst *instance) captureDiagnostics(paths []string) ([][]protocol.Diagnostic, bool) { - inst.mu.Lock() - if inst.state != stateReady || inst.procDone == nil { - inst.mu.Unlock() - return nil, false - } - // A process that has exited reads as not alive even before its watcher - // moves the state. - select { - case <-inst.procDone: - inst.mu.Unlock() - return nil, false - default: - } - inst.diagMu.RLock() - sample := make([][]protocol.Diagnostic, len(paths)) - for i, p := range paths { - sample[i] = append([]protocol.Diagnostic(nil), inst.diagnostics[protocol.URIFromPath(p)]...) - } - inst.diagMu.RUnlock() - inst.mu.Unlock() - return sample, true -} - -func (inst *instance) resetIdle() { - inst.mu.Lock() - defer inst.mu.Unlock() - if inst.idleTimer != nil { - inst.idleTimer.Stop() - } - inst.idleTimer = time.AfterFunc(idleTimeout, func() { - inst.shutdown() - }) -} - -// killForTeardown is the manager-wide teardown path (owner shutdown): the -// process is killed and reaped directly instead of negotiating a shutdown, -// because the owner is exiting on every host and the servers are our own child -// processes. It keeps shutdown's bookkeeping — the connection close and the -// captured handles cleared under the same attempt-identity check, so a -// concurrent restart is not clobbered. The terminal state was already fixed -// by closeAdmission (stateFailed is final), so the captured process is the one -// this teardown actually waits on. -func (inst *instance) killForTeardown() { - inst.mu.Lock() - rpc := inst.rpc - cmd := inst.cmd - procDone := inst.procDone - if inst.idleTimer != nil { - inst.idleTimer.Stop() - inst.idleTimer = nil - } - inst.setTerminalLocked(stateShutdown) - inst.mu.Unlock() - - if cmd != nil && cmd.Process != nil { - _ = cmd.Process.Kill() - // The watcher (or the start path of an attempt still launching) reaps - // the process and closes procDone, so the wait returns promptly. - <-procDone - } - - if rpc != nil { - rpc.Close() - } - - // Clear the captured attempt's handles and diagnostics only if it is - // still the current one — procDone is the field this teardown actually - // waited on and the identity of the attempt it captured. The clear shares - // the identity check's hold, so a replacement attempt can neither lose - // its handles nor its diagnostics to a superseded teardown. - inst.mu.Lock() - if inst.procDone == procDone { - inst.rpc = nil - inst.cmd = nil - inst.openedVer = make(map[string]int) - inst.diagMu.Lock() - inst.diagnostics = make(map[string][]protocol.Diagnostic) - inst.diagMu.Unlock() - } - inst.mu.Unlock() -} - -func (inst *instance) shutdown() { - inst.mu.Lock() - rpc := inst.rpc - cmd := inst.cmd - procDone := inst.procDone - if inst.idleTimer != nil { - inst.idleTimer.Stop() - inst.idleTimer = nil - } - // The capture and the transition share one hold, so the transition can - // only end the attempt captured above; a newer attempt that becomes - // current later is left intact. - inst.setTerminalLocked(stateShutdown) - inst.mu.Unlock() - - if rpc == nil { - return - } - - ctx, cancel := context.WithTimeout(context.Background(), shutdownWait) - defer cancel() - _, _ = rpc.Call(ctx, "shutdown", nil) - _ = rpc.Notify("exit", nil) - rpc.Close() - - if cmd != nil && cmd.Process != nil { - select { - case <-procDone: - case <-time.After(shutdownWait): - _ = cmd.Process.Kill() - <-procDone - } - } - - // Clear the captured attempt's handles and diagnostics only if it is - // still the current one — procDone is the field this shutdown actually - // waited on and the identity of the attempt it captured. The clear shares - // the identity check's hold, so a replacement attempt can neither lose - // its handles nor its diagnostics to a superseded shutdown. - inst.mu.Lock() - if inst.procDone == procDone { - inst.rpc = nil - inst.cmd = nil - inst.openedVer = make(map[string]int) - inst.diagMu.Lock() - inst.diagnostics = make(map[string][]protocol.Diagnostic) - inst.diagMu.Unlock() - } - inst.mu.Unlock() -} diff --git a/internal/lsp/instance_ready_test.go b/internal/lsp/instance_ready_test.go deleted file mode 100644 index 28e789c9..00000000 --- a/internal/lsp/instance_ready_test.go +++ /dev/null @@ -1,770 +0,0 @@ -package lsp - -// These tests exercise the instance's start/wait protocol at package level. -// No adapter-facing entry point exposes an *instance — the runtime only hands -// out per-file handles — so the protocol can only be exercised on the package's -// own types. They drive a real server process (a small stdio script) so that -// process counts, failures, cancellation and timeouts are observed without -// adding any test-only hooks to production code. - -import ( - "bytes" - "context" - "errors" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" - "testing" - "time" - - "github.com/MMinasyan/lightcode/internal/lsp/protocol" - "github.com/MMinasyan/lightcode/internal/lsp/server" - "github.com/MMinasyan/lightcode/internal/testassert" -) - -// fakeServerScript is a minimal LSP server over stdio that records every -// launch in a log file. Args: log path, optional fail marker (the server -// exits before logging when the file exists), optional delay in seconds before -// answering initialize, optional delay in seconds before sending the work-done -// progress notification that marks the server ready (a negative delay -// suppresses it entirely), a crash mode ("first" crashes only the very first -// launch, "always" every launch, both after initialize and before readiness; -// "after-open" lets the first launch become ready and then crashes it on its -// first didOpen, with every later launch crashing before readiness), -// and a stubborn flag that makes the server never answer the shutdown -// request. -const fakeServerScript = `#!/usr/bin/env python3 -import json -import os -import sys -import time - -log = sys.argv[1] -fail_marker = sys.argv[2] if len(sys.argv) > 2 else "" -init_delay = float(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3] else 0.0 -ready_delay = float(sys.argv[4]) if len(sys.argv) > 4 and sys.argv[4] else 0.0 -crash = sys.argv[5] if len(sys.argv) > 5 and sys.argv[5] else "" -stubborn = sys.argv[6] if len(sys.argv) > 6 and sys.argv[6] else "" -requests_log = os.environ.get("FAKE_LSP_REQUESTS_LOG", "") - -first_launch = False -if crash: - try: - first_launch = os.path.getsize(log) == 0 - except OSError: - first_launch = True - -if fail_marker and os.path.exists(fail_marker): - sys.exit(1) - -with open(log, "a") as f: - f.write("started\n") - - -def send(obj): - data = json.dumps(obj, separators=(",", ":")).encode() - sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data) + data) - sys.stdout.buffer.flush() - - -stdin = sys.stdin.buffer -while True: - length = None - while True: - line = stdin.readline() - if not line: - sys.exit(0) - if line in (b"\r\n", b"\n"): - break - if line.lower().startswith(b"content-length:"): - length = int(line.split(b":", 1)[1]) - if length is None: - continue - body = json.loads(stdin.read(length)) - method = body.get("method") - if requests_log: - with open(requests_log, "a") as f: - f.write(method + "\n") - if method == "initialize": - if init_delay: - time.sleep(init_delay) - send({"jsonrpc": "2.0", "id": body["id"], "result": {"capabilities": {}}}) - elif method == "shutdown": - if stubborn: - continue - send({"jsonrpc": "2.0", "id": body["id"], "result": None}) - elif method == "exit": - sys.exit(0) - elif method == "initialized": - if ready_delay < 0: - continue - if crash == "always" or (crash == "first" and first_launch) or (crash == "after-open" and not first_launch): - sys.exit(1) - if ready_delay: - time.sleep(ready_delay) - send({"jsonrpc": "2.0", "method": "$/progress", - "params": {"token": 0, "value": {"kind": "end"}}}) - elif method == "textDocument/didOpen": - if crash == "after-open": - sys.exit(1) -` - -func newFakeServerInstance(t *testing.T, log, failMarker string, initDelay, readyDelay float64, crash, stubborn string) *instance { - return newFakeServerInstanceWithLifetime(t, log, failMarker, initDelay, readyDelay, crash, stubborn, context.Background()) -} - -// newFakeServerInstanceWithLifetime is the same helper with the instance's -// restart lifetime under the test's control: restarts derive their bounded -// start context from it. -func newFakeServerInstanceWithLifetime(t *testing.T, log, failMarker string, initDelay, readyDelay float64, crash, stubborn string, lifetime context.Context) *instance { - t.Helper() - home := t.TempDir() - cacheDir := filepath.Join(home, ".cache", "lightcode", "lsp", "fake") - testassert.NoError(t, os.MkdirAll(cacheDir, 0o755)) - script := filepath.Join(cacheDir, "fake-lsp") - testassert.NoError(t, os.WriteFile(script, []byte(fakeServerScript), 0o755)) - inst := newInstance(&server.Definition{ - Name: "fake", - Command: "fake-lsp", - Args: []string{ - log, - failMarker, - strconv.FormatFloat(initDelay, 'f', -1, 64), - strconv.FormatFloat(readyDelay, 'f', -1, 64), - crash, - stubborn, - }, - }, t.TempDir(), home, lifetime, nil) - t.Cleanup(func() { inst.shutdown() }) - return inst -} - -// newPIDRecordingFakeServer builds the fake server behind a shell wrapper -// that records the launched process's pid before exec'ing the real script, so -// a test can observe whether a process was launched and whether it was -// reaped. The pidfile and the launch log use separate temporary directories. -func newPIDRecordingFakeServer(t *testing.T, initDelay string) (home, pidfile, log string, def *server.Definition) { - t.Helper() - home = t.TempDir() - cacheDir := filepath.Join(home, ".cache", "lightcode", "lsp", "fake") - testassert.NoError(t, os.MkdirAll(cacheDir, 0o755)) - real := filepath.Join(cacheDir, "fake-lsp.py") - testassert.NoError(t, os.WriteFile(real, []byte(fakeServerScript), 0o755)) - wrapper := filepath.Join(cacheDir, "fake-lsp") - wrapperScript := "#!/bin/sh\necho $$ > \"${FAKE_PIDFILE:?}\"\nexec python3 \"$(dirname \"$0\")/fake-lsp.py\" \"$@\"\n" - testassert.NoError(t, os.WriteFile(wrapper, []byte(wrapperScript), 0o755)) - pidfile = filepath.Join(t.TempDir(), "lsp.pid") - t.Setenv("FAKE_PIDFILE", pidfile) - log = filepath.Join(t.TempDir(), "launches.log") - def = &server.Definition{ - Name: "fake", - Command: "fake-lsp", - Args: []string{log, "", initDelay, "0", "", "1"}, - } - return home, pidfile, log, def -} - -func countLaunches(log string) int { - b, err := os.ReadFile(log) - if err != nil { - return 0 - } - return bytes.Count(b, []byte("started\n")) -} - -func waitForLaunches(t *testing.T, log string, want int) { - t.Helper() - deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - if countLaunches(log) >= want { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("server process not launched within 10s") -} - -func TestWaitReadyConcurrentCallsStartOneProcess(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - // The readiness notification is delayed so the first launch stays in - // flight while the second caller joins. - inst := newFakeServerInstance(t, log, "", 0, 0.5, "", "") - - // Barrier: the second caller is released only once the first launch has - // begun, so it reliably reaches the start decision mid-launch instead of - // racing it. - first := make(chan error, 1) - go func() { first <- inst.waitReady(context.Background()) }() - waitForLaunches(t, log, 1) - - second := make(chan error, 1) - go func() { second <- inst.waitReady(context.Background()) }() - - testassert.NoError(t, <-second) - testassert.NoError(t, <-first) - testassert.Equal(t, countLaunches(log), 1) -} - -func TestWaitReadyFailedStartAllowsRetry(t *testing.T) { - dir := t.TempDir() - log := filepath.Join(dir, "launches.log") - failMarker := filepath.Join(dir, "fail") - inst := newFakeServerInstance(t, log, failMarker, 0, 0, "", "") - testassert.NoError(t, os.WriteFile(failMarker, nil, 0o644)) - - var wg sync.WaitGroup - errs := make([]error, 2) - for i := range errs { - wg.Add(1) - go func(i int) { - defer wg.Done() - errs[i] = inst.waitReady(context.Background()) - }(i) - } - wg.Wait() - - for i, err := range errs { - testassert.Check(t, err != nil, "caller %d: error = nil after failed start, want a failure reported", i) - } - - // A failed start attempt ends in the idle state, not the failed one: the - // two outcomes are distinct, and only idle keeps the instance claimable. - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - testassert.Equal(t, state, stateIdle) - - // A failed start attempt must leave the instance usable: once the cause - // is removed a fresh wait claims a new launch and reaches ready. - testassert.NoError(t, os.Remove(failMarker)) - testassert.NoError(t, inst.waitReady(context.Background())) - testassert.Equal(t, countLaunches(log), 1) -} - -func TestWaitReadyCancelDoesNotAbortStart(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - // The initialize answer is delayed so the start is provably in flight when - // the first caller cancels, and the ready notification is delayed so a - // second caller is still waiting when it lands. - inst := newFakeServerInstance(t, log, "", 1.0, 0.5, "", "") - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - first := make(chan error, 1) - go func() { first <- inst.waitReady(ctx) }() - waitForLaunches(t, log, 1) - - cancel() - second := make(chan error, 1) - go func() { second <- inst.waitReady(context.Background()) }() - - testassert.NoError(t, <-second) - testassert.Equal(t, <-first, context.Canceled) - testassert.Equal(t, countLaunches(log), 1) -} - -func TestWaitReadyTimeoutReportsStateNotSuccess(t *testing.T) { - old := readyTimeout - readyTimeout = 100 * time.Millisecond - t.Cleanup(func() { readyTimeout = old }) - - log := filepath.Join(t.TempDir(), "launches.log") - // A negative ready delay makes the server never send the work-done - // progress notification, so the instance stays starting. - inst := newFakeServerInstance(t, log, "", 0, -1, "", "") - - err := inst.waitReady(context.Background()) - testassert.Require(t, err != nil, "waitReady error = nil, want a timeout reporting the state") - testassert.Require(t, strings.Contains(err.Error(), "starting"), "waitReady error = %q, want it to name the state", err) -} - -func TestWaitReadyRecoversAfterCrashBeforeReady(t *testing.T) { - // The first launch crashes after initialize and before announcing - // readiness; the caller of the dying attempt is released with a failure - // and the instance restarts instead of staying stuck in the crashed - // launch. A short timeout turns a stuck instance into a fast failure. - old := readyTimeout - readyTimeout = 2 * time.Second - t.Cleanup(func() { readyTimeout = old }) - - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "first", "") - - { - err := inst.waitReady(context.Background()) - testassert.Require(t, err != nil, "waitReady error = nil for the crashed launch, want a failure") - } - - // The restart must recover the instance: a fresh wait reaches ready. - testassert.NoError(t, inst.waitReady(context.Background())) - testassert.Equal(t, countLaunches(log), 2) -} - -func waitForState(t *testing.T, inst *instance, want int) { - t.Helper() - deadline := time.Now().Add(10 * time.Second) - for { - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - if state == want { - return - } - testassert.Require(t, !time.Now().After(deadline), "instance state = %d, want %d", state, want) - time.Sleep(10 * time.Millisecond) - } -} - -func TestFailedInstanceStaysFailed(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - // Every launch crashes before announcing readiness, so the crash loop - // exhausts the restart budget and the instance ends failed. - inst := newFakeServerInstance(t, log, "", 0, 0, "always", "") - - // The first wait rides the initial launch and is released with its - // failure once the attempt dies. - { - err := inst.waitReady(context.Background()) - testassert.Require(t, err != nil, "waitReady error = nil for the crashed launch, want a failure") - } - waitForState(t, inst, stateFailed) - - // A later wait on the failed instance must not launch another process. - { - err := inst.waitReady(context.Background()) - testassert.Require(t, err != nil, "waitReady on the failed instance: error = nil, want unavailable") - } - { - _, err := inst.start(context.Background()) - testassert.NoError(t, err) - } - testassert.Equal(t, countLaunches(log), maxRestarts+1) -} - -func TestSupersededWatcherIsInert(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "") - - testassert.NoError(t, inst.waitReady(context.Background())) - - // Reproduce the window in which a shutdown followed by a wait replaces - // the attempt: kill the running process while holding the lock, so the - // old watcher wakes to find a newer attempt already installed. The lock - // hold makes the ordering deterministic — the watcher cannot run its - // post-exit path until the swap below has landed. - inst.mu.Lock() - oldProcDone := inst.procDone - if err := inst.cmd.Process.Kill(); err != nil { - inst.mu.Unlock() - t.Fatalf("kill the running server: %v", err) - } - inst.state = stateStarting - inst.readyCh = make(chan struct{}) - fresh := make(chan struct{}) - close(fresh) - inst.procDone = fresh - inst.mu.Unlock() - - // The old watcher wakes once the killed process exits; wait until it has - // run its post-exit path (it closes the procDone it captured). - select { - case <-oldProcDone: - case <-time.After(10 * time.Second): - t.Fatal("the superseded watcher never ran") - } - // Give a watcher that fails to notice the supersession a moment to apply - // its accounting, so the assertions below catch it. - time.Sleep(250 * time.Millisecond) - - inst.mu.Lock() - state := inst.state - restarts := inst.restarts - inst.mu.Unlock() - testassert.Equal(t, state, stateStarting) - testassert.Equal(t, restarts, 0) - testassert.Equal(t, countLaunches(log), 1) -} - -func TestStartRefusedWhileReady(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "") - - testassert.NoError(t, inst.waitReady(context.Background())) - - // A launch is already running: starting again must be refused rather than - // launch a second process over the live one. - { - _, err := inst.start(context.Background()) - testassert.NoError(t, err) - } - testassert.Equal(t, countLaunches(log), 1) -} - -// TestStartAfterPermanentCloseSelfReaps proves the close-loser path: a start -// whose process was already launched when permanent close won must kill and -// reap that process itself — it is the sole cmd.Wait owner before procDone and -// watchProcess are installed — and return the unavailable error, so no -// launched process is ever left untracked. The barrier holds the start -// between cmd.Start and the post-launch attempt-identity recheck, and the -// waitCommand seam counts exactly one wait on this side of the handoff. -func TestStartAfterPermanentCloseSelfReaps(t *testing.T) { - home, pidfile, log, def := newPIDRecordingFakeServer(t, "0") - inst := newInstance(def, t.TempDir(), home, context.Background(), nil) - t.Cleanup(func() { inst.shutdown() }) - - // Count every instance-owned process wait through the seam. - var waits atomic.Int64 - oldWait := waitCommand - waitCommand = func(cmd *exec.Cmd) error { waits.Add(1); return oldWait(cmd) } - t.Cleanup(func() { waitCommand = oldWait }) - - // Hold the start after cmd.Start and before the attempt-identity recheck. - launched := make(chan struct{}) - release := make(chan struct{}) - var once sync.Once - oldProbe := startProbe - startProbe = func() { once.Do(func() { close(launched) }); <-release } - t.Cleanup(func() { startProbe = oldProbe }) - - started := make(chan error, 1) - go func() { _, err := inst.start(context.Background()); started <- err }() - select { - case <-launched: - case <-time.After(10 * time.Second): - t.Fatal("start never reached the post-launch identity recheck") - } - - // The process is running when permanent close wins. Wait for the launch - // log too: the wrapper writes the pidfile before exec'ing the script, so - // the log write is what proves the server process itself is up. - var pid int - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if countLaunches(log) >= 1 { - if data, err := os.ReadFile(pidfile); err == nil { - if p, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && p > 0 { - pid = p - break - } - } - } - time.Sleep(10 * time.Millisecond) - } - testassert.Require(t, pid != 0, "language server process never launched") - - inst.closeAdmission() - close(release) - - { - err := <-started - testassert.Require(t, err != nil && strings.Contains(err.Error(), "unavailable"), "start after permanent close = %v, want the unavailable error", err) - } - // The close loser's process is gone and reaped by the start goroutine - // itself: kill(pid, 0) returns ESRCH only once reaped. - { - err := syscall.Kill(pid, 0) - testassert.Require(t, errors.Is(err, syscall.ESRCH), "close-loser process %d still alive after start returned (kill err = %v)", pid, err) - } - // Exactly one wait happened, and it was this start goroutine's: procDone - // and the watcher were never installed, so no other waiter exists. - testassert.Equal(t, waits.Load(), 1) - inst.mu.Lock() - state := inst.state - procDone := inst.procDone - cmd := inst.cmd - inst.mu.Unlock() - if state != stateFailed { - t.Fatalf("state = %d (%s), want %d (failed)", state, stateName(state), stateFailed) - } - testassert.Require(t, procDone == nil && cmd == nil, "close loser installed handles: procDone = %v, cmd = %v", procDone, cmd) - // Permanent close cannot restart: a later ordinary start is a refused - // no-op and launches no process. - { - _, err := inst.start(context.Background()) - testassert.NoError(t, err) - } - testassert.Equal(t, countLaunches(log), 1) -} - -// TestOpenFileCanonicalAfterPermanentCloseRefusesWithoutProtocolWrite proves -// the retained-instance terminal admission on the canonical open: after -// permanent close wins on a ready instance, openFileCanonical must return the -// unavailable error before reading the file or sending didOpen — no protocol -// write reaches the server. -func TestOpenFileCanonicalAfterPermanentCloseRefusesWithoutProtocolWrite(t *testing.T) { - reqLog := filepath.Join(t.TempDir(), "requests.log") - t.Setenv("FAKE_LSP_REQUESTS_LOG", reqLog) - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "") - testassert.NoError(t, inst.waitReady(context.Background())) - inst.closeAdmission() - - target := filepath.Join(t.TempDir(), "existing.txt") - testassert.NoError(t, os.WriteFile(target, []byte("content"), 0o600)) - { - err := inst.openFileCanonical(context.Background(), target) - testassert.Require(t, err != nil && strings.Contains(err.Error(), "unavailable"), "openFileCanonical after permanent close = %v, want the unavailable error", err) - } - // No protocol write: the server saw the startup initialize/initialized - // requests but never a didOpen. - data, err := os.ReadFile(reqLog) - testassert.NoError(t, err) - testassert.Require(t, !strings.Contains(string(data), "textDocument/didOpen"), "openFileCanonical after permanent close sent didOpen to the server") -} - -// TestIdleRetirementRestartsWithFreshBudget is the positive idle-retirement -// sibling of permanent close: a ready instance retired by the idle path -// reaches ordinary stateShutdown, and a later ordinary waitReady restarts it -// with a fresh crash budget — the allowed restart row that permanent close's -// final stateFailed must not collapse. -func TestIdleRetirementRestartsWithFreshBudget(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "") - - testassert.NoError(t, inst.waitReady(context.Background())) - // The idle-retirement path: retire the ready instance without permanent - // close. - inst.shutdown() - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - if state != stateShutdown { - t.Fatalf("state after idle retirement = %d (%s), want %d (shutdown)", state, stateName(state), stateShutdown) - } - - // A later ordinary request restarts the retired instance. - testassert.NoError(t, inst.waitReady(context.Background())) - inst.mu.Lock() - state = inst.state - restarts := inst.restarts - inst.mu.Unlock() - if state != stateReady { - t.Fatalf("state after restart = %d (%s), want %d (ready)", state, stateName(state), stateReady) - } - testassert.Equal(t, restarts, 0) - testassert.Equal(t, countLaunches(log), 2) -} - -// TestPermanentCloseWatcherReapsWithoutRestart is the permanent-close watcher -// sibling: close admission wins on a ready instance, then its process exits; -// the watcher reaps it (the sole post-handoff waiter, counted through the -// waitCommand seam) but must not relaunch it, leaving stateFailed and exactly -// one launch. It is the forbidden restart row for permanent close. -func TestPermanentCloseWatcherReapsWithoutRestart(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "") - - // Count every instance-owned process wait through the seam from before - // the initial launch: the watcher installed by that launch is the only - // waiter that should ever reap. - var waits atomic.Int64 - oldWait := waitCommand - waitCommand = func(cmd *exec.Cmd) error { waits.Add(1); return oldWait(cmd) } - t.Cleanup(func() { waitCommand = oldWait }) - - testassert.NoError(t, inst.waitReady(context.Background())) - inst.closeAdmission() - inst.mu.Lock() - procDone := inst.procDone - cmd := inst.cmd - inst.mu.Unlock() - testassert.Require(t, procDone != nil && cmd != nil, "ready instance has no procDone or cmd") - - // The process exits after permanent close: the watcher reaps it. - testassert.NoError(t, cmd.Process.Kill()) - select { - case <-procDone: - case <-time.After(10 * time.Second): - t.Fatal("watcher never reaped the killed process") - } - // Exactly one wait happened after the procDone/watcher handoff: the - // watcher's own. No teardown path, restart, or second waiter touched the - // process. - testassert.Equal(t, waits.Load(), 1) - - inst.mu.Lock() - state := inst.state - restarts := inst.restarts - inst.mu.Unlock() - if state != stateFailed { - t.Fatalf("state = %d (%s), want %d (failed)", state, stateName(state), stateFailed) - } - testassert.Equal(t, restarts, 0) - testassert.Equal(t, countLaunches(log), 1) - // A later ordinary request refuses: permanent close cannot restart. - { - err := inst.waitReady(context.Background()) - testassert.Require(t, err != nil, "waitReady after permanent close: error = nil, want unavailable") - } - testassert.Equal(t, countLaunches(log), 1) -} - -func TestSupersededShutdownLeavesNewAttemptIntact(t *testing.T) { - old := shutdownWait - shutdownWait = 200 * time.Millisecond - t.Cleanup(func() { shutdownWait = old }) - - log := filepath.Join(t.TempDir(), "launches.log") - // The stubborn server never answers the shutdown request, so the teardown - // stays open long enough for a newer attempt to become current. - inst := newFakeServerInstance(t, log, "", 0, 0, "", "1") - - testassert.NoError(t, inst.waitReady(context.Background())) - - done := make(chan struct{}) - go func() { inst.shutdown(); close(done) }() - - // Wait until shutdown has captured the running attempt (the capture and - // the terminal transition share one lock hold), then install a newer - // attempt while shutdown is blocked waiting for the server to answer. - deadline := time.Now().Add(10 * time.Second) - for { - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - if state == stateShutdown { - break - } - testassert.Require(t, !time.Now().After(deadline), "shutdown never captured the attempt") - time.Sleep(10 * time.Millisecond) - } - - inst.mu.Lock() - inst.state = stateStarting - inst.readyCh = make(chan struct{}) - fresh := make(chan struct{}) - close(fresh) - inst.procDone = fresh - inst.mu.Unlock() - - <-done - - inst.mu.Lock() - state := inst.state - rpc := inst.rpc - cmd := inst.cmd - procDone := inst.procDone - inst.mu.Unlock() - testassert.Equal(t, state, stateStarting) - testassert.Require(t, rpc != nil && cmd != nil, "shutdown cleared the newer attempt's rpc or cmd") - testassert.Require(t, procDone == fresh, "shutdown replaced the newer attempt's procDone") -} - -// TestSupersededShutdownKeepsReplacementDiagnostics is the stale-attempt -// negative control: a teardown of an attempt that has been superseded must not -// clear the replacement ready attempt's diagnostics. The stubborn server keeps -// the shutdown open while the replacement attempt is installed and its -// diagnostic cache seeded; when the superseded teardown finishes, that cache -// must still hold the seeded error, and the captured attempt's process must -// still be killed and reaped by its own watcher. -func TestSupersededShutdownKeepsReplacementDiagnostics(t *testing.T) { - old := shutdownWait - shutdownWait = 200 * time.Millisecond - t.Cleanup(func() { shutdownWait = old }) - - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "1") - - // Count every instance-owned process wait from before the initial launch: - // the captured attempt's watcher is the one that must reap it. - var waits atomic.Int64 - oldWait := waitCommand - waitCommand = func(cmd *exec.Cmd) error { waits.Add(1); return oldWait(cmd) } - t.Cleanup(func() { waitCommand = oldWait }) - - testassert.NoError(t, inst.waitReady(context.Background())) - inst.mu.Lock() - pid := inst.cmd.Process.Pid - inst.mu.Unlock() - - done := make(chan struct{}) - go func() { inst.shutdown(); close(done) }() - - // Wait until shutdown has captured the running attempt (the capture and - // the terminal transition share one lock hold), then install a ready - // replacement attempt while shutdown is blocked waiting for the server - // to answer. - deadline := time.Now().Add(10 * time.Second) - for { - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - if state == stateShutdown { - break - } - testassert.Require(t, !time.Now().After(deadline), "shutdown never captured the attempt") - time.Sleep(10 * time.Millisecond) - } - - replacementDone := make(chan struct{}) - inst.mu.Lock() - inst.state = stateReady - inst.readyCh = make(chan struct{}) - inst.procDone = replacementDone - inst.mu.Unlock() - // The replacement is the only owner of its attempt channel; close it for - // the helper's cleanup teardown so it never waits on a live attempt. - t.Cleanup(func() { close(replacementDone) }) - - uri := protocol.URIFromPath(filepath.Join(t.TempDir(), "replacement.go")) - severity := protocol.SeverityError - inst.diagMu.Lock() - inst.diagnostics = map[string][]protocol.Diagnostic{ - uri: {{ - Severity: &severity, - Message: "replacement error", - Range: protocol.Range{Start: protocol.Position{Line: 4}}, - }}, - } - inst.diagMu.Unlock() - - <-done - - diags := inst.fileDiagnostics(uri) - testassert.Require(t, len(diags) == 1 && diags[0].Message == "replacement error", "diagnostics after the superseded shutdown = %+v, want the replacement attempt's cached error", diags) - // The captured attempt's process was killed and reaped inside the - // teardown: kill(pid, 0) returns ESRCH only once reaped, and the single - // wait is the watcher's own reap. - { - err := syscall.Kill(pid, 0) - testassert.Require(t, errors.Is(err, syscall.ESRCH), "captured attempt process %d still alive after the superseded shutdown (kill err = %v)", pid, err) - } - testassert.Equal(t, waits.Load(), 1) -} - -// TestStartWithCancelledContextLaunchesNothing proves the pre-spawn guard: a -// start whose supplied context is already cancelled refuses the attempt -// before binary resolution, pipes or spawn, ends it so the instance stays -// claimable, and returns the context error. The startProbe seam counts -// successful cmd.Start invocations, so the count does not depend on whether -// the child writes its launch log before it is killed. -func TestStartWithCancelledContextLaunchesNothing(t *testing.T) { - var starts atomic.Int64 - oldProbe := startProbe - startProbe = func() { starts.Add(1) } - t.Cleanup(func() { startProbe = oldProbe }) - - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "") - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - { - _, err := inst.start(ctx) - testassert.Require(t, errors.Is(err, context.Canceled), "start with a cancelled context: error = %v, want context.Canceled", err) - } - testassert.Equal(t, starts.Load(), 0) - testassert.Equal(t, countLaunches(log), 0) - - // The refused attempt ended the launch it claimed, so the instance is - // claimable again: a live start still works. - testassert.NoError(t, inst.waitReady(context.Background())) - testassert.Equal(t, countLaunches(log), 1) -} diff --git a/internal/lsp/instance_test.go b/internal/lsp/instance_test.go deleted file mode 100644 index 844112a4..00000000 --- a/internal/lsp/instance_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package lsp - -import ( - "context" - "encoding/json" - "path/filepath" - "runtime" - "sync" - "testing" - - "github.com/MMinasyan/lightcode/internal/lsp/protocol" - "github.com/MMinasyan/lightcode/internal/lsp/server" - "github.com/MMinasyan/lightcode/internal/testassert" -) - -func TestInstanceDiagnosticsAndReadyNotification(t *testing.T) { - inst := newInstance(&server.Definition{Name: "fake"}, t.TempDir(), t.TempDir(), context.Background(), nil) - // A readiness notification only ever arrives from a launch that is under - // way, so the instance must already be starting. - inst.mu.Lock() - inst.state = stateStarting - inst.mu.Unlock() - uri := protocol.URIFromPath("/tmp/main.go") - severity := protocol.SeverityError - params, err := json.Marshal(protocol.PublishDiagnosticsParams{ - URI: uri, - Diagnostics: []protocol.Diagnostic{{Severity: &severity, Message: "bad", Range: protocol.Range{Start: protocol.Position{Line: 3}}}}, - }) - testassert.NoError(t, err) - inst.handleNotification("textDocument/publishDiagnostics", params, inst.readyCh) - diags := inst.fileDiagnostics(uri) - testassert.Require(t, len(diags) == 1 && diags[0].Message == "bad" && diags[0].Range.Start.Line == 3, "diagnostics = %+v", diags) - - progress, err := json.Marshal(protocol.ProgressParams{Value: json.RawMessage(`{"kind":"end"}`)}) - testassert.NoError(t, err) - inst.handleNotification("$/progress", progress, inst.readyCh) - testassert.NoError(t, inst.waitReady(context.Background())) -} - -// TestCaptureDiagnosticsNeverSamplesClearedCacheAsLive pins the capture -// atomicity: the liveness decision and the diagnostic sample share one -// instance-mutex hold, so a transition that takes the mutex, starts a new -// attempt and clears the cache under the nested diagnostics lock cannot slip -// between them. Each iteration parks a capture on the instance mutex, creates -// that transition while the capture is parked, and releases the mutex, so the -// capture passes its liveness check while the transition is already contending -// for the same mutex and cache. A live sample must carry the seeded error, and -// the only other valid outcome is the unavailable refusal. A live-but-empty -// sample is the bug shape a separated liveness check and sample reproduces. -func TestCaptureDiagnosticsNeverSamplesClearedCacheAsLive(t *testing.T) { - inst := newInstance(&server.Definition{Name: "fake"}, t.TempDir(), t.TempDir(), context.Background(), nil) - path := filepath.Join(t.TempDir(), "seeded.go") - uri := protocol.URIFromPath(path) - severity := protocol.SeverityError - seeded := []protocol.Diagnostic{{ - Severity: &severity, - Message: "seeded error", - Range: protocol.Range{Start: protocol.Position{Line: 2}}, - }} - - for i := 0; i < 256; i++ { - // The no-process fixture: ready, with an open attempt channel and a - // seeded error for the requested path. - inst.mu.Lock() - inst.state = stateReady - inst.procDone = make(chan struct{}) - inst.mu.Unlock() - inst.diagMu.Lock() - inst.diagnostics = map[string][]protocol.Diagnostic{uri: seeded} - inst.diagMu.Unlock() - - var wg sync.WaitGroup - var sample [][]protocol.Diagnostic - var ok bool - - // Park the capture on the instance mutex, then create the transition - // while the capture is parked: releasing the mutex lets the parked - // capture own it first and reach its liveness check while the - // transition contends for the same mutex. - inst.mu.Lock() - wg.Add(1) - go func() { - defer wg.Done() - sample, ok = inst.captureDiagnostics([]string{path}) - }() - runtime.Gosched() - wg.Add(1) - go func() { - defer wg.Done() - // The transition: a new attempt starts and its reset clears the - // cache under the nested diagnostics lock. - inst.mu.Lock() - inst.state = stateStarting - inst.diagMu.Lock() - inst.diagnostics = make(map[string][]protocol.Diagnostic) - inst.diagMu.Unlock() - inst.mu.Unlock() - }() - inst.mu.Unlock() - wg.Wait() - - if !ok { - continue - } - testassert.Require(t, len(sample) == 1 && len(sample[0]) == 1 && sample[0][0].Message == "seeded error", "iteration %d: live sample = %+v, want the seeded error or the unavailable refusal", i, sample) - } -} - -func TestInstanceWaitReadyCanceledWhenStarting(t *testing.T) { - inst := newInstance(&server.Definition{Name: "fake"}, t.TempDir(), t.TempDir(), context.Background(), nil) - // A launch is already under way; waiting on it must observe the caller's - // cancellation rather than anything else. - inst.mu.Lock() - inst.state = stateStarting - inst.mu.Unlock() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - testassert.Equal(t, inst.waitReady(ctx), context.Canceled) -} diff --git a/internal/lsp/jsonrpc/client.go b/internal/lsp/jsonrpc/client.go deleted file mode 100644 index 64c9930c..00000000 --- a/internal/lsp/jsonrpc/client.go +++ /dev/null @@ -1,221 +0,0 @@ -package jsonrpc - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "sync" -) - -type ResponseError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (e *ResponseError) Error() string { - return fmt.Sprintf("jsonrpc error %d: %s", e.Code, e.Message) -} - -type rawResponse struct { - Result json.RawMessage - Error *ResponseError -} - -type message struct { - JSONRPC string `json:"jsonrpc"` - ID *int `json:"id,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *ResponseError `json:"error,omitempty"` -} - -type Client struct { - stdin io.WriteCloser - stdout *bufio.Reader - - writeMu sync.Mutex - - mu sync.Mutex - nextID int - pending map[int]chan rawResponse - closed bool - - OnNotify func(method string, params json.RawMessage) -} - -func NewClient(stdin io.WriteCloser, stdout io.Reader, stderr io.Reader, onNotify func(string, json.RawMessage)) *Client { - c := &Client{ - stdin: stdin, - stdout: bufio.NewReader(stdout), - pending: make(map[int]chan rawResponse), - OnNotify: onNotify, - } - if stderr != nil { - go io.Copy(io.Discard, stderr) - } - return c -} - -func (c *Client) Start() { - go c.readLoop() -} - -func (c *Client) readLoop() { - for { - body, err := ReadMessage(c.stdout) - if err != nil { - c.mu.Lock() - c.closed = true - for id, ch := range c.pending { - ch <- rawResponse{Error: &ResponseError{Code: -1, Message: "connection closed"}} - delete(c.pending, id) - } - c.mu.Unlock() - return - } - - var msg message - if err := json.Unmarshal(body, &msg); err != nil { - continue - } - - if msg.ID != nil && msg.Method == "" { - c.mu.Lock() - ch, ok := c.pending[*msg.ID] - if ok { - delete(c.pending, *msg.ID) - } - c.mu.Unlock() - if ok { - ch <- rawResponse{Result: msg.Result, Error: msg.Error} - } - } else if msg.Method != "" && msg.ID == nil { - if c.OnNotify != nil { - c.OnNotify(msg.Method, msg.Params) - } - } else if msg.Method != "" && msg.ID != nil { - c.respondToRequest(*msg.ID, msg.Method) - } - } -} - -func (c *Client) respondToRequest(id int, method string) { - resp := message{ - JSONRPC: "2.0", - ID: &id, - Result: json.RawMessage("null"), - } - body, _ := json.Marshal(resp) - c.writeMu.Lock() - _ = WriteMessage(c.stdin, body) - c.writeMu.Unlock() -} - -func (c *Client) Call(ctx context.Context, method string, params any) (json.RawMessage, error) { - c.mu.Lock() - if c.closed { - c.mu.Unlock() - return nil, fmt.Errorf("connection closed") - } - id := c.nextID - c.nextID++ - ch := make(chan rawResponse, 1) - c.pending[id] = ch - c.mu.Unlock() - - var rawParams json.RawMessage - if params != nil { - var err error - rawParams, err = json.Marshal(params) - if err != nil { - c.mu.Lock() - delete(c.pending, id) - c.mu.Unlock() - return nil, fmt.Errorf("marshal params: %w", err) - } - } - - msg := message{ - JSONRPC: "2.0", - ID: &id, - Method: method, - Params: rawParams, - } - body, _ := json.Marshal(msg) - - c.writeMu.Lock() - err := WriteMessage(c.stdin, body) - c.writeMu.Unlock() - if err != nil { - c.mu.Lock() - delete(c.pending, id) - c.mu.Unlock() - return nil, fmt.Errorf("write request: %w", err) - } - - select { - case <-ctx.Done(): - c.mu.Lock() - delete(c.pending, id) - c.mu.Unlock() - return nil, ctx.Err() - case resp := <-ch: - if resp.Error != nil { - return nil, resp.Error - } - return resp.Result, nil - } -} - -func (c *Client) Notify(method string, params any) error { - c.mu.Lock() - if c.closed { - c.mu.Unlock() - return fmt.Errorf("connection closed") - } - c.mu.Unlock() - - var rawParams json.RawMessage - if params != nil { - var err error - rawParams, err = json.Marshal(params) - if err != nil { - return fmt.Errorf("marshal params: %w", err) - } - } - - msg := message{ - JSONRPC: "2.0", - Method: method, - Params: rawParams, - } - body, _ := json.Marshal(msg) - - c.writeMu.Lock() - defer c.writeMu.Unlock() - return WriteMessage(c.stdin, body) -} - -func (c *Client) Close() { - c.mu.Lock() - if c.closed { - c.mu.Unlock() - return - } - c.closed = true - for id, ch := range c.pending { - ch <- rawResponse{Error: &ResponseError{Code: -1, Message: "client closed"}} - delete(c.pending, id) - } - c.mu.Unlock() - _ = c.stdin.Close() -} - -func (c *Client) IsClosed() bool { - c.mu.Lock() - defer c.mu.Unlock() - return c.closed -} diff --git a/internal/lsp/jsonrpc/client_test.go b/internal/lsp/jsonrpc/client_test.go deleted file mode 100644 index 3a3696fa..00000000 --- a/internal/lsp/jsonrpc/client_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package jsonrpc - -import ( - "bytes" - "context" - "encoding/json" - "io" - "strings" - "sync" - "testing" - "time" - - "github.com/MMinasyan/lightcode/internal/testassert" -) - -type writeCloserBuffer struct { - mu sync.Mutex - buf bytes.Buffer -} - -func (b *writeCloserBuffer) Write(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.Write(p) -} - -func (b *writeCloserBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.buf.String() -} - -func (b *writeCloserBuffer) Close() error { return nil } - -func TestClientCallSuccessAndServerError(t *testing.T) { - stdin := &writeCloserBuffer{} - stdoutR, stdoutW := io.Pipe() - client := NewClient(stdin, stdoutR, nil, nil) - client.Start() - defer client.Close() - - resultCh := make(chan struct { - data json.RawMessage - err error - }, 1) - go func() { - data, err := client.Call(context.Background(), "workspace/symbol", map[string]any{"query": "x"}) - resultCh <- struct { - data json.RawMessage - err error - }{data: data, err: err} - }() - waitForWrittenRequest(t, stdin, "workspace/symbol") - writeJSONRPCMessage(t, stdoutW, message{JSONRPC: "2.0", ID: intPtr(0), Result: json.RawMessage(`{"ok":true}`)}) - got := <-resultCh - testassert.Require(t, got.err == nil && string(got.data) == `{"ok":true}`, "Call success = %s, %v", got.data, got.err) - - go func() { - data, err := client.Call(context.Background(), "bad", nil) - resultCh <- struct { - data json.RawMessage - err error - }{data: data, err: err} - }() - waitForWrittenRequest(t, stdin, `"bad"`) - writeJSONRPCMessage(t, stdoutW, message{JSONRPC: "2.0", ID: intPtr(1), Error: &ResponseError{Code: -32000, Message: "boom"}}) - got = <-resultCh - testassert.Require(t, got.err != nil && strings.Contains(got.err.Error(), "boom"), "Call server error = %s, %v; want boom error", got.data, got.err) -} - -func TestClientNotifyAndOnNotify(t *testing.T) { - stdin := &writeCloserBuffer{} - stdoutR, stdoutW := io.Pipe() - notifyCh := make(chan string, 1) - client := NewClient(stdin, stdoutR, nil, func(method string, params json.RawMessage) { notifyCh <- method + ":" + string(params) }) - client.Start() - defer client.Close() - - testassert.NoError(t, client.Notify("initialized", map[string]any{"ok": true})) - if !strings.Contains(stdin.String(), "initialized") { - t.Fatalf("stdin after Notify = %q", stdin.String()) - } - writeJSONRPCMessage(t, stdoutW, message{JSONRPC: "2.0", Method: "window/logMessage", Params: json.RawMessage(`{"message":"hi"}`)}) - select { - case got := <-notifyCh: - testassert.Require(t, strings.Contains(got, "window/logMessage") && strings.Contains(got, "hi"), "notification = %q", got) - case <-time.After(time.Second): - t.Fatal("OnNotify not called") - } -} - -func TestClientCallContextCanceledAndClose(t *testing.T) { - stdin := &writeCloserBuffer{} - stdoutR, _ := io.Pipe() - client := NewClient(stdin, stdoutR, nil, nil) - client.Start() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - { - _, err := client.Call(ctx, "slow", nil) - testassert.Require(t, err != nil, "Call with canceled context error = nil, want error") - } - client.Close() - testassert.Require(t, client.IsClosed(), "IsClosed = false after Close") - { - err := client.Notify("x", nil) - testassert.Require(t, err != nil, "Notify after Close error = nil, want error") - } -} - -func waitForWrittenRequest(t *testing.T, buf *writeCloserBuffer, contains string) { - t.Helper() - deadline := time.Now().Add(time.Second) - for time.Now().Before(deadline) { - if strings.Contains(buf.String(), contains) { - return - } - time.Sleep(time.Millisecond) - } - t.Fatalf("buffer never contained %q: %q", contains, buf.String()) -} - -func writeJSONRPCMessage(t *testing.T, w io.Writer, msg message) { - t.Helper() - body, err := json.Marshal(msg) - testassert.NoError(t, err) - testassert.NoError(t, WriteMessage(w, body)) -} - -func intPtr(v int) *int { return &v } diff --git a/internal/lsp/jsonrpc/frame.go b/internal/lsp/jsonrpc/frame.go deleted file mode 100644 index 168ee994..00000000 --- a/internal/lsp/jsonrpc/frame.go +++ /dev/null @@ -1,47 +0,0 @@ -package jsonrpc - -import ( - "bufio" - "fmt" - "io" - "strconv" - "strings" -) - -func WriteMessage(w io.Writer, body []byte) error { - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(body)) - if _, err := io.WriteString(w, header); err != nil { - return err - } - _, err := w.Write(body) - return err -} - -func ReadMessage(r *bufio.Reader) ([]byte, error) { - var contentLength int - for { - line, err := r.ReadString('\n') - if err != nil { - return nil, fmt.Errorf("read header: %w", err) - } - line = strings.TrimRight(line, "\r\n") - if line == "" { - break - } - if strings.HasPrefix(line, "Content-Length: ") { - n, err := strconv.Atoi(strings.TrimPrefix(line, "Content-Length: ")) - if err != nil { - return nil, fmt.Errorf("parse Content-Length: %w", err) - } - contentLength = n - } - } - if contentLength == 0 { - return nil, fmt.Errorf("missing Content-Length header") - } - body := make([]byte, contentLength) - if _, err := io.ReadFull(r, body); err != nil { - return nil, fmt.Errorf("read body: %w", err) - } - return body, nil -} diff --git a/internal/lsp/jsonrpc/frame_test.go b/internal/lsp/jsonrpc/frame_test.go deleted file mode 100644 index 911a0e9b..00000000 --- a/internal/lsp/jsonrpc/frame_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package jsonrpc - -import ( - "bufio" - "bytes" - "strings" - "testing" - - "github.com/MMinasyan/lightcode/internal/testassert" -) - -func TestWriteReadMessageRoundTrip(t *testing.T) { - body := []byte(`{"jsonrpc":"2.0","id":1}`) - var buf bytes.Buffer - testassert.NoError(t, WriteMessage(&buf, body)) - got, err := ReadMessage(bufio.NewReader(&buf)) - testassert.NoError(t, err) - testassert.Require(t, string(got) == string(body), "body = %q, want %q", got, body) -} - -func TestReadMessageHeadersAndErrors(t *testing.T) { - got, err := ReadMessage(bufio.NewReader(strings.NewReader("X-Test: yes\r\nContent-Length: 2\r\n\r\n{}"))) - testassert.NoError(t, err) - testassert.Require(t, string(got) == "{}", "body = %q, want {}", got) - { - _, err := ReadMessage(bufio.NewReader(strings.NewReader("Header: nope\r\n\r\n{}"))) - testassert.Require(t, err != nil, "missing Content-Length error = nil, want error") - } - { - _, err := ReadMessage(bufio.NewReader(strings.NewReader("Content-Length: nope\r\n\r\n{}"))) - testassert.Require(t, err != nil, "bad Content-Length error = nil, want error") - } -} diff --git a/internal/lsp/lifetime_test.go b/internal/lsp/lifetime_test.go deleted file mode 100644 index e4e35e3a..00000000 --- a/internal/lsp/lifetime_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package lsp - -// The owner-lifetime contract tests: the install context threads through the -// manager, instance restarts derive their bounded start context from the -// context that started the instance, the shared client queries return -// observed caller cancellation with their partial content instead of -// swallowing it, and the canonical diagnostics read refuses unsafe leaves -// without substituted content. - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MMinasyan/lightcode/internal/lsp/server" - "github.com/MMinasyan/lightcode/internal/testassert" - "golang.org/x/sys/unix" -) - -func TestManagerInstallThreadsContext(t *testing.T) { - m := NewManager(t.TempDir(), t.TempDir()) - started := make(chan struct{}) - ctx, cancel := context.WithCancel(context.Background()) - def := &server.Definition{ - Name: "fake", - Install: func(installCtx context.Context, cacheDir string) error { - close(started) - <-installCtx.Done() - return installCtx.Err() - }, - } - - done := make(chan error, 1) - go func() { done <- m.install(ctx, def) }() - select { - case <-started: - case <-time.After(5 * time.Second): - t.Fatal("install never reached the installer") - } - cancel() - - select { - case err := <-done: - testassert.Require(t, errors.Is(err, context.Canceled), "install after cancellation = %v, want context.Canceled", err) - case <-time.After(5 * time.Second): - t.Fatal("install never returned after the owner context was canceled") - } -} - -// TestRestartDerivesFromInstanceLifetime pins the restart lifetime rule for -// the idle/shutdown restart: a retired instance whose lifetime was canceled -// cannot be resurrected from Background, because its launch context derives -// from the lifetime and is already done. start() refuses it at the pre-spawn -// check before any process is created, so the launch log stays at the initial -// launch. -func TestRestartDerivesFromInstanceLifetime(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - lifetime, cancel := context.WithCancel(context.Background()) - inst := newFakeServerInstanceWithLifetime(t, log, "", 0, 0, "", "", lifetime) - - testassert.NoError(t, inst.waitReady(context.Background())) - cancel() - inst.shutdown() - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - if state != stateShutdown { - t.Fatalf("state after retirement = %d (%s), want %d (shutdown)", state, stateName(state), stateShutdown) - } - - { - err := inst.waitReady(context.Background()) - testassert.Require(t, err != nil, "waitReady after lifetime cancellation restarted the server; want the restart to fail") - } - inst.mu.Lock() - state = inst.state - inst.mu.Unlock() - testassert.Require(t, state != stateReady, "the restart became ready under a canceled lifetime") - testassert.Equal(t, countLaunches(log), 1) -} - -// TestCrashRestartDerivesFromInstanceLifetime pins the crash-restart sibling: -// a ready instance whose lifetime is canceled and whose process then dies -// cannot be resurrected by the watcher's restart. -func TestCrashRestartDerivesFromInstanceLifetime(t *testing.T) { - log := filepath.Join(t.TempDir(), "launches.log") - lifetime, cancel := context.WithCancel(context.Background()) - inst := newFakeServerInstanceWithLifetime(t, log, "", 0, 0, "", "", lifetime) - - testassert.NoError(t, inst.waitReady(context.Background())) - cancel() - inst.mu.Lock() - cmd := inst.cmd - inst.mu.Unlock() - testassert.Require(t, cmd != nil && cmd.Process != nil, "ready instance has no process") - testassert.NoError(t, cmd.Process.Kill()) - // The watcher counts the crash and runs its restart, which must fail - // under the canceled lifetime and settle the instance back to idle. - waitForRestartSettled(t, inst) - { - err := inst.waitReady(context.Background()) - testassert.Require(t, err != nil, "waitReady after the crash restart became ready under a canceled lifetime; want the restart to fail") - } - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - testassert.Require(t, state != stateReady, "the crash restart became ready under a canceled lifetime") - testassert.Equal(t, countLaunches(log), 1) -} - -// waitForRestartSettled waits until the crash watcher has counted its restart -// and the instance has settled out of the killed attempt. -func waitForRestartSettled(t *testing.T, inst *instance) { - t.Helper() - deadline := time.Now().Add(10 * time.Second) - for { - inst.mu.Lock() - restarts, state := inst.restarts, inst.state - inst.mu.Unlock() - if restarts == 1 && state == stateIdle { - return - } - testassert.Require(t, !time.Now().After(deadline), "restart never settled: restarts=%d state=%d", restarts, state) - time.Sleep(10 * time.Millisecond) - } -} - -// TestWorkspaceSymbolReturnsCanceledNotSwallowed pins the cancellation -// observation at the waitReady point: a canceled caller gets the symbols -// collected so far (none) with the cancellation error, never the settled -// no-symbols content. -func TestWorkspaceSymbolReturnsCanceledNotSwallowed(t *testing.T) { - m := NewManager(t.TempDir(), t.TempDir()) - def := server.ForExtension(".go") - inst := newInstance(def, m.projectRoot, m.home, context.Background(), nil) - m.mu.Lock() - m.instances[def.Name] = inst - m.mu.Unlock() - // A launch is permanently under way: the caller's waitReady blocks until - // its own cancellation. - inst.mu.Lock() - inst.state = stateStarting - inst.mu.Unlock() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - client := NewClient(m) - content, err := client.WorkspaceSymbol(ctx, "x") - testassert.Require(t, errors.Is(err, context.Canceled), "WorkspaceSymbol canceled = (%q, %v), want the cancellation error", content, err) - testassert.Equal(t, content, "") -} - -// readyGoInstance registers one all-zero ready fake .go instance under a new -// Manager. Per-test files, request-log setup, instance-lock gates and queries -// stay outside. -func readyGoInstance(t *testing.T) (*instance, *Manager) { - t.Helper() - log := filepath.Join(t.TempDir(), "launches.log") - inst := newFakeServerInstance(t, log, "", 0, 0, "", "") - testassert.NoError(t, inst.waitReady(context.Background())) - m := NewManager(t.TempDir(), t.TempDir()) - def := server.ForExtension(".go") - m.mu.Lock() - m.instances[def.Name] = inst - m.mu.Unlock() - return inst, m -} - -// TestGetDiagnosticsCanonicalCancellationReturnsPartial pins the cancellation -// observation at the sample window: a canceled caller gets the could-not-check -// content accumulated so far with the cancellation error. The instance mutex -// gates the query — it cannot pass its first waitReady while the test holds -// the lock — so the cancellation is issued before the query can reach the -// sample window and the window's cancellation case, the only ready case when -// the query gets there, is its outcome. The healthy file synchronizes (no -// refusal line for it) while the missing leaf accumulates its refusal, so the -// returned partial proves the query reached the window instead of the -// nothing-synchronized gate. -func TestGetDiagnosticsCanonicalCancellationReturnsPartial(t *testing.T) { - inst, m := readyGoInstance(t) - - dir := t.TempDir() - healthy := filepath.Join(dir, "healthy.go") - testassert.NoError(t, os.WriteFile(healthy, []byte("package healthy\n"), 0o600)) - missing := filepath.Join(dir, "missing.go") - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - done := make(chan struct{}) - inst.mu.Lock() - go func() { - defer close(done) - client := NewClient(m) - content, err := client.GetDiagnosticsCanonical(ctx, []string{missing, healthy}) - testassert.Check(t, errors.Is(err, context.Canceled), "GetDiagnosticsCanonical canceled = (%q, %v), want the cancellation error", content, err) - testassert.Check(t, strings.Contains(content, "(could not check)"), "GetDiagnosticsCanonical canceled content = %q, want the partial could-not-check line", content) - testassert.Check(t, strings.Contains(content, "missing.go") && !strings.Contains(content, "healthy.go"), "GetDiagnosticsCanonical canceled content = %q, want the missing leaf's refusal only", content) - }() - cancel() - inst.mu.Unlock() - - select { - case <-done: - case <-time.After(10 * time.Second): - t.Fatal("the canceled query never settled") - } -} - -// TestGetDiagnosticsCanonicalRefusesReplacedSymlink pins the canonical read: -// a file replaced by a symlink fails the no-follow open, reports the retained -// could-not-check line through the all-failed error, and never sends the -// substituted content to the server. -func TestGetDiagnosticsCanonicalRefusesReplacedSymlink(t *testing.T) { - reqLog := filepath.Join(t.TempDir(), "requests.log") - t.Setenv("FAKE_LSP_REQUESTS_LOG", reqLog) - _, m := readyGoInstance(t) - - dir := t.TempDir() - outside := filepath.Join(t.TempDir(), "outside.go") - testassert.NoError(t, os.WriteFile(outside, []byte("package outside\n"), 0o600)) - swapped := filepath.Join(dir, "swapped.go") - testassert.NoError(t, os.WriteFile(swapped, []byte("package swapped\n"), 0o600)) - testassert.NoError(t, os.Remove(swapped)) - testassert.NoError(t, os.Symlink(outside, swapped)) - - client := NewClient(m) - content, err := client.GetDiagnosticsCanonical(context.Background(), []string{swapped}) - testassert.Require(t, err != nil && content == "", "GetDiagnosticsCanonical = (%q, %v), want the all-failed error for the replaced symlink", content, err) - testassert.Require(t, strings.Contains(err.Error(), "(could not check)"), "GetDiagnosticsCanonical error = %q, want the could-not-check refusal line", err) - data, err := os.ReadFile(reqLog) - testassert.NoError(t, err) - testassert.Require(t, !strings.Contains(string(data), "didOpen") && !strings.Contains(string(data), "didChange"), "canonical read sent document sync for the symlink leaf: %s", data) -} - -// TestGetDiagnosticsCanonicalWriterlessFIFOIsCouldNotCheck pins the -// non-blocking leaf open on the canonical read: a writerless FIFO leaf -// cannot block the openat — the no-follow open settles immediately, none -// synchronizes, and the retained could-not-check line is the all-failed error. -func TestGetDiagnosticsCanonicalWriterlessFIFOIsCouldNotCheck(t *testing.T) { - _, m := readyGoInstance(t) - - fifo := filepath.Join(t.TempDir(), "fifo.go") - if err := unix.Mkfifo(fifo, 0o600); err != nil { - t.Skipf("mkfifo unavailable: %v", err) - } - - type outcome struct { - content string - err error - } - done := make(chan outcome, 1) - go func() { - client := NewClient(m) - content, err := client.GetDiagnosticsCanonical(context.Background(), []string{fifo}) - done <- outcome{content, err} - }() - select { - case got := <-done: - testassert.Require(t, got.err != nil && got.content == "", "GetDiagnosticsCanonical writerless FIFO = (%q, %v), want the all-failed error", got.content, got.err) - testassert.Require(t, strings.Contains(got.err.Error(), "(could not check)"), "GetDiagnosticsCanonical error = %q, want the could-not-check refusal line", got.err) - case <-time.After(5 * time.Second): - t.Fatal("GetDiagnosticsCanonical blocked on the writerless FIFO leaf; want the could-not-check line") - } -} - -// TestGetDiagnosticsCanonicalRefusedDocumentsAreCouldNotCheck pins the -// usable-partial rule for refused documents: a vanished leaf and an -// unsupported path can contribute no cached diagnostics, while a -// synchronizable regular sibling still produces the explicit failed-path lines -// and the sampled result. -func TestGetDiagnosticsCanonicalRefusedDocumentsAreCouldNotCheck(t *testing.T) { - _, m := readyGoInstance(t) - - dir := t.TempDir() - healthy := filepath.Join(dir, "healthy.go") - testassert.NoError(t, os.WriteFile(healthy, []byte("package healthy\n"), 0o600)) - missing := filepath.Join(dir, "gone.go") - unsupported := filepath.Join(dir, "notes.txt") - testassert.NoError(t, os.WriteFile(unsupported, []byte("notes\n"), 0o600)) - client := NewClient(m) - got, err := client.GetDiagnosticsCanonical(context.Background(), []string{missing, unsupported, healthy}) - testassert.NoError(t, err) - testassert.Require(t, strings.Contains(got, "gone.go") && strings.Contains(got, "notes.txt") && strings.Contains(got, "(could not check)"), "GetDiagnosticsCanonical = %q, want the failed-path lines for the refused documents; GetDiagnosticsCanonical = %q, want the could-not-check rendering of the refused paths", got, got) -} - -// TestGetDiagnosticsCanonicalRefusesHardlinkedLeaf pins the hardlink sibling -// of the canonical read: a leaf whose link count is greater than one fails the -// no-follow open, reports the retained could-not-check line through the -// all-failed error, and never sends the aliased content to the server. -func TestGetDiagnosticsCanonicalRefusesHardlinkedLeaf(t *testing.T) { - reqLog := filepath.Join(t.TempDir(), "requests.log") - t.Setenv("FAKE_LSP_REQUESTS_LOG", reqLog) - _, m := readyGoInstance(t) - - dir := t.TempDir() - original := filepath.Join(dir, "original.go") - testassert.NoError(t, os.WriteFile(original, []byte("package original\n"), 0o600)) - aliased := filepath.Join(dir, "aliased.go") - testassert.NoError(t, os.Link(original, aliased)) - - client := NewClient(m) - content, err := client.GetDiagnosticsCanonical(context.Background(), []string{aliased}) - testassert.Require(t, err != nil && content == "", "GetDiagnosticsCanonical = (%q, %v), want the all-failed error for the hardlinked leaf", content, err) - testassert.Require(t, strings.Contains(err.Error(), "(could not check)") && strings.Contains(err.Error(), "hardlink"), "GetDiagnosticsCanonical error = %q, want the could-not-check refusal line", err) - data, err := os.ReadFile(reqLog) - testassert.NoError(t, err) - testassert.Require(t, !strings.Contains(string(data), "didOpen") && !strings.Contains(string(data), "didChange"), "canonical read sent document sync for the hardlinked leaf: %s", data) -} diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go deleted file mode 100644 index 0974d975..00000000 --- a/internal/lsp/manager.go +++ /dev/null @@ -1,198 +0,0 @@ -package lsp - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sync" - - "github.com/MMinasyan/lightcode/internal/lsp/server" -) - -type Manager struct { - mu sync.Mutex - instances map[string]*instance - projectRoot string - home string - onWarning func(kind, message string) - // closed is set by ShutdownAll so a server still starting during shutdown is - // torn down instead of registered after the shutdown snapshot. - closed bool -} - -func NewManager(projectRoot, home string) *Manager { - return &Manager{ - instances: make(map[string]*instance), - projectRoot: projectRoot, - home: home, - } -} - -func (m *Manager) SetWarningHandler(fn func(kind, message string)) { - m.mu.Lock() - defer m.mu.Unlock() - m.onWarning = fn -} - -func (m *Manager) Detect(ctx context.Context) { - defs := server.DetectFromProject(m.projectRoot) - var wg sync.WaitGroup - for _, def := range defs { - def := def - wg.Add(1) - go func() { - defer wg.Done() - m.startServer(ctx, def) - }() - } - wg.Wait() -} - -// startServerProbe is a test seam invoked immediately after the provisional -// map insertion and m.mu release, immediately before inst.start: the -// deterministic barrier after provisional mapping. It exists solely to hold a -// start in the post-handoff window; nil in production. -var startServerProbe = func() {} - -// startServer launches one detected server. The instance is provisionally -// mapped before its start: the mapping is the admission handoff that lets a -// concurrent ShutdownAll snapshot it, so a server that starts after close -// began is marked terminal and self-reaps instead of becoming an untracked -// process. A closed manager rejects the start before instance.start, so no -// process is ever launched after close. -func (m *Manager) startServer(ctx context.Context, def *server.Definition) { - // One short early admission check before any binary resolution, install, - // instance construction, or resource/process start: a closed manager - // rejects immediately. The map-time closed check and provisional mapping - // below stay exactly as-is — close can race a pre-admitted slow - // installation, and the mapping is what lets the close snapshot catch an - // admitted start. - m.mu.Lock() - closed := m.closed - m.mu.Unlock() - if closed { - return - } - binary := server.ResolveBinary(m.home, def) - if binary == "" { - if err := m.install(ctx, def); err != nil { - m.emitWarning("lsp_install_failed", - fmt.Sprintf("Failed to install %s language server: %v", def.Name, err)) - return - } - binary = server.ResolveBinary(m.home, def) - if binary == "" { - m.emitWarning("lsp_install_failed", - fmt.Sprintf("Installed %s but binary not found", def.Name)) - return - } - } - - inst := newInstance(def, m.projectRoot, m.home, ctx, func(name string) { - m.emitWarning("lsp_server_unavailable", - fmt.Sprintf("Language server %s has crashed repeatedly and is unavailable.", name)) - }) - - m.mu.Lock() - if m.closed { - m.mu.Unlock() - // Permanent close won before this start was admitted: reject before - // instance.start, so no process is launched after close. - return - } - m.instances[def.Name] = inst - m.mu.Unlock() - - // The post-handoff probe: the deterministic barrier after provisional - // mapping. It never runs in production. - startServerProbe() - - if _, err := inst.start(ctx); err != nil { - m.mu.Lock() - if m.instances[def.Name] == inst { - delete(m.instances, def.Name) - } - m.mu.Unlock() - m.emitWarning("lsp_server_unavailable", - fmt.Sprintf("Failed to start %s: %v", def.Name, err)) - return - } -} - -func (m *Manager) ForFile(path string) *instance { - ext := filepath.Ext(path) - if ext == "" { - return nil - } - def := server.ForExtension(ext) - if def == nil { - return nil - } - m.mu.Lock() - inst := m.instances[def.Name] - m.mu.Unlock() - return inst -} - -func (m *Manager) AllInstances() []*instance { - m.mu.Lock() - defer m.mu.Unlock() - out := make([]*instance, 0, len(m.instances)) - for _, inst := range m.instances { - out = append(out, inst) - } - return out -} - -// CloseAdmission stops new server starts without waiting: under m.mu it sets -// closed, snapshots every mapped instance, releases the lock, and marks each -// instance's admission closed (nonblocking). It is the owner's LSP-admission -// boundary, taken at the start of shutdown; ShutdownAll then kills and reaps -// the snapshot. -func (m *Manager) CloseAdmission() { - m.mu.Lock() - m.closed = true - instances := make([]*instance, 0, len(m.instances)) - for _, inst := range m.instances { - instances = append(instances, inst) - } - m.mu.Unlock() - - for _, inst := range instances { - inst.closeAdmission() - } -} - -// ShutdownAll closes LSP admission and tears down every mapped instance for -// the owner's shutdown: each server's process is killed and reaped directly -// instead of negotiating a shutdown, because the owner is exiting on every -// host and the servers are our own child processes. Nothing here waits on a -// server's answer, so one unresponsive server cannot delay the teardown of -// the others. -func (m *Manager) ShutdownAll() { - m.CloseAdmission() - for _, inst := range m.AllInstances() { - inst.killForTeardown() - } -} - -func (m *Manager) install(ctx context.Context, def *server.Definition) error { - if def.Install == nil { - return fmt.Errorf("%s must be installed via your system package manager (apt, dnf, pacman, brew, etc.)", def.Name) - } - cacheDir := server.CacheDir(m.home, def.Name) - if err := os.MkdirAll(cacheDir, 0755); err != nil { - return err - } - return def.Install(ctx, cacheDir) -} - -func (m *Manager) emitWarning(kind, message string) { - m.mu.Lock() - fn := m.onWarning - m.mu.Unlock() - if fn != nil { - fn(kind, message) - } -} diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go deleted file mode 100644 index a0539bfa..00000000 --- a/internal/lsp/manager_test.go +++ /dev/null @@ -1,359 +0,0 @@ -package lsp - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" - "syscall" - "testing" - "time" - - "github.com/MMinasyan/lightcode/internal/lsp/server" - "github.com/MMinasyan/lightcode/internal/testassert" -) - -func TestManagerForFileAndAllInstances(t *testing.T) { - m := NewManager(t.TempDir(), t.TempDir()) - { - got := m.ForFile("main.go") - testassert.Require(t, got == nil, "ForFile without instance = %+v, want nil", got) - } - { - got := m.ForFile("README") - testassert.Require(t, got == nil, "ForFile without extension = %+v, want nil", got) - } - { - got := m.ForFile("file.unknown") - testassert.Require(t, got == nil, "ForFile unknown extension = %+v, want nil", got) - } - - def := server.ForExtension(".go") - inst := newInstance(def, m.projectRoot, m.home, context.Background(), nil) - m.instances[def.Name] = inst - testassert.Equal(t, m.ForFile("main.go"), inst) - all := m.AllInstances() - testassert.Require(t, len(all) == 1 && all[0] == inst, "AllInstances = %+v, want inserted instance", all) -} - -func TestManagerConcurrentHandlerAndInstances(t *testing.T) { - m := NewManager(t.TempDir(), t.TempDir()) - def := server.ForExtension(".go") - inst := newInstance(def, m.projectRoot, m.home, context.Background(), nil) - m.mu.Lock() - m.instances[def.Name] = inst - m.mu.Unlock() - - var warnings atomic.Int64 - var wg sync.WaitGroup - for i := 0; i < 8; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < 100; j++ { - m.SetWarningHandler(func(string, string) { warnings.Add(1) }) - m.emitWarning("kind", "message") - _ = m.ForFile("main.go") - _ = m.AllInstances() - } - }() - } - wg.Wait() - if warnings.Load() == 0 { - t.Fatalf("the warning handler was not called: warnings=%d", warnings.Load()) - } -} - -func TestManagerWarningHandler(t *testing.T) { - m := NewManager(t.TempDir(), t.TempDir()) - var warningKind, warningMessage string - m.SetWarningHandler(func(kind, message string) { warningKind, warningMessage = kind, message }) - - m.emitWarning("kind", "message") - testassert.Require(t, warningKind == "kind" && warningMessage == "message", "warning = %q/%q", warningKind, warningMessage) -} - -func TestManagerShutdownAllKillsUnresponsiveServers(t *testing.T) { - // Two servers never answer the shutdown request and one answers it, so - // whichever order the manager map is walked, the teardown meets an - // unresponsive server and must still tear down every instance. - type server struct { - inst *instance - pid int - stubborn bool - } - servers := make([]server, 3) - for i := range servers { - log := filepath.Join(t.TempDir(), "launches.log") - stubborn := "" - if i < 2 { - stubborn = "1" - } - inst := newFakeServerInstance(t, log, "", 0, 0, "", stubborn) - { - err := inst.waitReady(context.Background()) - testassert.Require(t, err == nil, "waitReady server %d: %v", i, err) - } - servers[i] = server{inst: inst, pid: inst.cmd.Process.Pid, stubborn: stubborn == "1"} - } - - m := NewManager(t.TempDir(), t.TempDir()) - for i, s := range servers { - // The manager map is keyed by def name in production, but any key - // works here; all three definitions are named "fake" so the shared - // fake binary resolves. - m.instances[fmt.Sprintf("fake-%d", i)] = s.inst - } - - start := time.Now() - m.ShutdownAll() - elapsed := time.Since(start) - t.Logf("ShutdownAll with %d unresponsive servers took %s", 2, elapsed) - // The negotiate path waits shutdownWait per unresponsive instance before - // killing it; the teardown must kill instead, well below that. - testassert.Require(t, elapsed < 2*time.Second, "ShutdownAll took %s, want a prompt teardown (the negotiate path waited %s per unresponsive server)", elapsed, shutdownWait) - - for i, s := range servers { - // The process must be gone and reaped, not merely left to the owner's - // own process exit: kill(pid, 0) returns ESRCH only once reaped. - { - err := syscall.Kill(s.pid, 0) - testassert.Check(t, errors.Is(err, syscall.ESRCH), "server %d (stubborn=%v) process %d still alive after ShutdownAll: kill err = %v", i, s.stubborn, s.pid, err) - } - s.inst.mu.Lock() - state := s.inst.state - rpc := s.inst.rpc - cmd := s.inst.cmd - s.inst.mu.Unlock() - // Permanent close lands every instance in stateFailed (final and - // unclaimable), not the restartable idle-retirement shutdown state. - if state != stateFailed { - t.Errorf("server %d state = %d (%s), want %d (failed)", i, state, stateName(state), stateFailed) - } - testassert.Check(t, rpc == nil && cmd == nil, "server %d handles not cleared: rpc = %v, cmd = %v", i, rpc, cmd) - } -} - -func TestStartServerAfterClosedRejectsBeforeStart(t *testing.T) { - // A server whose start would only be discovered after the fact must be - // rejected before instance.start: permanent close refuses before OS start, - // so no process is ever launched after close. The old launched-then-killed - // path is gone. - home, pidfile, _, def := newPIDRecordingFakeServer(t, "0") - - m := NewManager(t.TempDir(), home) - m.mu.Lock() - m.closed = true - m.mu.Unlock() - - start := time.Now() - m.startServer(context.Background(), def) - elapsed := time.Since(start) - t.Logf("startServer after closed took %s", elapsed) - // Rejection happens before any start or negotiation, so it is prompt. - testassert.Require(t, elapsed < 2*time.Second, "startServer after closed took %s, want an immediate rejection", elapsed) - - // No process was ever launched: the wrapper never wrote the pidfile. - { - _, err := os.Stat(pidfile) - testassert.Require(t, os.IsNotExist(err), "pidfile exists after rejected start: a process was launched after close (stat err = %v)", err) - } - // And no instance was registered. - m.mu.Lock() - _, mapped := m.instances[def.Name] - m.mu.Unlock() - testassert.Require(t, !mapped, "startServer after closed registered an instance") -} - -// TestStartServerAfterClosedSkipsInstall covers the LSP close-before-install -// sibling: a server whose binary is missing and whose Install would otherwise -// run must be rejected by the early admission check before any resource or -// process start — no Install call, no instance mapping, no warning, and no -// process launch. The map-time closed check stays as the positive sibling for -// starts admitted before close (TestStartServerCloseRaceMapped -// InstanceIsTornDown). -func TestStartServerAfterClosedSkipsInstall(t *testing.T) { - home := t.TempDir() // no fake binary anywhere: ResolveBinary finds nothing - installed := false - def := &server.Definition{ - Name: "missing", - Command: "missing-lsp", - Args: []string{}, - Install: func(ctx context.Context, cacheDir string) error { - installed = true - return nil - }, - } - - m := NewManager(t.TempDir(), home) - var warnings atomic.Int64 - m.SetWarningHandler(func(string, string) { warnings.Add(1) }) - - m.CloseAdmission() - start := time.Now() - m.startServer(context.Background(), def) - elapsed := time.Since(start) - t.Logf("startServer after CloseAdmission with missing binary took %s", elapsed) - testassert.Require(t, elapsed < 2*time.Second, "startServer after CloseAdmission took %s, want an immediate rejection", elapsed) - testassert.Require(t, !installed, "Install was called after CloseAdmission: the early admission check did not reject") - if warnings.Load() != 0 { - t.Fatalf("warnings=%d after rejected start, want none", warnings.Load()) - } - m.mu.Lock() - _, mapped := m.instances[def.Name] - m.mu.Unlock() - testassert.Require(t, !mapped, "startServer after CloseAdmission mapped an instance") -} - -// TestStartServerCloseRaceProvisionalMappingHoldsTerminalAdmission proves the -// deterministic barrier after provisional mapping: with the start held -// at the post-handoff probe, permanent close (ShutdownAll) returns promptly -// while the start is still blocked, and once released the start cannot launch -// an OS process (no pidfile) because the mapped instance is already terminal -// stateFailed. Removing the provisional map insertion makes close miss the -// instance and the released start launch it — the regression this test pins. -func TestStartServerCloseRaceProvisionalMappingHoldsTerminalAdmission(t *testing.T) { - home, pidfile, log, def := newPIDRecordingFakeServer(t, "0") - - m := NewManager(t.TempDir(), home) - t.Cleanup(func() { m.ShutdownAll() }) - - // Hold the start at the post-handoff probe. - probeEntered := make(chan struct{}) - release := make(chan struct{}) - var once sync.Once - oldProbe := startServerProbe - startServerProbe = func() { once.Do(func() { close(probeEntered) }); <-release } - t.Cleanup(func() { startServerProbe = oldProbe }) - - started := make(chan struct{}) - go func() { - m.startServer(context.Background(), def) - close(started) - }() - select { - case <-probeEntered: - case <-time.After(5 * time.Second): - t.Fatal("startServer never reached the post-handoff probe") - } - - // Permanent close while the start is blocked after the handoff must - // return promptly and must not wait on the probe. - start := time.Now() - m.ShutdownAll() - elapsed := time.Since(start) - t.Logf("ShutdownAll with a probe-blocked start took %s", elapsed) - testassert.Require(t, elapsed < 2*time.Second, "ShutdownAll took %s, want a prompt permanent close", elapsed) - - close(release) - select { - case <-started: - case <-time.After(5 * time.Second): - t.Fatal("startServer did not return after the probe was released") - } - - // The released start could not launch an OS process: the mapped instance - // was already terminal, so inst.start refused before cmd.Start. - { - _, err := os.Stat(pidfile) - testassert.Require(t, os.IsNotExist(err), "pidfile exists: the released start launched a process after permanent close (stat err = %v)", err) - } - testassert.Equal(t, countLaunches(log), 0) - m.mu.Lock() - inst := m.instances[def.Name] - m.mu.Unlock() - testassert.Require(t, inst != nil, "provisionally mapped instance missing after permanent close") - inst.mu.Lock() - state := inst.state - inst.mu.Unlock() - if state != stateFailed { - t.Fatalf("mapped instance state = %d (%s), want %d (failed)", state, stateName(state), stateFailed) - } -} - -// TestStartServerCloseRaceMappedInstanceIsTornDown covers the "LSP start wins -// before permanent close" axis: a server whose start was admitted (the -// instance is provisionally mapped) before ShutdownAll begins is in the close -// snapshot, so ShutdownAll must kill and reap its process before returning — -// even though the start itself is still in flight (blocked in initialize). -// On the old branch the instance was mapped only after start completed, so -// ShutdownAll's snapshot missed it and the process survived the call. -func TestStartServerCloseRaceMappedInstanceIsTornDown(t *testing.T) { - requestsLog := filepath.Join(t.TempDir(), "requests.log") - t.Setenv("FAKE_LSP_REQUESTS_LOG", requestsLog) - // init_delay holds the start in the initialize call after the process has - // launched and the instance has been provisionally mapped. - home, pidfile, _, def := newPIDRecordingFakeServer(t, "2") - - m := NewManager(t.TempDir(), home) - started := make(chan struct{}) - go func() { - m.startServer(context.Background(), def) - close(started) - }() - t.Cleanup(func() { - m.ShutdownAll() - select { - case <-started: - case <-time.After(5 * time.Second): - t.Error("server start did not finish after teardown") - } - }) - - // Wait for the provisional mapping: the admission handoff that puts the - // mid-start instance into the close snapshot. - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - m.mu.Lock() - _, mapped := m.instances[def.Name] - m.mu.Unlock() - if mapped { - break - } - time.Sleep(10 * time.Millisecond) - } - m.mu.Lock() - _, mapped := m.instances[def.Name] - m.mu.Unlock() - testassert.Require(t, mapped, "startServer never mapped the instance") - // The wrapper's pidfile can precede handle installation. Receiving - // initialize proves cmd/procDone are installed and the delayed call is - // underway, so teardown owns the process this row asserts was reaped. - var pid int - deadline = time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - requests, _ := os.ReadFile(requestsLog) - if data, err := os.ReadFile(pidfile); err == nil && strings.Contains(string(requests), "initialize\n") { - if p, perr := strconv.Atoi(strings.TrimSpace(string(data))); perr == nil && p > 0 { - pid = p - break - } - } - time.Sleep(10 * time.Millisecond) - } - testassert.Require(t, pid != 0, "language server never reached initialization") - - start := time.Now() - m.ShutdownAll() - elapsed := time.Since(start) - t.Logf("ShutdownAll with a mid-start mapped server took %s", elapsed) - testassert.Require(t, elapsed < 2*time.Second, "ShutdownAll took %s, want a prompt teardown (no negotiation with the unresponsive server)", elapsed) - - // The mapped mid-start instance's process is killed and reaped inside the - // call: kill(pid, 0) returns ESRCH only once reaped. - { - err := syscall.Kill(pid, 0) - testassert.Require(t, errors.Is(err, syscall.ESRCH), "mid-start server process %d still alive after ShutdownAll returned (kill err = %v)", pid, err) - } - // The instance ended terminal and the failed start cleans the mapping. - <-started - m.mu.Lock() - _, stillMapped := m.instances[def.Name] - m.mu.Unlock() - testassert.Require(t, !stillMapped, "failed start left its instance mapped") -} diff --git a/internal/lsp/protocol/types.go b/internal/lsp/protocol/types.go deleted file mode 100644 index 211de569..00000000 --- a/internal/lsp/protocol/types.go +++ /dev/null @@ -1,230 +0,0 @@ -package protocol - -import ( - "encoding/json" - "net/url" - "strings" -) - -// Initialize - -type InitializeParams struct { - ProcessID *int `json:"processId"` - RootURI string `json:"rootUri"` - Capabilities ClientCapabilities `json:"capabilities"` - WorkspaceFolders []WorkspaceFolder `json:"workspaceFolders,omitempty"` -} - -type ClientCapabilities struct { - TextDocument TextDocumentClientCapabilities `json:"textDocument"` - Workspace WorkspaceClientCapabilities `json:"workspace,omitempty"` - Window WindowClientCapabilities `json:"window,omitempty"` -} - -type WorkspaceClientCapabilities struct { - WorkspaceFolders bool `json:"workspaceFolders,omitempty"` -} - -type WorkspaceFolder struct { - URI string `json:"uri"` - Name string `json:"name"` -} - -type TextDocumentClientCapabilities struct { - Definition GenericCapability `json:"definition,omitempty"` - References GenericCapability `json:"references,omitempty"` - Hover GenericCapability `json:"hover,omitempty"` - Implementation GenericCapability `json:"implementation,omitempty"` - PublishDiagnostics struct { - RelatedInformation bool `json:"relatedInformation,omitempty"` - } `json:"publishDiagnostics,omitempty"` -} - -type GenericCapability struct { - DynamicRegistration bool `json:"dynamicRegistration,omitempty"` -} - -type WindowClientCapabilities struct { - WorkDoneProgress bool `json:"workDoneProgress"` -} - -type InitializeResult struct { - Capabilities ServerCapabilities `json:"capabilities"` -} - -type ServerCapabilities struct { - DefinitionProvider any `json:"definitionProvider,omitempty"` - ReferencesProvider any `json:"referencesProvider,omitempty"` - HoverProvider any `json:"hoverProvider,omitempty"` - ImplementationProvider any `json:"implementationProvider,omitempty"` - DiagnosticProvider any `json:"diagnosticProvider,omitempty"` -} - -// Position and Location - -type Position struct { - Line int `json:"line"` - Character int `json:"character"` -} - -type Range struct { - Start Position `json:"start"` - End Position `json:"end"` -} - -type Location struct { - URI string `json:"uri"` - Range Range `json:"range"` -} - -type LocationLink struct { - TargetURI string `json:"targetUri"` - TargetRange Range `json:"targetRange"` - TargetSelectionRange Range `json:"targetSelectionRange"` -} - -// TextDocument params - -type TextDocumentIdentifier struct { - URI string `json:"uri"` -} - -type TextDocumentPositionParams struct { - TextDocument TextDocumentIdentifier `json:"textDocument"` - Position Position `json:"position"` -} - -type ReferenceParams struct { - TextDocument TextDocumentIdentifier `json:"textDocument"` - Position Position `json:"position"` - Context ReferenceContext `json:"context"` -} - -type ReferenceContext struct { - IncludeDeclaration bool `json:"includeDeclaration"` -} - -// TextDocument items (for didOpen) - -type TextDocumentItem struct { - URI string `json:"uri"` - LanguageID string `json:"languageId"` - Version int `json:"version"` - Text string `json:"text"` -} - -type DidOpenTextDocumentParams struct { - TextDocument TextDocumentItem `json:"textDocument"` -} - -// Hover - -type HoverResult struct { - Contents HoverContents `json:"contents"` - Range *Range `json:"range,omitempty"` -} - -type HoverContents struct { - Value string -} - -func (h *HoverContents) UnmarshalJSON(data []byte) error { - var mc MarkupContent - if err := json.Unmarshal(data, &mc); err == nil && mc.Value != "" { - h.Value = mc.Value - return nil - } - var s string - if err := json.Unmarshal(data, &s); err == nil { - h.Value = s - return nil - } - var arr []json.RawMessage - if err := json.Unmarshal(data, &arr); err == nil && len(arr) > 0 { - var first MarkupContent - if err := json.Unmarshal(arr[0], &first); err == nil && first.Value != "" { - h.Value = first.Value - return nil - } - var fs string - if err := json.Unmarshal(arr[0], &fs); err == nil { - h.Value = fs - return nil - } - } - h.Value = string(data) - return nil -} - -type MarkupContent struct { - Kind string `json:"kind"` - Value string `json:"value"` -} - -// Diagnostics - -type PublishDiagnosticsParams struct { - URI string `json:"uri"` - Diagnostics []Diagnostic `json:"diagnostics"` -} - -type Diagnostic struct { - Range Range `json:"range"` - Severity *int `json:"severity,omitempty"` - Message string `json:"message"` - Source string `json:"source,omitempty"` -} - -const ( - SeverityError = 1 - SeverityWarning = 2 - SeverityInfo = 3 - SeverityHint = 4 -) - -// Progress - -type ProgressParams struct { - Token json.RawMessage `json:"token"` - Value json.RawMessage `json:"value"` -} - -type WorkDoneProgressValue struct { - Kind string `json:"kind"` -} - -// Workspace Symbol - -type WorkspaceSymbolParams struct { - Query string `json:"query"` -} - -type SymbolInformation struct { - Name string `json:"name"` - Kind int `json:"kind"` - Location Location `json:"location"` -} - -var SymbolKindName = map[int]string{ - 1: "File", 2: "Module", 3: "Namespace", 4: "Package", 5: "Class", - 6: "Method", 7: "Property", 8: "Field", 9: "Constructor", 10: "Enum", - 11: "Interface", 12: "Function", 13: "Variable", 14: "Constant", - 15: "String", 16: "Number", 17: "Boolean", 18: "Array", 19: "Object", - 20: "Key", 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event", - 25: "Operator", 26: "TypeParameter", -} - -// Helpers - -func URIFromPath(absPath string) string { - u := url.URL{Scheme: "file", Path: absPath} - return u.String() -} - -func PathFromURI(uri string) string { - u, err := url.Parse(uri) - if err != nil { - return strings.TrimPrefix(uri, "file://") - } - return u.Path -} diff --git a/internal/lsp/protocol/types_test.go b/internal/lsp/protocol/types_test.go deleted file mode 100644 index 422087b0..00000000 --- a/internal/lsp/protocol/types_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package protocol - -import ( - "encoding/json" - "testing" - - "github.com/MMinasyan/lightcode/internal/testassert" -) - -func TestURIPathRoundTrip(t *testing.T) { - path := "/tmp/light code/файл.go" - uri := URIFromPath(path) - testassert.Equal(t, PathFromURI(uri), path) - testassert.Equal(t, PathFromURI("file:///tmp/a%20b.go"), "/tmp/a b.go") - { - got := PathFromURI("not a uri") - testassert.Require(t, got != "", "PathFromURI invalid URI returned empty path") - } -} - -func TestHoverContentsUnmarshalVariants(t *testing.T) { - tests := []struct{ name, input, want string }{ - {"string", `"plain"`, "plain"}, - {"markup", `{"kind":"markdown","value":"**doc**"}`, "**doc**"}, - {"array-markup", `[{"kind":"markdown","value":"first"}]`, "first"}, - {"array-string", `["first"]`, "first"}, - {"fallback", `{"kind":"markdown"}`, `{"kind":"markdown"}`}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var got HoverContents - testassert.NoError(t, json.Unmarshal([]byte(tt.input), &got)) - testassert.Equal(t, got.Value, tt.want) - }) - } -} diff --git a/internal/lsp/server/definitions.go b/internal/lsp/server/definitions.go deleted file mode 100644 index 3edf977f..00000000 --- a/internal/lsp/server/definitions.go +++ /dev/null @@ -1,193 +0,0 @@ -package server - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -type Definition struct { - Name string - Command string - Args []string - Extensions []string - Markers []string - LanguageID string - // Install installs the server binary into its cache directory. The - // context is the owner's lifetime: cancellation kills the install and - // returns the cancellation error. - Install func(ctx context.Context, cacheDir string) error -} - -var definitions = []Definition{ - { - Name: "gopls", - Command: "gopls", - Args: []string{"serve"}, - Extensions: []string{".go"}, - Markers: []string{"go.mod"}, - LanguageID: "go", - Install: func(ctx context.Context, cacheDir string) error { - cmd := exec.CommandContext(ctx, "go", "install", "golang.org/x/tools/gopls@latest") - cmd.Env = append(os.Environ(), "GOBIN="+cacheDir) - cmd.Stdout = nil - cmd.Stderr = nil - return runWithTimeout(ctx, cmd, 5*time.Minute) - }, - }, - { - Name: "pyright", - Command: "pyright-langserver", - Args: []string{"--stdio"}, - Extensions: []string{".py"}, - Markers: []string{"pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", ".python-version"}, - LanguageID: "python", - Install: func(ctx context.Context, cacheDir string) error { - cmd := exec.CommandContext(ctx, "npm", "install", "--prefix", cacheDir, "pyright") - cmd.Stdout = nil - cmd.Stderr = nil - return runWithTimeout(ctx, cmd, 5*time.Minute) - }, - }, - { - Name: "typescript-language-server", - Command: "typescript-language-server", - Args: []string{"--stdio"}, - Extensions: []string{".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}, - Markers: []string{"tsconfig.json", "jsconfig.json", "package.json"}, - LanguageID: "typescript", - Install: func(ctx context.Context, cacheDir string) error { - cmd := exec.CommandContext(ctx, "npm", "install", "--prefix", cacheDir, "typescript", "typescript-language-server") - cmd.Stdout = nil - cmd.Stderr = nil - return runWithTimeout(ctx, cmd, 5*time.Minute) - }, - }, - { - Name: "rust-analyzer", - Command: "rust-analyzer", - Args: []string{}, - Extensions: []string{".rs"}, - Markers: []string{"Cargo.toml"}, - LanguageID: "rust", - Install: func(ctx context.Context, cacheDir string) error { - cmd := exec.CommandContext(ctx, "rustup", "component", "add", "rust-analyzer") - cmd.Stdout = nil - cmd.Stderr = nil - if err := runWithTimeout(ctx, cmd, 5*time.Minute); err != nil { - return err - } - which := exec.CommandContext(ctx, "rustup", "which", "rust-analyzer") - out, err := which.Output() - if err != nil { - return fmt.Errorf("locate rust-analyzer: %w", err) - } - src := strings.TrimSpace(string(out)) - dst := filepath.Join(cacheDir, "rust-analyzer") - _ = os.MkdirAll(cacheDir, 0755) - _ = os.Remove(dst) - return os.Symlink(src, dst) - }, - }, - { - Name: "clangd", - Command: "clangd", - Args: []string{"--log=error"}, - Extensions: []string{".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".hxx"}, - Markers: []string{"CMakeLists.txt", "compile_commands.json", "Makefile", ".clang-format"}, - LanguageID: "c", - Install: nil, - }, - { - Name: "csharp-ls", - Command: "csharp-ls", - Args: []string{}, - Extensions: []string{".cs"}, - Markers: []string{".sln", ".csproj"}, - LanguageID: "csharp", - Install: func(ctx context.Context, cacheDir string) error { - cmd := exec.CommandContext(ctx, "dotnet", "tool", "install", "--tool-path", cacheDir, "csharp-ls") - cmd.Stdout = nil - cmd.Stderr = nil - return runWithTimeout(ctx, cmd, 5*time.Minute) - }, - }, -} - -func All() []Definition { - return definitions -} - -func ForExtension(ext string) *Definition { - for i := range definitions { - for _, e := range definitions[i].Extensions { - if e == ext { - return &definitions[i] - } - } - } - return nil -} - -func DetectFromProject(root string) []*Definition { - var found []*Definition - for i := range definitions { - for _, marker := range definitions[i].Markers { - if _, err := os.Stat(filepath.Join(root, marker)); err == nil { - found = append(found, &definitions[i]) - break - } - } - } - return found -} - -func CacheDir(home, name string) string { - return filepath.Join(home, ".cache", "lightcode", "lsp", name) -} - -func ResolveBinary(home string, def *Definition) string { - cacheDir := CacheDir(home, def.Name) - - direct := filepath.Join(cacheDir, def.Command) - if _, err := os.Stat(direct); err == nil { - return direct - } - - npmBin := filepath.Join(cacheDir, "node_modules", ".bin", def.Command) - if _, err := os.Stat(npmBin); err == nil { - return npmBin - } - - if p, err := exec.LookPath(def.Command); err == nil { - return p - } - - return "" -} - -// runWithTimeout runs cmd under a five-minute-class wall-clock timeout -// derived from ctx: the timeout still bounds the install when the owner -// outlives it, and cancellation of the parent kills the process, reaps it, -// and returns the cancellation error. -func runWithTimeout(ctx context.Context, cmd *exec.Cmd, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - if err := cmd.Start(); err != nil { - return err - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case err := <-done: - return err - case <-ctx.Done(): - _ = cmd.Process.Kill() - <-done - return ctx.Err() - } -} diff --git a/internal/lsp/server/definitions_ctx_test.go b/internal/lsp/server/definitions_ctx_test.go deleted file mode 100644 index 50ae516c..00000000 --- a/internal/lsp/server/definitions_ctx_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package server - -// The context-aware install runner tests: cancellation of the owner lifetime -// kills a running install promptly and returns the cancellation error, while -// the five-minute wall-clock timeout shape is preserved. - -import ( - "context" - "errors" - "os/exec" - "testing" - "time" - - "github.com/MMinasyan/lightcode/internal/testassert" -) - -func TestRunWithTimeoutContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cmd := exec.CommandContext(ctx, "sleep", "30") - - done := make(chan error, 1) - go func() { done <- runWithTimeout(ctx, cmd, 5*time.Minute) }() - time.Sleep(100 * time.Millisecond) - cancel() - - select { - case err := <-done: - testassert.Require(t, errors.Is(err, context.Canceled), "runWithTimeout after cancellation = %v, want context.Canceled", err) - case <-time.After(5 * time.Second): - t.Fatal("runWithTimeout never returned after the lifetime was canceled") - } -} - -func TestRunWithTimeoutPreservesDeadline(t *testing.T) { - // The wall-clock timeout survives the context threading: a short derived - // deadline kills the process and returns the deadline error. - start := time.Now() - err := runWithTimeout(context.Background(), exec.Command("sleep", "30"), 100*time.Millisecond) - elapsed := time.Since(start) - testassert.Require(t, errors.Is(err, context.DeadlineExceeded), "runWithTimeout past the deadline = %v, want context.DeadlineExceeded", err) - testassert.Require(t, elapsed < 5*time.Second, "runWithTimeout took %s, want a prompt deadline kill", elapsed) -} diff --git a/internal/lsp/server/definitions_test.go b/internal/lsp/server/definitions_test.go deleted file mode 100644 index 1ca64cf0..00000000 --- a/internal/lsp/server/definitions_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package server - -import ( - "os" - "path/filepath" - "testing" - - "github.com/MMinasyan/lightcode/internal/testassert" -) - -func TestDefinitionsLookupAndDetection(t *testing.T) { - testassert.Require(t, len(All()) != 0, "All() returned no definitions") - { - got := ForExtension(".go") - testassert.Require(t, got != nil && got.Name == "gopls", "ForExtension(.go) = %+v, want gopls", got) - } - { - got := ForExtension(".unknown") - testassert.Require(t, got == nil, "ForExtension(.unknown) = %+v, want nil", got) - } - - root := t.TempDir() - { - got := DetectFromProject(root) - testassert.Require(t, len(got) == 0, "DetectFromProject(empty) = %+v, want none", got) - } - testassert.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module x\n"), 0o600)) - found := DetectFromProject(root) - testassert.Require(t, len(found) == 1 && found[0].Name == "gopls", "DetectFromProject(go.mod) = %+v, want gopls", found) -} - -func TestCacheDirAndResolveBinaryPrefersCache(t *testing.T) { - home := t.TempDir() - def := &Definition{Name: "fake", Command: "fake-lsp"} - cache := CacheDir(home, def.Name) - testassert.Require(t, cache == filepath.Join(home, ".cache", "lightcode", "lsp", "fake"), "CacheDir = %q", cache) - bin := filepath.Join(cache, def.Command) - testassert.NoError(t, os.MkdirAll(cache, 0o700)) - testassert.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o700)) - testassert.Equal(t, ResolveBinary(home, def), bin) -} diff --git a/internal/plugins/adaptation/adaptation.go b/internal/plugins/adaptation/adaptation.go index 684177a8..d93b6571 100644 --- a/internal/plugins/adaptation/adaptation.go +++ b/internal/plugins/adaptation/adaptation.go @@ -11,6 +11,7 @@ import ( modeladapt "github.com/MMinasyan/lightcode/internal/adaptation" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" "github.com/MMinasyan/lightcode/runtime" ) @@ -32,21 +33,24 @@ func Plugin() runtime.Plugin { // resolver adapts the bundled binding table to the public contract. Match // receives the model component of the ref alone and returns an independent -// copy; nil is the baseline. +// copy; nil is the baseline. The bundled guidance append paragraphs apply only +// to full-mode prompts; tool exclusions and description replacements apply in +// every mode. type resolver struct{} -func (resolver) Resolve(ref model.ModelRef) (runtime.Adaptation, error) { +func (resolver) Resolve(ref model.ModelRef, mode prompt.Mode) (runtime.Adaptation, error) { match := modeladapt.Match(ref.Model) if match == nil { return runtime.Adaptation{}, nil } - return runtime.Adaptation{ + out := runtime.Adaptation{ ExcludeTools: match.ExcludeTools, - IncludeTools: match.IncludeTools, - Blocks: match.Blocks, - Additions: match.Additions, ToolDescriptionReplacements: match.ToolDescriptionReplacements, - }, nil + } + if mode == prompt.ModeFull { + out.Append = match.Append + } + return out, nil } func open(ctx context.Context, _ runtime.ScopeInfo, _ runtime.Bindings) (runtime.Instance, error) { diff --git a/internal/plugins/adaptation/adaptation_test.go b/internal/plugins/adaptation/adaptation_test.go index ba8eab2f..091bd47f 100644 --- a/internal/plugins/adaptation/adaptation_test.go +++ b/internal/plugins/adaptation/adaptation_test.go @@ -8,19 +8,26 @@ import ( "github.com/MMinasyan/lightcode/internal/plugins/adaptation" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" "github.com/MMinasyan/lightcode/runtime" ) +// bundledGPTGuidanceLine and bundledGPTGuidanceReplacement are the complete +// literal standard run_command guidance line the bundled GPT rows replace and +// its GPT form. +const bundledGPTGuidanceLine = "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file." +const bundledGPTGuidanceReplacement = "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." + // resolvePlugin opens the shipped plugin's single export and returns the // ModelAdaptation implementation it supplies. -func resolvePlugin(t *testing.T, ctx context.Context, ref model.ModelRef) (runtime.Adaptation, error) { +func resolvePlugin(t *testing.T, ctx context.Context, ref model.ModelRef, mode prompt.Mode) (runtime.Adaptation, error) { t.Helper() instance, err := adaptation.Plugin().Open(ctx, runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: t.TempDir()}, runtime.Bindings{}) testassert.NoError(t, err) testassert.Require(t, instance.Close == nil, "the adaptation plugin declares a Close") resolver, ok := instance.Values["model_adaptation"].(runtime.ModelAdaptation) testassert.Require(t, ok, "open supplied %T, want a runtime.ModelAdaptation", instance.Values["model_adaptation"]) - return resolver.Resolve(ref) + return resolver.Resolve(ref, mode) } func TestPluginDeclaresOneRuntimeScopedModelAdaptation(t *testing.T) { @@ -32,63 +39,72 @@ func TestPluginDeclaresOneRuntimeScopedModelAdaptation(t *testing.T) { func TestResolveShippedBundledRows(t *testing.T) { ctx := context.Background() - // The GPT-family row: excluded file tools, the included hidden patch tool, - // the description replacement, and the task-execution addition. - got, err := resolvePlugin(t, ctx, model.ModelRef{Provider: "openai", Model: "gpt-5.5-mini"}) + // The GPT-family row: excluded file tools, the literal description + // replacement, and the task-execution append paragraph. + got, err := resolvePlugin(t, ctx, model.ModelRef{Provider: "openai", Model: "gpt-5.5-mini"}, prompt.ModeFull) testassert.NoError(t, err) { want := []string{"edit_file", "write_file"} testassert.Require(t, reflect.DeepEqual(got.ExcludeTools, want), "exclude tools = %q, want %q", got.ExcludeTools, want) } - { - want := []string{"apply_patch"} - testassert.Require(t, reflect.DeepEqual(got.IncludeTools, want), "include tools = %q, want %q", got.IncludeTools, want) - } - testassert.Equal(t, got.ToolDescriptionReplacements[""], "apply_patch") - testassert.Require(t, got.Additions["task_execution"] != "", "task_execution addition = empty, want the shipped coaching block") + testassert.Require(t, reflect.DeepEqual(got.ToolDescriptionReplacements, map[string]string{bundledGPTGuidanceLine: bundledGPTGuidanceReplacement}), "tool description replacements = %q, want the one literal guidance-line rule", got.ToolDescriptionReplacements) + testassert.Require(t, len(got.Append) != 0, "append = empty, want the shipped guidance paragraph") - // The grok and gemini rows carry additions with no tool levers. + // The grok and gemini rows carry append paragraphs with no tool levers. for _, ref := range []model.ModelRef{{Provider: "xai", Model: "grok-build-0.1"}, {Provider: "google", Model: "gemini-3-pro"}} { - got, err = resolvePlugin(t, ctx, ref) + got, err = resolvePlugin(t, ctx, ref, prompt.ModeFull) if err != nil { t.Fatalf("Resolve(%s): %v", ref.String(), err) } - if got.ExcludeTools != nil || got.IncludeTools != nil || got.Additions["task_execution"] == "" { - t.Fatalf("%s adaptation = %+v, want additions only", ref.String(), got) + if got.ExcludeTools != nil || len(got.Append) == 0 { + t.Fatalf("%s adaptation = %+v, want append paragraphs only", ref.String(), got) } } } +func TestResolveBundledGuidanceOnlyInFullMode(t *testing.T) { + ctx := context.Background() + ref := model.ModelRef{Provider: "openai", Model: "gpt-5.5"} + full, err := resolvePlugin(t, ctx, ref, prompt.ModeFull) + testassert.NoError(t, err) + testassert.Require(t, len(full.Append) != 0, "full-mode bundled adaptation = %+v, want the guidance append", full) + for _, mode := range []prompt.Mode{prompt.ModeSimple, prompt.ModeNone} { + got, err := resolvePlugin(t, ctx, ref, mode) + testassert.NoError(t, err) + testassert.Require(t, len(got.Append) == 0, "%s-mode bundled adaptation = %+v, want no automatic bundled guidance", mode, got) + testassert.Require(t, reflect.DeepEqual(got.ExcludeTools, full.ExcludeTools) && reflect.DeepEqual(got.ToolDescriptionReplacements, full.ToolDescriptionReplacements), "%s-mode bundled adaptation = %+v, want the same tool exclusions and replacements", mode, got) + } +} + func TestResolveUnmatchedModelIsBaseline(t *testing.T) { - got, err := resolvePlugin(t, context.Background(), model.ModelRef{Provider: "anthropic", Model: "claude-x"}) + got, err := resolvePlugin(t, context.Background(), model.ModelRef{Provider: "anthropic", Model: "claude-x"}, prompt.ModeFull) testassert.NoError(t, err) - testassert.Require(t, got.ExcludeTools == nil && got.IncludeTools == nil && got.Blocks == nil && got.Additions == nil && got.ToolDescriptionReplacements == nil, "unmatched model adaptation = %+v, want the zero baseline", got) + testassert.Require(t, got.ExcludeTools == nil && len(got.Append) == 0 && got.ToolDescriptionReplacements == nil, "unmatched model adaptation = %+v, want the zero baseline", got) } func TestResolveMatchesTheModelComponentOnly(t *testing.T) { ctx := context.Background() // A pattern inside the provider component never matches: the matcher // receives ref.Model alone. - got, err := resolvePlugin(t, ctx, model.ModelRef{Provider: "gpt-5", Model: "unrelated"}) + got, err := resolvePlugin(t, ctx, model.ModelRef{Provider: "gpt-5", Model: "unrelated"}, prompt.ModeFull) testassert.NoError(t, err) - testassert.Require(t, got.ExcludeTools == nil && got.IncludeTools == nil && got.Additions == nil, "provider-shaped ref adaptation = %+v, want the zero baseline", got) + testassert.Require(t, got.ExcludeTools == nil && len(got.Append) == 0, "provider-shaped ref adaptation = %+v, want the zero baseline", got) // Matching is case-insensitive over the bare model component. - got, err = resolvePlugin(t, ctx, model.ModelRef{Provider: "openai", Model: "GPT-5.4"}) + got, err = resolvePlugin(t, ctx, model.ModelRef{Provider: "openai", Model: "GPT-5.4"}, prompt.ModeFull) testassert.NoError(t, err) - testassert.Require(t, got.IncludeTools != nil, "case-insensitive row did not match") + testassert.Require(t, len(got.ExcludeTools) == 2, "case-insensitive row did not match: %+v", got) } func TestResolveReturnsIndependentContainers(t *testing.T) { - first, err := resolvePlugin(t, context.Background(), model.ModelRef{Provider: "openai", Model: "gpt-5.5"}) + first, err := resolvePlugin(t, context.Background(), model.ModelRef{Provider: "openai", Model: "gpt-5.5"}, prompt.ModeFull) testassert.NoError(t, err) first.ExcludeTools[0] = "tampered" - first.IncludeTools[0] = "tampered" - first.Additions["task_execution"] = "tampered" - first.ToolDescriptionReplacements[""] = "tampered" + first.Append[0] = "tampered" + first.ToolDescriptionReplacements[bundledGPTGuidanceLine] = "tampered" - second, err := resolvePlugin(t, context.Background(), model.ModelRef{Provider: "openai", Model: "gpt-5.5"}) + second, err := resolvePlugin(t, context.Background(), model.ModelRef{Provider: "openai", Model: "gpt-5.5"}, prompt.ModeFull) testassert.NoError(t, err) - testassert.Require(t, second.ExcludeTools[0] == "edit_file" && second.IncludeTools[0] == "apply_patch" && second.Additions["task_execution"] != "tampered" && second.ToolDescriptionReplacements[""] == "apply_patch", "a mutated result leaked into the next resolve: %+v", second) + testassert.Require(t, second.ExcludeTools[0] == "edit_file" && second.Append[0] != "tampered" && second.ToolDescriptionReplacements[bundledGPTGuidanceLine] == bundledGPTGuidanceReplacement, "a mutated result leaked into the next resolve: %+v", second) } func TestOpenRejectsCanceledScopeContext(t *testing.T) { diff --git a/internal/plugins/builtin/builtin.go b/internal/plugins/builtin/builtin.go index ce0fd971..119e8344 100644 --- a/internal/plugins/builtin/builtin.go +++ b/internal/plugins/builtin/builtin.go @@ -1,22 +1,19 @@ -// Package builtin assembles Lightcode's shipped plugin registration set: the -// durable SQLite session store, the native file and command tools, the -// bundled model adaptation, the LSP tools, the background jobs capability, -// and the child-session task tool. It is ordinary composition data with no +// Package builtin assembles Lightcode's shipped optional plugin +// registration set: the bundled model adaptation and the child-session task +// tool. The durable SQLite store, the jobs service, and the seven standard +// tools are baseline services the Runtime owner constructs directly, so they +// are not part of this set. It is ordinary composition data with no // configuration logic and no additional Core authority; a custom build // supplies its own plugin slice through the same runtime.Open. package builtin import ( "github.com/MMinasyan/lightcode/internal/plugins/adaptation" - "github.com/MMinasyan/lightcode/internal/plugins/lsp" - "github.com/MMinasyan/lightcode/internal/plugins/sqlite" "github.com/MMinasyan/lightcode/internal/plugins/tasks" - "github.com/MMinasyan/lightcode/internal/plugins/tools" - "github.com/MMinasyan/lightcode/plugins/jobs" "github.com/MMinasyan/lightcode/runtime" ) // Plugins returns the shipped registration set in registration order. func Plugins() []runtime.Plugin { - return []runtime.Plugin{sqlite.Plugin(), tools.Plugin(), adaptation.Plugin(), lsp.Plugin(), jobs.Plugin(), tasks.Plugin()} + return []runtime.Plugin{adaptation.Plugin(), tasks.Plugin()} } diff --git a/internal/plugins/builtin/builtin_test.go b/internal/plugins/builtin/builtin_test.go index 5845618e..f5d0ac57 100644 --- a/internal/plugins/builtin/builtin_test.go +++ b/internal/plugins/builtin/builtin_test.go @@ -5,16 +5,16 @@ import ( "testing" ) -// TestShippedRegistrationHoldsSixInOrder proves the shipped registration set -// is exactly the six registered plugins in registration order: the durable -// SQLite session store first, then the native tools, the bundled adaptation, -// the LSP tools, the background jobs capability, and the child-session task -// tool. The full composition open over this set is exercised by the runtime -// integration suite. -func TestShippedRegistrationHoldsSixInOrder(t *testing.T) { +// TestShippedRegistrationHoldsTwoInOrder proves the shipped registration set +// is exactly the two declared plugins in registration order: the bundled +// adaptation and the child-session task tool. The session store, the standard +// tools, and the background jobs service are constructed directly by the +// Runtime owner and are not plugin declarations. The full composition open over +// this set is exercised by the runtime integration suite. +func TestShippedRegistrationHoldsTwoInOrder(t *testing.T) { plugins := Plugins() - testassert.Equal(t, len(plugins), 6) - want := []string{"sqlite", "tools", "adaptation", "lsp", "jobs", "tasks"} + testassert.Equal(t, len(plugins), 2) + want := []string{"adaptation", "tasks"} for i, p := range plugins { testassert.Check(t, p.ID == want[i], "plugin %d ID = %q, want %q", i, p.ID, want[i]) testassert.Check(t, p.Open != nil, "plugin %q has no Open factory", p.ID) diff --git a/internal/plugins/lsp/lsp.go b/internal/plugins/lsp/lsp.go deleted file mode 100644 index 99ef3a43..00000000 --- a/internal/plugins/lsp/lsp.go +++ /dev/null @@ -1,452 +0,0 @@ -// Package lsp declares Lightcode's Runtime-scoped LSP plugin: the -// diagnostics and workspace_symbol tools over Workspace-keyed shared -// language-server managers. It is an ordinary composition unit selected -// through static registration — the same Plugin/Instance contract an -// externally supplied capability implements, with no additional Runtime -// authority and no legacy authorization wrapper. -package lsp - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "unicode/utf8" - - "github.com/MMinasyan/lightcode/harness" - "github.com/MMinasyan/lightcode/internal/lsp" - "github.com/MMinasyan/lightcode/internal/pathutil" - "github.com/MMinasyan/lightcode/internal/snapshot" - "github.com/MMinasyan/lightcode/internal/toolargs" - "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/runtime" -) - -const ( - pluginID = "lsp" - - // maxDiagnosticBytes is the durable diagnostic bound for the - // validation-class diagnostics this plugin produces, matching the - // Harness's own tool-boundary bound. - maxDiagnosticBytes = 512 - - // maxContentBytes is the model-visible content bound of both tools: final - // content over the bound keeps the longest complete-character prefix that - // fits with the appended truncation marker. This is plugin-local - // formatting applied after the shared client formatting — not a Core - // limiter, another configuration channel or an output-spill mechanism — - // so read failures and long diagnostic messages cannot bypass it. - maxContentBytes = 15360 - - truncationMarker = "\n[Output truncated]" - - // diagnosticsDescription says the tool checks the files changed by this - // Session — every call recomputes the candidates, there is no - // since-the-previous-query cursor. - diagnosticsDescription = "Check for compilation errors in the files this Session has changed. Call after editing to verify correctness. Returns errors only (not warnings)." - diagnosticsParameters = `{"type":"object","properties":{}}` - - workspaceSymbolDescription = "Search for symbols by name across the project. Returns matching functions, types, variables, etc. with their file locations." - workspaceSymbolParameters = `{"type":"object","properties":{"query":{"type":"string","description":"Symbol name or partial name to search for."}},"required":["query"]}` -) - -// newManager is the package test seam for workspace-manager construction: -// tests wrap it to count, record or redirect managers. It is never nil in -// production and simply delegates to lsp.NewManager. -var newManager = lsp.NewManager - -// workspaceEntry is one workspace's shared LSP manager and its one -// detection: done closes when Detect returns, and every later caller reuses -// the manager — including one with an empty instance map — without -// re-detection. Failed detection is not retried. -type workspaceEntry struct { - manager *lsp.Manager - done chan struct{} -} - -// instance is one Open's constructed state: the runtime lifetime context, -// the resolved home directory, the owner data root, and the Runtime's -// passive warning-report callback captured at Open. Workspaces register -// lazily — detection starts only in an authorized tool execution; Open and -// every preparation start nothing. -type instance struct { - ctx context.Context - home string - dataDir string - reportWarning func(kind, message string) - - mu sync.Mutex - closed bool - - // workspaces is keyed by the canonical root (symlinks resolved) rather - // than the lexical workspace path, so a directory and its symlink alias - // share one manager entry. This is deliberate: per-call binding - // revalidation computes both compared values fresh and never consults - // the entry, so lexical keying would let a retargeted workspace pass - // revalidation and be served by the stale, old-tree entry. The - // canonical key makes the lookup itself the staleness detector — a - // miss after a retarget creates a correctly rooted new entry, while - // the old entry's servers retire on the 30-minute idle timer and the - // entry object persists until plugin close. Keying by the lexical path - // instead requires deliberately replacing entries on binding change, - // not a one-line key swap. - workspaces map[string]*workspaceEntry -} - -// workspace returns the canonical root's entry, creating it, installing the -// Runtime's warning reporter, and launching one detection goroutine on first -// use. The lock covers only the map check, the creation and the insertion; -// detection runs outside it on the plugin's runtime lifetime context, so a -// caller's cancellation never cancels shared detection. The reporter is -// installed before the detection goroutine exists, so every warning — -// including one emitted during detection — reaches the Runtime's store; a -// nil reporter is the harmless no-report shape. -func (in *instance) workspace(canonicalRoot string) *workspaceEntry { - in.mu.Lock() - if in.closed { - in.mu.Unlock() - return nil - } - entry := in.workspaces[canonicalRoot] - if entry == nil { - manager := newManager(canonicalRoot, in.home) - manager.SetWarningHandler(in.reportWarning) - entry = &workspaceEntry{manager: manager, done: make(chan struct{})} - in.workspaces[canonicalRoot] = entry - lifetime := in.ctx - go func() { - defer close(entry.done) - manager.Detect(lifetime) - }() - } - in.mu.Unlock() - return entry -} - -// waitDetection waits for the workspace's shared detection to complete, -// bounded by the caller's context alone; cancellation returns an error and -// never cancels the shared detection. -func (in *instance) waitDetection(ctx context.Context, entry *workspaceEntry) error { - select { - case <-entry.done: - return nil - case <-ctx.Done(): - return ctx.Err() - } -} - -// close tears every registered workspace manager down: under the lock it -// sets closed and snapshots the entries; outside it, every manager's -// admission closes first (so a server still starting is torn down), then -// in-flight detection joins, and only then the managers shut down. The -// registry lock is never held across any wait. -func (in *instance) close() error { - in.mu.Lock() - in.closed = true - entries := make([]*workspaceEntry, 0, len(in.workspaces)) - for _, entry := range in.workspaces { - entries = append(entries, entry) - } - in.mu.Unlock() - - for _, entry := range entries { - entry.manager.CloseAdmission() - } - for _, entry := range entries { - <-entry.done - } - for _, entry := range entries { - entry.manager.ShutdownAll() - } - return nil -} - -// immediateError is the normalization-class immediate outcome: status error -// carrying the bounded validation diagnostic, per the tool-boundary -// validation contract. -func immediateError(callID string, cause error) harness.PreparedTool { - return harness.PreparedTool{Immediate: &harness.ToolOutcome{ - Result: model.ToolResult{CallID: callID, Status: model.ResultError, Content: boundedDiagnostic(cause)}, - }} -} - -// immediateDenied is the failed-canonical-preparation immediate outcome: the -// fixed denial, per the tool-boundary contract. -func immediateDenied(callID string) harness.PreparedTool { - return harness.PreparedTool{Immediate: &harness.ToolOutcome{ - Result: model.ToolResult{CallID: callID, Status: model.ResultDenied, Content: harness.PermissionDeniedContent}, - }} -} - -// boundedDiagnostic renders one plugin-produced validation diagnostic within -// the durable diagnostic bound. -func boundedDiagnostic(cause error) string { - msg := cause.Error() - if len(msg) > maxDiagnosticBytes { - msg = strings.ToValidUTF8(msg[:maxDiagnosticBytes], "") - } - return msg -} - -// normalizeCallArguments is the shared Normalize body for both tools: the -// strict decode, the private model-supplied `_lightcode_` field stripping, -// the tool's consumed-field validation, and the marshaled normalized object. -// Unrelated accepted members are retained. -func normalizeCallArguments(call model.ToolCall, validate func(map[string]any) error) (json.RawMessage, error) { - args, err := toolargs.Decode(call.Arguments) - if err != nil { - return nil, err - } - clean := make(map[string]any, len(args)) - for k, v := range args { - if strings.HasPrefix(k, "_lightcode_") { - continue - } - clean[k] = v - } - if validate != nil { - if err := validate(clean); err != nil { - return nil, err - } - } - return json.Marshal(clean) -} - -// canonicalWorkspace resolves the lexical Workspace to its canonical root -// with the same pathutil canonicalization the shared preparation binds, so a -// symlinked lexical Workspace binds to the real root. -func canonicalWorkspace(lexical string) (string, error) { - resolved, err := pathutil.ResolveFilePathFrom(lexical, lexical) - if err != nil { - return "", err - } - return resolved.CanonicalPath, nil -} - -// capModelContent caps the final model-visible content at maxContentBytes -// UTF-8 bytes: content that fits is unchanged; otherwise the longest -// complete-character prefix that fits with the appended truncation marker is -// kept. -func capModelContent(content string) string { - if len(content) <= maxContentBytes { - return content - } - n := maxContentBytes - len(truncationMarker) - for n > 0 && !utf8.RuneStart(content[n]) { - n-- - } - return content[:n] + truncationMarker -} - -// toolOutcome is the one outcome-mapping rule for both tools: (content, nil) -// settles as success; an observed caller cancellation settles as interrupted -// carrying the capped partial content — the context error itself when -// nothing accumulated, since interrupted requires non-empty content; any -// other error settles as error with the capped error text. The cap applies -// to successful, partial and error content alike. -func toolOutcome(callID string, ctx context.Context, content string, err error) harness.ToolOutcome { - switch { - case err == nil: - return harness.ToolOutcome{Result: model.ToolResult{CallID: callID, Status: model.ResultSuccess, Content: capModelContent(content)}} - case ctx.Err() != nil: - partial := capModelContent(content) - if partial == "" { - partial = capModelContent(ctx.Err().Error()) - } - return harness.ToolOutcome{Result: model.ToolResult{CallID: callID, Status: model.ResultInterrupted, Content: partial}} - default: - return harness.ToolOutcome{Result: model.ToolResult{CallID: callID, Status: model.ResultError, Content: capModelContent(err.Error())}} - } -} - -// prepareLSP is the shared Prepare body for both tools: the canonical -// workspace root bound with its one workspace.inspect pair (an unconstrained -// write dir), and the executor that revalidates the binding before any -// service start or query. No Immediate success exists: both tools execute. -func (in *instance) prepareLSP(tc runtime.ToolContext, call model.ToolCall, query func(ctx context.Context, client *lsp.Client, canonicalRoot string) harness.ToolOutcome) harness.PreparedTool { - canonicalRoot, err := canonicalWorkspace(tc.Workspace) - if err != nil { - return immediateDenied(call.ID) - } - return harness.PreparedTool{ - Permissions: []harness.PermissionRequest{{Permission: harness.PermissionWorkspaceInspect, Target: canonicalRoot}}, - CanonicalWorkspace: canonicalRoot, - Execute: func(ctx context.Context) harness.ToolOutcome { - return in.execute(call.ID, tc, canonicalRoot, ctx, query) - }, - } -} - -// execute is the shared execution path: it first revalidates the workspace -// binding — a changed binding fails with an error outcome, starts nothing, -// and never substitutes a root — then waits for the workspace's one shared -// detection (a canceled caller is interrupted and never cancels the shared -// detection), then runs the tool's query body against the shared client. -func (in *instance) execute(callID string, tc runtime.ToolContext, canonicalRoot string, ctx context.Context, query func(context.Context, *lsp.Client, string) harness.ToolOutcome) harness.ToolOutcome { - revalidated, err := canonicalWorkspace(tc.Workspace) - if err != nil || revalidated != canonicalRoot { - return toolOutcome(callID, ctx, "", errors.New("workspace binding changed since the call was prepared")) - } - entry := in.workspace(canonicalRoot) - if entry == nil { - return toolOutcome(callID, ctx, "", errors.New("workspace services are closed")) - } - if err := in.waitDetection(ctx, entry); err != nil { - return toolOutcome(callID, ctx, "", err) - } - return query(ctx, lsp.NewClient(entry.manager), canonicalRoot) -} - -// diagnosticsTool is the diagnostics export: every call lists the calling -// session's unique canonical changed paths afresh — no cursor, no -// group-shaped result, no previous/current distinction and no checked-group -// set — filters them to the bound workspace, and queries the shared client -// through the canonical read path. -type diagnosticsTool struct{ inst *instance } - -func (diagnosticsTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return runtime.ToolDescription{ - Definition: model.ToolDefinition{ - Name: "diagnostics", - Description: diagnosticsDescription, - Parameters: json.RawMessage(diagnosticsParameters), - }, - Available: true, - }, nil -} - -func (diagnosticsTool) Normalize(_ runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { - return normalizeCallArguments(call, nil) -} - -func (t diagnosticsTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { - return t.inst.prepareLSP(tc, call, func(ctx context.Context, client *lsp.Client, canonicalRoot string) harness.ToolOutcome { - paths, err := snapshot.ListChangedFiles(t.inst.dataDir, tc.AdmittedEntry.SessionID) - if err != nil { - // The enumeration failure is the outcome: its actual error, - // capped like every other ordinary error. - return toolOutcome(call.ID, ctx, "", err) - } - paths = filterInsideRoot(paths, canonicalRoot) - if len(paths) == 0 { - return toolOutcome(call.ID, ctx, "No files have been modified.", nil) - } - content, err := client.GetDiagnosticsCanonical(ctx, paths) - return toolOutcome(call.ID, ctx, content, err) - }) -} - -// filterInsideRoot keeps the paths at or under the canonical root, derived -// with filepath.Rel; outside-root paths are dropped, never read. -func filterInsideRoot(paths []string, canonicalRoot string) []string { - filtered := make([]string, 0, len(paths)) - for _, p := range paths { - if rel, err := filepath.Rel(canonicalRoot, p); err == nil && - rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - filtered = append(filtered, p) - } - } - return filtered -} - -// workspaceSymbolTool is the workspace_symbol export: the retained -// description, schema and outcomes over the shared detection. -type workspaceSymbolTool struct{ inst *instance } - -func (workspaceSymbolTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return runtime.ToolDescription{ - Definition: model.ToolDefinition{ - Name: "workspace_symbol", - Description: workspaceSymbolDescription, - Parameters: json.RawMessage(workspaceSymbolParameters), - }, - Available: true, - }, nil -} - -// validateQueryArgs consumes the tool's one argument: exactly one present, -// non-empty string. Absent, null, wrong-typed and empty query values are -// argument-validation errors; there is no trimming or whitespace policy. -func validateQueryArgs(args map[string]any) error { - v, present := args["query"] - if !present || v == nil { - return errors.New("workspace_symbol: query is required") - } - s, ok := v.(string) - if !ok { - return errors.New("workspace_symbol: query must be a string") - } - if s == "" { - return errors.New("workspace_symbol: query is required") - } - return nil -} - -func (workspaceSymbolTool) Normalize(_ runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { - return normalizeCallArguments(call, validateQueryArgs) -} - -func (t workspaceSymbolTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { - args, err := toolargs.Decode(call.Arguments) - if err != nil { - return immediateError(call.ID, err) - } - if err := validateQueryArgs(args); err != nil { - return immediateError(call.ID, err) - } - query, _ := args["query"].(string) - return t.inst.prepareLSP(tc, call, func(ctx context.Context, client *lsp.Client, _ string) harness.ToolOutcome { - content, err := client.WorkspaceSymbol(ctx, query) - return toolOutcome(call.ID, ctx, content, err) - }) -} - -// Plugin returns the Runtime-scoped plugin whose two exports are the LSP -// diagnostics and workspace_symbol tools. It has no settings — the -// nil-validator no-settings rule applies — and no dependencies. Open resolves -// the home directory once, returning any error through ordinary failed -// construction; the retained language-server cache stays under -// home/.cache/lightcode/lsp independently of DataDir. -func Plugin() runtime.Plugin { - return runtime.Plugin{ - ID: pluginID, - Scope: runtime.ScopeRuntime, - Provides: []runtime.CapabilitySpec{ - runtime.ToolSpec("diagnostics", diagnosticsTool{}.describe), - runtime.ToolSpec("workspace_symbol", workspaceSymbolTool{}.describe), - }, - Open: open, - } -} - -// open checks the scope context, resolves home once, and captures the runtime -// lifetime context, the owner data root, and the Runtime's passive -// warning-report callback. No manager, workspace or server exists at -// construction; a nil callback is the no-report shape. -func open(ctx context.Context, info runtime.ScopeInfo, _ runtime.Bindings) (runtime.Instance, error) { - if err := ctx.Err(); err != nil { - return runtime.Instance{}, err - } - home, err := os.UserHomeDir() - if err != nil { - return runtime.Instance{}, fmt.Errorf("lsp: resolve home directory: %w", err) - } - inst := &instance{ - ctx: ctx, - home: home, - dataDir: info.DataDir, - reportWarning: info.ReportWarning, - workspaces: make(map[string]*workspaceEntry), - } - return runtime.Instance{ - Values: map[string]any{ - "diagnostics": diagnosticsTool{inst: inst}, - "workspace_symbol": workspaceSymbolTool{inst: inst}, - }, - Close: inst.close, - }, nil -} diff --git a/internal/plugins/lsp/lsp_mixed_test.go b/internal/plugins/lsp/lsp_mixed_test.go deleted file mode 100644 index 895af5fd..00000000 --- a/internal/plugins/lsp/lsp_mixed_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package lsp - -// The mixed-detection and plugin-restart rows: with markers for two language -// servers where exactly one is available, detection's mixed outcome still -// returns the available server's content with the unavailable one omitted; -// and a second plugin instance over the same data root re-detects and -// includes the same Session's changed files. - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MMinasyan/lightcode/harness" - "github.com/MMinasyan/lightcode/internal/testassert" - "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/runtime" -) - -// TestMixedDetectionReturnsAvailableServerContent: the workspace carries the -// gopls and pyright markers; only gopls is available — the planted pyright -// binary crashes before readiness on every launch, so its crash loop -// exhausts the restart budget and the instance ends mapped but terminal -// failed. The client query returns the available server's content and omits -// the unavailable one (the retained partial-server behavior). -func TestMixedDetectionReturnsAvailableServerContent(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - pyrightLog := plantServer(t, fakeHome, "pyright", "pyright-langserver", 0, "1", "", "") - dataDir := t.TempDir() - _, _, symbol, workspace := newPluginWorkspace(t, fakeHome, dataDir) - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "pyproject.toml"), []byte("[project]\n"), 0o600)) - - prepared := symbol.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"Foo"}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess && strings.Contains(outcome.Result.Content, "Foo"), "mixed detection query = %+v, want the available server's success; mixed detection content = %q, want the available server's symbol", outcome.Result, outcome.Result.Content) - waitForLog(t, requests, "workspace/symbol", 1) - // The unavailable server went through its crash loop: the pyright binary - // launched maxRestarts+1 times (the template writes "started" exactly - // once per launch) and ended failed; no symbol query ever reached it. - waitForLog(t, pyrightLog, "started", maxRestartLaunches) - testassert.Equal(t, logCount(t, pyrightLog, "workspace/symbol"), 0) -} - -// maxRestartLaunches is the launch count that exhausts the crash budget: the -// initial launch plus one restart per budgeted crash. -const maxRestartLaunches = 3 + 1 - -// TestRestartedPluginInstanceReincludesSessionFiles: after a first plugin -// instance's query, a second instance over the same data root re-detects and -// its diagnostics query includes the same Session's changed files. -func TestRestartedPluginInstanceReincludesSessionFiles(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - spy := &managerSpy{} - spy.install(t, fakeHome) - dataDir := t.TempDir() - workspace := markerWorkspace(t) - seedGroup(t, dataDir, pluginSessionID, groupOne, filepath.Join(workspace, "a.go")) - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "a.go"), []byte("package w\n"), 0o600)) - uriA := fileURI(workspace, "a.go") - - // The first instance's query. - plugin := Plugin() - first, err := plugin.Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dataDir}, runtime.Bindings{}) - testassert.NoError(t, err) - firstDiag := first.Values["diagnostics"].(runtime.Tool) - prepared := firstDiag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c1", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "first instance query = %+v, want success", outcome.Result) - waitForQuery(t, requests, uriA, 1) - testassert.NoError(t, first.Close()) - - // The restarted instance: fresh detection over the same data root and - // home, and the same Session's changed files are included again. - second, err := plugin.Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dataDir}, runtime.Bindings{}) - testassert.NoError(t, err) - t.Cleanup(func() { _ = second.Close() }) - secondDiag := second.Values["diagnostics"].(runtime.Tool) - prepared = secondDiag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c2", "diagnostics", `{}`)) - outcome = prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "restarted query = %+v, want success", outcome.Result) - waitForQuery(t, requests, uriA, 2) - testassert.Equal(t, spy.count(), 2) -} diff --git a/internal/plugins/lsp/lsp_test.go b/internal/plugins/lsp/lsp_test.go deleted file mode 100644 index 6e63d244..00000000 --- a/internal/plugins/lsp/lsp_test.go +++ /dev/null @@ -1,945 +0,0 @@ -package lsp - -// The plugin contract tests. The workspace registry, detection-once -// semantics, binding revalidation, outcome mapping and the content cap are -// exercised against a real fake language server: the newManager seam -// redirects every workspace manager at a test home whose cache holds a -// planted fake gopls binary, so detection starts a real stdio server and the -// shared client queries it end to end. The server logs every launch and -// request as "method uri" lines, which is how the tests observe exactly -// which files were read and synced. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - "unicode/utf8" - - "github.com/MMinasyan/lightcode/harness" - "github.com/MMinasyan/lightcode/internal/lsp" - "github.com/MMinasyan/lightcode/internal/testassert" - "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/runtime" -) - -// fakeServerTemplate is a minimal LSP server over stdio that logs every -// launch and request as "method uri" lines. {{LOG}} is the log path, {{DELAY}} -// the initialize delay in seconds (holds detection), {{CRASH}} a crash -// mode ("1" exits after initialize, before readiness — the crash loop drives -// the instance into the terminal failed state), {{BAD_SYMBOL}} a mode ("1") -// whose workspace/symbol reply is a JSON value that cannot decode into the -// symbol array, and {{EMPTY_SYMBOL}} a mode ("1") whose workspace/symbol reply -// is a valid empty symbol list. -const fakeServerTemplate = `#!/usr/bin/env python3 -import json, sys, time - -log = "{{LOG}}" -init_delay = {{DELAY}} -crash = "{{CRASH}}" -bad_symbol = "{{BAD_SYMBOL}}" -empty_symbol = "{{EMPTY_SYMBOL}}" - -def send(obj): - data = json.dumps(obj, separators=(",", ":")).encode() - sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(data) + data) - sys.stdout.buffer.flush() - -with open(log, "a") as f: - f.write("started\n") - -stdin = sys.stdin.buffer -while True: - length = None - while True: - line = stdin.readline() - if not line: - sys.exit(0) - if line in (b"\r\n", b"\n"): - break - if line.lower().startswith(b"content-length:"): - length = int(line.split(b":", 1)[1]) - if length is None: - continue - body = json.loads(stdin.read(length)) - method = body.get("method") - entry = method or "response" - params = body.get("params") or {} - doc = params.get("textDocument") if isinstance(params, dict) else None - if isinstance(doc, dict) and "uri" in doc: - entry += " " + doc["uri"] - with open(log, "a") as f: - f.write(entry + "\n") - if method == "initialize": - if init_delay: - time.sleep(init_delay) - send({"jsonrpc": "2.0", "id": body["id"], "result": {"capabilities": {}}}) - elif method == "initialized": - if crash: - sys.exit(1) - send({"jsonrpc": "2.0", "method": "$/progress", "params": {"token": 0, "value": {"kind": "end"}}}) - elif method == "workspace/symbol": - if bad_symbol: - send({"jsonrpc": "2.0", "id": body["id"], "result": "invalid"}) - elif empty_symbol: - send({"jsonrpc": "2.0", "id": body["id"], "result": []}) - else: - send({"jsonrpc": "2.0", "id": body["id"], "result": [ - {"name": "Foo", "kind": 12, "location": {"uri": "file:///w/foo.go", "range": {"start": {"line": 3}}}} - ]}) - elif method == "shutdown": - send({"jsonrpc": "2.0", "id": body["id"], "result": None}) - elif method == "exit": - sys.exit(0) -` - -// managerSpy records every workspace-manager construction the plugin makes -// through the newManager seam. With fakeHome nonempty it also redirects the -// manager at the fake server's home, so detection resolves the planted -// binary instead of anything on the host. -type managerSpy struct { - mu sync.Mutex - roots []string - homes []string -} - -func (s *managerSpy) install(t *testing.T, fakeHome string) { - t.Helper() - old := newManager - newManager = func(root, home string) *lsp.Manager { - s.mu.Lock() - s.roots = append(s.roots, root) - s.homes = append(s.homes, home) - s.mu.Unlock() - if fakeHome != "" { - return lsp.NewManager(root, fakeHome) - } - return lsp.NewManager(root, home) - } - t.Cleanup(func() { newManager = old }) -} - -func (s *managerSpy) count() int { - s.mu.Lock() - defer s.mu.Unlock() - return len(s.roots) -} - -func (s *managerSpy) root() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.roots[0] -} - -func (s *managerSpy) home() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.homes[0] -} - -// fakeServerHome plants the fake gopls binary where the manager's binary -// resolution finds it first: home/.cache/lightcode/lsp/gopls/gopls. It -// returns the fake home and the planted server's request-log path. -func fakeServerHome(t *testing.T, initDelay float64) (home, requests string) { - t.Helper() - home = t.TempDir() - requests = plantServer(t, home, "gopls", "gopls", initDelay, "", "", "") - return home, requests -} - -// plantServer plants one fake server binary at the definition's cache -// directory (home/.cache/lightcode/lsp//) and returns -// its request-log path. crash selects the crash mode; badSymbol the invalid -// workspace/symbol reply mode and emptySymbol the valid empty one; empty -// values disable each. -func plantServer(t *testing.T, home, dirName, binaryName string, initDelay float64, crash, badSymbol, emptySymbol string) string { - t.Helper() - cacheDir := filepath.Join(home, ".cache", "lightcode", "lsp", dirName) - testassert.NoError(t, os.MkdirAll(cacheDir, 0o755)) - requests := filepath.Join(t.TempDir(), "requests.log") - script := strings.ReplaceAll(fakeServerTemplate, "{{LOG}}", requests) - script = strings.ReplaceAll(script, "{{DELAY}}", fmt.Sprintf("%g", initDelay)) - script = strings.ReplaceAll(script, "{{CRASH}}", crash) - script = strings.ReplaceAll(script, "{{BAD_SYMBOL}}", badSymbol) - script = strings.ReplaceAll(script, "{{EMPTY_SYMBOL}}", emptySymbol) - testassert.NoError(t, os.WriteFile(filepath.Join(cacheDir, binaryName), []byte(script), 0o755)) - return requests -} - -const ( - pluginSessionID = "0123456789abcdef0123456789abcdef" - otherSessionID = "ffffffffffffffffffffffffffffffff" - - groupOne = "11111111111111111111111111111111" - groupTwo = "22222222222222222222222222222222" -) - -func toolCall(id, name, arguments string) model.ToolCall { - return model.ToolCall{ID: id, Name: name, Arguments: json.RawMessage(arguments)} -} - -// pluginUnderTest opens the plugin with the spy installed and returns the -// instance's two tool values. -func pluginUnderTest(t *testing.T, dataDir string) (runtime.Tool, runtime.Tool) { - t.Helper() - return openPlugin(t, Plugin(), dataDir) -} - -// newPluginWorkspace installs the manager spy over fakeHome, opens the plugin -// over dataDir, and builds a marked workspace. Each case keeps its own data -// root, seeds, request log and query expectations. -func newPluginWorkspace(t *testing.T, fakeHome, dataDir string) (spy *managerSpy, diag, symbol runtime.Tool, workspace string) { - t.Helper() - spy = &managerSpy{} - spy.install(t, fakeHome) - diag, symbol = pluginUnderTest(t, dataDir) - workspace = markerWorkspace(t) - return spy, diag, symbol, workspace -} - -func openPlugin(t *testing.T, plugin runtime.Plugin, dataDir string) (runtime.Tool, runtime.Tool) { - t.Helper() - inst, err := plugin.Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dataDir}, runtime.Bindings{}) - testassert.NoError(t, err) - t.Cleanup(func() { _ = inst.Close() }) - return inst.Values["diagnostics"].(runtime.Tool), inst.Values["workspace_symbol"].(runtime.Tool) -} - -// markerWorkspace builds a workspace root with the gopls detection marker. -func markerWorkspace(t *testing.T) string { - t.Helper() - root := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), []byte("module w\n"), 0o600)) - return root -} - -// seedGroup writes one code group's changed-file metadata the way a real -// group records it: DataDir/code///snapshots/1//meta.json. -func seedGroup(t *testing.T, dataDir, sessionID, groupID, canonical string) { - t.Helper() - seedEntry(t, dataDir, sessionID, groupID, "e", canonical) -} - -// seedEntry writes one changed-file metadata entry with the given entry name -// under the group's first turn: original_path from the canonical leaf, -// canonical_path verbatim, existed true. -func seedEntry(t *testing.T, dataDir, sessionID, groupID, entryName, canonical string) { - t.Helper() - entry := filepath.Join(dataDir, "code", sessionID, groupID, "snapshots", "1", entryName) - testassert.NoError(t, os.MkdirAll(entry, 0o755)) - meta := "{\"original_path\":\"" + filepath.Base(canonical) + "\",\"canonical_path\":\"" + canonical + "\",\"existed\":true}" - testassert.NoError(t, os.WriteFile(filepath.Join(entry, "meta.json"), []byte(meta), 0o600)) -} - -func logCount(t *testing.T, log, needle string) int { - t.Helper() - data, err := os.ReadFile(log) - if err != nil { - if os.IsNotExist(err) { - return 0 - } - t.Fatal(err) - } - return strings.Count(string(data), needle) -} - -// waitForLog fails the test unless the log reaches atLeast occurrences of -// needle within the deadline; a missing or empty log is the failure, never a -// success. -func waitForLog(t *testing.T, log, needle string, atLeast int) { - t.Helper() - deadline := time.Now().Add(10 * time.Second) - for { - if logCount(t, log, needle) >= atLeast { - return - } - if time.Now().After(deadline) { - data, _ := os.ReadFile(log) - t.Fatalf("%q never reached %d in %s; log:\n%s", needle, atLeast, log, string(data)) - } - time.Sleep(10 * time.Millisecond) - } -} - -func waitForQuery(t *testing.T, log, uri string, atLeast int) { - t.Helper() - // A document sync is a didOpen (first open) or a didChange (later syncs - // of the same document); the count spans both. - deadline := time.Now().Add(10 * time.Second) - for { - if logCount(t, log, "textDocument/didOpen "+uri)+logCount(t, log, "textDocument/didChange "+uri) >= atLeast { - return - } - if time.Now().After(deadline) { - data, _ := os.ReadFile(log) - t.Fatalf("document sync for %s never reached the server; log:\n%s", uri, string(data)) - } - time.Sleep(10 * time.Millisecond) - } -} - -func fileURI(root, name string) string { - return "file://" + filepath.Join(root, name) -} - -func TestPluginOpenStartsNothingAndResolvesHome(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - t.Setenv("HOME", fakeHome) - spy := &managerSpy{} - spy.install(t, fakeHome) - pluginUnderTest(t, t.TempDir()) - testassert.Require(t, spy.count() == 0, "plugin Open constructed a workspace manager; detection must start only in authorized execution") -} - -func TestPluginOpenHomeFailureFailsConstruction(t *testing.T) { - t.Setenv("HOME", "") - plugin := Plugin() - { - _, err := plugin.Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: t.TempDir()}, runtime.Bindings{}) - testassert.Require(t, err != nil, "plugin Open without a resolvable home = nil error, want ordinary failed construction") - } -} - -func TestPluginCacheStaysHomeBased(t *testing.T) { - // Hermetic: the plugin resolves its home from the faked HOME, whose - // cache holds the planted fake gopls — no host cache or installer is - // ever reached. - fakeHome, _ := fakeServerHome(t, 0) - t.Setenv("HOME", fakeHome) - dataDir := t.TempDir() - spy, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c1", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess && outcome.Result.Content == "No files have been modified.", "diagnostics = %+v, want the no-files success", outcome.Result) - testassert.Equal(t, spy.count(), 1) - if spy.root() != workspace { - t.Fatalf("manager root = %q, want the canonical workspace %q", spy.root(), workspace) - } - // The manager received the plugin's resolved home — the faked HOME — and - // the retained cache root stays home-based, under it, independently of - // DataDir. - resolved, err := os.UserHomeDir() - testassert.Require(t, err == nil && resolved == fakeHome, "os.UserHomeDir under the faked HOME = (%q, %v), want %q", resolved, err, fakeHome) - if spy.home() != resolved { - t.Fatalf("manager home = %q, want the plugin-resolved %q", spy.home(), resolved) - } - { - cache := filepath.Join(spy.home(), ".cache", "lightcode", "lsp") - testassert.Require(t, strings.HasPrefix(cache, fakeHome+string(filepath.Separator)), "cache root %q is not under the resolved home %q", cache, fakeHome) - } -} - -// TestConcurrentFirstUsesJoinOneDetect: eight concurrent first calls -// construct exactly one workspace manager and share one detection. Hermetic: -// the plugin's home resolves into the fake home whose cache holds the -// planted fake gopls. -func TestConcurrentFirstUsesJoinOneDetect(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - t.Setenv("HOME", fakeHome) - dataDir := t.TempDir() - spy, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - var wg sync.WaitGroup - errs := make([]error, 8) - for i := range errs { - wg.Add(1) - go func(i int) { - defer wg.Done() - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - errs[i] = outcomeError(prepared.Execute(context.Background())) - }(i) - } - wg.Wait() - for i, err := range errs { - testassert.Check(t, err == nil, "caller %d: %v", i, err) - } - testassert.Equal(t, spy.count(), 1) -} - -func outcomeError(outcome harness.ToolOutcome) error { - if outcome.Result.Status == model.ResultError || outcome.Result.Status == model.ResultDenied { - return errors.New(outcome.Result.Content) - } - return nil -} - -// TestDetectionOnceIncludingEmptyMap: after one call detected nothing (no -// markers, no servers), later calls reuse the same manager — including its -// empty instance map — without re-detection. -func TestDetectionOnceIncludingEmptyMap(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - t.Setenv("HOME", fakeHome) - spy := &managerSpy{} - spy.install(t, fakeHome) - dataDir := t.TempDir() - _, symbol := pluginUnderTest(t, dataDir) - workspace := t.TempDir() // no markers: detection finds nothing - - for i := 0; i < 3; i++ { - prepared := symbol.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"x"}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultError && outcome.Result.Content == "No language servers available.", "call %d = %+v, want the zero-instance unavailability error", i+1, outcome.Result) - } - testassert.Equal(t, spy.count(), 1) -} - -// TestCanceledWaiterDoesNotCancelDetection: a caller canceled during the -// shared detection wait settles interrupted while the detection continues and -// a second caller succeeds against the started server. -func TestCanceledWaiterDoesNotCancelDetection(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0.4) - dataDir := t.TempDir() - spy, _, symbol, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - first, cancelFirst := context.WithCancel(context.Background()) - prepared := symbol.Prepare(first, runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c1", "workspace_symbol", `{"query":"x"}`)) - done := make(chan harness.ToolOutcome, 1) - go func() { done <- prepared.Execute(first) }() - time.Sleep(100 * time.Millisecond) - cancelFirst() - - select { - case outcome := <-done: - testassert.Require(t, outcome.Result.Status == model.ResultInterrupted, "canceled waiter = %+v, want interrupted", outcome.Result) - testassert.Require(t, outcome.Result.Content != "", "interrupted result carries empty content") - case <-time.After(10 * time.Second): - t.Fatal("canceled waiter never settled") - } - - // The detection continues: a second caller waits it out and queries the - // started server. - second := symbol.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c2", "workspace_symbol", `{"query":"x"}`)) - outcome := second.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "second caller = %+v, want success against the started server", outcome.Result) - testassert.Equal(t, spy.count(), 1) - waitForLog(t, requests, "initialize", 1) -} - -// TestDetectionUnderCanceledRuntimeYieldsNoUsableServers: the plugin's -// lifetime is canceled after Open but before the first authorized use; -// detection runs under it, no usable server results, and no later call -// resurrects one. The live-lifetime sibling proves the same fixtures serve -// real queries, so the assertion discriminates. -func TestDetectionUnderCanceledRuntimeYieldsNoUsableServers(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - spy := &managerSpy{} - spy.install(t, fakeHome) - dataDir := t.TempDir() - workspace := markerWorkspace(t) - seedGroup(t, dataDir, pluginSessionID, groupOne, filepath.Join(workspace, "a.go")) - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "a.go"), []byte("package w\n"), 0o600)) - - lifetime, cancel := context.WithCancel(context.Background()) - inst, err := Plugin().Open(lifetime, runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dataDir}, runtime.Bindings{}) - testassert.NoError(t, err) - t.Cleanup(func() { _ = inst.Close() }) - diag := inst.Values["diagnostics"].(runtime.Tool) - // The lifetime is canceled after Open but before the first authorized - // use: detection runs under it, and the owner cancellation is what the - // detection's servers inherit. - cancel() - - for i := 0; i < 2; i++ { - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - refused := filepath.Join(workspace, "a.go") + ": no language server for this file (could not check)" - testassert.Require(t, outcome.Result.Status == model.ResultError && outcome.Result.Content == refused, "call %d under the canceled lifetime = %+v, want the per-path could-not-check error", i+1, outcome.Result) - } - testassert.Equal(t, logCount(t, requests, "textDocument/didOpen"), 0) - - // The live-lifetime sibling: the same fixtures start the server and the - // query reaches it. - diagLive, _ := openPlugin(t, Plugin(), dataDir) - prepared := diagLive.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "live-lifetime diagnostics = %+v, want success", outcome.Result) - waitForQuery(t, requests, fileURI(workspace, "a.go"), 1) - testassert.Equal(t, spy.count(), 2) -} - -// TestCloseJoinsInFlightDetection: Close waits for the shared detection to -// finish before shutting the managers down, and every server the detection -// started is torn down. -func TestCloseJoinsInFlightDetection(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0.4) - spy := &managerSpy{} - spy.install(t, fakeHome) - dataDir := t.TempDir() - workspace := markerWorkspace(t) - - plugin := Plugin() - inst, err := plugin.Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dataDir}, runtime.Bindings{}) - testassert.NoError(t, err) - symbol := inst.Values["workspace_symbol"].(runtime.Tool) - - // Start detection as a first authorized use and wait until its server - // process is launched: detection is now provably in flight (the 0.4s - // initialize delay holds it). - useCtx, cancelUse := context.WithCancel(context.Background()) - defer cancelUse() - prepared := symbol.Prepare(useCtx, runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"x"}`)) - go prepared.Execute(useCtx) - waitForLog(t, requests, "started", 1) - - // Close while detection is in flight: it must join before returning. - start := time.Now() - testassert.NoError(t, inst.Close()) - { - elapsed := time.Since(start) - testassert.Require(t, elapsed >= 300*time.Millisecond, "Close returned after %s; it must join the in-flight detection (init delay 0.4s)", elapsed) - } - testassert.Equal(t, spy.count(), 1) - waitForLog(t, requests, "initialize", 1) -} - -// TestDeniedAndPreparedCallsStartNothing: preparation of both tools, and a -// failed canonical preparation — a symlink-loop workspace root the path -// resolver rejects — constructs no manager and reads nothing; the failed -// preparation is the immediate fixed denial. -func TestDeniedAndPreparedCallsStartNothing(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - t.Setenv("HOME", fakeHome) - spy := &managerSpy{} - spy.install(t, fakeHome) - dataDir := t.TempDir() - diag, symbol := pluginUnderTest(t, dataDir) - workspace := t.TempDir() - - diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - symbol.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"x"}`)) - - // The symlink-loop workspace root: canonicalization fails and the - // preparation is denied without any manager construction. - loopA := filepath.Join(t.TempDir(), "loop-a") - loopB := filepath.Join(t.TempDir(), "loop-b") - testassert.NoError(t, os.Symlink(loopB, loopA)) - testassert.NoError(t, os.Symlink(loopA, loopB)) - - for _, tool := range []runtime.Tool{diag, symbol} { - prepared := tool.Prepare(context.Background(), runtime.ToolContext{Workspace: loopA, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"x"}`)) - testassert.Require(t, prepared.Immediate != nil, "symlink-loop workspace preparation = %+v, want an immediate denied outcome", prepared) - testassert.Require(t, prepared.Immediate.Result.Status == model.ResultDenied && prepared.Immediate.Result.Content == "Permission denied.", "symlink-loop workspace preparation = %+v, want the fixed denial", prepared.Immediate.Result) - testassert.Require(t, prepared.Execute == nil, "the denied preparation carries an executor") - } - - testassert.Equal(t, spy.count(), 0) -} - -// TestDiagnosticsNoCursorAcrossCalls: two seeded groups from two "operations" -// are both queried on every call — no cursor, no group-shaped result, no -// checked-group set. -func TestDiagnosticsNoCursorAcrossCalls(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - dataDir := t.TempDir() - _, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - seedGroup(t, dataDir, pluginSessionID, groupOne, filepath.Join(workspace, "a.go")) - seedGroup(t, dataDir, pluginSessionID, groupTwo, filepath.Join(workspace, "b.go")) - for _, name := range []string{"a.go", "b.go"} { - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, name), []byte("package w\n"), 0o600)) - } - uriA, uriB := fileURI(workspace, "a.go"), fileURI(workspace, "b.go") - - for call := 1; call <= 2; call++ { - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess && outcome.Result.Content == "No errors found.", "call %d = %+v, want success; call %d content = %q, want the clean no-error text", call, outcome.Result, call, outcome.Result.Content) - waitForQuery(t, requests, uriA, call) - waitForQuery(t, requests, uriB, call) - } -} - -// TestDiagnosticsExcludesOtherSessions: another session's groups are never -// read. -func TestDiagnosticsExcludesOtherSessions(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - dataDir := t.TempDir() - _, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - seedGroup(t, dataDir, pluginSessionID, groupOne, filepath.Join(workspace, "a.go")) - seedGroup(t, dataDir, otherSessionID, groupTwo, filepath.Join(workspace, "b.go")) - for _, name := range []string{"a.go", "b.go"} { - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, name), []byte("package w\n"), 0o600)) - } - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "diagnostics = %+v, want success", outcome.Result) - waitForQuery(t, requests, fileURI(workspace, "a.go"), 1) - testassert.Equal(t, logCount(t, requests, fileURI(workspace, "b.go")), 0) -} - -// TestDiagnosticsDropsOutsideRootPaths: an outside-root canonical path is -// dropped un-read. -func TestDiagnosticsDropsOutsideRootPaths(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - dataDir := t.TempDir() - _, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - seedGroup(t, dataDir, pluginSessionID, groupOne, filepath.Join(workspace, "a.go")) - outside := filepath.Join(t.TempDir(), "outside.go") - seedGroup(t, dataDir, pluginSessionID, groupTwo, outside) - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "a.go"), []byte("package w\n"), 0o600)) - testassert.NoError(t, os.WriteFile(outside, []byte("package outside\n"), 0o600)) - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "diagnostics = %+v, want success", outcome.Result) - waitForQuery(t, requests, fileURI(workspace, "a.go"), 1) - { - n := logCount(t, requests, "outside.go") - testassert.Require(t, n == 0, "the outside-root path was read despite being dropped") - } -} - -// TestSymlinkedWorkspaceBindsToRealRoot: a symlinked lexical Workspace binds -// to the real root and the tool succeeds against it. -func TestSymlinkedWorkspaceBindsToRealRoot(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - dataDir := t.TempDir() - spy, diag, _, realRoot := newPluginWorkspace(t, fakeHome, dataDir) - testassert.NoError(t, os.WriteFile(filepath.Join(realRoot, "a.go"), []byte("package w\n"), 0o600)) - seedGroup(t, dataDir, pluginSessionID, groupOne, filepath.Join(realRoot, "a.go")) - link := filepath.Join(t.TempDir(), "link") - testassert.NoError(t, os.Symlink(realRoot, link)) - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: link, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - testassert.Equal(t, prepared.CanonicalWorkspace, realRoot) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "diagnostics = %+v, want success through the symlinked lexical workspace", outcome.Result) - waitForQuery(t, requests, fileURI(realRoot, "a.go"), 1) - if spy.root() != realRoot { - t.Fatalf("manager root = %q, want the canonical real root", spy.root()) - } -} - -// TestPostAuthorizationBindingChangeFailsWithoutStart: after preparation, a -// repointed workspace symlink fails execution with an error outcome, starts -// nothing, and never substitutes the new root. -func TestPostAuthorizationBindingChangeFailsWithoutStart(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - t.Setenv("HOME", fakeHome) - dataDir := t.TempDir() - spy, diag, _, realRoot := newPluginWorkspace(t, fakeHome, dataDir) - link := filepath.Join(t.TempDir(), "link") - testassert.NoError(t, os.Symlink(realRoot, link)) - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: link, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - testassert.Equal(t, prepared.CanonicalWorkspace, realRoot) - - // Repoint the lexical workspace at a different root after authorization. - otherRoot := t.TempDir() - testassert.NoError(t, os.Remove(link)) - testassert.NoError(t, os.Symlink(otherRoot, link)) - - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultError && strings.Contains(outcome.Result.Content, "binding changed"), "changed binding = %+v, want an error outcome; changed binding content = %q", outcome.Result, outcome.Result.Content) - testassert.Equal(t, spy.count(), 0) -} - -// TestCallerCancellationDuringQueryIsInterrupted: cancellation during the -// shared client's sync wait settles interrupted with the bounded partial -// content. -func TestCallerCancellationDuringQueryIsInterrupted(t *testing.T) { - fakeHome, requests := fakeServerHome(t, 0) - dataDir := t.TempDir() - _, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - // One healthy file (its sync proves the opens completed) and one - // replaced-symlink leaf (its canonical read fails, so partial - // could-not-check content accumulates before the sync wait). - healthy := filepath.Join(workspace, "a.go") - testassert.NoError(t, os.WriteFile(healthy, []byte("package w\n"), 0o600)) - seedGroup(t, dataDir, pluginSessionID, groupOne, healthy) - target := filepath.Join(t.TempDir(), "target.go") - testassert.NoError(t, os.WriteFile(target, []byte("package target\n"), 0o600)) - swapped := filepath.Join(workspace, "swapped.go") - testassert.NoError(t, os.WriteFile(swapped, []byte("placeholder\n"), 0o600)) - testassert.NoError(t, os.Remove(swapped)) - testassert.NoError(t, os.Symlink(target, swapped)) - seedGroup(t, dataDir, pluginSessionID, groupTwo, swapped) - - ctx, cancel := context.WithCancel(context.Background()) - prepared := diag.Prepare(ctx, runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - done := make(chan harness.ToolOutcome, 1) - go func() { done <- prepared.Execute(ctx) }() - // The healthy file's document sync proves the opens are done and the - // shared body sits in its 500ms sync wait. - waitForQuery(t, requests, fileURI(workspace, "a.go"), 1) - cancel() - - select { - case outcome := <-done: - testassert.Require(t, outcome.Result.Status == model.ResultInterrupted, "canceled query = %+v, want interrupted", outcome.Result) - testassert.Require(t, strings.Contains(outcome.Result.Content, "(could not check)"), "interrupted content = %q, want the bounded partial could-not-check line", outcome.Result.Content) - testassert.Require(t, len(outcome.Result.Content) <= 15360, "interrupted content = %d bytes, want the bounded partial", len(outcome.Result.Content)) - case <-time.After(10 * time.Second): - t.Fatal("canceled query never settled") - } -} - -// TestDiagnosticsListingFailureIsError: a valid Session identity whose -// enumeration really fails settles as an error carrying that failure — never -// a successful text line that reads as a clean query. -func TestDiagnosticsListingFailureIsError(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - dataDir := t.TempDir() - // The valid Session identity's session path is a regular file: the - // enumeration fails for real. - testassert.NoError(t, os.MkdirAll(filepath.Join(dataDir, "code"), 0o755)) - testassert.NoError(t, os.WriteFile(filepath.Join(dataDir, "code", pluginSessionID), []byte("x"), 0o600)) - _, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultError && strings.Contains(outcome.Result.Content, "not a directory"), "listing failure = %+v, want an error outcome carrying the enumeration failure; listing failure content = %q, want the actual enumeration error", outcome.Result, outcome.Result.Content) -} - -// TestDiagnosticsNoFiles: an empty data root settles as the retained no-files -// content. -func TestDiagnosticsNoFiles(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - dataDir := t.TempDir() - _, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess && outcome.Result.Content == "No files have been modified.", "no files = %+v, want the retained no-files content", outcome.Result) -} - -// TestWorkspaceSymbolEmptyQueryRejected: a present empty query is an -// argument-validation error at Normalize, and a direct Prepare of the same -// input is the immediate validation error before any service start. -func TestWorkspaceSymbolEmptyQueryRejected(t *testing.T) { - symbol := workspaceSymbolTool{inst: &instance{}} - { - _, err := symbol.Normalize(runtime.ToolContext{}, toolCall("c", "workspace_symbol", `{"query":""}`)) - testassert.Require(t, err != nil, "workspace_symbol Normalize(empty query) = nil error, want an argument-validation error") - } - - fakeHome, _ := fakeServerHome(t, 0) - dataDir := t.TempDir() - spy, _, symbolTool, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - prepared := symbolTool.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":""}`)) - testassert.Require(t, prepared.Immediate != nil && prepared.Immediate.Result.Status == model.ResultError, "empty-query preparation = %+v, want the immediate validation error", prepared) - testassert.Require(t, prepared.Execute == nil, "the invalid-query preparation carries an executor") - testassert.Equal(t, spy.count(), 0) -} - -// TestWorkspaceSymbolAllFailedIsError: a server whose workspace/symbol reply -// cannot be decoded contributes no symbols, so no instance answered and the -// entry returns the fixed unavailability error, never a clean no-symbols -// result. -func TestWorkspaceSymbolAllFailedIsError(t *testing.T) { - home := t.TempDir() - plantServer(t, home, "gopls", "gopls", 0, "", "1", "") - dataDir := t.TempDir() - _, _, symbol, workspace := newPluginWorkspace(t, home, dataDir) - - prepared := symbol.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"Foo"}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultError && outcome.Result.Content == "No language servers available for the symbol query.", "undecodable symbol reply = %+v, want the fixed unavailability error", outcome.Result) -} - -// TestWorkspaceSymbolEmptyReplyIsSuccessful: a valid empty symbol list is a -// successful reply — a lone empty responder yields the success outcome with -// the responding-servers text, never the all-failed unavailability error. -func TestWorkspaceSymbolEmptyReplyIsSuccessful(t *testing.T) { - home := t.TempDir() - plantServer(t, home, "gopls", "gopls", 0, "", "", "1") - dataDir := t.TempDir() - _, _, symbol, workspace := newPluginWorkspace(t, home, dataDir) - - prepared := symbol.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"Foo"}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess && strings.Contains(outcome.Result.Content, "No symbols were reported by the responding language servers."), "empty symbol reply = %+v, want the success outcome for a responding server; empty symbol reply content = %q, want the responding-servers text", outcome.Result, outcome.Result.Content) -} - -// TestNormalizeContracts: the strict decode, the private-field stripping, -// the retained unrelated members and the consumed query field. -func TestNormalizeContracts(t *testing.T) { - diag := diagnosticsTool{inst: &instance{}} - symbol := workspaceSymbolTool{inst: &instance{}} - tc := runtime.ToolContext{} - - got, err := diag.Normalize(tc, toolCall("c", "diagnostics", `{"_lightcode_receipt":"forged","unrelated":1}`)) - testassert.NoError(t, err) - testassert.Require(t, string(got) == `{"unrelated":1}`, "diagnostics normalized = %s, want the stripped retained members", got) - for _, bad := range []string{`null`, `[1]`, `{`, `{} trailing`} { - { - _, err := diag.Normalize(tc, toolCall("c", "diagnostics", bad)) - testassert.Check(t, err != nil, "diagnostics Normalize(%q) = nil error, want an argument-validation error", bad) - } - } - - got, err = symbol.Normalize(tc, toolCall("c", "workspace_symbol", `{"query":"x","_lightcode_receipt":"forged","other":"kept"}`)) - testassert.NoError(t, err) - testassert.Require(t, strings.Contains(string(got), `"query":"x"`) && strings.Contains(string(got), `"other":"kept"`) && !strings.Contains(string(got), "_lightcode_"), "workspace_symbol normalized = %s", got) - for _, bad := range []string{`{}`, `{"query":null}`, `{"query":5}`, `{"query":["x"]}`, `{"query":""}`} { - { - _, err := symbol.Normalize(tc, toolCall("c", "workspace_symbol", bad)) - testassert.Check(t, err != nil, "workspace_symbol Normalize(%q) = nil error, want an argument-validation error", bad) - } - } -} - -// TestDescriptionsAndPreparationShape: the diagnostics description, the -// verbatim workspace_symbol schema, and the one workspace.inspect pair. -func TestDescriptionsAndPreparationShape(t *testing.T) { - diagDesc, err := diagnosticsTool{}.describe(runtime.Invocation{}, runtime.ToolConstraints{}, harness.SessionIdentity{}) - testassert.NoError(t, err) - testassert.Require(t, diagDesc.Definition.Description == "Check for compilation errors in the files this Session has changed. Call after editing to verify correctness. Returns errors only (not warnings)." && diagDesc.Definition.Name == "diagnostics" && diagDesc.Available, "got %v, want %v; diagnostics definition = %+v", diagDesc.Definition.Description, "Check for compilation errors in the files this Session has changed. Call after editing to verify correctness. Returns errors only (not warnings).", diagDesc.Definition) - - symbolDesc, err := workspaceSymbolTool{}.describe(runtime.Invocation{}, runtime.ToolConstraints{}, harness.SessionIdentity{}) - testassert.NoError(t, err) - testassert.Equal(t, symbolDesc.Definition.Description, "Search for symbols by name across the project. Returns matching functions, types, variables, etc. with their file locations.") - var schema struct { - Properties map[string]any `json:"properties"` - Required []string `json:"required"` - } - testassert.NoError(t, json.Unmarshal(symbolDesc.Definition.Parameters, &schema)) - { - _, ok := schema.Properties["query"] - testassert.Require(t, ok && len(schema.Required) == 1 && schema.Required[0] == "query", "workspace_symbol schema = %s", symbolDesc.Definition.Parameters) - } - - dataDir := t.TempDir() - workspace := markerWorkspace(t) - for i, tool := range []runtime.Tool{diagnosticsTool{inst: &instance{dataDir: dataDir}}, workspaceSymbolTool{inst: &instance{dataDir: dataDir}}} { - args := `{}` - if i == 1 { - // The symbol tool's required query: an absent one is the - // immediate validation error, not a preparation shape. - args = `{"query":"x"}` - } - prepared := tool.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", args)) - testassert.Require(t, len(prepared.Permissions) == 1, "permissions = %+v, want exactly one pair", prepared.Permissions) - pair := prepared.Permissions[0] - testassert.Require(t, pair.Permission == "workspace.inspect" && pair.Target == workspace, "permission pair = %+v, want workspace.inspect on the canonical root", pair) - testassert.Require(t, prepared.CanonicalWorkspace == workspace && prepared.CanonicalWriteDir == "", "canonical binding = %q/%q, want the root and no write dir", prepared.CanonicalWorkspace, prepared.CanonicalWriteDir) - } -} - -// TestContentCap: the at-limit and over-limit rows, the multi-byte character -// straddling the boundary, and the cap on error content. -func TestContentCap(t *testing.T) { - { - got := capModelContent(strings.Repeat("a", 15360)) - testassert.Require(t, got == strings.Repeat("a", 15360), "at-limit content must be unchanged") - } - over := strings.Repeat("a", 15361) - got := capModelContent(over) - testassert.Require(t, len(got) == 15360 && strings.HasSuffix(got, "\n[Output truncated]"), "over-limit cap = %d bytes, want 15360 with the marker", len(got)) - testassert.Require(t, strings.HasPrefix(got, over[:15360-len("\n[Output truncated]")]), "cap did not keep the longest fitting prefix") - - // A multi-byte character straddling the boundary: the cut backs off to - // the rune boundary and the result stays valid UTF-8. The first € starts - // before the budget cut and covers it, so an unguarded slice would cut - // mid-character. - prefix := strings.Repeat("a", 15360-len(truncationMarker)-2) - straddled := prefix + "€€€€€€€€€€" // each € is three bytes - got = capModelContent(straddled) - testassert.Require(t, len(got) <= 15360 && strings.HasSuffix(got, "\n[Output truncated]"), "straddled cap = %d bytes, want the marker within 15360", len(got)) - testassert.Require(t, utf8.ValidString(got), "cap produced invalid UTF-8") - testassert.Require(t, strings.HasPrefix(got, prefix), "cap lost the complete prefix") - - // Error content is capped by the same rule. - outcome := toolOutcome("c", context.Background(), "", errors.New(strings.Repeat("e", 20000))) - testassert.Require(t, outcome.Result.Status == model.ResultError && len(outcome.Result.Content) == 15360, "error cap = %+v (%d bytes), want the capped error content", outcome.Result, len(outcome.Result.Content)) - - // Success content is capped by the same rule. - outcome = toolOutcome("c", context.Background(), strings.Repeat("s", 20000), nil) - testassert.Require(t, outcome.Result.Status == model.ResultSuccess && len(outcome.Result.Content) == 15360, "success cap = %+v (%d bytes), want the capped content", outcome.Result, len(outcome.Result.Content)) -} - -// TestDiagnosticsBulkReadFailuresStayBounded proves the bulk-failure row: a -// session group with ~1000 unreadable canonical paths (dangling symlink -// leaves) refuses every leaf, so none synchronizes and the collected -// could-not-check failure is the error outcome, capped at the 15360-byte bound -// by the truncation marker — bounded content through the all-failed error path. -func TestDiagnosticsBulkReadFailuresStayBounded(t *testing.T) { - fakeHome, _ := fakeServerHome(t, 0) - dataDir := t.TempDir() - _, diag, _, workspace := newPluginWorkspace(t, fakeHome, dataDir) - - const count = 1000 - for i := 0; i < count; i++ { - leaf := filepath.Join(workspace, fmt.Sprintf("leaf%04d.go", i)) - testassert.NoError(t, os.Symlink(filepath.Join(workspace, "missing-target.go"), leaf)) - seedEntry(t, dataDir, pluginSessionID, groupOne, fmt.Sprintf("e%04d", i), leaf) - } - - prepared := diag.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "diagnostics", `{}`)) - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultError, "bulk-failure diagnostics = %+v, want the all-failed error outcome", outcome.Result) - content := outcome.Result.Content - testassert.Require(t, len(content) <= 15360, "bulk-failure content = %d bytes, want the 15360 cap", len(content)) - if !strings.HasSuffix(content, "\n[Output truncated]") { - t.Fatalf("bulk-failure content tail = %q, want the truncation marker", content[len(content)-60:]) - } - testassert.Require(t, strings.Contains(content, "(could not check)"), "bulk-failure content = %.120s, want could-not-check lines", content) -} - -// TestPluginInstallsReportWarningBeforeDetect proves the plugin captures the -// Runtime's ReportWarning callback at Open, installs it on every lazy -// workspace manager before its detection goroutine exists, and delivers the -// real detection-time warning through the real manager: with a cold HOME -// and an empty PATH no server resolves anywhere, every marker's install -// fails, and the manager's report reaches the Runtime closure. A -// nil callback is the harmless no-report shape the rest of this suite runs -// under. -func TestPluginInstallsReportWarningBeforeDetect(t *testing.T) { - t.Setenv("HOME", t.TempDir()) // cold LSP cache: nothing planted - t.Setenv("PATH", t.TempDir()) // no resolvable server anywhere - workspace := markerWorkspace(t) - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "CMakeLists.txt"), []byte("cmake_minimum_required(VERSION 3.10)\n"), 0o600)) - - var mu sync.Mutex - var reported []string - report := func(kind, message string) { - mu.Lock() - reported = append(reported, kind+"|"+message) - mu.Unlock() - } - // The real plugin Open captures the Runtime's closure from its scope - // identity — the only path the report below can arrive through. - instance, err := Plugin().Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: t.TempDir(), ReportWarning: report}, runtime.Bindings{}) - testassert.NoError(t, err) - t.Cleanup(func() { _ = instance.Close() }) - symbol := instance.Values["workspace_symbol"].(runtime.Tool) - - // The tool execution registers the workspace, installs the reporter, and - // starts detection: with no resolvable binary anywhere the detected - // servers fail their installations and the manager reports each through - // the installed callback — the zero-instance error proves detection - // completed, so the reports are not racing a later goroutine. - prepared := symbol.Prepare(context.Background(), runtime.ToolContext{Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: pluginSessionID}}, toolCall("c", "workspace_symbol", `{"query":"Foo"}`)) - { - outcome := prepared.Execute(context.Background()) - testassert.Require(t, outcome.Result.Status == model.ResultError && outcome.Result.Content == "No language servers available.", "missing-server query = %+v, want the cold no-server error after detection", outcome.Result) - } - deadline := time.Now().Add(10 * time.Second) - for { - mu.Lock() - count := len(reported) - mu.Unlock() - if count > 0 { - break - } - testassert.Require(t, !time.Now().After(deadline), "the manager's detection warning never reached the Runtime closure") - time.Sleep(5 * time.Millisecond) - } - mu.Lock() - defer mu.Unlock() - for _, report := range reported { - if strings.Contains(report, "lsp_install_failed") { - return - } - } - t.Fatalf("reported = %v, want the real install-failure warning", reported) -} diff --git a/internal/plugins/sqlite/sqlite.go b/internal/plugins/sqlite/sqlite.go deleted file mode 100644 index dff49bd9..00000000 --- a/internal/plugins/sqlite/sqlite.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package sqlite declares Lightcode's production durable-session-storage -// plugin. It is an ordinary composition unit selected through static -// registration: the same Plugin/Instance contract an externally supplied -// capability implements, with no additional Runtime authority. -package sqlite - -import ( - "context" - "path/filepath" - - "github.com/MMinasyan/lightcode/harness" - "github.com/MMinasyan/lightcode/internal/storage" - "github.com/MMinasyan/lightcode/runtime" -) - -// sessionStoreID is the capability ID under which the single export is -// declared. -const sessionStoreID = "session_store" - -// Plugin returns the Runtime-scoped plugin whose single export is declared -// exactly as harness.Storage, the private Core storage binding identified by -// type. It has no dependencies and no settings validator. -func Plugin() runtime.Plugin { - return runtime.Plugin{ - ID: "sqlite", - Scope: runtime.ScopeRuntime, - Provides: []runtime.CapabilitySpec{ - runtime.Spec[harness.Storage](sessionStoreID), - }, - Open: open, - } -} - -// open checks the scope context, opens the SQLite database derived from the -// owner data root, and returns the declared store plus its Close method. -func open(ctx context.Context, info runtime.ScopeInfo, _ runtime.Bindings) (runtime.Instance, error) { - if err := ctx.Err(); err != nil { - return runtime.Instance{}, err - } - store, err := storage.OpenSQLite(filepath.Join(info.DataDir, "lightcode.db")) - if err != nil { - return runtime.Instance{}, err - } - return runtime.Instance{ - Values: map[string]any{sessionStoreID: store}, - Close: store.Close, - }, nil -} diff --git a/internal/prompt/adaptation_assembly_test.go b/internal/prompt/adaptation_assembly_test.go deleted file mode 100644 index 6556ba5b..00000000 --- a/internal/prompt/adaptation_assembly_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package prompt - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/MMinasyan/lightcode/internal/adaptation" - "github.com/MMinasyan/lightcode/internal/testassert" -) - -// An adaptation with no additions produces the same prompt as baseline; -// only the presence of additions/blocks changes the output. -func TestAssembleForNoAdditionsMatchesBaseline(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("some user rules"), 0o600)) - base := assembleFull(projectRoot, home, nil).Prompt - adapt := assembleFull(projectRoot, home, &adaptation.Adaptation{}).Prompt - testassert.Equal(t, base, adapt) -} - -// Bullet 2: coaching blocks are inserted after the overridable sections and before -// user rules, without occupying or suppressing a section slot. -func TestAssembleForInsertsBlocksAfterSectionsBeforeRules(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - // User rules with a unique marker and NO section-override headings, so every - // overridable section renders. - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("PROJECT_USER_RULES_MARKER"), 0o600)) - prompt := assembleFull(projectRoot, home, &adaptation.Adaptation{Blocks: []string{"COACHING_BLOCK_MARKER"}}).Prompt - - idxLanguage := strings.Index(prompt, strings.TrimSpace(languageSection)) // last overridable section - idxBlock := strings.Index(prompt, "COACHING_BLOCK_MARKER") - idxRules := strings.Index(prompt, "PROJECT_USER_RULES_MARKER") - testassert.Require(t, idxLanguage >= 0 && idxBlock >= 0 && idxRules >= 0, "missing anchor: language=%d block=%d rules=%d", idxLanguage, idxBlock, idxRules) - endLanguage := idxLanguage + len(strings.TrimSpace(languageSection)) - testassert.Require(t, (endLanguage <= idxBlock && idxBlock < idxRules), "block not between last section and user rules: endLanguage=%d block=%d rules=%d", endLanguage, idxBlock, idxRules) - // The block does not suppress any overridable section. - for name, section := range overridableSections { - testassert.Require(t, strings.Contains(prompt, strings.TrimSpace(section)), "overridable section %q suppressed by the block", name) - } - // detectOverrides is unaffected: no section heading in the rules, none overridden. - { - ov := detectOverrides("PROJECT_USER_RULES_MARKER") - testassert.Require(t, !ov["safety"] && !ov["tone"] && !ov["task_execution"] && !ov["language"], "detectOverrides false positive from a block-only adaptation: %+v", ov) - } -} - -// Section additions: an adaptation with Additions but no Blocks renders each -// addition immediately after its section main and before the next section. -func TestAssembleForRendersSectionAddition(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("PROJECT_USER_RULES_MARKER"), 0o600)) - adapt := &adaptation.Adaptation{ - Additions: map[string]string{"tone": "TONE_ADDITION_MARKER"}, - } - prompt := assembleFull(projectRoot, home, adapt).Prompt - - idxToneMain := strings.Index(prompt, strings.TrimSpace(toneSection)) - idxToneAdd := strings.Index(prompt, "TONE_ADDITION_MARKER") - idxTask := strings.Index(prompt, strings.TrimSpace(taskExecutionSection)) - idxRules := strings.Index(prompt, "PROJECT_USER_RULES_MARKER") - testassert.Require(t, idxToneMain >= 0 && idxToneAdd >= 0 && idxTask >= 0 && idxRules >= 0, "missing anchor: toneMain=%d toneAdd=%d task=%d rules=%d", idxToneMain, idxToneAdd, idxTask, idxRules) - endToneMain := idxToneMain + len(strings.TrimSpace(toneSection)) - testassert.Require(t, (endToneMain <= idxToneAdd && idxToneAdd < idxTask && idxTask < idxRules), "addition not between its section main and the next section: endToneMain=%d toneAdd=%d task=%d", endToneMain, idxToneAdd, idxTask) -} - -// Section additions render even when a user heading overrides the section main. -func TestAssembleForAdditionRendersUnderUserOverride(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - // A user heading suppresses the tone main, but the tone addition must still render. - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("# tone\n\nUSER_TONE_RULES_MARKER"), 0o600)) - adapt := &adaptation.Adaptation{ - Additions: map[string]string{"tone": "TONE_ADDITION_MARKER"}, - } - prompt := assembleFull(projectRoot, home, adapt).Prompt - - testassert.Require(t, !strings.Contains(prompt, strings.TrimSpace(toneSection)), "user override did not suppress the tone main") - idxToneAdd := strings.Index(prompt, "TONE_ADDITION_MARKER") - idxUser := strings.Index(prompt, "USER_TONE_RULES_MARKER") - testassert.Require(t, idxToneAdd >= 0 && idxUser >= 0 && idxToneAdd < idxUser, "missing anchor: toneAdd=%d user=%d; tone addition (%d) must render before user rules (%d)", idxToneAdd, idxUser, idxToneAdd, idxUser) -} - -// Section additions apply independently per section id. -func TestAssembleForAdditionPerSection(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("PROJECT_USER_RULES_MARKER"), 0o600)) - - for _, name := range overridableOrder { - t.Run(name, func(t *testing.T) { - marker := strings.ToUpper(name) + "_ADDITION_MARKER" - adapt := &adaptation.Adaptation{ - Additions: map[string]string{name: marker}, - } - prompt := assembleFull(projectRoot, home, adapt).Prompt - testassert.Require(t, strings.Contains(prompt, marker), "addition for %q missing from prompt", name) - for _, other := range overridableOrder { - if other == name { - continue - } - testassert.Require(t, !strings.Contains(prompt, strings.ToUpper(other)+"_ADDITION_MARKER"), "addition for %q leaked into %q's test", other, name) - } - }) - } -} diff --git a/internal/prompt/assembler.go b/internal/prompt/assembler.go deleted file mode 100644 index d91fef87..00000000 --- a/internal/prompt/assembler.go +++ /dev/null @@ -1,315 +0,0 @@ -package prompt - -import ( - _ "embed" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "time" - - "github.com/MMinasyan/lightcode/internal/adaptation" -) - -//go:embed identity.md -var identitySection string - -//go:embed core_rules.md -var coreRulesSection string - -//go:embed rules_file_guide.md -var rulesFileGuideSection string - -//go:embed compaction_awareness.md -var compactionAwarenessSection string - -//go:embed safety.md -var safetySection string - -//go:embed tone.md -var toneSection string - -//go:embed task_execution.md -var taskExecutionSection string - -//go:embed language.md -var languageSection string - -var overridableOrder = []string{"safety", "tone", "task_execution", "language"} - -var overridableSections = map[string]string{ - "safety": safetySection, - "tone": toneSection, - "task_execution": taskExecutionSection, - "language": languageSection, -} - -const ( - SizeNone = "none" - SizeSimple = "simple" - SizeFull = "full" - - WarnRulesTooLarge = "rules_too_large" - WarnRulesNotFound = "rules_not_found" - WarnRulesReadError = "rules_read_error" - WarnLSPInstallFailed = "lsp_install_failed" - WarnLSPServerUnavailable = "lsp_server_unavailable" -) - -const agentPromptHeading = "## Your Role and Instructions" - -type Warning struct { - Kind string - Message string -} - -type Result struct { - Prompt string - Warnings []Warning -} - -type Spec struct { - Size string - Body string - Adapt *adaptation.Adaptation -} - -// Service assembles system prompts. It is stateless: it owns no cache and no -// per-session state, so one instance is shared across every unit. Simple/full -// calls read current global and project rules; none skips rules reads. Every -// call renders the caller's project root and session start, returning one -// immutable prompt. -type Service struct { - home string -} - -func NewService(home string) *Service { - return &Service{home: home} -} - -// Assemble builds the system prompt for one unit: its project root, its fixed -// session start, and the given spec. For the simple and full sizes it reads -// the current global and project rules fresh on every call (none skips rules -// reads) and holds nothing between calls. -func (s *Service) Assemble(projectRoot string, sessionStart time.Time, spec Spec) Result { - spec = normalizeSpec(spec) - var warnings []Warning - - var globalContent, projectContent string - if spec.Size != SizeNone { - var err error - globalContent, err = readRulesFile(filepath.Join(s.home, ".lightcode")) - if err != nil { - warnings = append(warnings, Warning{Kind: WarnRulesReadError, Message: "Failed to read global rules file: " + err.Error()}) - globalContent = "" - } - - projectContent, err = readRulesFile(projectRoot) - if err != nil { - warnings = append(warnings, Warning{Kind: WarnRulesReadError, Message: "Failed to read project rules file: " + err.Error()}) - projectContent = "" - } - - if projectContent == "" && globalContent == "" { - warnings = append(warnings, Warning{Kind: WarnRulesNotFound, Message: "No AGENTS.md found"}) - } - } - - combined := globalContent + "\x00" + projectContent - - if spec.Size != SizeNone && len(combined) > 20000 { - warnings = append(warnings, Warning{Kind: WarnRulesTooLarge, Message: fmt.Sprintf("Rules file exceeds 20,000 characters (%d chars). Consider trimming it.", len(combined))}) - } - - return Result{ - Prompt: buildSpec(projectRoot, sessionStart, globalContent, projectContent, spec), - Warnings: warnings, - } -} - -func buildSpec(projectRoot string, sessionStart time.Time, globalRules, projectRules string, spec Spec) string { - spec = normalizeSpec(spec) - var b strings.Builder - - if spec.Size == SizeNone { - writeAgentBody(&b, spec.Body) - return strings.TrimSpace(b.String()) - } - - for _, s := range defaultSectionsForSize(spec.Size) { - b.WriteString(strings.TrimSpace(s)) - b.WriteString("\n\n") - } - - b.WriteString(renderEnvironment(projectRoot, sessionStart)) - b.WriteString("\n\n") - - rulesContent := strings.TrimSpace(globalRules + "\n\n" + projectRules) - overridden := detectOverrides(rulesContent) - for _, name := range overridableOrderForSize(spec.Size) { - if !overridden[name] { - b.WriteString(strings.TrimSpace(overridableSections[name])) - b.WriteString("\n\n") - } - // System territory: renders even when a user heading overrides the main - // (D5: additions are system-owned). Empty defaults/adaptations write - // nothing, so the baseline prompt is unchanged. - if add := adaptation.SectionAddition(spec.Adapt, name); add != "" { - b.WriteString(strings.TrimSpace(add)) - b.WriteString("\n\n") - } - } - - // Adaptation coaching blocks sit after the built-in sections and before user - // rules. Baseline (nil/empty) inserts nothing, so the prompt is unchanged. - if spec.Adapt != nil && len(spec.Adapt.Blocks) > 0 { - for _, block := range spec.Adapt.Blocks { - b.WriteString(strings.TrimSpace(block)) - b.WriteString("\n\n") - } - } - - writeAgentBody(&b, spec.Body) - - trimmed := strings.TrimSpace(rulesContent) - if trimmed != "" { - b.WriteString(trimmed) - b.WriteString("\n\n") - } - - return strings.TrimSpace(b.String()) -} - -func normalizeSpec(spec Spec) Spec { - switch spec.Size { - case SizeNone, SizeSimple, SizeFull: - default: - spec.Size = SizeFull - } - spec.Body = strings.TrimSpace(spec.Body) - if spec.Size == SizeNone { - spec.Adapt = nil - } - return spec -} - -func defaultSectionsForSize(size string) []string { - if size == SizeSimple { - return []string{ - identitySection, - coreRulesSection, - compactionAwarenessSection, - } - } - return []string{ - identitySection, - coreRulesSection, - rulesFileGuideSection, - compactionAwarenessSection, - } -} - -func overridableOrderForSize(size string) []string { - if size == SizeSimple { - return []string{"safety"} - } - return overridableOrder -} - -func writeAgentBody(b *strings.Builder, body string) { - body = strings.TrimSpace(body) - if body == "" { - return - } - if !startsWithAgentPromptHeading(body) { - b.WriteString(agentPromptHeading) - b.WriteString("\n\n") - } - b.WriteString(body) - b.WriteString("\n\n") -} - -func startsWithAgentPromptHeading(body string) bool { - for _, line := range strings.Split(body, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - if !strings.HasPrefix(line, "#") { - return false - } - headingText := strings.TrimLeft(line, "#") - headingText = strings.TrimSpace(headingText) - headingText = strings.TrimSpace(strings.TrimRight(headingText, "#")) - headingText = strings.ToLower(headingText) - headingText = strings.ReplaceAll(headingText, "-", " ") - headingText = strings.ReplaceAll(headingText, "_", " ") - headingText = strings.Join(strings.Fields(headingText), " ") - return headingText == "your role and instructions" - } - return false -} - -func renderEnvironment(projectRoot string, sessionStart time.Time) string { - shell := os.Getenv("SHELL") - if shell == "" { - shell = "unknown" - } - return fmt.Sprintf("## Environment\n\nWorking directory: %s\nPlatform: %s\nShell: %s\nOS: %s\nSession started: %s", - projectRoot, - runtime.GOOS, - shell, - osDescription(), - sessionStart.Format("2006-01-02 15:04:05 MST"), - ) -} - -func osDescription() string { - if runtime.GOOS == "linux" { - if data, err := os.ReadFile("/etc/os-release"); err == nil { - for _, line := range strings.Split(string(data), "\n") { - if strings.HasPrefix(line, "PRETTY_NAME=") { - name := strings.TrimPrefix(line, "PRETTY_NAME=") - return strings.Trim(name, `"`) - } - } - } - } - return runtime.GOOS + "/" + runtime.GOARCH -} - -func readRulesFile(dir string) (string, error) { - path := filepath.Join(dir, "AGENTS.md") - data, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return "", fmt.Errorf("read %s: %w", path, err) - } - return string(data), nil -} - -func detectOverrides(rulesContent string) map[string]bool { - result := map[string]bool{ - "safety": false, - "tone": false, - "task_execution": false, - "language": false, - } - for _, line := range strings.Split(rulesContent, "\n") { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, "#") { - continue - } - headingText := strings.TrimLeft(trimmed, "#") - headingText = strings.TrimSpace(headingText) - headingText = strings.TrimSpace(strings.TrimRight(headingText, "#")) - headingText = strings.ToLower(headingText) - headingText = strings.ReplaceAll(headingText, "-", " ") - headingText = strings.ReplaceAll(headingText, "_", " ") - headingText = strings.Join(strings.Fields(headingText), "_") - if _, ok := result[headingText]; ok { - result[headingText] = true - } - } - return result -} diff --git a/internal/prompt/assembler_test.go b/internal/prompt/assembler_test.go deleted file mode 100644 index 8895828c..00000000 --- a/internal/prompt/assembler_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package prompt - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/MMinasyan/lightcode/internal/adaptation" - "github.com/MMinasyan/lightcode/internal/testassert" -) - -// testStart pins the rendered session start so prompts built in different calls -// are byte-comparable. -var testStart = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - -// assembleSpec renders one prompt through a fresh stateless service. -func assembleSpec(root, home string, spec Spec) Result { - return NewService(home).Assemble(root, testStart, spec) -} - -// assembleFull renders the default full prompt for adapt (nil = baseline). -func assembleFull(root, home string, adapt *adaptation.Adaptation) Result { - return assembleSpec(root, home, Spec{Size: SizeFull, Adapt: adapt}) -} - -// buildFull renders the full body directly from rules strings, without -// reading rules files — used by the section-composition tests. -func buildFull(root, globalRules, projectRules string, adapt *adaptation.Adaptation) string { - return buildSpec(root, testStart, globalRules, projectRules, Spec{Size: SizeFull, Adapt: adapt}) -} - -func TestDetectOverrides(t *testing.T) { - got := detectOverrides("# Safety\ntext\n## Tone\n### Task Execution\n# other\n") - testassert.Require(t, got["safety"] && got["tone"] && got["task_execution"] && !got["language"], "detectOverrides = %+v, want safety and tone; detectOverrides = %+v, want task_execution; detectOverrides false positives = %+v", got, got, got) - for _, rules := range []string{"## Task Execution", "## task_execution", "## task-execution", "## Task\tExecution", "## Task Execution", "## Task Execution ##"} { - testassert.Require(t, detectOverrides(rules)["task_execution"], "detectOverrides(%q) did not match task_execution", rules) - } - { - got := detectOverrides("## Environment") - testassert.Require(t, !got["safety"] && !got["tone"] && !got["task_execution"] && !got["language"], "environment heading caused override = %+v", got) - } -} - -func TestBuildIncludesBaseEnvironmentAndRules(t *testing.T) { - prompt := buildFull("/work/project", "global rules", "project rules", nil) - for _, want := range []string{"Working directory: /work/project", "global rules", "project rules"} { - testassert.Require(t, strings.Contains(prompt, want), "prompt missing %q\n%s", want, prompt) - } - withoutOverride := buildFull("/work/project", "", "", nil) - withOverride := buildFull("/work/project", "# Safety\ncustom", "", nil) - testassert.Require(t, !strings.Contains(withOverride, strings.TrimSpace(safetySection)) || strings.Contains(withoutOverride, strings.TrimSpace(safetySection)), "sanity check failed for safety section") - testassert.Require(t, !strings.Contains(withOverride, strings.TrimSpace(safetySection)), "safety section was not overridden by # Safety rules") -} - -func TestBuildIncludesReadableSectionHeadings(t *testing.T) { - prompt := buildFull("/work/project", "", "", nil) - for _, want := range []string{ - "## Identity", - "## Core Rules", - "## Rules File Guide", - "## Compaction Awareness", - "## Environment", - "## Safety", - "## Tone", - "## Task Execution", - "## Language", - } { - testassert.Require(t, strings.Contains(prompt, want), "prompt missing heading %q\n%s", want, prompt) - } - idxEnv := strings.Index(prompt, "## Environment") - idxWorkingDir := strings.Index(prompt, "Working directory: /work/project") - testassert.Require(t, idxEnv >= 0 && idxWorkingDir >= 0 && idxEnv <= idxWorkingDir, "environment heading not before working directory: heading=%d workingDir=%d", idxEnv, idxWorkingDir) -} - -func TestBuildTaskExecutionOverrideUsesReadableHeading(t *testing.T) { - withoutOverride := buildFull("/work/project", "", "", nil) - withOverride := buildFull("/work/project", "## Task Execution\ncustom task rules", "", nil) - testassert.Require(t, strings.Contains(withoutOverride, strings.TrimSpace(taskExecutionSection)), "sanity check failed for task execution section") - testassert.Require(t, !strings.Contains(withOverride, strings.TrimSpace(taskExecutionSection)), "task execution section was not overridden by ## Task Execution rules") - testassert.Require(t, strings.Contains(withOverride, "custom task rules"), "custom task execution rules missing") -} - -func TestAssemblerWarnings(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - first := assembleFull(projectRoot, home, nil) - testassert.Require(t, hasWarning(first.Warnings, WarnRulesNotFound), "warnings = %+v, want rules_not_found", first.Warnings) - - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte(strings.Repeat("x", 20001)), 0o600)) - second := assembleFull(projectRoot, home, nil) - testassert.Require(t, hasWarning(second.Warnings, WarnRulesTooLarge), "warnings = %+v, want rules_too_large", second.Warnings) - // The service reads rules fresh every call: the warning reflects the current - // on-disk state, not a cached earlier result. - testassert.Require(t, !hasWarning(second.Warnings, WarnRulesNotFound), "warnings = %+v, want no rules_not_found after AGENTS.md written", second.Warnings) -} - -// TestServiceIsStatelessAcrossProjectsAndSessions verifies the shared service -// holds no per-call state: it renders each caller's own project root and session -// start, and two projects with different rules stay isolated. -func TestServiceIsStatelessAcrossProjectsAndSessions(t *testing.T) { - home := t.TempDir() - projectA := t.TempDir() - projectB := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectA, "AGENTS.md"), []byte("PROJECT_A_RULES"), 0o600)) - testassert.NoError(t, os.WriteFile(filepath.Join(projectB, "AGENTS.md"), []byte("PROJECT_B_RULES"), 0o600)) - svc := NewService(home) - spec := Spec{Size: SizeFull} - - // Different project roots: each prompt renders its own working directory and - // rules; neither leaks the other's. - a := svc.Assemble(projectA, testStart, spec).Prompt - b := svc.Assemble(projectB, testStart, spec).Prompt - testassert.Require(t, strings.Contains(a, "Working directory: "+projectA) && strings.Contains(a, "PROJECT_A_RULES") && !strings.Contains(a, projectB) && !strings.Contains(a, "PROJECT_B_RULES"), "project A prompt missing its own root/rules\n%s; project A prompt leaked project B state\n%s", a, a) - testassert.Require(t, strings.Contains(b, "Working directory: "+projectB) && strings.Contains(b, "PROJECT_B_RULES"), "project B prompt missing its own root/rules\n%s", b) - - // Re-rendering project A after project B yields byte-identical output: no state - // carried over from the intervening call. - testassert.Equal(t, svc.Assemble(projectA, testStart, spec).Prompt, a) - - // Distinct session starts render distinct environment lines for the same root. - otherStart := testStart.Add(90 * time.Minute) - early := svc.Assemble(projectA, testStart, spec).Prompt - late := svc.Assemble(projectA, otherStart, spec).Prompt - testassert.Require(t, early != late, "distinct session starts produced identical prompts") - testassert.Require(t, strings.Contains(early, testStart.Format("2006-01-02 15:04:05 MST")) && strings.Contains(late, otherStart.Format("2006-01-02 15:04:05 MST")), "session start not rendered per call") -} - -// TestAssembledBaselinePromptDoesNotAdvertiseMemoryTools is the Step-2 prompt -// regression: with memory removed, the assembled baseline prompt must not advertise -// the deleted save_memory / search_memory / search_history tools or any memory -// tooling. Before removal the rules-file guide and the embedded memory instructions -// directed the model to use memory tools; both are gone now, so no assembled prompt -// (full/simple/none) may reintroduce them. -func TestAssembledBaselinePromptDoesNotAdvertiseMemoryTools(t *testing.T) { - home := t.TempDir() - root := t.TempDir() - for _, size := range []string{SizeFull, SizeSimple, SizeNone} { - prompt := assembleSpec(root, home, Spec{Size: size}).Prompt - for _, forbidden := range []string{ - "save_memory", - "search_memory", - "search_history", - "memory tools", - "Memory Instructions", - } { - testassert.Require(t, !strings.Contains(prompt, forbidden), "assembled %s prompt still advertises removed memory behavior (%q):\n%s", size, forbidden, prompt) - } - } -} - -func TestReadRulesFileAgentsOnly(t *testing.T) { - dir := t.TempDir() - { - got, err := readRulesFile(dir) - testassert.Require(t, err == nil && got == "", "empty readRulesFile = %q, %v", got, err) - } - testassert.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte("agents"), 0o600)) - { - got, err := readRulesFile(dir) - testassert.Require(t, err == nil && got == "agents", "AGENTS readRulesFile = %q, %v", got, err) - } -} - -func hasWarning(warnings []Warning, kind string) bool { - for _, w := range warnings { - if w.Kind == kind { - return true - } - } - return false -} diff --git a/internal/prompt/composition_test.go b/internal/prompt/composition_test.go deleted file mode 100644 index 23d47414..00000000 --- a/internal/prompt/composition_test.go +++ /dev/null @@ -1,159 +0,0 @@ -package prompt - -import ( - "os" - "path/filepath" - "strings" - "sync" - "testing" - - "github.com/MMinasyan/lightcode/internal/adaptation" - "github.com/MMinasyan/lightcode/internal/testassert" -) - -func TestAssembleFullSpecIsStatelessAndDeterministic(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("PROJECT_RULES_MARKER"), 0o600)) - spec := Spec{Size: SizeFull} - // A baseline (nil adapt) render and an explicit full spec are the same prompt, - // and repeated renders are byte-identical: the service holds no state that - // would drift between calls. - base := assembleFull(projectRoot, home, nil).Prompt - explicit := assembleSpec(projectRoot, home, spec).Prompt - testassert.Equal(t, base, explicit) - testassert.Equal(t, assembleSpec(projectRoot, home, spec).Prompt, explicit) -} - -func TestAssembleConcurrent(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("PROJECT_RULES_MARKER"), 0o600)) - svc := NewService(home) - adapt := &adaptation.Adaptation{Blocks: []string{"CONCURRENT_BLOCK"}} - var wg sync.WaitGroup - for i := 0; i < 8; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - for j := 0; j < 100; j++ { - res := svc.Assemble(projectRoot, testStart, Spec{ - Size: []string{SizeFull, SizeSimple, SizeNone}[j%3], - Body: "body", - Adapt: adapt, - }) - if strings.TrimSpace(res.Prompt) == "" { - t.Errorf("goroutine %d build %d produced empty prompt", i, j) - return - } - } - }(i) - } - wg.Wait() -} - -func TestAssembleFullWithAdaptationRendersAdaptationContent(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("PROJECT_RULES_MARKER"), 0o600)) - adapt := &adaptation.Adaptation{ - Blocks: []string{"FULL_BLOCK_MARKER"}, - Additions: map[string]string{"tone": "FULL_TONE_ADDITION"}, - } - prompt := assembleFull(projectRoot, home, adapt).Prompt - for _, want := range []string{"FULL_BLOCK_MARKER", "FULL_TONE_ADDITION", "PROJECT_RULES_MARKER", "## Environment"} { - testassert.Require(t, strings.Contains(prompt, want), "adapted full prompt missing %q\n%s", want, prompt) - } -} - -func TestAssembleForSpecSimpleIncludesOnlySimpleSectionsAndBodySlot(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("## Tone\n\nPROJECT_TONE_RULES_MARKER"), 0o600)) - adapt := &adaptation.Adaptation{ - Blocks: []string{"SIMPLE_BLOCK_MARKER"}, - Additions: map[string]string{"safety": "SIMPLE_SAFETY_ADDITION", "tone": "SIMPLE_TONE_ADDITION"}, - } - prompt := assembleSpec(projectRoot, home, Spec{ - Size: SizeSimple, - Body: "Do the focused job.", - Adapt: adapt, - }).Prompt - - for _, want := range []string{ - strings.TrimSpace(identitySection), - strings.TrimSpace(coreRulesSection), - strings.TrimSpace(compactionAwarenessSection), - "## Environment", - strings.TrimSpace(safetySection), - "SIMPLE_SAFETY_ADDITION", - "SIMPLE_BLOCK_MARKER", - agentPromptHeading, - "Do the focused job.", - "PROJECT_TONE_RULES_MARKER", - } { - testassert.Require(t, strings.Contains(prompt, want), "simple prompt missing %q\n%s", want, prompt) - } - for _, forbidden := range []string{ - strings.TrimSpace(rulesFileGuideSection), - strings.TrimSpace(toneSection), - strings.TrimSpace(taskExecutionSection), - strings.TrimSpace(languageSection), - "SIMPLE_TONE_ADDITION", - } { - testassert.Require(t, !strings.Contains(prompt, forbidden), "simple prompt contains omitted content %q\n%s", forbidden, prompt) - } - - assertOrder(t, prompt, - strings.TrimSpace(safetySection), - "SIMPLE_SAFETY_ADDITION", - "SIMPLE_BLOCK_MARKER", - agentPromptHeading, - "Do the focused job.", - "PROJECT_TONE_RULES_MARKER", - ) -} - -func TestAssembleForSpecNoneProducesBodyOnly(t *testing.T) { - home := t.TempDir() - projectRoot := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(projectRoot, "AGENTS.md"), []byte("PROJECT_RULES_MARKER"), 0o600)) - body := "## Your Role and Instructions\n\nOnly compact this transcript." - prompt := assembleSpec(projectRoot, home, Spec{ - Size: SizeNone, - Body: body, - Adapt: &adaptation.Adaptation{Blocks: []string{"IGNORED_BLOCK"}}, - }).Prompt - - testassert.Require(t, strings.Contains(prompt, body), "none prompt missing body %q\n%s", body, prompt) - for _, forbidden := range []string{ - strings.TrimSpace(identitySection), - strings.TrimSpace(coreRulesSection), - strings.TrimSpace(compactionAwarenessSection), - "## Environment", - strings.TrimSpace(safetySection), - strings.TrimSpace(rulesFileGuideSection), - strings.TrimSpace(toneSection), - strings.TrimSpace(taskExecutionSection), - strings.TrimSpace(languageSection), - "PROJECT_RULES_MARKER", - "IGNORED_BLOCK", - } { - testassert.Require(t, !strings.Contains(prompt, forbidden), "none prompt contains omitted content %q\n%s", forbidden, prompt) - } - { - count := strings.Count(prompt, agentPromptHeading) - testassert.Require(t, count == 1, "agent prompt heading count = %d, want 1\n%s", count, prompt) - } -} - -func assertOrder(t *testing.T, text string, markers ...string) { - t.Helper() - prev := -1 - for _, marker := range markers { - idx := strings.Index(text, marker) - testassert.Require(t, idx >= 0, "missing order marker %q\n%s", marker, text) - testassert.Require(t, idx >= prev, "marker %q at %d appeared before previous marker at %d\n%s", marker, idx, prev, text) - prev = idx - } -} diff --git a/internal/snapshot/code_groups_test.go b/internal/snapshot/code_groups_test.go index 52e0b96c..37ccb5f1 100644 --- a/internal/snapshot/code_groups_test.go +++ b/internal/snapshot/code_groups_test.go @@ -1,5 +1,6 @@ // Whole-invocation revert traversal coverage: one call owning the skip and -// reported-skip ledgers across every supplied directory and descending turn. +// reported-skip ledgers across every supplied directory, each group's fixed +// snapshots/1 directory visited exactly once in caller order. package snapshot import ( @@ -13,21 +14,20 @@ import ( // stageGroupCapture drives one real CodeStore capture in the given group // directory: the current target content is captured as the preimage, content -// is written, and the post-write identity is recorded and retained. -func stageGroupCapture(t *testing.T, groupDir, target string, turn int, content []byte) { +// is written, and the post-write identity is recorded. +func stageGroupCapture(t *testing.T, groupDir, target string, content []byte) { t.Helper() code := mustOpenCodeStore(t, groupDir) - entryID, _, err := code.SnapshotResolvedEntry(turn, target, target) + entryID, _, err := code.SnapshotResolvedEntry(target, target) testassert.Require(t, err == nil, "SnapshotResolvedEntry(%s): %v", target, err) { err := os.WriteFile(target, content, 0o600) testassert.Require(t, err == nil, "write %s: %v", target, err) } { - err := code.RecordSnapshotContent(turn, entryID, content) + err := code.RecordSnapshotContent(entryID, content) testassert.Require(t, err == nil, "RecordSnapshotContent(%s): %v", target, err) } - code.RetainSnapshotEntry(turn, entryID) } // stageTwoGroups seeds the shared two-group prologue: an entry-new and an @@ -42,8 +42,8 @@ func stageTwoGroups(t *testing.T) (newest, oldest, file string) { file = filepath.Join(root, "tree", "app.txt") testassert.NoError(t, os.MkdirAll(filepath.Dir(file), 0o700)) testassert.NoError(t, os.WriteFile(file, []byte("v0"), 0o600)) - stageGroupCapture(t, oldest, file, 1, []byte("v1")) - stageGroupCapture(t, newest, file, 1, []byte("v2")) + stageGroupCapture(t, oldest, file, []byte("v1")) + stageGroupCapture(t, newest, file, []byte("v2")) return newest, oldest, file } @@ -58,7 +58,7 @@ func TestRevertCodeGroupsSharesSkipLedgerAcrossGroups(t *testing.T) { // the shared ledger can keep the older group from restoring. testassert.NoError(t, os.WriteFile(file, []byte("v1"), 0o600)) - result, err := RevertCodeGroups([]string{newest, oldest}, 0) + result, err := RevertCodeGroups([]string{newest, oldest}) testassert.NoError(t, err) testassert.Require(t, len(result.Restored) == 0 && len(result.Skipped) == 1 && result.Skipped[0].Path == file, "restored = %v, want no restore after the newer skip; skipped = %+v, want exactly one reported skip for %s", result.Restored, result.Skipped, file) data, err := os.ReadFile(file) @@ -71,18 +71,18 @@ func TestRevertCodeGroupsSharesSkipLedgerAcrossGroups(t *testing.T) { func TestRevertCodeGroupsRestoresAcrossGroupsInOrder(t *testing.T) { newest, oldest, file := stageTwoGroups(t) - result, err := RevertCodeGroups([]string{newest, oldest}, 0) + result, err := RevertCodeGroups([]string{newest, oldest}) testassert.NoError(t, err) testassert.Require(t, len(result.Restored) == 2 && result.Restored[0] == file && result.Restored[1] == file, "restored = %v, want the newest then the older preimage", result.Restored) data, err := os.ReadFile(file) testassert.Require(t, err == nil && string(data) == "v0", "file = (%q, %v), want the oldest preimage v0", data, err) { _, err := os.Stat(filepath.Join(newest, "snapshots", "1")) - testassert.Require(t, errors.Is(err, os.ErrNotExist), "newest turn dir = %v, want it removed after the restore", err) + testassert.Require(t, errors.Is(err, os.ErrNotExist), "newest group entries dir = %v, want it removed after the restore", err) } { _, err := os.Stat(filepath.Join(oldest, "snapshots", "1")) - testassert.Require(t, errors.Is(err, os.ErrNotExist), "older turn dir = %v, want it removed after the restore", err) + testassert.Require(t, errors.Is(err, os.ErrNotExist), "older group entries dir = %v, want it removed after the restore", err) } } @@ -97,37 +97,14 @@ func TestRevertCodeGroupsPartialFailurePreservesPriorEffects(t *testing.T) { testassert.NoError(t, os.MkdirAll(badEntry, 0o700)) testassert.NoError(t, os.WriteFile(filepath.Join(badEntry, "meta.json"), []byte("{"), 0o600)) - result, err := RevertCodeGroups([]string{newest, oldest}, 0) + result, err := RevertCodeGroups([]string{newest, oldest}) testassert.Require(t, err != nil, "RevertCodeGroups = %+v, want the older group's traversal error", result) testassert.Require(t, len(result.Restored) == 1 && result.Restored[0] == file, "restored = %v, want the newest group's honest restore", result.Restored) data, err := os.ReadFile(file) testassert.Require(t, err == nil && string(data) == "v1", "file = (%q, %v), want the newest group's preimage v1", data, err) { _, err := os.Stat(filepath.Join(newest, "snapshots", "1")) - testassert.Require(t, errors.Is(err, os.ErrNotExist), "newest turn dir = %v, want it removed after its restore", err) - } - { - _, err := os.Stat(filepath.Join(oldest, "snapshots", "1")) - testassert.NoError(t, err) - } -} - -// TestRevertCodeGroupsAfterTurnAppliesPerDirectory proves after_turn is each -// directory's own turn floor: turns at or below it stay untouched in every -// group while the turns above it restore. -func TestRevertCodeGroupsAfterTurnAppliesPerDirectory(t *testing.T) { - newest, oldest, file := stageTwoGroups(t) - - stageGroupCapture(t, newest, file, 2, []byte("v3")) - - result, err := RevertCodeGroups([]string{newest, oldest}, 1) - testassert.NoError(t, err) - testassert.Require(t, len(result.Restored) == 1 && result.Restored[0] == file, "restored = %v, want only the newest group's turn-2 preimage", result.Restored) - data, err := os.ReadFile(file) - testassert.Require(t, err == nil && string(data) == "v2", "file = (%q, %v), want the turn-2 preimage v2", data, err) - { - _, err := os.Stat(filepath.Join(newest, "snapshots", "1")) - testassert.NoError(t, err) + testassert.Require(t, errors.Is(err, os.ErrNotExist), "newest group entries dir = %v, want it removed after its restore", err) } { _, err := os.Stat(filepath.Join(oldest, "snapshots", "1")) diff --git a/internal/snapshot/code_store.go b/internal/snapshot/code_store.go index 3c477fa5..e2008542 100644 --- a/internal/snapshot/code_store.go +++ b/internal/snapshot/code_store.go @@ -8,9 +8,6 @@ import ( "io" "os" "path/filepath" - "sort" - "strconv" - "sync" "syscall" "github.com/MMinasyan/lightcode/internal/pathutil" @@ -19,23 +16,20 @@ import ( ) // CodeStore owns the code-only snapshot implementation: the snapshot-root -// state, capture references, mutation locks, first-write and last-write -// identities, listing and restore. It knows nothing about conversation, -// claims, session metadata, transcript, tokens or compaction files — an -// isolated target code group is addressed through OpenCodeStore with no -// conversation state at all. +// state, capture, last-write identity replacement and recording, listing and +// restore. One group holds one fixed entry directory, snapshots/1. It knows +// nothing about conversation, claims, session metadata, transcript, tokens or +// compaction files — an isolated target code group is addressed through +// OpenCodeStore with no conversation state at all. One production call owns +// one handle; its sub-operations run sequentially, so the store keeps no +// cross-call claim state. type CodeStore struct { - mu sync.Mutex - snapshotsDir string - - snapshotTx map[string]*snapshotTxState - mutationLock map[string]*snapshotMutationLock } // OpenCodeStore returns a code store rooted at directory/snapshots. It -// creates no directories, conversation, turn or claim; snapshot writes begin -// only in the allowed effect. +// creates no directories or conversation state; snapshot writes begin only +// in the allowed effect. func OpenCodeStore(directory string) (*CodeStore, error) { if directory == "" { return nil, errors.New("snapshot: code store directory is empty") @@ -43,15 +37,12 @@ func OpenCodeStore(directory string) (*CodeStore, error) { return &CodeStore{snapshotsDir: filepath.Join(directory, "snapshots")}, nil } -// SnapshotResolvedEntry is SnapshotResolved plus the concrete entry id and a -// created flag. Mutation callers must either retain the entry once mutation -// starts or discard their pre-mutation claim if validation fails first. -func (c *CodeStore) SnapshotResolvedEntry(turn int, originalPath, canonicalPath string) (string, bool, error) { - c.mu.Lock() - defer c.mu.Unlock() - if err := validateSnapshotTurn(turn); err != nil { - return "", false, err - } +// SnapshotResolvedEntry captures the first preimage of one canonical target +// and reports the concrete entry id plus whether this call created the entry. +// A caller discards only an entry its own created flag says it created, and +// only when no mutation started; an existing first preimage survives later +// failures. +func (c *CodeStore) SnapshotResolvedEntry(originalPath, canonicalPath string) (string, bool, error) { snapshotsDir := c.snapshotsDir realPath, err := filepath.EvalSymlinks(canonicalPath) if err != nil { @@ -65,12 +56,9 @@ func (c *CodeStore) SnapshotResolvedEntry(turn int, originalPath, canonicalPath return "", false, fmt.Errorf("snapshot: stat %s: %w", canonicalPath, err) } pathHash := hashString(realPath) - entryDir := filepath.Join(snapshotsDir, strconv.Itoa(turn), pathHash) + entryDir := filepath.Join(snapshotsDir, groupEntriesDir, pathHash) metaPath := filepath.Join(entryDir, "meta.json") if _, err := os.Stat(metaPath); err == nil { - if state := c.snapshotTxStateLocked(turn, pathHash, false); state != nil { - state.refs++ - } return pathHash, false, nil } if err := os.MkdirAll(entryDir, 0o700); err != nil { @@ -110,256 +98,128 @@ func (c *CodeStore) SnapshotResolvedEntry(turn int, originalPath, canonicalPath return pathHash, false, fmt.Errorf("snapshot: write meta: %w", err) } created = true - state := c.snapshotTxStateLocked(turn, pathHash, true) - state.refs++ return pathHash, true, nil } -// DiscardSnapshotEntry releases a pre-mutation snapshot user. The entry is -// removed only if no concurrent user retained it or still depends on it. -func (c *CodeStore) DiscardSnapshotEntry(turn int, entryID string) error { - if err := validateSnapshotTurn(turn); err != nil { - return err - } +// DiscardSnapshotEntry removes a snapshot entry. Callers only discard entries +// their own SnapshotResolvedEntry created, and only while no mutation has +// started; a created flag that is false leaves the entry untouched. +func (c *CodeStore) DiscardSnapshotEntry(entryID string) error { if err := validateSnapshotEntryID(entryID); err != nil { return err } - c.mu.Lock() - defer c.mu.Unlock() - key := snapshotTxKey(turn, entryID) - state := c.snapshotTx[key] - if state == nil { - return nil - } - if state.refs > 0 { - state.refs-- - } - if state.refs > 0 { - return nil - } - delete(c.snapshotTx, key) - entryDir := filepath.Join(c.snapshotsDir, strconv.Itoa(turn), entryID) + entryDir := filepath.Join(c.snapshotsDir, groupEntriesDir, entryID) if err := os.RemoveAll(entryDir); err != nil { return fmt.Errorf("snapshot: discard %s: %w", entryDir, err) } return nil } -// RetainSnapshotEntry keeps a snapshot entry once any user starts mutating. -func (c *CodeStore) RetainSnapshotEntry(turn int, entryID string) { - c.mu.Lock() - defer c.mu.Unlock() - key := snapshotTxKey(turn, entryID) - state := c.snapshotTx[key] - if state == nil { - return - } - delete(c.snapshotTx, key) -} - -// LockSnapshotMutation serializes same-session mutations for one snapshot -// entry. Callers hold the returned release function until the disk mutation -// and last-write identity record have both completed. -func (c *CodeStore) LockSnapshotMutation(turn int, entryID string) (func(), error) { - if err := validateSnapshotTurn(turn); err != nil { - return nil, err - } +// ReplaceSnapshotIdentity records a new LastWrite identity for one entry and +// returns the identity the entry owned before the replacement. ReplaceSnapshotIdentity +// with a nil identity clears the proof before a mutation starts; the returned +// prior proof is only ever written back for a proven non-mutating failure. +func (c *CodeStore) ReplaceSnapshotIdentity(entryID string, identity *SnapshotContentIdentity) (*SnapshotContentIdentity, error) { if err := validateSnapshotEntryID(entryID); err != nil { return nil, err } - c.mu.Lock() - key := snapshotTxKey(turn, entryID) - if c.mutationLock == nil { - c.mutationLock = make(map[string]*snapshotMutationLock) - } - lock := c.mutationLock[key] - if lock == nil { - lock = &snapshotMutationLock{} - c.mutationLock[key] = lock - } - lock.refs++ - c.mu.Unlock() - - lock.mu.Lock() - released := false - return func() { - if released { - return - } - released = true - lock.mu.Unlock() - - c.mu.Lock() - defer c.mu.Unlock() - lock.refs-- - if lock.refs == 0 && c.mutationLock[key] == lock { - delete(c.mutationLock, key) - } - }, nil -} - -// RecordSnapshotContent records the content produced by a successful mutation. -func (c *CodeStore) RecordSnapshotContent(turn int, entryID string, content []byte) error { - return c.recordSnapshotIdentity(turn, entryID, SnapshotContentIdentity{Hash: hashBytes(content)}) -} - -// RecordSnapshotAbsence records that a successful mutation removed the file. -func (c *CodeStore) RecordSnapshotAbsence(turn int, entryID string) error { - return c.recordSnapshotIdentity(turn, entryID, SnapshotContentIdentity{Absent: true}) -} - -func (c *CodeStore) recordSnapshotIdentity(turn int, entryID string, identity SnapshotContentIdentity) error { - if err := validateSnapshotTurn(turn); err != nil { - return err - } - if err := validateSnapshotEntryID(entryID); err != nil { - return err - } - c.mu.Lock() - defer c.mu.Unlock() - metaPath := filepath.Join(c.snapshotsDir, strconv.Itoa(turn), entryID, "meta.json") + metaPath := filepath.Join(c.snapshotsDir, groupEntriesDir, entryID, "meta.json") var meta SnapshotMeta if err := readJSON(metaPath, &meta); err != nil { - return err + return nil, err } - meta.LastWrite = &identity + prior := meta.LastWrite + meta.LastWrite = identity if err := writeJSON(metaPath, meta); err != nil { - return err - } - return nil -} - -func (c *CodeStore) snapshotTxStateLocked(turn int, entryID string, create bool) *snapshotTxState { - key := snapshotTxKey(turn, entryID) - if c.snapshotTx == nil { - if !create { - return nil - } - c.snapshotTx = make(map[string]*snapshotTxState) - } - state := c.snapshotTx[key] - if state == nil && create { - state = &snapshotTxState{} - c.snapshotTx[key] = state + return prior, err } - return state + return prior, nil } -type snapshotTxState struct { - refs int +// RecordSnapshotContent records the content produced by a completed mutation. +func (c *CodeStore) RecordSnapshotContent(entryID string, content []byte) error { + _, err := c.ReplaceSnapshotIdentity(entryID, &SnapshotContentIdentity{Hash: hashBytes(content)}) + return err } -type snapshotMutationLock struct { - mu sync.Mutex - refs int -} - -func snapshotTxKey(turn int, entryID string) string { - return strconv.Itoa(turn) + "/" + entryID -} - -// validateSnapshotTurn and validateSnapshotEntryID are the shared argument -// checks for the transaction and identity methods. -func validateSnapshotTurn(turn int) error { - if turn < 1 { - return fmt.Errorf("snapshot: turn must be >= 1, got %d", turn) - } - return nil +// RecordSnapshotAbsence records that a completed mutation removed the file. +func (c *CodeStore) RecordSnapshotAbsence(entryID string) error { + _, err := c.ReplaceSnapshotIdentity(entryID, &SnapshotContentIdentity{Absent: true}) + return err } +// validateSnapshotEntryID is the shared argument check for the entry and +// identity methods. func validateSnapshotEntryID(entryID string) error { - if !isLegacyEntryID(entryID) { + if !isEntryID(entryID) { return fmt.Errorf("snapshot: invalid entry id %q", entryID) } return nil } -// TurnEntry describes one snapshot turn for UI display. -type TurnEntry struct { - Turn int `json:"turn"` - Files []SnapshotMeta `json:"files"` -} +// groupEntriesDir is the one fixed entry directory of a group's snapshots +// root: every capture of one code group lives under snapshots/1. +const groupEntriesDir = "1" -// ListTurns returns all recorded snapshot turns with their file meta. -func (c *CodeStore) ListTurns() ([]TurnEntry, error) { - c.mu.Lock() - snapshotsDir := c.snapshotsDir - c.mu.Unlock() - turns := readIntDirs(snapshotsDir) - var entries []TurnEntry - for _, turn := range turns { - turnDir := filepath.Join(snapshotsDir, strconv.Itoa(turn)) - dirEntries, err := os.ReadDir(turnDir) - if err != nil { - return entries, fmt.Errorf("snapshot: read turn %d: %w", turn, err) +// ListEntries returns every recorded snapshot entry of one group in the +// fixed entry directory, in the directory's own lexical entry order. +func (c *CodeStore) ListEntries() ([]SnapshotMeta, error) { + entriesDir := filepath.Join(c.snapshotsDir, groupEntriesDir) + dirEntries, err := os.ReadDir(entriesDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil } - var files []SnapshotMeta - for _, de := range dirEntries { - if !de.IsDir() { - continue - } - var meta SnapshotMeta - if err := readJSON(filepath.Join(turnDir, de.Name(), "meta.json"), &meta); err != nil { - continue - } - files = append(files, meta) + return nil, fmt.Errorf("snapshot: read entries dir %s: %w", entriesDir, err) + } + var metas []SnapshotMeta + for _, de := range dirEntries { + if !de.IsDir() { + continue } - entries = append(entries, TurnEntry{Turn: turn, Files: files}) + var meta SnapshotMeta + if err := readJSON(filepath.Join(entriesDir, de.Name(), "meta.json"), &meta); err != nil { + continue + } + metas = append(metas, meta) } - return entries, nil + return metas, nil } -// RevertCodeGroups restores every file snapshotted in turns > afterTurn -// across the supplied directories, latest-first, under ONE traversal -// ownership: the skip and reported-skip ledgers span every group and every -// descending turn, so an identity skipped in a newer directory is never -// restored by an older one. Turn numbering, the after-turn floor and turn-dir -// removal stay per directory. A failure returns the result accumulated so far -// with the error; the restore work that already succeeded stays honest. -func RevertCodeGroups(directories []string, afterTurn int) (RevertResult, error) { - if afterTurn < 0 { - afterTurn = 0 - } +// RevertCodeGroups restores every snapshotted file across the supplied +// directories under ONE traversal ownership: the skip and reported-skip +// ledgers span every group, so an identity skipped in a newer group is never +// restored by an older one. Each group's fixed entries directory is visited +// exactly once in the caller's order. A failure returns the result +// accumulated so far with the error; the restore work that already succeeded +// stays honest. +func RevertCodeGroups(directories []string) (RevertResult, error) { var result RevertResult skippedEntries := make(map[string]struct{}) reportedSkippedEntries := make(map[string]struct{}) for _, directory := range directories { - snapshotsDir := filepath.Join(directory, "snapshots") - turns := readIntDirs(snapshotsDir) - for i := len(turns) - 1; i >= 0; i-- { - turn := turns[i] - if turn <= afterTurn { - break - } - turnDir := filepath.Join(snapshotsDir, strconv.Itoa(turn)) - turnResult, err := revertOneTurn(turnDir, skippedEntries, reportedSkippedEntries) - result.Restored = append(result.Restored, turnResult.Restored...) - result.Skipped = append(result.Skipped, turnResult.Skipped...) - if err != nil { - return result, fmt.Errorf("snapshot: revert %s turn %d: %w", directory, turn, err) - } - if err := os.Remove(turnDir); err != nil && !errors.Is(err, os.ErrNotExist) && !isDirNotEmpty(err) { - return result, fmt.Errorf("snapshot: remove %s: %w", turnDir, err) - } + entriesDir := filepath.Join(directory, "snapshots", groupEntriesDir) + groupResult, err := revertEntriesDir(entriesDir, skippedEntries, reportedSkippedEntries) + result.Restored = append(result.Restored, groupResult.Restored...) + result.Skipped = append(result.Skipped, groupResult.Skipped...) + if err != nil { + return result, fmt.Errorf("snapshot: revert %s: %w", directory, err) + } + if err := os.Remove(entriesDir); err != nil && !errors.Is(err, os.ErrNotExist) && !isDirNotEmpty(err) { + return result, fmt.Errorf("snapshot: remove %s: %w", entriesDir, err) } } return result, nil } -// RevertCode restores every file snapshotted in turns > toTurn to its -// pre-turn state and deletes those snapshot turn dirs. The traversal is the -// shared whole-invocation one under this store's instance ownership. -func (c *CodeStore) RevertCode(toTurn int) (RevertResult, error) { - c.mu.Lock() - defer c.mu.Unlock() - return RevertCodeGroups([]string{filepath.Dir(c.snapshotsDir)}, toTurn) -} - // --- shared restore/identity/path/file-copy helpers --- -func revertOneTurn(turnDir string, skippedEntries, reportedSkippedEntries map[string]struct{}) (RevertResult, error) { - entries, err := os.ReadDir(turnDir) +func revertEntriesDir(entriesDir string, skippedEntries, reportedSkippedEntries map[string]struct{}) (RevertResult, error) { + entries, err := os.ReadDir(entriesDir) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return RevertResult{}, nil + } return RevertResult{}, err } var result RevertResult @@ -367,7 +227,7 @@ func revertOneTurn(turnDir string, skippedEntries, reportedSkippedEntries map[st if !e.IsDir() { continue } - entryDir := filepath.Join(turnDir, e.Name()) + entryDir := filepath.Join(entriesDir, e.Name()) var meta SnapshotMeta if err := readJSON(filepath.Join(entryDir, "meta.json"), &meta); err != nil { return result, fmt.Errorf("read snapshot meta in %s: %w", entryDir, err) @@ -497,22 +357,24 @@ func isDirNotEmpty(err error) bool { return errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, unix.ENOTEMPTY) } +// validateRestorePath proves one restore target through the entry's +// capture-time canonical witness. An entry without that witness is refused: +// the restore never reconstructs a target from a path guess. func validateRestorePath(entryID string, meta SnapshotMeta) (string, *SkippedRevert, error) { - if !meta.Existed { - restorePath, err := validateDeletePath(entryID, meta) - return restorePath, nil, err - } if meta.CanonicalPath == "" { - restorePath, err := validateLegacyRestorePath(entryID, meta.OriginalPath) + return "", nil, fmt.Errorf("canonical proof refused: snapshot entry for %s carries no capture-time canonical path", meta.OriginalPath) + } + if hashString(meta.CanonicalPath) != entryID { + return "", nil, fmt.Errorf("canonical proof refused: meta canonical path %q does not match entry id %s", meta.CanonicalPath, entryID) + } + if !meta.Existed { + restorePath, err := validateCanonicalDeletePath(meta) return restorePath, nil, err } - return validateModernRestorePath(entryID, meta) + return validateCanonicalRestorePath(meta) } -func validateModernRestorePath(entryID string, meta SnapshotMeta) (string, *SkippedRevert, error) { - if hashString(meta.CanonicalPath) != entryID { - return "", nil, fmt.Errorf("modern restore refused: meta canonical path %q does not match entry id %s", meta.CanonicalPath, entryID) - } +func validateCanonicalRestorePath(meta SnapshotMeta) (string, *SkippedRevert, error) { resolved, err := pathutil.ResolveFilePath(meta.OriginalPath) if err != nil { return "", nil, fmt.Errorf("resolve restore path %s: %w", meta.OriginalPath, err) @@ -526,17 +388,7 @@ func validateModernRestorePath(entryID string, meta SnapshotMeta) (string, *Skip return meta.CanonicalPath, nil, nil } -func validateDeletePath(entryID string, meta SnapshotMeta) (string, error) { - if meta.CanonicalPath != "" { - return validateModernDeletePath(entryID, meta) - } - return "", fmt.Errorf("legacy delete refused: %s has no canonical proof", meta.OriginalPath) -} - -func validateModernDeletePath(entryID string, meta SnapshotMeta) (string, error) { - if hashString(meta.CanonicalPath) != entryID { - return "", fmt.Errorf("modern delete refused: meta canonical path %q does not match entry id %s", meta.CanonicalPath, entryID) - } +func validateCanonicalDeletePath(meta SnapshotMeta) (string, error) { absOriginal, err := filepath.Abs(meta.OriginalPath) if err != nil { return "", fmt.Errorf("resolve delete path %s: %w", meta.OriginalPath, err) @@ -552,38 +404,7 @@ func validateModernDeletePath(entryID string, meta SnapshotMeta) (string, error) return meta.CanonicalPath, nil } -func validateLegacyRestorePath(entryID, path string) (string, error) { - if path == "" { - return "", fmt.Errorf("legacy restore path is empty") - } - if !isLegacyEntryID(entryID) { - return "", fmt.Errorf("legacy snapshot entry id %q is invalid", entryID) - } - absPath, err := filepath.Abs(path) - if err != nil { - return "", fmt.Errorf("resolve legacy restore path %s: %w", path, err) - } - - resolved, err := pathutil.ResolveFilePath(absPath) - if err == nil { - if got := hashString(resolved.CanonicalPath); got == entryID { - return resolved.CanonicalPath, nil - } else if resolved.LeafExists { - return "", fmt.Errorf("legacy snapshot target hash changed from %s to %s", entryID, got) - } - } - if err != nil && !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("resolve legacy restore path %s: %w", path, err) - } - - cleanAbs := filepath.Clean(absPath) - if got := hashString(cleanAbs); got == entryID { - return cleanAbs, nil - } - return "", fmt.Errorf("legacy snapshot target missing and cannot be proven for %s", path) -} - -func isLegacyEntryID(id string) bool { +func isEntryID(id string) bool { if len(id) != 16 { return false } @@ -595,26 +416,6 @@ func isLegacyEntryID(id string) bool { return true } -func readIntDirs(dir string) []int { - entries, err := os.ReadDir(dir) - if err != nil { - return nil - } - var out []int - for _, e := range entries { - if !e.IsDir() { - continue - } - n, err := strconv.Atoi(e.Name()) - if err != nil || n < 1 { - continue - } - out = append(out, n) - } - sort.Ints(out) - return out -} - func hashString(s string) string { sum := sha256.Sum256([]byte(s)) return hex.EncodeToString(sum[:])[:16] diff --git a/internal/snapshot/code_store_test.go b/internal/snapshot/code_store_test.go index 5cc3900f..0df1651c 100644 --- a/internal/snapshot/code_store_test.go +++ b/internal/snapshot/code_store_test.go @@ -13,6 +13,12 @@ import ( "github.com/MMinasyan/lightcode/internal/testassert" ) +// revertGroup drives the real whole-invocation restore producer over the +// fixture's known group directory, as production does. +func revertGroup(store *CodeStore) (RevertResult, error) { + return RevertCodeGroups([]string{filepath.Dir(store.snapshotsDir)}) +} + // TestCodeStoreArtifactOnlyGroupReopenAndRestore proves that an isolated // target code group can be captured, reopened, listed and restored through // CodeStore alone, with no conversation state, Project record, claim, @@ -34,14 +40,13 @@ func TestCodeStoreArtifactOnlyGroupReopenAndRestore(t *testing.T) { abs, err := filepath.Abs(target) testassert.NoError(t, err) - entryID, created, err := code.SnapshotResolvedEntry(1, abs, abs) + entryID, created, err := code.SnapshotResolvedEntry(abs, abs) testassert.NoError(t, err) testassert.Require(t, created, "SnapshotResolvedEntry created = false, want true") - code.RetainSnapshotEntry(1, entryID) mutated := []byte("mutated\n") testassert.NoError(t, os.WriteFile(target, mutated, 0o644)) - testassert.NoError(t, code.RecordSnapshotContent(1, entryID, mutated)) + testassert.NoError(t, code.RecordSnapshotContent(entryID, mutated)) // The group directory holds only the snapshot artifact tree: no // conversation, Project, claim, metadata or transcript files. @@ -49,20 +54,20 @@ func TestCodeStoreArtifactOnlyGroupReopenAndRestore(t *testing.T) { testassert.NoError(t, err) testassert.Require(t, len(entries) == 1 && entries[0].Name() == "snapshots", "group dir entries = %v, want only snapshots", entries) - turns, err := code.ListTurns() + metas, err := code.ListEntries() testassert.NoError(t, err) - testassert.Require(t, len(turns) == 1 && turns[0].Turn == 1 && len(turns[0].Files) == 1, "ListTurns = %+v, want one turn with one file", turns) - testassert.Require(t, turns[0].Files[0].LastWrite != nil && turns[0].Files[0].LastWrite.Hash == hashBytes(mutated), "last-write identity = %+v, want hash of mutated content", turns[0].Files[0].LastWrite) + testassert.Require(t, len(metas) == 1, "ListEntries = %+v, want one entry", metas) + testassert.Require(t, metas[0].LastWrite != nil && metas[0].LastWrite.Hash == hashBytes(mutated), "last-write identity = %+v, want hash of mutated content", metas[0].LastWrite) // Reopen the same group with a fresh handle: listing and restore must // work with no conversation/Project state at all. reopened, err := OpenCodeStore(groupDir) testassert.NoError(t, err) - reopenedTurns, err := reopened.ListTurns() + reopenedMetas, err := reopened.ListEntries() testassert.NoError(t, err) - testassert.Require(t, len(reopenedTurns) == 1 && len(reopenedTurns[0].Files) == 1, "reopened ListTurns = %+v, want one turn with one file", reopenedTurns) + testassert.Require(t, len(reopenedMetas) == 1, "reopened ListEntries = %+v, want one entry", reopenedMetas) - result, err := reopened.RevertCode(0) + result, err := revertGroup(reopened) testassert.NoError(t, err) testassert.Require(t, len(result.Restored) == 1 && result.Restored[0] == abs && len(result.Skipped) == 0, "RevertCode restored = %v, want [%s]; RevertCode skipped = %+v, want none", result.Restored, abs, result.Skipped) got, err := os.ReadFile(target) @@ -96,15 +101,14 @@ func TestCodeStoreTwoGroupsRestoreIndependently(t *testing.T) { t.Helper() code, err := OpenCodeStore(groupDir) testassert.Require(t, err == nil, "OpenCodeStore(%s): %v", groupDir, err) - entryID, created, err := code.SnapshotResolvedEntry(1, target, target) + entryID, created, err := code.SnapshotResolvedEntry(target, target) testassert.Require(t, err == nil && created, "SnapshotResolvedEntry(%s) = (%v, %v), want one created entry", groupDir, entryID, err) - code.RetainSnapshotEntry(1, entryID) { err := os.WriteFile(target, mutated, 0o644) testassert.Require(t, err == nil, "mutate %s: %v", target, err) } { - err := code.RecordSnapshotContent(1, entryID, mutated) + err := code.RecordSnapshotContent(entryID, mutated) testassert.Require(t, err == nil, "RecordSnapshotContent(%s): %v", groupDir, err) } } @@ -116,22 +120,22 @@ func TestCodeStoreTwoGroupsRestoreIndependently(t *testing.T) { // B's file stays mutated and B's snapshot tree stays intact. restoreA, err := OpenCodeStore(groupADir) testassert.NoError(t, err) - resultA, err := restoreA.RevertCode(0) + resultA, err := revertGroup(restoreA) testassert.NoError(t, err) testassert.Require(t, len(resultA.Restored) == 1 && resultA.Restored[0] == fileA && len(resultA.Skipped) == 0, "RevertCode(A) = %+v, want exactly %s restored", resultA, fileA) gotA, err := os.ReadFile(fileA) testassert.Require(t, err == nil && string(gotA) == string(originalA), "a.txt after restore A = (%q, %v), want the original bytes", gotA, err) gotB, err := os.ReadFile(fileB) testassert.Require(t, err == nil && string(gotB) == string(mutatedB), "b.txt after restore A = (%q, %v), want it untouched at the mutated bytes", gotB, err) - turnsB, err := mustOpenCodeStore(t, groupBDir).ListTurns() - testassert.Require(t, err == nil && len(turnsB) == 1 && len(turnsB[0].Files) == 1, "group B turns after restore A = %+v err %v, want the snapshot tree intact", turnsB, err) - turnsA, err := mustOpenCodeStore(t, groupADir).ListTurns() - testassert.Require(t, err == nil && len(turnsA) == 0, "group A turns after restore A = %+v err %v, want the restored turn dirs removed", turnsA, err) + metasB, err := mustOpenCodeStore(t, groupBDir).ListEntries() + testassert.Require(t, err == nil && len(metasB) == 1, "group B entries after restore A = %+v err %v, want the snapshot tree intact", metasB, err) + metasA, err := mustOpenCodeStore(t, groupADir).ListEntries() + testassert.Require(t, err == nil && len(metasA) == 0, "group A entries after restore A = %+v err %v, want the restored entry dir removed", metasA, err) // Restoring group B independently restores its own preimage. restoreB, err := OpenCodeStore(groupBDir) testassert.NoError(t, err) - resultB, err := restoreB.RevertCode(0) + resultB, err := revertGroup(restoreB) testassert.NoError(t, err) testassert.Require(t, len(resultB.Restored) == 1 && resultB.Restored[0] == fileB && len(resultB.Skipped) == 0, "RevertCode(B) = %+v, want exactly %s restored", resultB, fileB) gotB, err = os.ReadFile(fileB) @@ -146,38 +150,52 @@ func mustOpenCodeStore(t *testing.T, groupDir string) *CodeStore { return code } -// --- rows migrated to the artifact owner from the removed conversation -// store's mixed tests: capture, legacy restore proof, hardlink, skip/identity -// and partial-effect guarantees keep their real preimage, identity and -// metadata fixtures with explicit group/turn identity. --- - // newTestCodeGroup opens one artifact-only code group at an explicit test -// directory. Turn identity is named by each test directly; the removed -// store's turn allocation and session bookkeeping play no part. +// directory. func newTestCodeGroup(t *testing.T) *CodeStore { t.Helper() return mustOpenCodeStore(t, filepath.Join(t.TempDir(), "group")) } -func snapshotEntry(t *testing.T, store *CodeStore, turn int, originalPath, canonicalPath string) string { +func snapshotEntry(t *testing.T, store *CodeStore, originalPath, canonicalPath string) string { t.Helper() - entryID, _, err := store.SnapshotResolvedEntry(turn, originalPath, canonicalPath) + entryID, _, err := store.SnapshotResolvedEntry(originalPath, canonicalPath) testassert.NoError(t, err) return entryID } -func recordSnapshotContent(t *testing.T, store *CodeStore, turn int, entryID string, content string) { +func recordSnapshotContent(t *testing.T, store *CodeStore, entryID string, content string) { t.Helper() - testassert.NoError(t, store.RecordSnapshotContent(turn, entryID, []byte(content))) + testassert.NoError(t, store.RecordSnapshotContent(entryID, []byte(content))) } -func TestCodeStoreRejectsInvalidTurn(t *testing.T) { +// TestReplaceSnapshotIdentityReturnsOwnedPrior proves the identity +// replacement contract: the pre-mutation clear returns the identity the entry +// owned, a nil replacement leaves no proof, and a later replacement owns the +// prior it displaced. +func TestReplaceSnapshotIdentityReturnsOwnedPrior(t *testing.T) { store := newTestCodeGroup(t) path := filepath.Join(t.TempDir(), "file.txt") - { - _, _, err := store.SnapshotResolvedEntry(0, path, path) - testassert.Require(t, err != nil, "SnapshotResolvedEntry(0) error = nil, want error") - } + testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) + entryID, created, err := store.SnapshotResolvedEntry(path, path) + testassert.NoError(t, err) + testassert.Require(t, created, "SnapshotResolvedEntry created = false, want true") + + prior, err := store.ReplaceSnapshotIdentity(entryID, nil) + testassert.NoError(t, err) + testassert.Require(t, prior == nil, "clear of a fresh entry prior = %+v, want nil", prior) + + testassert.NoError(t, store.RecordSnapshotContent(entryID, []byte("first"))) + prior, err = store.ReplaceSnapshotIdentity(entryID, nil) + testassert.NoError(t, err) + testassert.Require(t, prior != nil && prior.Hash == hashBytes([]byte("first")), "clear prior = %+v, want the first write's identity", prior) + prior, err = store.ReplaceSnapshotIdentity(entryID, prior) + testassert.NoError(t, err) + testassert.Require(t, prior == nil, "restore prior = %+v, want the cleared nil identity", prior) + + meta, err := store.ListEntries() + testassert.NoError(t, err) + testassert.Require(t, len(meta) == 1 && meta[0].LastWrite != nil && meta[0].LastWrite.Hash == hashBytes([]byte("first")), "ListEntries = %+v, want the restored proof recorded", meta) } func TestSnapshotMetaJSONRoundTrip(t *testing.T) { @@ -209,25 +227,24 @@ func TestSnapshotDeduplicatesSymlinkAndRealPath(t *testing.T) { testassert.NoError(t, os.WriteFile(realPath, []byte("v1"), 0o644)) testassert.NoError(t, os.Symlink(realPath, linkPath)) - const turn = 1 first, second := realPath, linkPath if symlinkFirst { first, second = linkPath, realPath } - entryID := snapshotEntry(t, store, turn, first, realPath) + entryID := snapshotEntry(t, store, first, realPath) // The second capture resolves its requested path to a canonical // target the way the tool layer binds one before capture. resolved, err := pathutil.ResolveFilePath(second) testassert.NoError(t, err) - snapshotEntry(t, store, turn, second, resolved.CanonicalPath) + snapshotEntry(t, store, second, resolved.CanonicalPath) entries, err := os.ReadDir(filepath.Join(store.snapshotsDir, "1")) testassert.NoError(t, err) testassert.Equal(t, len(entries), 1) testassert.NoError(t, os.WriteFile(realPath, []byte("v2"), 0o644)) - recordSnapshotContent(t, store, turn, entryID, "v2") - affected, err := store.RevertCode(0) + recordSnapshotContent(t, store, entryID, "v2") + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 1, "affected = %v, want one path", affected) got, err := os.ReadFile(realPath) @@ -245,16 +262,15 @@ func TestSnapshotResolvedPreservesOriginalPathForListing(t *testing.T) { testassert.NoError(t, os.WriteFile(realPath, []byte("v1"), 0o644)) testassert.NoError(t, os.Symlink(realPath, linkPath)) - const turn = 1 - entryID := snapshotEntry(t, store, turn, linkPath, realPath) + entryID := snapshotEntry(t, store, linkPath, realPath) - entries, err := store.ListTurns() + entries, err := store.ListEntries() testassert.NoError(t, err) - testassert.Require(t, len(entries) == 1 && len(entries[0].Files) == 1, "ListTurns = %+v, want one snapshotted file", entries) - testassert.Equal(t, entries[0].Files[0].OriginalPath, linkPath) + testassert.Require(t, len(entries) == 1, "ListEntries = %+v, want one snapshotted file", entries) + testassert.Equal(t, entries[0].OriginalPath, linkPath) testassert.NoError(t, os.WriteFile(realPath, []byte("v2"), 0o644)) - recordSnapshotContent(t, store, turn, entryID, "v2") - affected, err := store.RevertCode(0) + recordSnapshotContent(t, store, entryID, "v2") + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{linkPath}), "affected = %v, want original requested path %q", affected, linkPath) { @@ -265,63 +281,22 @@ func TestSnapshotResolvedPreservesOriginalPathForListing(t *testing.T) { func TestSnapshotResolvedEntryReportsCreatedOnlyForNewEntry(t *testing.T) { store := newTestCodeGroup(t) - const turn = 1 path := filepath.Join(t.TempDir(), "file.txt") testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) - entryID, created, err := store.SnapshotResolvedEntry(turn, path, path) + entryID, created, err := store.SnapshotResolvedEntry(path, path) testassert.NoError(t, err) testassert.Require(t, entryID != "" && created, "first SnapshotResolvedEntry = (%q, %v), want non-empty created entry", entryID, created) - entryID2, created, err := store.SnapshotResolvedEntry(turn, path, path) + entryID2, created, err := store.SnapshotResolvedEntry(path, path) testassert.NoError(t, err) testassert.Require(t, entryID2 == entryID && !created, "second SnapshotResolvedEntry = (%q, %v), want same existing entry %q", entryID2, created, entryID) - testassert.NoError(t, store.DiscardSnapshotEntry(turn, entryID)) - if entries, err := os.ReadDir(filepath.Join(store.snapshotsDir, "1")); err != nil { - t.Fatal(err) - } else if len(entries) != 1 { - t.Fatalf("snapshot entries after one discard = %d, want one retained by second user", len(entries)) - } - testassert.NoError(t, store.DiscardSnapshotEntry(turn, entryID2)) + // Only the call whose created flag is true may discard: the entry it + // created is removed, and an existing entry's flag keeps it alive. + testassert.NoError(t, store.DiscardSnapshotEntry(entryID)) if entries, err := os.ReadDir(filepath.Join(store.snapshotsDir, "1")); err != nil && !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } else if len(entries) != 0 { - t.Fatalf("snapshot entries after discard = %d, want none", len(entries)) - } -} - -func TestSnapshotResolvedEntryRetainPreventsConcurrentDiscard(t *testing.T) { - store := newTestCodeGroup(t) - const turn = 1 - path := filepath.Join(t.TempDir(), "file.txt") - testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) - - entryID, created, err := store.SnapshotResolvedEntry(turn, path, path) - testassert.NoError(t, err) - testassert.Require(t, created, "first SnapshotResolvedEntry was not created") - entryID2, created, err := store.SnapshotResolvedEntry(turn, path, path) - testassert.NoError(t, err) - testassert.Require(t, entryID2 == entryID && !created, "second SnapshotResolvedEntry = (%q, %v), want same existing entry %q", entryID2, created, entryID) - store.RetainSnapshotEntry(turn, entryID2) - testassert.NoError(t, store.DiscardSnapshotEntry(turn, entryID)) - if entries, err := os.ReadDir(filepath.Join(store.snapshotsDir, "1")); err != nil { - t.Fatal(err) - } else if len(entries) != 1 { - t.Fatalf("snapshot entries after retained discard = %d, want one", len(entries)) - } -} - -func TestSnapshotResolvedRetainsEntryAgainstLaterDiscard(t *testing.T) { - store := newTestCodeGroup(t) - const turn = 1 - path := filepath.Join(t.TempDir(), "file.txt") - testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) - entryID := snapshotEntry(t, store, turn, path, path) - store.RetainSnapshotEntry(turn, entryID) - testassert.NoError(t, store.DiscardSnapshotEntry(turn, hashString(path))) - if entries, err := os.ReadDir(filepath.Join(store.snapshotsDir, "1")); err != nil { - t.Fatal(err) - } else if len(entries) != 1 { - t.Fatalf("snapshot entries after discard of retained snapshot = %d, want one", len(entries)) + t.Fatalf("snapshot entries after discarding the created entry = %d, want none", len(entries)) } } @@ -331,18 +306,17 @@ func TestSnapshotFirstWriteWinsAndNewFileDeletesOnRevert(t *testing.T) { existing := filepath.Join(projectDir, "existing.txt") created := filepath.Join(projectDir, "created.txt") testassert.NoError(t, os.WriteFile(existing, []byte("v1"), 0o644)) - const turn = 1 - existingEntry := snapshotEntry(t, store, turn, existing, existing) + existingEntry := snapshotEntry(t, store, existing, existing) testassert.NoError(t, os.WriteFile(existing, []byte("v2"), 0o644)) - recordSnapshotContent(t, store, turn, existingEntry, "v2") + recordSnapshotContent(t, store, existingEntry, "v2") // The second capture of the same target keeps the first preimage. - snapshotEntry(t, store, turn, existing, existing) - createdEntry := snapshotEntry(t, store, turn, created, created) + snapshotEntry(t, store, existing, existing) + createdEntry := snapshotEntry(t, store, created, created) testassert.NoError(t, os.WriteFile(created, []byte("new"), 0o644)) - recordSnapshotContent(t, store, turn, createdEntry, "new") + recordSnapshotContent(t, store, createdEntry, "new") { - _, err := store.RevertCode(0) + _, err := revertGroup(store) testassert.NoError(t, err) } { @@ -367,17 +341,16 @@ func TestRevertSkipsCanonicalPathReplacedBySymlinkAndContinues(t *testing.T) { testassert.NoError(t, os.WriteFile(ownedPath, []byte("owned-before"), 0o644)) testassert.NoError(t, os.Symlink(originalTarget, linkPath)) - const turn = 1 - linkEntry := snapshotEntry(t, store, turn, linkPath, originalTarget) - ownedEntry := snapshotEntry(t, store, turn, ownedPath, ownedPath) + linkEntry := snapshotEntry(t, store, linkPath, originalTarget) + ownedEntry := snapshotEntry(t, store, ownedPath, ownedPath) testassert.NoError(t, os.WriteFile(originalTarget, []byte("after"), 0o644)) - recordSnapshotContent(t, store, turn, linkEntry, "after") + recordSnapshotContent(t, store, linkEntry, "after") testassert.NoError(t, os.WriteFile(ownedPath, []byte("owned-after"), 0o644)) - recordSnapshotContent(t, store, turn, ownedEntry, "owned-after") + recordSnapshotContent(t, store, ownedEntry, "owned-after") testassert.NoError(t, os.Remove(linkPath)) testassert.NoError(t, os.Symlink(repointedTarget, linkPath)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{ownedPath}) && len(affected.Skipped) == 1 && affected.Skipped[0].Path == linkPath, "restored = %v, want [%s]; skipped = %+v, want %s", affected.Restored, ownedPath, affected.Skipped, linkPath) @@ -403,17 +376,16 @@ func TestRevertSkipsExistingFileReplacedByDirectoryAndContinues(t *testing.T) { testassert.NoError(t, os.WriteFile(dirPath, []byte("before"), 0o644)) testassert.NoError(t, os.WriteFile(ownedPath, []byte("owned-before"), 0o644)) - const turn = 1 - dirEntry := snapshotEntry(t, store, turn, dirPath, dirPath) - ownedEntry := snapshotEntry(t, store, turn, ownedPath, ownedPath) + dirEntry := snapshotEntry(t, store, dirPath, dirPath) + ownedEntry := snapshotEntry(t, store, ownedPath, ownedPath) testassert.NoError(t, os.WriteFile(dirPath, []byte("after"), 0o644)) - recordSnapshotContent(t, store, turn, dirEntry, "after") + recordSnapshotContent(t, store, dirEntry, "after") testassert.NoError(t, os.WriteFile(ownedPath, []byte("owned-after"), 0o644)) - recordSnapshotContent(t, store, turn, ownedEntry, "owned-after") + recordSnapshotContent(t, store, ownedEntry, "owned-after") testassert.NoError(t, os.Remove(dirPath)) testassert.NoError(t, os.Mkdir(dirPath, 0o755)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{ownedPath}) && len(affected.Skipped) == 1 && affected.Skipped[0].Path == dirPath, "restored = %v, want [%s]; skipped = %+v, want %s", affected.Restored, ownedPath, affected.Skipped, dirPath) @@ -427,69 +399,16 @@ func TestRevertSkipsExistingFileReplacedByDirectoryAndContinues(t *testing.T) { } } -func TestRevertOldSnapshotMetaFallsBackToOriginalPath(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - path := filepath.Join(projectDir, "file.txt") - testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) - - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(path)) - testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, copyFile(path, filepath.Join(entryDir, "original"))) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{ - OriginalPath: path, - Existed: true, - LastWrite: &SnapshotContentIdentity{Hash: hashBytes([]byte("after"))}, - })) - testassert.NoError(t, os.WriteFile(path, []byte("after"), 0o644)) - - affected, err := store.RevertCode(0) - - testassert.NoError(t, err) - testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{path}), "affected = %v, want [%s]", affected, path) - { - got, err := os.ReadFile(path) - testassert.Require(t, err == nil && string(got) == "before", "restored content = %q, %v; want before", got, err) - } -} - -func TestRevertOldSnapshotMetaRestoresMissingRegularPathWithOldHash(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - path := filepath.Join(projectDir, "file.txt") - testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) - - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(path)) - testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, copyFile(path, filepath.Join(entryDir, "original"))) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{ - OriginalPath: path, - Existed: true, - LastWrite: &SnapshotContentIdentity{Absent: true}, - })) - testassert.NoError(t, os.Remove(path)) - - affected, err := store.RevertCode(0) - - testassert.NoError(t, err) - testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{path}), "affected = %v, want [%s]", affected, path) - { - got, err := os.ReadFile(path) - testassert.Require(t, err == nil && string(got) == "before", "restored content = %q, %v; want before", got, err) - } -} - func TestRevertSkipsExistingFileSnapshotWithoutRecordedLastWriteIdentity(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() path := filepath.Join(projectDir, "target.txt") testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) - const turn = 1 - snapshotEntry(t, store, turn, path, path) + snapshotEntry(t, store, path, path) testassert.NoError(t, os.WriteFile(path, []byte("after"), 0o644)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0 && len(affected.Skipped) == 1 && affected.Skipped[0].Path == path, "restored = %v, want none; skipped = %+v, want %s", affected.Restored, affected, path) { @@ -497,92 +416,13 @@ func TestRevertSkipsExistingFileSnapshotWithoutRecordedLastWriteIdentity(t *test testassert.Require(t, err == nil && string(got) == "after", "path content = %q, %v; want after", got, err) } { - got := readIntDirs(store.snapshotsDir) - testassert.Require(t, reflect.DeepEqual(got, []int{turn}), "snapshot dirs = %v, want [%d]", got, turn) - } -} - -func TestRevertOldSnapshotMetaRestoresCurrentSymlinkTargetWhenHashMatches(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - originalTarget := filepath.Join(projectDir, "original.txt") - linkPath := filepath.Join(projectDir, "link.txt") - testassert.NoError(t, os.WriteFile(originalTarget, []byte("before"), 0o644)) - testassert.NoError(t, os.Symlink(originalTarget, linkPath)) - - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(originalTarget)) - testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, copyFile(originalTarget, filepath.Join(entryDir, "original"))) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{ - OriginalPath: linkPath, - Existed: true, - LastWrite: &SnapshotContentIdentity{Hash: hashBytes([]byte("after"))}, - })) - testassert.NoError(t, os.WriteFile(originalTarget, []byte("after"), 0o644)) - - affected, err := store.RevertCode(0) - - testassert.NoError(t, err) - testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{linkPath}), "affected = %v, want [%s]", affected, linkPath) - { - got, err := os.ReadFile(originalTarget) - testassert.Require(t, err == nil && string(got) == "before", "restored content = %q, %v; want before", got, err) - } -} - -func TestRevertOldSnapshotMetaRefusesGoneSymlinkTarget(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - originalTarget := filepath.Join(projectDir, "original.txt") - linkPath := filepath.Join(projectDir, "link.txt") - testassert.NoError(t, os.WriteFile(originalTarget, []byte("before"), 0o644)) - testassert.NoError(t, os.Symlink(originalTarget, linkPath)) - - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(originalTarget)) - testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, copyFile(originalTarget, filepath.Join(entryDir, "original"))) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: linkPath, Existed: true})) - testassert.NoError(t, os.Remove(linkPath)) - - affected, err := store.RevertCode(0) - - testassert.Require(t, err != nil && strings.Contains(err.Error(), "target missing and cannot be proven"), "RevertCode error = %v, want missing legacy target refusal", err) - testassert.Require(t, len(affected.Restored) == 0, "affected = %v, want none on refused restore", affected) -} - -func TestRevertOldSnapshotMetaRefusesCurrentSymlinkPath(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - originalTarget := filepath.Join(projectDir, "original.txt") - repointedTarget := filepath.Join(projectDir, "repointed.txt") - linkPath := filepath.Join(projectDir, "link.txt") - testassert.NoError(t, os.WriteFile(originalTarget, []byte("before"), 0o644)) - testassert.NoError(t, os.WriteFile(repointedTarget, []byte("other"), 0o644)) - testassert.NoError(t, os.Symlink(originalTarget, linkPath)) - - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(originalTarget)) - testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, copyFile(originalTarget, filepath.Join(entryDir, "original"))) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: linkPath, Existed: true})) - testassert.NoError(t, os.WriteFile(originalTarget, []byte("after"), 0o644)) - testassert.NoError(t, os.Remove(linkPath)) - testassert.NoError(t, os.Symlink(repointedTarget, linkPath)) - - affected, err := store.RevertCode(0) - - testassert.Require(t, err != nil && strings.Contains(err.Error(), "legacy snapshot target hash changed"), "RevertCode error = %v, want legacy hash-change refusal", err) - testassert.Require(t, len(affected.Restored) == 0, "affected = %v, want none on refused restore", affected) - { - got, err := os.ReadFile(originalTarget) - testassert.Require(t, err == nil && string(got) == "after", "original target = %q, %v; want after", got, err) - } - { - got, err := os.ReadFile(repointedTarget) - testassert.Require(t, err == nil && string(got) == "other", "repointed target = %q, %v; want other", got, err) + entries, err := os.ReadDir(filepath.Join(store.snapshotsDir, "1")) + testassert.NoError(t, err) + testassert.Require(t, len(entries) == 1, "snapshot entries = %v, want the skipped entry retained", entries) } } -func TestRevertModernSnapshotSkipsFinalTargetSwappedToSymlink(t *testing.T) { +func TestRevertSnapshotSkipsFinalTargetSwappedToSymlink(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() target := filepath.Join(projectDir, "target.txt") @@ -590,14 +430,13 @@ func TestRevertModernSnapshotSkipsFinalTargetSwappedToSymlink(t *testing.T) { testassert.NoError(t, os.WriteFile(target, []byte("before"), 0o644)) testassert.NoError(t, os.WriteFile(secret, []byte("secret"), 0o644)) - const turn = 1 - entryID := snapshotEntry(t, store, turn, target, target) + entryID := snapshotEntry(t, store, target, target) testassert.NoError(t, os.WriteFile(target, []byte("after"), 0o644)) - recordSnapshotContent(t, store, turn, entryID, "after") + recordSnapshotContent(t, store, entryID, "after") testassert.NoError(t, os.Remove(target)) testassert.NoError(t, os.Symlink(secret, target)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0 && len(affected.Skipped) == 1 && affected.Skipped[0].Path == target, "restored = %v, want none; skipped = %+v, want %s", affected.Restored, affected, target) @@ -614,11 +453,10 @@ func TestRevertExistedFalseSkipsSymlinkWithoutRecordedIdentity(t *testing.T) { secret := filepath.Join(projectDir, "secret.txt") testassert.NoError(t, os.WriteFile(secret, []byte("secret"), 0o644)) - const turn = 1 - snapshotEntry(t, store, turn, createdPath, createdPath) + snapshotEntry(t, store, createdPath, createdPath) testassert.NoError(t, os.Symlink(secret, createdPath)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0 && len(affected.Skipped) == 1 && affected.Skipped[0].Path == createdPath, "restored = %v, want none; skipped = %+v, want %s", affected.Restored, affected, createdPath) @@ -646,15 +484,14 @@ func TestRevertExistedFalseRefusesRepointedParent(t *testing.T) { originalTarget := filepath.Join(originalRoot, "created.txt") repointedTarget := filepath.Join(repointedRoot, "created.txt") - const turn = 1 - entryID := snapshotEntry(t, store, turn, requestedPath, originalTarget) + entryID := snapshotEntry(t, store, requestedPath, originalTarget) testassert.NoError(t, os.WriteFile(originalTarget, []byte("original"), 0o644)) - recordSnapshotContent(t, store, turn, entryID, "original") + recordSnapshotContent(t, store, entryID, "original") testassert.NoError(t, os.WriteFile(repointedTarget, []byte("repointed"), 0o644)) testassert.NoError(t, os.Remove(linkRoot)) testassert.NoError(t, os.Symlink(repointedRoot, linkRoot)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.Require(t, err != nil && strings.Contains(err.Error(), "canonical path changed"), "RevertCode error = %v, want canonical path changed error", err) testassert.Require(t, len(affected.Restored) == 0, "affected = %v, want none on refused delete", affected) @@ -668,34 +505,9 @@ func TestRevertExistedFalseRefusesRepointedParent(t *testing.T) { } } -func TestRevertCodeRestoresLaterSnapshotsAndDeletesLaterSnapshotDirs(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - path := filepath.Join(projectDir, "created.txt") - - // The empty turn-1 artifact directory is seeded explicitly: turn identity - // is named by the test, not allocated. - testassert.NoError(t, os.MkdirAll(filepath.Join(store.snapshotsDir, "1"), 0o700)) - entryID := snapshotEntry(t, store, 2, path, path) - testassert.NoError(t, os.WriteFile(path, []byte("new"), 0o644)) - recordSnapshotContent(t, store, 2, entryID, "new") - - affected, err := store.RevertCode(1) - testassert.NoError(t, err) - testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{path}), "affected = %v, want [%s]", affected, path) - { - _, err := os.Stat(path) - testassert.Require(t, os.IsNotExist(err), "created file should be removed after RevertCode, stat err = %v", err) - } - { - got := readIntDirs(store.snapshotsDir) - testassert.Require(t, reflect.DeepEqual(got, []int{1}), "snapshot dirs = %v, want [1]", got) - } -} - -// RevertCode must include both restored and skipped paths from the same turn so +// RevertCode must include both restored and skipped paths from one group so // callers can surface accurate partial-state reporting while revert continues. -func TestRevertCodeReportsRestoredAndSkippedStateInSameTurn(t *testing.T) { +func TestRevertCodeReportsRestoredAndSkippedStateInOneGroup(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() pathA := filepath.Join(projectDir, "fileA.txt") @@ -706,15 +518,15 @@ func TestRevertCodeReportsRestoredAndSkippedStateInSameTurn(t *testing.T) { } testassert.NoError(t, os.WriteFile(secret, []byte("secret"), 0o644)) - const turn = 1 - entryA := snapshotEntry(t, store, turn, pathA, pathA) - entryB := snapshotEntry(t, store, turn, pathB, pathB) - recordSnapshotContent(t, store, turn, entryA, "before") - recordSnapshotContent(t, store, turn, entryB, "before") + entryA := snapshotEntry(t, store, pathA, pathA) + entryB := snapshotEntry(t, store, pathB, pathB) + recordSnapshotContent(t, store, entryA, "before") + recordSnapshotContent(t, store, entryB, "before") - // revertOneTurn processes entries in os.ReadDir order (lexical by entry - // dir name = hashString(EvalSymlinks(canonicalPath))). Resolve symlinks - // before hashing so the prediction holds when TMPDIR itself is symlinked. + // revertEntriesDir processes entries in os.ReadDir order (lexical by + // entry dir name = hashString(EvalSymlinks(canonicalPath))). Resolve + // symlinks before hashing so the prediction holds when TMPDIR itself is + // symlinked. realA, err := filepath.EvalSymlinks(pathA) testassert.NoError(t, err) realB, err := filepath.EvalSymlinks(pathB) @@ -726,7 +538,7 @@ func TestRevertCodeReportsRestoredAndSkippedStateInSameTurn(t *testing.T) { testassert.NoError(t, os.Remove(secondPath)) testassert.NoError(t, os.Symlink(secret, secondPath)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) containsFirst := false for _, p := range affected.Restored { @@ -735,49 +547,33 @@ func TestRevertCodeReportsRestoredAndSkippedStateInSameTurn(t *testing.T) { break } } - testassert.Require(t, containsFirst, "affected = %v, want to include %q (already-restored entry before mid-turn failure)", affected, firstPath) + testassert.Require(t, containsFirst, "affected = %v, want to include %q (already-restored entry before mid-group failure)", affected, firstPath) testassert.Require(t, len(affected.Skipped) == 1 && affected.Skipped[0].Path == secondPath, "skipped = %+v, want %s", affected, secondPath) } -// One skipped identity blocks every older snapshot entry of the same file -// across turns, and the skipped entries' turn dirs stay recorded. -func TestRevertSkipBlocksOlderSnapshotForSameFile(t *testing.T) { - store := newTestCodeGroup(t) +// One skipped identity blocks every older group's snapshot of the same file, +// and the skipped entries stay recorded in both groups. +func TestRevertSkipBlocksOlderGroupForSameFile(t *testing.T) { + root := t.TempDir() + newestDir := filepath.Join(root, "entry-new") + oldestDir := filepath.Join(root, "entry-old") projectDir := t.TempDir() path := filepath.Join(projectDir, "file.txt") testassert.NoError(t, os.WriteFile(path, []byte("before"), 0o644)) - - entry1 := snapshotEntry(t, store, 1, path, path) - testassert.NoError(t, os.WriteFile(path, []byte("first"), 0o644)) - recordSnapshotContent(t, store, 1, entry1, "first") - - entry2 := snapshotEntry(t, store, 2, path, path) - testassert.NoError(t, os.WriteFile(path, []byte("second"), 0o644)) - recordSnapshotContent(t, store, 2, entry2, "second") + stageGroupCapture(t, oldestDir, path, []byte("first")) + stageGroupCapture(t, newestDir, path, []byte("second")) testassert.NoError(t, os.WriteFile(path, []byte("first"), 0o644)) - affected, err := store.RevertCode(0) + affected, err := RevertCodeGroups([]string{newestDir, oldestDir}) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0 && len(affected.Skipped) == 1 && affected.Skipped[0].Path == path, "restored = %v, want none; skipped = %+v, want one skipped %s", affected.Restored, affected, path) { got, err := os.ReadFile(path) testassert.Require(t, err == nil && string(got) == "first", "path content = %q, %v; want first", got, err) } - { - got := readIntDirs(store.snapshotsDir) - testassert.Require(t, reflect.DeepEqual(got, []int{1, 2}), "snapshot dirs = %v, want [1 2]", got) - } -} - -// copyFile seeds a snapshot preimage by hand in this package's tests: it -// copies a source file into an entry's original/ path exactly as capture -// does. It lives here because no production caller remains. -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err + for _, groupDir := range []string{newestDir, oldestDir} { + entries, err := os.ReadDir(filepath.Join(groupDir, "snapshots", "1")) + testassert.Require(t, err == nil && len(entries) == 1, "group %s entries = %v, %v; want the skipped entry retained", groupDir, entries, err) } - defer in.Close() - return copyFromFile(in, dst) } diff --git a/internal/snapshot/list_changed_files.go b/internal/snapshot/list_changed_files.go deleted file mode 100644 index 5b1c8b99..00000000 --- a/internal/snapshot/list_changed_files.go +++ /dev/null @@ -1,81 +0,0 @@ -package snapshot - -import ( - "fmt" - "os" - "path/filepath" - "sort" -) - -// ListChangedFiles returns the deduplicated, sorted canonical paths of every -// file recorded in the session's target code groups under -// DataDir/code/. The session ID must have the durable identity -// shape — exactly 32 lowercase hexadecimal characters — and the same shape -// check skips every child that is not a valid group directory (including the -// command-output spill directory), every symlink, and every non-directory. -// Entries whose metadata carries no CanonicalPath are skipped: target groups -// require CanonicalPath and there is no legacy-path fallback in this -// namespace. An absent session directory is empty success; any other -// enumeration failure, and any ListTurns error, discards the complete result -// and returns nil with the error. The helper is read-only: it creates no -// groups and reads no conversation or project data. -func ListChangedFiles(dataDir, sessionID string) ([]string, error) { - if !isDurableID(sessionID) { - return nil, fmt.Errorf("snapshot: invalid session id %q", sessionID) - } - sessionDir := filepath.Join(dataDir, "code", sessionID) - children, err := os.ReadDir(sessionDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - seen := make(map[string]bool) - var paths []string - for _, child := range children { - // ReadDir reports Lstat info, so IsDir is already false for symlinks: - // this one check skips symlinks and non-directories alike. - if !child.IsDir() { - continue - } - if !isDurableID(child.Name()) { - continue - } - store, err := OpenCodeStore(filepath.Join(sessionDir, child.Name())) - if err != nil { - return nil, err - } - turns, err := store.ListTurns() - if err != nil { - return nil, err - } - for _, turn := range turns { - for _, file := range turn.Files { - if file.CanonicalPath == "" || seen[file.CanonicalPath] { - continue - } - seen[file.CanonicalPath] = true - paths = append(paths, file.CanonicalPath) - } - } - } - sort.Strings(paths) - return paths, nil -} - -// isDurableID reports whether s has the durable identity shape: exactly 32 -// lowercase hexadecimal characters. It mirrors the harness identity -// validator's shape — this package cannot import the harness. -func isDurableID(s string) bool { - if len(s) != 32 { - return false - } - for i := 0; i < len(s); i++ { - c := s[i] - if (c < '0' || c > '9') && (c < 'a' || c > 'f') { - return false - } - } - return true -} diff --git a/internal/snapshot/list_changed_files_test.go b/internal/snapshot/list_changed_files_test.go deleted file mode 100644 index ac8fd1b5..00000000 --- a/internal/snapshot/list_changed_files_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package snapshot - -import ( - "os" - "path/filepath" - "slices" - "strconv" - "testing" - - "github.com/MMinasyan/lightcode/internal/testassert" -) - -// The ListChangedFiles contract tests: the durable identity shape on the -// session id and every group name, the absent-directory and enumeration-error -// rows, the skip rules, the empty-CanonicalPath rule, and the dedupe/sort -// result. - -const listTestSession = "0123456789abcdef0123456789abcdef" - -// seedListGroup writes one code group's snapshot metadata the way a real -// group records it: /snapshots///meta.json. -func seedListGroup(t *testing.T, dataDir, sessionID, groupID string, turns map[int][]SnapshotMeta) { - t.Helper() - for turn, files := range turns { - for i, meta := range files { - entry := filepath.Join(dataDir, "code", sessionID, groupID, "snapshots", strconv.Itoa(turn), "entry"+strconv.Itoa(i)) - testassert.NoError(t, os.MkdirAll(entry, 0o755)) - writeMeta(t, filepath.Join(entry, "meta.json"), meta) - } - } -} - -func writeMeta(t *testing.T, path string, meta SnapshotMeta) { - t.Helper() - data := "{\"original_path\":\"" + meta.OriginalPath + "\",\"canonical_path\":\"" + meta.CanonicalPath + "\",\"existed\":true}" - testassert.NoError(t, os.WriteFile(path, []byte(data), 0o600)) -} - -func TestListChangedFilesAbsentSessionIsEmptySuccess(t *testing.T) { - paths, err := ListChangedFiles(t.TempDir(), listTestSession) - testassert.NoError(t, err) - testassert.Require(t, len(paths) == 0, "paths = %q, want empty", paths) -} - -func TestListChangedFilesSessionIDShape(t *testing.T) { - for _, bad := range []string{"", "short", "0123456789abcdef0123456789abcde", "0123456789ABCDEF0123456789ABCDEF", "0123456789abcdef0123456789abcdeg"} { - { - _, err := ListChangedFiles(t.TempDir(), bad) - testassert.Check(t, err != nil, "ListChangedFiles(%q) = nil error, want the identity-shape rejection", bad) - } - } -} - -func TestListChangedFilesEnumerationError(t *testing.T) { - dataDir := t.TempDir() - // The session path is a file: reading it as a directory is an error other - // than not-exist. - testassert.NoError(t, os.MkdirAll(filepath.Join(dataDir, "code"), 0o755)) - testassert.NoError(t, os.WriteFile(filepath.Join(dataDir, "code", listTestSession), []byte("x"), 0o600)) - paths, err := ListChangedFiles(dataDir, listTestSession) - testassert.Require(t, err != nil, "ListChangedFiles on a file session path = nil error, want the enumeration failure") - testassert.Require(t, paths == nil, "paths = %q, want nil", paths) -} - -func TestListChangedFilesSkipsInvalidNamesSymlinksAndNonDirectories(t *testing.T) { - dataDir := t.TempDir() - sessionDir := filepath.Join(dataDir, "code", listTestSession) - testassert.NoError(t, os.MkdirAll(sessionDir, 0o755)) - // The command-output spill directory: a non-hex sibling of the groups. - testassert.NoError(t, os.MkdirAll(filepath.Join(sessionDir, "output"), 0o755)) - // A valid group whose content would leak if the name check were missing. - testassert.NoError(t, os.MkdirAll(filepath.Join(sessionDir, "output", "snapshots", "1", "e"), 0o755)) - writeMeta(t, filepath.Join(sessionDir, "output", "snapshots", "1", "e", "meta.json"), SnapshotMeta{OriginalPath: "/o", CanonicalPath: "/leaked"}) - - // A symlinked group directory with real content behind it. - real := filepath.Join(t.TempDir(), "realgroup") - testassert.NoError(t, os.MkdirAll(filepath.Join(real, "snapshots", "1", "e"), 0o755)) - writeMeta(t, filepath.Join(real, "snapshots", "1", "e", "meta.json"), SnapshotMeta{OriginalPath: "/s", CanonicalPath: "/symlinked"}) - testassert.NoError(t, os.Symlink(real, filepath.Join(sessionDir, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))) - - // A non-directory child with a valid group-ID name. - testassert.NoError(t, os.WriteFile(filepath.Join(sessionDir, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), []byte("x"), 0o600)) - - // A short-named group: content present, name invalid. - testassert.NoError(t, os.MkdirAll(filepath.Join(sessionDir, "short", "snapshots", "1", "e"), 0o755)) - writeMeta(t, filepath.Join(sessionDir, "short", "snapshots", "1", "e", "meta.json"), SnapshotMeta{OriginalPath: "/h", CanonicalPath: "/shortgroup"}) - - paths, err := ListChangedFiles(dataDir, listTestSession) - testassert.NoError(t, err) - testassert.Require(t, len(paths) == 0, "paths = %q, want every invalid-name, symlink and non-directory child skipped", paths) -} - -func TestListChangedFilesSkipsMalformedMetadataAndEmptyCanonical(t *testing.T) { - dataDir := t.TempDir() - sessionDir := filepath.Join(dataDir, "code", listTestSession) - group := filepath.Join(sessionDir, "cccccccccccccccccccccccccccccccc", "snapshots", "1", "a") - testassert.NoError(t, os.MkdirAll(group, 0o755)) - // Malformed metadata is skipped silently by ListTurns. - testassert.NoError(t, os.WriteFile(filepath.Join(group, "meta.json"), []byte("{not json"), 0o600)) - // An empty-CanonicalPath entry is skipped: no legacy-path fallback. - empty := filepath.Join(sessionDir, "cccccccccccccccccccccccccccccccc", "snapshots", "1", "b") - testassert.NoError(t, os.MkdirAll(empty, 0o755)) - writeMeta(t, filepath.Join(empty, "meta.json"), SnapshotMeta{OriginalPath: "/legacy-only"}) - - // The one valid entry. - valid := filepath.Join(sessionDir, "cccccccccccccccccccccccccccccccc", "snapshots", "2", "c") - testassert.NoError(t, os.MkdirAll(valid, 0o755)) - writeMeta(t, filepath.Join(valid, "meta.json"), SnapshotMeta{OriginalPath: "/orig", CanonicalPath: "/kept.go"}) - - paths, err := ListChangedFiles(dataDir, listTestSession) - testassert.NoError(t, err) - testassert.Require(t, len(paths) == 1 && paths[0] == "/kept.go", "paths = %q, want [/kept.go] only", paths) -} - -func TestListChangedFilesDeduplicatesAndSortsAcrossGroups(t *testing.T) { - newMeta := func(original, canonical string) SnapshotMeta { - return SnapshotMeta{OriginalPath: original, CanonicalPath: canonical} - } - groups := func() map[int][]SnapshotMeta { - return map[int][]SnapshotMeta{ - 1: {newMeta("/z", "/z.go"), newMeta("/a", "/a.go")}, - 2: {newMeta("/a2", "/a.go"), newMeta("/m", "/m.go")}, - } - } - dataDir := t.TempDir() - seedListGroup(t, dataDir, listTestSession, "dddddddddddddddddddddddddddddddd", groups()) - seedListGroup(t, dataDir, listTestSession, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", groups()) - paths, err := ListChangedFiles(dataDir, listTestSession) - testassert.NoError(t, err) - want := []string{"/a.go", "/m.go", "/z.go"} - testassert.Require(t, slices.Equal(paths, want), "paths = %q, want %q", paths, want) -} - -func TestListChangedFilesTurnErrorDiscardsResult(t *testing.T) { - dataDir := t.TempDir() - sessionDir := filepath.Join(dataDir, "code", listTestSession) - - // An earlier readable group contributes a path. - seedListGroup(t, dataDir, listTestSession, "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", map[int][]SnapshotMeta{ - 1: {SnapshotMeta{OriginalPath: "/e", CanonicalPath: "/kept.go"}}, - }) - - // A later group's turn is unreadable: ListTurns fails with its partial - // entries, and the complete result — including the earlier group's - // path — must be discarded. - turnDir := filepath.Join(sessionDir, "ffffffffffffffffffffffffffffffff", "snapshots", "1", "e") - testassert.NoError(t, os.MkdirAll(turnDir, 0o755)) - writeMeta(t, filepath.Join(turnDir, "meta.json"), SnapshotMeta{OriginalPath: "/x", CanonicalPath: "/x.go"}) - if os.Geteuid() == 0 { - t.Skip("directory permissions do not block reads as root") - } - testassert.NoError(t, os.Chmod(filepath.Join(sessionDir, "ffffffffffffffffffffffffffffffff", "snapshots", "1"), 0o000)) - t.Cleanup(func() { - _ = os.Chmod(filepath.Join(sessionDir, "ffffffffffffffffffffffffffffffff", "snapshots", "1"), 0o755) - }) - - paths, err := ListChangedFiles(dataDir, listTestSession) - testassert.Require(t, err != nil, "ListChangedFiles = nil error, want the turn-read failure") - testassert.Require(t, paths == nil, "paths = %q, want the complete result discarded", paths) -} diff --git a/internal/snapshot/meta.go b/internal/snapshot/meta.go index 119ae4bb..2d11b703 100644 --- a/internal/snapshot/meta.go +++ b/internal/snapshot/meta.go @@ -1,6 +1,6 @@ // Package snapshot owns the deferred copy-based code capture per target code -// group: snapshots/// holding each touched file's preimage -// and metadata, with first-write-wins capture, last-write identity checks and +// group: snapshots/1// holding each touched file's preimage and +// metadata, with first-write-wins capture, last-write identity checks and // the shared restore traversal. It reads no conversation, claim or session // lifecycle state. // @@ -8,15 +8,13 @@ // // / // └── snapshots/ -// └── / +// └── 1/ // └── / // ├── original # pre-edit bytes, absent if file didn't exist // └── meta.json # {original_path, canonical_path, existed, last_write} // -// Turn numbers are integers starting at 1. Retained pre-cutover session -// directories keep their old meta.json/turns/claims files untouched; the -// retained reader in runtime proves only the explicit old ID, project path -// and project hash members and never interprets the rest. +// Old pre-cutover session directories keep their meta.json/turns/claims files +// inert and untouched; no snapshot reader interprets them. package snapshot import ( @@ -29,10 +27,10 @@ import ( // SnapshotMeta is written alongside each snapshotted file. OriginalPath // is the user-facing path shown in history and affected-file output. -// CanonicalPath is the hidden restore target for new snapshots. Old -// metadata without CanonicalPath falls back to OriginalPath. -// Existed is false when the snapshot represents a file that did not -// exist before the turn — on revert, that file is deleted. +// CanonicalPath is the capture-time canonical witness every restore is +// proven against; an entry without it is refused, never reconstructed +// from a path guess. Existed is false when the snapshot represents a file +// that did not exist before the mutation — on revert, that file is deleted. type SnapshotMeta struct { OriginalPath string `json:"original_path"` CanonicalPath string `json:"canonical_path,omitempty"` @@ -40,8 +38,10 @@ type SnapshotMeta struct { LastWrite *SnapshotContentIdentity `json:"last_write,omitempty"` } -// SnapshotContentIdentity identifies the state produced by the session's -// latest successful mutation for a snapshot entry. +// SnapshotContentIdentity identifies the state a completed producer mutation +// left for a snapshot entry. The pre-mutation proof clear and any uncertain +// mutation failure leave LastWrite nil, and a restore against a nil LastWrite +// skips the entry instead of guessing. type SnapshotContentIdentity struct { Hash string `json:"hash,omitempty"` Absent bool `json:"absent,omitempty"` diff --git a/internal/snapshot/snapshot_restore_security_test.go b/internal/snapshot/snapshot_restore_security_test.go index 8c287878..5090081d 100644 --- a/internal/snapshot/snapshot_restore_security_test.go +++ b/internal/snapshot/snapshot_restore_security_test.go @@ -3,7 +3,6 @@ package snapshot import ( "os" "path/filepath" - "reflect" "strings" "syscall" "testing" @@ -16,11 +15,10 @@ func TestRevertSkipsCreatedDirectoryLeavesWithoutRecordedIdentity(t *testing.T) store := newTestCodeGroup(t) projectDir := t.TempDir() createdPath := filepath.Join(projectDir, "created-dir") - const turn = 1 - snapshotEntry(t, store, turn, createdPath, createdPath) + snapshotEntry(t, store, createdPath, createdPath) testassert.NoError(t, os.Mkdir(createdPath, 0o755)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0 && len(affected.Skipped) == 1 && affected.Skipped[0].Path == createdPath, "restored = %v, want none; skipped = %+v, want %s", affected.Restored, affected.Skipped, createdPath) if info, err := os.Lstat(createdPath); err != nil || !info.IsDir() { @@ -33,12 +31,11 @@ func TestRevertSkipsCreatedDirectoryLeavesWithoutRecordedIdentity(t *testing.T) projectDir := t.TempDir() createdPath := filepath.Join(projectDir, "created-dir") childPath := filepath.Join(createdPath, "child.txt") - const turn = 1 - snapshotEntry(t, store, turn, createdPath, createdPath) + snapshotEntry(t, store, createdPath, createdPath) testassert.NoError(t, os.Mkdir(createdPath, 0o755)) testassert.NoError(t, os.WriteFile(childPath, []byte("child"), 0o644)) - affected, err := store.RevertCode(0) + affected, err := revertGroup(store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0 && len(affected.Skipped) == 1 && affected.Skipped[0].Path == createdPath, "restored = %v, want none; skipped = %+v, want %s", affected.Restored, affected.Skipped, createdPath) { @@ -48,31 +45,51 @@ func TestRevertSkipsCreatedDirectoryLeavesWithoutRecordedIdentity(t *testing.T) }) } -func TestLegacyCreatedDeleteRequiresCleanPathHash(t *testing.T) { - // Hash match or mismatch, the legacy Existed:false branch must refuse — - // legacy snapshots carry no capture-time canonical witness, so the - // restore cannot prove the current path matches what was observed-as-missing. - t.Run("hash match still refuses", func(t *testing.T) { +// An entry whose meta carries no capture-time canonical proof is refused: +// restore never reconstructs a target from a path guess, for either an +// existing preimage or a created-file delete. +func TestRestoreRefusesEntriesWithoutCanonicalProof(t *testing.T) { + t.Run("existed leaf refuses despite clean-path hash match", func(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() - testassert.NoError(t, os.Mkdir(filepath.Join(projectDir, "sub"), 0o755)) - cleanPath := filepath.Join(projectDir, "created.txt") - dirtyPath := filepath.Join(projectDir, "sub") + string(filepath.Separator) + ".." + string(filepath.Separator) + "created.txt" - testassert.NoError(t, os.WriteFile(cleanPath, []byte("created"), 0o644)) - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(filepath.Clean(dirtyPath))) + path := filepath.Join(projectDir, "file.txt") + testassert.NoError(t, os.WriteFile(path, []byte("after"), 0o644)) + entryDir := filepath.Join(store.snapshotsDir, "1", hashString(path)) testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: dirtyPath, Existed: false})) + testassert.NoError(t, os.WriteFile(filepath.Join(entryDir, "original"), []byte("before"), 0o600)) + testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{ + OriginalPath: path, + Existed: true, + LastWrite: &SnapshotContentIdentity{Hash: hashBytes([]byte("after"))}, + })) + + affected, err := revertGroup(store) + testassert.Require(t, err != nil, "RevertCodeGroups = %+v, want refusal of an entry with no capture-time canonical proof", affected) + testassert.Require(t, len(affected.Restored) == 0, "affected = %v, want none on refused restore", affected) + { + got, readErr := os.ReadFile(path) + testassert.Require(t, readErr == nil && string(got) == "after", "file = (%q, %v), want the current content untouched", got, readErr) + } + }) - _, err := store.RevertCode(0) - testassert.Require(t, err != nil, "RevertCode succeeded for legacy Existed:false with hash match; want unconditional refusal") - testassert.Require(t, strings.Contains(err.Error(), "legacy delete refused"), "error %v does not mention legacy delete refused", err) + t.Run("created leaf refuses despite clean-path hash match", func(t *testing.T) { + store := newTestCodeGroup(t) + projectDir := t.TempDir() + path := filepath.Join(projectDir, "created.txt") + testassert.NoError(t, os.WriteFile(path, []byte("created"), 0o644)) + entryDir := filepath.Join(store.snapshotsDir, "1", hashString(path)) + testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) + testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: path, Existed: false})) + + _, err := revertGroup(store) + testassert.Require(t, err != nil, "RevertCodeGroups succeeded for a created entry with no capture-time canonical proof, want refusal") { - got, readErr := os.ReadFile(cleanPath) - testassert.Require(t, readErr == nil && string(got) == "created", "file content = %q, %v; want unchanged after refused legacy delete", got, readErr) + got, readErr := os.ReadFile(path) + testassert.Require(t, readErr == nil && string(got) == "created", "file = (%q, %v), want unchanged after refused delete", got, readErr) } }) - t.Run("hash mismatch refuses", func(t *testing.T) { + t.Run("created leaf refuses with a mismatched dirty path", func(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() testassert.NoError(t, os.Mkdir(filepath.Join(projectDir, "sub"), 0o755)) @@ -84,119 +101,29 @@ func TestLegacyCreatedDeleteRequiresCleanPathHash(t *testing.T) { testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: victimDirty, Existed: false})) - affected, err := store.RevertCode(0) - testassert.Require(t, err != nil, "RevertCode succeeded for mismatched legacy delete hash, want refusal") + affected, err := revertGroup(store) + testassert.Require(t, err != nil, "RevertCodeGroups succeeded for a mismatched no-proof delete, want refusal") testassert.Require(t, len(affected.Restored) == 0, "affected = %v, want none", affected) { got, err := os.ReadFile(victimClean) testassert.Require(t, err == nil && string(got) == "victim", "victim content = %q, %v; want unchanged", got, err) } }) -} - -func TestLegacyMissingLeafRestoreRequiresTargetProof(t *testing.T) { - t.Run("symlink parent still points to original target", func(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - realDir := filepath.Join(projectDir, "real") - testassert.NoError(t, os.Mkdir(realDir, 0o755)) - linkDir := filepath.Join(projectDir, "linked") - testassert.NoError(t, os.Symlink(realDir, linkDir)) - target := filepath.Join(realDir, "file.txt") - requested := filepath.Join(linkDir, "file.txt") - testassert.NoError(t, os.WriteFile(target, []byte("before"), 0o644)) - writeLegacySnapshotForMissingLeafProof(t, store, requested, target) - testassert.NoError(t, os.Remove(target)) - - affected, err := store.RevertCode(0) - testassert.NoError(t, err) - testassert.Require(t, reflect.DeepEqual(affected.Restored, []string{requested}), "affected = %v, want [%s]", affected, requested) - { - got, err := os.ReadFile(target) - testassert.Require(t, err == nil && string(got) == "before", "restored target = %q, %v; want before", got, err) - } - }) - - t.Run("symlink parent repointed elsewhere", func(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - realDir := filepath.Join(projectDir, "real") - secretDir := filepath.Join(projectDir, "secret") - testassert.NoError(t, os.Mkdir(realDir, 0o755)) - testassert.NoError(t, os.Mkdir(secretDir, 0o755)) - linkDir := filepath.Join(projectDir, "linked") - testassert.NoError(t, os.Symlink(realDir, linkDir)) - target := filepath.Join(realDir, "file.txt") - requested := filepath.Join(linkDir, "file.txt") - secretTarget := filepath.Join(secretDir, "file.txt") - testassert.NoError(t, os.WriteFile(target, []byte("before"), 0o644)) - testassert.NoError(t, os.WriteFile(secretTarget, []byte("secret"), 0o644)) - writeLegacySnapshotForMissingLeafProof(t, store, requested, target) - testassert.NoError(t, os.Remove(target)) - testassert.NoError(t, os.Remove(linkDir)) - testassert.NoError(t, os.Symlink(secretDir, linkDir)) - - affected, err := store.RevertCode(0) - testassert.Require(t, err != nil, "RevertCode succeeded for repointed symlink parent, want refusal") - testassert.Require(t, len(affected.Restored) == 0, "affected = %v, want none", affected) - { - got, err := os.ReadFile(secretTarget) - testassert.Require(t, err == nil && string(got) == "secret", "secret target = %q, %v; want unchanged", got, err) - } - }) -} - -func writeLegacySnapshotForMissingLeafProof(t *testing.T, store *CodeStore, requested, canonical string) { - t.Helper() - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(canonical)) - testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, copyFile(canonical, filepath.Join(entryDir, "original"))) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{ - OriginalPath: requested, - Existed: true, - LastWrite: &SnapshotContentIdentity{Absent: true}, - })) -} - -// Legacy Existed:false snapshots (no meta.CanonicalPath) must be refused -// at restore time — they carry no capture-time canonical witness, so the -// restore cannot prove the current path matches what was observed-as-missing. -func TestPR11Closure_LegacyExistedFalseDeletesAreRefused(t *testing.T) { - t.Run("file present at OriginalPath", func(t *testing.T) { - store := newTestCodeGroup(t) - projectDir := t.TempDir() - createdPath := filepath.Join(projectDir, "victim.txt") - testassert.NoError(t, os.WriteFile(createdPath, []byte("victim-content"), 0o644)) - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(filepath.Clean(createdPath))) - testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), - SnapshotMeta{OriginalPath: createdPath, Existed: false})) - - _, err := store.RevertCode(0) - testassert.Require(t, err != nil, "RevertCode succeeded for legacy Existed:false, want refusal") - testassert.Require(t, strings.Contains(err.Error(), "legacy delete refused"), "error %v does not mention legacy delete refused", err) - { - got, err := os.ReadFile(createdPath) - testassert.Require(t, err == nil && string(got) == "victim-content", "file content = %q, %v; want unchanged", got, err) - } - }) - t.Run("target absent at OriginalPath", func(t *testing.T) { + t.Run("created leaf refuses when the target is absent", func(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() absentPath := filepath.Join(projectDir, "absent.txt") - entryDir := filepath.Join(store.snapshotsDir, "1", hashString(filepath.Clean(absentPath))) + entryDir := filepath.Join(store.snapshotsDir, "1", hashString(absentPath)) testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) - testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), - SnapshotMeta{OriginalPath: absentPath, Existed: false})) + testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: absentPath, Existed: false})) - _, err := store.RevertCode(0) - testassert.Require(t, err != nil, "RevertCode succeeded for legacy Existed:false with absent target, want refusal") - testassert.Require(t, strings.Contains(err.Error(), "legacy delete refused"), "error %v does not mention legacy delete refused", err) + _, err := revertGroup(store) + testassert.Require(t, err != nil, "RevertCodeGroups succeeded for a no-proof delete of an absent target, want refusal") }) } -// A modern snapshot whose current on-disk leaf is a hardlink to an +// A canonical snapshot whose current on-disk leaf is a hardlink to an // outside-boundary inode must be refused by restore so the FD-acquisition // nlink check stops the mutation before it crosses the project boundary. func TestPR11Closure_HardlinkRefusedOnSnapshotRestore(t *testing.T) { @@ -205,17 +132,16 @@ func TestPR11Closure_HardlinkRefusedOnSnapshotRestore(t *testing.T) { target := filepath.Join(projectDir, "target.txt") testassert.NoError(t, os.WriteFile(target, []byte("before"), 0o644)) - const turn = 1 - entryID := snapshotEntry(t, store, turn, target, target) + entryID := snapshotEntry(t, store, target, target) // Replace target with a hardlink to an outside-boundary file. outside := filepath.Join(t.TempDir(), "outside.txt") testassert.NoError(t, os.WriteFile(outside, []byte("outside-content"), 0o644)) testassert.NoError(t, os.Remove(target)) testassert.NoError(t, os.Link(outside, target)) - recordSnapshotContent(t, store, turn, entryID, "outside-content") + recordSnapshotContent(t, store, entryID, "outside-content") - _, err := store.RevertCode(0) + _, err := revertGroup(store) testassert.Require(t, err != nil, "RevertCode succeeded with hardlinked restore target; want refusal") msg := strings.ToLower(err.Error()) testassert.Require(t, strings.Contains(msg, "hardlink") || strings.Contains(msg, "link count") || strings.Contains(msg, "nlink"), "error %v does not mention hardlink/link count/nlink", err) @@ -225,23 +151,22 @@ func TestPR11Closure_HardlinkRefusedOnSnapshotRestore(t *testing.T) { } } -// A modern Existed:false snapshot must refuse to delete a current hardlinked -// regular-file leaf. This closes the created-file delete branch, which does -// not go through restoreFile's OpenForWrite nlink check. +// A canonical Existed:false snapshot must refuse to delete a current +// hardlinked regular-file leaf. This closes the created-file delete branch, +// which does not go through restoreFile's OpenForWrite nlink check. func TestPR11Closure_HardlinkRefusedOnSnapshotCreatedDelete(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() createdPath := filepath.Join(projectDir, "created.txt") - const turn = 1 - entryID := snapshotEntry(t, store, turn, createdPath, createdPath) + entryID := snapshotEntry(t, store, createdPath, createdPath) outside := filepath.Join(t.TempDir(), "outside.txt") testassert.NoError(t, os.WriteFile(outside, []byte("outside-content"), 0o644)) testassert.NoError(t, os.Link(outside, createdPath)) - recordSnapshotContent(t, store, turn, entryID, "outside-content") + recordSnapshotContent(t, store, entryID, "outside-content") - _, err := store.RevertCode(0) + _, err := revertGroup(store) testassert.Require(t, err != nil, "RevertCode succeeded deleting hardlinked created-file leaf; want refusal") msg := strings.ToLower(err.Error()) testassert.Require(t, strings.Contains(msg, "hardlink") || strings.Contains(msg, "link count") || strings.Contains(msg, "nlink"), "error %v does not mention hardlink/link count/nlink", err) @@ -260,8 +185,7 @@ func TestPR11Closure_HardlinkedSymlinkRefusedOnSnapshotCreatedDelete(t *testing. projectDir := t.TempDir() createdPath := filepath.Join(projectDir, "created-link") - const turn = 1 - entryID := snapshotEntry(t, store, turn, createdPath, createdPath) + entryID := snapshotEntry(t, store, createdPath, createdPath) target := filepath.Join(t.TempDir(), "outside-target.txt") testassert.NoError(t, os.WriteFile(target, []byte("outside-target"), 0o644)) @@ -270,9 +194,9 @@ func TestPR11Closure_HardlinkedSymlinkRefusedOnSnapshotCreatedDelete(t *testing. testassert.NoError(t, os.Link(originalLink, createdPath)) assertHardlinkedSymlink(t, originalLink) assertHardlinkedSymlink(t, createdPath) - recordSnapshotContent(t, store, turn, entryID, "outside-target") + recordSnapshotContent(t, store, entryID, "outside-target") - _, err := store.RevertCode(0) + _, err := revertGroup(store) testassert.Require(t, err != nil, "RevertCode succeeded deleting hardlinked symlink created leaf; want refusal") msg := strings.ToLower(err.Error()) testassert.Require(t, strings.Contains(msg, "hardlink") || strings.Contains(msg, "link count") || strings.Contains(msg, "nlink"), "error %v does not mention hardlink/link count/nlink", err) @@ -290,19 +214,18 @@ func TestPR11Closure_HardlinkedSymlinkRefusedOnSnapshotCreatedDelete(t *testing. } } -// A modern Existed:false snapshot must be bound to the entry directory ID +// A canonical Existed:false snapshot must be bound to the entry directory ID // (hashString(realPath)). A meta.json content rewrite alone that redirects // CanonicalPath must be refused because hashString(new path) no longer // matches the on-disk entry directory name. -func TestPR11Closure_ModernDeleteBoundToEntryID(t *testing.T) { +func TestPR11Closure_CanonicalDeleteBoundToEntryID(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() originalPath := filepath.Join(projectDir, "created.txt") victimPath := filepath.Join(projectDir, "victim.txt") testassert.NoError(t, os.WriteFile(victimPath, []byte("victim-content"), 0o644)) - const turn = 1 - snapshotEntry(t, store, turn, originalPath, originalPath) + snapshotEntry(t, store, originalPath, originalPath) // The capture writes meta with CanonicalPath=originalPath, entryDir=hashString(originalPath). // Tamper meta.json to redirect CanonicalPath (and OriginalPath) to victimPath. entryDir := filepath.Join(store.snapshotsDir, "1", hashString(filepath.Clean(originalPath))) @@ -313,21 +236,20 @@ func TestPR11Closure_ModernDeleteBoundToEntryID(t *testing.T) { testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: victimPath, CanonicalPath: victimPath, Existed: false})) - _, err := store.RevertCode(0) + _, err := revertGroup(store) testassert.Require(t, err != nil, "RevertCode succeeded with tampered meta.json; want refusal") { got, readErr := os.ReadFile(victimPath) testassert.Require(t, readErr == nil && string(got) == "victim-content", "victim file = %q, %v; want unchanged", got, readErr) } - _ = turn } -// A modern Existed:true snapshot must be bound to the entry directory ID +// A canonical Existed:true snapshot must be bound to the entry directory ID // (hashString(realPath)). A meta.json content rewrite alone that redirects // CanonicalPath must be refused because hashString(new path) no longer -// matches the on-disk entry directory name. Symmetric with the modern delete -// binding in TestPR11Closure_ModernDeleteBoundToEntryID. -func TestPR11Closure_ModernRestoreBoundToEntryID(t *testing.T) { +// matches the on-disk entry directory name. Symmetric with the canonical +// delete binding in TestPR11Closure_CanonicalDeleteBoundToEntryID. +func TestPR11Closure_CanonicalRestoreBoundToEntryID(t *testing.T) { store := newTestCodeGroup(t) projectDir := t.TempDir() originalPath := filepath.Join(projectDir, "original.txt") @@ -335,8 +257,7 @@ func TestPR11Closure_ModernRestoreBoundToEntryID(t *testing.T) { testassert.NoError(t, os.WriteFile(originalPath, []byte("original-content"), 0o644)) testassert.NoError(t, os.WriteFile(victimPath, []byte("victim-content"), 0o644)) - const turn = 1 - snapshotEntry(t, store, turn, originalPath, originalPath) + snapshotEntry(t, store, originalPath, originalPath) // Snapshot names the entry dir after hashString(EvalSymlinks(canonicalPath)), // so resolve symlinks before hashing in case TMPDIR itself is symlinked. realOriginal, err := filepath.EvalSymlinks(originalPath) @@ -350,19 +271,18 @@ func TestPR11Closure_ModernRestoreBoundToEntryID(t *testing.T) { } // Redirect meta to victimPath, but write its canonical-resolved form so // the tampered entry is self-consistent under ResolveFilePath. Without - // the new entry-id binding, the redirect would slip through; with it, + // the entry-id binding, the redirect would slip through; with it, // hashString(realVictim) != entryID is the only line of defense. testassert.NoError(t, writeJSON(filepath.Join(entryDir, "meta.json"), SnapshotMeta{OriginalPath: victimPath, CanonicalPath: realVictim, Existed: true})) - _, err = store.RevertCode(0) - testassert.Require(t, err != nil, "RevertCode succeeded with tampered modern restore meta.json; want refusal") - testassert.Require(t, strings.Contains(err.Error(), "modern restore refused"), "error %v does not mention modern restore refusal", err) + _, err = revertGroup(store) + testassert.Require(t, err != nil, "RevertCode succeeded with tampered canonical restore meta.json; want refusal") + testassert.Require(t, strings.Contains(err.Error(), "canonical proof refused"), "error %v does not mention the canonical proof refusal", err) { got, readErr := os.ReadFile(victimPath) testassert.Require(t, readErr == nil && string(got) == "victim-content", "victim file = %q, %v; want unchanged", got, readErr) } - _ = turn } func assertHardlinkedSymlink(t *testing.T, path string) { diff --git a/internal/snapshot/snapshot_shape_test.go b/internal/snapshot/snapshot_shape_test.go index 3c58e868..811f41ad 100644 --- a/internal/snapshot/snapshot_shape_test.go +++ b/internal/snapshot/snapshot_shape_test.go @@ -14,7 +14,7 @@ import ( "golang.org/x/sys/unix" ) -func TestSnapshotResolvedRejectsNonRegularExistingTargets(t *testing.T) { +func TestSnapshotResolvedEntryRejectsNonRegularExistingTargets(t *testing.T) { for _, tc := range []struct { name string target func(t *testing.T) string @@ -55,16 +55,15 @@ func TestSnapshotResolvedRejectsNonRegularExistingTargets(t *testing.T) { t.Run(tc.name, func(t *testing.T) { store := newTestCodeGroup(t) target := tc.target(t) - const turn = 1 err := runSnapshotShapeWithTimeout(func() error { - _, _, err := store.SnapshotResolvedEntry(turn, target, target) + _, _, err := store.SnapshotResolvedEntry(target, target) return err }) - testassert.Require(t, err != nil, "SnapshotResolved(%s) succeeded, want non-regular target refusal", tc.name) + testassert.Require(t, err != nil, "SnapshotResolvedEntry(%s) succeeded, want non-regular target refusal", tc.name) msg := strings.ToLower(err.Error()) - testassert.Require(t, !strings.Contains(msg, "did not fail closed before blocking"), "SnapshotResolved(%s) error = %v, want refusal before blocking on target I/O", tc.name, err) - testassert.Require(t, strings.Contains(msg, "non-regular"), "SnapshotResolved(%s) error = %v, want explicit non-regular target refusal", tc.name, err) + testassert.Require(t, !strings.Contains(msg, "did not fail closed before blocking"), "SnapshotResolvedEntry(%s) error = %v, want refusal before blocking on target I/O", tc.name, err) + testassert.Require(t, strings.Contains(msg, "non-regular"), "SnapshotResolvedEntry(%s) error = %v, want explicit non-regular target refusal", tc.name, err) entries, readErr := os.ReadDir(filepath.Join(store.snapshotsDir, "1")) testassert.Require(t, readErr == nil || errors.Is(readErr, os.ErrNotExist), "%v", readErr) @@ -78,20 +77,19 @@ func TestSnapshotResolvedRejectsNonRegularExistingTargets(t *testing.T) { } } -func TestSnapshotResolvedStillAllowsMissingAndRegularTargets(t *testing.T) { +func TestSnapshotResolvedEntryStillAllowsMissingAndRegularTargets(t *testing.T) { store := newTestCodeGroup(t) - const turn = 1 missing := filepath.Join(t.TempDir(), "new-file.txt") { - _, _, err := store.SnapshotResolvedEntry(turn, missing, missing) + _, _, err := store.SnapshotResolvedEntry(missing, missing) testassert.NoError(t, err) } regular := filepath.Join(t.TempDir(), "regular.txt") testassert.NoError(t, os.WriteFile(regular, []byte("before"), 0o644)) { - _, _, err := store.SnapshotResolvedEntry(turn, regular, regular) + _, _, err := store.SnapshotResolvedEntry(regular, regular) testassert.NoError(t, err) } } diff --git a/internal/terminal/render.go b/internal/terminal/render.go index 11cdc2e7..eb8a9cc8 100644 --- a/internal/terminal/render.go +++ b/internal/terminal/render.go @@ -561,14 +561,6 @@ func FormatToolArgs(name, args string) string { return FormatHeaderArg(prompt, 80) } return FormatHeaderArg(args, 80) - case "diagnostics": - return "" - case "workspace_symbol": - q := extractJSONString(args, "query") - if q != "" { - return FormatHeaderArg(q, 80) - } - return FormatHeaderArg(args, 80) default: return FormatHeaderArg(args, 80) } diff --git a/internal/tool/apply_patch_apply.go b/internal/tool/apply_patch_apply.go index 728e305f..7ce19902 100644 --- a/internal/tool/apply_patch_apply.go +++ b/internal/tool/apply_patch_apply.go @@ -55,12 +55,27 @@ func executeParsedPatch(root, rootCanonical string, store SnapshotStore, p *patc } // Phase 2: apply (mutations). Each op tracks its own committed state - // so a later op's failure can report the earlier committed files. + // so a later op's failure can report the earlier committed files. The + // already-approved Workspace root, write-dir and canonical target + // witnesses ride to each op's snapshot-to-I/O boundary. previews := make([]AppliedFilePreview, 0, len(plans)) committed := make([]appliedOp, 0, len(plans)) for i := range plans { pl := &plans[i] - preview, err := applyOne(pl, store, &committed) + srcBound := boundCall{ + root: root, toolName: "apply_patch", path: pl.op.path, + rootCanonical: rootCanonical, canonical: pl.canonicalPath, + writeDir: writeDir, writeDirCanonical: writeDirCanonical, + } + var dstBound boundCall + if pl.moveCanonical != "" { + dstBound = boundCall{ + root: root, toolName: "apply_patch", path: pl.op.movePath, + rootCanonical: rootCanonical, canonical: pl.moveCanonical, + writeDir: writeDir, writeDirCanonical: writeDirCanonical, + } + } + preview, err := applyOne(pl, store, &committed, srcBound, dstBound) if err != nil { return "", nil, err } @@ -352,15 +367,16 @@ func hunkTransformPreservingMatchedContext(h hunk, matchedLines []string, synthe // partial-failure summary in *ExitError so the model sees the committed-files // A/M/D list. committed is the running list of ops that have already landed on // disk; applyOne appends to it on success and reads it for partial-failure -// summaries. -func applyOne(pl *applyPlan, store SnapshotStore, committed *[]appliedOp) ([]AppliedFilePreview, error) { +// summaries. srcBound and dstBound carry the approved witnesses to each +// mutation's snapshot-to-I/O boundary. +func applyOne(pl *applyPlan, store SnapshotStore, committed *[]appliedOp, srcBound, dstBound boundCall) ([]AppliedFilePreview, error) { if err := revalidateApplyPlan(pl); err != nil { return nil, &ExitError{Output: buildPartialSummary(*committed) + "\n\n" + err.Error()} } // Move: write the destination first (so it lands), then snapshot + delete the source. if pl.op.movePath != "" { - if err := writeWithSnapshot(store, pl.moveDisplay, pl.moveCanonical, pl.newContent, true); err != nil { + if err := writeWithSnapshot(store, dstBound, pl.moveDisplay, pl.moveCanonical, pl.newContent, true); err != nil { return nil, &ExitError{Output: buildPartialSummary(*committed) + "\n\n" + err.Error()} } *committed = append(*committed, appliedOp{kind: "M", path: pl.op.movePath}) @@ -370,7 +386,7 @@ func applyOne(pl *applyPlan, store SnapshotStore, committed *[]appliedOp) ([]App } // Now the original-path mutation (Add / Update / Delete), tracked separately for Move. - origErr := applyOriginalMutation(pl, store, committed) + origErr := applyOriginalMutation(pl, store, committed, srcBound) if origErr != nil { return nil, &ExitError{Output: buildPartialSummary(*committed) + "\n\n" + origErr.Error()} } @@ -445,11 +461,11 @@ func buildOpPreviews(pl *applyPlan) []AppliedFilePreview { return nil } -func applyOriginalMutation(pl *applyPlan, store SnapshotStore, committed *[]appliedOp) error { +func applyOriginalMutation(pl *applyPlan, store SnapshotStore, committed *[]appliedOp, srcBound boundCall) error { if pl.moveCanonical != "" { // For Move the source-path delete is the original-path mutation; the // kind is "D" of the source. - if err := deleteWithSnapshot(store, pl.displayAbsPath, pl.canonicalPath); err != nil { + if err := deleteWithSnapshot(store, srcBound, pl.displayAbsPath, pl.canonicalPath); err != nil { return err } *committed = append(*committed, appliedOp{kind: "D", path: pl.op.path}) @@ -458,17 +474,17 @@ func applyOriginalMutation(pl *applyPlan, store SnapshotStore, committed *[]appl switch pl.op.kind { case opAdd: - if err := writeWithSnapshot(store, pl.displayAbsPath, pl.canonicalPath, pl.newContent, true); err != nil { + if err := writeWithSnapshot(store, srcBound, pl.displayAbsPath, pl.canonicalPath, pl.newContent, true); err != nil { return err } *committed = append(*committed, appliedOp{kind: "A", path: pl.op.path}) case opUpdate: - if err := writeWithSnapshot(store, pl.displayAbsPath, pl.canonicalPath, pl.newContent, false); err != nil { + if err := writeWithSnapshot(store, srcBound, pl.displayAbsPath, pl.canonicalPath, pl.newContent, false); err != nil { return err } *committed = append(*committed, appliedOp{kind: "M", path: pl.op.path}) case opDelete: - if err := deleteWithSnapshot(store, pl.displayAbsPath, pl.canonicalPath); err != nil { + if err := deleteWithSnapshot(store, srcBound, pl.displayAbsPath, pl.canonicalPath); err != nil { return err } *committed = append(*committed, appliedOp{kind: "D", path: pl.op.path}) @@ -476,106 +492,85 @@ func applyOriginalMutation(pl *applyPlan, store SnapshotStore, committed *[]appl return nil } -// writeWithSnapshot snapshots the target then writes the new content. The -// snapshot is retained on any post-snapshot error so the partial state is -// recoverable via the turn revert. forNew enforces the Add / Move-dest -// exclusive-create guarantee: if the path was validated to not exist and -// OpenForWrite reports it now does (concurrent create between validate and -// apply), the write is refused. -func writeWithSnapshot(store SnapshotStore, displayAbsPath, canonicalPath, content string, forNew bool) error { - var snap snapshotEntry - hasSnap := false - if store != nil { - s, err := snapshotFileForMutation(store, store.CurrentTurn(), displayAbsPath, canonicalPath) - if err != nil { - return fmt.Errorf("snapshot: %w", err) +// writeWithSnapshot captures the target's preimage, clears the LastWrite +// proof, revalidates the approved witnesses, then writes the new content. +// A failure that proves no mutation ran abandons the snapshot; a failure with +// known-complete produced bytes records them; an uncertain started mutation +// retains the preimage with nil identity. forNew enforces the Add / +// Move-dest exclusive-create guarantee: if the path was validated to not +// exist and OpenForWrite reports it now does (concurrent create between +// validate and apply), the write is refused. +func writeWithSnapshot(store SnapshotStore, bound boundCall, displayAbsPath, canonicalPath, content string, forNew bool) error { + snap, err := captureSnapshotEntry(store, displayAbsPath, canonicalPath) + if err != nil { + return fmt.Errorf("snapshot: %w", err) + } + // The fail-closed root/write-dir/target revalidators run after capture + // and before any open/truncate; a newly resolved target is never + // substituted. + if err := bound.revalidate(); err != nil { + err = fmt.Errorf("apply_patch: resolve path: %w", err) + if abandonErr := abandonUnmutatedSnapshot(snap); abandonErr != nil { + return fmt.Errorf("%w; additionally failed to abandon snapshot: %v", err, abandonErr) } - snap = s - hasSnap = true - defer releaseSnapshotMutation(snap) + return err } - f, existed, _, err := openWriteTargetForMutation(canonicalPath) + f, existed, err := openWriteTargetForMutation(canonicalPath) if err != nil { - if hasSnap { - _ = discardUnmutatedSnapshot(snap) + if abandonErr := abandonUnmutatedSnapshot(snap); abandonErr != nil { + return fmt.Errorf("%w; additionally failed to abandon snapshot: %v", err, abandonErr) } return fmt.Errorf("open: %w", err) } defer f.Close() if forNew && existed { + appearedErr := fmt.Errorf("path %s appeared during apply (Add requires the path to not exist)", canonicalPath) _ = f.Close() - if hasSnap { - _ = discardUnmutatedSnapshot(snap) + if abandonErr := abandonUnmutatedSnapshot(snap); abandonErr != nil { + return fmt.Errorf("%w; additionally failed to abandon snapshot: %v", appearedErr, abandonErr) } - return fmt.Errorf("path %s appeared during apply (Add requires the path to not exist)", canonicalPath) + return appearedErr } - if err := f.Truncate(0); err != nil { - if hasSnap { - err = retainFailedMutatedSnapshot(snap, canonicalPath, err) - } - return fmt.Errorf("truncate: %w", err) + complete, werr := writeReplacement(f, []byte(content)) + if werr != nil { + return finishMutationFailure(snap, true, complete, []byte(content), werr) } - if _, err := f.Seek(0, io.SeekStart); err != nil { - if hasSnap { - err = retainFailedMutatedSnapshot(snap, canonicalPath, err) - } - return fmt.Errorf("seek: %w", err) - } - if _, err := f.Write([]byte(content)); err != nil { - if hasSnap { - err = retainFailedMutatedSnapshot(snap, canonicalPath, err) - } - return fmt.Errorf("write: %w", err) - } - if err := f.Sync(); err != nil { - if hasSnap { - err = retainFailedMutatedSnapshot(snap, canonicalPath, err) - } - return fmt.Errorf("sync: %w", err) - } - if hasSnap { - if err := recordMutatedSnapshotContent(snap, []byte(content)); err != nil { - retainMutatedSnapshot(snap) - return fmt.Errorf("record snapshot identity: %w", err) - } - } - if hasSnap { - retainMutatedSnapshot(snap) + if err := recordProducedSnapshotContent(snap, []byte(content)); err != nil { + return fmt.Errorf("record snapshot identity: %w", err) } return nil } -// deleteWithSnapshot snapshots the target then removes it. The snapshot -// captures the file's content so RevertCode can restore it. -func deleteWithSnapshot(store SnapshotStore, displayAbsPath, canonicalPath string) error { - var snap snapshotEntry - hasSnap := false - if store != nil { - s, err := snapshotFileForMutation(store, store.CurrentTurn(), displayAbsPath, canonicalPath) - if err != nil { - return fmt.Errorf("snapshot: %w", err) +// deleteWithSnapshot captures the target's preimage, clears the LastWrite +// proof, revalidates the approved witnesses, then removes it. The snapshot +// captures the file's content so the restore traversal can recover it. A +// failed absence record leaves nil proof and preserves the preimage for a +// later restore skip. +func deleteWithSnapshot(store SnapshotStore, bound boundCall, displayAbsPath, canonicalPath string) error { + snap, err := captureSnapshotEntry(store, displayAbsPath, canonicalPath) + if err != nil { + return fmt.Errorf("snapshot: %w", err) + } + // The fail-closed root/write-dir/target revalidators run after capture + // and before the remove; a newly resolved target is never substituted. + if err := bound.revalidate(); err != nil { + err = fmt.Errorf("apply_patch: resolve path: %w", err) + if abandonErr := abandonUnmutatedSnapshot(snap); abandonErr != nil { + return fmt.Errorf("%w; additionally failed to abandon snapshot: %v", err, abandonErr) } - snap = s - hasSnap = true - defer releaseSnapshotMutation(snap) + return err } if err := safefs.RemoveLeaf(canonicalPath); err != nil { - if hasSnap { - _ = discardUnmutatedSnapshot(snap) + if abandonErr := abandonUnmutatedSnapshot(snap); abandonErr != nil { + return fmt.Errorf("%w; additionally failed to abandon snapshot: %v", err, abandonErr) } return fmt.Errorf("remove: %w", err) } - if hasSnap { - if err := recordMutatedSnapshotAbsence(snap); err != nil { - retainMutatedSnapshot(snap) - return fmt.Errorf("record snapshot identity: %w", err) - } - } - if hasSnap { - retainMutatedSnapshot(snap) + if err := recordProducedSnapshotAbsence(snap); err != nil { + return fmt.Errorf("record snapshot identity: %w", err) } return nil } diff --git a/internal/tool/apply_patch_display_test.go b/internal/tool/apply_patch_display_test.go index 0f6e1eaf..c2c73f0f 100644 --- a/internal/tool/apply_patch_display_test.go +++ b/internal/tool/apply_patch_display_test.go @@ -13,7 +13,7 @@ func TestApplyPatchPreviewMetadataEmitsEditPreviewFiles(t *testing.T) { dir := t.TempDir() testassert.NoError(t, os.WriteFile(filepath.Join(dir, "x.txt"), []byte("alpha\nBEFORE\nbeta"), 0o644)) testassert.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("bye"), 0o644)) - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Add File: new.txt\n+hi\n*** Update File: x.txt\n@@\n alpha\n-BEFORE\n+AFTER\n beta\n*** Delete File: a.txt") res, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -43,7 +43,7 @@ func TestApplyPatchPreviewMetadataEmitsEditPreviewFiles(t *testing.T) { func TestApplyPatchPreviewMetadataMoveTwoEntries(t *testing.T) { dir := t.TempDir() testassert.NoError(t, os.WriteFile(filepath.Join(dir, "old.go"), []byte("a\nB\nc"), 0o644)) - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Update File: old.go\n*** Move to: new.go\n@@\n a\n-B\n+B2\n c") res, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -59,7 +59,7 @@ func TestApplyPatchPreviewMetadataMoveTwoEntries(t *testing.T) { func TestApplyPatchFailureEmitsNoPreviewMetadata(t *testing.T) { dir := t.TempDir() - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Delete File: ghost.txt") res, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) diff --git a/internal/tool/apply_patch_test.go b/internal/tool/apply_patch_test.go index 43890e6a..c55c8036 100644 --- a/internal/tool/apply_patch_test.go +++ b/internal/tool/apply_patch_test.go @@ -16,95 +16,13 @@ func applyPatchInput(t *testing.T, s string) string { return "*** Begin Patch\n" + s + "\n*** End Patch" } -// applyPatchStore is a SnapshotStore that tolerates non-existent files -// (recording "didn't exist" as the captured state) and records every -// call. The real snapshot store does this; the test store used by -// edit_file tests assumes a pre-existing file, which is wrong for -// apply_patch's Add and Move-destination cases. - -type applyPatchStore struct { - turn int - calls []snapshotCall - seen map[string]string - identityRecords []snapshotIdentityRecord - onSnapshot func(call int) - // failOnCall, when > 0, makes the Nth snapshot call return errFail - // (used to simulate a mid-write failure at the second op). - failOnCall int - errFail error - // discards records every DiscardSnapshotEntry call (entry IDs) so tests - // can observe the discard-before-mutation snapshot behavior. - discards []string -} - -func (s *applyPatchStore) Snapshot(turn int, absPath string) error { - return s.SnapshotResolved(turn, absPath, absPath) -} - -func (s *applyPatchStore) SnapshotResolved(turn int, originalPath, canonicalPath string) error { - s.calls = append(s.calls, snapshotCall{turn: turn, path: originalPath, canonical: canonicalPath}) - if s.onSnapshot != nil { - s.onSnapshot(len(s.calls)) - } - if s.failOnCall > 0 && len(s.calls) == s.failOnCall { - return s.errFail - } - if s.seen == nil { - s.seen = map[string]string{} - } - data, err := os.ReadFile(canonicalPath) - if err != nil { - if os.IsNotExist(err) { - s.seen[canonicalPath] = "" // captured "didn't exist" - return nil - } - return err - } - s.seen[canonicalPath] = string(data) - return nil -} - -func (s *applyPatchStore) SnapshotResolvedEntry(turn int, originalPath, canonicalPath string) (string, bool, error) { - if err := s.SnapshotResolved(turn, originalPath, canonicalPath); err != nil { - return "", false, err - } - return canonicalPath, true, nil -} - -func (s *applyPatchStore) DiscardSnapshotEntry(_ int, entryID string) error { - s.discards = append(s.discards, entryID) - return nil -} - -func (s *applyPatchStore) RetainSnapshotEntry(int, string) {} - -func (s *applyPatchStore) RecordSnapshotContent(turn int, entryID string, content []byte) error { - s.identityRecords = append(s.identityRecords, snapshotIdentityRecord{ - turn: turn, - entryID: entryID, - content: string(content), - }) - return nil -} - -func (s *applyPatchStore) RecordSnapshotAbsence(turn int, entryID string) error { - s.identityRecords = append(s.identityRecords, snapshotIdentityRecord{ - turn: turn, - entryID: entryID, - absent: true, - }) - return nil -} - -func (s *applyPatchStore) CurrentTurn() int { return s.turn } - func TestApplyPatchAllOrNothingValidationFailure(t *testing.T) { // First op is a successful Add; second op is an Update of a // non-existent file. Validation runs the existence check before any // write, so the Add must not land (validate-first all-or-nothing). dir := t.TempDir() newPath := filepath.Join(dir, "new.txt") - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Add File: new.txt\n+hello\n*** Update File: ghost.go\n@@\n-missing\n+replacement") _, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -122,7 +40,7 @@ func TestApplyPatchAllOrNothingPartialApplyError(t *testing.T) { // the Delete fails, so the Add is not written either. dir := t.TempDir() newPath := filepath.Join(dir, "new.txt") - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Add File: new.txt\n+hi\n*** Delete File: ghost.txt") _, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -142,10 +60,10 @@ func TestApplyPatchMidWriteFailureReturnsExitError(t *testing.T) { // error message. dir := t.TempDir() testassert.NoError(t, os.WriteFile(filepath.Join(dir, "x.txt"), []byte("alpha\nBEFORE\nbeta"), 0o644)) - store := &applyPatchStore{ - turn: 1, - failOnCall: 2, // the second snapshot call fails - errFail: errors.New("simulated disk full"), + store := &recordingCodeGroupStore{ + testCodeGroupStore: newTempCodeGroupStore(t), + failOnCall: 2, // the second snapshot call fails + errFail: errors.New("simulated disk full"), } input := applyPatchInput(t, "*** Add File: new.txt\n+hi\n*** Update File: x.txt\n@@\n alpha\n-BEFORE\n+AFTER\n beta") @@ -160,7 +78,7 @@ func TestApplyPatchRefusesUpdateWhenContentChangesAfterValidation(t *testing.T) dir := t.TempDir() path := filepath.Join(dir, "target.txt") testassert.NoError(t, os.WriteFile(path, []byte("alpha\nBEFORE\nbeta"), 0o644)) - store := &applyPatchStore{turn: 1, onSnapshot: func(call int) { + store := &recordingCodeGroupStore{testCodeGroupStore: newTempCodeGroupStore(t), onSnapshot: func(call int) { if call == 1 { testassert.NoError(t, os.WriteFile(path, []byte("alpha\nCHANGED\nbeta"), 0o644)) } @@ -176,7 +94,7 @@ func TestApplyPatchRefusesDeleteWhenContentChangesAfterValidation(t *testing.T) dir := t.TempDir() path := filepath.Join(dir, "target.txt") testassert.NoError(t, os.WriteFile(path, []byte("delete me"), 0o644)) - store := &applyPatchStore{turn: 1, onSnapshot: func(call int) { + store := &recordingCodeGroupStore{testCodeGroupStore: newTempCodeGroupStore(t), onSnapshot: func(call int) { if call == 1 { testassert.NoError(t, os.WriteFile(path, []byte("changed"), 0o644)) } @@ -193,7 +111,7 @@ func TestApplyPatchRefusesMoveSourceWhenContentChangesAfterDestinationWrite(t *t src := filepath.Join(dir, "old.txt") dst := filepath.Join(dir, "new.txt") testassert.NoError(t, os.WriteFile(src, []byte("old content"), 0o644)) - store := &applyPatchStore{turn: 1, onSnapshot: func(call int) { + store := &recordingCodeGroupStore{testCodeGroupStore: newTempCodeGroupStore(t), onSnapshot: func(call int) { if call == 2 { testassert.NoError(t, os.WriteFile(src, []byte("changed"), 0o644)) } @@ -215,7 +133,7 @@ func TestApplyPatchRecordsSnapshotIdentityForAddUpdateAndDelete(t *testing.T) { addPath := filepath.Join(dir, "new.txt") testassert.NoError(t, os.WriteFile(updatePath, []byte("a\nB\nc"), 0o644)) testassert.NoError(t, os.WriteFile(deletePath, []byte("remove me"), 0o644)) - store := &applyPatchStore{turn: 4} + store := &recordingCodeGroupStore{testCodeGroupStore: newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"))} input := applyPatchInput(t, "*** Add File: new.txt\n+created\n*** Update File: update.txt\n@@\n a\n-B\n+B2\n c\n*** Delete File: delete.txt") { @@ -223,9 +141,9 @@ func TestApplyPatchRecordsSnapshotIdentityForAddUpdateAndDelete(t *testing.T) { testassert.NoError(t, err) } - assertSnapshotIdentityContent(t, store.identityRecords, 4, addPath, "created") - assertSnapshotIdentityContent(t, store.identityRecords, 4, updatePath, "a\nB2\nc") - assertSnapshotIdentityAbsent(t, store.identityRecords, 4, deletePath) + assertSnapshotIdentityContent(t, store.identityRecords, store.entryIDFor(addPath), "created") + assertSnapshotIdentityContent(t, store.identityRecords, store.entryIDFor(updatePath), "a\nB2\nc") + assertSnapshotIdentityAbsent(t, store.identityRecords, store.entryIDFor(deletePath)) } func TestApplyPatchSnapshotsEveryTouchedFile(t *testing.T) { @@ -234,7 +152,7 @@ func TestApplyPatchSnapshotsEveryTouchedFile(t *testing.T) { updated := filepath.Join(dir, "x.txt") testassert.NoError(t, os.WriteFile(src, []byte("a\nB\nc"), 0o644)) testassert.NoError(t, os.WriteFile(updated, []byte("a\nB\nc"), 0o644)) - store := &applyPatchStore{turn: 7} + store := &recordingCodeGroupStore{testCodeGroupStore: newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"))} input := applyPatchInput(t, "*** Update File: x.txt\n@@\n a\n-B\n+B2\n c\n*** Update File: old.go\n*** Move to: new.go\n@@\n a\n-B\n+B2\n c") { @@ -242,18 +160,15 @@ func TestApplyPatchSnapshotsEveryTouchedFile(t *testing.T) { testassert.NoError(t, err) } // Expected snapshots: updated (Update), new.go (Move dest, Existed:false), - // old.go (Move source, Existed:true). That's 3 snapshot calls under turn=7. + // old.go (Move source, Existed:true). That's 3 snapshot calls. testassert.Equal(t, len(store.calls), 3) - for _, c := range store.calls { - testassert.Equal(t, c.turn, 7) - } } func TestApplyPatchAddFailsIfFileExists(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "exists.txt") testassert.NoError(t, os.WriteFile(path, []byte("old"), 0o644)) - store := &applyPatchStore{turn: 1} + store := newRecordingCodeGroupStore(t) input := applyPatchInput(t, "*** Add File: exists.txt\n+new") _, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -269,7 +184,7 @@ func TestApplyPatchMoveFailsIfDestinationExists(t *testing.T) { dst := filepath.Join(dir, "new.go") testassert.NoError(t, os.WriteFile(src, []byte("alpha\nBEFORE\nbeta"), 0o644)) testassert.NoError(t, os.WriteFile(dst, []byte("preexisting"), 0o644)) - store := &applyPatchStore{turn: 1} + store := newRecordingCodeGroupStore(t) input := applyPatchInput(t, "*** Update File: old.go\n*** Move to: new.go\n@@\n alpha\n-BEFORE\n+AFTER\n beta") _, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -287,7 +202,7 @@ func TestApplyPatchCanonicalCollisionRejectedBeforeApply(t *testing.T) { // catches the canonical collision. dir := t.TempDir() testassert.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hi"), 0o644)) - store := &applyPatchStore{turn: 1} + store := newRecordingCodeGroupStore(t) // "a.txt" and "./a.txt" are different raw paths but resolve to the same canonical. input := applyPatchInput(t, "*** Update File: a.txt\n@@\n-hi\n+bye\n*** Update File: ./a.txt\n@@\n-hi\n+there") _, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -302,7 +217,7 @@ func TestApplyPatchCanonicalCollisionSymlinkRejected(t *testing.T) { link := filepath.Join(dir, "link.txt") testassert.NoError(t, os.WriteFile(real, []byte("content"), 0o644)) testassert.NoError(t, os.Symlink(real, link)) - store := &applyPatchStore{turn: 1} + store := newRecordingCodeGroupStore(t) // Two raw paths resolving to the same canonical via symlink. input := applyPatchInput(t, "*** Update File: real.txt\n@@\n content\n+changed\n*** Delete File: link.txt") _, err := execPatch(t, dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -318,7 +233,7 @@ func TestApplyPatchFuzzyMatchPreservesActualTrailingWhitespaceContext(t *testing input := applyPatchInput(t, "*** Update File: a.go\n@@\n alpha\n-BEFORE\n+AFTER\n beta") { - _, err := execPatch(t, dir, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"input": input}) + _, err := execPatch(t, dir, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"input": input}) testassert.NoError(t, err) } testassert.Equal(t, readFile(t, path), "alpha \nAFTER\nbeta") @@ -331,7 +246,7 @@ func TestApplyPatchFuzzyMatchPreservesActualUnicodeContext(t *testing.T) { input := applyPatchInput(t, "*** Update File: a.go\n@@\n say \"hello\"\n-BEFORE\n+AFTER\n beta") { - _, err := execPatch(t, dir, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"input": input}) + _, err := execPatch(t, dir, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"input": input}) testassert.NoError(t, err) } testassert.Equal(t, readFile(t, path), "say \u201Chello\u201D\nAFTER\nbeta") @@ -343,7 +258,7 @@ func TestApplyPatchSyntheticEOFContextDoesNotCreateTrailingNewline(t *testing.T) testassert.NoError(t, os.WriteFile(path, []byte("foo\nbar"), 0o644)) input := applyPatchInput(t, "*** Update File: a.txt\n@@\n foo\n-bar\n+baz\n ") - res, err := execPatch(t, dir, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"input": input}) + res, err := execPatch(t, dir, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"input": input}) testassert.NoError(t, err) testassert.Equal(t, readFile(t, path), "foo\nbaz") rows := previewRowsFor(t, ApplyPatchPreviewMetadata(res.Previews), "a.txt") @@ -356,7 +271,7 @@ func TestApplyPatchSyntheticEOFRemoveFails(t *testing.T) { testassert.NoError(t, os.WriteFile(path, []byte("foo"), 0o644)) input := applyPatchInput(t, "*** Update File: a.txt\n@@\n-\n+bar") - _, err := execPatch(t, dir, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"input": input}) + _, err := execPatch(t, dir, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"input": input}) testassert.Require(t, err != nil, "Execute err = nil, want synthetic EOF remove to fail") testassert.Require(t, strings.HasPrefix(err.Error(), "Failed to find expected lines in a.txt:"), "err = %v, want expected-lines failure", err) testassert.Equal(t, readFile(t, path), "foo") @@ -368,7 +283,7 @@ func TestApplyPatchRealEOFContextPreservesTrailingNewline(t *testing.T) { testassert.NoError(t, os.WriteFile(path, []byte("foo\nbar\n"), 0o644)) input := applyPatchInput(t, "*** Update File: a.txt\n@@\n foo\n-bar\n+baz\n ") - res, err := execPatch(t, dir, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"input": input}) + res, err := execPatch(t, dir, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"input": input}) testassert.NoError(t, err) testassert.Equal(t, readFile(t, path), "foo\nbaz\n") rows := previewRowsFor(t, ApplyPatchPreviewMetadata(res.Previews), "a.txt") @@ -382,7 +297,7 @@ func TestApplyPatchRealEmptyRemoveStillWorks(t *testing.T) { input := applyPatchInput(t, "*** Update File: a.txt\n@@\n foo\n-\n+bar\n baz") { - _, err := execPatch(t, dir, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"input": input}) + _, err := execPatch(t, dir, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"input": input}) testassert.NoError(t, err) } testassert.Equal(t, readFile(t, path), "foo\nbar\nbaz") diff --git a/internal/tool/edit_file_test.go b/internal/tool/edit_file_test.go index 929b37e0..c55e4270 100644 --- a/internal/tool/edit_file_test.go +++ b/internal/tool/edit_file_test.go @@ -36,7 +36,7 @@ func TestPreparedEditReplacesUniqueStringAndReportsLineRange(t *testing.T) { path := readFileTestFile(t, "file.txt", "alpha\nbeta\ngamma") root := filepath.Dir(path) - result, err := execEdit(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + result, err := execEdit(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": "beta", "new_string": "delta", @@ -50,7 +50,7 @@ func TestPreparedEditReplaceAllReportsAllLineRanges(t *testing.T) { path := readFileTestFile(t, "file.txt", "same\nother\nsame") root := filepath.Dir(path) - result, err := execEdit(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + result, err := execEdit(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": "same", "new_string": "done", @@ -65,7 +65,7 @@ func TestPreparedEditRejectsAmbiguousMatchWithoutReplaceAll(t *testing.T) { path := readFileTestFile(t, "file.txt", "same\nsame") root := filepath.Dir(path) - _, err := execEdit(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execEdit(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": "same", "new_string": "done", @@ -91,7 +91,7 @@ func TestPreparedEditValidatesOldString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := execEdit(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execEdit(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": tt.oldString, "new_string": tt.newString, @@ -106,7 +106,7 @@ func TestPreparedEditMultilineReplacementLineRange(t *testing.T) { path := readFileTestFile(t, "file.txt", "start\nold\nend") root := filepath.Dir(path) - result, err := execEdit(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + result, err := execEdit(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": "old", "new_string": "new\nline", @@ -119,7 +119,7 @@ func TestPreparedEditMultilineReplacementLineRange(t *testing.T) { func TestPreparedEditWithSnapshotSnapshotsBeforeEdit(t *testing.T) { path := readFileTestFile(t, "file.txt", "before") root := filepath.Dir(path) - store := &recordingSnapshotStore{turn: 11, before: map[string]string{}} + store := &recordingCodeGroupStore{testCodeGroupStore: newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"))} result, err := execEdit(t, root, "", "", CapabilityOptions{}, store, map[string]any{ "path": path, @@ -129,8 +129,8 @@ func TestPreparedEditWithSnapshotSnapshotsBeforeEdit(t *testing.T) { testassert.NoError(t, err) testassert.Equal(t, result, "Edited "+path+" (1 replacement, lines 1-1).") testassert.Equal(t, len(store.calls), 1) - testassert.Require(t, store.calls[0].turn == 11 && store.calls[0].path == path, "snapshot call = %+v, want turn=11 path=%q", store.calls[0], path) - testassert.Require(t, store.before[path] == "before", "snapshot saw %q, want pre-edit content", store.before[path]) + testassert.Require(t, store.calls[0].path == path, "snapshot call = %+v, want path=%q", store.calls[0], path) + assertEntryPreimage(t, store, path, "before") assertFileContent(t, path, "after") } @@ -142,7 +142,7 @@ func TestPreparedEditWithSnapshotUsesCanonicalTargetAndRequestedResult(t *testin linkDir := filepath.Join(root, "linked") testassert.NoError(t, os.Symlink(realDir, linkDir)) requested := filepath.Join(linkDir, "file.txt") - store := &recordingSnapshotStore{turn: 12, before: map[string]string{}} + store := &recordingCodeGroupStore{testCodeGroupStore: newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"))} result, err := execEdit(t, root, "", "", CapabilityOptions{}, store, map[string]any{ "path": requested, @@ -154,7 +154,7 @@ func TestPreparedEditWithSnapshotUsesCanonicalTargetAndRequestedResult(t *testin testassert.Equal(t, result, "Edited "+requested+" (1 replacement, lines 1-1).") testassert.Equal(t, len(store.calls), 1) testassert.Require(t, store.calls[0].path == requested && store.calls[0].canonical == target, "snapshot call = %+v, want original=%q canonical=%q", store.calls[0], requested, target) - testassert.Require(t, store.before[target] == "before", "snapshot saw %q, want pre-edit content", store.before[target]) + assertEntryPreimage(t, store, target, "before") assertFileContent(t, target, "after") } diff --git a/internal/tool/file_revalidation_test.go b/internal/tool/file_revalidation_test.go index e920b61d..5d617cc1 100644 --- a/internal/tool/file_revalidation_test.go +++ b/internal/tool/file_revalidation_test.go @@ -32,7 +32,7 @@ func TestFileToolsRefuseRepointedApprovedSymlink(t *testing.T) { testassert.NoError(t, err) writeArgs, err := NormalizeWriteArgs(map[string]any{"path": link, "content": "after"}) testassert.NoError(t, err) - writeCall, err := PrepareWriteCall(root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, writeArgs) + writeCall, err := PrepareWriteCall(root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), writeArgs) testassert.NoError(t, err) editArgs, err := NormalizeEditArgs(map[string]any{ "path": link, @@ -40,7 +40,7 @@ func TestFileToolsRefuseRepointedApprovedSymlink(t *testing.T) { "new_string": "after", }) testassert.NoError(t, err) - editCall, err := PrepareEditCall(root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, editArgs) + editCall, err := PrepareEditCall(root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), editArgs) testassert.NoError(t, err) testassert.NoError(t, os.Remove(link)) @@ -77,7 +77,7 @@ func TestFileToolsRefuseFinalTargetReplacedBySymlink(t *testing.T) { testassert.NoError(t, err) writeArgs, err := NormalizeWriteArgs(map[string]any{"path": target, "content": "after"}) testassert.NoError(t, err) - writeCall, err := PrepareWriteCall(root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, writeArgs) + writeCall, err := PrepareWriteCall(root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), writeArgs) testassert.NoError(t, err) editArgs, err := NormalizeEditArgs(map[string]any{ "path": target, @@ -85,7 +85,7 @@ func TestFileToolsRefuseFinalTargetReplacedBySymlink(t *testing.T) { "new_string": "after", }) testassert.NoError(t, err) - editCall, err := PrepareEditCall(root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, editArgs) + editCall, err := PrepareEditCall(root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), editArgs) testassert.NoError(t, err) testassert.NoError(t, os.Remove(target)) diff --git a/internal/tool/file_shape_test.go b/internal/tool/file_shape_test.go index 28a498a6..e1ed120d 100644 --- a/internal/tool/file_shape_test.go +++ b/internal/tool/file_shape_test.go @@ -60,7 +60,7 @@ func TestFileToolsRejectNonRegularExistingTargets(t *testing.T) { return nonRegularDirectoryTarget(t), func() {} }, run: func(path string) error { - _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"path": path, "content": "after"}) + _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"path": path, "content": "after"}) return err }, }, @@ -71,7 +71,7 @@ func TestFileToolsRejectNonRegularExistingTargets(t *testing.T) { }, run: func(path string) error { return runNonRegularTargetWithTimeout(func() error { - _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"path": path, "content": "after"}) + _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"path": path, "content": "after"}) return err }) }, @@ -82,7 +82,7 @@ func TestFileToolsRejectNonRegularExistingTargets(t *testing.T) { return nonRegularUnixSocket(t), func() {} }, run: func(path string) error { - _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"path": path, "content": "after"}) + _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"path": path, "content": "after"}) return err }, }, @@ -92,7 +92,7 @@ func TestFileToolsRejectNonRegularExistingTargets(t *testing.T) { return nonRegularDirectoryTarget(t), func() {} }, run: func(path string) error { - _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": "before", "new_string": "after", @@ -108,7 +108,7 @@ func TestFileToolsRejectNonRegularExistingTargets(t *testing.T) { }, run: func(path string) error { return runNonRegularTargetWithTimeout(func() error { - _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": "before", "new_string": "after", @@ -123,7 +123,7 @@ func TestFileToolsRejectNonRegularExistingTargets(t *testing.T) { return nonRegularUnixSocket(t), func() {} }, run: func(path string) error { - _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "old_string": "before", "new_string": "after", @@ -145,14 +145,14 @@ func TestNonRegularFileToolsDoNotSnapshot(t *testing.T) { for _, tc := range []struct { name string target func(t *testing.T) (path string, release func()) - run func(store *recordingNonRegularSnapshotStore, path string) error + run func(store *recordingCodeGroupStore, path string) error }{ { name: "write_directory", target: func(t *testing.T) (string, func()) { return nonRegularDirectoryTarget(t), func() {} }, - run: func(store *recordingNonRegularSnapshotStore, path string) error { + run: func(store *recordingCodeGroupStore, path string) error { _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "after"}) return err }, @@ -162,7 +162,7 @@ func TestNonRegularFileToolsDoNotSnapshot(t *testing.T) { target: func(t *testing.T) (string, func()) { return nonRegularFIFO(t), func() {} }, - run: func(store *recordingNonRegularSnapshotStore, path string) error { + run: func(store *recordingCodeGroupStore, path string) error { return runNonRegularTargetWithTimeout(func() error { _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "after"}) return err @@ -174,7 +174,7 @@ func TestNonRegularFileToolsDoNotSnapshot(t *testing.T) { target: func(t *testing.T) (string, func()) { return nonRegularDirectoryTarget(t), func() {} }, - run: func(store *recordingNonRegularSnapshotStore, path string) error { + run: func(store *recordingCodeGroupStore, path string) error { _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, store, map[string]any{ "path": path, "old_string": "before", @@ -189,7 +189,7 @@ func TestNonRegularFileToolsDoNotSnapshot(t *testing.T) { path := nonRegularFIFO(t) return path, startNonRegularFIFOFeed(t, path, "before") }, - run: func(store *recordingNonRegularSnapshotStore, path string) error { + run: func(store *recordingCodeGroupStore, path string) error { return runNonRegularTargetWithTimeout(func() error { _, err := execEdit(t, filepath.Dir(path), "", "", CapabilityOptions{}, store, map[string]any{ "path": path, @@ -205,14 +205,14 @@ func TestNonRegularFileToolsDoNotSnapshot(t *testing.T) { target: func(t *testing.T) (string, func()) { return nonRegularUnixSocket(t), func() {} }, - run: func(store *recordingNonRegularSnapshotStore, path string) error { + run: func(store *recordingCodeGroupStore, path string) error { _, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "after"}) return err }, }, } { t.Run(tc.name, func(t *testing.T) { - store := &recordingNonRegularSnapshotStore{turn: 1} + store := newRecordingCodeGroupStore(t) path, release := tc.target(t) err := tc.run(store, path) release() @@ -224,7 +224,7 @@ func TestNonRegularFileToolsDoNotSnapshot(t *testing.T) { func TestMissingWriteFileStillCreatesNewFile(t *testing.T) { path := filepath.Join(t.TempDir(), "new.txt") - result, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + result, err := execWrite(t, filepath.Dir(path), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "content": "created", }) @@ -235,30 +235,6 @@ func TestMissingWriteFileStillCreatesNewFile(t *testing.T) { testassert.Require(t, string(data) == "created", "content = %q, want created", data) } -type nonRegularSnapshotCall struct { - turn int - original string - canonical string -} - -type recordingNonRegularSnapshotStore struct { - turn int - calls []nonRegularSnapshotCall -} - -func (s *recordingNonRegularSnapshotStore) Snapshot(turn int, absPath string) error { - return s.SnapshotResolved(turn, absPath, absPath) -} - -func (s *recordingNonRegularSnapshotStore) SnapshotResolved(turn int, originalPath, canonicalPath string) error { - s.calls = append(s.calls, nonRegularSnapshotCall{turn: turn, original: originalPath, canonical: canonicalPath}) - return nil -} - -func (s *recordingNonRegularSnapshotStore) CurrentTurn() int { - return s.turn -} - func assertNonRegularRefusal(t *testing.T, path string, err error) { t.Helper() testassert.Require(t, err != nil, "operation succeeded, want non-regular target refusal") @@ -377,14 +353,14 @@ func TestPR11Closure_HardlinkRefusedAcrossAllOperations(t *testing.T) { t.Run("write_file", func(t *testing.T) { hardlinked, _ := makeHardlink(t) - _, err := execWrite(t, filepath.Dir(hardlinked), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, + _, err := execWrite(t, filepath.Dir(hardlinked), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"path": hardlinked, "content": "mutated"}) assertHardlinkRefusal(t, err) }) t.Run("edit_file", func(t *testing.T) { hardlinked, _ := makeHardlink(t) - _, err := execEdit(t, filepath.Dir(hardlinked), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, + _, err := execEdit(t, filepath.Dir(hardlinked), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": hardlinked, "old_string": "outside-content", @@ -395,7 +371,7 @@ func TestPR11Closure_HardlinkRefusedAcrossAllOperations(t *testing.T) { t.Run("snapshot_capture", func(t *testing.T) { hardlinked, _ := makeHardlink(t) - store := newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"), 1) + store := newCodeGroupStore(t, filepath.Join(t.TempDir(), "group")) _, err := execWrite(t, filepath.Dir(hardlinked), "", "", CapabilityOptions{}, store, map[string]any{"path": hardlinked, "content": "mutated"}) assertHardlinkRefusal(t, err) diff --git a/internal/tool/prepare.go b/internal/tool/prepare.go index b1f6681e..2a2706dd 100644 --- a/internal/tool/prepare.go +++ b/internal/tool/prepare.go @@ -417,73 +417,55 @@ func prepareWrite(root string, store SnapshotStore, args map[string]any, approve } // writeMutation opens the write target (creating required parents), replaces -// its content, and reports whether a mutation may have started. -func writeMutation(canonical string, content, displayPath string) (string, bool, error) { - f, openedExisting, mutationStarted, err := openWriteTargetForMutation(canonical) +// its content, and reports whether a mutation may have started plus whether +// the intended bytes are known to have been fully produced. +func writeMutation(canonical string, content, displayPath string) (string, bool, bool, error) { + f, _, err := openWriteTargetForMutation(canonical) if err != nil { - return "", mutationStarted, fmt.Errorf("write_file: %w", err) + return "", false, false, fmt.Errorf("write_file: %w", err) } defer f.Close() - if openedExisting { - mutationStarted = true - } - if err := f.Truncate(0); err != nil { - return "", mutationStarted, fmt.Errorf("write_file: truncate: %w", err) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - return "", mutationStarted, fmt.Errorf("write_file: seek: %w", err) - } - if _, err := f.Write([]byte(content)); err != nil { - return "", mutationStarted, fmt.Errorf("write_file: %w", err) - } - if err := f.Sync(); err != nil { - return "", mutationStarted, fmt.Errorf("write_file: sync: %w", err) + // A successful open has created or opened the target, so any failure from + // here on has an uncertain started state. + complete, werr := writeReplacement(f, []byte(content)) + if werr != nil { + return "", true, complete, fmt.Errorf("write_file: %w", werr) } - return fmt.Sprintf("Wrote %s.", displayPath), mutationStarted, nil + return fmt.Sprintf("Wrote %s.", displayPath), true, true, nil } -// writeWithSnapshotBookkeeping wraps writeMutation with the retained -// snapshot transaction: preflight, capture, the post-snapshot binding -// revalidation, and the discard-before-mutation / retain-after-mutation -// failure behavior. +// writeWithSnapshotBookkeeping wraps writeMutation with the mandatory snapshot +// transaction: preflight, capture plus the pre-mutation proof clear, the +// post-capture binding revalidation, and the abandon-on-proven-non-mutation / +// produced-identity failure behavior. func writeWithSnapshotBookkeeping(bound boundCall, store SnapshotStore, displayAbs, canonical, content, displayPath string) (string, error) { if err := preflightWriteSnapshotTarget(canonical); err != nil { return "", err } - snapshot, err := snapshotFileForMutation(store, store.CurrentTurn(), displayAbs, canonical) + snapshot, err := captureSnapshotEntry(store, displayAbs, canonical) if err != nil { return "", fmt.Errorf("write_file: snapshot: %w", err) } - defer releaseSnapshotMutation(snapshot) // Retained legacy behavior: the binding is revalidated again after the // snapshot was captured, so a target repointed during snapshotting // fails before any mutation, and the unmutated snapshot claim is - // discarded exactly like every other pre-mutation failure. + // abandoned exactly like every other pre-mutation failure. if err := bound.revalidate(); err != nil { err = fmt.Errorf("write_file: resolve path: %w", err) - if discardErr := discardUnmutatedSnapshot(snapshot); discardErr != nil { - return "", fmt.Errorf("%w; additionally failed to discard snapshot: %v", err, discardErr) + if abandonErr := abandonUnmutatedSnapshot(snapshot); abandonErr != nil { + return "", fmt.Errorf("%w; additionally failed to abandon snapshot: %v", err, abandonErr) } return "", err } - res, mutationStarted, err := writeMutation(canonical, content, displayPath) + res, started, complete, err := writeMutation(canonical, content, displayPath) if err != nil { - if !mutationStarted { - if discardErr := discardUnmutatedSnapshot(snapshot); discardErr != nil { - return "", fmt.Errorf("%w; additionally failed to discard snapshot: %v", err, discardErr) - } - } else { - err = retainFailedMutatedSnapshot(snapshot, canonical, err) - } - return "", err + return "", finishMutationFailure(snapshot, started, complete, []byte(content), err) } - if err := recordMutatedSnapshotContent(snapshot, []byte(content)); err != nil { - retainMutatedSnapshot(snapshot) + if err := recordProducedSnapshotContent(snapshot, []byte(content)); err != nil { return "", fmt.Errorf("write_file: record snapshot identity: %w", err) } - retainMutatedSnapshot(snapshot) return res, nil } @@ -539,104 +521,89 @@ func prepareEdit(root string, store SnapshotStore, args map[string]any, approved } // editMutation validates the edit against current file content and applies -// it; mutationStarted reports whether bytes may have landed. -func editMutation(canonical string, displayPath, oldString, newString string, replaceAll bool) (*editResult, bool, error) { +// it; mutationStarted reports whether bytes may have landed, and complete +// whether the updated bytes are known to have been fully produced. produced +// carries the updated content once it is known, even on a write failure. +func editMutation(canonical string, displayPath, oldString, newString string, replaceAll bool) (res *editResult, produced []byte, started, complete bool, err error) { if oldString == "" { - return nil, false, fmt.Errorf("edit_file: old_string must not be empty") + return nil, nil, false, false, fmt.Errorf("edit_file: old_string must not be empty") } if oldString == newString { - return nil, false, fmt.Errorf("edit_file: old_string and new_string are identical") + return nil, nil, false, false, fmt.Errorf("edit_file: old_string and new_string are identical") } if _, err := ensureRegularExistingTarget(canonical); err != nil { - return nil, false, fmt.Errorf("edit_file: %w", err) + return nil, nil, false, false, fmt.Errorf("edit_file: %w", err) } f, err := openExistingMutationFile(canonical, os.O_RDWR) if err != nil { - return nil, false, fmt.Errorf("edit_file: %w", err) + return nil, nil, false, false, fmt.Errorf("edit_file: %w", err) } defer f.Close() info, err := f.Stat() if err != nil { - return nil, false, fmt.Errorf("edit_file: stat: %w", err) + return nil, nil, false, false, fmt.Errorf("edit_file: stat: %w", err) } if err := ensureRegularFileInfo(canonical, info); err != nil { - return nil, false, fmt.Errorf("edit_file: %w", err) + return nil, nil, false, false, fmt.Errorf("edit_file: %w", err) } data, err := io.ReadAll(f) if err != nil { - return nil, false, fmt.Errorf("edit_file: %w", err) + return nil, nil, false, false, fmt.Errorf("edit_file: %w", err) } content := string(data) - res, err := ApplyEdit(content, oldString, newString, replaceAll, displayPath) + edit, err := ApplyEdit(content, oldString, newString, replaceAll, displayPath) if err != nil { - return nil, false, err + return nil, nil, false, false, err } + produced = []byte(edit.UpdatedContent) - mutationStarted := true - if err := f.Truncate(0); err != nil { - return nil, mutationStarted, fmt.Errorf("edit_file: truncate: %w", err) - } - if _, err := f.Seek(0, io.SeekStart); err != nil { - return nil, mutationStarted, fmt.Errorf("edit_file: seek: %w", err) - } - if _, err := f.Write([]byte(res.UpdatedContent)); err != nil { - return nil, mutationStarted, fmt.Errorf("edit_file: write: %w", err) - } - if err := f.Sync(); err != nil { - return nil, mutationStarted, fmt.Errorf("edit_file: sync: %w", err) + complete, werr := writeReplacement(f, produced) + if werr != nil { + return nil, produced, true, complete, fmt.Errorf("edit_file: %w", werr) } return &editResult{ - Result: res.Summary, - UpdatedContent: res.UpdatedContent, - LineRanges: res.LineRanges, - Count: res.Count, - }, mutationStarted, nil + Result: edit.Summary, + UpdatedContent: edit.UpdatedContent, + LineRanges: edit.LineRanges, + Count: edit.Count, + }, produced, true, true, nil } -// editWithSnapshotBookkeeping wraps editMutation with the retained snapshot -// transaction: preflight, capture, the post-snapshot binding revalidation, -// and the discard-before-mutation / retain-after-mutation failure behavior. +// editWithSnapshotBookkeeping wraps editMutation with the mandatory snapshot +// transaction: preflight, capture plus the pre-mutation proof clear, the +// post-capture binding revalidation, and the abandon-on-proven-non-mutation / +// produced-identity failure behavior. func editWithSnapshotBookkeeping(bound boundCall, store SnapshotStore, displayAbs, canonical, displayPath, oldString, newString string, replaceAll bool) (string, error) { if err := preflightEditSnapshotTarget(canonical); err != nil { return "", err } - snapshot, err := snapshotFileForMutation(store, store.CurrentTurn(), displayAbs, canonical) + snapshot, err := captureSnapshotEntry(store, displayAbs, canonical) if err != nil { return "", fmt.Errorf("edit_file: snapshot: %w", err) } - defer releaseSnapshotMutation(snapshot) // Retained legacy behavior: the binding is revalidated again after the // snapshot was captured, so a target repointed during snapshotting // fails before any mutation, and the unmutated snapshot claim is - // discarded exactly like every other pre-mutation failure. + // abandoned exactly like every other pre-mutation failure. if err := bound.revalidate(); err != nil { err = fmt.Errorf("edit_file: resolve path: %w", err) - if discardErr := discardUnmutatedSnapshot(snapshot); discardErr != nil { - return "", fmt.Errorf("%w; additionally failed to discard snapshot: %v", err, discardErr) + if abandonErr := abandonUnmutatedSnapshot(snapshot); abandonErr != nil { + return "", fmt.Errorf("%w; additionally failed to abandon snapshot: %v", err, abandonErr) } return "", err } - res, mutationStarted, err := editMutation(canonical, displayPath, oldString, newString, replaceAll) + res, produced, started, complete, err := editMutation(canonical, displayPath, oldString, newString, replaceAll) if err != nil { - if !mutationStarted { - if discardErr := discardUnmutatedSnapshot(snapshot); discardErr != nil { - return "", fmt.Errorf("%w; additionally failed to discard snapshot: %v", err, discardErr) - } - } else { - err = retainFailedMutatedSnapshot(snapshot, canonical, err) - } - return "", err + return "", finishMutationFailure(snapshot, started, complete, produced, err) } - if err := recordMutatedSnapshotContent(snapshot, []byte(res.UpdatedContent)); err != nil { - retainMutatedSnapshot(snapshot) + if err := recordProducedSnapshotContent(snapshot, produced); err != nil { return "", fmt.Errorf("edit_file: record snapshot identity: %w", err) } - retainMutatedSnapshot(snapshot) return res.Result, nil } diff --git a/internal/tool/prepare_test.go b/internal/tool/prepare_test.go index 53b92815..87d13c8d 100644 --- a/internal/tool/prepare_test.go +++ b/internal/tool/prepare_test.go @@ -413,7 +413,7 @@ func TestPrepareReadRevalidatesRepointedWorkspaceRoot(t *testing.T) { func TestPrepareWriteRevalidatesRepointedWorkspaceRoot(t *testing.T) { realA, realB, root := workspaceRepointFixture(t, "x") - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) args, err := NormalizeWriteArgs(map[string]any{"path": filepath.Join(realA, "f.txt"), "content": "y"}) testassert.NoError(t, err) @@ -434,7 +434,7 @@ func TestPrepareWriteRevalidatesRepointedWorkspaceRoot(t *testing.T) { func TestPreparePatchRevalidatesRepointedWorkspaceRoot(t *testing.T) { realA, realB, root := workspaceRepointFixture(t, "hi") - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Update File: "+filepath.Join(realA, "f.txt")+"\n@@\n-hi\n+bye") args, err := NormalizePatchArgs(map[string]any{"input": input}) @@ -513,7 +513,7 @@ func TestPreparedEditAndWritePaths(t *testing.T) { path := filepath.Join(dir, "sub", "file.txt") testassert.NoError(t, os.Mkdir(filepath.Join(dir, "sub"), 0o755)) testassert.NoError(t, os.WriteFile(path, []byte("hello"), 0o644)) - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) // Prepared write through the exported entry (no tracker, nil-tracker // target path) overwrites content. @@ -591,7 +591,7 @@ func TestPrepareWriteAndEditDiscardSnapshotClaimOnPostSnapshotRevalidationFailur // must fail on the changed canonical target AND discard the snapshot // claim; neither file may be mutated. repointTo(real1) - store := &applyPatchStore{turn: 1, onSnapshot: func(call int) { + store := &recordingCodeGroupStore{testCodeGroupStore: newTempCodeGroupStore(t), onSnapshot: func(call int) { if call == 1 { repointTo(real2) } @@ -604,13 +604,15 @@ func TestPrepareWriteAndEditDiscardSnapshotClaimOnPostSnapshotRevalidationFailur _, err := prepared.Execute(context.Background()) testassert.Require(t, err != nil && strings.Contains(err.Error(), "approved canonical path changed"), "write Execute error = %v, want post-snapshot canonical change refusal", err) } - testassert.Require(t, len(store.discards) == 1 && store.discards[0] == real1, "write discards = %v, want the unmutated snapshot claim for %s discarded once", store.discards, real1) + testassert.Require(t, len(store.discards) == 1 && store.discards[0] == store.entryIDFor(real1), "write discards = %v, want the unmutated snapshot claim for %s discarded once", store.discards, real1) + assertEntryDiscarded(t, store, real1) + assertSnapshotEntryCount(t, store.groupDir, 0) assertFileContent(t, real1, "before") assertFileContent(t, real2, "before") // Edit path: same window, same discard-before-mutation behavior. repointTo(real1) - store2 := &applyPatchStore{turn: 1, onSnapshot: func(call int) { + store2 := &recordingCodeGroupStore{testCodeGroupStore: newTempCodeGroupStore(t), onSnapshot: func(call int) { if call == 1 { repointTo(real2) } @@ -623,7 +625,9 @@ func TestPrepareWriteAndEditDiscardSnapshotClaimOnPostSnapshotRevalidationFailur _, err := editPrepared.Execute(context.Background()) testassert.Require(t, err != nil && strings.Contains(err.Error(), "approved canonical path changed"), "edit Execute error = %v, want post-snapshot canonical change refusal", err) } - testassert.Require(t, len(store2.discards) == 1 && store2.discards[0] == real1, "edit discards = %v, want the unmutated snapshot claim for %s discarded once", store2.discards, real1) + testassert.Require(t, len(store2.discards) == 1 && store2.discards[0] == store2.entryIDFor(real1), "edit discards = %v, want the unmutated snapshot claim for %s discarded once", store2.discards, real1) + assertEntryDiscarded(t, store2, real1) + assertSnapshotEntryCount(t, store2.groupDir, 0) assertFileContent(t, real1, "before") assertFileContent(t, real2, "before") } @@ -631,7 +635,7 @@ func TestPrepareWriteAndEditDiscardSnapshotClaimOnPostSnapshotRevalidationFailur func TestPreparePatchExecutesParsedPatchWithoutReparse(t *testing.T) { dir := t.TempDir() testassert.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hi"), 0o644)) - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Update File: a.txt\n@@\n-hi\n+bye") args := map[string]any{"input": input} @@ -659,7 +663,7 @@ func TestPreparePatchClosureRevalidatesRepointedTarget(t *testing.T) { testassert.NoError(t, os.WriteFile(real2, []byte("hi"), 0o644)) link := filepath.Join(dir, "link.txt") testassert.NoError(t, os.Symlink(real1, link)) - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) input := applyPatchInput(t, "*** Update File: link.txt\n@@\n-hi\n+bye") prepared, err := PreparePatchCall(dir, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) @@ -682,7 +686,7 @@ func TestPreparePatchPartialFailureReportsCommittedFiles(t *testing.T) { second := filepath.Join(dir, "second.txt") testassert.NoError(t, os.WriteFile(first, []byte("one"), 0o644)) testassert.NoError(t, os.WriteFile(second, []byte("two"), 0o644)) - store := &applyPatchStore{turn: 1} + store := &recordingCodeGroupStore{testCodeGroupStore: newTempCodeGroupStore(t)} // After the first op commits its snapshot, tamper with the second op's // file: the per-op current-content check must refuse it and the partial // summary must report the committed first file. @@ -710,7 +714,7 @@ func TestPrepareWriteRevalidatesWriteDirWitness(t *testing.T) { testassert.NoError(t, os.Mkdir(realB, 0o755)) wd := filepath.Join(root, "wd") testassert.NoError(t, os.Symlink(realA, wd)) - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) // The target is addressed directly through the real directory while the // write-dir witness goes through the symlink; the target canonical path @@ -784,7 +788,7 @@ func TestNormalizePatchArgsStrictInputType(t *testing.T) { args, err = NormalizePatchArgs(map[string]any{}) testassert.NoError(t, err) { - _, err := PreparePatchCall(t.TempDir(), "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, args) + _, err := PreparePatchCall(t.TempDir(), "", "", CapabilityOptions{}, newTempCodeGroupStore(t), args) testassert.Require(t, err != nil && strings.Contains(err.Error(), "input is empty"), "PreparePatchCall error = %v, want patch-syntax error", err) } } @@ -835,7 +839,7 @@ func patchWriteDirWitnessInput(t *testing.T, target string) string { func TestPreparePatchRevalidatesWriteDirWitness(t *testing.T) { t.Run("prepared_closure", func(t *testing.T) { f := newPatchWriteDirWitnessFixture(t) - store := &applyPatchStore{turn: 1} + store := newTempCodeGroupStore(t) rootCanonical, err := bindWorkspaceRoot(f.root) testassert.NoError(t, err) writeDirCanonical, err := bindWriteDir(f.root, "apply_patch", f.wd) @@ -870,7 +874,7 @@ func TestPreparePatchRejectsOutsideWriteDirAtPreparation(t *testing.T) { input := applyPatchInput(t, "*** Add File: "+filepath.Join(outside, "new.txt")+"\n+data") args, err := NormalizePatchArgs(map[string]any{"input": input}) testassert.NoError(t, err) - prepared, err := PreparePatchCall(root, rootCanonical, writeDirCanonical, CapabilityOptions{WriteDir: inside}, &applyPatchStore{turn: 1}, args) + prepared, err := PreparePatchCall(root, rootCanonical, writeDirCanonical, CapabilityOptions{WriteDir: inside}, newTempCodeGroupStore(t), args) testassert.Require(t, err != nil && strings.Contains(err.Error(), "outside write_dir"), "PreparePatchCall error = %v, want outside-write_dir rejection at preparation", err) testassert.Require(t, prepared == nil, "prepared call = %+v, want nil after preparation rejection", prepared) { @@ -883,7 +887,7 @@ func TestPreparePatchRejectsOutsideWriteDirAtPreparation(t *testing.T) { args, err = NormalizePatchArgs(map[string]any{"input": input}) testassert.NoError(t, err) { - _, err := PreparePatchCall(root, rootCanonical, writeDirCanonical, CapabilityOptions{WriteDir: inside}, &applyPatchStore{turn: 1}, args) + _, err := PreparePatchCall(root, rootCanonical, writeDirCanonical, CapabilityOptions{WriteDir: inside}, newTempCodeGroupStore(t), args) testassert.Require(t, err != nil && strings.Contains(err.Error(), "outside write_dir"), "PreparePatchCall error = %v, want outside-write_dir source rejection at preparation", err) } @@ -891,7 +895,7 @@ func TestPreparePatchRejectsOutsideWriteDirAtPreparation(t *testing.T) { input = applyPatchInput(t, "*** Update File: "+filepath.Join(inside, "a.txt")+"\n@@\n-x\n+fixed") args, err = NormalizePatchArgs(map[string]any{"input": input}) testassert.NoError(t, err) - prepared, err = PreparePatchCall(root, rootCanonical, writeDirCanonical, CapabilityOptions{WriteDir: inside}, &applyPatchStore{turn: 1}, args) + prepared, err = PreparePatchCall(root, rootCanonical, writeDirCanonical, CapabilityOptions{WriteDir: inside}, newTempCodeGroupStore(t), args) testassert.NoError(t, err) { _, err := prepared.Execute(context.Background()) diff --git a/internal/tool/shared_test_helpers_test.go b/internal/tool/shared_test_helpers_test.go index d0e49938..1936ec63 100644 --- a/internal/tool/shared_test_helpers_test.go +++ b/internal/tool/shared_test_helpers_test.go @@ -2,40 +2,39 @@ package tool import ( "context" + "crypto/sha256" + "encoding/hex" + "errors" "os" + "path/filepath" "testing" "github.com/MMinasyan/lightcode/internal/snapshot" "github.com/MMinasyan/lightcode/internal/testassert" ) -// testCodeGroupStore adapts one real snapshot.CodeStore to the shared +// testCodeGroupStore adapts one real snapshot.CodeStore to the complete // SnapshotStore interface in the same private group-adapter shape the -// production tools plugin uses around a per-call code group: the group's -// single turn is explicit test identity, and Snapshot delegates to the -// resolved-entry capture. +// production tools plugin uses around a per-call code group. type testCodeGroupStore struct { *snapshot.CodeStore - turn int -} - -// CurrentTurn pins the group's single explicit test turn. -func (s testCodeGroupStore) CurrentTurn() int { return s.turn } - -// Snapshot satisfies the plain SnapshotStore surface through the resolved -// entry capture. -func (s testCodeGroupStore) Snapshot(turn int, absPath string) error { - _, _, err := s.SnapshotResolvedEntry(turn, absPath, absPath) - return err + groupDir string } // newCodeGroupStore opens one real artifact code group at the test's explicit -// directory and turn identity. -func newCodeGroupStore(t *testing.T, groupDir string, turn int) testCodeGroupStore { +// directory. +func newCodeGroupStore(t *testing.T, groupDir string) testCodeGroupStore { t.Helper() store, err := snapshot.OpenCodeStore(groupDir) testassert.NoError(t, err) - return testCodeGroupStore{CodeStore: store, turn: turn} + return testCodeGroupStore{CodeStore: store, groupDir: groupDir} +} + +// newTempCodeGroupStore opens one real artifact code group in a fresh +// temporary directory. +func newTempCodeGroupStore(t *testing.T) testCodeGroupStore { + t.Helper() + return newCodeGroupStore(t, filepath.Join(t.TempDir(), "group")) } // execPatch runs one apply_patch through the exported Normalize* + Prepare* @@ -60,80 +59,190 @@ func execPatch(t *testing.T, root, rootCanonical, writeDirCanonical string, opts return call.Execute(context.Background()) } -// Shared, non-staging test fixtures recovered from the deleted staging executor -// test file. These are used across the immediate-path tool test suites -// (apply_patch, write_file, edit_file, file_identity, snapshot_lifecycle, ...). - type snapshotCall struct { - turn int path string canonical string + entryID string + created bool } type snapshotIdentityRecord struct { - turn int entryID string content string absent bool } -// recordingSnapshotStore is a SnapshotStore that records every call and the -// before-content of each path. The real snapshot store does this; the test -// store lets immediate-path assertions inspect what was captured. -type recordingSnapshotStore struct { - turn int +// recordingCodeGroupStore is the single recording/fault wrapper over one real +// code group. Capture, entry ids, created flags, identity replacement, +// records and discard all delegate to the real store and persist on disk; +// the counters exist only so invocation-order assertions can inspect what +// was captured. onSnapshot fires after the underlying SnapshotResolvedEntry +// succeeded, before the returned entry is used by the caller. failOnCall +// makes the Nth capture return errFail instead of capturing (simulated +// capture failure); plantBefore creates one file just before the next +// capture (concurrent appearance); failAbandon makes a non-nil identity +// replacement (the prior-proof restoration) fail without performing it. +type recordingCodeGroupStore struct { + testCodeGroupStore calls []snapshotCall - before map[string]string identityRecords []snapshotIdentityRecord + discards []string + onSnapshot func(call int) + failOnCall int + errFail error + plantBefore string + failAbandon bool } -func (s *recordingSnapshotStore) Snapshot(turn int, absPath string) error { - return s.SnapshotResolved(turn, absPath, absPath) +func newRecordingCodeGroupStore(t *testing.T) *recordingCodeGroupStore { + t.Helper() + return &recordingCodeGroupStore{testCodeGroupStore: newTempCodeGroupStore(t)} } -func (s *recordingSnapshotStore) SnapshotResolved(turn int, originalPath, canonicalPath string) error { - data, err := os.ReadFile(canonicalPath) +func (s *recordingCodeGroupStore) SnapshotResolvedEntry(originalPath, canonicalPath string) (string, bool, error) { + if s.plantBefore != "" { + path := s.plantBefore + s.plantBefore = "" + if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) { + if err := os.WriteFile(path, appearedDestinationBytes, 0o644); err != nil { + return "", false, err + } + } + } + if s.failOnCall > 0 && len(s.calls)+1 == s.failOnCall { + return "", false, s.errFail + } + entryID, created, err := s.testCodeGroupStore.SnapshotResolvedEntry(originalPath, canonicalPath) if err != nil { - return err + return "", false, err + } + s.calls = append(s.calls, snapshotCall{path: originalPath, canonical: canonicalPath, entryID: entryID, created: created}) + if s.onSnapshot != nil { + s.onSnapshot(len(s.calls)) } - s.calls = append(s.calls, snapshotCall{turn: turn, path: originalPath, canonical: canonicalPath}) - s.before[canonicalPath] = string(data) - return nil + return entryID, created, nil } -func (s *recordingSnapshotStore) SnapshotResolvedEntry(turn int, originalPath, canonicalPath string) (string, bool, error) { - if err := s.SnapshotResolved(turn, originalPath, canonicalPath); err != nil { - return "", false, err +func (s *recordingCodeGroupStore) DiscardSnapshotEntry(entryID string) error { + s.discards = append(s.discards, entryID) + return s.testCodeGroupStore.DiscardSnapshotEntry(entryID) +} + +func (s *recordingCodeGroupStore) ReplaceSnapshotIdentity(entryID string, identity *snapshot.SnapshotContentIdentity) (*snapshot.SnapshotContentIdentity, error) { + if s.failAbandon && identity != nil { + return nil, errInjectedAbandon + } + return s.testCodeGroupStore.ReplaceSnapshotIdentity(entryID, identity) +} + +func (s *recordingCodeGroupStore) RecordSnapshotContent(entryID string, content []byte) error { + s.identityRecords = append(s.identityRecords, snapshotIdentityRecord{entryID: entryID, content: string(content)}) + return s.testCodeGroupStore.RecordSnapshotContent(entryID, content) +} + +func (s *recordingCodeGroupStore) RecordSnapshotAbsence(entryID string) error { + s.identityRecords = append(s.identityRecords, snapshotIdentityRecord{entryID: entryID, absent: true}) + return s.testCodeGroupStore.RecordSnapshotAbsence(entryID) +} + +// entryIDFor returns the real entry id the recorded capture reported for one +// canonical target. +func (s *recordingCodeGroupStore) entryIDFor(canonical string) string { + for _, c := range s.calls { + if c.canonical == canonical { + return c.entryID + } } - return canonicalPath, true, nil + return "" } -func (s *recordingSnapshotStore) DiscardSnapshotEntry(int, string) error { - return nil +// --- on-disk snapshot outcome assertions --- + +func snapshotIdentityHash(content string) string { + sum := sha256.Sum256([]byte(content)) + return hex.EncodeToString(sum[:]) } -func (s *recordingSnapshotStore) RetainSnapshotEntry(int, string) {} +func snapshotEntryDir(store testCodeGroupStore, entryID string) string { + return filepath.Join(store.groupDir, "snapshots", "1", entryID) +} -func (s *recordingSnapshotStore) RecordSnapshotContent(turn int, entryID string, content []byte) error { - s.identityRecords = append(s.identityRecords, snapshotIdentityRecord{ - turn: turn, - entryID: entryID, - content: string(content), - }) - return nil +func soleSnapshotMeta(t *testing.T, store testCodeGroupStore) snapshot.SnapshotMeta { + t.Helper() + metas, err := store.ListEntries() + testassert.NoError(t, err) + testassert.Require(t, len(metas) == 1, "ListEntries = %+v, want one entry", metas) + return metas[0] } -func (s *recordingSnapshotStore) RecordSnapshotAbsence(turn int, entryID string) error { - s.identityRecords = append(s.identityRecords, snapshotIdentityRecord{ - turn: turn, - entryID: entryID, - absent: true, - }) - return nil +func metaForCanonical(t *testing.T, store testCodeGroupStore, canonical string) snapshot.SnapshotMeta { + t.Helper() + metas, err := store.ListEntries() + testassert.NoError(t, err) + for _, meta := range metas { + if meta.CanonicalPath == canonical { + return meta + } + } + t.Fatalf("ListEntries = %+v, want an entry recorded for %s", metas, canonical) + return snapshot.SnapshotMeta{} } -func (s *recordingSnapshotStore) CurrentTurn() int { - return s.turn +func assertSnapshotEntryCount(t *testing.T, groupDir string, want int) { + t.Helper() + entries, err := os.ReadDir(filepath.Join(groupDir, "snapshots", "1")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + testassert.Require(t, len(entries) == want, "snapshot entries = %v, want %d", entries, want) +} + +func assertEntryPresent(t *testing.T, store *recordingCodeGroupStore, canonical string) { + t.Helper() + entryID := store.entryIDFor(canonical) + testassert.Require(t, entryID != "", "no recorded capture for %s", canonical) + info, err := os.Stat(snapshotEntryDir(store.testCodeGroupStore, entryID)) + testassert.Require(t, err == nil && info.IsDir(), "entry %s for %s = %v, want it persisted on disk", entryID, canonical, err) +} + +func assertEntryDiscarded(t *testing.T, store *recordingCodeGroupStore, canonical string) { + t.Helper() + entryID := store.entryIDFor(canonical) + testassert.Require(t, entryID != "", "no recorded capture for %s", canonical) + _, err := os.Stat(snapshotEntryDir(store.testCodeGroupStore, entryID)) + testassert.Require(t, os.IsNotExist(err), "entry %s for %s = %v, want the refused new capture discarded from disk", entryID, canonical, err) +} + +func assertEntryOriginal(t *testing.T, groupDir, want string) { + t.Helper() + entries, err := os.ReadDir(filepath.Join(groupDir, "snapshots", "1")) + testassert.NoError(t, err) + testassert.Require(t, len(entries) == 1 && entries[0].IsDir(), "snapshot entries = %v, want one entry directory", entries) + data, err := os.ReadFile(filepath.Join(groupDir, "snapshots", "1", entries[0].Name(), "original")) + testassert.NoError(t, err) + testassert.Require(t, string(data) == want, "preimage = %q, want %q", data, want) +} + +func assertEntryPreimage(t *testing.T, store *recordingCodeGroupStore, canonical, want string) { + t.Helper() + entryID := store.entryIDFor(canonical) + testassert.Require(t, entryID != "", "no recorded capture for %s", canonical) + data, err := os.ReadFile(filepath.Join(snapshotEntryDir(store.testCodeGroupStore, entryID), "original")) + testassert.NoError(t, err) + testassert.Require(t, string(data) == want, "persisted preimage of %s = %q, want %q", canonical, data, want) +} + +func revertTestGroup(t *testing.T, groupDir string) snapshot.RevertResult { + t.Helper() + result, err := snapshot.RevertCodeGroups([]string{groupDir}) + testassert.NoError(t, err) + return result +} + +func assertPathMissing(t *testing.T, path string) { + t.Helper() + _, err := os.Lstat(path) + testassert.Require(t, os.IsNotExist(err), "%s = %v, want absent", path, err) } func assertFileContent(t *testing.T, path, want string) { @@ -146,22 +255,140 @@ func assertFileContent(t *testing.T, path, want string) { } } -func assertSnapshotIdentityContent(t *testing.T, records []snapshotIdentityRecord, turn int, entryID, content string) { +func assertSnapshotIdentityContent(t *testing.T, records []snapshotIdentityRecord, entryID, content string) { t.Helper() for _, record := range records { - if record.turn == turn && record.entryID == entryID && !record.absent && record.content == content { + if record.entryID == entryID && !record.absent && record.content == content { return } } - t.Fatalf("snapshot identity records = %+v, want turn=%d entry=%q content=%q", records, turn, entryID, content) + t.Fatalf("snapshot identity records = %+v, want entry=%q content=%q", records, entryID, content) } -func assertSnapshotIdentityAbsent(t *testing.T, records []snapshotIdentityRecord, turn int, entryID string) { +func assertSnapshotIdentityAbsent(t *testing.T, records []snapshotIdentityRecord, entryID string) { t.Helper() for _, record := range records { - if record.turn == turn && record.entryID == entryID && record.absent { + if record.entryID == entryID && record.absent { return } } - t.Fatalf("snapshot identity records = %+v, want turn=%d entry=%q absent", records, turn, entryID) + t.Fatalf("snapshot identity records = %+v, want entry=%q absent", records, entryID) +} + +// --- shared producer inventory --- + +// producerMutationCase is one standard mutation producer with the content it +// intends to produce, shared by the lifecycle truncate/full-write/sync rows +// and the short/foreign-write identity rows. +type producerMutationCase struct { + name string + existing bool + seed string + intended string + run func(t *testing.T, root, path string, store testCodeGroupStore) error +} + +func producerMutationCases() []producerMutationCase { + return []producerMutationCase{ + { + name: "write", existing: true, seed: "before", intended: "after", + run: func(t *testing.T, root, path string, store testCodeGroupStore) error { + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "after"}) + return err + }, + }, + { + name: "edit", existing: true, seed: "before", intended: "after", + run: func(t *testing.T, root, path string, store testCodeGroupStore) error { + _, err := execEdit(t, root, "", "", CapabilityOptions{}, store, map[string]any{ + "path": path, "old_string": "before", "new_string": "after", + }) + return err + }, + }, + { + name: "patch_update", existing: true, seed: "a\nB\nc", intended: "a\nB2\nc", + run: func(t *testing.T, root, _ string, store testCodeGroupStore) error { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Update File: file.txt\n@@\n-B\n+B2")}) + return err + }, + }, + { + name: "write_created", existing: false, seed: "", intended: "after", + run: func(t *testing.T, root, path string, store testCodeGroupStore) error { + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "after"}) + return err + }, + }, + { + name: "patch_add_created", existing: false, seed: "", intended: "after", + run: func(t *testing.T, root, _ string, store testCodeGroupStore) error { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Add File: created.txt\n+after")}) + return err + }, + }, + } +} + +// runProducerMutationCase drives one producer case through one injected +// mutation stimulus over the real store and asserts the produced-bytes +// outcome, the persisted meta identity, and the reversion result. +func runProducerMutationCase(t *testing.T, tc producerMutationCase, step mutationFailureStep) { + t.Helper() + root := t.TempDir() + name := "created.txt" + if tc.existing { + name = "file.txt" + } + path := filepath.Join(root, name) + if tc.existing { + testassert.NoError(t, os.WriteFile(path, []byte(tc.seed), 0o644)) + } + groupDir := filepath.Join(t.TempDir(), "group") + store := newCodeGroupStore(t, groupDir) + withMutationFailure(t, path, step) + + err := tc.run(t, root, path, store) + + testassert.Require(t, err != nil, "%s with %s succeeded, want injected mutation failure", tc.name, step) + meta := soleSnapshotMeta(t, store) + assertSnapshotEntryCount(t, groupDir, 1) + if tc.existing { + assertEntryOriginal(t, groupDir, tc.seed) + } + prefix := tc.intended[:len(tc.intended)/2] + switch step { + case failAfterTruncate, failShortWriteError, failShortWriteNil: + testassert.Require(t, meta.LastWrite == nil, "%s: LastWrite = %+v, want nil: an incomplete mutation may not claim a complete identity", step, meta.LastWrite) + case failAfterWrite, failAfterSync, failForeignWriteError, failForeignSync: + testassert.Require(t, meta.LastWrite != nil && meta.LastWrite.Hash == snapshotIdentityHash(tc.intended), "%s: LastWrite = %+v, want the own complete intended bytes, never a foreign reread", step, meta.LastWrite) + } + switch step { + case failAfterTruncate: + assertFileContent(t, path, "") + case failShortWriteError, failShortWriteNil: + assertFileContent(t, path, prefix) + case failForeignWriteError, failForeignSync: + assertFileContent(t, path, string(foreignMutationBytes)) + } + result := revertTestGroup(t, groupDir) + switch step { + case failAfterWrite, failAfterSync: + testassert.Require(t, len(result.Restored) == 1 && result.Restored[0] == path && len(result.Skipped) == 0, "restored = %v skipped = %+v, want the known-complete mutation restorable for %s", result.Restored, result.Skipped, path) + if tc.existing { + assertFileContent(t, path, tc.seed) + } else { + assertPathMissing(t, path) + } + default: + testassert.Require(t, len(result.Restored) == 0 && len(result.Skipped) == 1 && result.Skipped[0].Path == path, "restored = %v want none; skipped = %+v, want one skip for %s", result.Restored, result.Skipped, path) + switch step { + case failAfterTruncate: + assertFileContent(t, path, "") + case failShortWriteError, failShortWriteNil: + assertFileContent(t, path, prefix) + case failForeignWriteError, failForeignSync: + assertFileContent(t, path, string(foreignMutationBytes)) + } + } } diff --git a/internal/tool/snapshot_lifecycle_test.go b/internal/tool/snapshot_lifecycle_test.go index 705a1e8e..1d96692e 100644 --- a/internal/tool/snapshot_lifecycle_test.go +++ b/internal/tool/snapshot_lifecycle_test.go @@ -1,16 +1,21 @@ package tool import ( - "fmt" "os" "path/filepath" - "sync" "testing" - "time" + "github.com/MMinasyan/lightcode/internal/snapshot" "github.com/MMinasyan/lightcode/internal/testassert" ) +// revertLifecycleGroup runs the real whole-invocation restore producer over +// the fixture's known group directory, as production does. +func revertLifecycleGroup(t *testing.T, store testCodeGroupStore) (snapshot.RevertResult, error) { + t.Helper() + return snapshot.RevertCodeGroups([]string{store.groupDir}) +} + func TestWriteFileRetainsSnapshotWhenTargetChangesBeforeMutation(t *testing.T) { for _, tc := range []struct { name string @@ -36,10 +41,12 @@ func TestWriteFileRetainsSnapshotWhenTargetChangesBeforeMutation(t *testing.T) { root := t.TempDir() path := snapshotLifecycleFile(t, root, "file.txt", "before") store := newSnapshotLifecycleStore(t) - wrapped := &mutatingTransactionalSnapshotStore{ + wrapped := &recordingCodeGroupStore{ testCodeGroupStore: store, - mutate: func() { - tc.mutate(t, path) + onSnapshot: func(call int) { + if call == 1 { + tc.mutate(t, path) + } }, } @@ -50,7 +57,7 @@ func TestWriteFileRetainsSnapshotWhenTargetChangesBeforeMutation(t *testing.T) { testassert.NoError(t, err) assertFileContent(t, path, "after") - affected, err := store.RevertCode(0) + affected, err := revertLifecycleGroup(t, store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 1 && affected.Restored[0] == path, "RevertCode affected = %v, want restored %s", affected, path) assertFileContent(t, path, "before") @@ -70,7 +77,7 @@ func TestEditFileDiscardsSnapshotWhenEditFailsBeforeMutation(t *testing.T) { }) testassert.Require(t, err != nil, "edit_file succeeded, want missing match error") - affected, err := store.RevertCode(0) + affected, err := revertLifecycleGroup(t, store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0, "RevertCode affected = %v, want no files", affected) assertFileContent(t, path, "before") @@ -80,11 +87,13 @@ func TestEditFileRetainsSnapshotWhenTargetChangesBeforeMutation(t *testing.T) { root := t.TempDir() path := snapshotLifecycleFile(t, root, "file.txt", "before") store := newSnapshotLifecycleStore(t) - wrapped := &mutatingTransactionalSnapshotStore{ + wrapped := &recordingCodeGroupStore{ testCodeGroupStore: store, - mutate: func() { - testassert.NoError(t, os.Remove(path)) - testassert.NoError(t, os.WriteFile(path, []byte("external"), 0o644)) + onSnapshot: func(call int) { + if call == 1 { + testassert.NoError(t, os.Remove(path)) + testassert.NoError(t, os.WriteFile(path, []byte("external"), 0o644)) + } }, } @@ -96,7 +105,7 @@ func TestEditFileRetainsSnapshotWhenTargetChangesBeforeMutation(t *testing.T) { testassert.NoError(t, err) assertFileContent(t, path, "after") - affected, err := store.RevertCode(0) + affected, err := revertLifecycleGroup(t, store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 1 && affected.Restored[0] == path, "RevertCode affected = %v, want restored %s", affected, path) assertFileContent(t, path, "before") @@ -120,13 +129,13 @@ func TestFailedValidationDoesNotDiscardPreviouslyRetainedSnapshot(t *testing.T) "new_string": "other", }) testassert.Require(t, err != nil, "edit_file succeeded, want missing match error") - affected, err := store.RevertCode(0) + affected, err := revertLifecycleGroup(t, store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 1 && affected.Restored[0] == path, "RevertCode affected = %v, want retained snapshot for %s", affected, path) assertFileContent(t, path, "before") } -func TestWriteFileSameTurnSecondWriteControlsSnapshotIdentity(t *testing.T) { +func TestWriteFileSameGroupSecondWriteControlsSnapshotIdentity(t *testing.T) { root := t.TempDir() path := snapshotLifecycleFile(t, root, "file.txt", "before") store := newSnapshotLifecycleStore(t) @@ -147,170 +156,23 @@ func TestWriteFileSameTurnSecondWriteControlsSnapshotIdentity(t *testing.T) { } testassert.NoError(t, os.WriteFile(path, []byte("first"), 0o644)) - affected, err := store.RevertCode(0) + affected, err := revertLifecycleGroup(t, store) testassert.NoError(t, err) testassert.Require(t, len(affected.Restored) == 0 && len(affected.Skipped) == 1 && affected.Skipped[0].Path == path, "RevertCode restored = %v, want none; RevertCode skipped = %+v, want skipped %s", affected.Restored, affected, path) assertFileContent(t, path, "first") } -func TestConcurrentSameTurnWritesKeepLatestSnapshotIdentity(t *testing.T) { - root := t.TempDir() - path := snapshotLifecycleFile(t, root, "file.txt", "before") - store := &blockingRecordSnapshotStore{ - testCodeGroupStore: newSnapshotLifecycleStore(t), - firstRecordStarted: make(chan struct{}), - releaseFirstRecord: make(chan struct{}), - secondLockAttempt: make(chan struct{}), - } - - firstDone := make(chan error, 1) - go func() { - _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{ - "path": path, - "content": "first", - }) - firstDone <- err - }() - - <-store.firstRecordStarted - secondDone := make(chan error, 1) - go func() { - _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{ - "path": path, - "content": "second", - }) - secondDone <- err - }() - - secondFinishedBeforeRelease := false - select { - case err := <-secondDone: - testassert.NoError(t, err) - secondFinishedBeforeRelease = true - case <-store.secondLockAttempt: - case <-time.After(time.Second): - t.Fatal("second write neither completed nor reached the snapshot mutation lock") - } - - close(store.releaseFirstRecord) - testassert.NoError(t, <-firstDone) - if !secondFinishedBeforeRelease { - testassert.NoError(t, <-secondDone) - } - - assertFileContent(t, path, "second") - affected, err := store.RevertCode(0) - testassert.NoError(t, err) - testassert.Require(t, len(affected.Restored) == 1 && affected.Restored[0] == path && len(affected.Skipped) == 0, "RevertCode restored = %v, want %s; RevertCode skipped = %+v, want none", affected.Restored, path, affected.Skipped) - assertFileContent(t, path, "before") -} - -func TestRetainedPartialMutationRecordsCurrentIdentityAndReverts(t *testing.T) { - steps := []mutationFailureStep{failAfterTruncate, failAfterWrite, failAfterSync} - cases := []struct { - name string - run func(t *testing.T, root, path string, store testCodeGroupStore) error - }{ - { - name: "immediate_write", - run: func(t *testing.T, root, path string, store testCodeGroupStore) error { - t.Helper() - _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{ - "path": path, - "content": "after", - }) - return err - }, - }, - { - name: "immediate_edit", - run: func(t *testing.T, root, path string, store testCodeGroupStore) error { - t.Helper() - _, err := execEdit(t, root, "", "", CapabilityOptions{}, store, map[string]any{ - "path": path, - "old_string": "before", - "new_string": "after", - }) - return err - }, - }, - { - name: "apply_patch", - run: func(t *testing.T, root, _ string, store testCodeGroupStore) error { - t.Helper() - input := applyPatchInput(t, "*** Update File: file.txt\n@@\n-before\n+after") - _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) - return err - }, - }, - } - - for _, tc := range cases { - for _, step := range steps { - t.Run(fmt.Sprintf("%s_%s", tc.name, step), func(t *testing.T) { - root := t.TempDir() - path := snapshotLifecycleFile(t, root, "file.txt", "before") - store := newSnapshotLifecycleStore(t) - withMutationFailure(t, path, step) - - err := tc.run(t, root, path, store) - - testassert.Require(t, err != nil, "%s with %s succeeded, want injected I/O failure", tc.name, step) - affected, revErr := store.RevertCode(0) - testassert.Require(t, revErr == nil, "RevertCode error = %v", revErr) - testassert.Require(t, len(affected.Restored) == 1 && affected.Restored[0] == path && len(affected.Skipped) == 0, "RevertCode affected = %+v, want restored %s; RevertCode skipped = %+v, want none", affected, path, affected.Skipped) - assertFileContent(t, path, "before") - }) - } - } -} - -func TestRetainedPartialCreatedFileMutationRecordsCurrentIdentityAndDeletesOnRevert(t *testing.T) { - steps := []mutationFailureStep{failAfterTruncate, failAfterWrite, failAfterSync} - cases := []struct { - name string - run func(t *testing.T, root, path string, store testCodeGroupStore) error - }{ - { - name: "immediate_write_create", - run: func(t *testing.T, root, path string, store testCodeGroupStore) error { - t.Helper() - _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{ - "path": path, - "content": "after", - }) - return err - }, - }, - { - name: "apply_patch_add", - run: func(t *testing.T, root, _ string, store testCodeGroupStore) error { - t.Helper() - input := applyPatchInput(t, "*** Add File: created.txt\n+after") - _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": input}) - return err - }, - }, - } - - for _, tc := range cases { - for _, step := range steps { - t.Run(fmt.Sprintf("%s_%s", tc.name, step), func(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, "created.txt") - store := newSnapshotLifecycleStore(t) - withMutationFailure(t, path, step) - - err := tc.run(t, root, path, store) - - testassert.Require(t, err != nil, "%s with %s succeeded, want injected I/O failure", tc.name, step) - affected, revErr := store.RevertCode(0) - testassert.Require(t, revErr == nil, "RevertCode error = %v", revErr) - testassert.Require(t, len(affected.Restored) == 1 && affected.Restored[0] == path && len(affected.Skipped) == 0, "RevertCode affected = %+v, want restored %s; RevertCode skipped = %+v, want none", affected, path, affected.Skipped) - { - _, statErr := os.Lstat(path) - testassert.Require(t, os.IsNotExist(statErr), "created file should be removed after RevertCode, stat err = %v", statErr) - } +// TestRetainedPartialMutationRestoresOrSkipsByProducedBytes: every producer +// over the real store — existing-file and created-file rows. A mutation +// whose full intended bytes are known (completed Write byte count, or a Sync +// error after them) keeps a restorable produced identity; an uncertain +// outcome (truncate failure) retains the preimage with nil identity and the +// restore skips. +func TestRetainedPartialMutationRestoresOrSkipsByProducedBytes(t *testing.T) { + for _, tc := range producerMutationCases() { + for _, step := range []mutationFailureStep{failAfterTruncate, failAfterWrite, failAfterSync} { + t.Run(tc.name+"_"+string(step), func(t *testing.T) { + runProducerMutationCase(t, tc, step) }) } } @@ -322,8 +184,22 @@ const ( failAfterTruncate mutationFailureStep = "truncate" failAfterWrite mutationFailureStep = "write" failAfterSync mutationFailureStep = "sync" + // failShortWriteError delegates only a strict prefix and returns an + // injected error; failShortWriteNil delegates the same strict prefix and + // returns nil error. Both are incomplete-produced-byte stimuli. + failShortWriteError mutationFailureStep = "short-write-error" + failShortWriteNil mutationFailureStep = "short-write-nil" + // failForeignWriteError and failForeignSync delegate the full own write, + // then write distinct foreign bytes before returning the injected Write + // or Sync error. + failForeignWriteError mutationFailureStep = "foreign-write-error" + failForeignSync mutationFailureStep = "foreign-sync-error" ) +// foreignMutationBytes are the distinct outside-editor bytes written after +// the mutation file's own full write completes. +var foreignMutationBytes = []byte("FOREIGN") + type injectedMutationError string func (e injectedMutationError) Error() string { return string(e) } @@ -332,7 +208,8 @@ var errInjectedMutation = injectedMutationError("injected mutation error") type failingMutationFile struct { mutationFile - step mutationFailureStep + step mutationFailureStep + target string } func (f *failingMutationFile) Truncate(size int64) error { @@ -347,6 +224,23 @@ func (f *failingMutationFile) Truncate(size int64) error { } func (f *failingMutationFile) Write(p []byte) (int, error) { + switch f.step { + case failShortWriteError, failShortWriteNil: + n, err := f.mutationFile.Write(p[:len(p)/2]) + if f.step == failShortWriteError { + return n, errInjectedMutation + } + return n, err + case failForeignWriteError: + n, err := f.mutationFile.Write(p) + if err != nil { + return n, err + } + if err := os.WriteFile(f.target, foreignMutationBytes, 0o644); err != nil { + return n, err + } + return n, errInjectedMutation + } n, err := f.mutationFile.Write(p) if err != nil { return n, err @@ -362,6 +256,12 @@ func (f *failingMutationFile) Sync() error { if err != nil { return err } + if f.step == failForeignSync { + if err := os.WriteFile(f.target, foreignMutationBytes, 0o644); err != nil { + return err + } + return errInjectedMutation + } if f.step == failAfterSync { return errInjectedMutation } @@ -373,17 +273,17 @@ func withMutationFailure(t *testing.T, target string, step mutationFailureStep) target = filepath.Clean(target) prevWrite := openWriteTargetForMutationFunc prevExisting := openExistingMutationFile - openWriteTargetForMutationFunc = func(absPath string) (mutationFile, bool, bool, error) { - f, existed, started, err := prevWrite(absPath) + openWriteTargetForMutationFunc = func(absPath string) (mutationFile, bool, error) { + f, existed, err := prevWrite(absPath) if err == nil && filepath.Clean(absPath) == target { - f = &failingMutationFile{mutationFile: f, step: step} + f = &failingMutationFile{mutationFile: f, step: step, target: target} } - return f, existed, started, err + return f, existed, err } openExistingMutationFile = func(absPath string, flag int) (mutationFile, error) { f, err := prevExisting(absPath, flag) if err == nil && filepath.Clean(absPath) == target { - f = &failingMutationFile{mutationFile: f, step: step} + f = &failingMutationFile{mutationFile: f, step: step, target: target} } return f, err } @@ -393,61 +293,11 @@ func withMutationFailure(t *testing.T, target string, step mutationFailureStep) }) } -type mutatingTransactionalSnapshotStore struct { - testCodeGroupStore - mutate func() -} - -func (s *mutatingTransactionalSnapshotStore) SnapshotResolvedEntry(turn int, originalPath, canonicalPath string) (string, bool, error) { - entryID, created, err := s.testCodeGroupStore.SnapshotResolvedEntry(turn, originalPath, canonicalPath) - if err == nil && s.mutate != nil { - mutate := s.mutate - s.mutate = nil - mutate() - } - return entryID, created, err -} - -type blockingRecordSnapshotStore struct { - testCodeGroupStore - mu sync.Mutex - records int - lockAttempts int - firstRecordStarted chan struct{} - releaseFirstRecord chan struct{} - secondLockAttempt chan struct{} -} - -func (s *blockingRecordSnapshotStore) LockSnapshotMutation(turn int, entryID string) (func(), error) { - s.mu.Lock() - s.lockAttempts++ - attempt := s.lockAttempts - s.mu.Unlock() - - if attempt == 2 { - close(s.secondLockAttempt) - } - return s.testCodeGroupStore.LockSnapshotMutation(turn, entryID) -} - -func (s *blockingRecordSnapshotStore) RecordSnapshotContent(turn int, entryID string, content []byte) error { - s.mu.Lock() - s.records++ - record := s.records - s.mu.Unlock() - - if record == 1 { - close(s.firstRecordStarted) - <-s.releaseFirstRecord - } - return s.testCodeGroupStore.RecordSnapshotContent(turn, entryID, content) -} - -// newSnapshotLifecycleStore opens one real artifact code group with explicit -// group and turn identity for the snapshot-lifecycle rows. +// newSnapshotLifecycleStore opens one real artifact code group +// group for the snapshot-lifecycle rows. func newSnapshotLifecycleStore(t *testing.T) testCodeGroupStore { t.Helper() - return newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"), 1) + return newCodeGroupStore(t, filepath.Join(t.TempDir(), "group")) } func snapshotLifecycleFile(t *testing.T, root, name, content string) string { diff --git a/internal/tool/snapshot_mutation_identity_test.go b/internal/tool/snapshot_mutation_identity_test.go new file mode 100644 index 00000000..8a20650d --- /dev/null +++ b/internal/tool/snapshot_mutation_identity_test.go @@ -0,0 +1,819 @@ +package tool + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MMinasyan/lightcode/internal/atomicfs" + "github.com/MMinasyan/lightcode/internal/testassert" +) + +// Last-produced snapshot identities must come only from what the mutation +// producer actually completed: retained write byte counts and known-complete +// produced content, never a post-error filesystem reread. The pre-mutation +// proof clear must also hold: when it fails, no mutation I/O may run. + +// --- incomplete and foreign-byte mutation failures over the real store --- + +func TestShortWriteClaimsNoProducedIdentityAndRevertSkips(t *testing.T) { + for _, step := range []mutationFailureStep{failShortWriteError, failShortWriteNil} { + for _, tc := range producerMutationCases() { + t.Run(string(step)+"_"+tc.name, func(t *testing.T) { + runProducerMutationCase(t, tc, step) + }) + } + } +} + +func TestForeignWriteBeforeErrorKeepsOwnProducedIdentityAndRevertSkips(t *testing.T) { + for _, step := range []mutationFailureStep{failForeignWriteError, failForeignSync} { + for _, tc := range producerMutationCases() { + t.Run(string(step)+"_"+tc.name, func(t *testing.T) { + runProducerMutationCase(t, tc, step) + }) + } + } +} + +// --- mutation file I/O counting --- + +type mutationIOCount struct { + opens int + writes int + truncates int +} + +type countingMutationFile struct { + mutationFile + count *mutationIOCount +} + +func (c *countingMutationFile) Write(p []byte) (int, error) { + c.count.writes++ + return c.mutationFile.Write(p) +} + +func (c *countingMutationFile) Truncate(size int64) error { + c.count.truncates++ + return c.mutationFile.Truncate(size) +} + +func withMutationFileCounting(t *testing.T, count *mutationIOCount) { + t.Helper() + prev := openWriteTargetForMutationFunc + openWriteTargetForMutationFunc = func(absPath string) (mutationFile, bool, error) { + f, existed, err := prev(absPath) + if err == nil { + count.opens++ + f = &countingMutationFile{mutationFile: f, count: count} + } + return f, existed, err + } + t.Cleanup(func() { openWriteTargetForMutationFunc = prev }) +} + +// --- snapshot meta publication failure seam --- + +type injectedMetaSyncError string + +func (e injectedMetaSyncError) Error() string { return string(e) } + +var errInjectedMetaSync = injectedMetaSyncError("injected snapshot meta publish failure") + +// withSnapshotMetaPublishFailure injects a failure on the nth meta.json +// temp-file publication (capture writes and proof/identity writes) done while +// the seam is installed, leaving every other publication to the real sync. +func withSnapshotMetaPublishFailure(t *testing.T, nth int) { + t.Helper() + previous := atomicfs.SyncFileFunc + metaSyncs := 0 + atomicfs.SyncFileFunc = func(f *os.File) error { + if strings.HasPrefix(filepath.Base(f.Name()), "meta.json.tmp-") { + metaSyncs++ + if metaSyncs == nth { + return errInjectedMetaSync + } + } + if previous != nil { + return previous(f) + } + return f.Sync() + } + t.Cleanup(func() { atomicfs.SyncFileFunc = previous }) +} + +// --- pre-mutation proof clear and prior-proof restoration --- + +func TestProofClearFailureBeforeMutationLeavesFileAndPreimageUntouched(t *testing.T) { + root := t.TempDir() + path := snapshotLifecycleFile(t, root, "file.txt", "before") + groupDir := filepath.Join(t.TempDir(), "group") + store := newCodeGroupStore(t, groupDir) + { + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "first"}) + testassert.NoError(t, err) + } + testassert.Require(t, soleSnapshotMeta(t, store).LastWrite != nil && soleSnapshotMeta(t, store).LastWrite.Hash == snapshotIdentityHash("first"), "seed LastWrite = %+v, want the first write's proof", soleSnapshotMeta(t, store).LastWrite) + + withSnapshotMetaPublishFailure(t, 1) + counts := &mutationIOCount{} + withMutationFileCounting(t, counts) + + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "second"}) + + testassert.Require(t, err != nil, "write whose pre-mutation proof clear fails succeeded, want refusal without mutation") + assertFileContent(t, path, "first") + last := soleSnapshotMeta(t, store).LastWrite + testassert.Require(t, last != nil && last.Hash == snapshotIdentityHash("first"), "LastWrite after proof-clear failure = %+v, want the prior proof unchanged", last) + assertEntryOriginal(t, groupDir, "before") + testassert.Require(t, counts.writes == 0 && counts.truncates == 0, "mutation file I/O = %+v, want no Write/Truncate after a failed proof clear", counts) +} + +func TestProvenPreMutationFailureRestoresPriorProof(t *testing.T) { + root := t.TempDir() + path := snapshotLifecycleFile(t, root, "file.txt", "before") + groupDir := filepath.Join(t.TempDir(), "group") + store := newCodeGroupStore(t, groupDir) + { + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "first"}) + testassert.NoError(t, err) + } + + _, err := execEdit(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "old_string": "missing", "new_string": "second"}) + + testassert.Require(t, err != nil, "edit with no match succeeded, want a pre-mutation failure") + assertFileContent(t, path, "first") + last := soleSnapshotMeta(t, store).LastWrite + testassert.Require(t, last != nil && last.Hash == snapshotIdentityHash("first"), "LastWrite after proven non-mutating failure = %+v, want the prior proof restored", last) + result := revertTestGroup(t, groupDir) + testassert.Require(t, len(result.Restored) == 1 && result.Restored[0] == path && len(result.Skipped) == 0, "restored = %v skipped = %+v, want the retained snapshot to restore %s", result.Restored, result.Skipped, path) + assertFileContent(t, path, "before") +} + +func TestFailedPriorProofRestoreKeepsNilProofAndSkipsLaterRevert(t *testing.T) { + root := t.TempDir() + path := snapshotLifecycleFile(t, root, "file.txt", "before") + groupDir := filepath.Join(t.TempDir(), "group") + store := newCodeGroupStore(t, groupDir) + { + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "first"}) + testassert.NoError(t, err) + } + + // The failing publication is the prior-proof restore: the pre-mutation + // proof clear that precedes it succeeds, the restore is the second meta + // publication of this call. + withSnapshotMetaPublishFailure(t, 2) + _, err := execEdit(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "old_string": "missing", "new_string": "second"}) + + testassert.Require(t, err != nil && strings.Contains(err.Error(), "restore snapshot proof"), "edit error = %v, want the failed proof restoration reported", err) + assertFileContent(t, path, "first") + testassert.Require(t, soleSnapshotMeta(t, store).LastWrite == nil, "LastWrite after failed proof restoration = %+v, want nil proof", soleSnapshotMeta(t, store).LastWrite) + assertEntryOriginal(t, groupDir, "before") + result := revertTestGroup(t, groupDir) + testassert.Require(t, len(result.Restored) == 0 && len(result.Skipped) == 1 && result.Skipped[0].Path == path, "restored = %v skipped = %+v, want the nil proof to skip %s", result.Restored, result.Skipped, path) + assertSnapshotEntryCount(t, groupDir, 1) + assertFileContent(t, path, "first") +} + +func TestFailedFinalIdentityRecordKeepsNilProofAndSkipsRevert(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "created.txt") + groupDir := filepath.Join(t.TempDir(), "group") + store := newCodeGroupStore(t, groupDir) + + // Publications under the seam: capture meta, pre-mutation proof clear, + // and the final identity record — only the final record fails. + withSnapshotMetaPublishFailure(t, 3) + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": path, "content": "created"}) + + testassert.Require(t, err != nil && strings.Contains(err.Error(), "record snapshot identity"), "write error = %v, want the failed final identity record", err) + assertFileContent(t, path, "created") + testassert.Require(t, soleSnapshotMeta(t, store).LastWrite == nil, "LastWrite after failed final record = %+v, want nil proof", soleSnapshotMeta(t, store).LastWrite) + assertSnapshotEntryCount(t, groupDir, 1) + result := revertTestGroup(t, groupDir) + testassert.Require(t, len(result.Restored) == 0 && len(result.Skipped) == 1 && result.Skipped[0].Path == path, "restored = %v skipped = %+v, want the nil proof to skip %s", result.Restored, result.Skipped, path) + assertFileContent(t, path, "created") +} + +// --- destination appeared during apply: refusal plus abandonment cleanup --- + +// appearedDestinationBytes are the outside-origin bytes that create the +// destination between plan validation and the apply-time open. +var appearedDestinationBytes = []byte("appeared") + +type injectedAbandonError string + +func (e injectedAbandonError) Error() string { return string(e) } + +var errInjectedAbandon = injectedAbandonError("injected snapshot abandonment failure") + +func TestAppearedDestinationWithFailedAbandonReportsRefusalAndCleanupFailure(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "created.txt") + groupDir := filepath.Join(t.TempDir(), "group") + store := newCodeGroupStore(t, groupDir) + { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Add File: created.txt\n+one")}) + testassert.NoError(t, err) + } + testassert.NoError(t, os.Remove(path)) + + wrapped := &recordingCodeGroupStore{testCodeGroupStore: store, plantBefore: path, failAbandon: true} + _, err := execPatch(t, root, "", "", CapabilityOptions{}, wrapped, map[string]any{"input": applyPatchInput(t, "*** Add File: created.txt\n+two")}) + + testassert.Require(t, err != nil, "Add whose destination appeared with failing abandonment succeeded, want refusal") + msg := err.Error() + testassert.Require(t, strings.Contains(msg, "appeared during apply"), "err = %v, want the destination-appeared refusal itself", err) + testassert.Require(t, strings.Contains(msg, "additionally failed to abandon snapshot"), "err = %v, want the abandonment failure reported alongside the refusal", err) + testassert.Require(t, strings.Contains(msg, errInjectedAbandon.Error()), "err = %v, want the injected cleanup failure visible", err) + testassert.Require(t, !strings.Contains(msg, "%!w()"), "err = %q, want the refusal wrapped around the cleanup failure, no %%!w()", msg) + testassert.Require(t, len(wrapped.discards) == 0, "discards = %v, want none: the reused entry is not discardable", wrapped.discards) + assertFileContent(t, path, string(appearedDestinationBytes)) + testassert.Require(t, soleSnapshotMeta(t, store).LastWrite == nil, "LastWrite after failed restoration = %+v, want the cleared nil proof preserved", soleSnapshotMeta(t, store).LastWrite) + assertSnapshotEntryCount(t, groupDir, 1) +} + +func TestAppearedDestinationWithCompletedAbandonKeepsEntryAndPriorProof(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "created.txt") + groupDir := filepath.Join(t.TempDir(), "group") + store := newCodeGroupStore(t, groupDir) + { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Add File: created.txt\n+one")}) + testassert.NoError(t, err) + } + testassert.NoError(t, os.Remove(path)) + + wrapped := &recordingCodeGroupStore{testCodeGroupStore: store, plantBefore: path} + _, err := execPatch(t, root, "", "", CapabilityOptions{}, wrapped, map[string]any{"input": applyPatchInput(t, "*** Add File: created.txt\n+two")}) + + testassert.Require(t, err != nil && strings.Contains(err.Error(), "appeared during apply"), "err = %v, want the destination-appeared refusal", err) + testassert.Require(t, !strings.Contains(err.Error(), "additionally failed"), "err = %v, want the completed abandonment reported without a cleanup failure", err) + testassert.Require(t, !strings.Contains(err.Error(), "%!w()"), "err = %q, want no %%!w()", err.Error()) + testassert.Require(t, len(wrapped.discards) == 0, "discards = %v, want none: the reused entry survives", wrapped.discards) + assertFileContent(t, path, string(appearedDestinationBytes)) + last := soleSnapshotMeta(t, store).LastWrite + testassert.Require(t, last != nil && last.Hash == snapshotIdentityHash("one"), "LastWrite after refusal = %+v, want the existing entry's prior proof restored", last) + assertSnapshotEntryCount(t, groupDir, 1) +} + +// --- post-capture rebinding refusals for every mutation producer --- + +func rebindingLifecycleFixture(t *testing.T) (root, link, real1, real2 string) { + t.Helper() + root = t.TempDir() + real1 = filepath.Join(root, "real1.txt") + real2 = filepath.Join(root, "real2.txt") + for _, p := range []string{real1, real2} { + testassert.NoError(t, os.WriteFile(p, []byte("before"), 0o644)) + } + link = filepath.Join(root, "link.txt") + testassert.NoError(t, os.Symlink(real1, link)) + return root, link, real1, real2 +} + +func rebindLink(t *testing.T, link, target string) { + t.Helper() + testassert.NoError(t, os.Remove(link)) + testassert.NoError(t, os.Symlink(target, link)) +} + +// TestMutationToolsRefuseRebindingDuringSnapshotCapture threads every patch +// mutation (Update, Add, Delete, Move destination, Move source) and the +// write/edit producers through target, Workspace-root, and write-dir rebinding +// flips that land AFTER the real snapshot capture (the wrapper's onSnapshot +// seam): the post-capture fail-closed revalidators must refuse, leave BOTH +// possible targets unmutated, and abandon the refused capture on disk — a +// freshly created capture is discarded; a reused entry survives with its prior +// proof restored (shared abandonment rule, observed by the two reused rows). +// Move-source refusals must keep the already completed destination and report +// the partial work. +func TestMutationToolsRefuseRebindingDuringSnapshotCapture(t *testing.T) { + refusalFlip := func(store *recordingCodeGroupStore, at int, flip func()) { + store.onSnapshot = func(call int) { + if call == at { + flip() + } + } + } + assertPartialCommitted := func(t *testing.T, err error, committed string) { + t.Helper() + var exitErr *ExitError + testassert.Require(t, errors.As(err, &exitErr) && strings.Contains(exitErr.Output, committed), "Execute err = %v, want partial work reporting %s", err, committed) + } + assertPlantedSymlink := func(t *testing.T, path string) { + t.Helper() + info, err := os.Lstat(path) + testassert.Require(t, err == nil && info.Mode()&os.ModeSymlink != 0, "%s = %v %v, want the planted symlink, never a written regular file", path, info, err) + } + // A refused capture created by this call must be discarded on disk with + // zero recorded identity for the refused operation. + assertFreshCaptureAbandoned := func(t *testing.T, store *recordingCodeGroupStore, canonical string) { + t.Helper() + testassert.Require(t, len(store.discards) == 1, "discards = %v, want exactly one discard for the refused capture of %s", store.discards, canonical) + assertEntryDiscarded(t, store, canonical) + testassert.Require(t, len(store.identityRecords) == 0, "identity records = %+v, want none for the refused operation", store.identityRecords) + } + // A refused capture that reused this group's existing entry must leave the + // entry and its preimage on disk with the prior proof restored, and + // discard nothing. + assertReusedCapturePreserved := func(t *testing.T, store *recordingCodeGroupStore, canonical, priorProof string) { + t.Helper() + testassert.Require(t, len(store.discards) == 0, "discards = %v, want none: a reused entry is never discarded", store.discards) + assertEntryPresent(t, store, canonical) + last := metaForCanonical(t, store.testCodeGroupStore, canonical).LastWrite + testassert.Require(t, last != nil && last.Hash == snapshotIdentityHash(priorProof), "LastWrite after refusal = %+v, want the prior proof %q restored", last, priorProof) + } + + type rebindingCase struct { + name string + build func(t *testing.T, store *recordingCodeGroupStore) (run func() error, expect string, assertOutcome func(*testing.T, error)) + } + cases := []rebindingCase{ + { + name: "write_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + root, link, real1, real2 := rebindingLifecycleFixture(t) + refusalFlip(store, 1, func() { rebindLink(t, link, real2) }) + run := func() error { + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": link, "content": "after"}) + return err + } + assert := func(t *testing.T, _ error) { + assertFileContent(t, real1, "before") + assertFileContent(t, real2, "before") + assertFreshCaptureAbandoned(t, store, real1) + } + return run, "approved canonical path changed", assert + }, + }, + { + // Reused-entry sibling of write_target: the seed write already + // created the group's entry for real1, so the refused capture + // reuses it — the entry must survive with the prior proof + // restored instead of being discarded. + name: "write_reused_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + root, link, real1, real2 := rebindingLifecycleFixture(t) + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": link, "content": "first"}) + testassert.NoError(t, err) + refusalFlip(store, 2, func() { rebindLink(t, link, real2) }) + run := func() error { + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": link, "content": "after"}) + return err + } + assert := func(t *testing.T, _ error) { + assertFileContent(t, real1, "first") + assertFileContent(t, real2, "before") + assertReusedCapturePreserved(t, store, real1, "first") + assertSnapshotIdentityContent(t, store.identityRecords, store.entryIDFor(real1), "first") + } + return run, "approved canonical path changed", assert + }, + }, + { + name: "edit_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + root, link, real1, real2 := rebindingLifecycleFixture(t) + refusalFlip(store, 1, func() { rebindLink(t, link, real2) }) + run := func() error { + _, err := execEdit(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": link, "old_string": "before", "new_string": "after"}) + return err + } + assert := func(t *testing.T, _ error) { + assertFileContent(t, real1, "before") + assertFileContent(t, real2, "before") + assertFreshCaptureAbandoned(t, store, real1) + } + return run, "approved canonical path changed", assert + }, + }, + { + name: "patch_update_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + root, link, real1, real2 := rebindingLifecycleFixture(t) + refusalFlip(store, 1, func() { rebindLink(t, link, real2) }) + run := func() error { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Update File: link.txt\n@@\n-before\n+after")}) + return err + } + assert := func(t *testing.T, _ error) { + assertFileContent(t, real1, "before") + assertFileContent(t, real2, "before") + assertFreshCaptureAbandoned(t, store, real1) + } + return run, "approved canonical path changed", assert + }, + }, + { + name: "patch_add_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + base := t.TempDir() + approved := filepath.Join(base, "link.txt") // approved canonical: the absent leaf itself + replacement := filepath.Join(base, "replacement.txt") + testassert.NoError(t, os.WriteFile(replacement, []byte("secret"), 0o644)) + refusalFlip(store, 1, func() { testassert.NoError(t, os.Symlink(replacement, approved)) }) + run := func() error { + _, err := execPatch(t, base, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Add File: link.txt\n+after")}) + return err + } + assert := func(t *testing.T, _ error) { + assertFileContent(t, replacement, "secret") + assertPlantedSymlink(t, approved) + assertFreshCaptureAbandoned(t, store, approved) + } + return run, "approved canonical path changed", assert + }, + }, + { + name: "patch_delete_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + root, link, real1, real2 := rebindingLifecycleFixture(t) + refusalFlip(store, 1, func() { rebindLink(t, link, real2) }) + run := func() error { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Delete File: link.txt")}) + return err + } + assert := func(t *testing.T, _ error) { + assertFileContent(t, real1, "before") + assertFileContent(t, real2, "before") + assertFreshCaptureAbandoned(t, store, real1) + } + return run, "approved canonical path changed", assert + }, + }, + { + name: "patch_move_dst_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + base := t.TempDir() + src := filepath.Join(base, "src.txt") + testassert.NoError(t, os.WriteFile(src, []byte("old content"), 0o644)) + approved := filepath.Join(base, "new.txt") // approved destination: the absent leaf itself + replacement := filepath.Join(base, "replacement.txt") + testassert.NoError(t, os.WriteFile(replacement, []byte("secret"), 0o644)) + refusalFlip(store, 1, func() { testassert.NoError(t, os.Symlink(replacement, approved)) }) + run := func() error { + _, err := execPatch(t, base, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Update File: src.txt\n*** Move to: new.txt\n@@\n-old content\n+NEW")}) + return err + } + assert := func(t *testing.T, _ error) { + assertFileContent(t, src, "old content") + assertFileContent(t, replacement, "secret") + assertPlantedSymlink(t, approved) + assertFreshCaptureAbandoned(t, store, approved) + } + return run, "approved canonical path changed", assert + }, + }, + { + name: "patch_move_src_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + root, link, real1, real2 := rebindingLifecycleFixture(t) + dst := filepath.Join(root, "new.txt") + refusalFlip(store, 2, func() { rebindLink(t, link, real2) }) + run := func() error { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Update File: link.txt\n*** Move to: new.txt\n@@\n-before\n+after")}) + return err + } + assert := func(t *testing.T, err error) { + assertFileContent(t, dst, "after") + assertFileContent(t, real1, "before") + assertFileContent(t, real2, "before") + assertPartialCommitted(t, err, "M new.txt") + testassert.Require(t, len(store.discards) == 1, "discards = %v, want exactly one discard: the refused source capture", store.discards) + assertEntryPresent(t, store, dst) + assertEntryDiscarded(t, store, real1) + testassert.Require(t, len(store.identityRecords) == 1, "identity records = %+v, want only the completed destination record", store.identityRecords) + assertSnapshotIdentityContent(t, store.identityRecords, store.entryIDFor(dst), "after") + } + return run, "approved canonical path changed", assert + }, + }, + { + // Reused-entry sibling of patch_move_src_target: the seed write + // created the group's entry for real1, so the refused source + // capture reuses it — the entry must survive with the prior + // proof restored while the completed destination stays recorded. + name: "patch_move_src_reused_target", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + root, link, real1, real2 := rebindingLifecycleFixture(t) + dst := filepath.Join(root, "new.txt") + _, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{"path": link, "content": "first"}) + testassert.NoError(t, err) + refusalFlip(store, 3, func() { rebindLink(t, link, real2) }) + run := func() error { + _, err := execPatch(t, root, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, "*** Update File: link.txt\n*** Move to: new.txt\n@@\n-first\n+after")}) + return err + } + assert := func(t *testing.T, err error) { + assertFileContent(t, dst, "after") + assertFileContent(t, real1, "first") + assertFileContent(t, real2, "before") + assertPartialCommitted(t, err, "M new.txt") + assertReusedCapturePreserved(t, store, real1, "first") + assertEntryPresent(t, store, dst) + testassert.Require(t, len(store.identityRecords) == 2, "identity records = %+v, want the seed write plus the completed destination", store.identityRecords) + assertSnapshotIdentityContent(t, store.identityRecords, store.entryIDFor(dst), "after") + } + return run, "approved canonical path changed", assert + }, + }, + } + + // Workspace-root rebinding rows. + for _, op := range []struct { + name string + seed func(t *testing.T, realA, realB string) + input func() string + flipCall int + assert func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, err error) + }{ + { + name: "patch_update", seed: func(t *testing.T, realA, realB string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "file.txt"), []byte("hi"), 0o644)) + testassert.NoError(t, os.WriteFile(filepath.Join(realB, "file.txt"), []byte("secret"), 0o644)) + }, + input: func() string { return "*** Update File: file.txt\n@@\n-hi\n+after" }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertFileContent(t, filepath.Join(realA, "file.txt"), "hi") + assertFileContent(t, filepath.Join(realB, "file.txt"), "secret") + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "file.txt")) + }, + }, + { + name: "patch_add", seed: func(*testing.T, string, string) {}, + input: func() string { return "*** Add File: new.txt\n+after" }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertPathMissing(t, filepath.Join(realA, "new.txt")) + assertPathMissing(t, filepath.Join(realB, "new.txt")) + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "new.txt")) + }, + }, + { + name: "patch_delete", seed: func(t *testing.T, realA, realB string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "gone.txt"), []byte("doomed"), 0o644)) + testassert.NoError(t, os.WriteFile(filepath.Join(realB, "gone.txt"), []byte("secret"), 0o644)) + }, + input: func() string { return "*** Delete File: gone.txt" }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertFileContent(t, filepath.Join(realA, "gone.txt"), "doomed") + assertFileContent(t, filepath.Join(realB, "gone.txt"), "secret") + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "gone.txt")) + }, + }, + { + name: "patch_move_dst", seed: func(t *testing.T, realA, _ string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "src.txt"), []byte("old content"), 0o644)) + }, + input: func() string { return "*** Update File: src.txt\n*** Move to: new.txt\n@@\n-old content\n+NEW" }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertFileContent(t, filepath.Join(realA, "src.txt"), "old content") + assertPathMissing(t, filepath.Join(realA, "new.txt")) + assertPathMissing(t, filepath.Join(realB, "new.txt")) + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "new.txt")) + }, + }, + { + name: "patch_move_src", seed: func(t *testing.T, realA, _ string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "old.txt"), []byte("before"), 0o644)) + }, + input: func() string { return "*** Update File: old.txt\n*** Move to: new.txt\n@@\n-before\n+after" }, + flipCall: 2, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, err error) { + assertFileContent(t, filepath.Join(realA, "new.txt"), "after") + assertFileContent(t, filepath.Join(realA, "old.txt"), "before") + assertPathMissing(t, filepath.Join(realB, "old.txt")) + assertPartialCommitted(t, err, "M new.txt") + testassert.Require(t, len(store.discards) == 1, "discards = %v, want exactly one discard: the refused source capture", store.discards) + assertEntryPresent(t, store, filepath.Join(realA, "new.txt")) + assertEntryDiscarded(t, store, filepath.Join(realA, "old.txt")) + testassert.Require(t, len(store.identityRecords) == 1, "identity records = %+v, want only the completed destination record", store.identityRecords) + }, + }, + } { + cases = append(cases, rebindingCase{ + name: op.name + "_root", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + base := t.TempDir() + realA := filepath.Join(base, "realA") + realB := filepath.Join(base, "realB") + for _, dir := range []string{realA, realB} { + testassert.NoError(t, os.Mkdir(dir, 0o755)) + } + op.seed(t, realA, realB) + rootLink := filepath.Join(base, "root") + testassert.NoError(t, os.Symlink(realA, rootLink)) + refusalFlip(store, op.flipCall, func() { rebindLink(t, rootLink, realB) }) + run := func() error { + _, err := execPatch(t, rootLink, "", "", CapabilityOptions{}, store, map[string]any{"input": applyPatchInput(t, op.input())}) + return err + } + assert := func(tt *testing.T, err error) { op.assert(tt, store, realA, realB, err) } + return run, "approved canonical workspace root changed", assert + }, + }) + } + + // Write/edit producers revalidated against a flipped Workspace root. + for _, tool := range []struct { + name string + run func(t *testing.T, store *recordingCodeGroupStore, rootLink string) error + }{ + {name: "write", run: func(t *testing.T, store *recordingCodeGroupStore, rootLink string) error { + _, err := execWrite(t, rootLink, "", "", CapabilityOptions{}, store, map[string]any{"path": "file.txt", "content": "after"}) + return err + }}, + {name: "edit", run: func(t *testing.T, store *recordingCodeGroupStore, rootLink string) error { + _, err := execEdit(t, rootLink, "", "", CapabilityOptions{}, store, map[string]any{"path": "file.txt", "old_string": "hi", "new_string": "after"}) + return err + }}, + } { + cases = append(cases, rebindingCase{ + name: tool.name + "_root", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + base := t.TempDir() + realA := filepath.Join(base, "realA") + realB := filepath.Join(base, "realB") + for _, dir := range []string{realA, realB} { + testassert.NoError(t, os.Mkdir(dir, 0o755)) + } + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "file.txt"), []byte("hi"), 0o644)) + testassert.NoError(t, os.WriteFile(filepath.Join(realB, "file.txt"), []byte("secret"), 0o644)) + rootLink := filepath.Join(base, "root") + testassert.NoError(t, os.Symlink(realA, rootLink)) + refusalFlip(store, 1, func() { rebindLink(t, rootLink, realB) }) + run := func() error { return tool.run(t, store, rootLink) } + assert := func(t *testing.T, _ error) { + assertFileContent(t, filepath.Join(realA, "file.txt"), "hi") + assertFileContent(t, filepath.Join(realB, "file.txt"), "secret") + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "file.txt")) + } + return run, "approved canonical workspace root changed", assert + }, + }) + } + + // Write-dir rebinding rows. + for _, op := range []struct { + name string + seed func(t *testing.T, realA, realB string) + input func(realA string) string + flipCall int + assert func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, err error) + }{ + { + name: "patch_update", seed: func(t *testing.T, realA, realB string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "file.txt"), []byte("hi"), 0o644)) + testassert.NoError(t, os.WriteFile(filepath.Join(realB, "file.txt"), []byte("secret"), 0o644)) + }, + input: func(realA string) string { + return "*** Update File: " + filepath.Join(realA, "file.txt") + "\n@@\n-hi\n+after" + }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertFileContent(t, filepath.Join(realA, "file.txt"), "hi") + assertFileContent(t, filepath.Join(realB, "file.txt"), "secret") + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "file.txt")) + }, + }, + { + name: "patch_add", seed: func(*testing.T, string, string) {}, + input: func(realA string) string { return "*** Add File: " + filepath.Join(realA, "new.txt") + "\n+after" }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertPathMissing(t, filepath.Join(realA, "new.txt")) + assertPathMissing(t, filepath.Join(realB, "new.txt")) + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "new.txt")) + }, + }, + { + name: "patch_delete", seed: func(t *testing.T, realA, realB string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "gone.txt"), []byte("doomed"), 0o644)) + testassert.NoError(t, os.WriteFile(filepath.Join(realB, "gone.txt"), []byte("secret"), 0o644)) + }, + input: func(realA string) string { return "*** Delete File: " + filepath.Join(realA, "gone.txt") }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertFileContent(t, filepath.Join(realA, "gone.txt"), "doomed") + assertFileContent(t, filepath.Join(realB, "gone.txt"), "secret") + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "gone.txt")) + }, + }, + { + name: "patch_move_dst", seed: func(t *testing.T, realA, _ string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "src.txt"), []byte("old content"), 0o644)) + }, + input: func(realA string) string { + return "*** Update File: " + filepath.Join(realA, "src.txt") + "\n*** Move to: " + filepath.Join(realA, "new.txt") + "\n@@\n-old content\n+NEW" + }, + flipCall: 1, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, _ error) { + assertFileContent(t, filepath.Join(realA, "src.txt"), "old content") + assertPathMissing(t, filepath.Join(realA, "new.txt")) + assertPathMissing(t, filepath.Join(realB, "new.txt")) + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "new.txt")) + }, + }, + { + name: "patch_move_src", seed: func(t *testing.T, realA, realB string) { + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "old.txt"), []byte("before"), 0o644)) + testassert.NoError(t, os.WriteFile(filepath.Join(realB, "old.txt"), []byte("secret"), 0o644)) + }, + input: func(realA string) string { + return "*** Update File: " + filepath.Join(realA, "old.txt") + "\n*** Move to: " + filepath.Join(realA, "new.txt") + "\n@@\n-before\n+after" + }, + flipCall: 2, + assert: func(t *testing.T, store *recordingCodeGroupStore, realA, realB string, err error) { + assertFileContent(t, filepath.Join(realA, "new.txt"), "after") + assertFileContent(t, filepath.Join(realA, "old.txt"), "before") + assertFileContent(t, filepath.Join(realB, "old.txt"), "secret") + assertPartialCommitted(t, err, "M "+filepath.Join(realA, "new.txt")) + testassert.Require(t, len(store.discards) == 1, "discards = %v, want exactly one discard: the refused source capture", store.discards) + assertEntryPresent(t, store, filepath.Join(realA, "new.txt")) + assertEntryDiscarded(t, store, filepath.Join(realA, "old.txt")) + testassert.Require(t, len(store.identityRecords) == 1, "identity records = %+v, want only the completed destination record", store.identityRecords) + }, + }, + } { + cases = append(cases, rebindingCase{ + name: op.name + "_write_dir", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + base := t.TempDir() + realA := filepath.Join(base, "realA") + realB := filepath.Join(base, "realB") + for _, dir := range []string{realA, realB} { + testassert.NoError(t, os.Mkdir(dir, 0o755)) + } + wd := filepath.Join(base, "wd") + testassert.NoError(t, os.Symlink(realA, wd)) + op.seed(t, realA, realB) + wdCanonical, err := bindWriteDir(base, "apply_patch", wd) + testassert.NoError(t, err) + refusalFlip(store, op.flipCall, func() { rebindLink(t, wd, realB) }) + run := func() error { + _, err := execPatch(t, base, "", wdCanonical, CapabilityOptions{WriteDir: wd}, store, map[string]any{"input": applyPatchInput(t, op.input(realA))}) + return err + } + assert := func(tt *testing.T, err error) { op.assert(tt, store, realA, realB, err) } + return run, "approved canonical write_dir changed", assert + }, + }) + } + + // Write/edit producers revalidated against a flipped write-dir witness. + for _, tool := range []struct { + name string + run func(t *testing.T, store *recordingCodeGroupStore, base, realA, wd string) error + }{ + {name: "write", run: func(t *testing.T, store *recordingCodeGroupStore, base, realA, wd string) error { + _, err := execWrite(t, base, "", "", CapabilityOptions{WriteDir: wd}, store, map[string]any{"path": filepath.Join(realA, "file.txt"), "content": "after"}) + return err + }}, + {name: "edit", run: func(t *testing.T, store *recordingCodeGroupStore, base, realA, wd string) error { + _, err := execEdit(t, base, "", "", CapabilityOptions{WriteDir: wd}, store, map[string]any{"path": filepath.Join(realA, "file.txt"), "old_string": "hi", "new_string": "after"}) + return err + }}, + } { + cases = append(cases, rebindingCase{ + name: tool.name + "_write_dir", + build: func(t *testing.T, store *recordingCodeGroupStore) (func() error, string, func(*testing.T, error)) { + base := t.TempDir() + realA := filepath.Join(base, "realA") + realB := filepath.Join(base, "realB") + for _, dir := range []string{realA, realB} { + testassert.NoError(t, os.Mkdir(dir, 0o755)) + } + wd := filepath.Join(base, "wd") + testassert.NoError(t, os.Symlink(realA, wd)) + testassert.NoError(t, os.WriteFile(filepath.Join(realA, "file.txt"), []byte("hi"), 0o644)) + testassert.NoError(t, os.WriteFile(filepath.Join(realB, "file.txt"), []byte("secret"), 0o644)) + refusalFlip(store, 1, func() { rebindLink(t, wd, realB) }) + run := func() error { return tool.run(t, store, base, realA, wd) } + assert := func(t *testing.T, _ error) { + assertFileContent(t, filepath.Join(realA, "file.txt"), "hi") + assertFileContent(t, filepath.Join(realB, "file.txt"), "secret") + assertFreshCaptureAbandoned(t, store, filepath.Join(realA, "file.txt")) + } + return run, "approved canonical write_dir changed", assert + }, + }) + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + store := newRecordingCodeGroupStore(t) + run, expect, assertOutcome := tc.build(t, store) + err := run() + testassert.Require(t, err != nil && strings.Contains(err.Error(), expect), "Execute error = %v, want %q refusal", err, expect) + assertOutcome(t, err) + }) + } +} diff --git a/internal/tool/workspace_root_test.go b/internal/tool/workspace_root_test.go index 318e57a4..66c45b3c 100644 --- a/internal/tool/workspace_root_test.go +++ b/internal/tool/workspace_root_test.go @@ -24,7 +24,7 @@ func TestFileToolsResolveRelativePathsFromWorkspaceRoot(t *testing.T) { testassert.Require(t, strings.Contains(out, "old"), "read output = %q, want root file content", out) { - _, err := execEdit(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execEdit(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": "existing.txt", "old_string": "old", "new_string": "new", @@ -37,7 +37,7 @@ func TestFileToolsResolveRelativePathsFromWorkspaceRoot(t *testing.T) { } { - _, err := execWrite(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{"path": "created.txt", "content": "created"}) + _, err := execWrite(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{"path": "created.txt", "content": "created"}) testassert.NoError(t, err) } { diff --git a/internal/tool/write_file.go b/internal/tool/write_file.go index afb1f2da..0ad89e47 100644 --- a/internal/tool/write_file.go +++ b/internal/tool/write_file.go @@ -6,32 +6,18 @@ import ( "os" "github.com/MMinasyan/lightcode/internal/safefs" + "github.com/MMinasyan/lightcode/internal/snapshot" ) -// SnapshotStore is the minimum surface the prepared write, edit and patch -// mutations need from the snapshot store. +// SnapshotStore is the complete internal snapshot surface the prepared write, +// edit and patch mutations require. It is mandatory: mutation never runs +// against a missing or partial store. type SnapshotStore interface { - Snapshot(turn int, absPath string) error - CurrentTurn() int -} - -type resolvedSnapshotStore interface { - SnapshotResolved(turn int, originalPath, canonicalPath string) error -} - -type transactionalSnapshotStore interface { - SnapshotResolvedEntry(turn int, originalPath, canonicalPath string) (entryID string, created bool, err error) - DiscardSnapshotEntry(turn int, entryID string) error - RetainSnapshotEntry(turn int, entryID string) -} - -type snapshotIdentityRecorder interface { - RecordSnapshotContent(turn int, entryID string, content []byte) error - RecordSnapshotAbsence(turn int, entryID string) error -} - -type snapshotMutationLocker interface { - LockSnapshotMutation(turn int, entryID string) (func(), error) + SnapshotResolvedEntry(originalPath, canonicalPath string) (entryID string, created bool, err error) + DiscardSnapshotEntry(entryID string) error + RecordSnapshotContent(entryID string, content []byte) error + RecordSnapshotAbsence(entryID string) error + ReplaceSnapshotIdentity(entryID string, identity *snapshot.SnapshotContentIdentity) (*snapshot.SnapshotContentIdentity, error) } type mutationFile interface { @@ -51,138 +37,142 @@ var ( } ) +// snapshotEntry is one captured mutation snapshot: the entry id, whether this +// call created the preimage, and the LastWrite proof the entry owned before +// the pre-mutation clear. type snapshotEntry struct { - turn int + store SnapshotStore entryID string - store transactionalSnapshotStore - release func() + created bool + prior *snapshot.SnapshotContentIdentity } -func snapshotFile(store SnapshotStore, turn int, originalPath, canonicalPath string) error { - if resolved, ok := store.(resolvedSnapshotStore); ok { - return resolved.SnapshotResolved(turn, originalPath, canonicalPath) +// captureSnapshotEntry captures the preimage and persists the cleared +// LastWrite proof under the existing execution/artifact ownership. A failed +// clear abandons the capture (a newly created preimage is discarded) and the +// caller must not mutate. +func captureSnapshotEntry(store SnapshotStore, originalPath, canonicalPath string) (snapshotEntry, error) { + entryID, created, err := store.SnapshotResolvedEntry(originalPath, canonicalPath) + if err != nil { + return snapshotEntry{}, err } - return store.Snapshot(turn, canonicalPath) -} - -func snapshotFileForMutation(store SnapshotStore, turn int, originalPath, canonicalPath string) (snapshotEntry, error) { - if transactional, ok := store.(transactionalSnapshotStore); ok { - entryID, _, err := transactional.SnapshotResolvedEntry(turn, originalPath, canonicalPath) - if err != nil { - return snapshotEntry{}, err - } - entry := snapshotEntry{turn: turn, entryID: entryID, store: transactional} - if locker, ok := store.(snapshotMutationLocker); ok { - release, err := locker.LockSnapshotMutation(turn, entryID) - if err != nil { - if discardErr := transactional.DiscardSnapshotEntry(turn, entryID); discardErr != nil { - return snapshotEntry{}, fmt.Errorf("%w; additionally failed to discard snapshot: %v", err, discardErr) - } - return snapshotEntry{}, err + prior, err := store.ReplaceSnapshotIdentity(entryID, nil) + if err != nil { + if created { + if discardErr := store.DiscardSnapshotEntry(entryID); discardErr != nil { + return snapshotEntry{}, fmt.Errorf("%w; additionally failed to discard snapshot: %v", err, discardErr) } - entry.release = release } - return entry, nil + return snapshotEntry{}, err } - return snapshotEntry{}, snapshotFile(store, turn, originalPath, canonicalPath) + return snapshotEntry{store: store, entryID: entryID, created: created, prior: prior}, nil } -func releaseSnapshotMutation(entry snapshotEntry) { - if entry.release != nil { - entry.release() +// abandonUnmutatedSnapshot resolves a proven non-mutation failure: this call's +// newly created preimage is discarded, and an existing entry gets its owned +// prior proof restored. A failed restoration leaves the cleared nil proof, +// reports its error, and preserves the preimage for a later restore skip. +func abandonUnmutatedSnapshot(entry snapshotEntry) error { + if entry.created { + return entry.store.DiscardSnapshotEntry(entry.entryID) } -} - -func discardUnmutatedSnapshot(entry snapshotEntry) error { - if entry.store == nil || entry.entryID == "" { + if entry.prior == nil { return nil } - return entry.store.DiscardSnapshotEntry(entry.turn, entry.entryID) -} - -func retainMutatedSnapshot(entry snapshotEntry) { - if entry.store == nil || entry.entryID == "" { - return + if _, err := entry.store.ReplaceSnapshotIdentity(entry.entryID, entry.prior); err != nil { + return fmt.Errorf("restore snapshot proof: %w", err) } - entry.store.RetainSnapshotEntry(entry.turn, entry.entryID) + return nil } -func recordMutatedSnapshotContent(entry snapshotEntry, content []byte) error { - if entry.store == nil || entry.entryID == "" { - return nil +// writeReplacement performs the truncate/seek/write/sync content replacement +// and reports, independently of the returned error, whether the intended +// bytes are known to have been fully produced: only a byte count that covers +// the whole content claims a complete identity. A truncate, seek, or short +// write leaves the produced state uncertain. +func writeReplacement(f mutationFile, content []byte) (complete bool, err error) { + if err := f.Truncate(0); err != nil { + return false, fmt.Errorf("truncate: %w", err) } - recorder, ok := entry.store.(snapshotIdentityRecorder) - if !ok { - return nil + if _, err := f.Seek(0, io.SeekStart); err != nil { + return false, fmt.Errorf("seek: %w", err) } - return recorder.RecordSnapshotContent(entry.turn, entry.entryID, content) -} - -func recordMutatedSnapshotAbsence(entry snapshotEntry) error { - if entry.store == nil || entry.entryID == "" { - return nil + n, writeErr := f.Write(content) + if n != len(content) { + if writeErr != nil { + return false, fmt.Errorf("write: incomplete %d of %d bytes: %w", n, len(content), writeErr) + } + return false, fmt.Errorf("write: incomplete %d of %d bytes", n, len(content)) } - recorder, ok := entry.store.(snapshotIdentityRecorder) - if !ok { - return nil + if writeErr != nil { + return true, fmt.Errorf("write: %w", writeErr) + } + if err := f.Sync(); err != nil { + return true, fmt.Errorf("sync: %w", err) } - return recorder.RecordSnapshotAbsence(entry.turn, entry.entryID) + return true, nil } -func recordCurrentSnapshotContent(entry snapshotEntry, path string) error { - if entry.store == nil || entry.entryID == "" { - return nil - } - f, err := safefs.OpenExisting(path, os.O_RDONLY) - if err != nil { +// finishMutationFailure resolves a mutation error against the snapshot entry: +// a proven non-mutation abandons the entry; a mutation whose complete +// produced content is known records that producer-owned identity; an +// uncertain started mutation retains the preimage with the cleared nil +// identity. No post-error filesystem reread establishes output. +func finishMutationFailure(entry snapshotEntry, started, complete bool, produced []byte, err error) error { + if !started { + if abandonErr := abandonUnmutatedSnapshot(entry); abandonErr != nil { + return fmt.Errorf("%w; additionally failed to abandon snapshot: %v", err, abandonErr) + } return err } - defer f.Close() - content, err := io.ReadAll(f) - if err != nil { - return err + if complete { + if recordErr := entry.store.RecordSnapshotContent(entry.entryID, produced); recordErr != nil { + return fmt.Errorf("%w; additionally failed to record snapshot identity: %v", err, recordErr) + } } - return recordMutatedSnapshotContent(entry, content) + return err } -func retainFailedMutatedSnapshot(entry snapshotEntry, path string, err error) error { - if recordErr := recordCurrentSnapshotContent(entry, path); recordErr != nil { - retainMutatedSnapshot(entry) - return fmt.Errorf("%w; additionally failed to record snapshot identity: %v", err, recordErr) - } - retainMutatedSnapshot(entry) - return err +// recordProducedSnapshotContent records the identity of content the producer +// completed. A failure leaves the cleared nil proof and reports its error; +// the retained preimage serves a later restore skip. +func recordProducedSnapshotContent(entry snapshotEntry, content []byte) error { + return entry.store.RecordSnapshotContent(entry.entryID, content) +} + +func recordProducedSnapshotAbsence(entry snapshotEntry) error { + return entry.store.RecordSnapshotAbsence(entry.entryID) } // openWriteTargetForMutation opens the canonical write target through the // shared safefs primitives. -func openWriteTargetForMutation(absPath string) (mutationFile, bool, bool, error) { +func openWriteTargetForMutation(absPath string) (mutationFile, bool, error) { return openWriteTargetForMutationFunc(absPath) } -func openWriteTargetForMutationReal(absPath string) (mutationFile, bool, bool, error) { +func openWriteTargetForMutationReal(absPath string) (mutationFile, bool, error) { _, err := ensureRegularExistingTarget(absPath) if err != nil { - return nil, false, false, err + return nil, false, err } f, openedExisting, err := safefs.OpenForWrite(absPath, 0o644) if err != nil { - return nil, false, false, err + return nil, false, err } if openedExisting { info, err := f.Stat() if err != nil { _ = f.Close() - return nil, false, false, fmt.Errorf("stat: %w", err) + return nil, false, fmt.Errorf("stat: %w", err) } if err := ensureRegularFileInfo(absPath, info); err != nil { _ = f.Close() - return nil, false, false, err + return nil, false, err } } - return f, openedExisting, !openedExisting, nil + return f, openedExisting, nil } func preflightWriteSnapshotTarget(absPath string) error { diff --git a/internal/tool/write_file_test.go b/internal/tool/write_file_test.go index 04dfdf4b..e5ab6f10 100644 --- a/internal/tool/write_file_test.go +++ b/internal/tool/write_file_test.go @@ -35,7 +35,7 @@ func TestPreparedWriteCreatesNewFileAndParentDirs(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "nested", "new.txt") - result, err := execWrite(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + result, err := execWrite(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "content": "created", }) @@ -49,7 +49,7 @@ func TestPreparedWriteWritesContentExactlyIncludingTrailingNewline(t *testing.T) path := filepath.Join(root, "exact.txt") { - _, err := execWrite(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execWrite(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "content": "no trailing newline", }) @@ -58,7 +58,7 @@ func TestPreparedWriteWritesContentExactlyIncludingTrailingNewline(t *testing.T) assertFileContent(t, path, "no trailing newline") { - _, err := execWrite(t, root, "", "", CapabilityOptions{}, &applyPatchStore{turn: 1}, map[string]any{ + _, err := execWrite(t, root, "", "", CapabilityOptions{}, newTempCodeGroupStore(t), map[string]any{ "path": path, "content": "with trailing newline\n", }) @@ -70,7 +70,7 @@ func TestPreparedWriteWritesContentExactlyIncludingTrailingNewline(t *testing.T) func TestPreparedWriteWithSnapshotSnapshotsBeforeWrite(t *testing.T) { path := readFileTestFile(t, "file.txt", "before") root := filepath.Dir(path) - store := &recordingSnapshotStore{turn: 9, before: map[string]string{}} + store := &recordingCodeGroupStore{testCodeGroupStore: newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"))} result, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{ "path": path, @@ -79,8 +79,8 @@ func TestPreparedWriteWithSnapshotSnapshotsBeforeWrite(t *testing.T) { testassert.NoError(t, err) testassert.Equal(t, result, "Wrote "+path+".") testassert.Equal(t, len(store.calls), 1) - testassert.Require(t, store.calls[0].turn == 9 && store.calls[0].path == path, "snapshot call = %+v, want turn=9 path=%q", store.calls[0], path) - testassert.Require(t, store.before[path] == "before", "snapshot saw %q, want pre-write content", store.before[path]) + testassert.Require(t, store.calls[0].path == path, "snapshot call = %+v, want path=%q", store.calls[0], path) + assertEntryPreimage(t, store, path, "before") assertFileContent(t, path, "after") } @@ -92,7 +92,7 @@ func TestPreparedWriteWithSnapshotUsesCanonicalTargetAndRequestedResult(t *testi linkDir := filepath.Join(root, "linked") testassert.NoError(t, os.Symlink(realDir, linkDir)) requested := filepath.Join(linkDir, "file.txt") - store := &recordingSnapshotStore{turn: 10, before: map[string]string{}} + store := &recordingCodeGroupStore{testCodeGroupStore: newCodeGroupStore(t, filepath.Join(t.TempDir(), "group"))} result, err := execWrite(t, root, "", "", CapabilityOptions{}, store, map[string]any{ "path": requested, @@ -103,6 +103,6 @@ func TestPreparedWriteWithSnapshotUsesCanonicalTargetAndRequestedResult(t *testi testassert.Equal(t, result, "Wrote "+requested+".") testassert.Equal(t, len(store.calls), 1) testassert.Require(t, store.calls[0].path == requested && store.calls[0].canonical == target, "snapshot call = %+v, want original=%q canonical=%q", store.calls[0], requested, target) - testassert.Require(t, store.before[target] == "before", "snapshot saw %q, want pre-write content", store.before[target]) + assertEntryPreimage(t, store, target, "before") assertFileContent(t, target, "after") } diff --git a/prompt/assembler.go b/prompt/assembler.go new file mode 100644 index 00000000..e0b3fcc5 --- /dev/null +++ b/prompt/assembler.go @@ -0,0 +1,290 @@ +// Package prompt assembles the baseline system prompt from explicit caller +// inputs. It owns no ambient person, location, environment or clock: rules +// reading binds the caller's directories, and assembly renders only the +// supplied identity, instructions, rule text and captured environment values. +package prompt + +import ( + _ "embed" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +//go:embed core_rules.md +var coreRulesSection string + +//go:embed rules_file_guide.md +var rulesFileGuideSection string + +//go:embed compaction_awareness.md +var compactionAwarenessSection string + +//go:embed safety.md +var safetySection string + +//go:embed tone.md +var toneSection string + +//go:embed task_execution.md +var taskExecutionSection string + +//go:embed language.md +var languageSection string + +var overridableOrder = []string{"safety", "tone", "task_execution", "language"} + +var overridableSections = map[string]string{ + "safety": safetySection, + "tone": toneSection, + "task_execution": taskExecutionSection, + "language": languageSection, +} + +// Mode selects one of the three baseline prompt sizes. It is a closed set: +// every other value, including the empty string, is invalid input. +type Mode string + +const ( + ModeNone Mode = "none" + ModeSimple Mode = "simple" + ModeFull Mode = "full" + + WarnRulesTooLarge = "rules_too_large" + WarnRulesNotFound = "rules_not_found" + WarnRulesReadError = "rules_read_error" +) + +// ErrInvalidInput is the typed invalid-input failure for an unsupported mode. +var ErrInvalidInput = errors.New("prompt: invalid input") + +const agentPromptHeading = "## Your Role and Instructions" + +// Warning is one non-fatal rule-reading diagnostic. +type Warning struct { + Kind string + Message string +} + +// Rules is the captured rule text one assembly consumes. Warnings carries the +// diagnostics of the read that produced it. +type Rules struct { + GlobalText string + WorkspaceText string + Warnings []Warning +} + +// AssemblyInput is the complete pure input of one baseline prompt. Identity is +// the application-supplied persona; none mode renders Instructions alone. +// OS, Shell and OSDescription are captured environment values rendered by the +// full and simple sizes; OS is the platform identifier. +type AssemblyInput struct { + Identity string + Instructions string + Mode Mode + Rules Rules + Workspace string + SessionStart time.Time + OS string + Shell string + OSDescription string +} + +// ReadRules reads the current global and Workspace AGENTS.md text for mode. +// None reads nothing and yields no diagnostics. An empty global directory +// performs no global lookup. Ordinary read problems become warnings while an +// unsupported mode fails with ErrInvalidInput before any read. +func ReadRules(globalDir, workspace string, mode Mode) (Rules, error) { + if !validMode(mode) { + return Rules{}, fmt.Errorf("%w: unsupported mode %q", ErrInvalidInput, mode) + } + var rules Rules + if mode == ModeNone { + return rules, nil + } + + if globalDir != "" { + content, err := readRulesFile(globalDir) + if err != nil { + rules.Warnings = append(rules.Warnings, Warning{Kind: WarnRulesReadError, Message: "Failed to read global rules file: " + err.Error()}) + content = "" + } + rules.GlobalText = content + } + + workspaceContent, err := readRulesFile(workspace) + if err != nil { + rules.Warnings = append(rules.Warnings, Warning{Kind: WarnRulesReadError, Message: "Failed to read project rules file: " + err.Error()}) + workspaceContent = "" + } + rules.WorkspaceText = workspaceContent + + if rules.WorkspaceText == "" && rules.GlobalText == "" { + rules.Warnings = append(rules.Warnings, Warning{Kind: WarnRulesNotFound, Message: "No AGENTS.md found"}) + } + + combined := rules.GlobalText + "\x00" + rules.WorkspaceText + if len(combined) > 20000 { + rules.Warnings = append(rules.Warnings, Warning{Kind: WarnRulesTooLarge, Message: fmt.Sprintf("Rules file exceeds 20,000 characters (%d chars). Consider trimming it.", len(combined))}) + } + return rules, nil +} + +// Assemble renders the complete baseline prompt for input. It performs no I/O +// and reads no ambient value: equal inputs produce equal output. None returns +// the trimmed Instructions alone; full and simple retain the baseline section +// order, separators, rules suppression and role-heading normalization. +func Assemble(input AssemblyInput) (string, error) { + if !validMode(input.Mode) { + return "", fmt.Errorf("%w: unsupported mode %q", ErrInvalidInput, input.Mode) + } + if input.Mode == ModeNone { + return strings.TrimSpace(input.Instructions), nil + } + return buildSpec(input), nil +} + +func validMode(mode Mode) bool { + switch mode { + case ModeNone, ModeSimple, ModeFull: + return true + default: + return false + } +} + +func buildSpec(input AssemblyInput) string { + var b strings.Builder + + for _, section := range defaultSectionsForMode(input) { + b.WriteString(strings.TrimSpace(section)) + b.WriteString("\n\n") + } + + b.WriteString(renderEnvironment(input)) + b.WriteString("\n\n") + + rulesContent := strings.TrimSpace(input.Rules.GlobalText + "\n\n" + input.Rules.WorkspaceText) + overridden := detectOverrides(rulesContent) + for _, name := range overridableOrderForMode(input.Mode) { + if !overridden[name] { + b.WriteString(strings.TrimSpace(overridableSections[name])) + b.WriteString("\n\n") + } + } + + writeAgentBody(&b, input.Instructions) + + trimmed := strings.TrimSpace(rulesContent) + if trimmed != "" { + b.WriteString(trimmed) + b.WriteString("\n\n") + } + + return strings.TrimSpace(b.String()) +} + +func defaultSectionsForMode(input AssemblyInput) []string { + if input.Mode == ModeSimple { + return []string{ + input.Identity, + coreRulesSection, + compactionAwarenessSection, + } + } + return []string{ + input.Identity, + coreRulesSection, + rulesFileGuideSection, + compactionAwarenessSection, + } +} + +func overridableOrderForMode(mode Mode) []string { + if mode == ModeSimple { + return []string{"safety"} + } + return overridableOrder +} + +func writeAgentBody(b *strings.Builder, body string) { + body = strings.TrimSpace(body) + if body == "" { + return + } + if !startsWithAgentPromptHeading(body) { + b.WriteString(agentPromptHeading) + b.WriteString("\n\n") + } + b.WriteString(body) + b.WriteString("\n\n") +} + +func startsWithAgentPromptHeading(body string) bool { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if !strings.HasPrefix(line, "#") { + return false + } + headingText := strings.TrimLeft(line, "#") + headingText = strings.TrimSpace(headingText) + headingText = strings.TrimSpace(strings.TrimRight(headingText, "#")) + headingText = strings.ToLower(headingText) + headingText = strings.ReplaceAll(headingText, "-", " ") + headingText = strings.ReplaceAll(headingText, "_", " ") + headingText = strings.Join(strings.Fields(headingText), " ") + return headingText == "your role and instructions" + } + return false +} + +func renderEnvironment(input AssemblyInput) string { + return fmt.Sprintf("## Environment\n\nWorking directory: %s\nPlatform: %s\nShell: %s\nOS: %s\nSession started: %s", + input.Workspace, + input.OS, + input.Shell, + input.OSDescription, + input.SessionStart.Format("2006-01-02 15:04:05 MST"), + ) +} + +func readRulesFile(dir string) (string, error) { + path := filepath.Join(dir, "AGENTS.md") + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("read %s: %w", path, err) + } + return string(data), nil +} + +func detectOverrides(rulesContent string) map[string]bool { + result := map[string]bool{ + "safety": false, + "tone": false, + "task_execution": false, + "language": false, + } + for _, line := range strings.Split(rulesContent, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "#") { + continue + } + headingText := strings.TrimLeft(trimmed, "#") + headingText = strings.TrimSpace(headingText) + headingText = strings.TrimSpace(strings.TrimRight(headingText, "#")) + headingText = strings.ToLower(headingText) + headingText = strings.ReplaceAll(headingText, "-", " ") + headingText = strings.ReplaceAll(headingText, "_", " ") + headingText = strings.Join(strings.Fields(headingText), "_") + if _, ok := result[headingText]; ok { + result[headingText] = true + } + } + return result +} diff --git a/prompt/assembler_test.go b/prompt/assembler_test.go new file mode 100644 index 00000000..cbec9b63 --- /dev/null +++ b/prompt/assembler_test.go @@ -0,0 +1,151 @@ +package prompt_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/prompt" +) + +var testStart = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + +func writeRules(t *testing.T, dir, content string) { + t.Helper() + testassert.NoError(t, os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(content), 0o600)) +} + +func readRules(t *testing.T, globalDir, workspace string, mode prompt.Mode) prompt.Rules { + t.Helper() + rules, err := prompt.ReadRules(globalDir, workspace, mode) + testassert.NoError(t, err) + return rules +} + +func TestReadRulesReadsOnlyAgentsInGlobalThenWorkspaceOrder(t *testing.T) { + globalDir := t.TempDir() + workspaceRoot := t.TempDir() + workspace := filepath.Join(workspaceRoot, "workspace") + testassert.NoError(t, os.MkdirAll(workspace, 0o700)) + writeRules(t, globalDir, "GLOBAL_RULES") + writeRules(t, workspaceRoot, "PARENT_AGENTS_MARKER") + writeRules(t, workspace, "WORKSPACE_RULES") + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "CLAUDE.md"), []byte("ALTERNATIVE_NAME_MARKER"), 0o600)) + + rules := readRules(t, globalDir, workspace, prompt.ModeFull) + testassert.Require(t, rules.GlobalText == "GLOBAL_RULES" && rules.WorkspaceText == "WORKSPACE_RULES", "rules = %+v, want both layers", rules) + testassert.Require(t, len(rules.Warnings) == 0, "warnings = %+v, want none", rules.Warnings) + + assembled, err := prompt.Assemble(prompt.AssemblyInput{Mode: prompt.ModeFull, Rules: rules, Workspace: workspace, SessionStart: testStart}) + testassert.NoError(t, err) + testassert.Require(t, strings.Index(assembled, "GLOBAL_RULES") < strings.Index(assembled, "WORKSPACE_RULES"), "global rules did not render before workspace rules") + testassert.Require(t, !strings.Contains(assembled, "PARENT_AGENTS_MARKER") && !strings.Contains(assembled, "ALTERNATIVE_NAME_MARKER"), "assembly searched a parent or alternative rules file") +} + +func TestReadRulesNoneReadsNothingAndYieldsNoWarnings(t *testing.T) { + globalDir := t.TempDir() + workspace := t.TempDir() + testassert.NoError(t, os.Mkdir(filepath.Join(globalDir, "AGENTS.md"), 0o700)) + testassert.NoError(t, os.Mkdir(filepath.Join(workspace, "AGENTS.md"), 0o700)) + rules := readRules(t, globalDir, workspace, prompt.ModeNone) + testassert.Require(t, rules.GlobalText == "" && rules.WorkspaceText == "" && len(rules.Warnings) == 0, "none rules = %+v, want no text and no diagnostics", rules) +} + +func TestReadRulesEmptyGlobalDirectoryPerformsNoGlobalLookup(t *testing.T) { + workspace := t.TempDir() + rules := readRules(t, "", workspace, prompt.ModeFull) + testassert.Require(t, rules.GlobalText == "" && len(rules.Warnings) == 1 && rules.Warnings[0].Kind == prompt.WarnRulesNotFound, "rules = %+v, want no global layer and only the not-found warning", rules) +} + +func TestReadRulesWarningsKindsMessagesAndOrder(t *testing.T) { + type layer struct { + content string + unreadable bool + } + type wantWarning struct { + kind string + messagePrefix string + } + cases := []struct { + name string + global layer + workspace layer + wantGlobal string + wantWorkspace string + wantWarnings []wantWarning + }{ + { + name: "both layers fail", + global: layer{unreadable: true}, + workspace: layer{unreadable: true}, + wantWarnings: []wantWarning{{prompt.WarnRulesReadError, "Failed to read global rules file: "}, {prompt.WarnRulesReadError, "Failed to read project rules file: "}, {prompt.WarnRulesNotFound, "No AGENTS.md found"}}, + }, + { + name: "global read error keeps workspace text", + global: layer{unreadable: true}, + workspace: layer{content: "WORKSPACE_OK"}, + wantWorkspace: "WORKSPACE_OK", + wantWarnings: []wantWarning{{prompt.WarnRulesReadError, "Failed to read global rules file: "}}, + }, + { + name: "workspace read error keeps global text", + global: layer{content: "GLOBAL_OK"}, + workspace: layer{unreadable: true}, + wantGlobal: "GLOBAL_OK", + wantWarnings: []wantWarning{{prompt.WarnRulesReadError, "Failed to read project rules file: "}}, + }, + } + setup := func(t *testing.T, l layer) string { + t.Helper() + dir := t.TempDir() + if l.unreadable { + testassert.NoError(t, os.Mkdir(filepath.Join(dir, "AGENTS.md"), 0o700)) + } else if l.content != "" { + writeRules(t, dir, l.content) + } + return dir + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + globalDir := setup(t, tc.global) + workspace := setup(t, tc.workspace) + rules := readRules(t, globalDir, workspace, prompt.ModeFull) + testassert.Require(t, rules.GlobalText == tc.wantGlobal && rules.WorkspaceText == tc.wantWorkspace, "rules = %+v, want global %q and workspace %q", rules, tc.wantGlobal, tc.wantWorkspace) + testassert.Require(t, len(rules.Warnings) == len(tc.wantWarnings), "warnings = %+v, want %d entries", rules.Warnings, len(tc.wantWarnings)) + for i, want := range tc.wantWarnings { + got := rules.Warnings[i] + testassert.Require(t, got.Kind == want.kind && strings.HasPrefix(got.Message, want.messagePrefix), "warning %d = %+v, want kind %q and message prefix %q", i, got, want.kind, want.messagePrefix) + } + }) + } +} + +func TestReadRulesCompleteOversizedTextAndWarningThreshold(t *testing.T) { + globalDir := t.TempDir() + workspace := t.TempDir() + oversized := strings.Repeat("x", 20001) + writeRules(t, workspace, oversized) + rules := readRules(t, globalDir, workspace, prompt.ModeFull) + testassert.Require(t, rules.WorkspaceText == oversized, "oversized rules text was truncated") + testassert.Require(t, len(rules.Warnings) == 1 && rules.Warnings[0].Kind == prompt.WarnRulesTooLarge, "warnings = %+v, want exactly one rules_too_large warning and no rules_not_found", rules.Warnings) + + assembled, err := prompt.Assemble(prompt.AssemblyInput{Mode: prompt.ModeFull, Rules: rules, Workspace: workspace, SessionStart: testStart}) + testassert.NoError(t, err) + testassert.Require(t, strings.Contains(assembled, oversized), "assembled prompt dropped the complete oversized rules text") +} + +func TestReadRulesInvalidModeFailsBeforeReading(t *testing.T) { + for _, mode := range []prompt.Mode{"", "bogus"} { + globalDir := t.TempDir() + if err := os.Mkdir(filepath.Join(globalDir, "AGENTS.md"), 0o700); err != nil { + t.Fatal(err) + } + rules, err := prompt.ReadRules(globalDir, t.TempDir(), mode) + testassert.Require(t, errors.Is(err, prompt.ErrInvalidInput), "ReadRules(%q) error = %v, want ErrInvalidInput", mode, err) + testassert.Require(t, rules.GlobalText == "" && rules.WorkspaceText == "" && len(rules.Warnings) == 0, "ReadRules(%q) = %+v, want no read result", mode, rules) + } +} diff --git a/internal/prompt/compaction_awareness.md b/prompt/compaction_awareness.md similarity index 100% rename from internal/prompt/compaction_awareness.md rename to prompt/compaction_awareness.md diff --git a/prompt/composition_test.go b/prompt/composition_test.go new file mode 100644 index 00000000..3abc76b6 --- /dev/null +++ b/prompt/composition_test.go @@ -0,0 +1,292 @@ +package prompt_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/prompt" +) + +func assemblyInput(workspace string, mode prompt.Mode, rules prompt.Rules) prompt.AssemblyInput { + return prompt.AssemblyInput{ + Identity: "## Identity\n\nCALLER_IDENTITY_MARKER", + Instructions: "Do the task.", + Mode: mode, + Rules: rules, + Workspace: workspace, + SessionStart: testStart, + OS: "testos", + Shell: "/bin/testsh", + OSDescription: "TestOS/testarch", + } +} + +func assembleInput(t *testing.T, input prompt.AssemblyInput) string { + t.Helper() + assembled, err := prompt.Assemble(input) + testassert.NoError(t, err) + return assembled +} + +func assertOrder(t *testing.T, text string, markers ...string) { + t.Helper() + previous := -1 + for _, marker := range markers { + index := strings.Index(text, marker) + testassert.Require(t, index >= 0, "missing order marker %q\n%s", marker, text) + testassert.Require(t, index >= previous, "marker %q at %d appeared before previous marker at %d\n%s", marker, index, previous, text) + previous = index + } +} + +func TestAssembleFullBaselineSectionsEnvironmentAndRules(t *testing.T) { + workspace := t.TempDir() + rules := prompt.Rules{GlobalText: "GLOBAL_RULES_MARKER", WorkspaceText: "WORKSPACE_RULES_MARKER"} + assembled := assembleInput(t, assemblyInput(workspace, prompt.ModeFull, rules)) + + for _, want := range []string{ + "## Identity", + "CALLER_IDENTITY_MARKER", + "## Core Rules", + "## Rules File Guide", + "## Compaction Awareness", + "## Environment", + "Working directory: " + workspace, + "Platform: testos", + "Shell: /bin/testsh", + "OS: TestOS/testarch", + "Session started: " + testStart.Format("2006-01-02 15:04:05 MST"), + "## Safety", + "## Tone", + "## Task Execution", + "## Language", + "## Your Role and Instructions", + "Do the task.", + "GLOBAL_RULES_MARKER", + "WORKSPACE_RULES_MARKER", + } { + testassert.Require(t, strings.Contains(assembled, want), "full prompt missing %q\n%s", want, assembled) + } + assertOrder(t, assembled, + "## Identity", + "CALLER_IDENTITY_MARKER", + "## Core Rules", + "## Rules File Guide", + "## Compaction Awareness", + "## Environment", + "Working directory: "+workspace, + "## Safety", + "## Tone", + "## Task Execution", + "## Language", + "## Your Role and Instructions", + "Do the task.", + "GLOBAL_RULES_MARKER", + "WORKSPACE_RULES_MARKER", + ) +} + +func TestAssembleSimpleBaselineSections(t *testing.T) { + workspace := t.TempDir() + rules := prompt.Rules{WorkspaceText: "SIMPLE_WORKSPACE_RULES"} + assembled := assembleInput(t, assemblyInput(workspace, prompt.ModeSimple, rules)) + + for _, want := range []string{ + "## Identity", + "CALLER_IDENTITY_MARKER", + "## Core Rules", + "## Compaction Awareness", + "## Environment", + "Working directory: " + workspace, + "## Safety", + "## Your Role and Instructions", + "Do the task.", + "SIMPLE_WORKSPACE_RULES", + } { + testassert.Require(t, strings.Contains(assembled, want), "simple prompt missing %q\n%s", want, assembled) + } + for _, forbidden := range []string{"## Rules File Guide", "## Tone", "## Task Execution", "## Language"} { + testassert.Require(t, !strings.Contains(assembled, forbidden), "simple prompt contains omitted section %q\n%s", forbidden, assembled) + } + assertOrder(t, assembled, + "## Identity", + "## Core Rules", + "## Compaction Awareness", + "## Environment", + "Working directory: "+workspace, + "## Safety", + "## Your Role and Instructions", + "SIMPLE_WORKSPACE_RULES", + ) +} + +func TestAssembleNoneReturnsTrimmedInstructionsAlone(t *testing.T) { + workspace := t.TempDir() + rules := prompt.Rules{GlobalText: "GLOBAL_RULES_MARKER", WorkspaceText: "WORKSPACE_RULES_MARKER"} + input := assemblyInput(workspace, prompt.ModeNone, rules) + input.Instructions = "\n Only compact this transcript. \n" + + assembled := assembleInput(t, input) + testassert.Equal(t, assembled, "Only compact this transcript.") + for _, forbidden := range []string{ + "CALLER_IDENTITY_MARKER", + "## Identity", + "## Core Rules", + "## Environment", + "## Safety", + "GLOBAL_RULES_MARKER", + "WORKSPACE_RULES_MARKER", + } { + testassert.Require(t, !strings.Contains(assembled, forbidden), "none prompt contains omitted content %q\n%s", forbidden, assembled) + } +} + +func TestAssembleHeadingSuppression(t *testing.T) { + const ( + safetyBody = "Before modifying or deleting files, consider whether the action is reversible." + toneBody = "Be direct and concise." + taskBody = "For simple, well-defined tasks: execute immediately." + languageBody = "Respond in the same language the user writes in." + ) + sections := map[string]string{ + "safety": safetyBody, + "tone": toneBody, + "task_execution": taskBody, + "language": languageBody, + } + cases := []struct { + name string + mode prompt.Mode + global string + workspace string + suppressed []string + }{ + {"level 1 safety", prompt.ModeFull, "", "# Safety\n\nUSER_SAFETY_MARKER", []string{"safety"}}, + {"level 2 tone", prompt.ModeFull, "", "## Tone\n\nUSER_TONE_MARKER", []string{"tone"}}, + {"level 3 task execution", prompt.ModeFull, "", "### Task Execution\n\nUSER_TASK_MARKER", []string{"task_execution"}}, + {"task execution", prompt.ModeFull, "", "## Task Execution\n\nUSER_TASK_MARKER", []string{"task_execution"}}, + {"task underscore", prompt.ModeFull, "", "## task_execution\n\nUSER_TASK_MARKER", []string{"task_execution"}}, + {"task hyphen", prompt.ModeFull, "", "## task-execution\n\nUSER_TASK_MARKER", []string{"task_execution"}}, + {"task tab", prompt.ModeFull, "", "## Task\tExecution\n\nUSER_TASK_MARKER", []string{"task_execution"}}, + {"task multiple spaces", prompt.ModeFull, "", "## Task Execution\n\nUSER_TASK_MARKER", []string{"task_execution"}}, + {"task closing hashes", prompt.ModeFull, "", "## Task Execution ##\n\nUSER_TASK_MARKER", []string{"task_execution"}}, + {"combined headings", prompt.ModeFull, "", "# Safety\ntext\n## Tone\n### Task Execution\n# other\n\nCOMBINED_USER_MARKER", []string{"safety", "tone", "task_execution"}}, + {"global safety suppression", prompt.ModeFull, "# Safety\n\nGLOBAL_SAFETY_MARKER", "", []string{"safety"}}, + {"simple safety suppression", prompt.ModeSimple, "", "# Safety\n\nSIMPLE_SAFETY_MARKER", []string{"safety"}}, + {"unrelated other heading", prompt.ModeFull, "", "## Other\n\nUSER_OTHER_MARKER", nil}, + {"unrelated environment heading", prompt.ModeFull, "", "## Environment\n\nUSER_ENVIRONMENT_MARKER", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rules := prompt.Rules{GlobalText: tc.global, WorkspaceText: tc.workspace} + assembled := assembleInput(t, assemblyInput(t.TempDir(), tc.mode, rules)) + suppressed := make(map[string]bool, len(tc.suppressed)) + for _, name := range tc.suppressed { + suppressed[name] = true + } + modeSections := sections + if tc.mode == prompt.ModeSimple { + modeSections = map[string]string{"safety": safetyBody} + } + for name, body := range modeSections { + if suppressed[name] { + testassert.Require(t, !strings.Contains(assembled, body), "built-in %s content survived its matching user heading in %q\n%s", name, tc.workspace+tc.global, assembled) + continue + } + testassert.Require(t, strings.Contains(assembled, body), "unrelated built-in %s content was suppressed by %q\n%s", name, tc.workspace+tc.global, assembled) + } + userText := tc.workspace + if userText == "" { + userText = tc.global + } + testassert.Require(t, strings.Contains(assembled, strings.TrimSpace(userText)), "user rules text is missing\n%s", assembled) + }) + } +} + +func TestAssembleInstructionHeadingNormalization(t *testing.T) { + workspace := t.TempDir() + cases := []struct { + instructions string + headings int + }{ + {"Do the task.", 1}, + {"## Your Role and Instructions\n\nDo the task.", 1}, + {"# your-role-and-instructions\n\nDo the task.", 0}, + } + for _, tc := range cases { + input := assemblyInput(workspace, prompt.ModeFull, prompt.Rules{}) + input.Instructions = tc.instructions + assembled := assembleInput(t, input) + testassert.Require(t, strings.Count(assembled, "## Your Role and Instructions") == tc.headings, "instructions %q produced heading count %d, want %d\n%s", tc.instructions, strings.Count(assembled, "## Your Role and Instructions"), tc.headings, assembled) + testassert.Require(t, strings.Contains(assembled, "Do the task."), "instructions %q lost their body\n%s", tc.instructions, assembled) + } +} + +func TestAssembleUsesOnlyExplicitInputs(t *testing.T) { + home := t.TempDir() + testassert.NoError(t, os.MkdirAll(filepath.Join(home, ".lightcode"), 0o700)) + writeRules(t, filepath.Join(home, ".lightcode"), "AMBIENT_HOME_RULES") + t.Setenv("HOME", home) + t.Setenv("SHELL", "/ambient-shell") + + workspace := t.TempDir() + input := assemblyInput(workspace, prompt.ModeFull, prompt.Rules{WorkspaceText: "EXPLICIT_RULES"}) + input.Identity = "" + input.Shell = "caller-shell" + assembled := assembleInput(t, input) + + for _, forbidden := range []string{"AMBIENT_HOME_RULES", "Lightcode, an agentic coding assistant", "/ambient-shell"} { + testassert.Require(t, !strings.Contains(assembled, forbidden), "assembly carried implicit input %q\n%s", forbidden, assembled) + } + testassert.Require(t, strings.Contains(assembled, "Shell: caller-shell") && strings.Contains(assembled, "EXPLICIT_RULES"), "assembly lost an explicit input\n%s", assembled) + + ambientRules := readRules(t, "", workspace, prompt.ModeFull) + testassert.Require(t, ambientRules.GlobalText == "" && !strings.Contains(ambientRules.GlobalText, "AMBIENT_HOME_RULES"), "ReadRules looked up the ambient home: %+v", ambientRules) +} + +func TestAssembleDoesNotRereadWorkspaceRules(t *testing.T) { + workspace := t.TempDir() + input := assemblyInput(workspace, prompt.ModeFull, prompt.Rules{}) + writeRules(t, workspace, "LATE_WORKSPACE_RULES") + assembled := assembleInput(t, input) + testassert.Require(t, !strings.Contains(assembled, "LATE_WORKSPACE_RULES"), "pure assembly read the Workspace instead of the captured rules") +} + +func TestAssembleIsDeterministicForEqualInputs(t *testing.T) { + workspace := t.TempDir() + rules := readRules(t, t.TempDir(), workspace, prompt.ModeFull) + firstInput := assemblyInput(workspace, prompt.ModeFull, rules) + firstInput.SessionStart = testStart + secondInput := assemblyInput(workspace, prompt.ModeFull, rules) + secondInput.SessionStart = testStart.Add(90 * time.Minute) + first := assembleInput(t, firstInput) + second := assembleInput(t, secondInput) + testassert.Require(t, first != second, "distinct SessionStart values produced identical prompts") + testassert.Require(t, strings.Contains(first, "Session started: "+testStart.Format("2006-01-02 15:04:05 MST")), "first prompt lost its SessionStart\n%s", first) + testassert.Require(t, strings.Contains(second, "Session started: "+secondInput.SessionStart.Format("2006-01-02 15:04:05 MST")), "second prompt lost its SessionStart\n%s", second) + for i := 0; i < 5; i++ { + testassert.Equal(t, assembleInput(t, firstInput), first) + testassert.Equal(t, assembleInput(t, secondInput), second) + } +} + +func TestAssembleInvalidModeFails(t *testing.T) { + for _, mode := range []prompt.Mode{"", "bogus"} { + input := assemblyInput(t.TempDir(), mode, prompt.Rules{WorkspaceText: "RULES"}) + assembled, err := prompt.Assemble(input) + testassert.Require(t, errors.Is(err, prompt.ErrInvalidInput) && assembled == "", "Assemble(%q) = (%q, %v), want ErrInvalidInput and no prompt", mode, assembled, err) + } +} + +func TestAssembleNoneSampleTimeIsNotRead(t *testing.T) { + input := assemblyInput(t.TempDir(), prompt.ModeNone, prompt.Rules{}) + input.SessionStart = time.Time{} + assembled := assembleInput(t, input) + testassert.Equal(t, assembled, "Do the task.") +} diff --git a/internal/prompt/core_rules.md b/prompt/core_rules.md similarity index 100% rename from internal/prompt/core_rules.md rename to prompt/core_rules.md diff --git a/internal/prompt/language.md b/prompt/language.md similarity index 100% rename from internal/prompt/language.md rename to prompt/language.md diff --git a/internal/prompt/rules_file_guide.md b/prompt/rules_file_guide.md similarity index 100% rename from internal/prompt/rules_file_guide.md rename to prompt/rules_file_guide.md diff --git a/internal/prompt/safety.md b/prompt/safety.md similarity index 100% rename from internal/prompt/safety.md rename to prompt/safety.md diff --git a/prompt/summarizer.go b/prompt/summarizer.go new file mode 100644 index 00000000..832fe9ad --- /dev/null +++ b/prompt/summarizer.go @@ -0,0 +1,25 @@ +package prompt + +// DefaultSummarizerPrompt is the unchanged default compaction instruction: +// the baseline compaction prompt and the selectable compact builtin consume +// the same bytes. +const DefaultSummarizerPrompt = `You are summarizing a coding agent conversation so the agent can continue working with reduced context. Your summary must preserve everything the agent needs to continue without re-reading files or re-asking the user. + +Produce a summary with these sections: + +## Goal +What the user asked for, in their words. Include exact quotes of key instructions. + +## Files +Which files were read, created, or modified. For modified files: what changed and why. + +## Current state +What has been done so far. What works, what doesn't. + +## Unfinished work +Tasks started but not completed. Errors not yet resolved. Things the agent said it would do next. + +## Decisions +Choices the user made or confirmed. Constraints they stated. Things they rejected. + +Be specific — include file paths, function names, error messages, line numbers. Do not generalize. If the user said "don't use React," write that exactly, not "user has framework preferences."` diff --git a/internal/prompt/task_execution.md b/prompt/task_execution.md similarity index 100% rename from internal/prompt/task_execution.md rename to prompt/task_execution.md diff --git a/internal/prompt/tone.md b/prompt/tone.md similarity index 100% rename from internal/prompt/tone.md rename to prompt/tone.md diff --git a/protocol/contracts_test.go b/protocol/contracts_test.go index 5ba2bf56..87cc437b 100644 --- a/protocol/contracts_test.go +++ b/protocol/contracts_test.go @@ -137,15 +137,14 @@ func TestGeneratedScopeEventRoundTripsBothKinds(t *testing.T) { } } -// TestRevertDefaultsShareTheCodeRevertErrorContract keeps the two revert -// default responses on one shared union contract: both wire cases (a plain -// schema error and the partial result carrying its error) stay accepted, -// and a body matching neither branch stays rejected. +// TestRevertDefaultsShareTheCodeRevertErrorContract keeps the revert default +// response on its shared union contract: both wire cases (a plain schema +// error and the partial result carrying its error) stay accepted, and a body +// matching neither branch stays rejected. func TestRevertDefaultsShareTheCodeRevertErrorContract(t *testing.T) { doc := schema(t) for _, row := range []struct{ path string }{ {"/v1/sessions/{id}/revert-code"}, - {"/v1/retained-code-snapshots/revert"}, } { item := doc.Paths.Find(row.path) testassert.Require(t, item != nil && item.Post != nil, "%s has no POST operation", row.path) @@ -309,3 +308,20 @@ func TestGeneratedJSONObjectPreservesNumbersThroughUnionConstructors(t *testing. want := "{\"big\":" + string(members["big"]) + ",\"n\":" + string(members["n"]) + "}" testassert.Require(t, decoded.ToolCalls[0].Metadata != nil && string(*decoded.ToolCalls[0].Metadata) == want, "decoded metadata = %v, want the raw value preserved", decoded.ToolCalls[0].Metadata) } + +// TestSettingsBaselineSectionsStayRawObjects keeps the top-level tools, jobs +// and compaction members schema-defined JSON objects generated as the raw +// message alias: an inline structured type would renumber or reinterpret +// their members, while a plugin-document string would change the transport +// shape. +func TestSettingsBaselineSectionsStayRawObjects(t *testing.T) { + settings := componentSchema(t, "Settings") + for _, name := range []string{"tools", "jobs", "compaction"} { + property, ok := settings.Properties[name] + testassert.Require(t, ok && property.Ref == "#/components/schemas/"+strings.ToUpper(name[:1])+name[1:]+"Settings", "Settings.%s = %+v, want its named object component", name, property) + component := componentSchema(t, strings.ToUpper(name[:1])+name[1:]+"Settings") + testassert.Require(t, component.Type != nil && component.Type.Is("object"), "%sSettings type = %v, want object", name, component.Type) + testassert.Require(t, component.Extensions["x-go-type"] == "json.RawMessage", "%sSettings x-go-type = %v, want json.RawMessage", name, component.Extensions["x-go-type"]) + testassert.Require(t, component.AdditionalProperties.Has != nil && *component.AdditionalProperties.Has, "%sSettings must accept raw members", name) + } +} diff --git a/protocol/corpus_test.go b/protocol/corpus_test.go index ab094af8..fc877c03 100644 --- a/protocol/corpus_test.go +++ b/protocol/corpus_test.go @@ -546,6 +546,7 @@ var contractGeneratedTypes = map[string]func() any{ "CodeRevertResult": func() any { return new(protocol.CodeRevertResult) }, "CodeSnapshotGroup": func() any { return new(protocol.CodeSnapshotGroup) }, "CompactRequest": func() any { return new(protocol.CompactRequest) }, + "CompactionSettings": func() any { return new(protocol.CompactionSettings) }, "CheckpointItem": func() any { return new(protocol.CheckpointItem) }, "ConfigurationChangedEvent": func() any { return new(protocol.ConfigurationChangedEvent) }, "ConfigurationRevision": func() any { return new(protocol.ConfigurationRevision) }, @@ -603,9 +604,6 @@ var contractGeneratedTypes = map[string]func() any{ "ReadFileRequest": func() any { return new(protocol.ReadFileRequest) }, "ReadFileResult": func() any { return new(protocol.ReadFileResult) }, "RefusalDeltaEvent": func() any { return new(protocol.RefusalDeltaEvent) }, - "RetainedRevertRequest": func() any { return new(protocol.RetainedRevertRequest) }, - "RetainedTurn": func() any { return new(protocol.RetainedTurn) }, - "RetainedTurns": func() any { return new(protocol.RetainedTurns) }, "RevertCodeRequest": func() any { return new(protocol.RevertCodeRequest) }, "RevisionResponse": func() any { return new(protocol.RevisionResponse) }, "RuntimeScope": func() any { return new(protocol.RuntimeScope) }, @@ -617,6 +615,7 @@ var contractGeneratedTypes = map[string]func() any{ "SessionRevision": func() any { return new(protocol.SessionRevision) }, "SessionScope": func() any { return new(protocol.SessionScope) }, "SessionsSettings": func() any { return new(protocol.SessionsSettings) }, + "JobsSettings": func() any { return new(protocol.JobsSettings) }, "SetAgentTypeModelRequest": func() any { return new(protocol.SetAgentTypeModelRequest) }, "SetSessionAgentTypeRequest": func() any { return new(protocol.SetSessionAgentTypeRequest) }, "Settings": func() any { return new(protocol.Settings) }, @@ -633,6 +632,7 @@ var contractGeneratedTypes = map[string]func() any{ "ToolCallView": func() any { return new(protocol.ToolCallView) }, "ToolFinishedEvent": func() any { return new(protocol.ToolFinishedEvent) }, "ToolMetadata": func() any { return new(protocol.ToolMetadata) }, + "ToolsSettings": func() any { return new(protocol.ToolsSettings) }, "ToolStartedEvent": func() any { return new(protocol.ToolStartedEvent) }, "UpdateProviderDetailRequest": func() any { return new(protocol.UpdateProviderDetailRequest) }, "UpdateProviderModelRequest": func() any { return new(protocol.UpdateProviderModelRequest) }, diff --git a/protocol/openapi.yaml b/protocol/openapi.yaml index 64f0d12c..f0e9f52c 100644 --- a/protocol/openapi.yaml +++ b/protocol/openapi.yaml @@ -1,13 +1,13 @@ # Lightcode Runtime protocol — the single source of truth for HTTP paths, # request/response DTOs, closed enums, error payloads, and SSE event frames. -# Wire version "6" is the protocol identity, independent of the binary +# Wire version "8" is the protocol identity, independent of the binary # release version. This file is hand-maintained; the Go and TypeScript # artifacts under protocol/ and frontend/src/generated/protocol/ are # generated from it and are never hand-edited. openapi: "3.0.3" info: title: lightcode-protocol - version: "6" + version: "8" servers: - url: "http://127.0.0.1" # Every path requires the bearer credential; it is declared once here and @@ -323,56 +323,6 @@ paths: $ref: "#/components/schemas/ReadFileResult" default: $ref: "#/components/responses/Error" - /v1/retained-code-snapshots: - get: - operationId: GetRetainedCodeSnapshots - parameters: - - name: workspace - in: query - required: true - schema: - type: string - - name: session_id - in: query - required: true - schema: - type: string - pattern: "^[0-9a-f]{8}$" - responses: - "200": - description: The legacy per-turn snapshot groups of one pre-cutover Session. - content: - application/json: - schema: - $ref: "#/components/schemas/RetainedTurns" - default: - $ref: "#/components/responses/Error" - /v1/retained-code-snapshots/revert: - post: - operationId: RevertRetainedCode - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/RetainedRevertRequest" - responses: - "200": - description: The completed legacy restore result. - content: - application/json: - schema: - $ref: "#/components/schemas/CodeRevertResult" - default: - description: > - A traversal I/O failure after earlier turns changed returns the - same result DTO with accumulated restored/skipped members and the - error member; every other failure, including a pre-traversal - storage failure, returns the schema error. - content: - application/json: - schema: - $ref: "#/components/schemas/CodeRevertError" /v1/configuration: get: operationId: GetConfiguration @@ -888,7 +838,7 @@ components: protocol_version: type: string enum: - - "6" + - "8" Scope: description: > One SSE event's applicable scope, discriminated by kind. Every @@ -1867,45 +1817,6 @@ components: oneOf: - $ref: "#/components/schemas/CodeRevertResult" - $ref: "#/components/schemas/Error" - RetainedTurn: - type: object - additionalProperties: false - required: - - turn - - files - properties: - turn: - type: integer - minimum: 1 - files: - type: array - items: - $ref: "#/components/schemas/SnapshotFile" - RetainedTurns: - type: object - additionalProperties: false - required: - - turns - properties: - turns: - type: array - items: - $ref: "#/components/schemas/RetainedTurn" - RetainedRevertRequest: - type: object - additionalProperties: false - required: - - workspace - - session_id - - after_turn - properties: - workspace: - type: string - session_id: - type: string - pattern: "^[0-9a-f]{8}$" - after_turn: - type: integer SessionsSettings: type: object additionalProperties: false @@ -1920,6 +1831,33 @@ components: type: integer delete_after_archive_days: type: integer + ToolsSettings: + type: object + description: >- + The baseline standard tools' configuration object, carried with its + accepted raw members and exact numeric lexemes. An absent section is + the empty object, whose defaults the tools apply at consumption. + additionalProperties: true + x-go-type: json.RawMessage + JobsSettings: + type: object + description: >- + The baseline background jobs configuration object, carried with its + accepted raw members and exact numeric lexemes. An absent section is + the empty object, whose defaults the jobs service applies at + consumption. + additionalProperties: true + x-go-type: json.RawMessage + CompactionSettings: + type: object + description: >- + The baseline compaction configuration object: an optional independent + summarizer model reference and an optional custom nonempty prompt, + carried with its accepted raw members and exact numeric lexemes. An + absent section is the empty object, whose defaults apply at + consumption. + additionalProperties: true + x-go-type: json.RawMessage PluginConfigDocument: type: string description: >- @@ -1938,10 +1876,19 @@ components: additionalProperties: false required: - sessions + - tools + - jobs + - compaction - plugins properties: sessions: $ref: "#/components/schemas/SessionsSettings" + tools: + $ref: "#/components/schemas/ToolsSettings" + jobs: + $ref: "#/components/schemas/JobsSettings" + compaction: + $ref: "#/components/schemas/CompactionSettings" plugins: $ref: "#/components/schemas/PluginsSettings" Agent: diff --git a/protocol/protocol.gen.go b/protocol/protocol.gen.go index b3bb90d3..e4209774 100644 --- a/protocol/protocol.gen.go +++ b/protocol/protocol.gen.go @@ -181,13 +181,13 @@ func (e ExecutionEvidence) Valid() bool { // Defines values for HealthProtocolVersion. const ( - N6 HealthProtocolVersion = "6" + N8 HealthProtocolVersion = "8" ) // Valid indicates whether the value is a known member of the HealthProtocolVersion enum. func (e HealthProtocolVersion) Valid() bool { switch e { - case N6: + case N8: return true default: return false @@ -959,6 +959,9 @@ type CompactRequest struct { OperationId string `json:"operation_id"` } +// CompactionSettings The baseline compaction configuration object: an optional independent summarizer model reference and an optional custom nonempty prompt, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults apply at consumption. +type CompactionSettings = json.RawMessage + // ConfigurationChangedEvent defines model for ConfigurationChangedEvent. type ConfigurationChangedEvent struct { ConfigurationRevision ConfigurationRevision `json:"configuration_revision"` @@ -1154,6 +1157,9 @@ type JobScope struct { // JobScopeKind defines model for JobScope.Kind. type JobScopeKind string +// JobsSettings The baseline background jobs configuration object, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults the jobs service applies at consumption. +type JobsSettings = json.RawMessage + // Message defines model for Message. type Message struct { Content *[]ContentPart `json:"content,omitempty"` @@ -1441,24 +1447,6 @@ type RefusalDeltaEvent struct { // RefusalDeltaEventKind defines model for RefusalDeltaEvent.Kind. type RefusalDeltaEventKind string -// RetainedRevertRequest defines model for RetainedRevertRequest. -type RetainedRevertRequest struct { - AfterTurn int `json:"after_turn"` - SessionId string `json:"session_id"` - Workspace string `json:"workspace"` -} - -// RetainedTurn defines model for RetainedTurn. -type RetainedTurn struct { - Files []SnapshotFile `json:"files"` - Turn int `json:"turn"` -} - -// RetainedTurns defines model for RetainedTurns. -type RetainedTurns struct { - Turns []RetainedTurn `json:"turns"` -} - // RevertCodeRequest defines model for RevertCodeRequest. type RevertCodeRequest struct { BoundaryOperationId string `json:"boundary_operation_id"` @@ -1566,9 +1554,18 @@ type SetSessionAgentTypeRequest struct { // Settings defines model for Settings. type Settings struct { + // Compaction The baseline compaction configuration object: an optional independent summarizer model reference and an optional custom nonempty prompt, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults apply at consumption. + Compaction CompactionSettings `json:"compaction"` + + // Jobs The baseline background jobs configuration object, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults the jobs service applies at consumption. + Jobs JobsSettings `json:"jobs"` + // Plugins Every owned plugin section as its JSON document string, keyed by the compiled plugin ID; an absent ID uses that plugin's own defaults. Plugins PluginsSettings `json:"plugins"` Sessions SessionsSettings `json:"sessions"` + + // Tools The baseline standard tools' configuration object, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults the tools apply at consumption. + Tools ToolsSettings `json:"tools"` } // SettingsMutation defines model for SettingsMutation. @@ -1715,6 +1712,9 @@ type ToolStartedEvent struct { // ToolStartedEventKind defines model for ToolStartedEvent.Kind. type ToolStartedEventKind string +// ToolsSettings The baseline standard tools' configuration object, carried with its accepted raw members and exact numeric lexemes. An absent section is the empty object, whose defaults the tools apply at consumption. +type ToolsSettings = json.RawMessage + // UpdateProviderDetailRequest defines model for UpdateProviderDetailRequest. type UpdateProviderDetailRequest struct { Provider ProviderEdit `json:"provider"` @@ -1890,12 +1890,6 @@ type ResetProviderModelFieldParams struct { ModelId ModelIDQuery `form:"model_id" json:"model_id"` } -// GetRetainedCodeSnapshotsParams defines parameters for GetRetainedCodeSnapshots. -type GetRetainedCodeSnapshotsParams struct { - Workspace string `form:"workspace" json:"workspace"` - SessionId string `form:"session_id" json:"session_id"` -} - // ListSessionsParams defines parameters for ListSessions. type ListSessionsParams struct { Workspace WorkspaceQuery `form:"workspace" json:"workspace"` @@ -1928,9 +1922,6 @@ type DiscoverProviderCandidatesJSONRequestBody = DiscoveryRequest // UpdateProviderModelJSONRequestBody defines body for UpdateProviderModel for application/json ContentType. type UpdateProviderModelJSONRequestBody = UpdateProviderModelRequest -// RevertRetainedCodeJSONRequestBody defines body for RevertRetainedCode for application/json ContentType. -type RevertRetainedCodeJSONRequestBody = RetainedRevertRequest - // CreateSessionJSONRequestBody defines body for CreateSession for application/json ContentType. type CreateSessionJSONRequestBody = CreateSessionRequest @@ -3032,17 +3023,6 @@ type ClientInterface interface { // ResetProviderModelField performs a DELETE /v1/providers/models/fields/{field} (the `ResetProviderModelField` operationId) request. ResetProviderModelField(ctx context.Context, field ModelField, params *ResetProviderModelFieldParams, reqEditors ...RequestEditorFn) (*http.Response, error) - // GetRetainedCodeSnapshots performs a GET /v1/retained-code-snapshots (the `GetRetainedCodeSnapshots` operationId) request. - GetRetainedCodeSnapshots(ctx context.Context, params *GetRetainedCodeSnapshotsParams, reqEditors ...RequestEditorFn) (*http.Response, error) - - // RevertRetainedCodeWithBody performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request, - // with any type of body and a specified content type. - RevertRetainedCodeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - - // RevertRetainedCode performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request. - // Takes a body of the `application/json` content type. - RevertRetainedCode(ctx context.Context, body RevertRetainedCodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) - // ListSessions performs a GET /v1/sessions (the `ListSessions` operationId) request. ListSessions(ctx context.Context, params *ListSessionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -3511,47 +3491,6 @@ func (c *Client) ResetProviderModelField(ctx context.Context, field ModelField, return c.Client.Do(req) } -// GetRetainedCodeSnapshots performs a GET /v1/retained-code-snapshots (the `GetRetainedCodeSnapshots` operationId) request. -func (c *Client) GetRetainedCodeSnapshots(ctx context.Context, params *GetRetainedCodeSnapshotsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetRetainedCodeSnapshotsRequest(c.Server, params) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -// RevertRetainedCodeWithBody performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request, -// with any type of body and a specified content type. -func (c *Client) RevertRetainedCodeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewRevertRetainedCodeRequestWithBody(c.Server, contentType, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - -// RevertRetainedCode performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request. -// Takes a body of the `application/json` content type. -func (c *Client) RevertRetainedCode(ctx context.Context, body RevertRetainedCodeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewRevertRetainedCodeRequest(c.Server, body) - if err != nil { - return nil, err - } - req = req.WithContext(ctx) - if err := c.applyEditors(ctx, req, reqEditors); err != nil { - return nil, err - } - return c.Client.Do(req) -} - // ListSessions performs a GET /v1/sessions (the `ListSessions` operationId) request. func (c *Client) ListSessions(ctx context.Context, params *ListSessionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListSessionsRequest(c.Server, params) @@ -4886,104 +4825,6 @@ func NewResetProviderModelFieldRequest(server string, field ModelField, params * return req, nil } -// NewGetRetainedCodeSnapshotsRequest constructs an http.Request for the GetRetainedCodeSnapshots method -func NewGetRetainedCodeSnapshotsRequest(server string, params *GetRetainedCodeSnapshotsParams) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/retained-code-snapshots") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - if params != nil { - // queryValues collects non-styled parameters (passthrough, JSON) - // that are safe to round-trip through url.Values.Encode(). - queryValues := queryURL.Query() - // rawQueryFragments collects pre-encoded query fragments from - // styled parameters, preserving literal commas as delimiters - // per the OpenAPI spec (e.g. "color=blue,black,brown"). - var rawQueryFragments []string - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "workspace", params.Workspace, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "session_id", params.SessionId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") - } - - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) - if err != nil { - return nil, err - } - - return req, nil -} - -// NewRevertRetainedCodeRequest calls the generic RevertRetainedCode builder with application/json body -func NewRevertRetainedCodeRequest(server string, body RevertRetainedCodeJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewRevertRetainedCodeRequestWithBody(server, "application/json", bodyReader) -} - -// NewRevertRetainedCodeRequestWithBody constructs an http.Request for the RevertRetainedCode method, with any body, and a specified content type -func NewRevertRetainedCodeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/v1/retained-code-snapshots/revert") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - // NewListSessionsRequest constructs an http.Request for the ListSessions method func NewListSessionsRequest(server string, params *ListSessionsParams) (*http.Request, error) { var err error @@ -5894,21 +5735,6 @@ type ClientWithResponsesInterface interface { // Returns a wrapper object for the known response body format(s). ResetProviderModelFieldWithResponse(ctx context.Context, field ModelField, params *ResetProviderModelFieldParams, reqEditors ...RequestEditorFn) (*ResetProviderModelFieldResponse, error) - // GetRetainedCodeSnapshotsWithResponse performs a GET /v1/retained-code-snapshots (the `GetRetainedCodeSnapshots` operationId) request. - // - // Returns a wrapper object for the known response body format(s). - GetRetainedCodeSnapshotsWithResponse(ctx context.Context, params *GetRetainedCodeSnapshotsParams, reqEditors ...RequestEditorFn) (*GetRetainedCodeSnapshotsResponse, error) - - // RevertRetainedCodeWithBodyWithResponse performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request, - // with any type of body and a specified content type. - // - // Returns a wrapper object for the known response body format(s). - RevertRetainedCodeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RevertRetainedCodeResponse, error) - - // RevertRetainedCodeWithResponse performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request. - // Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). - RevertRetainedCodeWithResponse(ctx context.Context, body RevertRetainedCodeJSONRequestBody, reqEditors ...RequestEditorFn) (*RevertRetainedCodeResponse, error) - // ListSessionsWithResponse performs a GET /v1/sessions (the `ListSessions` operationId) request. // // Returns a wrapper object for the known response body format(s). @@ -7036,102 +6862,6 @@ func (r ResetProviderModelFieldResponse) ContentType() string { return "" } -type GetRetainedCodeSnapshotsResponse struct { - Body []byte - HTTPResponse *http.Response - // JSON200 the response for an HTTP 200 `application/json` response - JSON200 *RetainedTurns - // JSONDefault the response for an HTTP default `application/json` response - JSONDefault *Error -} - -// GetJSON200 returns the response for an HTTP 200 `application/json` response -func (r GetRetainedCodeSnapshotsResponse) GetJSON200() *RetainedTurns { - return r.JSON200 -} - -// GetJSONDefault returns the response for an HTTP default `application/json` response -func (r GetRetainedCodeSnapshotsResponse) GetJSONDefault() *Error { - return r.JSONDefault -} - -// GetBody returns the raw response body bytes -func (r GetRetainedCodeSnapshotsResponse) GetBody() []byte { - return r.Body -} - -// Status returns HTTPResponse.Status -func (r GetRetainedCodeSnapshotsResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r GetRetainedCodeSnapshotsResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r GetRetainedCodeSnapshotsResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - -type RevertRetainedCodeResponse struct { - Body []byte - HTTPResponse *http.Response - // JSON200 the response for an HTTP 200 `application/json` response - JSON200 *CodeRevertResult - // JSONDefault the response for an HTTP default `application/json` response - JSONDefault *CodeRevertError -} - -// GetJSON200 returns the response for an HTTP 200 `application/json` response -func (r RevertRetainedCodeResponse) GetJSON200() *CodeRevertResult { - return r.JSON200 -} - -// GetJSONDefault returns the response for an HTTP default `application/json` response -func (r RevertRetainedCodeResponse) GetJSONDefault() *CodeRevertError { - return r.JSONDefault -} - -// GetBody returns the raw response body bytes -func (r RevertRetainedCodeResponse) GetBody() []byte { - return r.Body -} - -// Status returns HTTPResponse.Status -func (r RevertRetainedCodeResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status - } - return http.StatusText(0) -} - -// StatusCode returns HTTPResponse.StatusCode -func (r RevertRetainedCodeResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode - } - return 0 -} - -// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers -func (r RevertRetainedCodeResponse) ContentType() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Header.Get("Content-Type") - } - return "" -} - type ListSessionsResponse struct { Body []byte HTTPResponse *http.Response @@ -8283,39 +8013,6 @@ func (c *ClientWithResponses) ResetProviderModelFieldWithResponse(ctx context.Co return ParseResetProviderModelFieldResponse(rsp) } -// GetRetainedCodeSnapshotsWithResponse performs a GET /v1/retained-code-snapshots (the `GetRetainedCodeSnapshots` operationId) request. -// -// Returns a wrapper object for the known response body format(s). -func (c *ClientWithResponses) GetRetainedCodeSnapshotsWithResponse(ctx context.Context, params *GetRetainedCodeSnapshotsParams, reqEditors ...RequestEditorFn) (*GetRetainedCodeSnapshotsResponse, error) { - rsp, err := c.GetRetainedCodeSnapshots(ctx, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseGetRetainedCodeSnapshotsResponse(rsp) -} - -// RevertRetainedCodeWithBodyWithResponse performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request, -// with any type of body and a specified content type. -// -// Returns a wrapper object for the known response body format(s). -func (c *ClientWithResponses) RevertRetainedCodeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RevertRetainedCodeResponse, error) { - rsp, err := c.RevertRetainedCodeWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseRevertRetainedCodeResponse(rsp) -} - -// RevertRetainedCodeWithResponse performs a POST /v1/retained-code-snapshots/revert (the `RevertRetainedCode` operationId) request. -// Takes a body of the `application/json` content type, and returns a wrapper object for the known response body format(s). -func (c *ClientWithResponses) RevertRetainedCodeWithResponse(ctx context.Context, body RevertRetainedCodeJSONRequestBody, reqEditors ...RequestEditorFn) (*RevertRetainedCodeResponse, error) { - rsp, err := c.RevertRetainedCode(ctx, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseRevertRetainedCodeResponse(rsp) -} - // ListSessionsWithResponse performs a GET /v1/sessions (the `ListSessions` operationId) request. // // Returns a wrapper object for the known response body format(s). @@ -9277,72 +8974,6 @@ func ParseResetProviderModelFieldResponse(rsp *http.Response) (*ResetProviderMod return response, nil } -// ParseGetRetainedCodeSnapshotsResponse parses an HTTP response from a GetRetainedCodeSnapshotsWithResponse call -func ParseGetRetainedCodeSnapshotsResponse(rsp *http.Response) (*GetRetainedCodeSnapshotsResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &GetRetainedCodeSnapshotsResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest RetainedTurns - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: - var dest Error - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSONDefault = &dest - - } - - return response, nil -} - -// ParseRevertRetainedCodeResponse parses an HTTP response from a RevertRetainedCodeWithResponse call -func ParseRevertRetainedCodeResponse(rsp *http.Response) (*RevertRetainedCodeResponse, error) { - bodyBytes, err := io.ReadAll(rsp.Body) - defer func() { _ = rsp.Body.Close() }() - if err != nil { - return nil, err - } - - response := &RevertRetainedCodeResponse{ - Body: bodyBytes, - HTTPResponse: rsp, - } - - switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest CodeRevertResult - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: - var dest CodeRevertError - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSONDefault = &dest - - } - - return response, nil -} - // ParseListSessionsResponse parses an HTTP response from a ListSessionsWithResponse call func ParseListSessionsResponse(rsp *http.Response) (*ListSessionsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -9991,12 +9622,6 @@ type ServerInterface interface { // (DELETE /v1/providers/models/fields/{field}) ResetProviderModelField(w http.ResponseWriter, r *http.Request, field ModelField, params ResetProviderModelFieldParams) - // (GET /v1/retained-code-snapshots) - GetRetainedCodeSnapshots(w http.ResponseWriter, r *http.Request, params GetRetainedCodeSnapshotsParams) - - // (POST /v1/retained-code-snapshots/revert) - RevertRetainedCode(w http.ResponseWriter, r *http.Request) - // (GET /v1/sessions) ListSessions(w http.ResponseWriter, r *http.Request, params ListSessionsParams) @@ -10659,66 +10284,6 @@ func (siw *ServerInterfaceWrapper) ResetProviderModelField(w http.ResponseWriter handler.ServeHTTP(w, r) } -// GetRetainedCodeSnapshots operation middleware -func (siw *ServerInterfaceWrapper) GetRetainedCodeSnapshots(w http.ResponseWriter, r *http.Request) { - - var err error - _ = err - - // Parameter object where we will unmarshal all parameters from the context - var params GetRetainedCodeSnapshotsParams - - // ------------- Required query parameter "workspace" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, true, "workspace", r.URL.Query(), ¶ms.Workspace, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "workspace"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "workspace", Err: err}) - } - return - } - - // ------------- Required query parameter "session_id" ------------- - - err = runtime.BindQueryParameterWithOptions("form", true, true, "session_id", r.URL.Query(), ¶ms.SessionId, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) - if err != nil { - var requiredError *runtime.RequiredParameterError - if errors.As(err, &requiredError) { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "session_id"}) - } else { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "session_id", Err: err}) - } - return - } - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetRetainedCodeSnapshots(w, r, params) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -// RevertRetainedCode operation middleware -func (siw *ServerInterfaceWrapper) RevertRetainedCode(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.RevertRetainedCode(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - // ListSessions operation middleware func (siw *ServerInterfaceWrapper) ListSessions(w http.ResponseWriter, r *http.Request) { @@ -11314,8 +10879,6 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/v1/sessions/{id}/revert-code", wrapper.RevertSessionCode) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/v1/workspaces", wrapper.ListWorkspaces) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/v1/workspaces/files/read", wrapper.ReadWorkspaceFile) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/v1/retained-code-snapshots", wrapper.GetRetainedCodeSnapshots) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/v1/retained-code-snapshots/revert", wrapper.RevertRetainedCode) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/v1/configuration", wrapper.GetConfiguration) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/v1/configuration/reload", wrapper.ReloadConfiguration) m.HandleFunc(http.MethodPut+" "+options.BaseURL+"/v1/configuration/settings", wrapper.UpdateConfigurationSettings) diff --git a/protocol/testdata/protocol_contract_fixtures.json b/protocol/testdata/protocol_contract_fixtures.json index 294a06c8..2c74610b 100644 --- a/protocol/testdata/protocol_contract_fixtures.json +++ b/protocol/testdata/protocol_contract_fixtures.json @@ -250,7 +250,7 @@ "accept": [ { "name": "minimal", - "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"settings\":{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"plugins\":{}},\"agents\":[],\"providers\":[]}" + "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"settings\":{\"tools\":{},\"jobs\":{},\"compaction\":{},\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"plugins\":{}},\"agents\":[],\"providers\":[]}" } ], "reject": [] @@ -595,7 +595,7 @@ "accept": [ { "name": "minimal", - "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"6\"}" + "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"8\"}" } ], "reject": [ @@ -605,16 +605,16 @@ }, { "name": "bad-instance-pattern", - "json": "{\"instance_id\":\"XYZ\",\"protocol_version\":\"6\"}", + "json": "{\"instance_id\":\"XYZ\",\"protocol_version\":\"8\"}", "typescript": false }, { "name": "missing-instance", - "json": "{\"protocol_version\":\"6\"}" + "json": "{\"protocol_version\":\"8\"}" }, { "name": "unknown-member", - "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"6\",\"extra\":true}" + "json": "{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"protocol_version\":\"8\",\"extra\":true}" } ] }, @@ -1246,55 +1246,6 @@ } ] }, - { - "schema": "RetainedRevertRequest", - "accept": [ - { - "name": "minimal", - "json": "{\"workspace\":\"fixture\",\"session_id\":\"01234567\",\"after_turn\":0}" - }, - { - "name": "after-zero", - "json": "{\"workspace\":\"/workspace\",\"session_id\":\"01234567\",\"after_turn\":0}" - }, - { - "name": "after-negative", - "json": "{\"workspace\":\"/workspace\",\"session_id\":\"01234567\",\"after_turn\":-1}" - } - ], - "reject": [ - { - "name": "missing-workspace", - "json": "{\"session_id\":\"01234567\",\"after_turn\":0}" - } - ] - }, - { - "schema": "RetainedTurn", - "accept": [ - { - "name": "minimal", - "json": "{\"turn\":1,\"files\":[]}" - } - ], - "reject": [ - { - "name": "zero-turn", - "json": "{\"turn\":0,\"files\":[]}", - "typescript": false - } - ] - }, - { - "schema": "RetainedTurns", - "accept": [ - { - "name": "minimal", - "json": "{\"turns\":[]}" - } - ], - "reject": [] - }, { "schema": "RevertCodeRequest", "accept": [ @@ -1602,6 +1553,91 @@ ], "reject": [] }, + { + "schema": "ToolsSettings", + "accept": [ + { + "name": "minimal", + "json": "{}" + }, + { + "name": "raw-numbers", + "json": "{\"read_max_lines\":9007199254740993,\"unknown\":{\"deep\":[1,2]}}" + } + ], + "reject": [ + { + "name": "null-literal", + "json": "null" + }, + { + "name": "array", + "json": "[]" + }, + { + "name": "scalar", + "json": "7" + } + ] + }, + { + "schema": "JobsSettings", + "accept": [ + { + "name": "minimal", + "json": "{}" + }, + { + "name": "raw-numbers", + "json": "{\"max_output_bytes\":9007199254740993}" + } + ], + "reject": [ + { + "name": "null-literal", + "json": "null" + }, + { + "name": "array", + "json": "[]" + }, + { + "name": "scalar", + "json": "7" + } + ] + }, + { + "schema": "CompactionSettings", + "accept": [ + { + "name": "minimal", + "json": "{}" + }, + { + "name": "configured", + "json": "{\"model\":\"prov/m\",\"prompt\":\"exact text\"}" + }, + { + "name": "raw-unknown", + "json": "{\"custom\":9007199254740993}" + } + ], + "reject": [ + { + "name": "null-literal", + "json": "null" + }, + { + "name": "array", + "json": "[]" + }, + { + "name": "scalar", + "json": "7" + } + ] + }, { "schema": "SetAgentTypeModelRequest", "accept": [ @@ -1632,17 +1668,34 @@ "accept": [ { "name": "minimal", - "json": "{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"plugins\":{}}" + "json": "{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"tools\":{},\"jobs\":{},\"compaction\":{},\"plugins\":{}}" + }, + { + "name": "baseline-raw", + "json": "{\"sessions\":{\"auto_archive\":true,\"archive_after_days\":7,\"delete_after_archive_days\":7},\"tools\":{\"read_max_lines\":9007199254740993},\"jobs\":{\"max_output_bytes\":1024},\"compaction\":{\"model\":\"prov/m\",\"prompt\":\"custom\"},\"plugins\":{\"tasks\":\"{}\"}}" } ], - "reject": [] + "reject": [ + { + "name": "missing-tools", + "json": "{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"jobs\":{},\"compaction\":{},\"plugins\":{}}" + }, + { + "name": "tools-null", + "json": "{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"tools\":null,\"jobs\":{},\"compaction\":{},\"plugins\":{}}" + }, + { + "name": "compaction-string", + "json": "{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"tools\":{},\"jobs\":{},\"compaction\":\"{}\",\"plugins\":{}}" + } + ] }, { "schema": "SettingsMutation", "accept": [ { "name": "minimal", - "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"result\":{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"plugins\":{}}}" + "json": "{\"configuration_revision\":{\"instance_id\":\"0123456789abcdef0123456789abcdef\",\"generation\":\"fixture\"},\"result\":{\"tools\":{},\"jobs\":{},\"compaction\":{},\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"plugins\":{}}}" } ], "reject": [] @@ -1966,7 +2019,7 @@ "accept": [ { "name": "minimal", - "json": "{\"settings\":{\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"plugins\":{}}}" + "json": "{\"settings\":{\"tools\":{},\"jobs\":{},\"compaction\":{},\"sessions\":{\"auto_archive\":false,\"archive_after_days\":0,\"delete_after_archive_days\":0},\"plugins\":{}}}" } ], "reject": [] @@ -2270,16 +2323,6 @@ "default": "Error" } }, - { - "operation_id": "GetRetainedCodeSnapshots", - "method": "get", - "path": "/v1/retained-code-snapshots", - "request": null, - "responses": { - "200": "RetainedTurns", - "default": "Error" - } - }, { "operation_id": "GetSessionCodeSnapshots", "method": "get", @@ -2429,16 +2472,6 @@ "default": "Error" } }, - { - "operation_id": "RevertRetainedCode", - "method": "post", - "path": "/v1/retained-code-snapshots/revert", - "request": "RetainedRevertRequest", - "responses": { - "200": "CodeRevertResult", - "default": "CodeRevertError" - } - }, { "operation_id": "RevertSessionCode", "method": "post", @@ -2547,6 +2580,18 @@ { "schema": "AssistantItem", "json": "{\"item_id\":\"assistant-item\",\"kind\":\"assistant\",\"committed_at\":\"2026-01-02T03:04:07Z\",\"operation_id\":\"op-1\",\"status\":\"completed\",\"source\":\"prov/m\",\"content\":[{\"kind\":\"text\",\"text\":\"hello\"},{\"kind\":\"image_url\",\"url\":\"https://example.invalid/image.png\"},{\"kind\":\"opaque\",\"opaque_wire_type\":\"vendor/wire\"}],\"refusal\":\"declined\",\"tool_calls\":[{\"id\":\"call-1\",\"name\":\"read\",\"arguments\":\"{\\\"path\\\":\\\"x\\\"}\",\"normalized_arguments\":{\"n\":9007199254740993,\"big\":1e1000},\"status\":\"success\",\"content\":\"ok\",\"metadata\":{\"n\":9007199254740993,\"big\":1e1000,\"list\":[null,false,0],\"text\":\"x\"},\"extra\":{\"origin\":\"provider\"}}],\"extra\":{\"reasoning\":\"completed\"}}" + }, + { + "schema": "ToolsSettings", + "json": "{\"n\":9007199254740993,\"big\":1e1000}" + }, + { + "schema": "JobsSettings", + "json": "{\"n\":9007199254740993,\"big\":1e1000}" + }, + { + "schema": "CompactionSettings", + "json": "{\"n\":9007199254740993,\"big\":1e1000}" } ] } diff --git a/runtime/adaptation.go b/runtime/adaptation.go index d045a529..7b598f4d 100644 --- a/runtime/adaptation.go +++ b/runtime/adaptation.go @@ -4,23 +4,26 @@ import ( "reflect" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" ) // Adaptation is the public per-model treatment value a ModelAdaptation -// resolves: which registered tools the model is shown, which coaching blocks -// join its system prompt, and which per-section additions and tool-description -// replacements apply. The zero value is a valid no-op baseline. +// resolves: which registered tools the model is hidden from, ordered prompt +// append paragraphs, and tool-description replacements. The zero value is a +// valid no-op baseline. type Adaptation struct { - ExcludeTools, IncludeTools, Blocks []string - Additions, ToolDescriptionReplacements map[string]string + ExcludeTools []string + Append []string + ToolDescriptionReplacements map[string]string } // ModelAdaptation is the consumed public capability: one Resolve per -// preparation converts the active model ref into the treatment applied to the -// captured prompt and tool surface. Resolve returns only the value above and -// cannot add capability IDs, hooks, or plugin implementations. +// preparation converts the active model ref and the preparation's prompt mode +// into the treatment applied to the captured prompt and tool surface. Resolve +// returns only the value above and cannot add capability IDs, hooks, or plugin +// implementations. type ModelAdaptation interface { - Resolve(model.ModelRef) (Adaptation, error) + Resolve(model.ModelRef, prompt.Mode) (Adaptation, error) } // modelAdaptationType is the declared-type identity of the public diff --git a/runtime/adaptation_test.go b/runtime/adaptation_test.go index 6ab59ef4..307ee88c 100644 --- a/runtime/adaptation_test.go +++ b/runtime/adaptation_test.go @@ -7,14 +7,16 @@ import ( "os" "path/filepath" "reflect" + goruntime "runtime" "strings" "testing" "time" "github.com/MMinasyan/lightcode/harness" - "github.com/MMinasyan/lightcode/internal/prompt" "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/internal/tool" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" ) // fakeAdaptation is a counting ModelAdaptation: it records every Resolve call @@ -22,13 +24,15 @@ import ( type fakeAdaptation struct { calls int ref model.ModelRef + mode prompt.Mode value Adaptation err error } -func (f *fakeAdaptation) Resolve(ref model.ModelRef) (Adaptation, error) { +func (f *fakeAdaptation) Resolve(ref model.ModelRef, mode prompt.Mode) (Adaptation, error) { f.calls++ f.ref = ref + f.mode = mode return f.value, f.err } @@ -81,7 +85,7 @@ func TestCompositionSelectsSingleModelAdaptationAndLeavesOtherExportsUnselected( }, }}) testassert.NoError(t, err) - testassert.Require(t, mixed.modelAdaptation == "model_adaptation" && reflect.DeepEqual(mixed.capabilityIDs, []string{"model_adaptation", "extra"}), "got %v, want %v; capability universe = %q, want both exports selectable", mixed.modelAdaptation, "model_adaptation", mixed.capabilityIDs) + testassert.Require(t, mixed.modelAdaptation == "model_adaptation" && reflect.DeepEqual(mixed.capabilityIDs[len(standardToolSpecs()):], []string{"model_adaptation", "extra"}), "got %v, want %v; capability universe past the standard-tool prefix = %q, want both exports selectable", mixed.modelAdaptation, "model_adaptation", mixed.capabilityIDs[len(standardToolSpecs()):]) { got := defaultCapabilityIDsFor(t, mixed) testassert.Require(t, reflect.DeepEqual(got, []string{"model_adaptation"}), "default capability list for the mixed plugin = %q, want [model_adaptation] only", got) @@ -136,17 +140,17 @@ func TestConfigurationServiceProjectsDefaultCapabilities(t *testing.T) { // Tool-spec fixtures for the composer: ToolSpec records the pure describe // function behind the same validation production declarations use. -func staticToolSpec(id, description string, available, hidden bool) CapabilitySpec { +func staticToolSpec(id, description string, available bool) CapabilitySpec { return ToolSpec(id, func(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { - return toolDescriptionFor(id, description, available, hidden) + return toolDescriptionFor(id, description, available) }) } // mutationToolSpec mirrors the native mutation-tool descriptions: available // unless the hard constraints make it ineligible. -func mutationToolSpec(id string, hidden bool) CapabilitySpec { +func mutationToolSpec(id string) CapabilitySpec { return ToolSpec(id, func(_ Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { - return toolDescriptionFor(id, id+" description", !constraints.Readonly || constraints.WriteDir != "", hidden) + return toolDescriptionFor(id, id+" description", !constraints.Readonly || constraints.WriteDir != "") }) } @@ -161,11 +165,11 @@ func failingToolSpec(id string) CapabilitySpec { // availability on the parent lineage. func lineageToolSpec(id string) CapabilitySpec { return ToolSpec(id, func(_ Invocation, _ ToolConstraints, identity harness.SessionIdentity) (ToolDescription, error) { - return toolDescriptionFor(id, id+" description", identity.ParentSessionID == "", false) + return toolDescriptionFor(id, id+" description", identity.ParentSessionID == "") }) } -func toolDescriptionFor(id, description string, available, hidden bool) (ToolDescription, error) { +func toolDescriptionFor(id, description string, available bool) (ToolDescription, error) { definition, err := model.NewToolDefinition(model.ToolDefinition{ Name: id, Description: description, @@ -174,18 +178,19 @@ func toolDescriptionFor(id, description string, available, hidden bool) (ToolDes if err != nil { return ToolDescription{}, err } - return ToolDescription{Definition: definition, Available: available, DefaultHidden: hidden}, nil + return ToolDescription{Definition: definition, Available: available}, nil } func baseSurfaceRequest(t *testing.T) surfaceRequest { t.Helper() return surfaceRequest{ - home: t.TempDir(), - workspace: t.TempDir(), - promptSize: prompt.SizeFull, + hostIdentity: "FIXTURE_IDENTITY", + globalRulesDir: t.TempDir(), + workspace: t.TempDir(), + promptSize: prompt.ModeFull, toolSpecs: []CapabilitySpec{ - staticToolSpec("read_file", "read_file description", true, false), - staticToolSpec("apply_patch", "apply_patch description", true, true), + staticToolSpec("read_file", "read_file description", true), + staticToolSpec("apply_patch", "apply_patch description", true), }, modelRef: model.ModelRef{Provider: "prov", Model: "m"}, } @@ -194,31 +199,42 @@ func baseSurfaceRequest(t *testing.T) surfaceRequest { func TestComposeSurfacePromptSizesSkipRulesUnderNone(t *testing.T) { req := baseSurfaceRequest(t) req.sessionStart = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) - for _, size := range []string{prompt.SizeNone, prompt.SizeSimple, prompt.SizeFull} { + for _, mode := range []prompt.Mode{prompt.ModeNone, prompt.ModeSimple, prompt.ModeFull} { r := req - r.promptSize = size + r.promptSize = mode result, _, err := composeSurface(r) - testassert.Require(t, err == nil, "composeSurface(%s): %v", size, err) - direct := prompt.NewService(req.home).Assemble(req.workspace, req.sessionStart, prompt.Spec{Size: size}) - testassert.Require(t, result.Prompt == direct.Prompt, "composeSurface(%s) prompt diverged from the direct assembly", size) - if size == prompt.SizeNone { + testassert.Require(t, err == nil, "composeSurface(%s): %v", mode, err) + rules, err := prompt.ReadRules(req.globalRulesDir, req.workspace, mode) + testassert.NoError(t, err) + direct, err := prompt.Assemble(prompt.AssemblyInput{ + Identity: req.hostIdentity, + Mode: mode, + Rules: rules, + Workspace: req.workspace, + SessionStart: req.sessionStart, + OS: goruntime.GOOS, + Shell: captureShell(), + OSDescription: captureOSDescription(), + }) + testassert.NoError(t, err) + testassert.Require(t, result.Prompt == direct, "composeSurface(%s) prompt diverged from the direct assembly", mode) + if mode == prompt.ModeNone { // None skips the rules reads: no warnings even though no rules - // file exists under the isolated home. + // file exists under the explicit directories. testassert.Require(t, len(result.Warnings) == 0, "none-size warnings = %#v, want none", result.Warnings) } else if len(result.Warnings) != 1 || result.Warnings[0].Kind != prompt.WarnRulesNotFound { - t.Fatalf("%s-size warnings = %#v, want the rules_not_found warning", size, result.Warnings) + t.Fatalf("%s-size warnings = %#v, want the rules_not_found warning", mode, result.Warnings) } } } func TestComposeSurfaceRulesPrecedenceAndOverrides(t *testing.T) { - home := t.TempDir() - testassert.NoError(t, os.MkdirAll(filepath.Join(home, ".lightcode"), 0o700)) - testassert.NoError(t, os.WriteFile(filepath.Join(home, ".lightcode", "AGENTS.md"), []byte("GLOBAL CONTENT"), 0o600)) + globalDir := t.TempDir() + testassert.NoError(t, os.WriteFile(filepath.Join(globalDir, "AGENTS.md"), []byte("GLOBAL CONTENT"), 0o600)) workspace := t.TempDir() testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("PROJECT CONTENT"), 0o600)) req := baseSurfaceRequest(t) - req.home, req.workspace = home, workspace + req.globalRulesDir, req.workspace = globalDir, workspace result, _, err := composeSurface(req) testassert.NoError(t, err) testassert.Require(t, strings.Contains(result.Prompt, "GLOBAL CONTENT") && strings.Contains(result.Prompt, "PROJECT CONTENT"), "the composed prompt lost a rules layer") @@ -232,91 +248,188 @@ func TestComposeSurfaceRulesPrecedenceAndOverrides(t *testing.T) { testassert.Require(t, strings.Contains(result.Prompt, "Project tone rule."), "the overriding project tone rule is missing") } -func TestComposeSurfaceBlocksAndAdditionsRender(t *testing.T) { +func TestComposeSurfaceAppendsParagraphAfterAssembly(t *testing.T) { + workspace := t.TempDir() + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("USER_RULES_MARKER"), 0o600)) req := baseSurfaceRequest(t) - req.promptBody = "Do the task." - req.adaptation = &fakeAdaptation{value: Adaptation{ - Blocks: []string{"COACHING BLOCK"}, - Additions: map[string]string{"safety": "ADDITION SAFETY"}, - }} + req.workspace = workspace + baseline, _, err := composeSurface(req) + testassert.NoError(t, err) + req.adaptation = &fakeAdaptation{value: Adaptation{Append: []string{" APPENDED PARAGRAPH "}}} result, _, err := composeSurface(req) testassert.NoError(t, err) - testassert.Require(t, strings.Contains(result.Prompt, "COACHING BLOCK") && strings.Contains(result.Prompt, "ADDITION SAFETY"), "the adaptation blocks or additions did not render") - testassert.Require(t, strings.Index(result.Prompt, "COACHING BLOCK") < strings.Index(result.Prompt, "## Your Role and Instructions"), "coaching blocks did not render before the agent body and rules") + testassert.Equal(t, result.Prompt, baseline.Prompt+"\n\n APPENDED PARAGRAPH ") + for _, retained := range []string{"FIXTURE_IDENTITY", "## Core Rules", "USER_RULES_MARKER", "## Environment"} { + testassert.Require(t, strings.Contains(result.Prompt, retained), "the append lost the assembled %q", retained) + } +} + +func TestComposeSurfaceCustomPromptAppendAppliesInEveryMode(t *testing.T) { + for _, mode := range []prompt.Mode{prompt.ModeFull, prompt.ModeSimple, prompt.ModeNone} { + t.Run(string(mode), func(t *testing.T) { + req := baseSurfaceRequest(t) + req.promptSize = mode + req.promptBody = "DEFAULT_INSTRUCTIONS" + baseline, _, err := composeSurface(req) + testassert.NoError(t, err) + req.adaptation = &fakeAdaptation{value: Adaptation{Append: []string{" CUSTOM_APPEND\n"}}} + result, _, err := composeSurface(req) + testassert.NoError(t, err) + testassert.Equal(t, result.Prompt, baseline.Prompt+"\n\n CUSTOM_APPEND\n") + }) + } +} + +func TestComposeSurfaceZeroValueAdaptationIsNoOp(t *testing.T) { + req := baseSurfaceRequest(t) + req.sessionStart = time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + baseline, _, err := composeSurface(req) + testassert.NoError(t, err) + req.adaptation = &fakeAdaptation{value: Adaptation{}} + got, _, err := composeSurface(req) + testassert.NoError(t, err) + testassert.Equal(t, got.Prompt, baseline.Prompt) +} + +func TestComposeSurfaceInvalidModeFailsBeforeRulesRead(t *testing.T) { + req := baseSurfaceRequest(t) + globalDir := t.TempDir() + testassert.NoError(t, os.Mkdir(filepath.Join(globalDir, "AGENTS.md"), 0o700)) + req.globalRulesDir = globalDir + req.promptSize = "bogus" + result, advertised, err := composeSurface(req) + testassert.Require(t, errors.Is(err, prompt.ErrInvalidInput), "composeSurface invalid mode error = %v, want ErrInvalidInput", err) + testassert.Require(t, result.Prompt == "" && len(result.Warnings) == 0 && advertised == nil, "invalid mode produced a surface: %+v", result) } func TestComposeSurfaceDescriptionReplacements(t *testing.T) { - const description = "Edit via one call." + const description = "Edit edit_file or write_file via one call." req := baseSurfaceRequest(t) - req.toolSpecs = []CapabilitySpec{staticToolSpec("read_file", description, true, false)} + req.toolSpecs = []CapabilitySpec{staticToolSpec("read_file", description, true)} - // No adaptation: the bundled default replacement applies. + // No adaptation: the literal description is advertised unchanged. _, advertised, err := composeSurface(req) testassert.NoError(t, err) testassert.Equal(t, advertised[0].Description, "Edit edit_file or write_file via one call.") - // The shipped GPT row's entry override applies instead. + // The shipped GPT row's literal override applies instead. req.adaptation = &fakeAdaptation{value: Adaptation{ - ToolDescriptionReplacements: map[string]string{"": "apply_patch"}, + ToolDescriptionReplacements: map[string]string{"edit_file or write_file": "apply_patch"}, }} _, advertised, err = composeSurface(req) testassert.NoError(t, err) testassert.Equal(t, advertised[0].Description, "Edit apply_patch via one call.") } +// bundledGPTGuidanceLine and bundledGPTGuidanceReplacement are the complete +// literal standard run_command guidance line the bundled GPT rows replace and +// its GPT form: the data names the whole line, so no other prose carrying the +// phrase can be rewritten. +const bundledGPTGuidanceLine = "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file." +const bundledGPTGuidanceReplacement = "- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use apply_patch." + +// TestComposeSurfaceBundledGPTDescriptionReplacementIsLiteral proves the +// shipped GPT replacement is a literal whole-line rule over the REAL standard +// run_command description composed with the REAL bundled matcher: the final +// text is exactly the standard description with that one line rewritten (the +// same text the previous broad phrase key produced), while an unrelated +// optional tool whose text merely names the same phrase is untouched. Nil, +// unselected and readonly behavior stay as before. +func TestComposeSurfaceBundledGPTDescriptionReplacementIsLiteral(t *testing.T) { + const unrelated = "Edit edit_file or write_file via one call." + descriptions := func(advertised []model.ToolDefinition) map[string]string { + out := make(map[string]string, len(advertised)) + for _, definition := range advertised { + out[definition.Name] = definition.Description + } + return out + } + req := baseSurfaceRequest(t) + req.toolSpecs = []CapabilitySpec{ + ToolSpec("run_command", runCommandTool{}.describe), + staticToolSpec("note_editor", unrelated, true), + } + + // Unselected: with no active adaptation each tool advertises its own text. + _, advertised, err := composeSurface(req) + testassert.NoError(t, err) + { + got := descriptions(advertised) + testassert.Require(t, len(got) == 2 && got["run_command"] == runCommandDescription && got["note_editor"] == unrelated, "baseline descriptions = %q, want each tool's own text", got) + } + + want := strings.Replace(runCommandDescription, bundledGPTGuidanceLine, bundledGPTGuidanceReplacement, 1) + testassert.Require(t, strings.ReplaceAll(runCommandDescription, "edit_file or write_file", "apply_patch") == want, "the literal expectation is not the text the previous broad phrase key composed") + req.modelRef = model.ModelRef{Provider: "prov", Model: "gpt-5.5"} + req.adaptation = &bundledAdaptation{} + _, advertised, err = composeSurface(req) + testassert.NoError(t, err) + { + got := descriptions(advertised) + testassert.Require(t, got["run_command"] == want, "GPT run_command description = %q, want the standard text with only its guidance line rewritten", got["run_command"]) + testassert.Require(t, got["note_editor"] == unrelated, "GPT unrelated description = %q, want the bundled whole-line rule to leave it unchanged", got["note_editor"]) + } + + // Readonly swaps run_command to its retained read-only text and toggles + // availability only: the bundled whole-line rule names the standard + // guidance line, so the read-only text stays exactly as the tool declares + // it and both tools remain advertised. + req.constraints = ToolConstraints{Readonly: true} + _, advertised, err = composeSurface(req) + testassert.NoError(t, err) + { + got := descriptions(advertised) + testassert.Require(t, got["run_command"] == tool.ReadOnlyRunCommandDescription && got["note_editor"] == unrelated, "readonly GPT descriptions = %q, want the read-only text and the untouched unrelated text", got) + } +} + func TestComposeSurfaceAdvertisement(t *testing.T) { req := baseSurfaceRequest(t) - // Unselected: only the available, non-hidden tool is advertised. + // Unselected: every eligible tool is advertised in declaration order. _, advertised, err := composeSurface(req) testassert.NoError(t, err) { names := advertisedNames(advertised) - testassert.Require(t, reflect.DeepEqual(names, []string{"read_file"}), "baseline advertisement = %q, want [read_file]", names) + testassert.Require(t, reflect.DeepEqual(names, []string{"read_file", "apply_patch"}), "baseline advertisement = %q, want the declaration order", names) } - // The hidden tool appears only through the include list. - req.adaptation = &fakeAdaptation{value: Adaptation{IncludeTools: []string{"apply_patch"}}} + // The adaptation only subtracts: the exclusion removes one selected tool. + req.adaptation = &fakeAdaptation{value: Adaptation{ExcludeTools: []string{"read_file"}}} _, advertised, err = composeSurface(req) testassert.NoError(t, err) { names := advertisedNames(advertised) - testassert.Require(t, reflect.DeepEqual(names, []string{"read_file", "apply_patch"}), "included advertisement = %q, want the declaration order", names) + testassert.Require(t, reflect.DeepEqual(names, []string{"apply_patch"}), "excluded advertisement = %q, want [apply_patch]", names) } - // Exclusion always wins over inclusion. - req.adaptation = &fakeAdaptation{value: Adaptation{ - ExcludeTools: []string{"read_file"}, - IncludeTools: []string{"read_file", "apply_patch"}, - }} + // The nearest forbidden sibling: an exclusion naming nothing selects no + // tool removal, and an unknown name removes none either. + req.adaptation = &fakeAdaptation{value: Adaptation{ExcludeTools: []string{"absent_tool"}}} _, advertised, err = composeSurface(req) testassert.NoError(t, err) { names := advertisedNames(advertised) - testassert.Require(t, reflect.DeepEqual(names, []string{"apply_patch"}), "exclusion-wins advertisement = %q, want [apply_patch]", names) + testassert.Require(t, reflect.DeepEqual(names, []string{"read_file", "apply_patch"}), "unknown-name advertisement = %q, want the untouched surface", names) } } func TestComposeSurfaceReadonlyCannotRegainMutationTools(t *testing.T) { req := baseSurfaceRequest(t) req.toolSpecs = []CapabilitySpec{ - staticToolSpec("read_file", "read_file description", true, false), - mutationToolSpec("write_file", false), - mutationToolSpec("edit_file", false), - mutationToolSpec("apply_patch", true), + staticToolSpec("read_file", "read_file description", true), + mutationToolSpec("write_file"), + mutationToolSpec("edit_file"), + mutationToolSpec("apply_patch"), } req.constraints = ToolConstraints{Readonly: true} - req.adaptation = &fakeAdaptation{value: Adaptation{ - IncludeTools: []string{"write_file", "edit_file", "apply_patch"}, - }} _, advertised, err := composeSurface(req) testassert.NoError(t, err) { names := advertisedNames(advertised) testassert.Require(t, reflect.DeepEqual(names, []string{"read_file"}), "readonly advertisement = %q, want only the eligible read_file", names) } - // The nearest sibling: with a write dir the tools are eligible and the - // include surfaces the hidden one. + // The nearest sibling: with a write dir the tools are eligible. req.constraints = ToolConstraints{Readonly: true, WriteDir: "/tmp/plan"} _, advertised, err = composeSurface(req) testassert.NoError(t, err) @@ -343,8 +456,8 @@ func TestComposeSurfaceResolvesExactlyOnce(t *testing.T) { testassert.NoError(t, err) } testassert.Equal(t, fake.calls, 1) - if fake.ref != req.modelRef { - t.Fatalf("Resolve received %s, want %s", fake.ref.String(), req.modelRef.String()) + if fake.ref != req.modelRef || fake.mode != req.promptSize { + t.Fatalf("Resolve received %s/%q, want %s/%q", fake.ref.String(), fake.mode, req.modelRef.String(), req.promptSize) } } @@ -362,11 +475,23 @@ func TestComposeSurfaceUnselectedAdaptationKeepsBaselinePrompt(t *testing.T) { req.sessionStart = start result, advertised, err := composeSurface(req) testassert.NoError(t, err) - direct := prompt.NewService(req.home).Assemble(req.workspace, start, prompt.Spec{Size: prompt.SizeFull}) - testassert.Require(t, result.Prompt == direct.Prompt, "the unselected adaptation changed the baseline prompt") + rules, err := prompt.ReadRules(req.globalRulesDir, req.workspace, prompt.ModeFull) + testassert.NoError(t, err) + direct, err := prompt.Assemble(prompt.AssemblyInput{ + Identity: req.hostIdentity, + Mode: prompt.ModeFull, + Rules: rules, + Workspace: req.workspace, + SessionStart: start, + OS: goruntime.GOOS, + Shell: captureShell(), + OSDescription: captureOSDescription(), + }) + testassert.NoError(t, err) + testassert.Require(t, result.Prompt == direct, "the unselected adaptation changed the baseline prompt") { names := advertisedNames(advertised) - testassert.Require(t, reflect.DeepEqual(names, []string{"read_file"}), "baseline advertisement = %q, want the full eligible surface", names) + testassert.Require(t, reflect.DeepEqual(names, []string{"read_file", "apply_patch"}), "baseline advertisement = %q, want the full eligible surface", names) } } @@ -407,7 +532,7 @@ func TestComposeSurfaceExcludesLineageUnavailableTools(t *testing.T) { testassert.NoError(t, err) { names := advertisedNames(root) - testassert.Require(t, reflect.DeepEqual(names, []string{"read_file", "task"}), "root advertisement = %q, want the lineage-gated tool present alongside its sibling", names) + testassert.Require(t, reflect.DeepEqual(names, []string{"read_file", "apply_patch", "task"}), "root advertisement = %q, want the lineage-gated tool present alongside its siblings", names) } childReq := req @@ -416,7 +541,7 @@ func TestComposeSurfaceExcludesLineageUnavailableTools(t *testing.T) { testassert.NoError(t, err) { names := advertisedNames(child) - testassert.Require(t, reflect.DeepEqual(names, []string{"read_file"}), "child advertisement = %q, want the lineage-gated tool excluded", names) + testassert.Require(t, reflect.DeepEqual(names, []string{"read_file", "apply_patch"}), "child advertisement = %q, want the lineage-gated tool excluded", names) } } diff --git a/runtime/background_lifecycle_test.go b/runtime/background_lifecycle_test.go index abd73e7d..6899e0d7 100644 --- a/runtime/background_lifecycle_test.go +++ b/runtime/background_lifecycle_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "path/filepath" "sync" @@ -13,6 +14,7 @@ import ( "github.com/MMinasyan/lightcode/harness" "github.com/MMinasyan/lightcode/internal/agents" + "github.com/MMinasyan/lightcode/internal/jobs" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" ) @@ -169,6 +171,23 @@ func (s *lifecycleStopper) addDelivery(sessionID, completionID, content string) s.deliveries = append(s.deliveries, lifecycleDelivery{sessionID: sessionID, completionID: completionID, content: content}) } +// The remaining jobsService methods are inert for these rows: the stop seam is +// what they drive. +func (s *lifecycleStopper) Reserve() (string, error) { return "stubjob1", nil } +func (s *lifecycleStopper) Abort(string) {} +func (s *lifecycleStopper) Start(jobs.StartRequest) error { + return errors.New("lifecycle jobs service starts no process") +} +func (s *lifecycleStopper) Read(string, string) (string, error) { + return "", errors.New("lifecycle jobs service reads nothing") +} +func (s *lifecycleStopper) Kill(string, string) error { + return errors.New("lifecycle jobs service kills nothing") +} +func (s *lifecycleStopper) List(string) string { return "" } +func (s *lifecycleStopper) Live(string) []string { return nil } +func (s *lifecycleStopper) Close() error { return nil } + func (s *lifecycleStopper) StopJob(sessionID, jobID string) { s.mu.Lock() deliveries := s.deliveries @@ -400,16 +419,16 @@ type bgRuntime struct { tools *lifecycleTools } -func openBackgroundRuntime(t *testing.T, store harness.Storage, stopper harness.JobStopper) *bgRuntime { +func openBackgroundRuntime(t *testing.T, store harness.Storage, stopper jobsService) *bgRuntime { t.Helper() e := newOwnerEnv(t) writeServiceFile(t, e.configPath, backgroundLifecycleConfigDocument(e.server.URL)) writeServiceFile(t, agents.PathForConfig(e.configPath), lifecycleAgentsDocument) hook := &recordHook{name: "hook.first", events: &traceLog{}} tools := &lifecycleTools{} + e.fixtureStore(store) + e.fixtureJobs(stopper) r, err := e.open(context.Background(), - e.storagePlugin(store), - jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper), hookPlugin(hook), lifecycleToolsPlugin(tools)) testassert.NoError(t, err) diff --git a/runtime/background_lifecycle_tools_test.go b/runtime/background_lifecycle_tools_test.go index 4dc233de..01b2070b 100644 --- a/runtime/background_lifecycle_tools_test.go +++ b/runtime/background_lifecycle_tools_test.go @@ -1,4 +1,4 @@ -package runtime_test +package runtime import ( "context" @@ -15,18 +15,14 @@ import ( "github.com/MMinasyan/lightcode/internal/storage" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/plugins/jobs" - "github.com/MMinasyan/lightcode/runtime" ) -// The real-plugin background-lifecycle rows: the jobs capability, the -// run_command background start, and the process tool run against real -// background processes through the composed tools fixture — the real jobs -// and tools plugins composed in one scope over a real Harness — with the -// per-Session job limit, the retained immediate and limit texts, and the -// per-Session stop scope proving no process-global stop. The in-package -// suite composes the same rows through the Runtime's own surfaces where the -// plugin import cycle allows it. +// The real-jobs background-lifecycle rows: the jobs service, the run_command +// background start, and the process tool run against real background +// processes through the composed tools fixture — the directly constructed +// baseline tools over the real jobs service on one data root and a real +// Harness — with the per-Session job limit, the retained immediate and limit +// texts, and the per-Session stop scope proving no process-global stop. // backgroundIDPattern extracts one started background job identity from a // retained immediate result. @@ -56,8 +52,8 @@ func lifecycleRequestText(req model.Request) string { return b.String() } -// lastUserText returns the last user message's text of one model request. -func lastUserText(req model.Request) string { +// requestUserText returns the last user message's text of one model request. +func requestUserText(req model.Request) string { for i := len(req.Messages) - 1; i >= 0; i-- { if req.Messages[i].Role != "user" { continue @@ -92,10 +88,10 @@ func lastToolText(req model.Request) string { return text.String() } -// lastMessageRole returns the wire role of one model request's final +// requestMessageRole returns the wire role of one model request's final // message: a tool result ends the current operation's follow-up turn, while // an earlier operation's tool results must not short-circuit a new one. -func lastMessageRole(req model.Request) model.Role { +func requestMessageRole(req model.Request) model.Role { if len(req.Messages) == 0 { return "" } @@ -170,8 +166,8 @@ func testJobsAcrossSessions(t *testing.T, store harness.Storage) { reads := 0 readCallID := "call-b-read" modelFn := func(_ context.Context, req model.Request) (model.Stream, error) { - prompt := lastUserText(req) - if lastMessageRole(req) == "tool" { + prompt := requestUserText(req) + if requestMessageRole(req) == "tool" { // Readiness rendezvous through the model loop: while the turn // following the read call still lacks the process output (the // capture returned its placeholder), emit another read instead @@ -213,10 +209,10 @@ func testJobsAcrossSessions(t *testing.T, store harness.Storage) { return stopStream("done"), nil } } - invocation, err := runtime.ConfiguredInvocationForTest(map[string]string{"jobs": `{"max_background_processes":1}`}) + invocation, err := ConfiguredInvocationForTest("", `{"max_background_processes":1}`, "", nil) testassert.NoError(t, err) th := newToolsHarnessWith(t, modelFn, toolsHarnessOpts{ - jobsPlugin: jobs.Plugin(), + realJobs: true, advertise: []string{"run_command", "process"}, background: background, invocation: invocation, diff --git a/runtime/background_shutdown_test.go b/runtime/background_shutdown_test.go index 13c44eeb..12971234 100644 --- a/runtime/background_shutdown_test.go +++ b/runtime/background_shutdown_test.go @@ -4,20 +4,23 @@ import ( "context" "encoding/json" "errors" + "os" "path/filepath" - "strings" + "sync" + "sync/atomic" "testing" "time" "github.com/MMinasyan/lightcode/harness" + "github.com/MMinasyan/lightcode/internal/jobs" "github.com/MMinasyan/lightcode/internal/storage" "github.com/MMinasyan/lightcode/internal/testassert" ) -// The job-stop seam fixtures: one stub records stop invocations and, when -// armed with a live Harness and a captured completion identity, delivers the -// stopped job's terminal report — the plugin-side delivery shape the Runtime -// drives through Dependencies.Jobs. +// The jobs-service fixtures: one scripted service records stop invocations +// and, when armed with a live Harness and a captured completion identity, +// delivers the stopped job's terminal report. The owner's construction seam +// (newJobs) is pointed at it; no composition declaration supplies the service. type stubStopper struct { events *traceLog @@ -25,29 +28,53 @@ type stubStopper struct { completionID string content string deliverErr error + stopped []string } func (s *stubStopper) StopJob(sessionID, jobID string) { - s.events.add("stopjob") + s.stopped = append(s.stopped, sessionID+"/"+jobID) + if s.events != nil { + s.events.add("stopjob") + } if s.h == nil { return } s.deliverErr = s.h.DeliverBackgroundCompletion(context.Background(), sessionID, s.completionID, s.content) } -// jobStopperPlugin declares one export of the given declared type over the -// seam value, recording its construction and disposal order. -func jobStopperPlugin(e *ownerEnv, id, exportID string, scope ScopeKind, value any) Plugin { +// The remaining jobsService methods are inert for these rows: the stop seam +// and the shutdown close are what they drive. +func (s *stubStopper) Reserve() (string, error) { return "stubjob1", nil } +func (s *stubStopper) Abort(string) {} +func (s *stubStopper) Start(jobs.StartRequest) error { + return errors.New("stub jobs service starts no process") +} +func (s *stubStopper) Read(string, string) (string, error) { + return "", errors.New("stub jobs service reads nothing") +} +func (s *stubStopper) Kill(string, string) error { + return errors.New("stub jobs service kills nothing") +} +func (s *stubStopper) List(string) string { return "" } +func (s *stubStopper) Live(string) []string { return nil } + +func (s *stubStopper) Close() error { + if s.events != nil { + s.events.add("close:jobs") + } + return nil +} + +// jobStopperExportPlugin declares one ordinary capability export of the +// seam's declared type: the composition treats it like any other value, and +// the owner's constructed service stays the seam. +func jobStopperExportPlugin(id string, value harness.JobStopper) Plugin { return Plugin{ ID: id, - Scope: scope, - Provides: []CapabilitySpec{Spec[harness.JobStopper](exportID)}, + Scope: ScopeRuntime, + Provides: []CapabilitySpec{Spec[harness.JobStopper](id + ".stopper")}, Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { - e.events.add("open:" + id) - return Instance{ - Values: map[string]any{exportID: value}, - Close: func() error { e.events.add("close:" + id); return nil }, - }, nil + return Instance{Values: map[string]any{id + ".stopper": value}}, nil }, } } @@ -60,15 +87,18 @@ func startJobThrough(r *Runtime, sessionID, jobID string, spawn func(context.Con }) } -// startLiveJobRuntime opens one Runtime with a delivering job-stop seam and -// starts one live job on a fresh Session over the given store, arming the -// seam to deliver the given terminal report when shutdown stops the job. +// startLiveJobRuntime opens one Runtime whose constructed jobs service is a +// scripted stopper, and starts one live job on a fresh Session over the given +// store, arming the stopper to deliver the given terminal report when shutdown +// stops the job. func startLiveJobRuntime(t *testing.T, store harness.Storage, content string) (*ownerEnv, *Runtime, harness.Storage, *stubStopper, string) { t.Helper() ctx := context.Background() e := newOwnerEnv(t) stopper := &stubStopper{events: e.events, content: content} - r, err := e.open(ctx, e.storagePlugin(store), jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper)) + e.fixtureStore(store) + e.fixtureJobs(stopper) + r, err := e.open(ctx) testassert.NoError(t, err) session, err := r.createSession(ctx, filepath.Join(e.home, "live-job-ws"), "solo") testassert.NoError(t, err) @@ -85,53 +115,28 @@ func startLiveJobRuntime(t *testing.T, store harness.Storage, content string) (* return e, r, store, stopper, session.Identity.SessionID } -// TestRuntimeJobsCapabilityWiring pins the job-stopper export contract: the -// seam stays nil without an export or under a narrower declared type, binds -// exactly one Runtime-scoped export declared as harness.JobStopper, and -// rejects typed nils, ambiguity, and non-Runtime scopes before Harness -// publication. -func TestRuntimeJobsCapabilityWiring(t *testing.T) { +// TestRuntimeJobsSeamIsDirectlyBound pins the job-stop seam's construction: +// direct construction binds it with no export at all — StartJob admits and +// hands the spawn the reserved completion identity, and its error returns +// unchanged — while a plugin export of the seam's own declared type is an +// ordinary capability that never becomes the seam. +func TestRuntimeJobsSeamIsDirectlyBound(t *testing.T) { ctx := context.Background() errSpawn := errors.New("spawn ran past the seam") - startFor := func(t *testing.T, e *ownerEnv, r *Runtime, spawned *bool) error { - t.Helper() - session, err := r.createSession(ctx, filepath.Join(e.home, "wiring-ws"), "solo") - testassert.NoError(t, err) - return startJobThrough(r, session.Identity.SessionID, "0a1b2c3d", func(context.Context, string) error { - *spawned = true - return nil - }) - } - assertUnbound := func(t *testing.T, err error, spawned bool) { - t.Helper() - testassert.Require(t, errors.Is(err, harness.ErrInvalid) && strings.Contains(err.Error(), "no job stopper configured"), "StartJob = %v, want the unbound-seam rejection", err) - testassert.Require(t, !spawned, "spawn ran without a bound seam") - } - closeOK := func(t *testing.T, r *Runtime) { - t.Helper() - testassert.NoError(t, r.Close(ctx)) - } - - t.Run("absent export leaves the seam nil", func(t *testing.T) { - e := newOwnerEnv(t) - r, err := e.open(ctx, e.storagePlugin(storage.NewMemory())) - testassert.NoError(t, err) - defer closeOK(t, r) - spawned := false - assertUnbound(t, startFor(t, e, r, &spawned), spawned) - }) - - t.Run("binds the one exact declared type", func(t *testing.T) { + t.Run("the seam is bound with no export at all", func(t *testing.T) { e := newOwnerEnv(t) - stopper := &stubStopper{events: e.events} - r, err := e.open(ctx, e.storagePlugin(storage.NewMemory()), jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper)) + e.fixtureStore(storage.NewMemory()) + e.fixtureJobs(&stubStopper{events: e.events}) + r, err := e.open(ctx) testassert.NoError(t, err) - defer closeOK(t, r) + defer func() { testassert.NoError(t, r.Close(ctx)) }() session, err := r.createSession(ctx, filepath.Join(e.home, "wiring-ws"), "solo") testassert.NoError(t, err) - var completionID string + spawned := false + completionID := "" err = startJobThrough(r, session.Identity.SessionID, "0a1b2c3d", func(_ context.Context, id string) error { + spawned = true completionID = id return errSpawn }) @@ -139,55 +144,208 @@ func TestRuntimeJobsCapabilityWiring(t *testing.T) { // completion identity and its error returns unchanged (the // spawn-failure path finishes the member, so shutdown stops nothing). testassert.Require(t, errors.Is(err, errSpawn), "StartJob = %v, want the spawn error through the bound seam", err) - testassert.Require(t, completionID != "", "spawn received no completion identity") + testassert.Require(t, spawned && completionID != "", "spawn ran=%v completion identity %q, want the call admitted with its completion identity", spawned, completionID) }) - t.Run("a narrower declared type leaves the seam nil", func(t *testing.T) { + t.Run("an exported seam value is inert", func(t *testing.T) { e := newOwnerEnv(t) - narrow := Plugin{ - ID: "narrow", - Scope: ScopeRuntime, - Provides: []CapabilitySpec{Spec[*stubStopper]("narrow.stopper")}, - Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { - return Instance{Values: map[string]any{"narrow.stopper": &stubStopper{events: e.events}}}, nil - }, - } - r, err := e.open(ctx, e.storagePlugin(storage.NewMemory()), narrow) + constructed := &stubStopper{events: e.events, content: "job stopped"} + exported := &stubStopper{events: e.events} + e.fixtureStore(storage.NewMemory()) + e.fixtureJobs(constructed) + r, err := e.open(ctx, + jobStopperExportPlugin("shadow", exported)) testassert.NoError(t, err) - defer closeOK(t, r) - spawned := false - assertUnbound(t, startFor(t, e, r, &spawned), spawned) + session, err := r.createSession(ctx, filepath.Join(e.home, "wiring-ws"), "solo") + testassert.NoError(t, err) + var completionID string + err = startJobThrough(r, session.Identity.SessionID, "0a1b2c3d", func(_ context.Context, id string) error { + completionID = id + return nil + }) + testassert.NoError(t, err) + constructed.h = r.harness + constructed.completionID = completionID + testassert.NoError(t, r.Close(ctx)) + { + got := constructed.stopped + testassert.Require(t, len(got) == 1 && got[0] == session.Identity.SessionID+"/0a1b2c3d", "constructed service stops = %v, want the live job", got) + } + testassert.Require(t, len(exported.stopped) == 0, "exported seam value stops = %v, want none", exported.stopped) + testassert.Require(t, constructed.deliverErr == nil, "stopped job delivery: %v", constructed.deliverErr) }) +} - t.Run("typed nil rejects before Harness publication", func(t *testing.T) { - e := newOwnerEnv(t) - var absent *stubStopper // typed nil: assignable to the seam, no verifiable instance - wantFailedOpen(t, e, []Plugin{ - e.storagePlugin(storage.NewMemory()), - jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, absent), - }, ErrComposition) - e.assertLockReleased(t) - }) +// realJobsProbe wraps the real jobs manager: Start wraps the received exit +// callback with the two bounded barriers (before the original delivery, and +// after it returns but before the callback returns), and Close records its +// entry and return while delegating the real workgroup join. +type realJobsProbe struct { + *jobs.Manager + events *traceLog - t.Run("ambiguity rejects before Harness publication", func(t *testing.T) { - e := newOwnerEnv(t) - stopper := &stubStopper{events: e.events} - wantFailedOpen(t, e, []Plugin{ - e.storagePlugin(storage.NewMemory()), - jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper), - jobStopperPlugin(e, "jobs.second", "job-stopper.second", ScopeRuntime, stopper), - }, ErrComposition) - e.assertLockReleased(t) + exitEntered chan struct{} + exitDelivered chan struct{} + exitRelease chan struct{} + exitReturn chan struct{} + exits atomic.Int64 + jobID string +} + +func (p *realJobsProbe) Start(req jobs.StartRequest) error { + original := req.OnExit + p.jobID = req.JobID + req.OnExit = func(er jobs.ExitResult) { + p.exits.Add(1) + signalOnce(p.exitEntered) + <-p.exitRelease + if original != nil { + original(er) + } + signalOnce(p.exitDelivered) + <-p.exitReturn + } + return p.Manager.Start(req) +} + +func (p *realJobsProbe) Close() error { + p.events.add("jobclose:enter") + err := p.Manager.Close() + p.events.add("jobclose:return") + return err +} + +// signalOnce publishes one non-blocking arrival signal. +func signalOnce(ch chan struct{}) { + select { + case ch <- struct{}{}: + default: + } +} + +// awaitSignal waits for one bounded arrival signal. +func awaitSignal(t *testing.T, ch chan struct{}, what string) { + t.Helper() + select { + case <-ch: + case <-time.After(10 * time.Second): + t.Fatalf("%s never arrived", what) + } +} + +// TestRuntimeShutdownJoinsRealJobsWorkGroup extends the owner shutdown rows to +// a REAL jobs manager and a real background shell process started through the +// real run_command tool over the ordinary admission path. The wrapped exit +// callback parks twice: before the original delivery (no durable completion, +// no service close, lock held) and after it returns (completion durable and +// the Harness member joined, while the owner's real jobs close stays joined on +// the manager workgroup with storage and lock still open). Releasing the +// callback converges the manager, then storage closes and the lock releases in +// order. +func TestRuntimeShutdownJoinsRealJobsWorkGroup(t *testing.T) { + ctx := context.Background() + e := newOwnerEnv(t) + // lockHeld reports whether an independent contender can acquire the data + // root: while the owner holds the lock, its open fails with ErrOwned before + // any store or factory. + lockHeld := func() bool { + _, err := open(context.Background(), options{DataDir: e.dataDir, ConfigPath: e.configPath}) + return errors.Is(err, ErrOwned) + } + probe := &realJobsProbe{ + Manager: jobs.New(e.dataDir), + events: e.events, + exitEntered: make(chan struct{}, 1), + exitDelivered: make(chan struct{}, 1), + exitRelease: make(chan struct{}), + exitReturn: make(chan struct{}), + } + var releaseOnce, returnOnce sync.Once + release := func() { releaseOnce.Do(func() { close(probe.exitRelease) }) } + releaseReturn := func() { returnOnce.Do(func() { close(probe.exitReturn) }) } + t.Cleanup(func() { // a failed row must release both barriers and join the manager + release() + releaseReturn() + _ = probe.Manager.Close() }) - t.Run("non-Runtime scope rejects before Harness publication", func(t *testing.T) { - e := newOwnerEnv(t) - wantFailedOpen(t, e, []Plugin{ - e.storagePlugin(storage.NewMemory()), - jobStopperPlugin(e, "jobs", "job-stopper", ScopeWorkspace, &stubStopper{events: e.events}), - }, ErrComposition) - e.assertLockReleased(t) + store := storage.NewMemory() + e.fixtureStoreClose(store, func() error { e.events.add("close:core"); return nil }) + e.fixtureJobs(probe) + e.server.setScript(func(_ context.Context, body string) []string { + if lastMessageRole(body) == "tool" { + return textTurnEvents("done") + } + return toolCallTurnEvents("call-1", "run_command", `{"command":"sleep 60","background":true}`) }) + + workspace := filepath.Join(e.home, "real-job-ws") + testassert.NoError(t, os.MkdirAll(workspace, 0o700)) + r, err := e.open(ctx) + testassert.NoError(t, err) + session, err := r.createSession(ctx, workspace, "solo") + testassert.NoError(t, err) + sessionID := session.Identity.SessionID + submitThroughRuntime(t, r, sessionID, "op-1", "start the background process") + awaitOperation(t, r, sessionID, "op-1", harness.OperationSuccess) + testassert.Require(t, probe.jobID != "", "the background start reserved no job identity") + testassert.Require(t, len(completionsOf(store, sessionID)) == 0, "completions before shutdown = %v, want the live job still owing its report", completionsOf(store, sessionID)) + + // Close while the real job is live: the stop reaps the process, then the + // wrapped callback parks before its original delivery. + closeDone := make(chan error, 1) + go func() { closeDone <- r.Close(ctx) }() + awaitSignal(t, probe.exitEntered, "the job exit callback's pre-delivery barrier") + { + testassert.Require(t, len(completionsOf(store, sessionID)) == 0, "completions at the pre-delivery barrier = %v, want none yet", completionsOf(store, sessionID)) + testassert.Require(t, eventsNamed(e.events.all(), "jobclose:enter") == 0 && eventsNamed(e.events.all(), "close:core") == 0, "service events at the pre-delivery barrier = %v, want no jobs or storage cleanup", e.events.all()) + testassert.Require(t, lockHeld(), "ownership lock at the pre-delivery barrier = free, want it held") + select { + case err := <-closeDone: + t.Fatalf("Close returned before the job delivered its completion (%v)", err) + default: + } + } + + // The delivery runs: the durable completion lands and the Harness member + // finishes, so shutdown advances to the real jobs close — which stays + // joined on the manager workgroup because the callback has not returned. + release() + awaitSignal(t, probe.exitDelivered, "the wrapped callback's original delivery return") + deadline := time.Now().Add(10 * time.Second) + for eventsNamed(e.events.all(), "jobclose:enter") == 0 { + testassert.Require(t, !time.Now().After(deadline), "the owner's jobs close never entered after the member settled; events = %v", e.events.all()) + time.Sleep(5 * time.Millisecond) + } + { + completions := completionsOf(store, sessionID) + testassert.Require(t, len(completions) == 1 && completions[0].memberKind == "job" && completions[0].memberID == probe.jobID && completions[0].content != "", "completions after delivery = %+v, want the job %s with its report", completions, probe.jobID) + testassert.Require(t, eventsNamed(e.events.all(), "jobclose:return") == 0 && eventsNamed(e.events.all(), "close:core") == 0, "service events after delivery = %v, want the jobs close still joined and no storage close", e.events.all()) + testassert.Require(t, lockHeld(), "ownership lock after delivery = free, want it still held") + select { + case err := <-closeDone: + t.Fatalf("Close returned while the real jobs close was still joined on the manager workgroup (%v)", err) + default: + } + } + + // The callback returns: the manager workgroup joins, the jobs close + // returns, storage closes, and the lock releases in order. + releaseReturn() + select { + case err := <-closeDone: + testassert.NoError(t, err) + case <-time.After(10 * time.Second): + t.Fatal("Close never converged after the exit callback returned") + } + events := e.events.all() + testassert.Require(t, orderedSubset(events, "jobclose:enter", "jobclose:return", "close:core"), "shutdown events = %v, want the jobs close entry, its return, then the storage close", events) + testassert.Equal(t, probe.exits.Load(), int64(1)) + testassert.NoError(t, r.Close(ctx)) // repeat callers join the shared shutdown + testassert.Equal(t, probe.exits.Load(), int64(1)) + // The ownership lock released last: an independent owner acquires the same + // data root and closes cleanly. + e.assertLockReleased(t) } // TestRuntimeShutdownDeliversLiveJobCompletion pins the managed-shutdown @@ -226,29 +384,28 @@ func TestRuntimeShutdownDeliversLiveJobCompletion(t *testing.T) { testassert.Require(t, found, "shutdown with a live job recorded no background_completion signal entry") } -// TestRuntimeJobStopperDisposesBeforeStorage pins the disposal order of one +// TestRuntimeShutdownStopsJobsBeforeStorageClose pins the disposal order of one // managed shutdown. The unparked sibling proves the stop runs while both the -// job-stop capability and its storage are still open (the delivery succeeded) -// and the capability's scope Close runs before the storage Close. The parked -// row holds the stopped job's required terminal completion transaction at its -// real commit-return boundary over both stores: the durable completion is -// observable while Close stays joined behind it and no plugin scope Close has -// run; only after the release do the disposals record -// stopjob → close:jobs → close:core. -func TestRuntimeJobStopperDisposesBeforeStorage(t *testing.T) { +// jobs service and its storage are still open (the delivery succeeded) and the +// jobs service Close runs before the storage Close. The parked row holds the +// stopped job's required terminal completion transaction at its real +// commit-return boundary over both stores: the durable completion is observable +// while Close stays joined behind it and no service closer has run; only after +// the release do the disposals record stopjob → close:jobs → close:core. +func TestRuntimeShutdownStopsJobsBeforeStorageClose(t *testing.T) { ctx := context.Background() - t.Run("the stop runs while the scopes are open", func(t *testing.T) { + t.Run("the stop runs while the services are open", func(t *testing.T) { e, r, _, stopper, _ := startLiveJobRuntime(t, storage.NewMemory(), "job stopped") testassert.NoError(t, r.Close(ctx)) testassert.Require(t, stopper.deliverErr == nil, "stopped job delivery: %v", stopper.deliverErr) events := e.events.all() - testassert.Require(t, orderedSubset(events, "stopjob", "close:jobs", "close:core"), "shutdown events = %v, want the stop, then the jobs capability close, then the storage close", events) + testassert.Require(t, orderedSubset(events, "stopjob", "close:jobs", "close:core"), "shutdown events = %v, want the stop, then the jobs service close, then the storage close", events) }) eachPrepStore(t, func(t *testing.T, store harness.Storage) { - t.Run("no scope closes before the held terminal commit", func(t *testing.T) { + t.Run("no closer runs before the held terminal commit", func(t *testing.T) { gated := newGatedStore(store) e, r, _, stopper, sessionID := startLiveJobRuntime(t, gated, "job stopped") gated.armPostCommit(1) @@ -276,12 +433,12 @@ func TestRuntimeJobStopperDisposesBeforeStorage(t *testing.T) { t.Fatalf("Close returned while the terminal completion transaction was still held (%v)", err) default: } - // The stop already ran, and no plugin scope Close has started - // before the held terminal commit returns. + // The stop already ran, and no service closer has started before + // the held terminal commit returns. testassert.Equal(t, eventsNamed(e.events.all(), "stopjob"), 1) { closes := eventsNamedList(e.events.all(), "close") - testassert.Require(t, len(closes) == 0, "scope closes before the held terminal commit = %v, want none", closes) + testassert.Require(t, len(closes) == 0, "service closes before the held terminal commit = %v, want none", closes) } gated.releaseGate() @@ -293,7 +450,7 @@ func TestRuntimeJobStopperDisposesBeforeStorage(t *testing.T) { } testassert.Require(t, stopper.deliverErr == nil, "stopped job delivery: %v", stopper.deliverErr) events := e.events.all() - testassert.Require(t, orderedSubset(events, "stopjob", "close:jobs", "close:core"), "shutdown events = %v, want the stop, then the jobs capability close, then the storage close", events) + testassert.Require(t, orderedSubset(events, "stopjob", "close:jobs", "close:core"), "shutdown events = %v, want the stop, then the jobs service close, then the storage close", events) }) }) } diff --git a/runtime/background_test.go b/runtime/background_test.go index 180f0c98..58bb8be2 100644 --- a/runtime/background_test.go +++ b/runtime/background_test.go @@ -24,7 +24,9 @@ func TestBackgroundBridgeMethodsForward(t *testing.T) { e := newOwnerEnv(t) store := storage.NewMemory() stopper := &stubStopper{events: e.events} - r, err := e.open(ctx, e.storagePlugin(store), jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper)) + e.fixtureStore(store) + e.fixtureJobs(stopper) + r, err := e.open(ctx) testassert.NoError(t, err) bridge := &backgroundBridge{h: r.harness} @@ -134,8 +136,9 @@ func TestBackgroundBridgeArmingAcrossPublication(t *testing.T) { testassert.NoError(t, os.MkdirAll(workspace, 0o700)) probe := &backgroundProbe{} + useFixtureStore(t, store) r, err := Open(ctx, Options{DataDir: dataDir, ConfigPath: configPath, Plugins: []Plugin{ - coreStoragePlugin(store), backgroundProbePlugin(probe), + backgroundProbePlugin(probe), }}) testassert.NoError(t, err) // The unarmed-to-armed window ends before publication: construction diff --git a/runtime/baseline_settings_test.go b/runtime/baseline_settings_test.go new file mode 100644 index 00000000..6432cdaf --- /dev/null +++ b/runtime/baseline_settings_test.go @@ -0,0 +1,429 @@ +package runtime + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/MMinasyan/lightcode/harness" + "github.com/MMinasyan/lightcode/internal/agents" + "github.com/MMinasyan/lightcode/internal/jobs" + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" +) + +// baselineSettingsConfigDocument is the baseline-settings fixture: the +// compact capture fixture plus the top-level tools, jobs and compaction +// sections the baseline services consume directly. +func baselineSettingsConfigDocument(endpoint string) string { + return strings.TrimSuffix(compactPrepConfigDocument(endpoint), "}") + + `,"tools":{"read_max_lines":77,"unknown_tool_member":1},` + + `"jobs":{"max_output_bytes":1024,"unknown_jobs_member":1},` + + `"compaction":{"model":"prov/m2","prompt":"baseline summary"}}` +} + +// baselineSettingsPrep publishes the baseline-settings fixture with an +// agents document that also overlays the selectable compact builtin. +func baselineSettingsPrep(t *testing.T, agentsDoc, endpoint string) (configurationCapture, *preparation) { + t.Helper() + sh := newServiceHarness(t) + t.Setenv("PREP_COMPACT_KEY", "compact-secret") + writeServiceFile(t, sh.configPath, baselineSettingsConfigDocument(endpoint)) + writeServiceFile(t, agents.PathForConfig(sh.configPath), agentsDoc) + owner, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + comp := mustComposition(t, compactToolsPlugin()) + runtimeScope := mustOpenScope(t, comp, owner, ScopeInfo{Kind: ScopeRuntime, DataDir: sh.dataDir}, nil) + obs := newObservation() + ws := newWorkspaceScopes(owner, comp, []*scope{runtimeScope}, obs) + svc := newConfigurationService(owner, comp, sh.loader, sh.configPath, obs) + captured, err := svc.publish(context.Background()) + testassert.NoError(t, err) + return captured, newPreparation(svc, comp, runtimeScope, ws, sh.home, "", "", nil, nil, nil, nil) +} + +// TestBaselineSettingsDriveToolsJobsAndCompaction proves the top-level +// baseline sections reach the actual services without any optional plugin, +// while the selectable compact Agent keeps its own independent overlay. +func TestBaselineSettingsDriveToolsJobsAndCompaction(t *testing.T) { + agentsDoc := agentsWithCompact(`{"model":"prov/m3"}`) + captured, prep := baselineSettingsPrep(t, agentsDoc, "https://prov.test/v1") + snapshot := captured.snapshot + + // The five builtin Agent positions remain, and the compact definition + // keeps its own overlay. + var compact harness.AgentType + roster := snapshot.agentTypes() + var builtinNames []string + for _, agent := range roster { + if agent.Name == "compact" { + compact = agent + } + } + for _, builtin := range []string{"primary", "secondary", "explore", "review", "compact"} { + found := false + for _, agent := range roster { + if agent.Name == builtin { + found = true + break + } + } + if found { + builtinNames = append(builtinNames, builtin) + } + } + testassert.Equal(t, strings.Join(builtinNames, ","), "primary,secondary,explore,review,compact") + testassert.Require(t, compact.Name == "compact" && compact.Model.String() == "prov/m3" && compact.Prompt == retainedSummarizerPrompt, "compact definition = %+v, want its independent model overlay and the builtin instructions", compact) + + invocation := Invocation{snapshot: snapshot} + + // The real read tool consumes the top-level tools section. + byID := newStandardTools(t.TempDir(), &fakeJobs{}) + readTool := byID["read_file"] + call, err := model.NewToolCall(model.ToolCall{ID: "call-1", Name: "read_file", Arguments: json.RawMessage(`{"path":"a.txt"}`)}) + testassert.NoError(t, err) + normalized, err := readTool.Normalize(ToolContext{Invocation: invocation}, call) + testassert.NoError(t, err) + testassert.Require(t, string(normalized) == `{"limit":77,"offset":1,"path":"a.txt"}`, "read normalization = %s, want the top-level tools limit 77", normalized) + + // The real background command path forwards the top-level jobs section. + jobsInst := &fakeJobs{} + tools := newStandardTools(t.TempDir(), jobsInst) + backgroundCall, err := model.NewToolCall(model.ToolCall{ID: "call-2", Name: "run_command", Arguments: json.RawMessage(`{"command":"true","background":true}`)}) + testassert.NoError(t, err) + plan := tools["run_command"].Prepare(context.Background(), ToolContext{ + Workspace: t.TempDir(), + AdmittedEntry: harness.EntryRef{SessionID: testSessionID, EntryID: testEntryID}, + Invocation: invocation, + Background: scriptBackground{}, + SubprocessEnv: func() []string { return []string{} }, + }, backgroundCall) + testassert.Require(t, plan.Immediate == nil && plan.Execute != nil, "background plan = %+v, want one executor", plan) + outcome := plan.Execute(context.Background()) + testassert.Require(t, outcome.Result.Status == model.ResultSuccess, "background start = %+v, want success", outcome.Result) + testassert.Require(t, len(jobsInst.started) == 1, "jobs starts = %d, want one", len(jobsInst.started)) + testassert.Equal(t, compactJSON(t, jobsInst.started[0].Config), `{"max_output_bytes":1024,"unknown_jobs_member":1}`) + + // The compact capture consumes the top-level compaction section, not the + // selectable compact Agent's overlay. + selection := compactPrepSelection(t, captured, "solo") + capture, opener, err := prep.concretePrepare(context.Background(), compactPrepRequest(), selection) + testassert.NoError(t, err) + testassert.Require(t, opener != nil, "concretePrepare returned no opener") + testassert.Equal(t, capture.Compact.Model.String(), "prov/m2") + testassert.Equal(t, capture.Compact.SystemPrompt, "baseline summary") + testassert.Require(t, capture.Compact.Pricing != nil && capture.Compact.Pricing.Input != nil && *capture.Compact.Pricing.Input == 0.5, "compact pricing = %+v, want the top-level compact model's rates", capture.Compact.Pricing) +} + +// TestBaselineCompactionAndCompactAgentEditIndependently proves the two +// owners stay separate across real mutations: editing the selectable compact +// Agent's model leaves the baseline compaction values unchanged, and a +// settings save replacing the baseline compaction section leaves the compact +// Agent definition unchanged. +func TestBaselineCompactionAndCompactAgentEditIndependently(t *testing.T) { + sh := newServiceHarness(t) + configDoc := strings.Replace(baselineSettingsConfigDocument("https://prov.test/v1"), `"prompt":"baseline summary"`, `"prompt":"baseline edit"`, 1) + writeServiceFile(t, sh.configPath, configDoc) + writeServiceFile(t, agents.PathForConfig(sh.configPath), prepAgentsDocument) + owner, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + comp := mustComposition(t) + svc := newConfigurationService(owner, comp, sh.loader, sh.configPath, newObservation()) + { + _, err := svc.publish(context.Background()) + testassert.NoError(t, err) + } + snapshot := svc.current() + testassert.Require(t, snapshot.compactionValues.model == (model.ModelRef{Provider: "prov", Model: "m2"}) && snapshot.compactionValues.prompt == "baseline edit", "published compaction = %+v, want the baseline section", snapshot.compactionValues) + compactBefore := compactAgent(snapshot.agentTypes()) + testassert.Require(t, compactBefore.Model.IsZero(), "compact Agent model = %s, want the builtin fallback", compactBefore.Model.String()) + + { + _, err := svc.mutate(context.Background(), svc.editAgentModel("compact", "prov/m3")) + testassert.NoError(t, err) + } + snapshot = svc.current() + testassert.Require(t, compactAgent(snapshot.agentTypes()).Model == (model.ModelRef{Provider: "prov", Model: "m3"}), "compact Agent model = %s, want the saved definition override", compactAgent(snapshot.agentTypes()).Model.String()) + testassert.Require(t, snapshot.compactionValues.model == (model.ModelRef{Provider: "prov", Model: "m2"}) && snapshot.compactionValues.prompt == "baseline edit", "compaction after the Agent edit = %+v, want the baseline values unchanged", snapshot.compactionValues) + + settings := projectSettings(snapshot.sessions, snapshot.tools, snapshot.jobs, snapshot.compaction, snapshot.plugins) + settings.Compaction = json.RawMessage(`{"model":"prov/mzero","prompt":"settings edit"}`) + { + _, err := svc.mutate(context.Background(), editSettings(settings)) + testassert.NoError(t, err) + } + snapshot = svc.current() + testassert.Require(t, snapshot.compactionValues.model == (model.ModelRef{Provider: "prov", Model: "mzero"}) && snapshot.compactionValues.prompt == "settings edit", "compaction after the settings save = %+v, want the written baseline section", snapshot.compactionValues) + testassert.Require(t, compactAgent(snapshot.agentTypes()).Model == (model.ModelRef{Provider: "prov", Model: "m3"}), "compact Agent model after the settings save = %s, want the saved override unchanged", compactAgent(snapshot.agentTypes()).Model.String()) +} + +func compactAgent(roster []harness.AgentType) harness.AgentType { + for _, agent := range roster { + if agent.Name == "compact" { + return agent + } + } + return harness.AgentType{} +} + +// TestCompactionSettingsResetRestoresFallbackDefaults proves a settings save +// that resets the whole compaction object to {} applies the documented +// defaults at the next admission: the conversation-model fallback and the +// unchanged builtin prompt. +func TestCompactionSettingsResetRestoresFallbackDefaults(t *testing.T) { + sh := newServiceHarness(t) + t.Setenv("PREP_COMPACT_KEY", "compact-secret") + writeServiceFile(t, sh.configPath, baselineSettingsConfigDocument("https://prov.test/v1")) + writeServiceFile(t, agents.PathForConfig(sh.configPath), prepAgentsDocument) + owner, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + comp := mustComposition(t, compactToolsPlugin()) + runtimeScope := mustOpenScope(t, comp, owner, ScopeInfo{Kind: ScopeRuntime, DataDir: sh.dataDir}, nil) + obs := newObservation() + ws := newWorkspaceScopes(owner, comp, []*scope{runtimeScope}, obs) + svc := newConfigurationService(owner, comp, sh.loader, sh.configPath, obs) + captured, err := svc.publish(context.Background()) + testassert.NoError(t, err) + prep := newPreparation(svc, comp, runtimeScope, ws, sh.home, "", "", nil, nil, nil, nil) + + prepare := func(t *testing.T, captured configurationCapture) harness.ExecutionCapture { + t.Helper() + selection := compactPrepSelection(t, captured, "solo") + capture, opener, err := prep.concretePrepare(context.Background(), compactPrepRequest(), selection) + testassert.NoError(t, err) + testassert.Require(t, opener != nil, "concretePrepare returned no opener") + return capture + } + before := prepare(t, captured) + testassert.Require(t, before.Compact.Model.String() == "prov/m2" && before.Compact.SystemPrompt == "baseline summary", "custom compaction capture = %+v, want the configured model and prompt", before.Compact) + + settings := projectSettings(svc.current().sessions, svc.current().tools, svc.current().jobs, svc.current().compaction, svc.current().plugins) + settings.Compaction = json.RawMessage(`{}`) + { + _, err := svc.mutate(context.Background(), editSettings(settings)) + testassert.NoError(t, err) + } + reset, err := svc.capture(context.Background()) + testassert.NoError(t, err) + after := prepare(t, reset) + testassert.Require(t, after.Compact.Model.String() == "prov/m" && after.Compact.SystemPrompt == retainedSummarizerPrompt, "reset compaction capture = %+v, want the conversation model and unchanged builtin prompt", after.Compact) + testassert.Require(t, after.Compact.Pricing != nil && after.Compact.Pricing.Input != nil && *after.Compact.Pricing.Input == 1.5, "reset compact pricing = %+v, want the conversation model's rates", after.Compact.Pricing) +} + +// newBaselineSettingsService publishes one baseline configuration over the +// compact fixture and returns its harness, service, preparation and first +// capture. +func newBaselineSettingsService(t *testing.T, configDoc string) (*serviceHarness, *configurationService, *preparation, configurationCapture) { + t.Helper() + sh := newServiceHarness(t) + t.Setenv("PREP_COMPACT_KEY", "compact-secret") + writeServiceFile(t, sh.configPath, configDoc) + writeServiceFile(t, agents.PathForConfig(sh.configPath), prepAgentsDocument) + owner, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + comp := mustComposition(t, compactToolsPlugin()) + runtimeScope := mustOpenScope(t, comp, owner, ScopeInfo{Kind: ScopeRuntime, DataDir: sh.dataDir}, nil) + obs := newObservation() + ws := newWorkspaceScopes(owner, comp, []*scope{runtimeScope}, obs) + svc := newConfigurationService(owner, comp, sh.loader, sh.configPath, obs) + captured, err := svc.publish(context.Background()) + testassert.NoError(t, err) + return sh, svc, newPreparation(svc, comp, runtimeScope, ws, sh.home, "", "", nil, nil, nil, nil), captured +} + +// prepareCompactCapture runs one concrete preparation over a selection built +// from the supplied capture. +func prepareCompactCapture(t *testing.T, prep *preparation, captured configurationCapture) harness.ExecutionCapture { + t.Helper() + selection := compactPrepSelection(t, captured, "solo") + capture, opener, err := prep.concretePrepare(context.Background(), compactPrepRequest(), selection) + testassert.NoError(t, err) + testassert.Require(t, opener != nil, "concretePrepare returned no opener") + return capture +} + +// startBackgroundForTest runs the real run_command tool's background path and +// returns the one forwarded StartRequest. +func startBackgroundForTest(t *testing.T, invocation Invocation) jobs.StartRequest { + t.Helper() + recorder := &fakeJobs{} + values := newStandardTools(t.TempDir(), recorder) + call, err := model.NewToolCall(model.ToolCall{ID: "call-bg", Name: "run_command", Arguments: json.RawMessage(`{"command":"true","background":true}`)}) + testassert.NoError(t, err) + plan := values["run_command"].Prepare(context.Background(), ToolContext{ + Workspace: t.TempDir(), + AdmittedEntry: harness.EntryRef{SessionID: testSessionID, EntryID: testEntryID}, + Invocation: invocation, + Background: scriptBackground{}, + SubprocessEnv: func() []string { return []string{} }, + }, call) + testassert.Require(t, plan.Immediate == nil && plan.Execute != nil, "background plan = %+v, want one executor", plan) + outcome := plan.Execute(context.Background()) + testassert.Require(t, outcome.Result.Status == model.ResultSuccess && len(recorder.started) == 1, "background start = %+v with %d starts, want one success", outcome.Result, len(recorder.started)) + return recorder.started[0] +} + +// TestBaselineCapturesStayFrozenAcrossSettingsPublication proves a saved +// settings revision reaches only later admissions: the retained Invocation +// keeps its tools/jobs/compaction values while the fresh capture observes the +// new ones, for the real read and background-command paths and for compact +// preparation. +func TestBaselineCapturesStayFrozenAcrossSettingsPublication(t *testing.T) { + sh, svc, prep, oldCaptured := newBaselineSettingsService(t, baselineSettingsConfigDocument("https://prov.test/v1")) + oldInvocation := Invocation{snapshot: oldCaptured.snapshot} + workspace := t.TempDir() + lines := make([]string, 120) + for i := range lines { + lines[i] = strings.Repeat("x", 10) + } + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "big.txt"), []byte(strings.Join(lines, "\n")), 0o644)) + readCall, err := model.NewToolCall(model.ToolCall{ID: "call-read", Name: "read_file", Arguments: json.RawMessage(`{"path":"big.txt"}`)}) + testassert.NoError(t, err) + readTool := newStandardTools(sh.dataDir, &fakeJobs{})["read_file"] + read := func(t *testing.T, invocation Invocation) string { + t.Helper() + normalized, err := readTool.Normalize(ToolContext{Invocation: invocation}, readCall) + testassert.NoError(t, err) + call, err := model.NewToolCall(model.ToolCall{ID: "call-read", Name: "read_file", Arguments: normalized}) + testassert.NoError(t, err) + plan := readTool.Prepare(context.Background(), ToolContext{Workspace: workspace, Invocation: invocation}, call) + testassert.Require(t, plan.Immediate == nil && plan.Execute != nil, "read plan = %+v, want one executor", plan) + return plan.Execute(context.Background()).Result.Content + } + oldRead := read(t, oldInvocation) + testassert.Require(t, strings.Contains(oldRead, "(Showing lines 1-77 of 120."), "old read = %q, want the captured 77-line window", oldRead) + oldJob := startBackgroundForTest(t, oldInvocation) + testassert.Equal(t, compactJSON(t, oldJob.Config), `{"max_output_bytes":1024,"unknown_jobs_member":1}`) + oldCompact := prepareCompactCapture(t, prep, oldCaptured) + testassert.Require(t, oldCompact.Compact.Model.String() == "prov/m2" && oldCompact.Compact.SystemPrompt == "baseline summary", "old compact = %+v, want the retained revision", oldCompact.Compact) + + settings := projectSettings(oldCaptured.snapshot.sessions, oldCaptured.snapshot.tools, oldCaptured.snapshot.jobs, oldCaptured.snapshot.compaction, oldCaptured.snapshot.plugins) + settings.Tools = json.RawMessage(`{"read_max_lines":88}`) + settings.Jobs = json.RawMessage(`{"max_output_bytes":2048}`) + settings.Compaction = json.RawMessage(`{"model":"prov/m","prompt":"fresh prompt"}`) + settings.Plugins = nil + { + _, err := svc.mutate(context.Background(), editSettings(settings)) + testassert.NoError(t, err) + } + freshCaptured, err := svc.capture(context.Background()) + testassert.NoError(t, err) + freshInvocation := Invocation{snapshot: freshCaptured.snapshot} + freshRead := read(t, freshInvocation) + testassert.Require(t, strings.Contains(freshRead, "(Showing lines 1-88 of 120."), "fresh read = %q, want the new 88-line window", freshRead) + freshJob := startBackgroundForTest(t, freshInvocation) + testassert.Equal(t, compactJSON(t, freshJob.Config), `{"max_output_bytes":2048}`) + freshCompact := prepareCompactCapture(t, prep, freshCaptured) + testassert.Require(t, freshCompact.Compact.Model.String() == "prov/m" && freshCompact.Compact.SystemPrompt == "fresh prompt", "fresh compact = %+v, want the newly published revision", freshCompact.Compact) + + // The frozen capture still consumes the old values after publication. + againOld := read(t, oldInvocation) + testassert.Require(t, strings.Contains(againOld, "(Showing lines 1-77 of 120."), "retained read after publication = %q, want the old window", againOld) + testassert.Equal(t, compactJSON(t, startBackgroundForTest(t, oldInvocation).Config), `{"max_output_bytes":1024,"unknown_jobs_member":1}`) + againOldCompact := prepareCompactCapture(t, prep, oldCaptured) + testassert.Require(t, againOldCompact.Compact.Model.String() == "prov/m2" && againOldCompact.Compact.SystemPrompt == "baseline summary", "retained compact after publication = %+v, want the old revision", againOldCompact.Compact) +} + +// TestBaselinePartialObjectSettingsUseAbsentDefaults proves raw-document +// edits that omit one member leave that member's documented default at the +// consuming service: an omitted tools read limit, an omitted jobs output +// bound, and an omitted compaction prompt/model. +func TestBaselinePartialObjectSettingsUseAbsentDefaults(t *testing.T) { + sh, svc, prep, captured := newBaselineSettingsService(t, baselineSettingsConfigDocument("https://prov.test/v1")) + settings := projectSettings(captured.snapshot.sessions, captured.snapshot.tools, captured.snapshot.jobs, captured.snapshot.compaction, captured.snapshot.plugins) + settings.Tools = json.RawMessage(`{"max_output_bytes":2048}`) + settings.Jobs = json.RawMessage(`{"max_background_processes":5}`) + settings.Compaction = json.RawMessage(`{"model":"prov/m2"}`) + settings.Plugins = nil + { + _, err := svc.mutate(context.Background(), editSettings(settings)) + testassert.NoError(t, err) + } + fresh, err := svc.capture(context.Background()) + testassert.NoError(t, err) + invocation := Invocation{snapshot: fresh.snapshot} + + readTool := newStandardTools(sh.dataDir, &fakeJobs{})["read_file"] + call, err := model.NewToolCall(model.ToolCall{ID: "call-read", Name: "read_file", Arguments: json.RawMessage(`{"path":"a.txt"}`)}) + testassert.NoError(t, err) + normalized, err := readTool.Normalize(ToolContext{Invocation: invocation}, call) + testassert.NoError(t, err) + testassert.Require(t, string(normalized) == `{"limit":500,"offset":1,"path":"a.txt"}`, "omitted read_max_lines normalization = %s, want the absent-member default 500", normalized) + + // The real jobs manager consumes the forwarded object and applies its own + // absent-member max_output_bytes default. + manager := jobs.New(t.TempDir()) + t.Cleanup(func() { _ = manager.Close() }) + jobID, err := manager.Reserve() + testassert.NoError(t, err) + exited := make(chan jobs.ExitResult, 1) + testassert.NoError(t, manager.Start(jobs.StartRequest{ + JobID: jobID, SessionID: testSessionID, Workspace: sh.dataDir, Command: "printf ok", Env: []string{}, + Config: invocation.jobsSettings(), OnExit: func(result jobs.ExitResult) { exited <- result }, + })) + select { + case result := <-exited: + testassert.Equal(t, result.MaxOutputBytes, 15360) + case <-time.After(15 * time.Second): + t.Fatal("the omitted-max-output job never exited") + } + + compact := prepareCompactCapture(t, prep, fresh) + testassert.Require(t, compact.Compact.Model.String() == "prov/m2" && compact.Compact.SystemPrompt == prompt.DefaultSummarizerPrompt, "omitted compaction prompt capture = %+v, want the builtin default", compact.Compact) + + // Omit model independently while retaining a custom prompt. + settings = projectSettings(fresh.snapshot.sessions, fresh.snapshot.tools, fresh.snapshot.jobs, fresh.snapshot.compaction, fresh.snapshot.plugins) + settings.Compaction = json.RawMessage(`{"prompt":"custom retained"}`) + settings.Plugins = nil + { + _, err := svc.mutate(context.Background(), editSettings(settings)) + testassert.NoError(t, err) + } + fresh, err = svc.capture(context.Background()) + testassert.NoError(t, err) + compact = prepareCompactCapture(t, prep, fresh) + testassert.Require(t, compact.Compact.Model.String() == "prov/m" && compact.Compact.SystemPrompt == "custom retained", "omitted compaction model capture = %+v, want the conversation fallback with the custom prompt", compact.Compact) + testassert.Require(t, compact.Compact.Pricing != nil && compact.Compact.Pricing.Input != nil && *compact.Compact.Pricing.Input == 1.5, "omitted compaction model pricing = %+v, want the conversation model's rates", compact.Compact.Pricing) +} + +// TestConcretePrepareCompactionWithoutRoster proves compaction preparation +// consumes only the captured compaction settings: the selection's Invocation +// points at an owned configuration copy whose definitions are nil, while the +// published snapshot and its backing roster remain untouched. +func TestConcretePrepareCompactionWithoutRoster(t *testing.T) { + endpoint := "https://prov.test/v1" + for _, tc := range []struct { + name string + configDoc string + wantModel string + wantPrompt string + wantInput float64 + }{ + {"configured settings", baselineSettingsConfigDocument(endpoint), "prov/m2", "baseline summary", 0.5}, + {"unavailable model fallback", compactPrepConfigDocumentWith(endpoint, `{"model":"prov/absent"}`), "prov/m", prompt.DefaultSummarizerPrompt, 1.5}, + {"absent compaction", compactPrepConfigDocument(endpoint), "prov/m", prompt.DefaultSummarizerPrompt, 1.5}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, prep, captured := newBaselineSettingsService(t, tc.configDoc) + publishedRoster := len(captured.snapshot.agentTypes()) + testassert.Require(t, publishedRoster != 0, "published roster is empty before the owned copy") + + owned := *captured.snapshot + owned.definitions = nil + testassert.Equal(t, len(owned.agentTypes()), 0) + selection := compactPrepSelection(t, captured, "solo") + selection.invocation = Invocation{snapshot: &owned} + capture, opener, err := prep.concretePrepare(context.Background(), compactPrepRequest(), selection) + testassert.NoError(t, err) + testassert.Require(t, opener != nil, "concretePrepare returned no opener") + testassert.Require(t, capture.Compact.Model.String() == tc.wantModel && capture.Compact.SystemPrompt == tc.wantPrompt, "rosterless compact capture = %+v, want %s with %q", capture.Compact, tc.wantModel, tc.wantPrompt) + testassert.Require(t, capture.Compact.Pricing != nil && capture.Compact.Pricing.Input != nil && *capture.Compact.Pricing.Input == tc.wantInput, "rosterless compact pricing = %+v, want input %v", capture.Compact.Pricing, tc.wantInput) + testassert.Equal(t, len(captured.snapshot.agentTypes()), publishedRoster) + }) + } +} diff --git a/runtime/builtin_composed_test.go b/runtime/builtin_composed_test.go new file mode 100644 index 00000000..e18fa95d --- /dev/null +++ b/runtime/builtin_composed_test.go @@ -0,0 +1,80 @@ +package runtime_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "sort" + "testing" + + "github.com/MMinasyan/lightcode/internal/plugins/builtin" + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/runtime" +) + +// The shipped registration set composes through the real construction path +// beside the directly built baseline services. This suite lives in the +// external package so the plugin registration import never cycles into the +// runtime owner. + +// TestBuiltinRegistrationComposes proves the shipped optional registration +// set — the bundled adaptation and the child-session task tool — composes +// through the real construction path beside the directly constructed +// baseline store, jobs, and tools: the complete composition opens over the +// included SQLite database, reload publishes, and shutdown converges leaving +// exactly the included store's files. +func TestBuiltinRegistrationComposes(t *testing.T) { + ctx := context.Background() + home, dataDir, configDir := t.TempDir(), t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + entries, err := os.ReadDir(filepath.Join("..", "internal", "catalog", "builtin")) + testassert.NoError(t, err) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join("..", "internal", "catalog", "builtin", entry.Name())) + if err != nil { + t.Fatalf("read bundled provider %s: %v", entry.Name(), err) + } + var provider struct { + Transport struct { + APIKeyEnv string `json:"api_key_env"` + } `json:"transport"` + } + if err := json.Unmarshal(data, &provider); err != nil { + t.Fatalf("decode bundled provider %s: %v", entry.Name(), err) + } + if provider.Transport.APIKeyEnv != "" { + t.Setenv(provider.Transport.APIKeyEnv, "") + } + } + configPath := filepath.Join(configDir, "config.json") + testassert.NoError(t, os.WriteFile(configPath, []byte(`{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}}}`), 0o600)) + + r, err := runtime.OpenForTest(ctx, dataDir, configPath, builtin.Plugins()) + testassert.NoError(t, err) + { + revision, err := r.Reload(ctx) + testassert.Require(t, err == nil && revision == "2", "Reload = (%q, %v), want 2", revision, err) + } + testassert.NoError(t, r.Close(ctx)) + { + got := sortedDataRootNames(t, dataDir) + testassert.Require(t, slices.Equal(got, []string{"lightcode.db", "runtime.lock"}), "data root after shutdown = %v, want exactly the included store's files and the ownership lock", got) + } +} + +func sortedDataRootNames(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + testassert.Require(t, err == nil, "read dir %s: %v", dir, err) + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names +} diff --git a/runtime/compact_capture_test.go b/runtime/compact_capture_test.go index 8181d6ce..eefed59f 100644 --- a/runtime/compact_capture_test.go +++ b/runtime/compact_capture_test.go @@ -66,14 +66,27 @@ func agentsWithCompact(overlay string) string { return strings.TrimSuffix(prepAgentsDocument, "}") + ",\n \"compact\": " + overlay + "}" } +// compactPrepConfigDocumentWith appends one top-level compaction section to +// the compact fixture document. +func compactPrepConfigDocumentWith(endpoint, compaction string) string { + return strings.TrimSuffix(compactPrepConfigDocument(endpoint), "}") + `,"compaction":` + compaction + `}` +} + // compactPrep wires one concrete preparation (nil controlled prepare) over // the compact fixture documents, publishes the configuration once, and // returns that publication's own snapshot+credential capture. func compactPrep(t *testing.T, agentsDoc, endpoint string) (configurationCapture, *preparation) { + t.Helper() + return compactPrepWith(t, agentsDoc, compactPrepConfigDocument(endpoint), endpoint) +} + +// compactPrepWith publishes the exact config document together with the +// agents document and returns that publication's own capture. +func compactPrepWith(t *testing.T, agentsDoc, configDoc, endpoint string) (configurationCapture, *preparation) { t.Helper() sh := newServiceHarness(t) t.Setenv("PREP_COMPACT_KEY", "compact-secret") - writeServiceFile(t, sh.configPath, compactPrepConfigDocument(endpoint)) + writeServiceFile(t, sh.configPath, configDoc) writeServiceFile(t, agents.PathForConfig(sh.configPath), agentsDoc) owner, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -84,12 +97,12 @@ func compactPrep(t *testing.T, agentsDoc, endpoint string) (configurationCapture svc := newConfigurationService(owner, comp, sh.loader, sh.configPath, obs) captured, err := svc.publish(context.Background()) testassert.NoError(t, err) - return captured, newPreparation(svc, comp, runtimeScope, ws, sh.home, nil, nil, nil, nil) + return captured, newPreparation(svc, comp, runtimeScope, ws, sh.home, "", "", nil, nil, nil, nil) } func compactToolsPlugin() Plugin { return Plugin{ - ID: "tools", + ID: "compacttools", Scope: ScopeRuntime, Provides: []CapabilitySpec{ToolSpec("echo", staticToolDescription("echo")), ToolSpec("read", staticToolDescription("read"))}, Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { @@ -139,9 +152,9 @@ func testToolDefinitionRuntime() model.ToolDefinition { // TestConcretePrepareCompactCapture pins every capture field the compaction // configuration must carry from one revision: the conversation window, the -// output reserve with the 131072 fallback, the compact selection with the -// conversation-model default, the compact prompt equal to the resolved -// compact type's Prompt, and the captured pricing of the final effective +// output reserve with the 131072 fallback, the compact selection from the +// top-level compaction section with the conversation-model fallback, the +// unchanged builtin prompt, and the captured pricing of the final effective // conversation and compact models — the conversation model's own rates // exactly when the fallback kept that model and its transport, and the // fallback candidate's rates never reaching a capture it did not select. @@ -155,55 +168,65 @@ func TestConcretePrepareCompactCapture(t *testing.T) { cases := []struct { name string agentsDoc string + compaction string agent string wantModel model.ModelRef wantWindow int wantReserve int wantCompactRef model.ModelRef + wantCompactPrompt string wantPricing *harness.ModelPricing wantCompactPricing *harness.ModelPricing }{ { name: "conversation model with the reserve fallback", agentsDoc: prepAgentsDocument, + compaction: `{}`, agent: "solo", wantModel: conversationRef, wantWindow: 4096, wantReserve: 131072, wantCompactRef: conversationRef, + wantCompactPrompt: retainedSummarizerPrompt, wantPricing: mPricing, wantCompactPricing: mPricing, }, { - name: "compact model override via the roster", - agentsDoc: agentsWithCompact(`{"model": "prov/m2"}`), + name: "compact model override via settings", + agentsDoc: prepAgentsDocument, + compaction: `{"model":"prov/m2"}`, agent: "solo", wantModel: conversationRef, wantWindow: 4096, wantReserve: 131072, wantCompactRef: m2Ref, + wantCompactPrompt: retainedSummarizerPrompt, wantPricing: mPricing, wantCompactPricing: m2Pricing, }, { name: "compact model that does not resolve falls back", - agentsDoc: agentsWithCompact(`{"model": "prov/absent"}`), + agentsDoc: prepAgentsDocument, + compaction: `{"model":"prov/absent"}`, agent: "solo", wantModel: conversationRef, wantWindow: 4096, wantReserve: 131072, wantCompactRef: conversationRef, + wantCompactPrompt: retainedSummarizerPrompt, wantPricing: mPricing, wantCompactPricing: mPricing, }, { name: "compact key that does not resolve falls back", - agentsDoc: agentsWithCompact(`{"model": "p2/m3"}`), + agentsDoc: prepAgentsDocument, + compaction: `{"model":"p2/m3"}`, agent: "solo", wantModel: conversationRef, wantWindow: 4096, wantReserve: 131072, wantCompactRef: conversationRef, + wantCompactPrompt: retainedSummarizerPrompt, wantPricing: mPricing, wantCompactPricing: mPricing, }, @@ -211,30 +234,34 @@ func TestConcretePrepareCompactCapture(t *testing.T) { // catalog.Lookup rejects the zero-window model, so the // conversation-model default applies. name: "compact entry with no positive window falls back", - agentsDoc: agentsWithCompact(`{"model": "prov/mzero"}`), + agentsDoc: prepAgentsDocument, + compaction: `{"model":"prov/mzero"}`, agent: "solo", wantModel: conversationRef, wantWindow: 4096, wantReserve: 131072, wantCompactRef: conversationRef, + wantCompactPrompt: retainedSummarizerPrompt, wantPricing: mPricing, wantCompactPricing: mPricing, }, { name: "conversation reserve from the entry's max output tokens", agentsDoc: strings.TrimSuffix(prepAgentsDocument, "}") + ",\n \"reservable\": {\"model\": \"prov/m2\", \"system_prompt\": \"simple\", \"tools\": [\"echo\"]}}", + compaction: `{}`, agent: "reservable", wantModel: m2Ref, wantWindow: 2048, wantReserve: 1000, wantCompactRef: m2Ref, + wantCompactPrompt: retainedSummarizerPrompt, wantPricing: m2Pricing, wantCompactPricing: m2Pricing, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - captured, prep := compactPrep(t, tc.agentsDoc, "https://prov.test/v1") + captured, prep := compactPrepWith(t, tc.agentsDoc, compactPrepConfigDocumentWith("https://prov.test/v1", tc.compaction), "https://prov.test/v1") sel := compactPrepSelection(t, captured, tc.agent) capture, opener, err := prep.concretePrepare(context.Background(), compactPrepRequest(), sel) testassert.NoError(t, err) @@ -246,7 +273,7 @@ func TestConcretePrepareCompactCapture(t *testing.T) { if capture.Compact.Model != tc.wantCompactRef { t.Fatalf("capture Compact.Model = %s, want %s", capture.Compact.Model.String(), tc.wantCompactRef.String()) } - testassert.Equal(t, capture.Compact.SystemPrompt, retainedSummarizerPrompt) + testassert.Equal(t, capture.Compact.SystemPrompt, tc.wantCompactPrompt) testassert.Require(t, capture.Pricing.Equal(tc.wantPricing), "capture pricing = %+v, want the conversation entry's catalog rates", capture.Pricing) testassert.Require(t, capture.Compact.Pricing.Equal(tc.wantCompactPricing), "capture compact pricing = %+v, want the final effective compact model's catalog rates", capture.Compact.Pricing) }) @@ -310,16 +337,16 @@ func TestConcreteOpenerCompactModel(t *testing.T) { conversationRef := model.ModelRef{Provider: "prov", Model: "m"} cases := []struct { name string - agentsDoc string + compaction string wantCompactOn string }{ - {"compact model override", agentsWithCompact(`{"model": "prov/m2"}`), "m2"}, - {"conversation model default", prepAgentsDocument, "m"}, + {"compact model override", `{"model":"prov/m2"}`, "m2"}, + {"conversation model default", `{}`, "m"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { server := newCompactRecordingServer(t) - captured, prep := compactPrep(t, tc.agentsDoc, server.URL) + captured, prep := compactPrepWith(t, prepAgentsDocument, compactPrepConfigDocumentWith(server.URL, tc.compaction), server.URL) sel := compactPrepSelection(t, captured, "solo") capture, opener, err := prep.concretePrepare(context.Background(), compactPrepRequest(), sel) testassert.NoError(t, err) diff --git a/runtime/compact_lifecycle_test.go b/runtime/compact_lifecycle_test.go index 2c3c313a..c0865711 100644 --- a/runtime/compact_lifecycle_test.go +++ b/runtime/compact_lifecycle_test.go @@ -29,20 +29,19 @@ import ( // compactObservationConfigDocument configures the conversation model with a // window whose trigger threshold the seed turn's reported usage overflows, -// and the compact model as a distinct catalog identity, so the wire -// distinguishes the transports by model ID. +// and the top-level compaction model as a distinct catalog identity, so the +// wire distinguishes the transports by model ID. func compactObservationConfigDocument(endpoint string) string { return `{"providers":{"prov":` + nominalProvMember(endpoint, "", `{"m":{"name":"M","context_window":8192,"max_output_tokens":4096},`+ - `"k":{"name":"K","context_window":32768,"max_output_tokens":4096,"usage_in_stream":true}}`) + `}}` + `"k":{"name":"K","context_window":32768,"max_output_tokens":4096,"usage_in_stream":true}}`) + `}, + "compaction":{"model":"prov/k"}}` } // compactObservationAgentsDocument selects the conversation model for the -// working type and overlays the compact builtin onto the distinct compact -// model, so the two transports are distinguishable on the wire. +// working type. const compactObservationAgentsDocument = `{ - "tight": {"model": "prov/m", "system_prompt": "simple"}, - "compact": {"model": "prov/k"} + "tight": {"model": "prov/m", "system_prompt": "simple"} }` // TestCompactLifecycleNativeCompactionObservation keeps the Runtime observer @@ -59,7 +58,8 @@ func TestCompactLifecycleNativeCompactionObservation(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, compactObservationConfigDocument(e.server.URL)) writeServiceFile(t, agents.PathForConfig(e.configPath), compactObservationAgentsDocument) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) ctx := context.Background() @@ -120,7 +120,7 @@ func TestCompactLifecycleNativeCompactionObservation(t *testing.T) { err := json.Unmarshal(summary.Messages[0].Content, &systemText) testassert.Require(t, err == nil && systemText == op2.Admission.Execution.Compact.SystemPrompt, "compact summary system = %q (err %v), want the captured compact prompt %q", systemText, err, op2.Admission.Execution.Compact.SystemPrompt) } - testassert.Require(t, op2.Admission.Execution.Compact.Model == (model.ModelRef{Provider: "prov", Model: "k"}), "captured compact model = %+v, want the overlaid prov/k", op2.Admission.Execution.Compact.Model) + testassert.Require(t, op2.Admission.Execution.Compact.Model == (model.ModelRef{Provider: "prov", Model: "k"}), "captured compact model = %+v, want the configured prov/k", op2.Admission.Execution.Compact.Model) first := decodeWireChatBody(t, conversation[1]) { roles := wireRoles(first) @@ -200,7 +200,8 @@ func TestCompactLifecycleNativeCompactionAgentScopeCount(t *testing.T) { writeServiceFile(t, e.configPath, compactObservationConfigDocument(e.server.URL)) writeServiceFile(t, agents.PathForConfig(e.configPath), compactObservationAgentsDocument) var agOpens atomic.Int64 - r, err := e.open(context.Background(), e.storagePlugin(store), + e.fixtureStore(store) + r, err := e.open(context.Background(), shortScopeProbe("probe-agent", ScopeAgent, &agOpens)) testassert.NoError(t, err) ctx := context.Background() diff --git a/runtime/compaction_settings.go b/runtime/compaction_settings.go new file mode 100644 index 00000000..87912cbc --- /dev/null +++ b/runtime/compaction_settings.go @@ -0,0 +1,84 @@ +package runtime + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" +) + +// compactionSettings is the parsed top-level compaction section: the optional +// independent summarizer model reference and the effective instruction text. +// An absent or empty model keeps the conversation-model fallback, and an +// absent prompt is the shared builtin summarizer text. +type compactionSettings struct { + model model.ModelRef + prompt string +} + +// defaultCompactionSettings is the absent-section value: no independent model +// and the unchanged builtin prompt. +func defaultCompactionSettings() compactionSettings { + return compactionSettings{prompt: prompt.DefaultSummarizerPrompt} +} + +// requireSettingsObject enforces the one section rule shared by the baseline +// configuration sections: absent is empty, and a present member is a JSON +// object, never null, an array, or a scalar. +func requireSettingsObject(name string, raw json.RawMessage) error { + if len(bytes.TrimSpace(raw)) == 0 { + return nil + } + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil || object == nil { + return fmt.Errorf("%s: settings section must be a JSON object", name) + } + return nil +} + +// parseCompactionSettings decodes and validates one top-level compaction +// section. Unknown members are ignored, a malformed nonempty model reference +// rejects the candidate through the existing model-reference parser, and a +// present prompt must be a nonempty string whose bytes are kept exactly. +func parseCompactionSettings(raw json.RawMessage) (compactionSettings, error) { + settings := defaultCompactionSettings() + if err := requireSettingsObject("compaction", raw); err != nil { + return compactionSettings{}, err + } + if len(bytes.TrimSpace(raw)) == 0 { + return settings, nil + } + var members struct { + Model *string `json:"model"` + Prompt json.RawMessage `json:"prompt"` + } + if err := json.Unmarshal(raw, &members); err != nil { + return compactionSettings{}, fmt.Errorf("compaction: invalid settings: %w", err) + } + if members.Model != nil && *members.Model != "" { + ref, err := model.Parse(*members.Model) + if err != nil { + return compactionSettings{}, fmt.Errorf("compaction.model: %w", err) + } + settings.model = ref + } + if members.Prompt != nil { + var text string + if err := json.Unmarshal(members.Prompt, &text); err != nil || text == "" { + return compactionSettings{}, fmt.Errorf("compaction.prompt must be a nonempty string") + } + settings.prompt = text + } + return settings, nil +} + +// validateBaselineSection applies the object-when-present rule and then the +// owning baseline service's own settings validator. +func validateBaselineSection(name string, raw json.RawMessage, validate func(json.RawMessage) error) error { + if err := requireSettingsObject(name, raw); err != nil { + return err + } + return validate(raw) +} diff --git a/runtime/compaction_settings_test.go b/runtime/compaction_settings_test.go new file mode 100644 index 00000000..67332e66 --- /dev/null +++ b/runtime/compaction_settings_test.go @@ -0,0 +1,112 @@ +package runtime + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" + "github.com/MMinasyan/lightcode/protocol" +) + +// TestParseCompactionSettings pins the top-level compaction section: an +// absent or empty model keeps the conversation-model fallback, a nonempty +// model goes through the existing model-reference parser, an absent prompt +// keeps the shared builtin text, and a present prompt must be nonempty and is +// kept exactly. Unknown members are ignored. +func TestParseCompactionSettings(t *testing.T) { + for _, tc := range []struct { + name string + raw string + wantModel model.ModelRef + wantPrompt string + }{ + {"absent", "", model.ModelRef{}, prompt.DefaultSummarizerPrompt}, + {"empty object", `{}`, model.ModelRef{}, prompt.DefaultSummarizerPrompt}, + {"empty model", `{"model":""}`, model.ModelRef{}, prompt.DefaultSummarizerPrompt}, + {"model", `{"model":"prov/m2"}`, model.ModelRef{Provider: "prov", Model: "m2"}, prompt.DefaultSummarizerPrompt}, + {"custom prompt", `{"prompt":" exact\nbytes "}`, model.ModelRef{}, " exact\nbytes "}, + {"unknown members ignored", `{"model":"prov/m2","summarizer_provider":"legacy"}`, model.ModelRef{Provider: "prov", Model: "m2"}, prompt.DefaultSummarizerPrompt}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := parseCompactionSettings(json.RawMessage(tc.raw)) + testassert.NoError(t, err) + testassert.Require(t, got.model == tc.wantModel && got.prompt == tc.wantPrompt, "parseCompactionSettings(%s) = %+v, want %+v/%q", tc.raw, got, tc.wantModel, tc.wantPrompt) + }) + } + for _, raw := range []string{ + `null`, `[]`, `7`, `"compaction"`, + `{"model":"malformed"}`, `{"model":"prov/"}`, `{"model":5}`, + `{"prompt":""}`, `{"prompt":null}`, `{"prompt":5}`, + } { + t.Run("reject "+raw, func(t *testing.T) { + _, err := parseCompactionSettings(json.RawMessage(raw)) + testassert.Require(t, err != nil, "parseCompactionSettings(%s) accepted, want rejection", raw) + }) + } +} + +// TestCandidateRejectsInvalidBaselineSections pins the publication boundary: +// a non-object tools/jobs/compaction member or an invalid compaction model or +// prompt rejects the complete candidate with the typed configuration failure, +// while an absent section keeps its defaults. +func TestCandidateRejectsInvalidBaselineSections(t *testing.T) { + valid := `{"providers":{}}` + { + _, err := assembleConfiguration(1, valid, `{}`, nil) + testassert.NoError(t, err) + } + for _, tc := range []struct{ name, document string }{ + {"tools null", `{"providers":{},"tools":null}`}, + {"tools array", `{"providers":{},"tools":[]}`}, + {"jobs null", `{"providers":{},"jobs":null}`}, + {"jobs scalar", `{"providers":{},"jobs":7}`}, + {"compaction null", `{"providers":{},"compaction":null}`}, + {"compaction string", `{"providers":{},"compaction":"{}"}`}, + {"compaction malformed model", `{"providers":{},"compaction":{"model":"prov/"}}`}, + {"compaction empty prompt", `{"providers":{},"compaction":{"prompt":""}}`}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := assembleConfiguration(1, tc.document, `{}`, nil) + testassert.Require(t, err != nil && strings.Contains(err.Error(), "captured configuration"), "candidate %s = %v, want a captured-configuration rejection", tc.document, err) + }) + } +} + +// TestSettingsMutationWritesBaselineRootsExactly proves the settings writer +// carries the three top-level objects with exact numeric lexemes, unknown +// members, and the custom compaction prompt unchanged, preserves an unowned +// root member, and projects the same captured bytes back. +func TestSettingsMutationWritesBaselineRootsExactly(t *testing.T) { + h := newServiceHarness(t) + writeServiceFile(t, h.configPath, mutationConfigDocument()) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) + { + _, err := svc.publish(context.Background()) + testassert.NoError(t, err) + } + settings := mutationSettings() + settings.Tools = json.RawMessage(`{"read_max_lines":9007199254740993,"unknown":1}`) + settings.Jobs = json.RawMessage(`{"max_output_bytes":1048576,"unknown":2}`) + settings.Compaction = json.RawMessage(`{"model":"prov/m2","prompt":" exact prompt ","unknown":3}`) + settings.Plugins = protocol.PluginsSettings{} + candidate, err := svc.mutate(context.Background(), editSettings(settings)) + testassert.NoError(t, err) + root := fileRoot(t, h.configPath) + for name, want := range map[string]string{ + "tools": `{"read_max_lines":9007199254740993,"unknown":1}`, + "jobs": `{"max_output_bytes":1048576,"unknown":2}`, + "compaction": `{"model":"prov/m2","prompt":" exact prompt ","unknown":3}`, + } { + testassert.Equal(t, compactJSON(t, root[name]), want) + } + view := projectSettings(candidate.snapshot.sessions, candidate.snapshot.tools, candidate.snapshot.jobs, candidate.snapshot.compaction, candidate.snapshot.plugins) + testassert.Equal(t, compactJSON(t, view.Tools), `{"read_max_lines":9007199254740993,"unknown":1}`) + testassert.Equal(t, compactJSON(t, view.Jobs), `{"max_output_bytes":1048576,"unknown":2}`) + testassert.Equal(t, compactJSON(t, view.Compaction), `{"model":"prov/m2","prompt":" exact prompt ","unknown":3}`) + testassert.Require(t, candidate.snapshot.compactionValues.model == (model.ModelRef{Provider: "prov", Model: "m2"}) && candidate.snapshot.compactionValues.prompt == " exact prompt ", "parsed compaction = %+v, want the written model and exact prompt", candidate.snapshot.compactionValues) + testassert.Require(t, string(root["custom_flag"]) == "true", "the settings edit dropped the unowned root member: %s", root["custom_flag"]) +} diff --git a/runtime/composed_helpers_test.go b/runtime/composed_helpers_test.go new file mode 100644 index 00000000..57f9d9b0 --- /dev/null +++ b/runtime/composed_helpers_test.go @@ -0,0 +1,83 @@ +package runtime_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/runtime" +) + +// The shared fixtures of the external composed suite: the event readers over +// one live Subscription, and the bundled-credential isolation. Package +// runtime's own tests keep their copies: the two packages cannot share +// unexported test helpers. + +// isolateBundledCredentials empties every api_key_env declared by the bundled +// catalog so a captured build never starts a network attempt. +func isolateBundledCredentials(t *testing.T) { + t.Helper() + entries, err := os.ReadDir(filepath.Join("..", "internal", "catalog", "builtin")) + testassert.NoError(t, err) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + data, err := os.ReadFile(filepath.Join("..", "internal", "catalog", "builtin", entry.Name())) + if err != nil { + t.Fatalf("read bundled provider %s: %v", entry.Name(), err) + } + var provider struct { + Transport struct { + APIKeyEnv string `json:"api_key_env"` + } `json:"transport"` + } + if err := json.Unmarshal(data, &provider); err != nil { + t.Fatalf("decode bundled provider %s: %v", entry.Name(), err) + } + if provider.Transport.APIKeyEnv != "" { + t.Setenv(provider.Transport.APIKeyEnv, "") + } + } +} + +// nextComposedEvent waits for one event on one live Subscription. +func nextComposedEvent(t *testing.T, sub *runtime.Subscription) (runtime.Event, bool) { + t.Helper() + select { + case event, ok := <-sub.Events(): + return event, ok + case <-time.After(10 * time.Second): + t.Fatal("no event arrived within the test budget") + return runtime.Event{}, false + } +} + +// composedEventKind reads one composed event's discriminator. +func composedEventKind(t *testing.T, event runtime.Event) string { + t.Helper() + kind, err := event.Discriminator() + testassert.NoError(t, err) + return kind +} + +// composedEventGeneration reads one composed configuration event's +// generation. +func composedEventGeneration(t *testing.T, event runtime.Event) string { + t.Helper() + body, err := event.AsConfigurationChangedEvent() + testassert.NoError(t, err) + return body.ConfigurationRevision.Generation +} + +// composedEventJSON renders one composed event for failure diagnostics. +func composedEventJSON(event runtime.Event) string { + data, err := event.MarshalJSON() + if err != nil { + return "" + } + return string(data) +} diff --git a/runtime/composed_tools_harness_test.go b/runtime/composed_tools_harness_test.go new file mode 100644 index 00000000..f9dbbed0 --- /dev/null +++ b/runtime/composed_tools_harness_test.go @@ -0,0 +1,231 @@ +package runtime_test + +// The external suite's shared tool fixtures: the scripted model streams, the +// one-call preparation helper, the background-job identity reader, and the +// focused task Harness fixture the composed permission row drives. + +import ( + "context" + "encoding/json" + "io" + "path/filepath" + "regexp" + "testing" + "time" + + "github.com/MMinasyan/lightcode/harness" + "github.com/MMinasyan/lightcode/internal/storage" + "github.com/MMinasyan/lightcode/internal/testassert" + "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/runtime" +) + +const ( + testSessionID = "0123456789abcdef0123456789abcdef" + testEntryID = "fedcba9876543210fedcba9876543210" +) + +// backgroundIDPattern extracts one started background job identity from a +// retained immediate result. +var backgroundIDPattern = regexp.MustCompile("Command running in the background with ID: `([0-9a-f]{8})`") + +// backgroundJobID extracts the last started background job identity from one +// model request's accumulated text. +func backgroundJobID(text string) string { + matches := backgroundIDPattern.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return "" + } + return matches[len(matches)-1][1] +} + +func makeCall(t *testing.T, name, args string) model.ToolCall { + t.Helper() + call, err := model.NewToolCall(model.ToolCall{ID: "call-1", Name: name, Arguments: json.RawMessage(args)}) + testassert.NoError(t, err) + return call +} + +// prepare prepares one call, returning the plan. +func prepare(t *testing.T, tool runtime.Tool, tc runtime.ToolContext, name, args string) harness.PreparedTool { + t.Helper() + return tool.Prepare(context.Background(), tc, makeCall(t, name, args)) +} + +type deltaStream struct { + deltas []model.StreamDelta + i int +} + +func (s *deltaStream) Recv() (model.StreamDelta, error) { + if s.i >= len(s.deltas) { + return model.StreamDelta{}, io.EOF + } + d := s.deltas[s.i] + s.i++ + return d, nil +} + +func (s *deltaStream) Close() error { return nil } + +func streamOf(deltas ...model.StreamDelta) model.Stream { + return &deltaStream{deltas: deltas} +} + +// toolCallStream is one completed assistant turn publishing one tool call. +func toolCallStream(id, name, args string) model.Stream { + return streamOf( + model.StreamDelta{HasChoice: true, ToolFragments: []model.ToolCallFragment{{ID: id, Name: name, ArgumentFragment: args}}}, + model.StreamDelta{HasChoice: true, FinishReason: "tool_calls"}, + ) +} + +// stopStream is one completed assistant turn with plain text and no calls. +func stopStream(text string) model.Stream { + return streamOf( + model.StreamDelta{HasChoice: true, ContentFragments: []model.ContentFragment{{Position: 0, Kind: model.PartText, Text: text}}}, + model.StreamDelta{HasChoice: true, FinishReason: "stop"}, + ) +} + +// taskCapture is the task fixture's execution capture: it advertises exactly +// the one composed task tool. +func taskCapture() harness.ExecutionCapture { + return harness.ExecutionCapture{ + ConfigurationRevision: "1", + Model: model.ModelRef{Provider: "prov", Model: "m"}, + ContextWindow: 4096, + OutputReserve: 2048, + SystemPrompt: "composed task", + Tools: []model.ToolDefinition{{Name: "task", Parameters: json.RawMessage(`{"type":"object","properties":{}}`)}}, + Compact: harness.CompactCapture{ + Model: model.ModelRef{Provider: "prov", Model: "m"}, + SystemPrompt: "summarize", + }, + Strategy: harness.StrategyCapture{ID: harness.IncludedStrategyID, RequiresContextWindow: true}, + } +} + +// taskHarness is one real Harness over a real SQLite store whose execution +// advertises and drives exactly the composed task tool: the scripted model, +// the resolved permission policy, the captured invocation, and the scripted +// background bridge are the only inputs. +type taskHarness struct { + t *testing.T + h *harness.Harness + store harness.Storage + workspace string +} + +func newTaskHarness(t *testing.T, modelFn func(context.Context, model.Request) (model.Stream, error), policy harness.PermissionPolicy, invocation runtime.Invocation, background runtime.BackgroundServices) *taskHarness { + t.Helper() + workspace := t.TempDir() + store, err := storage.OpenSQLite(filepath.Join(t.TempDir(), "lightcode.db")) + testassert.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + task := openTask(t) + owner, cancel := context.WithCancel(context.Background()) + h, err := harness.New(owner, harness.Dependencies{ + Storage: store, + Prepare: func(context.Context, harness.PreparationRequest) (harness.PreparedExecution, error) { + return harness.PreparedExecution{ + Capture: taskCapture(), + Open: func(_ context.Context, admission harness.OperationAdmission) (harness.Execution, error) { + tc := runtime.ToolContext{ + Workspace: workspace, + AdmittedEntry: admission.AdmittedEntry, + Invocation: invocation, + Constraints: runtime.ToolConstraints{Readonly: admission.Execution.Readonly, WriteDir: admission.Execution.WriteDir}, + Background: background, + } + return harness.Execution{ + Permissions: policy, + OpenInvocation: func(context.Context, harness.InvocationRequest) (harness.InvocationExecution, error) { + return harness.InvocationExecution{ + Model: modelFn, + NormalizeTool: func(call model.ToolCall) (json.RawMessage, error) { return task.Normalize(tc, call) }, + Tool: func(ctx context.Context, call model.ToolCall) harness.PreparedTool { + return task.Prepare(ctx, tc, call) + }, + }, nil + }, + }, nil + }, + }, nil + }, + }) + if err != nil { + cancel() + t.Fatalf("harness.New: %v", err) + } + th := &taskHarness{t: t, h: h, store: store, workspace: workspace} + t.Cleanup(cancel) + return th +} + +func (th *taskHarness) createSession() string { + th.t.Helper() + rec, err := th.h.CreateSession(context.Background(), harness.CreateSessionRequest{Workspace: th.workspace, AgentType: "coder"}) + if err != nil { + th.t.Fatalf("CreateSession: %v", err) + } + return rec.Identity.SessionID +} + +func (th *taskHarness) submit(sessionID, operationID, text string) { + th.t.Helper() + res, err := th.h.Submit(context.Background(), harness.SubmitRequest{ + SessionID: sessionID, OperationID: operationID, Origin: harness.InputOriginUser, + Content: []model.ContentPart{{Kind: model.PartText, Text: text}}, Mode: harness.MessageModeRegular, + }) + if err != nil || res.Disposition != harness.DispositionAdmitted { + th.t.Fatalf("Submit = (%+v, %v), want admission", res, err) + } +} + +// awaitSettled waits for the operation's terminal settlement and returns it. +func (th *taskHarness) awaitSettled(sessionID, operationID string) harness.OperationRecord { + th.t.Helper() + deadline := time.Now().Add(15 * time.Second) + for { + rec, err := th.h.ReadOperation(context.Background(), sessionID, operationID) + if err != nil { + th.t.Fatalf("ReadOperation: %v", err) + } + switch rec.State.Status { + case harness.OperationSuccess, harness.OperationFailure, harness.OperationInterruption: + return rec + } + if time.Now().After(deadline) { + th.t.Fatalf("operation %q never settled: %+v", operationID, rec.State) + } + time.Sleep(5 * time.Millisecond) + } +} + +// readToolResults decodes every committed tool_result entry of one session, +// keyed by tool call ID. +func (th *taskHarness) readToolResults(sessionID string) map[string]model.ToolResult { + th.t.Helper() + entries, err := th.store.ReadEntries(context.Background(), sessionID, 0) + if err != nil { + th.t.Fatalf("ReadEntries: %v", err) + } + results := make(map[string]model.ToolResult) + for _, entry := range entries { + if entry.Kind != harness.EntryToolResult { + continue + } + var wire struct { + ToolCallID string `json:"tool_call_id"` + Status model.ToolResultStatus `json:"status"` + Content string `json:"content"` + Metadata json.RawMessage `json:"metadata"` + } + if err := json.Unmarshal(entry.Payload, &wire); err != nil { + th.t.Fatalf("decode tool_result payload: %v", err) + } + results[wire.ToolCallID] = model.ToolResult{CallID: wire.ToolCallID, Status: wire.Status, Content: wire.Content} + } + return results +} diff --git a/runtime/composition.go b/runtime/composition.go index 24d5b0fc..dd35f57b 100644 --- a/runtime/composition.go +++ b/runtime/composition.go @@ -112,11 +112,7 @@ type Instance struct { // of its owned input: no I/O, background work, waits, or retained input. A // nil ValidateConfig means the plugin has no settings, so only absent // configuration or the empty object is accepted, never arbitrary ignored -// JSON. An export declared with exactly the harness.Storage or -// harness.JobStopper type is a private Core seam binding, identified by type -// rather than a reserved ID; it stays in the same declaration, instance, and -// disposal machinery but is omitted from ordinary Bindings and the capability -// universe. +// JSON. type Plugin struct { ID string Scope ScopeKind @@ -195,6 +191,26 @@ func (in Invocation) Config(pluginID string) json.RawMessage { return append(json.RawMessage(nil), raw...) } +// toolsSettings returns the captured snapshot's owned top-level tools +// section, or nil when the revision carries none. The baseline tools' section +// is independent of every plugin's own configuration, including a registered +// plugin that happens to share the "tools" ID. +func (in Invocation) toolsSettings() json.RawMessage { + if in.snapshot == nil { + return nil + } + return append(json.RawMessage(nil), in.snapshot.tools...) +} + +// jobsSettings returns the captured snapshot's owned top-level jobs section, +// or nil when the revision carries none. +func (in Invocation) jobsSettings() json.RawMessage { + if in.snapshot == nil { + return nil + } + return append(json.RawMessage(nil), in.snapshot.jobs...) +} + // AgentTypes returns the captured snapshot's owned roster projection of the // loaded Agent definitions, preserving the snapshot's ordering. The zero // Invocation returns an empty roster. @@ -226,28 +242,61 @@ func acceptSettings(plugin Plugin, raw json.RawMessage) error { return fmt.Errorf("plugin %q declares no settings validator but received configuration: %w", plugin.ID, ErrComposition) } -// typedExportDecl records one export declared as exactly a -// Harness-recognized interface type — harness.Storage's private Core binding -// or the harness.JobStopper job-stop seam — so the Runtime can identify it by -// declared type rather than by ID or value assertion. -type typedExportDecl struct { - plugin string - scope ScopeKind - id string +// provider is one capability ID's supplier in the composition's single export +// table: a plugin's export, or the fixed ownerProvider identity for the +// baseline standard tools the Runtime owner supplies directly. +type provider struct { + plugin int + spec CapabilitySpec +} + +// ownerProvider is the export table's supplier index for the baseline +// standard tools: they have no Plugin declaration and no factory. +const ownerProvider = -1 + +func (p provider) owner() bool { return p.plugin == ownerProvider } + +// providerLabel names one supplier in a rejection: the plugin's registered ID, +// or the owner's directly constructed standard tools. +func providerLabel(owned []Plugin, p provider) string { + if p.owner() { + return "the owner's standard tools" + } + return owned[p.plugin].ID +} + +// providerScope is the lifetime a supplier provides: the owner-provided +// standard tools are Runtime-lifetime capabilities. +func providerScope(owned []Plugin, p provider) ScopeKind { + if p.owner() { + return ScopeRuntime + } + return owned[p.plugin].Scope } // composition is the validated static plugin set: an owned declaration copy, // per-scope construction plans in stable topological order (registration -// order breaks ties), the ordinary capability universe excluding Core -// storage exports, the tool-declaration universe feeding the agent parser, -// the storage export declarations, and the job-stop seam declarations. +// order breaks ties), and the one export table every capability ID resolves +// through — the seven standard tool declarations seeded as owner-provided +// Runtime-lifetime providers first, then the optional plugins' exports. The +// owner installs the directly constructed standard tool values into the +// Runtime scope's bindings, so every capability — a standard tool or a plugin +// export — travels the same selectCapabilities and Bind path. type composition struct { plugins []Plugin plan map[ScopeKind][]Plugin capabilityIDs []string toolIDs []string - coreExports []typedExportDecl - jobStoppers []typedExportDecl + + // exports is the one provider table: every selectable capability ID with + // its declaring type, description function, and supplier. + exports map[string]provider + + // standardValues holds the owner-constructed standard tool values the + // Runtime scope binds as its own provided capabilities. It is set by the + // Runtime owner before the Runtime scope opens; an isolated composition + // leaves it nil and its Runtime scope binds no tool values. + standardValues map[string]Tool // reportWarning is the owner's neutral plugin-warning sink: it receives // one registered plugin's ID with the reported (kind,message) and performs @@ -263,15 +312,17 @@ type composition struct { } // newComposition validates the complete selected plugin set before any -// factory can run: unique nonempty plugin IDs, valid scopes, non-nil Open, -// at least one provider, unique nonempty export IDs globally and unique -// nonempty dependency IDs per plugin, assignable provider types, -// lifetime-compatible PreparationHook declarations (Runtime or Workspace -// scope only), Tool declarations carrying their description functions, at -// most one ModelAdaptation export and it Runtime scoped, and -// same-or-longer-lived dependency scopes and an acyclic dependency graph. An -// ordinary dependency on an ID reserved by a Core -// storage export is a plain missing binding and is rejected. +// factory can run: unique nonempty plugin IDs, valid scopes, non-nil Open, at +// least one provider, unique nonempty export IDs globally and unique nonempty +// dependency IDs per plugin, assignable provider types, lifetime-compatible +// PreparationHook declarations (Runtime or Workspace scope only), Tool +// declarations carrying their description functions, at most one +// ModelAdaptation export and it Runtime scoped, and same-or-longer-lived +// dependency scopes and an acyclic dependency graph. The seven standard tool +// declarations are seeded into the same export table as owner-provided +// Runtime-lifetime capabilities, so an optional export naming one collides as +// a duplicate provider and an ordinary dependency on one resolves through the +// same type and lifetime rules as any other capability. func newComposition(plugins []Plugin) (*composition, error) { owned := make([]Plugin, len(plugins)) for i, p := range plugins { @@ -280,19 +331,15 @@ func newComposition(plugins []Plugin) (*composition, error) { owned[i] = p } - type exportSource struct { - plugin int - spec CapabilitySpec + exports := make(map[string]provider, len(standardToolSpecs())) + var capabilityIDs, toolIDs []string + for _, spec := range standardToolSpecs() { + exports[spec.id] = provider{plugin: ownerProvider, spec: spec} + capabilityIDs = append(capabilityIDs, spec.id) + toolIDs = append(toolIDs, spec.id) } - storageType := reflect.TypeFor[harness.Storage]() - jobStopperType := reflect.TypeFor[harness.JobStopper]() - all := make(map[string]exportSource) - ordinary := make(map[string]exportSource) + seenPluginIDs := make(map[string]bool) - var capabilityIDs []string - var toolIDs []string - var coreExports []typedExportDecl - var jobStoppers []typedExportDecl var adaptationExports []string for i, p := range owned { if p.ID == "" { @@ -328,29 +375,22 @@ func newComposition(plugins []Plugin) (*composition, error) { if prov.typ == toolType && prov.describe == nil { return nil, fmt.Errorf("plugin %q: capability %q declared as Tool lacks its description function: %w", p.ID, prov.id, ErrComposition) } - if src, dup := all[prov.id]; dup { - return nil, fmt.Errorf("plugin %q: capability %q already exported by %q: %w", p.ID, prov.id, owned[src.plugin].ID, ErrComposition) + if src, dup := exports[prov.id]; dup { + return nil, fmt.Errorf("plugin %q: capability %q already exported by %s: %w", p.ID, prov.id, providerLabel(owned, src), ErrComposition) } - all[prov.id] = exportSource{plugin: i, spec: prov} + exports[prov.id] = provider{plugin: i, spec: prov} + capabilityIDs = append(capabilityIDs, prov.id) if prov.typ.Implements(preparationHookType) && p.Scope != ScopeRuntime && p.Scope != ScopeWorkspace { return nil, fmt.Errorf("plugin %q (%s): capability %q declared as %s implements PreparationHook and requires Runtime or Workspace scope: %w", p.ID, p.Scope, prov.id, prov.typ, ErrComposition) } if prov.typ == modelAdaptationType && p.Scope != ScopeRuntime { return nil, fmt.Errorf("plugin %q (%s): capability %q declared as %s requires Runtime scope: %w", p.ID, p.Scope, prov.id, prov.typ, ErrComposition) } - if prov.typ == storageType { - coreExports = append(coreExports, typedExportDecl{plugin: p.ID, scope: p.Scope, id: prov.id}) - } else if prov.typ == jobStopperType { - jobStoppers = append(jobStoppers, typedExportDecl{plugin: p.ID, scope: p.Scope, id: prov.id}) - } else { - ordinary[prov.id] = exportSource{plugin: i, spec: prov} - capabilityIDs = append(capabilityIDs, prov.id) - if prov.typ == toolType { - toolIDs = append(toolIDs, prov.id) - } - if prov.typ == modelAdaptationType { - adaptationExports = append(adaptationExports, prov.id) - } + if prov.typ == toolType { + toolIDs = append(toolIDs, prov.id) + } + if prov.typ == modelAdaptationType { + adaptationExports = append(adaptationExports, prov.id) } } } @@ -359,16 +399,15 @@ func newComposition(plugins []Plugin) (*composition, error) { } for _, p := range owned { for _, req := range p.Requires { - src, ok := ordinary[req.id] + src, ok := exports[req.id] if !ok { - return nil, fmt.Errorf("plugin %q: required capability %q has no ordinary binding: %w", p.ID, req.id, ErrComposition) + return nil, fmt.Errorf("plugin %q: required capability %q has no provider: %w", p.ID, req.id, ErrComposition) } if !src.spec.typ.AssignableTo(req.typ) { return nil, fmt.Errorf("plugin %q: capability %q declared as %s is not assignable to required %s: %w", p.ID, req.id, src.spec.typ, req.typ, ErrComposition) } - provider := owned[src.plugin] - if provider.Scope.rank() > p.Scope.rank() { - return nil, fmt.Errorf("plugin %q (%s) requires %q from shorter-lived %s plugin %q: %w", p.ID, p.Scope, req.id, provider.Scope, provider.ID, ErrComposition) + if providerScope(owned, src).rank() > p.Scope.rank() { + return nil, fmt.Errorf("plugin %q (%s) requires %q from shorter-lived %s provider %s: %w", p.ID, p.Scope, req.id, providerScope(owned, src), providerLabel(owned, src), ErrComposition) } } } @@ -385,7 +424,10 @@ func newComposition(plugins []Plugin) (*composition, error) { } for i, p := range owned { for _, req := range p.Requires { - src := ordinary[req.id] + src := exports[req.id] + if src.owner() { // the owner supplies it; no plugin edge exists + continue + } if edges[i][src.plugin] { continue } @@ -428,8 +470,7 @@ func newComposition(plugins []Plugin) (*composition, error) { plan: plan, capabilityIDs: capabilityIDs, toolIDs: toolIDs, - coreExports: coreExports, - jobStoppers: jobStoppers, + exports: exports, modelAdaptation: modelAdaptation, }, nil } @@ -480,21 +521,23 @@ func (c *composition) bindReportWarning(scopeCtx context.Context, info ScopeInfo // openScope constructs the plugins planned for info.Kind in topological // order, seeding ordinary dependencies from the supplied ancestor scopes in -// longer-to-shorter order. The failing factory owns its provisional -// resources; every other failure runs the common scope cleanup — cancel, join -// admitted work, dispose constructed instances in reverse construction order -// attempting all and joining errors — and publishes nothing. +// longer-to-shorter order. The Runtime scope binds the owner's directly +// constructed standard tool values as its own provided capabilities before any +// planned factory runs, so a factory's declared dependency on a standard tool +// resolves through the same Bind path as any other capability. The failing +// factory owns its provisional resources; every other failure runs the common +// scope cleanup — cancel, join admitted work, dispose constructed instances in +// reverse construction order attempting all and joining errors — and publishes +// nothing. func (c *composition) openScope(ctx context.Context, info ScopeInfo, ancestors []*scope) (*scope, error) { if err := validateScopeInfo(info); err != nil { return nil, err } scopeCtx, cancel := context.WithCancel(ctx) sc := &scope{ - info: info, - ctx: scopeCtx, - cancel: cancel, - storages: make(map[string]any), - jobStoppers: make(map[string]any), + info: info, + ctx: scopeCtx, + cancel: cancel, } if len(ancestors) > 0 { sc.obs = ancestors[0].obs @@ -506,6 +549,17 @@ func (c *composition) openScope(ctx context.Context, info ScopeInfo, ancestors [ } } own := make(map[string]bindingEntry) + if info.Kind == ScopeRuntime { + for id, src := range c.exports { + value, supplied := c.standardValues[id] + if !src.owner() || !supplied { + continue + } + entry := bindingEntry{declared: src.spec.typ, value: value, from: sc} + resolved[id] = entry + own[id] = entry + } + } fail := func(err error) (*scope, error) { return nil, errors.Join(err, sc.cleanup()) } @@ -555,8 +609,8 @@ func (c *composition) openScope(ctx context.Context, info ScopeInfo, ancestors [ // registerInstance validates one factory output — exactly the declared IDs, // each with a dynamic Go type assignable to its declaration, untyped nil // supplying no verifiable type — and copies the returned map into the scope's -// resolved ordinary bindings and Core storage values. Typed nils and zero -// concrete values follow the ordinary assignability rule. +// resolved ordinary bindings. Typed nils and zero concrete values follow the +// ordinary assignability rule. func registerInstance(p Plugin, values map[string]any, sc *scope, resolved, own map[string]bindingEntry) error { declared := make(map[string]CapabilitySpec, len(p.Provides)) for _, spec := range p.Provides { @@ -567,8 +621,6 @@ func registerInstance(p Plugin, values map[string]any, sc *scope, resolved, own return fmt.Errorf("instance exports undeclared capability %q: %w", id, ErrComposition) } } - storageType := reflect.TypeFor[harness.Storage]() - jobStopperType := reflect.TypeFor[harness.JobStopper]() for id, spec := range declared { value, ok := values[id] if !ok { @@ -580,38 +632,29 @@ func registerInstance(p Plugin, values map[string]any, sc *scope, resolved, own if !reflect.TypeOf(value).AssignableTo(spec.typ) { return fmt.Errorf("capability %q supplied as %T is not assignable to declared %s: %w", id, value, spec.typ, ErrComposition) } - if spec.typ == storageType { - sc.storages[id] = value - } else if spec.typ == jobStopperType { - sc.jobStoppers[id] = value - } else { - entry := bindingEntry{declared: spec.typ, value: value, from: sc, plugin: p.ID} - own[id] = entry - resolved[id] = entry - } + entry := bindingEntry{declared: spec.typ, value: value, from: sc, plugin: p.ID} + own[id] = entry + resolved[id] = entry } return nil } -// toolSpec resolves one tool ID to its composed declaration. Agent tool -// lists are validated against the composed tool universe, so a miss cannot -// occur in a published selection; the returned spec is the one declaration -// the ID globally names. +// toolSpec resolves one tool ID to its composed declaration through the +// single export table: the owner-provided standard tools and the plugin +// declarations are the same lookup. Agent tool lists are validated against the +// composed tool universe, so a miss cannot occur in a published selection; the +// returned spec is the one declaration the ID globally names. func (c *composition) toolSpec(id string) (CapabilitySpec, bool) { - for _, p := range c.plugins { - for _, spec := range p.Provides { - if spec.id == id && spec.typ == toolType { - return spec, true - } - } + if src, ok := c.exports[id]; ok && src.spec.typ == toolType { + return src.spec, true } return CapabilitySpec{}, false } // selectCapabilities builds an Agent's selected view from the same resolved -// scope instances, containing only the selected ordinary exports. Unknown or -// Core-storage IDs are not in the capability universe and are rejected; the -// selected view never rebinds. +// scope instances, containing only the selected capability IDs. Unknown IDs are +// not in the capability universe and are rejected; the selected view never +// rebinds. func selectCapabilities(scopes []*scope, selected []string) (Bindings, error) { entries := make(map[string]bindingEntry, len(selected)) for _, id := range selected { @@ -624,7 +667,7 @@ func selectCapabilities(scopes []*scope, selected []string) (Bindings, error) { } } if !found { - return Bindings{}, fmt.Errorf("capability %q is not an ordinary export of the supplied scopes: %w", id, ErrComposition) + return Bindings{}, fmt.Errorf("capability %q is not a bound capability of the supplied scopes: %w", id, ErrComposition) } } return Bindings{entries: entries}, nil @@ -647,10 +690,8 @@ type scope struct { // ancestor; scopes constructed before the owner exists keep it nil. obs *observation - bindings Bindings - storages map[string]any - jobStoppers map[string]any - closers []func() error + bindings Bindings + closers []func() error mu sync.Mutex closed bool diff --git a/runtime/composition_test.go b/runtime/composition_test.go index 12b2a849..21b16584 100644 --- a/runtime/composition_test.go +++ b/runtime/composition_test.go @@ -176,15 +176,11 @@ func TestCompositionRejectsInvalidDeclarations(t *testing.T) { }}, {"export ID collision across plugins", []Plugin{declares("a", ScopeRuntime, "dup"), declares("b", ScopeRuntime, "dup")}}, {"export ID collision within one plugin", []Plugin{declares("a", ScopeRuntime, "dup", "dup")}}, - {"ordinary export collides with Core storage export", []Plugin{ + {"export ID collision across declaration types", []Plugin{ provides("a", ScopeRuntime, Spec[harness.Storage]("core")), declares("b", ScopeRuntime, "core"), }}, {"missing binding", []Plugin{requires("a", ScopeRuntime, Spec[greeter]("nobody"))}}, - {"dependency on Core storage export by ID", []Plugin{ - provides("a", ScopeRuntime, Spec[harness.Storage]("core")), - requires("b", ScopeRuntime, Spec[harness.Storage]("core")), - }}, {"provider type not assignable to requested type", []Plugin{ provides("a", ScopeRuntime, Spec[greeter]("g")), requires("b", ScopeRuntime, Spec[loudGreeter]("g")), @@ -244,7 +240,7 @@ func TestCompositionAcceptsLongLivedHookDeclarations(t *testing.T) { testassert.Require(t, got == 1, "%s plan holds %d plugins, want 1", kind, got) } } - ids := append([]string(nil), c.capabilityIDs...) + ids := append([]string(nil), c.capabilityIDs[len(standardToolSpecs()):]...) slices.Sort(ids) { want := []string{"ag.byvalue", "ag.g", "hook.iface", "hook.ptr", "hook.value", "op.g"} @@ -451,41 +447,30 @@ func TestScopeConstructionFailuresRollBackWithoutPublishing(t *testing.T) { } } -func TestScopeAcceptsZeroTypedAndCoreSeamValues(t *testing.T) { +// TestScopeAcceptsZeroTypedValues proves the uniform export rule: every +// declared value, whatever its type, is an ordinary capability with an +// ordinary binding and an ordinary selectable ID — the composition recognizes +// no storage or job-stopper seam. +func TestScopeAcceptsZeroTypedValues(t *testing.T) { c := mustComposition(t, Plugin{ID: "ok", Scope: ScopeRuntime, Provides: []CapabilitySpec{Spec[int]("ok.n"), Spec[counted]("ok.c"), Spec[harness.Storage]("ok.store"), Spec[harness.JobStopper]("ok.stop")}, Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { return Instance{Values: map[string]any{"ok.n": 0, "ok.c": (*counterService)(nil), "ok.store": stubCoreStorage{}, "ok.stop": (*stubStopper)(nil)}}, nil }}, ) - testassert.Require(t, len(c.capabilityIDs) == 2 && c.capabilityIDs[0] == "ok.n" && c.capabilityIDs[1] == "ok.c" && len(c.coreExports) == 1 && c.coreExports[0].id == "ok.store" && c.coreExports[0].plugin == "ok" && c.coreExports[0].scope == ScopeRuntime && len(c.jobStoppers) == 1 && c.jobStoppers[0].id == "ok.stop" && c.jobStoppers[0].plugin == "ok" && c.jobStoppers[0].scope == ScopeRuntime, "capability universe = %q, want the ordinary IDs without the Core seam exports; Core storage declarations = %+v, want the one Runtime export; job-stop declarations = %+v, want the one Runtime export", c.capabilityIDs, c.coreExports, c.jobStoppers) + ids := append([]string(nil), c.capabilityIDs[len(standardToolSpecs()):]...) + slices.Sort(ids) + testassert.Require(t, slices.Equal(ids, []string{"ok.c", "ok.n", "ok.stop", "ok.store"}), "capability universe past the standard-tool prefix = %q, want every declared export", ids) sc := mustOpenScope(t, c, context.Background(), runtimeScopeInfo(), nil) { - _, ok := sc.storages["ok.store"].(stubCoreStorage) - testassert.Require(t, ok, "Core storage resolution = %#v, want the supplied stub", sc.storages) - } - { - _, ok := sc.jobStoppers["ok.stop"] - testassert.Require(t, ok, "job-stop resolution = %#v, want the supplied value", sc.jobStoppers) - } - { - _, leaked := sc.bindings.entries["ok.store"] - testassert.Require(t, !leaked, "Core storage export leaked into ordinary bindings") - } - { - _, leaked := sc.bindings.entries["ok.stop"] - testassert.Require(t, !leaked, "job-stop export leaked into ordinary bindings") + _, ok := sc.bindings.entries["ok.store"].value.(stubCoreStorage) + testassert.Require(t, ok, "storage export resolution = %#v, want the supplied value in the ordinary bindings", sc.bindings.entries) } { - _, err := selectCapabilities([]*scope{sc}, []string{"ok.store"}) - testassert.Check(t, errors.Is(err, ErrComposition), "selecting the Core storage export: %v, want ErrComposition", err) + testassert.Require(t, sc.bindings.entries["ok.stop"].value != nil, "job-stopper export resolution = %#v, want the supplied value in the ordinary bindings", sc.bindings.entries) } { - _, err := selectCapabilities([]*scope{sc}, []string{"ok.stop"}) - testassert.Check(t, errors.Is(err, ErrComposition), "selecting the job-stop export: %v, want ErrComposition", err) - } - { - _, err := selectCapabilities([]*scope{sc}, []string{"ok.n", "ok.c"}) - testassert.Check(t, err == nil, "selecting ordinary exports: %v", err) + _, err := selectCapabilities([]*scope{sc}, []string{"ok.n", "ok.c", "ok.store", "ok.stop"}) + testassert.Check(t, err == nil, "selecting every export: %v", err) } } @@ -1097,8 +1082,8 @@ func TestCompositionRejectsToolDeclarationWithoutDescription(t *testing.T) { name string plugins []Plugin }{ - {"Spec[Tool] provides", []Plugin{{ID: "tools", Scope: ScopeRuntime, Provides: []CapabilitySpec{Spec[Tool]("tool.x")}, Open: noop}}}, - {"ToolSpec with a nil function", []Plugin{{ID: "tools", Scope: ScopeRuntime, Provides: []CapabilitySpec{ToolSpec("tool.x", nil)}, Open: noop}}}, + {"Spec[Tool] provides", []Plugin{{ID: "toolprov", Scope: ScopeRuntime, Provides: []CapabilitySpec{Spec[Tool]("tool.x")}, Open: noop}}}, + {"ToolSpec with a nil function", []Plugin{{ID: "toolprov", Scope: ScopeRuntime, Provides: []CapabilitySpec{ToolSpec("tool.x", nil)}, Open: noop}}}, } { t.Run(row.name, func(t *testing.T) { { @@ -1118,7 +1103,7 @@ func TestCompositionRejectsToolDeclarationWithoutDescription(t *testing.T) { } return Instance{Values: map[string]any{"consumer.greet": loudGreeter{word: "hi"}}}, nil }} - provider := Plugin{ID: "tools", Scope: ScopeRuntime, + provider := Plugin{ID: "toolprov", Scope: ScopeRuntime, Provides: []CapabilitySpec{ToolSpec("tool.x", staticToolDescription("tool.x"))}, Open: noop} { _, err := newComposition([]Plugin{provider, consumer}) @@ -1127,8 +1112,8 @@ func TestCompositionRejectsToolDeclarationWithoutDescription(t *testing.T) { } // TestToolDeclarationsComposeAtAllScopes proves a tool provider may use any -// of the four scopes and its declaration ID joins the tool universe in -// registration order without running any factory. +// of the four scopes and its declaration ID joins the tool universe after the +// baseline standard tools, without running any factory. func TestToolDeclarationsComposeAtAllScopes(t *testing.T) { describe := func(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { return ToolDescription{}, nil @@ -1156,10 +1141,15 @@ func TestToolDeclarationsComposeAtAllScopes(t *testing.T) { testassert.Require(t, got == 1, "%s plan holds %d plugins, want 1", kind, got) } } + baselineIDs := make([]string, 0, len(standardToolSpecs())) + for _, spec := range standardToolSpecs() { + baselineIDs = append(baselineIDs, spec.id) + } + testassert.Require(t, slices.Equal(c.toolIDs[:len(baselineIDs)], baselineIDs), "tool universe prefix = %v, want the baseline standard tools first", c.toolIDs) slices.Sort(wantIDs) - gotIDs := append([]string(nil), c.toolIDs...) + gotIDs := append([]string(nil), c.toolIDs[len(baselineIDs):]...) slices.Sort(gotIDs) - testassert.Require(t, slices.Equal(gotIDs, wantIDs), "tool universe = %v, want every declared tool ID %v", gotIDs, wantIDs) + testassert.Require(t, slices.Equal(gotIDs, wantIDs), "declared tool universe = %v, want every declared tool ID %v", gotIDs, wantIDs) testassert.Equal(t, opened, 0) } @@ -1174,7 +1164,7 @@ func TestToolSpecDescribeValidatesAndCopies(t *testing.T) { source := model.ToolDefinition{Name: "tool.x", Description: "reads", Parameters: json.RawMessage(`{"a":1}`)} describe := func(invocation Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { gotInvocation, gotConstraints = invocation, constraints - return ToolDescription{Definition: source, Available: true, DefaultHidden: true}, nil + return ToolDescription{Definition: source, Available: true}, nil } spec := ToolSpec("tool.x", describe) @@ -1185,7 +1175,7 @@ func TestToolSpecDescribeValidatesAndCopies(t *testing.T) { if gotInvocation.Revision() != wantInvocation.Revision() || gotConstraints != wantConstraints { t.Fatalf("describe inputs = (%q, %+v), want the supplied invocation and constraints echoed", gotInvocation.Revision(), gotConstraints) } - testassert.Require(t, description.Available && description.DefaultHidden && description.Definition.Description == "reads" && description.Definition.Name == "tool.x", "description = %+v, want the flags and validated definition passed through", description) + testassert.Require(t, description.Available && description.Definition.Description == "reads" && description.Definition.Name == "tool.x", "description = %+v, want the availability and validated definition passed through", description) // The returned definition is an owned copy in both directions. source.Parameters = json.RawMessage(`{"a":2}`) testassert.Require(t, string(description.Definition.Parameters) == `{"a":1}`, "returned parameters = %s, want the copied bytes", description.Definition.Parameters) diff --git a/runtime/configuration.go b/runtime/configuration.go index fcdffaf0..fa1b84e7 100644 --- a/runtime/configuration.go +++ b/runtime/configuration.go @@ -16,6 +16,7 @@ import ( "github.com/MMinasyan/lightcode/internal/agents" "github.com/MMinasyan/lightcode/internal/catalog" "github.com/MMinasyan/lightcode/internal/config" + "github.com/MMinasyan/lightcode/internal/jobs" "github.com/MMinasyan/lightcode/model" "github.com/MMinasyan/lightcode/protocol" ) @@ -35,6 +36,10 @@ type configuration struct { definitions []agents.Resolved agentWarnings []agents.Warning sessions config.SessionConfig + tools json.RawMessage + jobs json.RawMessage + compaction json.RawMessage + compactionValues compactionSettings plugins map[string]json.RawMessage userProviders map[string]any permissions json.RawMessage @@ -42,21 +47,24 @@ type configuration struct { } // capturedConfigDocument is the private single-pass decode of one captured -// main configuration document: only the catalog, sessions, and plugin -// sections are consumed while the raw permissions member is captured -// unchanged for policy resolution, numbers stay exact through the UseNumber -// decoder, and no legacy whole-document shape or permission rule is -// enforced. +// main configuration document: the catalog, sessions, the top-level baseline +// tools/jobs/compaction objects, and the plugin sections are consumed while +// the raw permissions member is captured unchanged for policy resolution, +// numbers stay exact through the UseNumber decoder, and no legacy +// whole-document shape or permission rule is enforced. type capturedConfigDocument struct { Providers map[string]any `json:"providers"` Sessions json.RawMessage `json:"sessions"` + Tools json.RawMessage `json:"tools"` + Jobs json.RawMessage `json:"jobs"` + Compaction json.RawMessage `json:"compaction"` Plugins map[string]json.RawMessage `json:"plugins"` Permissions json.RawMessage `json:"permissions"` } // decodeCapturedConfig decodes one captured main configuration document in a -// single pass: only the catalog, sessions, and plugin sections are consumed, -// numbers stay exact through the UseNumber decoder, and no legacy +// single pass: the catalog, sessions, baseline and plugin sections are +// consumed, numbers stay exact through the UseNumber decoder, and no legacy // whole-document shape or permission rule is enforced. func decodeCapturedConfig(configData []byte) (capturedConfigDocument, error) { var doc capturedConfigDocument @@ -90,6 +98,16 @@ func newConfiguration(generation uint64, doc capturedConfigDocument, built catal if err != nil { return nil, fmt.Errorf("captured configuration sessions: %w", err) } + if err := validateBaselineSection("tools", doc.Tools, validateToolsSettings); err != nil { + return nil, fmt.Errorf("captured configuration tools: %w", err) + } + if err := validateBaselineSection("jobs", doc.Jobs, jobs.ValidateSettings); err != nil { + return nil, fmt.Errorf("captured configuration jobs: %w", err) + } + compaction, err := parseCompactionSettings(doc.Compaction) + if err != nil { + return nil, fmt.Errorf("captured configuration compaction: %w", err) + } definitions, err := agents.ParseWithCapabilities(agentsData, capabilityIDs, toolIDs, defaultCapabilityIDs) if err != nil { return nil, fmt.Errorf("decode captured agent definitions: %w", err) @@ -101,6 +119,10 @@ func newConfiguration(generation uint64, doc capturedConfigDocument, built catal definitions: definitions.All(), agentWarnings: definitions.Warnings(), sessions: sessions, + tools: doc.Tools, + jobs: doc.Jobs, + compaction: doc.Compaction, + compactionValues: compaction, plugins: doc.Plugins, userProviders: doc.Providers, permissions: doc.Permissions, @@ -109,20 +131,26 @@ func newConfiguration(generation uint64, doc capturedConfigDocument, built catal } // projectSettings projects one captured revision's settings view: the parsed -// session policy beside every owned raw plugin section as its opaque JSON -// document string. This is a lossless transport projection, not an -// interpreted settings authority — each document is returned as captured and -// the selected declaration's validator owns interpretation. An owned empty -// object is an empty document, absent sections are absent map members, and -// unowned numbers survive as their exact lexemes inside the strings. -func projectSettings(sessions config.SessionConfig, plugins map[string]json.RawMessage) protocol.Settings { +// session policy beside the top-level baseline sections and every owned raw +// plugin section. The baseline sections are copied as their exact captured +// JSON objects, with an absent section projected as the empty object so its +// owning service applies its defaults at consumption. Every plugin section is +// an opaque JSON document string. This is a lossless transport projection, not +// an interpreted settings authority — each section is returned as captured +// and the owning declaration's validator owns interpretation. An owned empty +// object is an empty document, absent plugin sections are absent map members, +// and unowned numbers survive as their exact lexemes inside the copied bytes. +func projectSettings(sessions config.SessionConfig, tools, jobs, compaction json.RawMessage, plugins map[string]json.RawMessage) protocol.Settings { out := protocol.Settings{ Sessions: protocol.SessionsSettings{ AutoArchive: sessions.AutoArchive, ArchiveAfterDays: sessions.ArchiveAfterDays, DeleteAfterArchiveDays: sessions.DeleteAfterArchiveDays, }, - Plugins: make(protocol.PluginsSettings, len(plugins)), + Tools: projectSettingsObject(tools), + Jobs: projectSettingsObject(jobs), + Compaction: projectSettingsObject(compaction), + Plugins: make(protocol.PluginsSettings, len(plugins)), } for id, raw := range plugins { out.Plugins[id] = string(raw) @@ -130,6 +158,16 @@ func projectSettings(sessions config.SessionConfig, plugins map[string]json.RawM return out } +// projectSettingsObject returns one owned copy of a top-level baseline +// section; an absent section is the empty object its consuming service +// defaults from. +func projectSettingsObject(raw json.RawMessage) json.RawMessage { + if len(bytes.TrimSpace(raw)) == 0 { + return json.RawMessage("{}") + } + return append(json.RawMessage(nil), raw...) +} + // permissionPolicy resolves this revision's automatic policy for one // Workspace from the captured global permissions member and the captured // Workspace permission bytes keyed by directory ID; neither input is reread. diff --git a/runtime/configuration_connection_test.go b/runtime/configuration_connection_test.go index 1a4376bd..6ec5c474 100644 --- a/runtime/configuration_connection_test.go +++ b/runtime/configuration_connection_test.go @@ -1406,7 +1406,7 @@ func TestMutationResultStaysFrozenAfterNextPublication(t *testing.T) { h := newMetadataHarness(t) writeServiceFile(t, h.configPath, metadataConfigDocument) env := config.NewManagedEnvForTest(filepath.Join(h.dataDir, ".env")) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) svc.attachWarnings(newWarningStore()) svc.attachEnv(env) { @@ -2382,7 +2382,7 @@ func TestCreateProviderNilManagedEnvTypedFailure(t *testing.T) { // no second manager is constructed on demand. h := newServiceHarness(t) writeServiceFile(t, h.configPath, providerConfigFile("One")) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) svc.attachWarnings(newWarningStore()) { _, err := svc.publish(context.Background()) @@ -2497,7 +2497,7 @@ func TestProductionConnectThenCommandEnvironment(t *testing.T) { `"discp":`+nominalProvMember("https://disc.test/v1", "CONNECT_CHAIN_MANAGED", `{"m":{"name":"M","context_window":4096}}`)+`}}`) writeServiceFile(t, agents.PathForConfig(e.configPath), `{"runner": {"model": "prov/m", "system_prompt": "simple", "tools": ["env_echo"]}}`) - r, err := e.openPlugins(ctx, coreStoragePlugin(e.store), connectEnvEchoPlugin()) + r, err := e.openPlugins(ctx, connectEnvEchoPlugin()) testassert.NoError(t, err) defer r.Close(ctx) diff --git a/runtime/configuration_metadata_test.go b/runtime/configuration_metadata_test.go index 8b6c91fd..702c5b7d 100644 --- a/runtime/configuration_metadata_test.go +++ b/runtime/configuration_metadata_test.go @@ -109,7 +109,7 @@ func metadataBuiltinOverrideDocument() string { // subscribed service with its attached warning store. func metadataService(t *testing.T, h *serviceHarness) (*configurationService, *Subscription) { t.Helper() - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) svc.attachWarnings(newWarningStore()) { _, err := svc.publish(context.Background()) @@ -1453,7 +1453,8 @@ func TestRuntimeModelMetadataMutationAffectsNextAdmissions(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerConfigDocument(e.server.URL)) writeServiceFile(t, agents.PathForConfig(e.configPath), `{"worker":{"model":"prov/m","system_prompt":"simple"}}`) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer closeProjectionRuntime(r) ctx := context.Background() diff --git a/runtime/configuration_mutation.go b/runtime/configuration_mutation.go index 9d929fdc..e5b2c002 100644 --- a/runtime/configuration_mutation.go +++ b/runtime/configuration_mutation.go @@ -1,6 +1,7 @@ package runtime import ( + "bytes" "context" "encoding/json" "errors" @@ -321,13 +322,16 @@ func restoreOwningBytes(path string, prior []byte, existed bool) error { return nil } -// editSettings builds the settings edit: the whole sessions member and the -// whole plugins member are written as the generated target shape — each -// plugin ID's opaque document string becomes that plugin's owned raw -// section, and an ID absent from the target removes any newer section, -// exactly one object per document — and every other top-level root member is -// unowned and kept untouched. Never merged per field. The selected -// declaration's validator, not this edit, interprets each document. +// editSettings builds the settings edit: the whole sessions member, the +// top-level baseline tools, jobs and compaction sections, and the whole +// plugins member are written as the generated target shape — each baseline +// section as its exact JSON object bytes and each plugin ID's opaque document +// string becomes that plugin's owned raw section, and an ID absent from the +// target removes any newer section, exactly one object per document — and +// every other top-level root member is unowned and kept untouched. Never +// merged per field. The selected declaration's validator, not this edit, +// interprets each plugin document; the candidate construction validates the +// baseline sections with their owning services' validators. func editSettings(settings protocol.Settings) configurationEdit { return configurationEdit{apply: func(roots rawRoots, _ configurationCapture) (editedFile, error) { sessions, err := json.Marshal(settings.Sessions) @@ -344,10 +348,23 @@ func editSettings(settings protocol.Settings) configurationEdit { } roots.config["sessions"] = sessions roots.config["plugins"] = pluginsRaw + roots.config["tools"] = settingsSection(settings.Tools) + roots.config["jobs"] = settingsSection(settings.Jobs) + roots.config["compaction"] = settingsSection(settings.Compaction) return editMainConfig, nil }} } +// settingsSection copies one top-level baseline settings object; an absent +// member is the empty object the owning service defaults from, matching the +// projection's absent-section value. +func settingsSection(raw json.RawMessage) json.RawMessage { + if len(bytes.TrimSpace(raw)) == 0 { + return json.RawMessage("{}") + } + return append(json.RawMessage(nil), raw...) +} + // editAgentModel copies the retained owning model-field mutation onto the // latest agents root: a nonempty ref replaces the user model override, an // empty ref clears it. Every successful set or clear — absent override and diff --git a/runtime/configuration_mutation_composed_test.go b/runtime/configuration_mutation_composed_test.go index 76b0eecc..a411ee22 100644 --- a/runtime/configuration_mutation_composed_test.go +++ b/runtime/configuration_mutation_composed_test.go @@ -23,18 +23,36 @@ import ( // in-package fake validators alone do not prove the real limits. // composedMutationSettings is one complete settings shape the real -// validators accept: each compiled plugin's section as its opaque document -// string. +// validators accept: the top-level tools and jobs objects beside no optional +// plugin documents. func composedMutationSettings() protocol.Settings { return protocol.Settings{ - Sessions: protocol.SessionsSettings{AutoArchive: true, ArchiveAfterDays: 14, DeleteAfterArchiveDays: 3}, - Plugins: protocol.PluginsSettings{ - "tools": `{"command_timeout":90,"max_output_bytes":4096,"read_line_max_chars":4000,"read_max_lines":200}`, - "jobs": `{"max_background_processes":5,"max_output_bytes":8192,"read_line_max_chars":4000}`, - }, + Sessions: protocol.SessionsSettings{AutoArchive: true, ArchiveAfterDays: 14, DeleteAfterArchiveDays: 3}, + Tools: json.RawMessage(`{"command_timeout":90,"max_output_bytes":4096,"read_line_max_chars":4000,"read_max_lines":200}`), + Jobs: json.RawMessage(`{"max_background_processes":5,"max_output_bytes":8192,"read_line_max_chars":4000}`), + Compaction: json.RawMessage(`{}`), + Plugins: protocol.PluginsSettings{}, } } +// mutationEnv is this suite's own isolated composition environment: a +// throwaway home and one config file over a dedicated data root. The +// in-package suite keeps its copy because internal/plugins/* cannot be +// imported from package runtime's own tests. +type mutationEnv struct { + dataDir string + configPath string +} + +func newMutationEnv(t *testing.T) mutationEnv { + t.Helper() + home, dataDir, configDir := t.TempDir(), t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + configPath := filepath.Join(configDir, "config.json") + testassert.NoError(t, os.WriteFile(configPath, []byte(`{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}}}`), 0o600)) + return mutationEnv{dataDir: dataDir, configPath: configPath} +} + // TestComposedSettingsMutationThroughRealValidators proves the settings PUT // end to end over the shipped plugin set: a valid whole-shape write is // validated by the real plugins' validators, rewritten to the owning file, @@ -42,7 +60,7 @@ func composedMutationSettings() protocol.Settings { // tools validator rejects changes no file, generation, warning, or event. func TestComposedSettingsMutationThroughRealValidators(t *testing.T) { ctx := context.Background() - e := newComposeEnv(t) + e := newMutationEnv(t) r, err := runtime.OpenForTest(ctx, e.dataDir, e.configPath, builtin.Plugins()) testassert.NoError(t, err) sub, err := r.Subscribe(8) @@ -56,19 +74,17 @@ func TestComposedSettingsMutationThroughRealValidators(t *testing.T) { // The valid write: the real validators accept the marshaled sections. mutation, err := runtime.UpdateSettingsForTest(r, composedMutationSettings()) testassert.NoError(t, err) - testassert.Require(t, mutation.ConfigurationRevision.Generation == "2" && mutation.Result.Sessions.ArchiveAfterDays == 14 && compactComposed(t, []byte(mutation.Result.Plugins["jobs"])) == compactComposed(t, []byte(composedMutationSettings().Plugins["jobs"])), "mutation revision = %+v, want generation 2; mutation result = %+v, want the written shape", mutation.ConfigurationRevision, mutation.Result) + testassert.Require(t, mutation.ConfigurationRevision.Generation == "2" && mutation.Result.Sessions.ArchiveAfterDays == 14 && compactComposed(t, mutation.Result.Tools) == compactComposed(t, composedMutationSettings().Tools) && compactComposed(t, mutation.Result.Jobs) == compactComposed(t, composedMutationSettings().Jobs), "mutation revision = %+v, want generation 2; mutation result = %+v, want the written shape", mutation.ConfigurationRevision, mutation.Result) data, err := os.ReadFile(e.configPath) testassert.NoError(t, err) var root struct { - Plugins struct { - Tools json.RawMessage `json:"tools"` - } `json:"plugins"` + Tools json.RawMessage `json:"tools"` Sessions json.RawMessage `json:"sessions"` } testassert.NoError(t, json.Unmarshal(data, &root)) { - got := compactComposed(t, root.Plugins.Tools) - testassert.Require(t, strings.Contains(got, `"max_output_bytes":4096`), "plugins.tools member = %s, want the written section", got) + got := compactComposed(t, root.Tools) + testassert.Require(t, strings.Contains(got, `"max_output_bytes":4096`), "tools member = %s, want the written section", got) } if event, ok := nextComposedEvent(t, sub); !ok || composedEventKind(t, event) != "configuration_changed" || composedEventGeneration(t, event) != "2" { t.Fatalf("event = %s (ok=%v), want the generation 2 configuration event", composedEventJSON(event), ok) @@ -82,7 +98,7 @@ func TestComposedSettingsMutationThroughRealValidators(t *testing.T) { warnings, err := runtime.GetWarningsForTest(r) testassert.NoError(t, err) rejected := composedMutationSettings() - rejected.Plugins["tools"] = `{"command_timeout":0,"max_output_bytes":0,"read_line_max_chars":0,"read_max_lines":0}` + rejected.Tools = json.RawMessage(`{"command_timeout":0,"max_output_bytes":0,"read_line_max_chars":0,"read_max_lines":0}`) { mutation, err := runtime.UpdateSettingsForTest(r, rejected) testassert.Require(t, err != nil, "rejected mutation = %+v, want the real tools validator's failure", mutation) @@ -108,7 +124,7 @@ func TestComposedSettingsMutationThroughRealValidators(t *testing.T) { // while an unknown type fails invalid without creating a bare entry. func TestComposedAgentModelMutationThroughRealRoster(t *testing.T) { ctx := context.Background() - e := newComposeEnv(t) + e := newMutationEnv(t) r, err := runtime.OpenForTest(ctx, e.dataDir, e.configPath, builtin.Plugins()) testassert.NoError(t, err) agentsPath := filepath.Join(filepath.Dir(e.configPath), "agents.json") diff --git a/runtime/configuration_mutation_test.go b/runtime/configuration_mutation_test.go index a0b0fcde..3108900a 100644 --- a/runtime/configuration_mutation_test.go +++ b/runtime/configuration_mutation_test.go @@ -39,13 +39,12 @@ func mutationConfigDocument() string { } // mutationSettings is one complete settings shape the tests write: the -// sessions policy beside one opaque tools document string. +// sessions policy beside the top-level tools object and no plugin documents. func mutationSettings() protocol.Settings { return protocol.Settings{ Sessions: protocol.SessionsSettings{AutoArchive: false, ArchiveAfterDays: 5, DeleteAfterArchiveDays: 2}, - Plugins: protocol.PluginsSettings{ - "tools": `{"command_timeout":60,"max_output_bytes":2048,"read_line_max_chars":3000,"read_max_lines":100}`, - }, + Tools: json.RawMessage(`{"command_timeout":60,"max_output_bytes":2048,"read_line_max_chars":3000,"read_max_lines":100}`), + Plugins: protocol.PluginsSettings{}, } } @@ -64,17 +63,17 @@ func compactJSON(t *testing.T, data []byte) string { return buf.String() } -// opaqueSettingsDocument is the settings round-trip fixture: two compiled -// plugin sections carrying fields common Runtime does not know plus one -// int64 lexeme beyond JS number precision, one empty compiled section, and -// one unowned provider model carrying the same kind of lexeme. +// opaqueSettingsDocument is the settings round-trip fixture: one compiled +// plugin section carrying fields common Runtime does not know plus one int64 +// lexeme beyond JS number precision, one empty compiled section, the +// top-level tools section carrying the same kind of lexeme, and one unowned +// provider model carrying the same kind of lexeme. func opaqueSettingsDocument() string { return `{"providers":{"prov":` + nominalProvMember("https://prov.test/v1", "", `{"m":{"name":"One","context_window":9007199254740993}}`) + - `},"sessions":{"archive_after_days":3},"plugins":{` + - `"alpha":{"kept":1,"exact":9007199254740993},` + + `},"sessions":{"archive_after_days":3},` + `"tools":{"max_output_bytes":42,"exact":9007199254740993},` + - `"tasks":{}}}` + `"plugins":{"alpha":{"kept":1,"exact":9007199254740993},"tasks":{}}}` } // TestRuntimeSettingsReadSaveRoundTripPreservesOwnedPluginDocuments proves @@ -98,7 +97,6 @@ func TestRuntimeSettingsReadSaveRoundTripPreservesOwnedPluginDocuments(t *testin read := view.Settings.Plugins want := map[string]string{ "alpha": `{"kept":1,"exact":9007199254740993}`, - "tools": `{"max_output_bytes":42,"exact":9007199254740993}`, "tasks": `{}`, } testassert.Require(t, len(read) == len(want), "settings plugins = %v, want exactly the owned sections %v", read, want) @@ -256,7 +254,7 @@ func (p *writeSyncProbe) awaitWriteStarted(t *testing.T) { func TestConfigurationMutateSettingsReplacesWholeShapeAndRetainsUnowned(t *testing.T) { h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) testassert.NoError(t, err) @@ -271,7 +269,7 @@ func TestConfigurationMutateSettingsReplacesWholeShapeAndRetainsUnowned(t *testi if candidate.snapshot.generation != 2 || svc.current() != candidate.snapshot { t.Fatalf("mutate = generation %d (current published %v), want generation 2 published", candidate.snapshot.generation, svc.current() == candidate.snapshot) } - testassert.Equal(t, projectSettings(candidate.snapshot.sessions, candidate.snapshot.plugins).Sessions, wantSettings.Sessions) + testassert.Equal(t, projectSettings(candidate.snapshot.sessions, candidate.snapshot.tools, candidate.snapshot.jobs, candidate.snapshot.compaction, candidate.snapshot.plugins).Sessions, wantSettings.Sessions) // The owning file: the written members are the marshaled shape, the // unowned members and their exact numeric lexeme survive. @@ -283,7 +281,7 @@ func TestConfigurationMutateSettingsReplacesWholeShapeAndRetainsUnowned(t *testi var plugins map[string]json.RawMessage testassert.NoError(t, json.Unmarshal(root["plugins"], &plugins)) { - got, want := compactJSON(t, plugins["tools"]), compactJSON(t, []byte(wantSettings.Plugins["tools"])) + got, want := compactJSON(t, root["tools"]), compactJSON(t, wantSettings.Tools) testassert.Equal(t, got, want) } for _, omitted := range []string{"jobs", "tasks"} { @@ -326,7 +324,7 @@ func TestConfigurationMutateSettingsReplacesWholeShapeAndRetainsUnowned(t *testi func TestConfigurationMutateIdenticalBytesStillWriteAndPublish(t *testing.T) { h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) testassert.NoError(t, err) @@ -392,7 +390,7 @@ func TestConfigurationMutateSettingsOwnsOpaquePluginDocuments(t *testing.T) { var mu sync.Mutex // The fixture document carries the old alpha section the stale save must // remove wholesale. - writeServiceFile(t, h.configPath, strings.TrimSuffix(mutationConfigDocument(), "}")+`,"plugins":{"alpha":{"kept":1},"tools":{"max_output_bytes":42}}}`) + writeServiceFile(t, h.configPath, strings.TrimSuffix(mutationConfigDocument(), "}")+`,"tools":{"max_output_bytes":42},"plugins":{"alpha":{"kept":1}}}`) testassert.NoError(t, os.MkdirAll(h.dataDir, 0o700)) writeServiceFile(t, filepath.Join(h.dataDir, "agents.json"), workerAgentsDocument) svc := h.service(context.Background(), @@ -402,7 +400,7 @@ func TestConfigurationMutateSettingsOwnsOpaquePluginDocuments(t *testing.T) { mu.Unlock() return nil }), - servicePlugin("tools", &h.opens, acceptValidator), + servicePlugin("capowner", &h.opens, acceptValidator), ) svc.attachWarnings(newWarningStore()) { @@ -416,23 +414,23 @@ func TestConfigurationMutateSettingsOwnsOpaquePluginDocuments(t *testing.T) { agentsBefore, err := os.ReadFile(agentsPath) testassert.NoError(t, err) - // The omission domain: the target carries only the tools document; the - // old alpha section is removed wholesale even though it is valid. + // The omission domain: the target carries the tools object and no plugin + // documents; the old alpha section is removed wholesale even though it is + // valid. stale := protocol.Settings{ Sessions: protocol.SessionsSettings{AutoArchive: true, ArchiveAfterDays: 7, DeleteAfterArchiveDays: 7}, - Plugins: protocol.PluginsSettings{ - "tools": `{"command_timeout":30,"max_output_bytes":1024,"read_line_max_chars":2000,"read_max_lines":50}`, - }, + Tools: json.RawMessage(`{"command_timeout":30,"max_output_bytes":1024,"read_line_max_chars":2000,"read_max_lines":50}`), + Plugins: protocol.PluginsSettings{}, } candidate, err := svc.mutate(context.Background(), editSettings(stale)) testassert.NoError(t, err) - published := projectSettings(candidate.snapshot.sessions, candidate.snapshot.plugins) - testassert.Require(t, len(published.Plugins) == 1 && compactJSON(t, []byte(published.Plugins["tools"])) == compactJSON(t, []byte(stale.Plugins["tools"])), "published plugins = %v, want only the written tools document", published.Plugins) + published := projectSettings(candidate.snapshot.sessions, candidate.snapshot.tools, candidate.snapshot.jobs, candidate.snapshot.compaction, candidate.snapshot.plugins) + testassert.Require(t, len(published.Plugins) == 0 && compactJSON(t, published.Tools) == compactJSON(t, stale.Tools), "published plugins = %v, tools = %s, want no plugin documents and the written tools object", published.Plugins, published.Tools) root := fileRoot(t, h.configPath) var plugins map[string]json.RawMessage testassert.NoError(t, json.Unmarshal(root["plugins"], &plugins)) { - got, want := compactJSON(t, plugins["tools"]), compactJSON(t, []byte(stale.Plugins["tools"])) + got, want := compactJSON(t, root["tools"]), compactJSON(t, stale.Tools) testassert.Equal(t, got, want) } { @@ -494,7 +492,7 @@ func TestConfigurationMutateSettingsOwnsOpaquePluginDocuments(t *testing.T) { candidate, err = svc.mutate(context.Background(), editSettings(protocol.Settings{Sessions: stale.Sessions, Plugins: protocol.PluginsSettings{}})) testassert.NoError(t, err) { - got := projectSettings(candidate.snapshot.sessions, candidate.snapshot.plugins).Plugins + got := projectSettings(candidate.snapshot.sessions, candidate.snapshot.tools, candidate.snapshot.jobs, candidate.snapshot.compaction, candidate.snapshot.plugins).Plugins testassert.Require(t, len(got) == 0, "published plugins after the empty map = %v, want none", got) } root = fileRoot(t, h.configPath) @@ -510,7 +508,7 @@ func TestConfigurationMutateSettingsOwnsOpaquePluginDocuments(t *testing.T) { func TestConfigurationMutateEverySuccessfulEditWritesAndPublishes(t *testing.T) { h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) testassert.NoError(t, err) @@ -541,7 +539,7 @@ func TestConfigurationMutateWritesOwningFileOnly0600(t *testing.T) { h := newServiceHarness(t) // No input files exist yet: the mutation must not create the non-owning // file, and the owning file is born at mode 0600. - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) agentsPath := filepath.Join(h.dataDir, "agents.json") candidate, err := svc.mutate(context.Background(), svc.editAgentModel("primary", "prov/m")) @@ -757,37 +755,61 @@ func TestConfigurationMutateMalformedModelRefFailsWithoutWrite(t *testing.T) { // plugins member are removed by the whole-shape write instead — the // whole-plugins regression owns that axis.) func TestConfigurationMutateInvalidCandidateLeavesEverythingUnchanged(t *testing.T) { - sentinel := errors.New("tools settings invalid") + sentinel := errors.New("written settings invalid") // rejectWritten accepts the absent section but rejects the mutation's - // written tools section (MaxOutputBytes 2048). + // written section (2048) for the fixture's own unrelated plugin id. rejectWritten := func(raw json.RawMessage) error { if strings.Contains(string(raw), "2048") { return sentinel } return nil } + // withSection returns the shared written settings plus one extra owned + // plugin section, so a row can target one specific plugin settings owner. + withSection := func(id, document string) protocol.Settings { + settings := mutationSettings() + settings.Plugins[id] = document + return settings + } + // withTools replaces the written top-level tools object. + withTools := func(document string) protocol.Settings { + settings := mutationSettings() + settings.Tools = json.RawMessage(document) + return settings + } rows := []struct { name string validate func(json.RawMessage) error external string // the document an external editor writes before the edit + settings protocol.Settings wantSource string }{ { - name: "validator rejects the written section", + name: "the baseline settings owner rejects the written section", + validate: acceptValidator, + external: mutationConfigDocument(), + settings: withTools(`{"max_output_bytes":0}`), + wantSource: "tools: max_output_bytes must be positive", + }, + { + name: "an unrelated plugin validator rejects its own written section", validate: rejectWritten, external: mutationConfigDocument(), - wantSource: "tools settings invalid", + settings: withSection("capowner", `{"kept":2048}`), + wantSource: "written settings invalid", }, { name: "externally malformed document", validate: acceptValidator, external: `{not json`, + settings: mutationSettings(), wantSource: "decode", }, { name: "externally trailing document", validate: acceptValidator, external: mutationConfigDocument() + `{}`, + settings: mutationSettings(), wantSource: "after top-level value", }, } @@ -796,7 +818,7 @@ func TestConfigurationMutateInvalidCandidateLeavesEverythingUnchanged(t *testing h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) svc := h.service(context.Background(), - servicePlugin("tools", &h.opens, row.validate), + servicePlugin("capowner", &h.opens, row.validate), servicePlugin("alpha", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) @@ -812,7 +834,7 @@ func TestConfigurationMutateInvalidCandidateLeavesEverythingUnchanged(t *testing // is the state that write left. writeServiceFile(t, h.configPath, row.external) before, published, warnRev := metadataBaseline(t, svc, h.configPath) - candidate, err := svc.mutate(context.Background(), editSettings(mutationSettings())) + candidate, err := svc.mutate(context.Background(), editSettings(row.settings)) testassert.Require(t, candidate.snapshot == nil, "mutate = %+v, want a rejected candidate", candidate) testassert.Require(t, row.wantSource == "" || strings.Contains(err.Error(), row.wantSource), "error %v does not preserve %q", err, row.wantSource) assertMetadataRefused(t, svc, sub, h.configPath, before, published, warnRev, err, ErrConfiguration) @@ -858,7 +880,7 @@ func TestConfigurationMutateAgentEditInvalidCandidateLeavesEverythingUnchanged(t writeServiceFile(t, h.configPath, mutationConfigDocument()) writeServiceFile(t, filepath.Join(h.dataDir, "agents.json"), workerAgentsDocument) svc := h.service(context.Background(), - servicePlugin("tools", &h.opens, acceptValidator), + servicePlugin("capowner", &h.opens, acceptValidator), servicePlugin("alpha", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) @@ -897,7 +919,7 @@ func TestConfigurationMutateAgentEditInvalidCandidateLeavesEverythingUnchanged(t func TestConfigurationMutateMalformedPermissionsFallbackPublishes(t *testing.T) { h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) testassert.NoError(t, err) @@ -926,7 +948,7 @@ func TestConfigurationMutateCancelsBeforeWrite(t *testing.T) { h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) ctx, cancel := context.WithCancel(context.Background()) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, func(json.RawMessage) error { + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, func(json.RawMessage) error { cancel() return nil })) @@ -949,7 +971,7 @@ func TestConfigurationMutateCancelsBeforeWrite(t *testing.T) { owner, cancelOwner := context.WithCancel(context.Background()) h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) - svc := h.service(owner, servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(owner, servicePlugin("capowner", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) testassert.NoError(t, err) @@ -974,7 +996,7 @@ func TestConfigurationMutateCancelsBeforeWrite(t *testing.T) { func TestConfigurationMutateCompletesPublicationAfterWriteStarts(t *testing.T) { h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) testassert.NoError(t, err) @@ -1021,7 +1043,7 @@ func TestConfigurationMutateCompletesPublicationAfterWriteStarts(t *testing.T) { func TestConfigurationMutateWriteFailureLeavesEverythingUnchanged(t *testing.T) { h := newServiceHarness(t) writeServiceFile(t, h.configPath, mutationConfigDocument()) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, acceptValidator)) + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, acceptValidator)) { _, err := svc.publish(context.Background()) testassert.NoError(t, err) @@ -1056,7 +1078,7 @@ func TestConfigurationMutateSerializesWithReload(t *testing.T) { var builds atomic.Int64 gate := make(chan struct{}) entered := make(chan struct{}, 1) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, func(json.RawMessage) error { + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, func(json.RawMessage) error { if builds.Add(1) == 2 { // the second build — the mutation — parks entered <- struct{}{} <-gate @@ -1167,10 +1189,10 @@ func TestRuntimeSettingsMutationOwnershipAndResult(t *testing.T) { testassert.NoError(t, err) testassert.Require(t, mutation.ConfigurationRevision.Generation == "2" && mutation.ConfigurationRevision.InstanceId == "", "mutation revision = %+v, want generation 2 with the empty pre-server instance", mutation.ConfigurationRevision) mutation.Result.Sessions.ArchiveAfterDays = 999 - mutation.Result.Plugins["tools"] = `{"mutated":true}` + mutation.Result.Tools = json.RawMessage(`{"mutated":true}`) fresh, err := r.getConfiguration(ctx) testassert.NoError(t, err) - testassert.Require(t, fresh.Settings.Sessions.ArchiveAfterDays == 5 && compactJSON(t, []byte(fresh.Settings.Plugins["tools"])) == compactJSON(t, []byte(mutationSettings().Plugins["tools"])), "a mutation of the returned result reached the published settings: %+v", fresh.Settings) + testassert.Require(t, fresh.Settings.Sessions.ArchiveAfterDays == 5 && compactJSON(t, fresh.Settings.Tools) == compactJSON(t, mutationSettings().Tools), "a mutation of the returned result reached the published settings: %+v", fresh.Settings) }) } @@ -1185,7 +1207,8 @@ func TestRuntimeAgentModelMutationAffectsNextAdmissions(t *testing.T) { writeServiceFile(t, e.configPath, `{"providers":{"prov":`+nominalProvMember(e.server.URL, "", `{"m":{"name":"M","context_window":262144,"max_output_tokens":4096},"wide":{"name":"W","context_window":262144,"max_output_tokens":4096}}`)+`}}`) writeServiceFile(t, agents.PathForConfig(e.configPath), `{"solo":{"model":"prov/m","system_prompt":"simple"},"worker":{"model":"prov/m","system_prompt":"simple"}}`) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer closeProjectionRuntime(r) ctx := context.Background() @@ -1308,7 +1331,7 @@ func TestConfigurationMutateExternalEditorRaceLastWriterWins(t *testing.T) { var parked int32 // the validator parks only the edit build arrive := make(chan struct{}, 1) gate := make(chan struct{}) - svc := h.service(context.Background(), servicePlugin("tools", &h.opens, func(json.RawMessage) error { + svc := h.service(context.Background(), servicePlugin("capowner", &h.opens, func(json.RawMessage) error { if atomic.LoadInt32(&parked) == 1 { arrive <- struct{}{} // signal from the validation stage; no I/O here <-gate @@ -1382,16 +1405,14 @@ func TestConfigurationMutateExternalEditorRaceLastWriterWins(t *testing.T) { got, want := compactJSON(t, root["sessions"]), compactJSON(t, mustMarshal(t, mutationSettings().Sessions)) testassert.Equal(t, got, want) } - var writtenPlugins map[string]json.RawMessage - testassert.NoError(t, json.Unmarshal(root["plugins"], &writtenPlugins)) { - got, want := compactJSON(t, writtenPlugins["tools"]), compactJSON(t, []byte(mutationSettings().Plugins["tools"])) + got, want := compactJSON(t, root["tools"]), compactJSON(t, mutationSettings().Tools) testassert.Equal(t, got, want) } // The published projection: the same winning candidate's settings. - view := projectSettings(svc.current().sessions, svc.current().plugins) - testassert.Require(t, view.Sessions == mutationSettings().Sessions && compactJSON(t, []byte(view.Plugins["tools"])) == compactJSON(t, []byte(mutationSettings().Plugins["tools"])), "published settings = %+v, want the requested shape", view) + view := projectSettings(svc.current().sessions, svc.current().tools, svc.current().jobs, svc.current().compaction, svc.current().plugins) + testassert.Require(t, view.Sessions == mutationSettings().Sessions && compactJSON(t, view.Tools) == compactJSON(t, mutationSettings().Tools), "published settings = %+v, want the requested shape", view) } // nextConfigurationEvent reads one event and proves it is the configuration diff --git a/runtime/configuration_service_test.go b/runtime/configuration_service_test.go index 7b7cd66f..2c85ab19 100644 --- a/runtime/configuration_service_test.go +++ b/runtime/configuration_service_test.go @@ -854,7 +854,7 @@ func TestConfigurationServiceToolDeclarationsStayDeclarative(t *testing.T) { writeServiceFile(t, filepath.Join(h.dataDir, "agents.json"), `{"worker":{"tools":["tool.x"]}}`) describeCalled := false toolPlugin := Plugin{ - ID: "tools", + ID: "tooldecl", Scope: ScopeRuntime, Provides: []CapabilitySpec{ToolSpec("tool.x", func(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { describeCalled = true diff --git a/runtime/configuration_test.go b/runtime/configuration_test.go index 1a49d689..c07f2b54 100644 --- a/runtime/configuration_test.go +++ b/runtime/configuration_test.go @@ -13,6 +13,7 @@ import ( "github.com/MMinasyan/lightcode/internal/config" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" ) const testConfigDocument = `{ @@ -36,7 +37,6 @@ const testAgentsDocument = `{ "prompt": "plan prompt", "tools": ["plan_tool", "plan_tool", "read_file"], "capabilities": ["cap-b", "cap-a"], - "lsp": false, "readonly": true, "write_dir": "/tmp/plan", "description": "planner", @@ -83,6 +83,9 @@ func TestNewConfigurationSnapshot(t *testing.T) { modelEntry := provider.Models["m"] testassert.Require(t, modelEntry != nil && modelEntry.ContextWindow == 9007199254740993, "catalog model = %+v, want the exact integer preserved through the decode", modelEntry) } + // The legacy compaction member is unowned now: unknown members are + // ignored and the absent model/prompt keep their defaults. + testassert.Require(t, snapshot.compactionValues.model.IsZero() && snapshot.compactionValues.prompt == prompt.DefaultSummarizerPrompt, "compaction values = %+v, want the absent-section defaults", snapshot.compactionValues) plugin, ok := snapshot.plugins["alpha"] testassert.Require(t, ok, "plugins = %+v, want the alpha entry", snapshot.plugins) var pluginFields map[string]any @@ -104,7 +107,7 @@ func TestNewConfigurationSnapshot(t *testing.T) { if def.Name != "plan" { continue } - testassert.Require(t, !def.LSP && def.Readonly && def.WriteDir == "/tmp/plan" && def.Description == "planner" && def.Subagent && !def.Builtin, "resolved plan lost its retained fields: %+v", def) + testassert.Require(t, def.Readonly && def.WriteDir == "/tmp/plan" && def.Description == "planner" && def.Subagent && !def.Builtin, "resolved plan lost its retained fields: %+v", def) } testassert.Require(t, len(snapshot.catalogWarnings) == 0 && len(snapshot.agentWarnings) == 0, "valid input produced warnings: catalog %+v agents %+v", snapshot.catalogWarnings, snapshot.agentWarnings) } diff --git a/runtime/export_test.go b/runtime/export_test.go index 1d16b890..580aa162 100644 --- a/runtime/export_test.go +++ b/runtime/export_test.go @@ -23,16 +23,22 @@ func OpenForTest(ctx context.Context, dataDir, configPath string, plugins []Plug } // ConfiguredInvocationForTest builds one configured Invocation whose -// captured snapshot carries the given per-plugin sections, for the external -// tests that exercise a plugin's real Invocation.Config channel — outside -// this package only the zero Invocation is constructible. It is absent from -// production builds. -func ConfiguredInvocationForTest(sections map[string]string) (Invocation, error) { +// captured snapshot carries the given top-level baseline sections and +// per-plugin sections, for the external tests that exercise the real +// Invocation settings channels — outside this package only the zero +// Invocation is constructible. It is absent from production builds. +func ConfiguredInvocationForTest(tools, jobs, compaction string, sections map[string]string) (Invocation, error) { plugins := make(map[string]json.RawMessage, len(sections)) for id, section := range sections { plugins[id] = json.RawMessage(section) } - snapshot, err := newConfiguration(1, capturedConfigDocument{Plugins: plugins}, catalog.BuildResult{}, []byte("{}"), nil, nil, nil, nil) + doc := capturedConfigDocument{ + Tools: json.RawMessage(tools), + Jobs: json.RawMessage(jobs), + Compaction: json.RawMessage(compaction), + Plugins: plugins, + } + snapshot, err := newConfiguration(1, doc, catalog.BuildResult{}, []byte("{}"), nil, nil, nil, nil) if err != nil { return Invocation{}, err } @@ -47,10 +53,8 @@ func EqualEventForTest(a, b Event) bool { // ComposeScopeForTest opens the given plugins as one Runtime scope through // the composition machinery — every dependency binding is constructed -// in-package — and returns the bound capability values by ID (ordinary -// bindings plus the private Core seam values a Harness consumer wires, such -// as the job stopper) together with the scope disposal. It is absent from -// production builds. +// in-package — and returns the bound capability values by ID together with +// the scope disposal. It is absent from production builds. func ComposeScopeForTest(ctx context.Context, info ScopeInfo, plugins []Plugin) (map[string]any, func() error, error) { c, err := newComposition(plugins) if err != nil { @@ -60,13 +64,10 @@ func ComposeScopeForTest(ctx context.Context, info ScopeInfo, plugins []Plugin) if err != nil { return nil, nil, err } - values := make(map[string]any, len(sc.bindings.entries)+len(sc.jobStoppers)) + values := make(map[string]any, len(sc.bindings.entries)) for id, entry := range sc.bindings.entries { values[id] = entry.value } - for id, value := range sc.jobStoppers { - values[id] = value - } return values, sc.close, nil } diff --git a/runtime/integration_test.go b/runtime/integration_test.go index 7d883c95..b9768f92 100644 --- a/runtime/integration_test.go +++ b/runtime/integration_test.go @@ -203,8 +203,8 @@ func writeToolCallTurn(w http.ResponseWriter, id, name, args string) { } func assembledPlugins(e *ownerEnv, store harness.Storage, hook *recordHook, ws *workspaceOpens) []Plugin { + e.fixtureStore(store) return []Plugin{ - e.storagePlugin(store), hookPlugin(hook), { ID: "base", diff --git a/runtime/jobs_config_test.go b/runtime/jobs_config_test.go new file mode 100644 index 00000000..e57a968d --- /dev/null +++ b/runtime/jobs_config_test.go @@ -0,0 +1,76 @@ +package runtime + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/MMinasyan/lightcode/internal/testassert" +) + +// jobsComposedDoc appends top-level baseline roots to the shared composed +// provider document, the same split the tools publication matrix uses: direct +// decoder tests live in the jobs package, and the composed half drives the +// real configuration service here. Config writes go through the package's +// existing writeToolsConfig helper. +func jobsComposedDoc(roots string) string { + return strings.TrimSuffix(composedConfigDocument, "}") + `,` + roots + `}` +} + +// TestComposedJobsSettingsPublication proves the top-level jobs publication +// matrix over the directly constructed baseline: the baseline sections publish +// together with a well-formed jobs object (unknown keys ignored, a null +// member retaining its default), a consumed-range violation, a wrong +// consumed-field type, and a null section each fail publication with the jobs +// decoder's text through the object-only boundary, and a failed publication +// leaves the live revision untouched so the next success continues the +// generation sequence. +func TestComposedJobsSettingsPublication(t *testing.T) { + ctx := context.Background() + accept := jobsComposedDoc(`"tools":{},"jobs":{"max_background_processes":5,"unknown_member":1,"max_output_bytes":null}`) + violation := jobsComposedDoc(`"tools":{},"jobs":{"max_output_bytes":1023}`) + badType := jobsComposedDoc(`"tools":{},"jobs":{"max_output_bytes":"many"}`) + nullSection := jobsComposedDoc(`"tools":{},"jobs":null`) + + e := newComposeEnv(t) + writeToolsConfig(t, e.configPath, accept) + r, err := Open(ctx, Options{DataDir: e.dataDir, ConfigPath: e.configPath}) + testassert.NoError(t, err) + if rev, err := r.Reload(ctx); err != nil || rev != "2" { + _ = r.Close(ctx) + t.Fatalf("first Reload = (%q, %v), want revision 2", rev, err) + } + + writeToolsConfig(t, e.configPath, violation) + if _, err := r.Reload(ctx); err == nil || !errors.Is(err, ErrConfiguration) { + _ = r.Close(ctx) + t.Fatalf("Reload with an out-of-range jobs limit = %v, want a complete publication failure", err) + } else if !strings.Contains(err.Error(), "jobs: max_output_bytes must be between 1024 and 1048576") { + _ = r.Close(ctx) + t.Fatalf("Reload range failure error = %v, want the jobs range text preserved", err) + } + + writeToolsConfig(t, e.configPath, badType) + if _, err := r.Reload(ctx); err == nil || !errors.Is(err, ErrConfiguration) { + _ = r.Close(ctx) + t.Fatalf("Reload with a wrong-typed jobs field = %v, want a complete publication failure", err) + } else if !strings.Contains(err.Error(), "jobs: invalid settings:") { + _ = r.Close(ctx) + t.Fatalf("Reload type-failure error = %v, want the jobs invalid-settings wrap preserved", err) + } + + writeToolsConfig(t, e.configPath, nullSection) + if _, err := r.Reload(ctx); err == nil || !errors.Is(err, ErrConfiguration) { + _ = r.Close(ctx) + t.Fatalf("Reload with a null jobs section = %v, want the publisher's non-object rejection", err) + } + + writeToolsConfig(t, e.configPath, accept) + rev, err := r.Reload(ctx) + if err != nil || rev != "3" { + _ = r.Close(ctx) + t.Fatalf("Reload after the failures = (%q, %v), want generation 3: failed publications published nothing", rev, err) + } + testassert.NoError(t, r.Close(ctx)) +} diff --git a/runtime/jobs_plugin_config_test.go b/runtime/jobs_plugin_config_test.go deleted file mode 100644 index 54144898..00000000 --- a/runtime/jobs_plugin_config_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package runtime_test - -import ( - "context" - "errors" - "strings" - "testing" - - "github.com/MMinasyan/lightcode/internal/plugins/builtin" - "github.com/MMinasyan/lightcode/internal/testassert" - "github.com/MMinasyan/lightcode/runtime" -) - -// jobsComposedDoc wraps one plugins section in the shared composed provider -// document, the same split the tools publication matrix uses: direct decoder -// tests live in the plugin package, and the composed half drives the real -// configuration service here. Config writes go through the package's existing -// writeToolsConfig helper (identical to the internal-package writeServiceFile, -// which a runtime_test file importing the plugins cannot reference without an -// import cycle). -func jobsComposedDoc(plugins string) string { - return strings.TrimSuffix(composedConfigDocument, "}") + `,"plugins":` + plugins + `}` -} - -// TestComposedJobsPluginPublication composes the shipped five-plugin -// registration through the OpenForTest bridge and proves the plugins.jobs -// publication matrix: the five compiled sections publish together with a -// well-formed jobs object (unknown keys ignored, a null member retaining its -// default), a consumed-range violation, a wrong consumed-field type, and a -// null section each fail publication with the jobs decoder's text through the -// object-only boundary, and a failed publication leaves the live revision -// untouched so the next success continues the generation sequence. -func TestComposedJobsPluginPublication(t *testing.T) { - ctx := context.Background() - const fiveSections = `"sqlite":{},"tools":{},"adaptation":{},"lsp":{}` - accept := jobsComposedDoc(`{` + fiveSections + `,"jobs":{"max_background_processes":5,"unknown_member":1,"max_output_bytes":null}}`) - violation := jobsComposedDoc(`{` + fiveSections + `,"jobs":{"max_output_bytes":1023}}`) - badType := jobsComposedDoc(`{` + fiveSections + `,"jobs":{"max_output_bytes":"many"}}`) - nullSection := jobsComposedDoc(`{` + fiveSections + `,"jobs":null}`) - - e := newComposeEnv(t) - writeToolsConfig(t, e.configPath, accept) - r, err := runtime.OpenForTest(ctx, e.dataDir, e.configPath, builtin.Plugins()) - testassert.NoError(t, err) - if rev, err := r.Reload(ctx); err != nil || rev != "2" { - _ = r.Close(ctx) - t.Fatalf("first Reload = (%q, %v), want revision 2", rev, err) - } - - writeToolsConfig(t, e.configPath, violation) - if _, err := r.Reload(ctx); err == nil || !errors.Is(err, runtime.ErrConfiguration) { - _ = r.Close(ctx) - t.Fatalf("Reload with an out-of-range jobs limit = %v, want a complete publication failure", err) - } else if !strings.Contains(err.Error(), "jobs: max_output_bytes must be between 1024 and 1048576") { - _ = r.Close(ctx) - t.Fatalf("Reload range failure error = %v, want the jobs range text preserved", err) - } - - writeToolsConfig(t, e.configPath, badType) - if _, err := r.Reload(ctx); err == nil || !errors.Is(err, runtime.ErrConfiguration) { - _ = r.Close(ctx) - t.Fatalf("Reload with a wrong-typed jobs field = %v, want a complete publication failure", err) - } else if !strings.Contains(err.Error(), "jobs: invalid settings:") { - _ = r.Close(ctx) - t.Fatalf("Reload type-failure error = %v, want the jobs invalid-settings wrap preserved", err) - } - - writeToolsConfig(t, e.configPath, nullSection) - if _, err := r.Reload(ctx); err == nil || !errors.Is(err, runtime.ErrConfiguration) { - _ = r.Close(ctx) - t.Fatalf("Reload with a null jobs section = %v, want the publisher's non-object rejection", err) - } - - writeToolsConfig(t, e.configPath, accept) - rev, err := r.Reload(ctx) - if err != nil || rev != "3" { - _ = r.Close(ctx) - t.Fatalf("Reload after the failures = (%q, %v), want generation 3: failed publications published nothing", rev, err) - } - testassert.NoError(t, r.Close(ctx)) -} diff --git a/runtime/maintenance_test.go b/runtime/maintenance_test.go index 7ca678e4..d6f5861b 100644 --- a/runtime/maintenance_test.go +++ b/runtime/maintenance_test.go @@ -393,8 +393,8 @@ func TestMaintenanceInitialPassRunsAtStartup(t *testing.T) { seeded := seedStaleSession(t, store, filepath.Join(e.dataDir, "seeded"), 100*time.Hour) hook := &recordHook{name: "hook.first", events: &traceLog{}} var opOpens, agOpens atomic.Int64 + e.fixtureStore(store) r, err := e.open(context.Background(), - e.storagePlugin(store), hookPlugin(hook), shortScopeProbe("probe", ScopeOperation, &opOpens), shortScopeProbe("probe-agent", ScopeAgent, &agOpens)) @@ -428,7 +428,8 @@ func TestMaintenanceControlledTicksSweepUnderTheCurrentPolicy(t *testing.T) { wrapped := newSweepStore(store) hook := &recordHook{name: "hook.first", events: &traceLog{}} var opOpens, agOpens atomic.Int64 - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped), hookPlugin(hook), + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background(), hookPlugin(hook), shortScopeProbe("probe", ScopeOperation, &opOpens), shortScopeProbe("probe-agent", ScopeAgent, &agOpens)) testassert.NoError(t, err) @@ -493,7 +494,8 @@ func TestMaintenanceFailedPassReportsAndWaitsForTheNextTick(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":1,"delete_after_archive_days":1}`)) wrapped := newSweepStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background()) testassert.NoError(t, err) created, err := r.createSession(context.Background(), filepath.Join(e.dataDir, "sweep"), "solo") testassert.NoError(t, err) @@ -529,7 +531,8 @@ func TestMaintenanceShutdownJoinsTheBlockedPassBeforeStorageTeardown(t *testing. e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":1,"delete_after_archive_days":1}`)) wrapped := newSweepStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background()) testassert.NoError(t, err) created, err := r.createSession(context.Background(), filepath.Join(e.dataDir, "sweep"), "solo") testassert.NoError(t, err) @@ -582,7 +585,8 @@ func TestMaintenanceShutdownJoinsTheBlockedPassBeforeStorageTeardown(t *testing. writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":1,"delete_after_archive_days":1}`)) wrapped := newSweepStore(store) ctx := newDeadlineContext() - r, ticks, err := e.openSwept(ctx, e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(ctx) testassert.NoError(t, err) created, err := r.createSession(context.Background(), filepath.Join(e.dataDir, "sweep"), "solo") testassert.NoError(t, err) @@ -629,7 +633,8 @@ func TestMaintenanceUnrelatedFailureIsQuietOnceShutdownIsObserved(t *testing.T) e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":1,"delete_after_archive_days":1}`)) wrapped := newSweepStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background()) testassert.NoError(t, err) created, err := r.createSession(context.Background(), filepath.Join(e.dataDir, "sweep"), "solo") testassert.NoError(t, err) @@ -672,7 +677,8 @@ func TestMaintenanceGateRejectionIsQuiet(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":1,"delete_after_archive_days":1}`)) wrapped := newSweepStore(store) - r, err := e.open(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, err := e.open(context.Background()) testassert.NoError(t, err) testassert.NoError(t, r.Close(context.Background())) stderr := captureSweepStderr(t) @@ -695,7 +701,8 @@ func TestMaintenanceLeavesRunningSessionsAndAdmitsNoModel(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerSweepProbedDocument(e.server.URL, `{"archive_after_days":1,"delete_after_archive_days":1}`)) wrapped := newSweepStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped), hookPlugin(&recordHook{name: "hook.first", events: &traceLog{}})) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background(), hookPlugin(&recordHook{name: "hook.first", events: &traceLog{}})) testassert.NoError(t, err) busy, err := r.createSession(context.Background(), filepath.Join(e.dataDir, "busy"), "solo") testassert.NoError(t, err) @@ -738,7 +745,8 @@ func TestMaintenancePassSweepsValidSiblingsAroundCorruption(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":1,"delete_after_archive_days":1}`)) wrapped := newSweepStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background()) testassert.NoError(t, err) corrupted, err := r.createSession(context.Background(), filepath.Join(e.dataDir, "bad"), "solo") testassert.NoError(t, err) @@ -798,7 +806,8 @@ func TestMaintenanceNonpositiveThresholdsDisableTheirTransitions(t *testing.T) { writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":0,"delete_after_archive_days":0}`)) seeded := seedStaleSession(t, store, filepath.Join(e.dataDir, "seeded"), 100*time.Hour) wrapped := newSweepStore(store) - r, err := e.open(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, err := e.open(context.Background()) testassert.NoError(t, err) testassert.Equal(t, wrapped.listCount(), 2) var payload struct { @@ -932,7 +941,8 @@ func TestPrivateDeleteSessionCleansArtifacts(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { e := newOwnerEnv(t) ctx := context.Background() - r, err := e.open(ctx, e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(ctx) testassert.NoError(t, err) archive := func(sessionID string) { t.Helper() @@ -1010,7 +1020,8 @@ func TestPrivateDeleteSessionJoinsShutdown(t *testing.T) { e := newOwnerEnv(t) ctx := context.Background() blocked := newTargetedBlockStore(store) - r, err := open(ctx, e.options(e.storagePlugin(blocked))) + e.fixtureStore(blocked) + r, err := e.open(ctx) testassert.NoError(t, err) archived, err := r.createSession(ctx, filepath.Join(e.dataDir, "ws"), "solo") testassert.NoError(t, err) @@ -1063,7 +1074,8 @@ func TestPrivateDeleteSessionReturnsCleanupErrors(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { e := newOwnerEnv(t) ctx := context.Background() - r, err := e.open(ctx, e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(ctx) testassert.NoError(t, err) archived, err := r.createSession(ctx, filepath.Join(e.dataDir, "ws"), "solo") testassert.NoError(t, err) @@ -1115,7 +1127,8 @@ func TestMaintenanceSweepCleansCommittedSessions(t *testing.T) { wrapped := newSweepStore(store) wrapped.armDeleteTarget(second) stderr := captureSweepStderr(t) - r, err := open(context.Background(), e.options(e.storagePlugin(wrapped))) + e.fixtureStore(wrapped) + r, err := e.open(context.Background()) testassert.NoError(t, err) mustNotExist(t, firstCode, "the committed deletion's artifacts despite the stopping pass error") @@ -1163,7 +1176,8 @@ func TestMaintenanceSweepJoinsPassAndCleanupErrorsInOneDiagnostic(t *testing.T) wrapped := newSweepStore(store) wrapped.armDeleteTarget(second) stderr := captureSweepStderr(t) - r, err := open(context.Background(), e.options(e.storagePlugin(wrapped))) + e.fixtureStore(wrapped) + r, err := e.open(context.Background()) testassert.NoError(t, err) out := stderr() @@ -1196,7 +1210,8 @@ func TestMaintenanceSweepReportsCleanupErrorsAndAttemptsEveryID(t *testing.T) { t.Cleanup(func() { os.Chmod(blocked, 0o755) }) stderr := captureSweepStderr(t) - r, err := open(context.Background(), e.options(e.storagePlugin(store))) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) for _, id := range []string{a, b} { // committed deletions are never rolled back @@ -1238,7 +1253,8 @@ func TestMaintenanceShutdownJoinsSweepCleanup(t *testing.T) { plantSessionCode(t, e.dataDir, second) blocked := newTargetedBlockStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(blocked)) + e.fixtureStore(blocked) + r, ticks, err := e.openSwept(context.Background()) testassert.NoError(t, err) writeServiceFile(t, e.configPath, ownerSweepDocument(`{"archive_after_days":1,"delete_after_archive_days":1}`)) { @@ -1296,7 +1312,8 @@ func TestMaintenanceSweepRemovesWarningsForEveryCommittedDeletion(t *testing.T) first, second, third := ids[0], ids[1], ids[2] wrapped := newSweepStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background()) testassert.NoError(t, err) defer func() { { @@ -1405,7 +1422,8 @@ func TestMaintenanceSweepStopsOnCandidateReadFailure(t *testing.T) { thirdCode := plantSessionCode(t, e.dataDir, third) wrapped := newSweepStore(store) - r, ticks, err := e.openSwept(context.Background(), e.storagePlugin(wrapped)) + e.fixtureStore(wrapped) + r, ticks, err := e.openSwept(context.Background()) testassert.NoError(t, err) defer func() { { @@ -1509,7 +1527,8 @@ func TestSweepSkipsHeldArtifactInterval(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, ownerSweepProbedDocument(e.server.URL, `{"archive_after_days":1,"delete_after_archive_days":1}`)) ctx := context.Background() - r, err := e.open(ctx, e.storagePlugin(store), hookPlugin(&recordHook{name: "hook.first", events: &traceLog{}})) + e.fixtureStore(store) + r, err := e.open(ctx, hookPlugin(&recordHook{name: "hook.first", events: &traceLog{}})) testassert.NoError(t, err) heldWorkspace := filepath.Join(e.dataDir, "held-ws") testassert.NoError(t, os.MkdirAll(heldWorkspace, 0o700)) diff --git a/runtime/observation_bus_test.go b/runtime/observation_bus_test.go index 56cddd9a..c569b2c2 100644 --- a/runtime/observation_bus_test.go +++ b/runtime/observation_bus_test.go @@ -28,7 +28,8 @@ func TestObservationPausedFactEmitsCurrentRevision(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { ctx := context.Background() e := newOwnerEnv(t) - r, err := e.open(ctx, e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(ctx) testassert.NoError(t, err) defer func() { { @@ -430,7 +431,8 @@ func TestObservationPluginReportsLandWithRealAttribution(t *testing.T) { }, } } - r, err := e.open(ctx, e.storagePlugin(store), reporterPlugin("alpha"), reporterPlugin("beta")) + e.fixtureStore(store) + r, err := e.open(ctx, reporterPlugin("alpha"), reporterPlugin("beta")) testassert.NoError(t, err) defer func() { { diff --git a/runtime/observation_composed_lsp_test.go b/runtime/observation_composed_lsp_test.go deleted file mode 100644 index 2625adb1..00000000 --- a/runtime/observation_composed_lsp_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package runtime_test - -// The full-chain LSP ingest row: the public Open with the shipped builtin -// registration — the real LSP plugin and the concrete production preparation — -// runs one real model turn whose workspace_symbol tool call registers a -// workspace whose only detected server is unresolvable, and the real -// manager's warning reaches the Runtime's own store and bus: the global -// warning read and the runtime-scoped warning_changed hint. - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - "time" - - "github.com/MMinasyan/lightcode/internal/plugins/builtin" - "github.com/MMinasyan/lightcode/internal/testassert" - "github.com/MMinasyan/lightcode/protocol" - "github.com/MMinasyan/lightcode/runtime" -) - -// lspConfigDocument points the sole provider at the test model endpoint with -// a keyless transport, so no credential environment is needed. -func lspConfigDocument(endpoint string) string { - return `{ - "providers": { - "prov": { - "transport": {"base_url": "` + endpoint + `", "api_key_env": ""}, - "discovery": false, - "models": {"m": {"name": "M", "context_window": 262144}} - } - } -}` -} - -const lspAgentsDocument = `{ - "worker": {"model": "prov/m", "system_prompt": "simple", "tools": ["workspace_symbol"], "capabilities": []} -}` - -// writeLSPSSE writes one SSE turn the transport consumes. -func writeLSPSSE(w http.ResponseWriter, events ...string) { - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - flusher := w.(http.Flusher) - for _, event := range events { - _, _ = w.Write([]byte("data: " + event + "\n\n")) - } - _, _ = w.Write([]byte("data: [DONE]\n\n")) - flusher.Flush() -} - -// newLSPModelServer answers every first turn with one workspace_symbol tool -// call and every tool follow-up with a final text turn, over the SSE wire -// the transport consumes. -func newLSPModelServer(t *testing.T) *httptest.Server { - s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - if lastJSONRole(string(body)) == "tool" { - writeLSPSSE(w, - `{"choices":[{"delta":{"role":"assistant","content":"done"},"finish_reason":null}]}`, - `{"choices":[{"delta":{},"finish_reason":"stop"}]}`, - ) - return - } - writeLSPSSE(w, - `{"choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call-lsp","type":"function","function":{"name":"workspace_symbol","arguments":"{\"query\":\"Foo\"}"}}]},"finish_reason":null}]}`, - `{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`, - ) - })) - t.Cleanup(s.Close) - return s -} - -func lastJSONRole(body string) string { - needle := `"role":"` - if idx := strings.LastIndex(body, needle); idx >= 0 { - rest := body[idx+len(needle):] - if end := strings.IndexByte(rest, '"'); end > 0 { - return rest[:end] - } - } - return "" -} - -// TestComposedLSPWarningLandsInGlobalStoreAndEvents runs the complete real -// chain: public Open over the shipped builtin registration, the concrete -// production preparation, a real model turn whose workspace_symbol call -// registers a clangd-marker workspace with no resolvable binary, and the real -// LSP manager's detection warning flowing into the Runtime's global store and -// the runtime-scoped warning_changed hint. -func TestComposedLSPWarningLandsInGlobalStoreAndEvents(t *testing.T) { - e := newComposeEnv(t) - t.Setenv("PATH", t.TempDir()) // no resolvable language server anywhere - server := newLSPModelServer(t) - testassert.NoError(t, os.WriteFile(e.configPath, []byte(lspConfigDocument(server.URL)), 0o600)) - testassert.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(e.configPath), "agents.json"), []byte(lspAgentsDocument), 0o600)) - - workspace := t.TempDir() - testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "CMakeLists.txt"), []byte("cmake_minimum_required(VERSION 3.10)\n"), 0o600)) - - ctx := context.Background() - r, err := runtime.Open(ctx, runtime.Options{DataDir: e.dataDir, ConfigPath: e.configPath, Plugins: builtin.Plugins()}) - testassert.NoError(t, err) - // The owner closes on every exit: the deferred Close is idempotent and - // joins the same shutdown, covering an assertion failure before the - // server and data-root cleanups run. - defer func() { - { - err := r.Close(context.Background()) - testassert.Check(t, err == nil, "Close: %v", err) - } - }() - sub, err := r.Subscribe(256) - testassert.NoError(t, err) - defer sub.Close() - - session, err := runtime.CreateSessionForTest(ctx, r, workspace, "worker") - testassert.NoError(t, err) - testassert.NoError(t, runtime.SubmitForTest(ctx, r, session.Identity.SessionID, "op-lsp", "find Foo")) - - // The real warning arrives through the real manager's report: the - // runtime-scoped warning_changed hint, then the global store read. The - // wait blocks on the subscription with one bounded timeout. - deadline := time.After(30 * time.Second) - var hint *protocol.WarningChangedEvent - for hint == nil { - select { - case event, ok := <-sub.Events(): - testassert.Require(t, ok, "the subscription closed before the LSP warning hint") - kind, err := event.Discriminator() - if err != nil || kind != "warning_changed" { - continue - } - body, err := event.AsWarningChangedEvent() - testassert.NoError(t, err) - if composedScopeKind(body.Scope) == "runtime" { - hint = &body - } - case <-deadline: - t.Fatal("the real LSP warning hint never arrived") - } - } - { - _, err := strconv.ParseUint(hint.WarningsRevision.Revision, 10, 64) - testassert.Require(t, err == nil, "warning hint revision = %q, want a decimal value", hint.WarningsRevision.Revision) - } - - warnings, err := runtime.GetWarningsForTest(r) - testassert.NoError(t, err) - found := false - for _, warning := range warnings.Warnings { - if warning.Source == "plugin:lsp" && strings.Contains(warning.Kind, "lsp_") { - found = true - } - } - testassert.Require(t, found, "the global warning read misses the real LSP report: %+v", warnings.Warnings) - testassert.NoError(t, r.Close(ctx)) -} diff --git a/runtime/observation_test.go b/runtime/observation_test.go index ef41b7ce..6e19fe01 100644 --- a/runtime/observation_test.go +++ b/runtime/observation_test.go @@ -153,7 +153,8 @@ func drainClosed(t *testing.T, sub *Subscription) []Event { func TestRuntimeSubscribeValidatesCapacityAndUsesTheClosedGate(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) { _, err := r.Subscribe(0) @@ -182,8 +183,8 @@ func TestRuntimeSubscribeValidatesCapacityAndUsesTheClosedGate(t *testing.T) { func TestRuntimeObservationPublishesCommittedTransitionsInOrder(t *testing.T) { e := newOwnerEnv(t) + e.fixtureStore(storage.NewMemory()) r, err := e.open(context.Background(), - e.storagePlugin(storage.NewMemory()), e.ordinaryPlugin("w", ScopeWorkspace, "w.cap")) testassert.NoError(t, err) sub, err := r.Subscribe(16) @@ -216,8 +217,8 @@ func TestRuntimeObservationPublishesCommittedTransitionsInOrder(t *testing.T) { func TestObservationPausedPublisherOrdersTheNextPublication(t *testing.T) { e := newOwnerEnv(t) + e.fixtureStore(storage.NewMemory()) r, err := e.open(context.Background(), - e.storagePlugin(storage.NewMemory()), e.ordinaryPlugin("w", ScopeWorkspace, "w.cap")) testassert.NoError(t, err) defer func() { @@ -344,7 +345,8 @@ func pauseObservation(t *testing.T, o *observation, event Event) (<-chan struct{ func TestObservationSaturatedSubscriberIsRemovedAndHealthyOnesContinue(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) full, err := r.Subscribe(1) testassert.NoError(t, err) @@ -387,7 +389,8 @@ func TestObservationSaturatedSubscriberIsRemovedAndHealthyOnesContinue(t *testin func TestObservationSubscriptionCloseRacesPublication(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) healthy, err := r.Subscribe(64) testassert.NoError(t, err) @@ -456,8 +459,8 @@ func TestObservationSubscriptionCloseRacesPublication(t *testing.T) { func TestRuntimeShutdownDoesNotWaitForUndrainedObservers(t *testing.T) { e := newOwnerEnv(t) + e.fixtureStore(storage.NewMemory()) r, err := e.open(context.Background(), - e.storagePlugin(storage.NewMemory()), e.ordinaryPlugin("w", ScopeWorkspace, "w.cap")) testassert.NoError(t, err) stalled, err := r.Subscribe(1) @@ -494,7 +497,8 @@ func TestRuntimeShutdownDoesNotWaitForUndrainedObservers(t *testing.T) { func TestObservationSubscriberLossDoesNotAffectExecution(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(store), + e.fixtureStore(store) + r, err := e.open(context.Background(), e.ordinaryPlugin("w", ScopeWorkspace, "w.cap")) testassert.NoError(t, err) session, err := r.createSession(context.Background(), e.dataDir, "solo") diff --git a/runtime/preparation.go b/runtime/preparation.go index 1cc70bf6..c151d47b 100644 --- a/runtime/preparation.go +++ b/runtime/preparation.go @@ -15,8 +15,8 @@ import ( "github.com/MMinasyan/lightcode/harness" "github.com/MMinasyan/lightcode/internal/catalog" "github.com/MMinasyan/lightcode/internal/config" - "github.com/MMinasyan/lightcode/internal/prompt" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" "github.com/MMinasyan/lightcode/protocol" ) @@ -144,8 +144,15 @@ type preparation struct { runtime *scope workspaces *workspaceScopes home string - background BackgroundServices - passive *observationAdapter + + // identity and globalRulesDir are the host-supplied prompt inputs: the + // application persona text and the explicit global rules directory. Their + // zero values supply no identity and no global rules, never an implicit + // fallback. + identity string + globalRulesDir string + background BackgroundServices + passive *observationAdapter // artifacts is the Runtime's private per-Session artifact interval // registry. Every committed opener acquires its Session's token here @@ -165,23 +172,26 @@ type preparation struct { // newPreparation wires the binder to the published configuration, the // composition with its constructed Runtime scope and Workspace registry, the -// once-resolved home, the background services bridge armed after harness.New +// once-resolved home, the host-supplied prompt identity and global rules +// directory, the background services bridge armed after harness.New // returns, the Runtime's passive publication adapter (nil in isolated // preparation tests, dropping only passive presentation), the Runtime's // private artifact interval registry (nil without a Runtime), and the // Runtime's live managed-environment producer (nil without a retained // manager). One concrete preparation path serves every admission. -func newPreparation(config *configurationService, c *composition, runtime *scope, workspaces *workspaceScopes, home string, background BackgroundServices, passive *observationAdapter, artifacts *artifactIntervals, subprocessEnv func() []string) *preparation { +func newPreparation(config *configurationService, c *composition, runtime *scope, workspaces *workspaceScopes, home, identity, globalRulesDir string, background BackgroundServices, passive *observationAdapter, artifacts *artifactIntervals, subprocessEnv func() []string) *preparation { return &preparation{ - config: config, - composition: c, - runtime: runtime, - workspaces: workspaces, - home: home, - background: background, - passive: passive, - artifacts: artifacts, - subprocessEnv: subprocessEnv, + config: config, + composition: c, + runtime: runtime, + workspaces: workspaces, + home: home, + identity: identity, + globalRulesDir: globalRulesDir, + background: background, + passive: passive, + artifacts: artifacts, + subprocessEnv: subprocessEnv, } } @@ -320,10 +330,13 @@ func (p *preparation) open(ctx context.Context, admission harness.OperationAdmis if err != nil { return unwind(err, nil) } - // The invocation view binds the Agent's declared tool exports — - // separately from the explicitly selected non-tool capability IDs — - // over the freshly opened Agent tier. - bindings, err := selectCapabilities([]*scope{p.runtime, workspaceScope, operation, agentScope}, slices.Concat(agent.Capabilities, agent.Tools)) + // The invocation view selects every capability the Agent declared — + // its non-tool capability IDs and each declared tool name, the + // standard tools included — over the freshly opened Agent tier. + selected := make([]string, 0, len(agent.Capabilities)+len(agent.Tools)) + selected = append(selected, agent.Capabilities...) + selected = append(selected, agent.Tools...) + bindings, err := selectCapabilities([]*scope{p.runtime, workspaceScope, operation, agentScope}, selected) if err != nil { return unwind(err, releaseAgent) } @@ -432,25 +445,21 @@ func (p *preparation) concretePrepare(_ context.Context, req harness.Preparation if err != nil { return harness.ExecutionCapture{}, nil, err } - // The compact configuration rides the same one-revision snapshot: the - // resolved compact Agent type carries the selection's model and prompt. - // Its resolution failure is an admission failure — the compact builtin is - // always present and no retained prompt source exists to fall back to. - compactType, err := harness.ResolveAgentType("compact", snapshot.agentTypes()) - if err != nil { - return harness.ExecutionCapture{}, nil, fmt.Errorf("compact agent type: %w", err) - } + // The compact configuration is the top-level compaction section of the + // same one-revision snapshot; it is independent of the selectable compact + // Agent definition. + compaction := snapshot.compactionValues // The effective compact model is one fallback chain ending at the - // conversation model: the compact type's model — when it names a - // different model at all — must resolve in the catalog, build a transport - // over a resolved credential; otherwise the conversation model and its - // transport are kept. The recorded compact configuration is always the - // final effective model's. + // conversation model: the configured compaction model — when it names a + // different model at all — must resolve in the catalog and build a + // transport over a resolved credential; otherwise the conversation model + // and its transport are kept. The recorded compact configuration is + // always the final effective model's. compactRef := sel.agent.Model compactTransport := transport compactCost := entry.Cost - if !compactType.Model.IsZero() && compactType.Model != sel.agent.Model { - compactProvider, compactEntry, lookupErr := snapshot.catalog.Lookup(catalog.ModelRef{Provider: compactType.Model.Provider, Model: compactType.Model.Model}) + if !compaction.model.IsZero() && compaction.model != sel.agent.Model { + compactProvider, compactEntry, lookupErr := snapshot.catalog.Lookup(catalog.ModelRef{Provider: compaction.model.Provider, Model: compaction.model.Model}) if lookupErr == nil { keyMissing := false compactKey := "" @@ -459,9 +468,9 @@ func (p *preparation) concretePrepare(_ context.Context, req harness.Preparation keyMissing = compactKey == "" } if !keyMissing { - built, buildErr := p.concreteTransport(compactProvider, compactEntry, compactType.Model, compactKey) + built, buildErr := p.concreteTransport(compactProvider, compactEntry, compaction.model, compactKey) if buildErr == nil { - compactRef = compactType.Model + compactRef = compaction.model compactTransport = built compactCost = compactEntry.Cost } @@ -477,17 +486,18 @@ func (p *preparation) concretePrepare(_ context.Context, req harness.Preparation return harness.ExecutionCapture{}, nil, err } result, tools, err := composeSurface(surfaceRequest{ - home: p.home, - workspace: req.Session.Identity.Workspace, - sessionStart: req.Session.Identity.CreatedAt, // a Fork's zero start samples the compose time once - promptSize: sel.agent.SystemPrompt, - promptBody: sel.agent.Prompt, - invocation: sel.invocation, - constraints: ToolConstraints{Readonly: sel.agent.Readonly, WriteDir: sel.agent.WriteDir}, - identity: req.Session.Identity, - toolSpecs: specs, - adaptation: adaptation, - modelRef: sel.agent.Model, + hostIdentity: p.identity, + globalRulesDir: p.globalRulesDir, + workspace: req.Session.Identity.Workspace, + sessionStart: req.Session.Identity.CreatedAt, // a Fork's zero start samples the compose time once + promptSize: prompt.Mode(sel.agent.SystemPrompt), + promptBody: sel.agent.Prompt, + invocation: sel.invocation, + constraints: ToolConstraints{Readonly: sel.agent.Readonly, WriteDir: sel.agent.WriteDir}, + identity: req.Session.Identity, + toolSpecs: specs, + adaptation: adaptation, + modelRef: sel.agent.Model, }) if err != nil { return harness.ExecutionCapture{}, nil, err @@ -511,7 +521,7 @@ func (p *preparation) concretePrepare(_ context.Context, req harness.Preparation Pricing: capturedPricing(entry.Cost), Compact: harness.CompactCapture{ Model: compactRef, - SystemPrompt: compactType.Prompt, + SystemPrompt: compaction.prompt, Pricing: capturedPricing(compactCost), }, Strategy: harness.StrategyCapture{ diff --git a/runtime/preparation_test.go b/runtime/preparation_test.go index 80437d9a..a2e92494 100644 --- a/runtime/preparation_test.go +++ b/runtime/preparation_test.go @@ -365,7 +365,8 @@ func newPrepEnv(t *testing.T, store harness.Storage) *prepEnv { for i, name := range []string{"arg.runtime", "arg.workspace", "arg.operation", "arg.agent"} { e.argHooks[i] = &recordArgumentHook{replace: json.RawMessage(`{"repaired":"` + name + `"}`)} } - r, err := e.open(context.Background(), append(e.plugins(), e.storagePlugin(store))...) + e.fixtureStore(store) + r, err := e.open(context.Background(), e.plugins()...) testassert.NoError(t, err) e.r = r t.Cleanup(func() { _ = e.converge() }) @@ -662,10 +663,9 @@ func TestPreparationHappyPathThroughHarness(t *testing.T) { } // Hooks ran in selected order inside the preparation guard; the owner's - // storage plugin opened first at public Open; reverse disposal closed - // the Agent scope before the Operation scope. + // store is directly constructed, so no factory precedes them; reverse + // disposal closed the Agent scope before the Operation scope. wantEvents := []string{ - "open:core", "hook:hook.first", "hook:hook.second", "op-open:op-1", "ag-open", "ag-close", "op-close", diff --git a/runtime/production_composition_test.go b/runtime/production_composition_test.go index a3c8b196..2f261e17 100644 --- a/runtime/production_composition_test.go +++ b/runtime/production_composition_test.go @@ -10,7 +10,6 @@ package runtime // stores with isolated HOME. import ( - "bytes" "context" "encoding/json" "errors" @@ -29,20 +28,18 @@ import ( "github.com/MMinasyan/lightcode/harness" modeladapt "github.com/MMinasyan/lightcode/internal/adaptation" "github.com/MMinasyan/lightcode/internal/agents" - "github.com/MMinasyan/lightcode/internal/pathutil" - "github.com/MMinasyan/lightcode/internal/snapshot" "github.com/MMinasyan/lightcode/internal/testassert" - "github.com/MMinasyan/lightcode/internal/tool" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" ) // chainAgentsDocument builds the combined scenario's agents document with the // chained agent's system prompt as the given text: the agent selects the -// preparation hook, the argument hook, and the model adaptation, and declares -// the write tool and the patch tool. +// preparation hook and the argument hook, and declares the directly +// constructed baseline write and patch tools. func chainAgentsDocument(body string) string { return `{ - "chained": {"model": "prov/gpt-5.5", "system_prompt": "simple", "prompt": "` + body + `", "tools": ["prod_write", "apply_patch"], "capabilities": ["hook.first", "arg.rewrite", "model_adaptation"]} + "chained": {"model": "prov/gpt-5.5", "system_prompt": "simple", "prompt": "` + body + `", "tools": ["write_file", "apply_patch"], "capabilities": ["hook.first", "arg.rewrite"]} }` } @@ -77,7 +74,7 @@ func chainModelsConfigDocument(endpoint string) string { } // rewriteWriteHook is the combined scenario's ToolArgumentsHook: it replaces -// every prod_write call's arguments with its fixed rewrite, passes every +// every write_file call's arguments with its fixed rewrite, passes every // other call through unchanged, and records every received call's identity // and raw argument bytes. type rewriteWriteHook struct { @@ -90,7 +87,7 @@ func (h *rewriteWriteHook) BeforeTool(_ context.Context, _ Invocation, call mode h.mu.Lock() h.seen = append(h.seen, call.Name+" "+string(call.Arguments)) h.mu.Unlock() - if call.Name == "prod_write" { + if call.Name == "write_file" { return json.RawMessage(h.replace), nil } return call.Arguments, nil @@ -140,18 +137,19 @@ func argumentHookPlugin(id string, hook ToolArgumentsHook) Plugin { // composed directly over the real bundled matcher). type bundledAdaptation struct{} -func (bundledAdaptation) Resolve(ref model.ModelRef) (Adaptation, error) { +func (bundledAdaptation) Resolve(ref model.ModelRef, mode prompt.Mode) (Adaptation, error) { match := modeladapt.Match(ref.Model) if match == nil { return Adaptation{}, nil } - return Adaptation{ + out := Adaptation{ ExcludeTools: match.ExcludeTools, - IncludeTools: match.IncludeTools, - Blocks: match.Blocks, - Additions: match.Additions, ToolDescriptionReplacements: match.ToolDescriptionReplacements, - }, nil + } + if mode == prompt.ModeFull { + out.Append = match.Append + } + return out, nil } // bundledAdaptationPlugin declares the bundled resolver as the composition's @@ -167,344 +165,6 @@ func bundledAdaptationPlugin() Plugin { } } -// chainDecodeArguments strictly decodes one call's JSON arguments into an -// owned map, rejecting malformed, non-object, null and trailing data. -func chainDecodeArguments(raw json.RawMessage) (map[string]any, error) { - var args map[string]any - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.UseNumber() - if err := dec.Decode(&args); err != nil || args == nil { - return nil, errors.New("arguments must be one JSON object") - } - if _, err := dec.Token(); !errors.Is(err, io.EOF) { - return nil, errors.New("arguments must be one JSON object") - } - return args, nil -} - -// chainImmediateError is the validation-class immediate outcome: status error -// carrying the bounded diagnostic. -func chainImmediateError(callID string, cause error) harness.PreparedTool { - msg := cause.Error() - if len(msg) > 512 { - msg = msg[:512] - } - return harness.PreparedTool{Immediate: &harness.ToolOutcome{ - Result: model.ToolResult{CallID: callID, Status: model.ResultError, Content: msg}, - }} -} - -// chainImmediateDenied is the failed-canonical-preparation immediate outcome: -// the fixed denial. -func chainImmediateDenied(callID string) harness.PreparedTool { - return harness.PreparedTool{Immediate: &harness.ToolOutcome{ - Result: model.ToolResult{CallID: callID, Status: model.ResultDenied, Content: "Permission denied."}, - }} -} - -// chainCodeGroup adapts one per-call snapshot.CodeStore to the shared -// SnapshotStore interface, mirroring the shipped plugin's group wrapper: the -// transactional path preempts the plain methods; they are interface -// compliance only. -type chainCodeGroup struct{ *snapshot.CodeStore } - -func (chainCodeGroup) CurrentTurn() int { return 1 } - -func (s chainCodeGroup) Snapshot(turn int, absPath string) error { - _, _, err := s.SnapshotResolvedEntry(turn, absPath, absPath) - return err -} - -// chainWriteTool is the combined scenario's prod_write capability: the -// shared write preparation (the per-call code group opened fresh from the -// admitted-input identity, the pre-write preimage capture, one file.write -// pair) behind the production Tool contract, mirroring the shipped plugin's -// write_file export. -type chainWriteTool struct{ dataDir string } - -func (chainWriteTool) describe(_ Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { - definition, err := model.NewToolDefinition(model.ToolDefinition{ - Name: "prod_write", - Description: "writes one file in the workspace", - Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}`), - }) - if err != nil { - return ToolDescription{}, err - } - return ToolDescription{Definition: definition, Available: !constraints.Readonly || constraints.WriteDir != ""}, nil -} - -func (t chainWriteTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { - args, err := chainDecodeArguments(call.Arguments) - if err != nil { - return nil, err - } - normalized, err := tool.NormalizeWriteArgs(args) - if err != nil { - return nil, err - } - return json.Marshal(normalized) -} - -func (t chainWriteTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { - args, err := chainDecodeArguments(call.Arguments) - if err != nil { - return chainImmediateError(call.ID, err) - } - resolved, err := pathutil.ResolveFilePathFrom("", tc.Workspace) - if err != nil { - return chainImmediateDenied(call.ID) - } - writeDirCanonical := "" - if tc.Constraints.WriteDir != "" { - resolvedWriteDir, err := pathutil.ResolveFilePathFrom(tc.Workspace, tc.Constraints.WriteDir) - if err != nil { - return chainImmediateDenied(call.ID) - } - writeDirCanonical = resolvedWriteDir.CanonicalPath - } - group, err := snapshot.OpenCodeStore(filepath.Join(t.dataDir, "code", tc.AdmittedEntry.SessionID, tc.AdmittedEntry.EntryID)) - if err != nil { - return chainImmediateDenied(call.ID) - } - prepared, err := tool.PrepareWriteCall(tc.Workspace, resolved.CanonicalPath, writeDirCanonical, tool.CapabilityOptions{WriteDir: tc.Constraints.WriteDir}, chainCodeGroup{group}, args) - if err != nil { - return chainImmediateDenied(call.ID) - } - pairs := make([]harness.PermissionRequest, 0, len(prepared.Targets)) - for _, target := range prepared.Targets { - pairs = append(pairs, harness.PermissionRequest{Permission: "file.write", Target: target.CanonicalPath}) - } - return harness.PreparedTool{ - Permissions: pairs, - CanonicalWorkspace: resolved.CanonicalPath, - Execute: func(ctx context.Context) harness.ToolOutcome { - result, execErr := prepared.Execute(ctx) - if execErr != nil { - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: execErr.Error()}} - } - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: result}} - }, - } -} - -// chainPatchTool is the combined scenario's apply_patch capability: the -// shared patch engine (one V4A parse, the per-call code group opened fresh -// from the admitted-input identity, the same preview capture) behind the -// production Tool contract, mirroring the shipped plugin's apply_patch -// export. -type chainPatchTool struct{ dataDir string } - -func (chainPatchTool) describe(_ Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { - definition, err := model.NewToolDefinition(model.ToolDefinition{ - Name: "apply_patch", - Description: "edits, creates, deletes, or renames files using the V4A patch format", - Parameters: json.RawMessage(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`), - }) - if err != nil { - return ToolDescription{}, err - } - return ToolDescription{Definition: definition, Available: !constraints.Readonly || constraints.WriteDir != "", DefaultHidden: true}, nil -} - -func (t chainPatchTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { - args, err := chainDecodeArguments(call.Arguments) - if err != nil { - return nil, err - } - normalized, err := tool.NormalizePatchArgs(args) - if err != nil { - return nil, err - } - return json.Marshal(normalized) -} - -func (t chainPatchTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { - args, err := chainDecodeArguments(call.Arguments) - if err != nil { - return chainImmediateError(call.ID, err) - } - resolved, err := pathutil.ResolveFilePathFrom("", tc.Workspace) - if err != nil { - return chainImmediateDenied(call.ID) - } - writeDirCanonical := "" - if tc.Constraints.WriteDir != "" { - resolvedWriteDir, err := pathutil.ResolveFilePathFrom(tc.Workspace, tc.Constraints.WriteDir) - if err != nil { - return chainImmediateDenied(call.ID) - } - writeDirCanonical = resolvedWriteDir.CanonicalPath - } - group, err := snapshot.OpenCodeStore(filepath.Join(t.dataDir, "code", tc.AdmittedEntry.SessionID, tc.AdmittedEntry.EntryID)) - if err != nil { - return chainImmediateDenied(call.ID) - } - prepared, err := tool.PreparePatchCall(tc.Workspace, resolved.CanonicalPath, writeDirCanonical, tool.CapabilityOptions{WriteDir: tc.Constraints.WriteDir}, chainCodeGroup{group}, args) - if err != nil { - return chainImmediateDenied(call.ID) - } - pairs := make([]harness.PermissionRequest, 0, len(prepared.Targets)) - for _, target := range prepared.Targets { - pairs = append(pairs, harness.PermissionRequest{Permission: "file.write", Target: target.CanonicalPath}) - } - return harness.PreparedTool{ - Permissions: pairs, - CanonicalWorkspace: resolved.CanonicalPath, - Execute: func(ctx context.Context) harness.ToolOutcome { - result, execErr := prepared.Execute(ctx) - if execErr != nil { - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: execErr.Error()}} - } - outcome := harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: result.Result}} - if m := tool.ApplyPatchPreviewMetadata(result.Previews); m != nil { - if raw, merr := json.Marshal(m); merr == nil { - outcome.Metadata = raw - } - } - return outcome - }, - } -} - -// chainCommandTool is the readonly-git row's run_command capability: the -// read-only allowlist rewrite with its injected safety flags, one command.run -// pair on the rewritten target, and real foreground execution in the -// Workspace — mirroring the shipped plugin's readonly command path. -type chainCommandTool struct{ dataDir string } - -func (chainCommandTool) describe(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { - definition, err := model.NewToolDefinition(model.ToolDefinition{ - Name: "run_command", - Description: "executes one command", - Parameters: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`), - }) - if err != nil { - return ToolDescription{}, err - } - return ToolDescription{Definition: definition, Available: true}, nil -} - -func (t chainCommandTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { - args, err := chainDecodeArguments(call.Arguments) - if err != nil { - return nil, err - } - normalized, err := tool.NormalizeRunCommandArgs(args, 120) - if err != nil { - return nil, err - } - return json.Marshal(normalized) -} - -func (t chainCommandTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { - args, err := chainDecodeArguments(call.Arguments) - if err != nil { - return chainImmediateError(call.ID, err) - } - command, _ := args["command"].(string) - rewritten, err := tool.ReadOnlyCommand(command) - if err != nil { - return chainImmediateError(call.ID, errors.New(tool.ReadOnlyRunCommandRejected)) - } - spillDir := filepath.Join(t.dataDir, "code", tc.AdmittedEntry.SessionID, "output") - return harness.PreparedTool{ - Permissions: []harness.PermissionRequest{{Permission: "command.run", Target: rewritten}}, - Execute: func(ctx context.Context) harness.ToolOutcome { - result, execErr := tool.RunForegroundCommand(ctx, rewritten, tc.Workspace, 120, 15360, 5000, spillDir, os.Environ()) - if execErr != nil { - status := model.ResultError - var exitErr *tool.ExitError - if errors.As(execErr, &exitErr) && exitErr.Cancelled { - status = model.ResultInterrupted - } - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: status, Content: execErr.Error()}} - } - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: result}} - }, - } -} - -// chainCommandPlugin declares the readonly-git row's run_command export. -func chainCommandPlugin() Plugin { - return Plugin{ - ID: "chaincommands", - Scope: ScopeRuntime, - Provides: []CapabilitySpec{ToolSpec("run_command", chainCommandTool{}.describe)}, - Open: func(_ context.Context, info ScopeInfo, _ Bindings) (Instance, error) { - return Instance{Values: map[string]any{"run_command": chainCommandTool{dataDir: info.DataDir}}}, nil - }, - } -} - -// inertTool is the capability-ID row's tool value: it composes a real -// description and advertisement and dispatches nothing (the row's model turns -// carry no tool calls; a dispatch settles the call with an immediate error). -type inertTool struct { - name string - defThide bool -} - -func (t inertTool) describe(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { - definition, err := model.NewToolDefinition(model.ToolDefinition{ - Name: t.name, - Description: "inert " + t.name, - Parameters: json.RawMessage(`{"type":"object","properties":{}}`), - }) - if err != nil { - return ToolDescription{}, err - } - return ToolDescription{Definition: definition, Available: true, DefaultHidden: t.defThide}, nil -} - -func (t inertTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { - return runtimeNormalize(call) -} - -func (t inertTool) Prepare(_ context.Context, _ ToolContext, call model.ToolCall) harness.PreparedTool { - return chainImmediateError(call.ID, errors.New("inert tool dispatch")) -} - -// inertToolsPlugin declares the four native file-tool names as inert tools -// for the capability-ID row's advertisement composition. -func inertToolsPlugin() Plugin { - return Plugin{ - ID: "inerttools", - Scope: ScopeRuntime, - Provides: []CapabilitySpec{ - ToolSpec("read_file", inertTool{name: "read_file"}.describe), - ToolSpec("write_file", inertTool{name: "write_file"}.describe), - ToolSpec("edit_file", inertTool{name: "edit_file"}.describe), - ToolSpec("apply_patch", inertTool{name: "apply_patch", defThide: true}.describe), - }, - Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { - return Instance{Values: map[string]any{ - "read_file": inertTool{name: "read_file"}, - "write_file": inertTool{name: "write_file"}, - "edit_file": inertTool{name: "edit_file"}, - "apply_patch": inertTool{name: "apply_patch", defThide: true}, - }}, nil - }, - } -} - -// chainToolsPlugin declares the combined scenario's two concrete tools: the -// group-capturing write tool and the patch tool, both deriving their code -// groups from the owner data root. -func chainToolsPlugin() Plugin { - return Plugin{ - ID: "chaintools", - Scope: ScopeRuntime, - Provides: []CapabilitySpec{ToolSpec("prod_write", chainWriteTool{}.describe), ToolSpec("apply_patch", chainPatchTool{}.describe)}, - Open: func(_ context.Context, info ScopeInfo, _ Bindings) (Instance, error) { - return Instance{Values: map[string]any{ - "prod_write": chainWriteTool{dataDir: info.DataDir}, - "apply_patch": chainPatchTool{dataDir: info.DataDir}, - }}, nil - }, - } -} - // chainTurn is one scripted model turn: a response action, optionally held // until the test closes its hold channel. type chainTurn struct { @@ -683,13 +343,13 @@ func TestProductionCrossBoundaryComposition(t *testing.T) { allowedPatch, err := json.Marshal(map[string]any{"input": "*** Begin Patch\n*** Update File: notes.txt\n*** Move to: renamed.txt\n@@\n-alpha\n+ALPHA\n*** End Patch"}) testassert.NoError(t, err) writeTurn := func(id string) func(http.ResponseWriter) { - return func(w http.ResponseWriter) { writeToolCallTurn(w, id, "prod_write", modelWriteArgs) } + return func(w http.ResponseWriter) { writeToolCallTurn(w, id, "write_file", modelWriteArgs) } } release2 := make(chan struct{}) release3 := make(chan struct{}) parked := make(chan struct{}) // the canceled operation parks until the owner cancels server := newChainServer(t, - chainTurn{respond: func(w http.ResponseWriter) { writeToolCallTurn(w, "call-1", "prod_write", modelWriteArgs) }}, + chainTurn{respond: func(w http.ResponseWriter) { writeToolCallTurn(w, "call-1", "write_file", modelWriteArgs) }}, chainTurn{hold: release2, respond: writeTurn("call-2")}, chainTurn{hold: release3, respond: writeTurn("call-3")}, chainTurn{respond: func(w http.ResponseWriter) { writeToolCallTurn(w, "call-4", "apply_patch", string(deniedPatch)) }}, @@ -720,9 +380,10 @@ func TestProductionCrossBoundaryComposition(t *testing.T) { return c }} argHook := &rewriteWriteHook{replace: hookRewriteArgs} + useFixtureStore(t, e.store) openChained := func(ctx context.Context, hook *recordHook, argHook *rewriteWriteHook) (*Runtime, error) { return Open(ctx, Options{DataDir: dataDir, ConfigPath: configPath, Plugins: []Plugin{ - coreStoragePlugin(e.store), chainToolsPlugin(), hookPlugin(hook), + hookPlugin(hook), argumentHookPlugin("arg.rewrite", argHook), bundledAdaptationPlugin(), }}) } @@ -748,7 +409,7 @@ func TestProductionCrossBoundaryComposition(t *testing.T) { } { seen := argHook.received() - testassert.Require(t, slices.Equal(seen, []string{"prod_write " + modelWriteArgs}), "argument hook observations = %v, want exactly the original call arguments", seen) + testassert.Require(t, slices.Equal(seen, []string{"write_file " + modelWriteArgs}), "argument hook observations = %v, want exactly the original call arguments", seen) } admitted := readOperation(t, r, sessionID, "op-1").Admission.AdmittedEntry.EntryID @@ -862,7 +523,7 @@ func TestProductionCrossBoundaryComposition(t *testing.T) { // call: three rewritten writes and two pass-through patch calls. { seen := argHook.received() - testassert.Require(t, len(seen) == 5 && seen[0] == "prod_write "+modelWriteArgs && seen[1] == "prod_write "+modelWriteArgs && seen[2] == "prod_write "+modelWriteArgs && seen[3] == "apply_patch "+string(deniedPatch) && seen[4] == "apply_patch "+string(allowedPatch), "argument hook observations = %v, want each call's original arguments exactly once", seen) + testassert.Require(t, len(seen) == 5 && seen[0] == "write_file "+modelWriteArgs && seen[1] == "write_file "+modelWriteArgs && seen[2] == "write_file "+modelWriteArgs && seen[3] == "apply_patch "+string(deniedPatch) && seen[4] == "apply_patch "+string(allowedPatch), "argument hook observations = %v, want each call's original arguments exactly once", seen) } // The predecessor's own requests carry its immutable old capture, and @@ -964,7 +625,7 @@ func TestProductionReadonlyGitHookSeesOriginalArguments(t *testing.T) { `{"rogit": {"model": "prov/m", "system_prompt": "simple", "tools": ["run_command"], "readonly": true, "capabilities": ["arg.noop"]}}`) hook := &noopArgumentHook{} - r, err := e.openPlugins(ctx, coreStoragePlugin(e.store), chainCommandPlugin(), argumentHookPlugin("arg.noop", hook)) + r, err := e.openPlugins(ctx, argumentHookPlugin("arg.noop", hook)) testassert.NoError(t, err) defer r.Close(ctx) @@ -997,11 +658,19 @@ func TestProductionReadonlyGitHookSeesOriginalArguments(t *testing.T) { // two agents with identical configured capabilities but differently-adapted // models keep IDENTICAL capability ID lists in their captures while the // model-dependent adaptation changes their advertisements and prompts — the -// adaptation never leaks into the capability list. +// adaptation never leaks into the capability list. The GPT-adapted Agent then +// attempts the two tools its adaptation excludes: each call settles the +// ordinary exact unavailable error and leaves no file effect. func TestProductionCapabilityIDsModelIndependent(t *testing.T) { eachProductionStore(t, func(t *testing.T, e *productionEnv) { ctx := context.Background() server := newChainServer(t, + chainTurn{respond: func(w http.ResponseWriter) { + writeToolCallTurn(w, "call-write", "write_file", `{"path":"gpt-write.txt","content":"must not land"}`) + }}, + chainTurn{respond: func(w http.ResponseWriter) { + writeToolCallTurn(w, "call-edit", "edit_file", `{"path":"gpt-edit.txt","old_string":"keep me","new_string":"changed"}`) + }}, chainTurn{respond: func(w http.ResponseWriter) { writeTextTurn(w, "done") }}, chainTurn{respond: func(w http.ResponseWriter) { writeTextTurn(w, "done") }}, ) @@ -1011,11 +680,14 @@ func TestProductionCapabilityIDsModelIndependent(t *testing.T) { "vanillaagent": {"model": "prov/vanilla", "system_prompt": "full", "tools": ["read_file", "write_file", "edit_file", "apply_patch"], "capabilities": ["model_adaptation"]} }`) - r, err := e.openPlugins(ctx, coreStoragePlugin(e.store), inertToolsPlugin(), bundledAdaptationPlugin()) + r, err := e.openPlugins(ctx, bundledAdaptationPlugin()) testassert.NoError(t, err) defer r.Close(ctx) - gpt, err := r.createSession(ctx, e.workspace("adapt-gpt"), "gptagent") + gptWorkspace := e.workspace("adapt-gpt") + testassert.NoError(t, os.MkdirAll(gptWorkspace, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(gptWorkspace, "gpt-edit.txt"), []byte("keep me\n"), 0o600)) + gpt, err := r.createSession(ctx, gptWorkspace, "gptagent") testassert.NoError(t, err) submitThroughRuntime(t, r, gpt.Identity.SessionID, "op-gpt", "hello") gptRec := awaitOperation(t, r, gpt.Identity.SessionID, "op-gpt", harness.OperationSuccess) @@ -1030,17 +702,38 @@ func TestProductionCapabilityIDsModelIndependent(t *testing.T) { testassert.Require(t, slices.Equal(gptRec.Admission.Execution.Capabilities, vanRec.Admission.Execution.Capabilities) && slices.Equal(gptRec.Admission.Execution.Capabilities, []string{"model_adaptation"}), "capability IDs = %q vs %q, want identical lists across the model switch; capability IDs = %q, want exactly the agents' configured selection", gptRec.Admission.Execution.Capabilities, vanRec.Admission.Execution.Capabilities, gptRec.Admission.Execution.Capabilities) // The advertisements differ exactly as the bundled adaptations - // resolve: the GPT-family match swaps write/edit for apply_patch, - // the unmatched model keeps the default-hidden patch tool out. + // resolve: the GPT-family match excludes write/edit and keeps the + // patch tool, the unmatched model selects all four tools. gptTools := toolNames(gptRec.Admission.Execution.Tools) vanTools := toolNames(vanRec.Admission.Execution.Tools) testassert.Require(t, slices.Equal(gptTools, []string{"read_file", "apply_patch"}), "adapted advertisement = %v, want write/edit excluded and apply_patch included", gptTools) - testassert.Require(t, slices.Equal(vanTools, []string{"read_file", "write_file", "edit_file"}), "baseline advertisement = %v, want the untouched list with the hidden patch tool out", vanTools) + testassert.Require(t, slices.Equal(vanTools, []string{"read_file", "write_file", "edit_file", "apply_patch"}), "baseline advertisement = %v, want every selected tool", vanTools) + + // The excluded tools are refused by the ordinary unavailable path: + // each attempted call settles its exact error and no file effect + // happens, so the exclusion is an advertisement/dispatch rule rather + // than a rejected definition. + results := chainToolResults(t, e.store, gpt.Identity.SessionID) + for _, row := range []struct{ callID, name string }{ + {"call-write", "write_file"}, + {"call-edit", "edit_file"}, + } { + res, ok := results[row.callID] + testassert.Require(t, ok && res.Status == model.ResultError && res.Content == `Tool "`+row.name+`" is not available.`, "%s result = %+v (ok %v), want the ordinary exact unavailable error", row.name, res, ok) + } + { + _, err := os.Stat(filepath.Join(gptWorkspace, "gpt-write.txt")) + testassert.Require(t, errors.Is(err, os.ErrNotExist), "gpt-write.txt = err %v, want absent: the excluded write tool ran", err) + } + { + data, err := os.ReadFile(filepath.Join(gptWorkspace, "gpt-edit.txt")) + testassert.Require(t, err == nil && string(data) == "keep me\n", "gpt-edit.txt = (%q, %v), want the unchanged bytes: the excluded edit tool ran", data, err) + } - // The prompts differ too: the adapted model's task_execution - // addition renders in the full prompt. - testassert.Require(t, strings.Contains(gptRec.Admission.Execution.SystemPrompt, "Use more tool calls when they can improve the response"), "adapted prompt = %.200s, want the adaptation's task_execution addition", gptRec.Admission.Execution.SystemPrompt) - testassert.Require(t, !strings.Contains(vanRec.Admission.Execution.SystemPrompt, "Use more tool calls when they can improve the response"), "baseline prompt = %.200s, want no adaptation addition", vanRec.Admission.Execution.SystemPrompt) + // The prompts differ too: the adapted model's bundled guidance + // append renders in the full prompt. + testassert.Require(t, strings.Contains(gptRec.Admission.Execution.SystemPrompt, "Use more tool calls when they can improve the response"), "adapted prompt = %.200s, want the adaptation's append paragraph", gptRec.Admission.Execution.SystemPrompt) + testassert.Require(t, !strings.Contains(vanRec.Admission.Execution.SystemPrompt, "Use more tool calls when they can improve the response"), "baseline prompt = %.200s, want no automatic bundled guidance", vanRec.Admission.Execution.SystemPrompt) }) } diff --git a/runtime/production_test.go b/runtime/production_test.go index dfa7283e..fb5cf962 100644 --- a/runtime/production_test.go +++ b/runtime/production_test.go @@ -9,7 +9,6 @@ import ( "net/http" "net/http/httptest" "os" - "os/exec" "path/filepath" "slices" "strconv" @@ -23,9 +22,9 @@ import ( "github.com/MMinasyan/lightcode/internal/agents" "github.com/MMinasyan/lightcode/internal/catalog" "github.com/MMinasyan/lightcode/internal/config" - "github.com/MMinasyan/lightcode/internal/storage" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" "github.com/MMinasyan/lightcode/protocol" ) @@ -79,18 +78,18 @@ const productionAgentsDocument = `{ "absent": {"model": "prov/absent", "system_prompt": "simple"} }` -// coreStoragePlugin supplies one externally constructed store under the -// private Core contract — the memory variant's store, or the real SQLite -// engine the shipped plugin declares. -func coreStoragePlugin(store harness.Storage) Plugin { - return Plugin{ - ID: "core", - Scope: ScopeRuntime, - Provides: []CapabilitySpec{Spec[harness.Storage]("session_store")}, - Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { - return Instance{Values: map[string]any{"session_store": store}}, nil - }, +// useFixtureStore points the owner's store construction at one externally +// built store — the memory variant's store, or a real SQLite engine opened +// outside the data directory — for the fixture run. The store's lifetime stays +// the fixture's: the owner's storage close disposes nothing the row may still +// read, and eachProductionStore closes the store at the run's end. +func useFixtureStore(t *testing.T, store harness.Storage) { + t.Helper() + previous := openStore + openStore = func(ctx context.Context, dataDir string) (harness.Storage, func() error, error) { + return store, func() error { return nil }, nil } + t.Cleanup(func() { openStore = previous }) } func hookPlugin(hook *recordHook) Plugin { @@ -216,70 +215,28 @@ func (t fileReadTool) Prepare(_ context.Context, tc ToolContext, call model.Tool } } -// commandTool is the concrete command row's tool: one declared command.run -// pair on the fixed target and an executor running the requested token as -// argv (no shell), so the settled result is deterministic. -type commandTool struct{} - -func (t commandTool) describe(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { - definition, err := model.NewToolDefinition(model.ToolDefinition{ - Name: "run_command", - Description: "echoes one token", - Parameters: json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}},"required":["command"]}`), - }) - if err != nil { - return ToolDescription{}, err - } - return ToolDescription{Definition: definition, Available: true}, nil -} - -func (t commandTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { - return runtimeNormalize(call) -} - -func (t commandTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { - var args struct { - Command string `json:"command"` - } - if err := json.Unmarshal(call.Arguments, &args); err != nil || args.Command == "" { - return harness.PreparedTool{Immediate: &harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: "run_command requires command"}}} - } - return harness.PreparedTool{ - Permissions: []harness.PermissionRequest{{Permission: "command.run", Target: "*"}}, - Execute: func(ctx context.Context) harness.ToolOutcome { - if err := ctx.Err(); err != nil { - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: "interrupted"}} - } - out, err := exec.Command("echo", args.Command).Output() - if err != nil { - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: err.Error()}} - } - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: strings.TrimSpace(string(out))}} - }, - } -} - -// productionToolsPlugin declares the suite's concrete tools. +// productionToolsPlugin declares the suite's concrete tools. The standard +// tools are not exportable: run_command in this list is the reserved-name +// rejection's discriminating sibling, tested separately. func productionToolsPlugin() Plugin { return Plugin{ ID: "prodtools", Scope: ScopeRuntime, - Provides: []CapabilitySpec{ToolSpec("prod_write", fileWriteTool{}.describe), ToolSpec("prod_read", fileReadTool{}.describe), ToolSpec("run_command", commandTool{}.describe)}, + Provides: []CapabilitySpec{ToolSpec("prod_write", fileWriteTool{}.describe), ToolSpec("prod_read", fileReadTool{}.describe)}, Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { - return Instance{Values: map[string]any{"prod_write": fileWriteTool{}, "prod_read": fileReadTool{}, "run_command": commandTool{}}}, nil + return Instance{Values: map[string]any{"prod_write": fileWriteTool{}, "prod_read": fileReadTool{}}}, nil }, } } // testAdaptation is the custom test plugin's ModelAdaptation value: one -// coaching block, one section addition, and one excluded tool. +// appended paragraph and one excluded tool. type testAdaptation struct{} -func (testAdaptation) Resolve(model.ModelRef) (Adaptation, error) { +func (testAdaptation) Resolve(model.ModelRef, prompt.Mode) (Adaptation, error) { return Adaptation{ ExcludeTools: []string{"prod_read"}, - Blocks: []string{"PROD-ADAPTATION-BLOCK"}, - Additions: map[string]string{"safety": "ADDITION-SAFETY-TEXT"}, // the one section the simple prompt renders + Append: []string{" PROD-ADAPTATION-BLOCK\n"}, }, nil } @@ -307,12 +264,14 @@ func eachProductionStore(t *testing.T, run func(t *testing.T, e *productionEnv)) } type productionEnv struct { - t *testing.T - home string - dataDir string - configPath string - server *productionModelServer - store harness.Storage + t *testing.T + home string + dataDir string + configPath string + server *productionModelServer + store harness.Storage + identity string + globalRulesDir string } // newProductionEnv isolates HOME and both data roots, points the configured @@ -337,17 +296,18 @@ func (e *productionEnv) open(ctx context.Context, hook *recordHook, park *parkin } // openPlugins opens the fixture's exact plugin list over its data and config -// roots. +// roots, with the owner's store construction pointed at the fixture's store. func (e *productionEnv) openPlugins(ctx context.Context, plugins ...Plugin) (*Runtime, error) { e.t.Helper() - return Open(ctx, Options{DataDir: e.dataDir, ConfigPath: e.configPath, Plugins: plugins}) + useFixtureStore(e.t, e.store) + return Open(ctx, Options{DataDir: e.dataDir, ConfigPath: e.configPath, Plugins: plugins, Identity: e.identity, GlobalRulesDir: e.globalRulesDir}) } // openWithPlugins composes the suite's base plugin set plus extras — the // adaptation row adds its custom ModelAdaptation export this way. func (e *productionEnv) openWithPlugins(ctx context.Context, hook *recordHook, park *parkingHook, extra ...Plugin) (*Runtime, error) { e.t.Helper() - plugins := []Plugin{coreStoragePlugin(e.store), productionToolsPlugin(), hookPlugin(hook), parkPlugin(park)} + plugins := []Plugin{productionToolsPlugin(), hookPlugin(hook), parkPlugin(park)} return e.openPlugins(ctx, append(plugins, extra...)...) } @@ -456,7 +416,7 @@ func (s *productionModelServer) serve(w http.ResponseWriter, r *http.Request) { } default: if lastUserText(string(body)) == "please run" { - toolName, toolArgs = "run_command", `{"command":"production-command-row"}` + toolName, toolArgs = "run_command", `{"command":"printf production-command-row"}` } if gate != nil { // one shared first-turn gate wait for every tool turn select { @@ -1132,13 +1092,13 @@ func TestProductionTransportConversion(t *testing.T) { writeServiceFile(t, agents.PathForConfig(sh.configPath), "{}") owner, cancel := context.WithCancel(context.Background()) defer cancel() - c := mustComposition(t, coreStoragePlugin(storage.NewMemory())) + c := mustComposition(t) runtimeScope := mustOpenScope(t, c, owner, ScopeInfo{Kind: ScopeRuntime, DataDir: sh.dataDir}, nil) obs := newObservation() svc := newConfigurationService(owner, c, sh.loader, sh.configPath, obs) snapshot, err := svc.publish(owner) testassert.NoError(t, err) - p := newPreparation(svc, c, runtimeScope, newWorkspaceScopes(owner, c, []*scope{runtimeScope}, obs), sh.home, nil, nil, nil, nil) + p := newPreparation(svc, c, runtimeScope, newWorkspaceScopes(owner, c, []*scope{runtimeScope}, obs), sh.home, "", "", nil, nil, nil, nil) ref := model.ModelRef{Provider: "prov", Model: "m"} provider, entry, err := snapshot.snapshot.catalog.Lookup(catalog.ModelRef{Provider: ref.Provider, Model: ref.Model}) testassert.NoError(t, err) @@ -1220,7 +1180,9 @@ func TestProductionCommandRow(t *testing.T) { r, err := e.open(ctx, e.hookedHook(), &parkingHook{}) testassert.NoError(t, err) defer r.Close(ctx) - session, err := r.createSession(ctx, e.workspace("run-ws"), "runner") + workspace := e.workspace("run-ws") + testassert.NoError(t, os.MkdirAll(workspace, 0o700)) + session, err := r.createSession(ctx, workspace, "runner") testassert.NoError(t, err) submitThroughRuntime(t, r, session.Identity.SessionID, "op-1", "please run") rec := awaitOperation(t, r, session.Identity.SessionID, "op-1", harness.OperationSuccess) @@ -1246,11 +1208,267 @@ func TestProductionCommandRow(t *testing.T) { }) } +// TestProductionPatchRow admits an Agent that selects only the baseline +// apply_patch tool on an owner with no optional plugin: the exact +// advertisement on the capture and on the wire request, the real patch +// mutation through the concrete dispatch, the engine's settled summary in the +// durable tool-result entry, and the preimage snapshot inside the admitted +// Operation's own code group. The hard-constraint siblings stay honest: a +// readonly Agent without a write dir never sees the mutation tool and its call +// settles unavailable with the file untouched, while a confined write dir +// admits the tool, applies the in-bound patch, and denies the out-of-bound one +// without touching the file. The bundled GPT exclusion sibling is +// TestProductionCapabilityIDsModelIndependent. +func TestProductionPatchRow(t *testing.T) { + eachProductionStore(t, func(t *testing.T, e *productionEnv) { + ctx := context.Background() + const original = "alpha\nbeta\ngamma\n" + const patchIn = "out/notes.txt" + update := func(path, old, added string) string { + input := "*** Begin Patch\n*** Update File: " + path + "\n@@\n-" + old + "\n+" + added + "\n*** End Patch" + raw, err := json.Marshal(map[string]any{"input": input}) + testassert.NoError(t, err) + return string(raw) + } + writeServiceFile(t, e.configPath, chainCommandConfigDocument(e.server.URL)) + writeServiceFile(t, agents.PathForConfig(e.configPath), `{ + "patcher": {"model": "prov/m", "system_prompt": "simple", "tools": ["apply_patch"]}, + "patchro": {"model": "prov/m", "system_prompt": "simple", "tools": ["apply_patch"], "readonly": true}, + "patchdir": {"model": "prov/m", "system_prompt": "simple", "tools": ["apply_patch"], "readonly": true, "write_dir": "out"} + }`) + e.server.setScript(func(_ context.Context, body string) []string { + if lastMessageRole(body) == "tool" { + return textTurnEvents("done") // the ordinary follow-up after one settled call + } + switch lastUserText(body) { + case "patch it": + return toolCallTurnEvents("call-1", "apply_patch", update("notes.txt", "alpha", "ALPHA")) + case "patch out": + return toolCallTurnEvents("call-2", "apply_patch", update("notes.txt", "alpha", "ALPHA")) + case "patch in": + return toolCallTurnEvents("call-3", "apply_patch", update(patchIn, "alpha", "ALPHA")) + case "patch escape": + return toolCallTurnEvents("call-4", "apply_patch", update("notes.txt", "alpha", "ALPHA")) + } + return nil + }) + + // The positive case: the baseline patch tool selected alone. + workspace := e.workspace("patch-ws") + testassert.NoError(t, os.MkdirAll(filepath.Join(workspace, "out"), 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "notes.txt"), []byte(original), 0o600)) + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, patchIn), []byte(original), 0o600)) + r, err := e.openPlugins(ctx) + testassert.NoError(t, err) + defer r.Close(ctx) + + session, err := r.createSession(ctx, workspace, "patcher") + testassert.NoError(t, err) + sessionID := session.Identity.SessionID + submitThroughRuntime(t, r, sessionID, "op-1", "patch it") + rec := awaitOperation(t, r, sessionID, "op-1", harness.OperationSuccess) + { + got := toolNames(rec.Admission.Execution.Tools) + testassert.Require(t, slices.Equal(got, []string{"apply_patch"}), "advertisement = %v, want exactly the selected baseline patch tool", got) + } + { + wire := decodeWireChatBody(t, e.server.bodyAt(0)) + names := make([]string, 0, len(wire.Tools)) + for _, tool := range wire.Tools { + names = append(names, tool.Function.Name) + } + testassert.Require(t, slices.Equal(names, []string{"apply_patch"}), "wire request tools = %v, want exactly the selected baseline patch tool", names) + } + results := chainToolResults(t, e.store, sessionID) + settled, ok := results["call-1"] + testassert.Require(t, ok && settled.Status == model.ResultSuccess && settled.Content == "Success. Updated the following files:\nM notes.txt", "patch result = %+v (ok %v), want the engine's settled summary", settled, ok) + var preview struct { + EditPreviewFiles []struct { + Path string `json:"path"` + Op string `json:"op"` + } `json:"edit_preview_files"` + } + testassert.NoError(t, json.Unmarshal(settled.Metadata, &preview)) + testassert.Require(t, len(preview.EditPreviewFiles) == 1 && preview.EditPreviewFiles[0].Path == "notes.txt" && preview.EditPreviewFiles[0].Op == "M", "edit_preview_files = %+v, want the update's one preview entry", preview.EditPreviewFiles) + { + data, err := os.ReadFile(filepath.Join(workspace, "notes.txt")) + testassert.Require(t, err == nil && string(data) == "ALPHA\nbeta\ngamma\n", "notes.txt = (%q, %v), want the patched bytes", data, err) + } + { + group := filepath.Join(e.dataDir, "code", sessionID, rec.Admission.AdmittedEntry.EntryID, "snapshots", "1") + entries, err := os.ReadDir(group) + testassert.NoError(t, err) + found := false + for _, entry := range entries { + metaData, err := os.ReadFile(filepath.Join(group, entry.Name(), "meta.json")) + if err != nil { + continue + } + var meta struct { + OriginalPath string `json:"original_path"` + Existed bool `json:"existed"` + } + if err := json.Unmarshal(metaData, &meta); err != nil || !meta.Existed || !strings.HasSuffix(meta.OriginalPath, "notes.txt") { + continue + } + preimage, err := os.ReadFile(filepath.Join(group, entry.Name(), "original")) + testassert.Require(t, err == nil && string(preimage) == original, "notes.txt preimage = (%q, %v), want the pre-mutation bytes", preimage, err) + found = true + } + testassert.Require(t, found, "no notes.txt preimage snapshot in %s, want the mutation's snapshot evidence", group) + } + + // The readonly sibling without a write dir: the mutation tool is not + // advertised, its call settles unavailable, and the file stays intact. + roWorkspace := e.workspace("patch-ro-ws") + testassert.NoError(t, os.MkdirAll(roWorkspace, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(roWorkspace, "notes.txt"), []byte(original), 0o600)) + ro, err := r.createSession(ctx, roWorkspace, "patchro") + testassert.NoError(t, err) + submitThroughRuntime(t, r, ro.Identity.SessionID, "op-ro", "patch out") + recRO := awaitOperation(t, r, ro.Identity.SessionID, "op-ro", harness.OperationSuccess) + { + got := toolNames(recRO.Admission.Execution.Tools) + testassert.Require(t, len(got) == 0, "readonly advertisement = %v, want the mutation tool kept out", got) + } + { + result := chainToolResults(t, e.store, ro.Identity.SessionID)["call-2"] + testassert.Require(t, result.Status == model.ResultError && result.Content == `Tool "apply_patch" is not available.`, "readonly patch call = %+v, want the unavailable settlement", result) + } + { + data, err := os.ReadFile(filepath.Join(roWorkspace, "notes.txt")) + testassert.Require(t, err == nil && string(data) == original, "readonly sibling notes.txt = (%q, %v), want the untouched bytes", data, err) + } + + // The confined write-dir sibling: the tool is advertised, the in-bound + // patch runs, and the out-of-bound patch is denied with its file intact. + wd, err := r.createSession(ctx, workspace, "patchdir") + testassert.NoError(t, err) + submitThroughRuntime(t, r, wd.Identity.SessionID, "op-in", "patch in") + recIn := awaitOperation(t, r, wd.Identity.SessionID, "op-in", harness.OperationSuccess) + { + got := toolNames(recIn.Admission.Execution.Tools) + testassert.Require(t, slices.Equal(got, []string{"apply_patch"}), "write-dir advertisement = %v, want the confined mutation tool admitted", got) + } + { + data, err := os.ReadFile(filepath.Join(workspace, patchIn)) + testassert.Require(t, err == nil && string(data) == "ALPHA\nbeta\ngamma\n", "out/notes.txt = (%q, %v), want the in-bound patch applied", data, err) + } + submitThroughRuntime(t, r, wd.Identity.SessionID, "op-escape", "patch escape") + recOut := awaitOperation(t, r, wd.Identity.SessionID, "op-escape", harness.OperationSuccess) + { + _ = recOut + result := chainToolResults(t, e.store, wd.Identity.SessionID)["call-4"] + testassert.Require(t, result.Status == model.ResultDenied && result.Content == "Permission denied.", "out-of-bound patch call = %+v, want the fixed policy denial", result) + } + { + data, err := os.ReadFile(filepath.Join(workspace, "notes.txt")) + testassert.Require(t, err == nil && string(data) == "ALPHA\nbeta\ngamma\n", "write-dir sibling notes.txt = (%q, %v), want only the positive case's patched bytes", data, err) + } + }) +} + +// TestProductionBaselineCapabilitySelection admits an Agent that selects a +// standard tool as a capability: the selection survives configuration, the +// capture carries it, and every invocation resolves the owner-provided value +// through the ordinary selection path. The tools-list sibling keeps the +// existing advertisement behavior, and an Agent selecting an unknown capability +// stays rejected. +func TestProductionBaselineCapabilitySelection(t *testing.T) { + eachProductionStore(t, func(t *testing.T, e *productionEnv) { + ctx := context.Background() + writeServiceFile(t, e.configPath, chainCommandConfigDocument(e.server.URL)) + writeServiceFile(t, agents.PathForConfig(e.configPath), `{ + "capselect": {"model": "prov/m", "system_prompt": "simple", "tools": [], "capabilities": ["read_file"]}, + "toolselect": {"model": "prov/m", "system_prompt": "simple", "tools": ["read_file"]}, + "badcap": {"model": "prov/m", "system_prompt": "simple", "capabilities": ["nope.not.a.capability"]} + }`) + e.server.setScript(func(_ context.Context, body string) []string { + return textTurnEvents("done") // every turn answers plain text: only the selection path is under test + }) + r, err := e.openPlugins(ctx) + testassert.NoError(t, err) + defer r.Close(ctx) + + capWorkspace := e.workspace("cap-ws") + testassert.NoError(t, os.MkdirAll(capWorkspace, 0o700)) + session, err := r.createSession(ctx, capWorkspace, "capselect") + testassert.NoError(t, err) + submitThroughRuntime(t, r, session.Identity.SessionID, "op-cap", "hello") + rec := awaitOperation(t, r, session.Identity.SessionID, "op-cap", harness.OperationSuccess) + testassert.Require(t, slices.Equal(rec.Admission.Execution.Capabilities, []string{"read_file"}), "captured capabilities = %q, want the standard tool selection", rec.Admission.Execution.Capabilities) + testassert.Require(t, len(toolNames(rec.Admission.Execution.Tools)) == 0, "advertisement = %v, want no tool selected", toolNames(rec.Admission.Execution.Tools)) + + toolWorkspace := e.workspace("tool-ws") + testassert.NoError(t, os.MkdirAll(toolWorkspace, 0o700)) + toolSession, err := r.createSession(ctx, toolWorkspace, "toolselect") + testassert.NoError(t, err) + submitThroughRuntime(t, r, toolSession.Identity.SessionID, "op-tool", "hello") + toolRec := awaitOperation(t, r, toolSession.Identity.SessionID, "op-tool", harness.OperationSuccess) + { + got := toolNames(toolRec.Admission.Execution.Tools) + testassert.Require(t, slices.Equal(got, []string{"read_file"}), "advertisement = %v, want the selected baseline tool", got) + } + + // The forbidden sibling: an unknown capability never joins the + // capability universe, so its Agent definition is refused and its + // admission reports the missing Agent type. + badWorkspace := e.workspace("bad-ws") + testassert.NoError(t, os.MkdirAll(badWorkspace, 0o700)) + bad, err := r.createSession(ctx, badWorkspace, "badcap") + testassert.NoError(t, err) + _, err = submitRawThroughRuntime(ctx, r, bad.Identity.SessionID, "op-bad", "hello", harness.MessageModeRegular) + testassert.Require(t, err != nil && strings.Contains(err.Error(), "badcap"), "unknown-capability admission = err %v, want the refused Agent type reported", err) + }) +} + +// TestProductionSuppliesExplicitPromptInputs proves public Open forwards the +// caller's identity and global rules directory to preparation: an ambient +// home .lightcode rule never enters the prompt, and neither does an embedded +// persona. The zero-value sibling supplies no identity or global directory +// while the same ambient home rule exists. +func TestProductionSuppliesExplicitPromptInputs(t *testing.T) { + eachProductionStore(t, func(t *testing.T, e *productionEnv) { + ctx := context.Background() + workspace := e.workspace("explicit-ws") + testassert.NoError(t, os.MkdirAll(workspace, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("WORKSPACE_RULES_MARKER"), 0o600)) + testassert.NoError(t, os.MkdirAll(filepath.Join(e.home, ".lightcode"), 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(e.home, ".lightcode", "AGENTS.md"), []byte("AMBIENT_HOME_RULES"), 0o600)) + globalDir := filepath.Join(e.home, "explicit-rules") + testassert.NoError(t, os.MkdirAll(globalDir, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(globalDir, "AGENTS.md"), []byte("EXPLICIT_GLOBAL_RULES"), 0o600)) + + capture := func(t *testing.T, operationID, identity, rulesDir string) string { + t.Helper() + e.identity = identity + e.globalRulesDir = rulesDir + r, err := e.open(ctx, e.hookedHook(), &parkingHook{}) + testassert.NoError(t, err) + defer func() { testassert.NoError(t, r.Close(ctx)) }() + session, err := r.createSession(ctx, workspace, "worker") + testassert.NoError(t, err) + submitThroughRuntime(t, r, session.Identity.SessionID, operationID, "hello") + rec := awaitOperation(t, r, session.Identity.SessionID, operationID, harness.OperationSuccess) + return rec.Admission.Execution.SystemPrompt + } + + explicit := capture(t, "op-explicit", "PRODUCTION_HOST_IDENTITY", globalDir) + testassert.Require(t, strings.Contains(explicit, "PRODUCTION_HOST_IDENTITY") && strings.Contains(explicit, "EXPLICIT_GLOBAL_RULES") && strings.Contains(explicit, "WORKSPACE_RULES_MARKER"), "captured prompt misses the explicit host inputs or Workspace rules: %.300s", explicit) + testassert.Require(t, !strings.Contains(explicit, "AMBIENT_HOME_RULES") && !strings.Contains(explicit, "Lightcode, an agentic coding assistant"), "captured prompt carries an implicit home rule or embedded persona: %.300s", explicit) + + zero := capture(t, "op-zero", "", "") + testassert.Require(t, strings.Contains(zero, "WORKSPACE_RULES_MARKER"), "zero-value prompt misses the Workspace rules: %.300s", zero) + testassert.Require(t, !strings.Contains(zero, "PRODUCTION_HOST_IDENTITY") && !strings.Contains(zero, "EXPLICIT_GLOBAL_RULES"), "zero-value prompt carries an explicit host input: %.300s", zero) + testassert.Require(t, !strings.Contains(zero, "AMBIENT_HOME_RULES") && !strings.Contains(zero, "Lightcode, an agentic coding assistant"), "zero-value prompt carries an implicit home rule or embedded persona: %.300s", zero) + }) +} + // TestProductionAdaptationComposes composes the suite's custom // ModelAdaptation export through public Open and proves through one real // admitted operation that the selected adaptation reached the composed -// surface: the coaching block and section addition appear in the captured -// prompt and the excluded tool is absent from the advertisement. +// surface: the append paragraph lands after the assembled baseline and the +// excluded tool is absent from the advertisement. func TestProductionAdaptationComposes(t *testing.T) { eachProductionStore(t, func(t *testing.T, e *productionEnv) { ctx := context.Background() @@ -1263,7 +1481,8 @@ func TestProductionAdaptationComposes(t *testing.T) { rec := awaitOperation(t, r, session.Identity.SessionID, "op-1", harness.OperationSuccess) prompt := rec.Admission.Execution.SystemPrompt - testassert.Require(t, strings.Contains(prompt, "PROD-ADAPTATION-BLOCK") && strings.Contains(prompt, "ADDITION-SAFETY-TEXT"), "captured prompt misses the adaptation's coaching block: %.200s; captured prompt misses the adaptation's section addition: %.200s", prompt, prompt) + testassert.Require(t, strings.HasSuffix(prompt, " PROD-ADAPTATION-BLOCK\n"), "captured prompt misses the adaptation's append paragraph and whitespace: %.200s", prompt) + testassert.Require(t, strings.Contains(prompt, "## Core Rules"), "captured prompt lost the assembled baseline: %.200s", prompt) { got := toolNames(rec.Admission.Execution.Tools) testassert.Require(t, slices.Equal(got, []string{"prod_write"}), "advertisement = %v, want the adaptation's excluded tool absent", got) @@ -1275,6 +1494,83 @@ func TestProductionAdaptationComposes(t *testing.T) { }) } +// TestProductionPreparationHookReplacesAndRemovesPromptAfterAssembly proves +// the existing PreparationHook callback owns complete replacement and empty +// removal AFTER prompt assembly: full, simple and none each assemble their +// baseline (identity, rules, instructions), the hook replaces it with one +// exact text (or removes it), the admitted capture is exactly that text with +// no baseline, rule or identity reinsertion, and the model request carries +// the exact system message (or none at all). +func TestProductionPreparationHookReplacesAndRemovesPromptAfterAssembly(t *testing.T) { + eachProductionStore(t, func(t *testing.T, e *productionEnv) { + ctx := context.Background() + writeServiceFile(t, agents.PathForConfig(e.configPath), `{ + "fullagent": {"model": "prov/m", "system_prompt": "full", "prompt": "FULL_INSTRUCTIONS", "capabilities": ["hook.first"]}, + "simpleagent": {"model": "prov/m", "system_prompt": "simple", "prompt": "SIMPLE_INSTRUCTIONS", "capabilities": ["hook.first"]}, + "noneagent": {"model": "prov/m", "system_prompt": "none", "prompt": "NONE_INSTRUCTIONS", "capabilities": ["hook.first"]} +}`) + globalDir := e.workspace("global-rules") + testassert.NoError(t, os.MkdirAll(globalDir, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(globalDir, "AGENTS.md"), []byte("EXPLICIT_GLOBAL_RULES"), 0o600)) + e.identity = "PRODUCTION_HOST_IDENTITY" + e.globalRulesDir = globalDir + workspace := e.workspace("hook-prompt-ws") + testassert.NoError(t, os.MkdirAll(workspace, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "AGENTS.md"), []byte("WORKSPACE_RULES_MARKER"), 0o600)) + + const replaced = "HOOK-REPLACED-PROMPT" + cases := []struct { + name, agent, want string + assembled []string + }{ + {"full", "fullagent", replaced, []string{"PRODUCTION_HOST_IDENTITY", "EXPLICIT_GLOBAL_RULES", "WORKSPACE_RULES_MARKER", "FULL_INSTRUCTIONS"}}, + {"simple", "simpleagent", replaced, []string{"PRODUCTION_HOST_IDENTITY", "EXPLICIT_GLOBAL_RULES", "WORKSPACE_RULES_MARKER", "SIMPLE_INSTRUCTIONS"}}, + {"none", "noneagent", "", []string{"NONE_INSTRUCTIONS"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var mu sync.Mutex + var preHook string + hook := &recordHook{name: "hook.first", events: &traceLog{}, mutate: func(c harness.ExecutionCapture) harness.ExecutionCapture { + mu.Lock() + preHook = c.SystemPrompt + mu.Unlock() + c.SystemPrompt = tc.want + return c + }} + r, err := e.openPlugins(ctx, hookPlugin(hook)) + testassert.NoError(t, err) + defer r.Close(ctx) + session, err := r.createSession(ctx, workspace, tc.agent) + testassert.NoError(t, err) + before := e.server.requests() + submitThroughRuntime(t, r, session.Identity.SessionID, "op-"+tc.name, "hello") + rec := awaitOperation(t, r, session.Identity.SessionID, "op-"+tc.name, harness.OperationSuccess) + + mu.Lock() + assembled := preHook + mu.Unlock() + for _, marker := range tc.assembled { + testassert.Require(t, strings.Contains(assembled, marker), "pre-hook prompt misses the assembled %q: %.300s", marker, assembled) + } + testassert.Equal(t, rec.Admission.Execution.SystemPrompt, tc.want) + for _, marker := range tc.assembled { + testassert.Require(t, !strings.Contains(rec.Admission.Execution.SystemPrompt, marker), "admitted prompt reinserted %q", marker) + } + + testassert.Require(t, e.server.requests() > before, "no model request was recorded") + wire := decodeWireChatBody(t, e.server.bodyAt(before)) + if tc.want == "" { + testassert.Require(t, len(wire.Messages) != 0 && wire.Messages[0].Role != "system", "wire messages = %+v, want no system message for the removed prompt", wire.Messages) + return + } + want, err := json.Marshal(tc.want) + testassert.Require(t, err == nil && len(wire.Messages) != 0 && wire.Messages[0].Role == "system" && string(wire.Messages[0].Content) == string(want), "wire messages = %+v, want the exact replacement system message %s", wire.Messages, want) + }) + } + }) +} + // TestProductionKeylessProvider admits an agent on the credential-free // provider (empty api_key_env) through public Open: the admission resolves // no credential, the real model request carries no Authorization header, and diff --git a/runtime/protocol_code.go b/runtime/protocol_code.go index a1c053f3..d7ceec67 100644 --- a/runtime/protocol_code.go +++ b/runtime/protocol_code.go @@ -2,7 +2,6 @@ package runtime import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -12,7 +11,6 @@ import ( "syscall" "github.com/MMinasyan/lightcode/harness" - "github.com/MMinasyan/lightcode/internal/config" "github.com/MMinasyan/lightcode/internal/pathutil" "github.com/MMinasyan/lightcode/internal/safefs" "github.com/MMinasyan/lightcode/internal/snapshot" @@ -21,10 +19,9 @@ import ( // This file owns the deferred code-snapshot adjunct and the bounded viewer: // the target per-Operation code groups over the existing snapshot.CodeStore -// tree, the retained pre-cutover snapshot-only view, and one contained 1 MiB -// workspace-file read. Both restore paths share one result projection; no -// route, Harness durability kind, legacy Store, or conversation/recovery -// authority enters here. +// tree and one contained 1 MiB workspace-file read. The restore path owns the +// result projection; no route, Harness durability kind, legacy Store, or +// conversation/recovery authority enters here. // codeGroup is one derivable target code group: the owning Operation and its // admitted input entry — the group key, never a filesystem segment supplied by @@ -107,15 +104,13 @@ func projectCodeSnapshotGroups(sessionID string, headers []harness.HistoryEntryH if err != nil { return nil, err } - turns, err := store.ListTurns() + metas, err := store.ListEntries() if err != nil { return nil, err } - files := make([]protocol.SnapshotFile, 0) - for _, turn := range turns { - for _, meta := range turn.Files { - files = append(files, protocol.SnapshotFile{Path: meta.OriginalPath, Existed: meta.Existed}) - } + files := make([]protocol.SnapshotFile, 0, len(metas)) + for _, meta := range metas { + files = append(files, protocol.SnapshotFile{Path: meta.OriginalPath, Existed: meta.Existed}) } if len(files) == 0 { continue // only groups that recorded snapshots are listed @@ -185,7 +180,7 @@ func restoreSessionCode(sessionID string, view harness.HistoryReadView, dataDir, directories = append(directories, targetCodeGroupDir(dataDir, sessionID, groups[i].entryID)) } result := newCodeRevertResult() - traversed, err := snapshot.RevertCodeGroups(directories, 0) + traversed, err := snapshot.RevertCodeGroups(directories) accumulateCodeRevert(&result, traversed) if err != nil { return codeRevertFailure(result, err) @@ -214,7 +209,7 @@ func newCodeRevertResult() protocol.CodeRevertResult { } } -// accumulateCodeRevert appends one group's or turn's partial result to the +// accumulateCodeRevert appends one group's partial result to the // accumulated wire result in traversal order. func accumulateCodeRevert(result *protocol.CodeRevertResult, group snapshot.RevertResult) { result.Restored = append(result.Restored, group.Restored...) @@ -227,94 +222,14 @@ func accumulateCodeRevert(result *protocol.CodeRevertResult, group snapshot.Reve // result DTO: the internal error class rides the result while the Go error // stays non-nil, so a caller can never read the partial work as an empty // success or a completed rollback. It never fills Error.SessionId — that -// generated field names a target Session, never a caller-supplied legacy ID. +// generated field names a target Session. func codeRevertFailure(result protocol.CodeRevertResult, err error) (protocol.CodeRevertResult, error) { result.Error = &protocol.Error{Code: protocol.Internal, Message: err.Error()} return result, fmt.Errorf("restore code snapshots: %w", err) } -// isLegacySessionID reports the one retained ID shape: exactly 8 lowercase -// hexadecimal characters, distinct from every 32-hex target Session ID. -func isLegacySessionID(id string) bool { - if len(id) != 8 { - return false - } - for _, r := range id { - if (r < '0' || r > '9') && (r < 'a' || r > 'f') { - return false - } - } - return true -} - -// retainedCodeProof is the one old metadata shape this reader interprets: -// exactly the id, project_path and project_hash members under their existing -// JSON names. Those three fields must match the explicit old ID and the -// normalized Workspace proof; every other old metadata member is irrelevant -// to this reader and is not interpreted as Session, model or lifecycle truth. -type retainedCodeProof struct { - ID string `json:"id"` - ProjectPath string `json:"project_path"` - ProjectHash string `json:"project_hash"` -} - -// retainedSessionDir proves one pre-cutover snapshot-only Session directory: -// the exact 8-hex ID, the normalized workspace root, the deterministic -// p- workspace storage key (the same key the permission record -// directory uses), and an existing meta whose ID, ProjectPath, and recorded -// 16-hex project-hash prefix all match. Missing or -// mismatched proof fails before any snapshot read. The proof reads the -// complete regular meta file as one JSON document through the no-follow -// directory and regular-FD helpers and never loads turns, messages, claims, -// recovery, or model metadata. -func (r *Runtime) retainedSessionDir(workspace, legacyID string) (string, error) { - if workspace == "" { - return "", fmt.Errorf("workspace must be non-empty: %w", harness.ErrInvalid) - } - if !isLegacySessionID(legacyID) { - return "", fmt.Errorf("legacy session id %q is not exactly 8 lowercase hex: %w", legacyID, harness.ErrInvalid) - } - normalized, err := filepath.Abs(workspace) - if err != nil { - return "", fmt.Errorf("normalize workspace: %v: %w", err, harness.ErrInvalid) - } - normalized = filepath.Clean(normalized) - dirID, err := config.WorkspacePermissionDirID(normalized) - if err != nil { - return "", fmt.Errorf("derive workspace storage key: %v: %w", err, harness.ErrInvalid) - } - // The meta proof records the 16-hex digest prefix of the same storage key - // the directory is derived from, so the proof and the location can never - // disagree about the workspace identity. - hashPrefix := strings.TrimPrefix(dirID, "p-")[:16] - sessionDir := filepath.Join(r.config.loader.Home(), ".lightcode", "projects", dirID, "sessions", legacyID) - dir, err := safefs.OpenDirectory(sessionDir) - if err != nil { - return "", noFollowOpenError("legacy session directory", sessionDir, err) - } - _ = dir.Close() - metaPath := filepath.Join(sessionDir, "meta.json") - file, err := safefs.OpenExisting(metaPath, os.O_RDONLY) - if err != nil { - return "", noFollowOpenError("legacy session meta", metaPath, err) - } - defer file.Close() - data, err := io.ReadAll(file) - if err != nil { - return "", fmt.Errorf("read legacy session meta %s: %w", metaPath, err) - } - var meta retainedCodeProof - if err := json.Unmarshal(data, &meta); err != nil { - return "", fmt.Errorf("legacy session meta %s is malformed: %v: %w", metaPath, err, harness.ErrInvalid) - } - if meta.ID != legacyID || meta.ProjectPath != normalized || meta.ProjectHash != hashPrefix { - return "", fmt.Errorf("legacy session meta %s does not prove id, workspace and project hash for the requested root: %w", metaPath, harness.ErrInvalid) - } - return sessionDir, nil -} - // noFollowOpenError classifies one read-only no-follow regular-FD open -// failure for both the retained proof and the viewer: a missing path is +// failure for the viewer: a missing path is // not-found, a link/hardlink/non-regular refusal is invalid, and any other // I/O error passes through. label names the caller's target in the message. func noFollowOpenError(label, path string, err error) error { @@ -329,71 +244,6 @@ func noFollowOpenError(label, path string, err error) error { } } -// listRetainedCodeSnapshots projects the pre-cutover per-turn snapshot groups -// through the standalone CodeStore on the proven session directory: the -// retained turn order and display-path files, and nothing of the old -// conversation, claim, recovery, or model authority. -func (r *Runtime) listRetainedCodeSnapshots(ctx context.Context, workspace, legacyID string) (protocol.RetainedTurns, error) { - release, err := r.enter(ctx) - if err != nil { - return protocol.RetainedTurns{}, err - } - defer release() - sessionDir, err := r.retainedSessionDir(workspace, legacyID) - if err != nil { - return protocol.RetainedTurns{}, err - } - store, err := snapshot.OpenCodeStore(sessionDir) - if err != nil { - return protocol.RetainedTurns{}, err - } - turns, err := store.ListTurns() - if err != nil { - return protocol.RetainedTurns{}, err - } - out := protocol.RetainedTurns{Turns: make([]protocol.RetainedTurn, 0, len(turns))} - for _, turn := range turns { - files := make([]protocol.SnapshotFile, 0, len(turn.Files)) - for _, meta := range turn.Files { - files = append(files, protocol.SnapshotFile{Path: meta.OriginalPath, Existed: meta.Existed}) - } - out.Turns = append(out.Turns, protocol.RetainedTurn{Turn: turn.Turn, Files: files}) - } - return out, nil -} - -// revertRetainedCode restores the proven legacy directory's recorded turns -// after the caller's turn through the shared whole-invocation traversal, -// canonical and last-write proof, and partial-result contract. The retained -// proof occurs before the directory is leased: the proved session directory -// is the one artifact interval the restore takes nonblockingly — contention -// is a conflict — and the token remains held through the traversal and every -// exit. `after_turn` is used directly; no target Operation ID is fabricated -// for a legacy turn choice. -func (r *Runtime) revertRetainedCode(ctx context.Context, req protocol.RetainedRevertRequest) (protocol.CodeRevertResult, error) { - release, err := r.enter(ctx) - if err != nil { - return protocol.CodeRevertResult{}, err - } - defer release() - sessionDir, err := r.retainedSessionDir(req.Workspace, req.SessionId) - if err != nil { - return protocol.CodeRevertResult{}, err - } - releaseArtifacts, err := r.artifacts.acquireTry(sessionDir) - if err != nil { - return protocol.CodeRevertResult{}, err - } - defer releaseArtifacts() - restored, err := snapshot.RevertCodeGroups([]string{sessionDir}, req.AfterTurn) - result := newCodeRevertResult() - accumulateCodeRevert(&result, restored) - if err != nil { - return codeRevertFailure(result, err) - } - return result, nil -} - // maxViewPrefixBytes is the one viewer prefix cap: 1 MiB of raw file bytes. const maxViewPrefixBytes = 1 << 20 diff --git a/runtime/protocol_code_test.go b/runtime/protocol_code_test.go index 03e2b2b3..8e36b32c 100644 --- a/runtime/protocol_code_test.go +++ b/runtime/protocol_code_test.go @@ -7,9 +7,9 @@ import ( "encoding/hex" "encoding/json" "errors" - "net/http" "os" "path/filepath" + "reflect" "slices" "strings" "sync" @@ -33,23 +33,22 @@ func codeGroupRoot(r *Runtime, sessionID, entryID string) string { } // captureCodeMutation drives one real CodeStore capture exactly like a -// mutating tool: the on-disk preimage is captured, content is written, and the -// post-mutation identity is recorded and retained. +// mutating tool: the on-disk preimage is captured, content is written, and +// the post-mutation identity is recorded. func captureCodeMutation(t *testing.T, groupRoot, displayPath, target string, content []byte) { t.Helper() group, err := snapshot.OpenCodeStore(groupRoot) testassert.Require(t, err == nil, "OpenCodeStore(%s): %v", groupRoot, err) - entryID, _, err := group.SnapshotResolvedEntry(1, displayPath, target) + entryID, _, err := group.SnapshotResolvedEntry(displayPath, target) testassert.Require(t, err == nil, "SnapshotResolvedEntry(%s): %v", target, err) { err := os.WriteFile(target, content, 0o600) testassert.Require(t, err == nil, "WriteFile(%s): %v", target, err) } { - err := group.RecordSnapshotContent(1, entryID, content) + err := group.RecordSnapshotContent(entryID, content) testassert.Require(t, err == nil, "RecordSnapshotContent(%s): %v", target, err) } - group.RetainSnapshotEntry(1, entryID) } // submitCodeOperation submits one regular message and waits for its admitted @@ -151,7 +150,9 @@ func openCodeRuntime(t *testing.T, store harness.Storage) *codeRuntime { // text turns and manual compaction. e := newOwnerEnv(t) writeServiceFile(t, agents.PathForConfig(e.configPath), projectionAgentsDocument) - r, _, err := e.openSwept(context.Background(), e.storagePlugin(store), jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, newLifecycleStopper())) + e.fixtureStore(store) + e.fixtureJobs(newLifecycleStopper()) + r, _, err := e.openSwept(context.Background()) testassert.NoError(t, err) return &codeRuntime{t: t, store: store, r: r, e: e} } @@ -648,7 +649,7 @@ func seedFIFOCodeEntry(t *testing.T, groupRoot, target, content string) string { "existed": true, "last_write": map[string]any{"hash": sha256Hex([]byte(content))}, } - writeRetainedJSON(t, filepath.Join(entryDir, "meta.json"), meta) + writeTestJSON(t, filepath.Join(entryDir, "meta.json"), meta) return fifo } @@ -925,395 +926,150 @@ func TestCodeRestoreIntervalKeepsMetadataTransitionsAvailable(t *testing.T) { // --- retained pre-cutover rows --- const ( - retainedConversationCanary = "conversation-must-stay-untouched" - retainedClaimCanary = "claim-must-stay-untouched" + legacyConversationCanary = "conversation-must-stay-untouched" + legacyClaimCanary = "claim-must-stay-untouched" ) -// seedRetainedSession writes one valid legacy session meta plus conversation -// and claim canaries, returning the session directory. -func seedRetainedSession(t *testing.T, home, workspace, legacyID string) string { +// seedLegacySessionFixture writes one pre-cutover home-layout Session +// directory: the old meta.json proof members, the old conversation and claim +// canaries, and old turn-shaped snapshot entry dirs at snapshots/1 and +// snapshots/2 holding a real preimage meta. Nothing canonical reads this +// layout; the fixture only ever serves as inert sentinel data. +func seedLegacySessionFixture(t *testing.T, home, workspace, legacyID string) string { t.Helper() normalized := filepath.Clean(workspace) sum := sha256.Sum256([]byte(normalized)) hash := hex.EncodeToString(sum[:]) dir := filepath.Join(home, ".lightcode", "projects", "p-"+hash, "sessions", legacyID) - // The fixture seeds the three proof fields and ordinary extra metadata; - // the reader interprets only the proof fields. - writeRetainedJSON(t, filepath.Join(dir, "meta.json"), map[string]any{ + writeTestJSON(t, filepath.Join(dir, "meta.json"), map[string]any{ "id": legacyID, "created_at": "2026-01-01T00:00:00Z", "project_path": normalized, "project_hash": hash[:16], "state": "active", }) - writeServiceFile(t, filepath.Join(dir, "turns", "1", "messages.jsonl"), retainedConversationCanary) - writeServiceFile(t, filepath.Join(dir, "claims", "claim.json"), retainedClaimCanary) + writeServiceFile(t, filepath.Join(dir, "turns", "1", "messages.jsonl"), legacyConversationCanary) + writeServiceFile(t, filepath.Join(dir, "claims", "claim.json"), legacyClaimCanary) + for _, turn := range []string{"1", "2"} { + file := filepath.Join(workspace, "legacy.txt") + entryID := sha256Hex16(file) + entryDir := filepath.Join(dir, "snapshots", turn, entryID) + testassert.NoError(t, os.MkdirAll(entryDir, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(entryDir, "original"), []byte("legacy-preimage-"+turn), 0o600)) + writeTestJSON(t, filepath.Join(entryDir, "meta.json"), map[string]any{ + "original_path": file, + "canonical_path": file, + "existed": true, + "last_write": map[string]any{"hash": sha256Hex([]byte("legacy-" + turn))}, + }) + } return dir } -func writeRetainedJSON(t *testing.T, path string, value any) { +func writeTestJSON(t *testing.T, path string, value any) { t.Helper() data, err := json.Marshal(value) testassert.Require(t, err == nil, "marshal %s: %v", path, err) writeServiceFile(t, path, string(data)) } -func rewriteRetainedMeta(t *testing.T, sessionDir string, edit func(map[string]any)) { - t.Helper() - path := filepath.Join(sessionDir, "meta.json") - data, err := os.ReadFile(path) - testassert.NoError(t, err) - var meta map[string]any - testassert.NoError(t, json.Unmarshal(data, &meta)) - edit(meta) - writeRetainedJSON(t, path, meta) -} - -// recordRetainedMutation drives one real CodeStore capture at the given turn -// of a pruned legacy session directory. -func recordRetainedMutation(t *testing.T, sessionDir string, turn int, display, target string, content []byte) { - t.Helper() - store, err := snapshot.OpenCodeStore(sessionDir) - testassert.Require(t, err == nil, "OpenCodeStore(%s): %v", sessionDir, err) - entryID, _, err := store.SnapshotResolvedEntry(turn, display, target) - testassert.Require(t, err == nil, "SnapshotResolvedEntry turn %d: %v", turn, err) - { - err := os.WriteFile(target, content, 0o600) - testassert.Require(t, err == nil, "WriteFile(%s): %v", target, err) - } - { - err := store.RecordSnapshotContent(turn, entryID, content) - testassert.Require(t, err == nil, "RecordSnapshotContent turn %d: %v", turn, err) - } - store.RetainSnapshotEntry(turn, entryID) -} - -// assertCanariesUntouched verifies the hazardous conversation and claim files -// stayed byte-identical. -func assertCanariesUntouched(t *testing.T, sessionDir string) { +// snapshotTree walks one directory tree into a sorted path->content map, so a +// sentinel comparison can prove nothing was read into a change or written. +func snapshotTree(t *testing.T, root string) map[string]string { t.Helper() - for path, want := range map[string]string{ - filepath.Join(sessionDir, "turns", "1", "messages.jsonl"): retainedConversationCanary, - filepath.Join(sessionDir, "claims", "claim.json"): retainedClaimCanary, - } { - data, err := os.ReadFile(path) - testassert.Require(t, err == nil && string(data) == want, "canary %s = (%q, %v), want untouched %q", path, data, err, want) - } -} - -// TestRetainedCodeSnapshotsProof proves the retained path proof and its -// refusals: exact 8-hex IDs, normalized workspace, full-hash project -// directory, and the meta ID/ProjectPath/hash-prefix triple; every mismatch -// fails before any snapshot read, while a valid proof with absent snapshots is -// empty success. -func TestRetainedCodeSnapshotsProof(t *testing.T) { - eachPrepStore(t, func(t *testing.T, store harness.Storage) { - r, e := openProjectionRuntime(t, store) - defer closeProjectionRuntime(r) - workspace := filepath.Join(e.home, "legacy-ws") - testassert.NoError(t, os.MkdirAll(workspace, 0o700)) - legacyID := "deadbeef" - home := r.config.loader.Home() - sessionDir := seedRetainedSession(t, home, workspace, legacyID) - - turns, err := r.listRetainedCodeSnapshots(context.Background(), workspace, legacyID) - testassert.Require(t, err == nil && turns.Turns != nil && len(turns.Turns) == 0, "empty retained list = (%+v, %v), want a non-nil empty list", turns, err) - result, err := r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID, AfterTurn: 3}) - testassert.Require(t, err == nil && result.Restored != nil && result.Skipped != nil && len(result.Restored) == 0 && len(result.Skipped) == 0, "empty retained revert = (%+v, %v), want non-nil empty success", result, err) - - // The workspace proof is lexical: a non-normalized spelling of the - // same root normalizes to the same storage key and meta proof, while - // a symlinked spelling is a different key and is never resolved. - { - turns, err := r.listRetainedCodeSnapshots(context.Background(), workspace+string(filepath.Separator)+".", legacyID) - testassert.Require(t, err == nil && len(turns.Turns) == 0, "non-normalized workspace spelling = (%+v, %v), want the proven empty success", turns, err) - } - symlink := filepath.Join(e.home, "legacy-ws-link") - testassert.NoError(t, os.Symlink(workspace, symlink)) - { - _, err := r.listRetainedCodeSnapshots(context.Background(), symlink, legacyID) - testassert.Require(t, errors.Is(err, harness.ErrNotFound), "symlinked workspace spelling = %v, want harness.ErrNotFound (lexical proof only)", err) - } - - { - _, err := r.listRetainedCodeSnapshots(context.Background(), "", legacyID) - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "empty workspace = %v, want harness.ErrInvalid", err) - } - for _, id := range []string{"deadbee", "deadbeef0", "DEADBEEF", "deadbeez"} { - { - _, err := r.listRetainedCodeSnapshots(context.Background(), workspace, id) - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "legacy id %q = %v, want harness.ErrInvalid", id, err) - } - } - { - _, err := r.listRetainedCodeSnapshots(context.Background(), workspace, "0123456789abcdef0123456789abcdef") - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "32-hex target id into retained route = %v, want harness.ErrInvalid", err) - } - { - _, err := r.listRetainedCodeSnapshots(context.Background(), workspace, "abcdef01") - testassert.Require(t, errors.Is(err, harness.ErrNotFound), "missing legacy session = %v, want harness.ErrNotFound", err) - } - { - _, err := r.listSessionCodeSnapshots(context.Background(), legacyID) - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "8-hex id into target route = %v, want harness.ErrInvalid", err) - } - - for _, tc := range []struct { - name string - edit func(map[string]any) - }{ - {"wrong id", func(m map[string]any) { m["id"] = "deadbeee" }}, - {"wrong project path", func(m map[string]any) { m["project_path"] = filepath.Join(workspace, "other") }}, - {"wrong hash prefix", func(m map[string]any) { m["project_hash"] = "0000000000000000" }}, - } { - metaPath := filepath.Join(sessionDir, "meta.json") - original, err := os.ReadFile(metaPath) - testassert.NoError(t, err) - rewriteRetainedMeta(t, sessionDir, tc.edit) - { - _, err := r.listRetainedCodeSnapshots(context.Background(), workspace, legacyID) - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "%s = %v, want harness.ErrInvalid", tc.name, err) - } - { - _, err := r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID}) - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "%s revert = %v, want harness.ErrInvalid", tc.name, err) - } - writeServiceFile(t, metaPath, string(original)) - } - { - _, err := os.Stat(sessionDir) - testassert.NoError(t, err) - } - assertCanariesUntouched(t, sessionDir) - - // A large ignored legacy field is still one valid JSON meta document: - // the proof reads the complete regular file and ignores unknown - // members, so list and revert succeed with empty snapshots. The - // malformed sibling stays refused. - metaPath := filepath.Join(sessionDir, "meta.json") - original, err := os.ReadFile(metaPath) - testassert.NoError(t, err) - normalized := filepath.Clean(workspace) - sum := sha256.Sum256([]byte(normalized)) - hash := hex.EncodeToString(sum[:]) - large, err := json.Marshal(map[string]any{ - "id": legacyID, - "created_at": "2026-01-01T00:00:00Z", - "project_path": normalized, - "project_hash": hash[:16], - "state": "active", - "legacy_ignored_field": strings.Repeat("x", (1<<20)+1), - }) - testassert.NoError(t, err) - writeServiceFile(t, metaPath, string(large)) - { - turns, err := r.listRetainedCodeSnapshots(context.Background(), workspace, legacyID) - testassert.Require(t, err == nil && len(turns.Turns) == 0, "large ignored meta field list = (%+v, %v), want the proven empty success", turns, err) - } - { - result, err := r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID}) - testassert.Require(t, err == nil && len(result.Restored) == 0, "large ignored meta field revert = (%+v, %v), want the proven empty success", result, err) - } - writeServiceFile(t, metaPath, "{ not a json document") - { - _, err := r.listRetainedCodeSnapshots(context.Background(), workspace, legacyID) - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "malformed meta list = %v, want harness.ErrInvalid", err) + tree := map[string]string{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err } - { - _, err := r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID}) - testassert.Require(t, errors.Is(err, harness.ErrInvalid), "malformed meta revert = %v, want harness.ErrInvalid", err) + if info.IsDir() { + tree[path] = "" + return nil } - writeServiceFile(t, metaPath, string(original)) - - // The full-hash directory is exact: a same-ID session under a wrong - // project directory is not found. - wrongRoot := filepath.Join(e.home, "other-ws") - { - _, err := r.listRetainedCodeSnapshots(context.Background(), wrongRoot, legacyID) - testassert.Require(t, errors.Is(err, harness.ErrNotFound), "wrong workspace root = %v, want harness.ErrNotFound", err) + data, err := os.ReadFile(path) + if err != nil { + return err } + tree[path] = string(data) + return nil }) + testassert.Require(t, err == nil, "walk %s: %v", root, err) + return tree } -// TestRetainedCodeSnapshotsListAndRevert proves the retained list and restore -// over real CodeStore captures: turn order and display paths, after_turn used -// directly, created-file absence, skip-on-identity, and the untouched -// conversation/claim canaries. -func TestRetainedCodeSnapshotsListAndRevert(t *testing.T) { +// TestCodeSnapshotsLegacySessionDataStaysInert proves the old home layout is +// inert sentinel data for canonical operations: a canonical Session's list +// and restore over its own groups never read or change the pre-cutover +// Session directory, its snapshot entries, or its conversation and claim +// canaries — and the 8-hex legacy ID is refused on the canonical route, so a +// canonical operation can never address the legacy target. +func TestCodeSnapshotsLegacySessionDataStaysInert(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { r, e := openProjectionRuntime(t, store) defer closeProjectionRuntime(r) - workspace := filepath.Join(e.home, "legacy-list-ws") + workspace := filepath.Join(e.home, "legacy-inert-ws") testassert.NoError(t, os.MkdirAll(workspace, 0o700)) - legacyID := "cafebabe" - home := r.config.loader.Home() - sessionDir := seedRetainedSession(t, home, workspace, legacyID) + legacyID := "deadbeef" + legacyDir := seedLegacySessionFixture(t, r.config.loader.Home(), workspace, legacyID) + before := snapshotTree(t, legacyDir) + testassert.Require(t, len(before) > 4, "legacy sentinel tree = %v, want meta, canaries, and snapshot entries", before) + // Canonical operations over the same workspace. + session := projectionSession(t, r, workspace, "solo").Identity.SessionID + op := submitCodeOperation(t, r, session, "op-1", "one") file := filepath.Join(workspace, "legacy.txt") - testassert.NoError(t, os.WriteFile(file, []byte("a0"), 0o600)) - recordRetainedMutation(t, sessionDir, 1, file, file, []byte("a1")) - created := filepath.Join(workspace, "legacy-created.txt") - recordRetainedMutation(t, sessionDir, 2, file, file, []byte("a2")) - recordRetainedMutation(t, sessionDir, 2, created, created, []byte("c")) - - turns, err := r.listRetainedCodeSnapshots(context.Background(), workspace, legacyID) - testassert.NoError(t, err) - testassert.Require(t, len(turns.Turns) == 2 && turns.Turns[0].Turn == 1 && turns.Turns[1].Turn == 2, "turns = %+v, want the retained order 1, 2", turns.Turns) - testassert.Require(t, len(turns.Turns[0].Files) == 1 && turns.Turns[0].Files[0] == (protocol.SnapshotFile{Path: file, Existed: true}), "turn 1 files = %+v, want the edited display path", turns.Turns[0].Files) - testassert.Require(t, len(turns.Turns[1].Files) == 2, "turn 2 files = %+v, want the edited and created entries", turns.Turns[1].Files) - assertCanariesUntouched(t, sessionDir) - - result, err := r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID, AfterTurn: 1}) - testassert.NoError(t, err) - testassert.Require(t, len(result.Restored) == 2 && result.Skipped != nil && len(result.Skipped) == 0, "turn 2 result = %+v, want both files restored without skips", result) + testassert.NoError(t, os.WriteFile(file, []byte("canonical-v1"), 0o600)) + captureCodeMutation(t, codeGroupRoot(r, session, op.Admission.AdmittedEntry.EntryID), file, file, []byte("canonical-v2")) + awaitRestorableSession(t, r, session) + groups, err := r.listSessionCodeSnapshots(context.Background(), session) + testassert.Require(t, err == nil && len(groups) == 1 && groups[0].OperationId == "op-1", "canonical groups = (%+v, %v), want only the canonical group", groups, err) + result, err := r.revertSessionCode(context.Background(), session, "op-1") + testassert.Require(t, err == nil && len(result.Restored) == 1 && result.Restored[0] == file, "canonical restore = (%+v, %v), want the canonical file", result, err) { data, err := os.ReadFile(file) - testassert.Require(t, err == nil && string(data) == "a1", "legacy file after turn 2 revert = (%q, %v), want the turn 1 post-write", data, err) - } - { - _, err := os.Stat(created) - testassert.Require(t, errors.Is(err, os.ErrNotExist), "created file stat = %v, want restored absence", err) + testassert.Require(t, err == nil && string(data) == "canonical-v1", "canonical file = (%q, %v), want the canonical preimage", data, err) } - result, err = r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID}) - testassert.NoError(t, err) - testassert.Require(t, len(result.Restored) == 1 && result.Restored[0] == file, "turn 1 result = %+v, want the edited file restored", result) - { - data, err := os.ReadFile(file) - testassert.Require(t, err == nil && string(data) == "a0", "legacy file after turn 1 revert = (%q, %v), want the turn 1 preimage", data, err) - } - assertCanariesUntouched(t, sessionDir) + // The whole legacy tree — meta.json, conversation and claim canaries, + // and every snapshot entry — is byte-identical to the seeded tree. + after := snapshotTree(t, legacyDir) + testassert.Require(t, reflect.DeepEqual(before, after), "the canonical operations changed the legacy tree") - // A re-recorded turn 1 with an external edit skips instead of - // overwriting, still reporting the retained display path. - recordRetainedMutation(t, sessionDir, 1, file, file, []byte("a1")) - testassert.NoError(t, os.WriteFile(file, []byte("external"), 0o600)) - result, err = r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID}) - testassert.NoError(t, err) - testassert.Require(t, len(result.Restored) == 0 && len(result.Skipped) == 1 && result.Skipped[0].Path == file && strings.Contains(result.Skipped[0].Reason, "content changed"), "skip result = %+v, want the changed display path skipped", result) + // Identity boundary: the 8-hex legacy ID never addresses a canonical + // target route. { - data, err := os.ReadFile(file) - testassert.Require(t, err == nil && string(data) == "external", "legacy file after skip = (%q, %v), want the external content", data, err) + _, err := r.listSessionCodeSnapshots(context.Background(), legacyID) + testassert.Require(t, errors.Is(err, harness.ErrInvalid), "8-hex legacy id into the canonical list route = %v, want harness.ErrInvalid", err) } - }) -} - -// TestRetainedCodeSnapshotsPartialTraversal proves a later-visited corrupt -// turn returns the accumulated earlier turn result with the internal error -// class and no legacy 8-hex ID in the generated target Session field. -func TestRetainedCodeSnapshotsPartialTraversal(t *testing.T) { - eachPrepStore(t, func(t *testing.T, store harness.Storage) { - r, e := openProjectionRuntime(t, store) - defer closeProjectionRuntime(r) - workspace := filepath.Join(e.home, "legacy-partial-ws") - testassert.NoError(t, os.MkdirAll(workspace, 0o700)) - legacyID := "feedface" - home := r.config.loader.Home() - sessionDir := seedRetainedSession(t, home, workspace, legacyID) - - file := filepath.Join(workspace, "partial.txt") - testassert.NoError(t, os.WriteFile(file, []byte("p0"), 0o600)) - recordRetainedMutation(t, sessionDir, 1, file, file, []byte("p1")) - recordRetainedMutation(t, sessionDir, 2, file, file, []byte("p2")) - - // Corrupt turn 1's meta after recording: turn 2 restores first, then - // turn 1 errors. - turnDir := filepath.Join(sessionDir, "snapshots", "1") - entries, err := os.ReadDir(turnDir) - testassert.Require(t, err == nil && len(entries) == 1, "turn 1 dirs = (%d, %v), want one entry", len(entries), err) - testassert.NoError(t, os.WriteFile(filepath.Join(turnDir, entries[0].Name(), "meta.json"), []byte("{"), 0o600)) - - result, err := r.revertRetainedCode(context.Background(), protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID}) - testassert.Require(t, err != nil, "partial revert = %+v, want the traversal error", result) - testassert.Require(t, len(result.Restored) == 1 && result.Restored[0] == file && result.Skipped != nil && result.Error != nil && result.Error.Code == protocol.Internal && result.Error.Message != "" && result.Error.SessionId == nil, "partial result = %+v, want the later turn's accumulated restore; partial error = %+v, want the internal class with no target Session id", result, result.Error) { - data, err := os.ReadFile(file) - testassert.Require(t, err == nil && string(data) == "p1", "partial file = (%q, %v), want the completed later-turn restore", data, err) + _, err := r.revertSessionCode(context.Background(), legacyID, "op-1") + testassert.Require(t, errors.Is(err, harness.ErrInvalid), "8-hex legacy id into the canonical restore route = %v, want harness.ErrInvalid", err) } - assertCanariesUntouched(t, sessionDir) }) } -// TestRetainedMountedRestoreConflict proves the retained restore interval -// over the mounted generated client: one parked retained restore holds its -// directory, a concurrent mounted retained restore answers the typed -// conflict, and the parked restore completes with its FIFO preimage while -// the legacy canaries stay untouched. -func TestRetainedMountedRestoreConflict(t *testing.T) { - eachPrepStore(t, func(t *testing.T, store harness.Storage) { - r, e := openProjectionRuntime(t, store) - defer closeProjectionRuntime(r) - ps := openProtocolServer(t, r) - client := protocolClient(t, ps) - ctx := context.Background() - - workspace := filepath.Join(e.home, "retained-mounted-ws") - testassert.NoError(t, os.MkdirAll(workspace, 0o700)) - legacyID := "c0ffee12" - sessionDir := seedRetainedSession(t, r.config.loader.Home(), workspace, legacyID) - file := filepath.Join(workspace, "retained-mounted.txt") - testassert.NoError(t, os.WriteFile(file, []byte("v1"), 0o600)) - // The turn-1 entry's saved original is a FIFO whose last-write hash - // matches the file's current content, so the retained restore - // reaches restoreFile's plain os.Open and parks on the FIFO. - fifo := seedFIFOCodeEntry(t, sessionDir, file, "v1") - - // One result shape carries both mounted restore attempts — the - // parked first restore and the concurrent second restore. - type mountedRetainedResult struct { - response *protocol.RevertRetainedCodeResponse - err error - } - request := func() mountedRetainedResult { - response, err := client.RevertRetainedCodeWithResponse(ctx, protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID}) - return mountedRetainedResult{response: response, err: err} - } - - // The mounted retained restore parks inside its traversal: its HTTP - // handler is mid-restore while the client waits. - parked := make(chan mountedRetainedResult, 1) - go func() { parked <- request() }() - // The shared FIFO park returns only once the mounted restore's - // reader is parked in restoreFile's os.Open. - park := parkFIFO(t, fifo) - defer park.flush() - - // A concurrent mounted retained restore answers the typed conflict: - // the refusal happens before any traversal, so the bounded wait - // never competes with the parked FIFO. - secondCh := make(chan mountedRetainedResult, 1) - go func() { secondCh <- request() }() - var second mountedRetainedResult - select { - case second = <-secondCh: - case <-time.After(10 * time.Second): - t.Fatal("the second mounted retained restore never answered, want the typed conflict") - } - testassert.Require(t, second.err == nil, "second mounted retained restore: %v, want the typed conflict 409", second.err) - testassert.Require(t, second.response != nil, "second mounted retained restore = , want the typed conflict 409") - if second.response.JSONDefault == nil || second.response.HTTPResponse.StatusCode != http.StatusConflict { - body, _ := json.Marshal(second.response) - t.Fatalf("second mounted retained restore = (%d, %s), want the typed conflict 409", second.response.HTTPResponse.StatusCode, body) - } - conflictError, err := second.response.JSONDefault.AsError() - testassert.Require(t, err == nil && conflictError.Code == protocol.Conflict, "second retained restore error = %+v (%v), want the typed conflict", second.response.JSONDefault, err) - - // The parked mounted retained restore completes with its own result. - park.release("mounted-restored") - var out mountedRetainedResult - select { - case out = <-parked: - case <-time.After(10 * time.Second): - t.Fatal("the parked mounted retained restore never returned after its FIFO completed") - } - testassert.Require(t, out.err == nil && out.response != nil && out.response.JSON200 != nil, "mounted retained restore: %v, want the restored result; mounted retained restore = %+v, want the restored result", out.err, out.response) - testassert.Require(t, len(out.response.JSON200.Restored) == 1 && out.response.JSON200.Restored[0] == file && len(out.response.JSON200.Skipped) == 0, "restored = %+v, want exactly the retained display path", out.response.JSON200) - { - data, err := os.ReadFile(file) - testassert.Require(t, err == nil && string(data) == "mounted-restored", "file = (%q, %v), want the FIFO content", data, err) - } - assertCanariesUntouched(t, sessionDir) - }) +// TestCodeGroupsOwnershipTable proves the group derivation is exactly one +// rule over the captured headers — an admitted input owned by an Operation +// derives a group regardless of its origin, and everything else does not: +// an operationless copied input and every non-input kind are excluded, and +// the headers' own sequence order is preserved. +func TestCodeGroupsOwnershipTable(t *testing.T) { + headers := []harness.HistoryEntryHeader{ + {EntryID: "entry-user", OperationID: "op-user", Kind: harness.EntryInput, InputOrigin: harness.InputOriginUser}, + {EntryID: "entry-assistant", OperationID: "op-user", Kind: harness.EntryAssistant}, + {EntryID: "entry-tool", OperationID: "op-user", Kind: harness.EntryToolResult}, + {EntryID: "entry-copied", OperationID: "", Kind: harness.EntryInput, InputOrigin: harness.InputOriginUser}, + {EntryID: "entry-runtime", OperationID: "op-runtime", Kind: harness.EntryInput, InputOrigin: harness.InputOriginRuntime}, + {EntryID: "entry-settlement", OperationID: "op-runtime", Kind: harness.EntryOperationSettlement}, + {EntryID: "entry-plugin", OperationID: "op-plugin", Kind: harness.EntryInput, InputOrigin: harness.InputOriginPlugin}, + } + groups := codeGroups(headers) + want := []codeGroup{ + {operationID: "op-user", entryID: "entry-user"}, + {operationID: "op-runtime", entryID: "entry-runtime"}, + {operationID: "op-plugin", entryID: "entry-plugin"}, + } + testassert.Require(t, reflect.DeepEqual(groups, want), "codeGroups = %+v, want the three owned inputs in header order (copied and non-input kinds excluded)", groups) } // --- bounded viewer rows --- @@ -1511,14 +1267,6 @@ func TestCodeProducersClosedOwner(t *testing.T) { _, err := r.revertSessionCode(ctx, sessionID, "op") testassert.Require(t, errors.Is(err, ErrClosed), "closed revert = %v, want ErrClosed", err) } - { - _, err := r.listRetainedCodeSnapshots(ctx, workspace, "deadbeef") - testassert.Require(t, errors.Is(err, ErrClosed), "closed retained list = %v, want ErrClosed", err) - } - { - _, err := r.revertRetainedCode(ctx, protocol.RetainedRevertRequest{Workspace: workspace, SessionId: "deadbeef"}) - testassert.Require(t, errors.Is(err, ErrClosed), "closed retained revert = %v, want ErrClosed", err) - } { _, err := r.readWorkspaceFile(ctx, protocol.ReadFileRequest{Workspace: workspace, Path: "x"}) testassert.Require(t, errors.Is(err, ErrClosed), "closed viewer = %v, want ErrClosed", err) @@ -1526,8 +1274,8 @@ func TestCodeProducersClosedOwner(t *testing.T) { }) } -// sha256Hex and sha256Hex16 mirror the retained snapshot identity encodings -// for hand-built fixtures only. +// sha256Hex and sha256Hex16 mirror the snapshot identity encodings for +// hand-built fixtures only. func sha256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) @@ -1633,7 +1381,9 @@ func TestBackgroundWakeOpenerWaitsForRestoreInterval(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, agents.PathForConfig(e.configPath), projectionAgentsDocument) stopper := &stubStopper{events: e.events} - r, err := e.open(context.Background(), e.storagePlugin(store), jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper)) + e.fixtureStore(store) + e.fixtureJobs(stopper) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer closeProjectionRuntime(r) workspace := filepath.Join(e.home, "wake-ws") @@ -1713,7 +1463,8 @@ func TestExecutionOpenerFailureReleasesInterval(t *testing.T) { e := newOwnerEnv(t) var fail atomic.Bool fail.Store(true) - r, err := e.open(context.Background(), e.storagePlugin(store), failingAgentFactoryPlugin(&fail)) + e.fixtureStore(store) + r, err := e.open(context.Background(), failingAgentFactoryPlugin(&fail)) testassert.NoError(t, err) defer closeProjectionRuntime(r) workspace := filepath.Join(e.home, "openerr-ws") @@ -1766,7 +1517,8 @@ func TestExecutionCleanupReleasesIntervalLast(t *testing.T) { e := newOwnerEnv(t) closeGate := make(chan struct{}) closeStarted := make(chan struct{}, 1) - r, err := e.open(context.Background(), e.storagePlugin(store), parkingAgentClosePlugin(closeStarted, closeGate)) + e.fixtureStore(store) + r, err := e.open(context.Background(), parkingAgentClosePlugin(closeStarted, closeGate)) testassert.NoError(t, err) defer closeProjectionRuntime(r) workspace := filepath.Join(e.home, "cleanup-ws") diff --git a/runtime/protocol_composed_test.go b/runtime/protocol_composed_test.go index d9f45bfb..2ed4f5a5 100644 --- a/runtime/protocol_composed_test.go +++ b/runtime/protocol_composed_test.go @@ -1,8 +1,8 @@ package runtime_test // The phase-composed mounted flow: one real production composition (the -// shipped tools, jobs, tasks, adaptation, and LSP plugins over memory or the -// shipped SQLite set), one mounted authenticated protocol server, one real +// shipped adaptation and tasks plugins over the directly constructed SQLite +// baseline), one mounted authenticated protocol server, one real // discovery record, the generated Go client, and one authenticated SSE // stream. The scenario drives the complete create → model/tool/child/Job → // hydration/events → older pages → archive/reopen/delete path, provider @@ -30,13 +30,9 @@ import ( "github.com/MMinasyan/lightcode/harness" "github.com/MMinasyan/lightcode/internal/agents" "github.com/MMinasyan/lightcode/internal/plugins/adaptation" - "github.com/MMinasyan/lightcode/internal/plugins/lsp" - "github.com/MMinasyan/lightcode/internal/plugins/sqlite" "github.com/MMinasyan/lightcode/internal/plugins/tasks" - "github.com/MMinasyan/lightcode/internal/plugins/tools" "github.com/MMinasyan/lightcode/internal/storage" "github.com/MMinasyan/lightcode/internal/testassert" - "github.com/MMinasyan/lightcode/plugins/jobs" "github.com/MMinasyan/lightcode/protocol" "github.com/MMinasyan/lightcode/runtime" ) @@ -68,20 +64,6 @@ const composedFlowAgentsDocument = `{ "childtype": {"model": "prov/m", "system_prompt": "simple"} }` -// composedMemoryStorage is the memory-store composition unit of the flow: -// the same declared export the shipped SQLite plugin provides, over the -// in-memory store the test retains. -func composedMemoryStorage(store harness.Storage) runtime.Plugin { - return runtime.Plugin{ - ID: "memstore", - Scope: runtime.ScopeRuntime, - Provides: []runtime.CapabilitySpec{runtime.Spec[harness.Storage]("session_store")}, - Open: func(context.Context, runtime.ScopeInfo, runtime.Bindings) (runtime.Instance, error) { - return runtime.Instance{Values: map[string]any{"session_store": store}}, nil - }, - } -} - // composedCustomPluginID is the flow's selected custom plugin: an arbitrary // registered ID whose own settings document and Runtime-scoped warning the // mounted generated client must carry unchanged. @@ -545,7 +527,7 @@ func TestProtocolComposedFlow(t *testing.T) { // endpoint is no usable owner. foreign := filepath.Join(t.TempDir(), "foreign.json") wrongCredential := strings.Repeat("0", 64) - composedWriteFile(t, foreign, fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":%q,"credential":%q}`, ps.Endpoint(), c.InstanceID(), protocol.N6, wrongCredential)) + composedWriteFile(t, foreign, fmt.Sprintf(`{"endpoint":%q,"instance_id":%q,"protocol_version":%q,"credential":%q}`, ps.Endpoint(), c.InstanceID(), protocol.N8, wrongCredential)) { _, err := client.Connect(ctx, foreign) testassert.Require(t, errors.Is(err, client.ErrNoRuntime), "wrong-credential connect = %v, want ErrNoRuntime", err) @@ -828,17 +810,16 @@ func TestProtocolComposedFlow(t *testing.T) { testassert.Require(t, foreignCursor.JSONDefault != nil && foreignCursor.JSONDefault.Code == protocol.Invalid, "cross-session cursor response = %+v (status %d), want the typed invalid refusal", foreignCursor.JSONDefault, foreignCursor.HTTPResponse.StatusCode) // 7. Settings: the stale whole-section save succeeds under - // last-writer-wins; each compiled plugin's own document string - // round-trips through the mounted generated client — the shipped - // tools document and the selected custom plugin's own opaque - // document with its exact integer lexeme; the invalid candidate - // never publishes. + // last-writer-wins; the top-level tools object and each compiled + // plugin's own document string round-trip through the mounted + // generated client — including the selected custom plugin's own + // opaque document with its exact integer lexeme; the invalid + // candidate never publishes. settingsADocument := `{"command_timeout":60,"max_output_bytes":2048,"read_line_max_chars":3000,"read_max_lines":100,"opaque":9007199254740993}` - settingsA := protocol.Settings{Sessions: protocol.SessionsSettings{AutoArchive: true, ArchiveAfterDays: 3, DeleteAfterArchiveDays: 0}, Plugins: protocol.PluginsSettings{ - "tools": settingsADocument, + settingsA := protocol.Settings{Sessions: protocol.SessionsSettings{AutoArchive: true, ArchiveAfterDays: 3, DeleteAfterArchiveDays: 0}, Tools: json.RawMessage(settingsADocument), Jobs: json.RawMessage(`{}`), Compaction: json.RawMessage(`{}`), Plugins: protocol.PluginsSettings{ composedCustomPluginID: composedCustomSettingsDocument, }} - settingsB := protocol.Settings{Sessions: protocol.SessionsSettings{AutoArchive: false, ArchiveAfterDays: 9, DeleteAfterArchiveDays: 5}, Plugins: protocol.PluginsSettings{}} + settingsB := protocol.Settings{Sessions: protocol.SessionsSettings{AutoArchive: false, ArchiveAfterDays: 9, DeleteAfterArchiveDays: 5}, Tools: json.RawMessage(`{}`), Jobs: json.RawMessage(`{}`), Compaction: json.RawMessage(`{}`), Plugins: protocol.PluginsSettings{}} put := func(settings protocol.Settings) *protocol.UpdateConfigurationSettingsResponse { t.Helper() resp, err := c.UpdateConfigurationSettingsWithResponse(ctx, protocol.UpdateSettingsRequest{Settings: settings}) @@ -846,26 +827,26 @@ func TestProtocolComposedFlow(t *testing.T) { return resp } firstPut := put(settingsA) - testassert.Require(t, firstPut.JSON200 != nil && firstPut.JSON200.Result.Sessions.AutoArchive && firstPut.JSON200.Result.Sessions.ArchiveAfterDays == 3 && compactComposed(t, []byte(firstPut.JSON200.Result.Plugins["tools"])) == compactComposed(t, []byte(settingsADocument)) && compactComposed(t, []byte(firstPut.JSON200.Result.Plugins[composedCustomPluginID])) == compactComposed(t, []byte(composedCustomSettingsDocument)), "settings write A = %+v, want the published A section with every plugin's exact document", firstPut.JSON200) + testassert.Require(t, firstPut.JSON200 != nil && firstPut.JSON200.Result.Sessions.AutoArchive && firstPut.JSON200.Result.Sessions.ArchiveAfterDays == 3 && compactComposed(t, firstPut.JSON200.Result.Tools) == compactComposed(t, []byte(settingsADocument)) && compactComposed(t, []byte(firstPut.JSON200.Result.Plugins[composedCustomPluginID])) == compactComposed(t, []byte(composedCustomSettingsDocument)), "settings write A = %+v (status %d, body %s), want the published A section with every plugin's exact document", firstPut.JSON200, firstPut.HTTPResponse.StatusCode, firstPut.Body) secondPut := put(settingsB) testassert.Require(t, secondPut.JSON200 != nil && !secondPut.JSON200.Result.Sessions.AutoArchive && len(secondPut.JSON200.Result.Plugins) == 0, "settings write B = %+v, want the published B section with every plugin section removed", secondPut.JSON200) stalePut := put(settingsA) - testassert.Require(t, stalePut.JSON200 != nil && stalePut.JSON200.Result.Sessions.AutoArchive && stalePut.JSON200.Result.Sessions.ArchiveAfterDays == 3 && stalePut.JSON200.Result.Sessions.DeleteAfterArchiveDays == 0 && compactComposed(t, []byte(stalePut.JSON200.Result.Plugins["tools"])) == compactComposed(t, []byte(settingsADocument)) && compactComposed(t, []byte(stalePut.JSON200.Result.Plugins[composedCustomPluginID])) == compactComposed(t, []byte(composedCustomSettingsDocument)), "stale whole-section save = %+v, want the wholesale replacement back to A", stalePut.JSON200) + testassert.Require(t, stalePut.JSON200 != nil && stalePut.JSON200.Result.Sessions.AutoArchive && stalePut.JSON200.Result.Sessions.ArchiveAfterDays == 3 && stalePut.JSON200.Result.Sessions.DeleteAfterArchiveDays == 0 && compactComposed(t, stalePut.JSON200.Result.Tools) == compactComposed(t, []byte(settingsADocument)) && compactComposed(t, []byte(stalePut.JSON200.Result.Plugins[composedCustomPluginID])) == compactComposed(t, []byte(composedCustomSettingsDocument)), "stale whole-section save = %+v, want the wholesale replacement back to A", stalePut.JSON200) publishedGeneration := stalePut.JSON200.ConfigurationRevision.Generation - invalid := protocol.Settings{Sessions: settingsA.Sessions, Plugins: protocol.PluginsSettings{"tasks": `{"max_concurrent":99,"max_output_bytes":4096}`}} + invalid := protocol.Settings{Sessions: settingsA.Sessions, Tools: settingsA.Tools, Jobs: settingsA.Jobs, Compaction: settingsA.Compaction, Plugins: protocol.PluginsSettings{"tasks": `{"max_concurrent":99,"max_output_bytes":4096}`}} refused, err := c.UpdateConfigurationSettingsWithResponse(ctx, protocol.UpdateSettingsRequest{Settings: invalid}) testassert.NoError(t, err) testassert.Require(t, refused.JSONDefault != nil && refused.JSONDefault.Code == protocol.Configuration, "invalid settings response = %+v (status %d), want the typed configuration refusal", refused.JSONDefault, refused.HTTPResponse.StatusCode) // The selected custom plugin's own validator refuses its invalid // document with the same configuration class. - customInvalid := protocol.Settings{Sessions: settingsA.Sessions, Plugins: protocol.PluginsSettings{composedCustomPluginID: `{"threshold":0}`}} + customInvalid := protocol.Settings{Sessions: settingsA.Sessions, Tools: settingsA.Tools, Jobs: settingsA.Jobs, Compaction: settingsA.Compaction, Plugins: protocol.PluginsSettings{composedCustomPluginID: `{"threshold":0}`}} customRefused, err := c.UpdateConfigurationSettingsWithResponse(ctx, protocol.UpdateSettingsRequest{Settings: customInvalid}) testassert.NoError(t, err) testassert.Require(t, customRefused.JSONDefault != nil && customRefused.JSONDefault.Code == protocol.Configuration, "invalid custom settings response = %+v (status %d), want the typed configuration refusal", customRefused.JSONDefault, customRefused.HTTPResponse.StatusCode) view, err := c.GetConfigurationWithResponse(ctx) testassert.NoError(t, err) testassert.Require(t, view.JSON200 != nil, "GetConfiguration = status %d: %s", view.HTTPResponse.StatusCode, view.Body) - testassert.Require(t, view.JSON200.ConfigurationRevision.Generation == publishedGeneration && view.JSON200.Settings.Sessions.AutoArchive && view.JSON200.Settings.Sessions.ArchiveAfterDays == 3 && compactComposed(t, []byte(view.JSON200.Settings.Plugins["tools"])) == compactComposed(t, []byte(settingsADocument)) && compactComposed(t, []byte(view.JSON200.Settings.Plugins[composedCustomPluginID])) == compactComposed(t, []byte(composedCustomSettingsDocument)), "configuration after the refused write = %+v, want the unchanged last publication", view.JSON200) + testassert.Require(t, view.JSON200.ConfigurationRevision.Generation == publishedGeneration && view.JSON200.Settings.Sessions.AutoArchive && view.JSON200.Settings.Sessions.ArchiveAfterDays == 3 && compactComposed(t, view.JSON200.Settings.Tools) == compactComposed(t, []byte(settingsADocument)) && compactComposed(t, []byte(view.JSON200.Settings.Plugins[composedCustomPluginID])) == compactComposed(t, []byte(composedCustomSettingsDocument)), "configuration after the refused write = %+v, want the unchanged last publication", view.JSON200) // The custom plugin's neutral warning: the Runtime-scoped factory's // report carries its own registered source through the mounted @@ -982,16 +963,9 @@ func TestProtocolComposedFlow(t *testing.T) { } } - t.Run("memory", func(t *testing.T) { - run(t, []runtime.Plugin{ - composedMemoryStorage(storage.NewMemory()), - tools.Plugin(), adaptation.Plugin(), lsp.Plugin(), jobs.Plugin(), tasks.Plugin(), - composedCustomPlugin(), - }) - }) - t.Run("sqlite", func(t *testing.T) { - run(t, []runtime.Plugin{sqlite.Plugin(), tools.Plugin(), adaptation.Plugin(), lsp.Plugin(), jobs.Plugin(), tasks.Plugin(), composedCustomPlugin()}) - }) + // The shipped composition over the included store: the composition's + // optional plugins ride the directly constructed baseline services. + run(t, []runtime.Plugin{adaptation.Plugin(), tasks.Plugin(), composedCustomPlugin()}) } func composedHistory(t *testing.T, c *client.Client, sessionID string, cursor *string) *protocol.HistoryPage { @@ -1261,11 +1235,11 @@ func TestProtocolComposedHeldCommandEnvironment(t *testing.T) { agentsPath := agents.PathForConfig(configPath) composedWriteFile(t, agentsPath, heldAgentsMain) - store := storage.NewMemory() - r, err := runtime.Open(ctx, runtime.Options{DataDir: dataDir, ConfigPath: configPath, Plugins: []runtime.Plugin{ - composedMemoryStorage(store), tools.Plugin(), jobs.Plugin(), - }}) + r, err := runtime.Open(ctx, runtime.Options{DataDir: dataDir, ConfigPath: configPath}) + testassert.NoError(t, err) + store, err := storage.OpenSQLite(filepath.Join(dataDir, "lightcode.db")) testassert.NoError(t, err) + defer store.Close() defer func() { _ = r.Close(ctx) }() ps, err := r.OpenProtocol(ctx) testassert.NoError(t, err) diff --git a/runtime/protocol_configuration.go b/runtime/protocol_configuration.go index a6e7da3e..932e2287 100644 --- a/runtime/protocol_configuration.go +++ b/runtime/protocol_configuration.go @@ -34,7 +34,7 @@ func (r *Runtime) getConfiguration(ctx context.Context) (protocol.ConfigurationV } return protocol.ConfigurationView{ ConfigurationRevision: configurationRevision(captured.snapshot), - Settings: projectSettings(captured.snapshot.sessions, captured.snapshot.plugins), + Settings: projectSettings(captured.snapshot.sessions, captured.snapshot.tools, captured.snapshot.jobs, captured.snapshot.compaction, captured.snapshot.plugins), Agents: projectAgents(captured.snapshot), Providers: projectProviders(captured), }, nil @@ -173,7 +173,7 @@ func (r *Runtime) updateSettings(ctx context.Context, settings protocol.Settings } return protocol.SettingsMutation{ ConfigurationRevision: configurationRevision(captured.snapshot), - Result: projectSettings(captured.snapshot.sessions, captured.snapshot.plugins), + Result: projectSettings(captured.snapshot.sessions, captured.snapshot.tools, captured.snapshot.jobs, captured.snapshot.compaction, captured.snapshot.plugins), }, nil } @@ -540,7 +540,7 @@ func projectModelListEntry(prov *catalog.Provider, entry *catalog.Model) protoco // the existing agentTypes() projection — the one selected-model and // write-dir normalization producer admission already uses. Required array // members are owned non-nil lists even when the definition carries none, and -// no private definition field (system prompt, LSP flag) is exposed. +// no private definition field (system prompt, prompt body) is exposed. func projectAgents(captured *configuration) []protocol.Agent { out := make([]protocol.Agent, 0, len(captured.definitions)) for _, at := range captured.agentTypes() { diff --git a/runtime/protocol_configuration_test.go b/runtime/protocol_configuration_test.go index 2d12a374..aba1c66b 100644 --- a/runtime/protocol_configuration_test.go +++ b/runtime/protocol_configuration_test.go @@ -50,7 +50,9 @@ const configurationProvidersDocument = `{ "openrouter": {"transport": {"headers": {"x-title": "user-title", "X-Custom": "keep"}}, "models": {"m": {"context_window": 4096}}} }, "sessions": {"auto_archive": false, "archive_after_days": 3}, - "plugins": {"tools": {"max_output_bytes": 42}, "jobs": {"max_background_processes": 20}, "tasks": {}} + "tools": {"max_output_bytes": 42}, + "jobs": {"max_background_processes": 20}, + "plugins": {"tasks": {}} }` // configurationAgentsDocument configures the primary model and one @@ -66,8 +68,8 @@ const configurationAgentsDocument = `{ func settingsPlugins() []Plugin { var opens atomic.Int64 // the fixture counters are unread here return []Plugin{ - servicePlugin("tools", &opens, func(json.RawMessage) error { return nil }), - servicePlugin("jobs", &opens, func(json.RawMessage) error { return nil }), + servicePlugin("capowner", &opens, func(json.RawMessage) error { return nil }), + servicePlugin("jobcap", &opens, func(json.RawMessage) error { return nil }), servicePlugin("tasks", &opens, func(json.RawMessage) error { return nil }), rosterToolPlugin(), } @@ -292,16 +294,17 @@ func openConfigurationRuntimeWithEnv(t *testing.T, store harness.Storage, e *own t.Helper() writeServiceFile(t, e.configPath, configDoc) writeServiceFile(t, agents.PathForConfig(e.configPath), agentsDoc) - r, err := e.open(context.Background(), append([]Plugin{e.storagePlugin(store)}, extra...)...) + e.fixtureStore(store) + r, err := e.open(context.Background(), extra...) testassert.NoError(t, err) return r, e } // TestConfigurationViewProjectsSettingsAndRoster pins the settings view: the -// parsed session policy, each owned plugin section returned as its exact -// opaque document string — no ID-chosen decoder, no injected defaults, an -// owned empty object as `{}` — absent sections omitted, and the Agent -// roster's public fields. +// parsed session policy, the top-level baseline sections as exact objects, +// each owned plugin section returned as its exact opaque document string — +// no ID-chosen decoder, no injected defaults, an owned empty object as `{}` — +// absent sections omitted, and the Agent roster's public fields. func TestConfigurationViewProjectsSettingsAndRoster(t *testing.T) { eachPrepStoreOnce(t, func(t *testing.T, store harness.Storage) { r, _ := openConfigurationRuntime(t, store, configurationProvidersDocument, configurationAgentsDocument, settingsPlugins()...) @@ -311,9 +314,9 @@ func TestConfigurationViewProjectsSettingsAndRoster(t *testing.T) { testassert.NoError(t, err) sessions := view.Settings.Sessions testassert.Require(t, !sessions.AutoArchive && sessions.ArchiveAfterDays == 3 && sessions.DeleteAfterArchiveDays == 7, "sessions settings = %+v, want the supplied values with the 7-day delete default", sessions) + testassert.Require(t, compactJSON(t, view.Settings.Tools) == `{"max_output_bytes":42}`, "tools = %s, want the exact top-level object", view.Settings.Tools) + testassert.Require(t, compactJSON(t, view.Settings.Jobs) == `{"max_background_processes":20}`, "jobs = %s, want the exact top-level object", view.Settings.Jobs) wantDocs := map[string]string{ - "tools": `{"max_output_bytes":42}`, - "jobs": `{"max_background_processes":20}`, "tasks": `{}`, } testassert.Require(t, len(view.Settings.Plugins) == len(wantDocs), "plugins = %v, want exactly the owned sections %v", view.Settings.Plugins, wantDocs) @@ -333,6 +336,7 @@ func TestConfigurationViewProjectsSettingsAndRoster(t *testing.T) { absentView, err := absent.getConfiguration(context.Background()) testassert.NoError(t, err) testassert.Require(t, len(absentView.Settings.Plugins) == 0, "absent plugin sections projected = %+v, want none", absentView.Settings.Plugins) + testassert.Require(t, string(absentView.Settings.Tools) == `{}` && string(absentView.Settings.Jobs) == `{}` && string(absentView.Settings.Compaction) == `{}`, "absent baseline sections = %s/%s/%s, want empty objects", absentView.Settings.Tools, absentView.Settings.Jobs, absentView.Settings.Compaction) // The Agent roster: the primary model selection and the worker's // public fields with the trimmed write dir. @@ -550,7 +554,10 @@ func TestConfigurationReadsOwnReturnedValues(t *testing.T) { (*model.ExtraBody)["side"] = "mutated" model.InputModalities[0] = "mutated" baseline.Settings.Sessions.ArchiveAfterDays = 999 - baseline.Settings.Plugins["tools"] = `{"mutated":true}` + baseline.Settings.Plugins["tasks"] = `{"mutated":true}` + if len(baseline.Settings.Tools) > 0 { + baseline.Settings.Tools[0] = 'x' + } for i := range baseline.Agents { if baseline.Agents[i].Name == "worker" && len(baseline.Agents[i].Tools) > 0 { baseline.Agents[i].Tools[0] = "mutated" diff --git a/runtime/protocol_discovery_process_test.go b/runtime/protocol_discovery_process_test.go index 4d059191..17d19a5d 100644 --- a/runtime/protocol_discovery_process_test.go +++ b/runtime/protocol_discovery_process_test.go @@ -99,7 +99,7 @@ func TestRuntimeDiscoveryOwnershipAcrossProcesses(t *testing.T) { record := readDiscoveryRecord(t, recordPath) health := healthSnapshot(t, record) - testassert.Require(t, health.InstanceId == record.InstanceId && health.ProtocolVersion == protocol.N6, "authenticated health = %s/%s, want the record's %s/%s", health.InstanceId, health.ProtocolVersion, record.InstanceId, record.ProtocolVersion) + testassert.Require(t, health.InstanceId == record.InstanceId && health.ProtocolVersion == protocol.N8, "authenticated health = %s/%s, want the record's %s/%s", health.InstanceId, health.ProtocolVersion, record.InstanceId, record.ProtocolVersion) winner.shutdown() { @@ -166,7 +166,7 @@ func TestRuntimeDiscoveryOwnershipAcrossProcesses(t *testing.T) { record := readDiscoveryRecord(t, recordPath) before := readDiscoveryBytes(t, recordPath) first := healthSnapshot(t, record) - testassert.Require(t, first.InstanceId == record.InstanceId && first.ProtocolVersion != "" && first.ProtocolVersion != protocol.N6, "incompatible health = %s/%q, want the record's instance and a different nonempty version", first.InstanceId, first.ProtocolVersion) + testassert.Require(t, first.InstanceId == record.InstanceId && first.ProtocolVersion != "" && first.ProtocolVersion != protocol.N8, "incompatible health = %s/%q, want the record's instance and a different nonempty version", first.InstanceId, first.ProtocolVersion) contenderMarker := filepath.Join(t.TempDir(), "marker") contender := startDiscoveryChild(t, e.home, dataDir, recordPath, contenderMarker, discoveryModeOwner) @@ -401,7 +401,7 @@ func readDiscoveryRecord(t *testing.T, path string) discoveryRecord { err := decodeOneDocument(data, &record) testassert.Require(t, err == nil, "decode discovery record at %s: %v", path, err) } - testassert.Require(t, record.ProtocolVersion == string(protocol.N6) && len(record.InstanceId) == 32 && strings.Trim(record.InstanceId, "0123456789abcdef") == "", "discovery record protocol version = %q, want %q; discovery instance %q is not 32 lowercase hex", record.ProtocolVersion, protocol.N6, record.InstanceId) + testassert.Require(t, record.ProtocolVersion == string(protocol.N8) && len(record.InstanceId) == 32 && strings.Trim(record.InstanceId, "0123456789abcdef") == "", "discovery record protocol version = %q, want %q; discovery instance %q is not 32 lowercase hex", record.ProtocolVersion, protocol.N8, record.InstanceId) testassert.Require(t, len(record.Credential) == 64 && strings.Trim(record.Credential, "0123456789abcdef") == "", "discovery credential is not 64 lowercase hex") endpoint, err := url.Parse(record.Endpoint) testassert.Require(t, err == nil && endpoint.Scheme == "http" && endpoint.Hostname() == "127.0.0.1" && endpoint.Port() != "", "discovery endpoint %q is not an http loopback host:port", record.Endpoint) diff --git a/runtime/protocol_handlers.go b/runtime/protocol_handlers.go index cc028d1b..bd25ba88 100644 --- a/runtime/protocol_handlers.go +++ b/runtime/protocol_handlers.go @@ -30,7 +30,7 @@ func (rt *protocolHandlers) GetHealth(w http.ResponseWriter, r *http.Request) { return } defer release() - health := protocol.Health{InstanceId: rt.protocolInstance(), ProtocolVersion: protocol.N6} + health := protocol.Health{InstanceId: rt.protocolInstance(), ProtocolVersion: protocol.N8} rt.writeQualifiedJSON(w, http.StatusOK, &health) } @@ -468,18 +468,6 @@ func (rt *protocolHandlers) RevertSessionCode(w http.ResponseWriter, r *http.Req rt.writeCodeRevertResponse(w, result, err) } -// RevertRetainedCode serves the retained pre-cutover restore under the same -// partial-result contract. -func (rt *protocolHandlers) RevertRetainedCode(w http.ResponseWriter, r *http.Request) { - var body protocol.RetainedRevertRequest - if err := decodeRequestBody(r, &body); err != nil { - writeProtocolError(w, err) - return - } - result, err := rt.revertRetainedCode(r.Context(), body) - rt.writeCodeRevertResponse(w, result, err) -} - // writeCodeRevertResponse writes the one restore contract: success carries // the completed result; a traversal failure carries the accumulated result // with its error member and the mapped status; a pre-traversal refusal @@ -523,14 +511,3 @@ func (rt *protocolHandlers) ReadWorkspaceFile(w http.ResponseWriter, r *http.Req } rt.writeQualifiedJSON(w, http.StatusOK, &result) } - -// GetRetainedCodeSnapshots serves the legacy turn listing of one proven -// pre-cutover Session directory. -func (rt *protocolHandlers) GetRetainedCodeSnapshots(w http.ResponseWriter, r *http.Request, params protocol.GetRetainedCodeSnapshotsParams) { - turns, err := rt.listRetainedCodeSnapshots(r.Context(), params.Workspace, params.SessionId) - if err != nil { - writeProtocolError(w, err) - return - } - rt.writeQualifiedJSON(w, http.StatusOK, &turns) -} diff --git a/runtime/protocol_lifecycle_test.go b/runtime/protocol_lifecycle_test.go index d239c1ce..f425f309 100644 --- a/runtime/protocol_lifecycle_test.go +++ b/runtime/protocol_lifecycle_test.go @@ -452,7 +452,7 @@ func TestProtocolServerDiscoveryPublication(t *testing.T) { if err := decoder.Decode(&published); err != nil { t.Fatalf("decode published record %s: %v", string(data), err) } - testassert.Require(t, published.Endpoint == ps.Endpoint() && published.InstanceId == ps.instance && published.ProtocolVersion == string(protocol.N6) && published.Credential == ps.credential, "published record = %+v, want the endpoint, identity, wire version, and credential", published) + testassert.Require(t, published.Endpoint == ps.Endpoint() && published.InstanceId == ps.instance && published.ProtocolVersion == string(protocol.N8) && published.Credential == ps.credential, "published record = %+v, want the endpoint, identity, wire version, and credential", published) // A second publication is refused — successful publication cannot repeat. second := filepath.Join(dir, "second.json") diff --git a/runtime/protocol_paging_test.go b/runtime/protocol_paging_test.go index 3a85c22e..bfe3fc79 100644 --- a/runtime/protocol_paging_test.go +++ b/runtime/protocol_paging_test.go @@ -1012,7 +1012,8 @@ func TestUsageReadsDistinctWindowsAndClocks(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, `{"providers":{"prov":`+nominalProvMember("https://prov.test/v1", "", `{"m":{"name":"M","context_window":4096}}`)+`}}`) writeServiceFile(t, agents.PathForConfig(e.configPath), usageWindowAgents) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer closeProjectionRuntime(r) ctx := context.Background() @@ -1123,7 +1124,8 @@ func TestHydrationSelectedModelFollowsNextAdmission(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, `{"providers":{"prov":`+nominalProvMember(e.server.URL, "", `{"m":{"name":"M","context_window":4096,"max_output_tokens":1024},"m2":{"name":"M2","context_window":8192,"max_output_tokens":1024}}`)+`}}`) writeServiceFile(t, agents.PathForConfig(e.configPath), `{"solo":{"model":"prov/m","system_prompt":"simple"},"alternate":{"model":"prov/m2","system_prompt":"simple"},"modelless":{"system_prompt":"simple"},"gone":{"model":"prov/gone","system_prompt":"simple"}}`) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer closeProjectionRuntime(r) diff --git a/runtime/protocol_projection_test.go b/runtime/protocol_projection_test.go index 7a596901..71a6842a 100644 --- a/runtime/protocol_projection_test.go +++ b/runtime/protocol_projection_test.go @@ -35,7 +35,8 @@ func openProjectionRuntime(t *testing.T, store harness.Storage) (*Runtime, *owne t.Helper() e := newOwnerEnv(t) writeServiceFile(t, agents.PathForConfig(e.configPath), projectionAgentsDocument) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) return r, e } diff --git a/runtime/protocol_server.go b/runtime/protocol_server.go index 5e8bbe0b..aa6b6f4c 100644 --- a/runtime/protocol_server.go +++ b/runtime/protocol_server.go @@ -235,7 +235,7 @@ func (ps *ProtocolServer) PublishDiscovery(path string) error { record, err := json.Marshal(discoveryRecord{ Endpoint: ps.endpoint, InstanceId: ps.instance, - ProtocolVersion: string(protocol.N6), + ProtocolVersion: string(protocol.N8), Credential: ps.credential, }) if err != nil { @@ -275,7 +275,7 @@ func (ps *ProtocolServer) verifyReady() error { if err := decodeOneDocument(body, &health); err != nil { return fmt.Errorf("decode health response: %w", err) } - if health.InstanceId != ps.instance || health.ProtocolVersion != protocol.N6 { + if health.InstanceId != ps.instance || health.ProtocolVersion != protocol.N8 { return fmt.Errorf("health response identity %q/%q does not match this server", health.InstanceId, health.ProtocolVersion) } return nil diff --git a/runtime/protocol_server_test.go b/runtime/protocol_server_test.go index e572de24..ea1bac58 100644 --- a/runtime/protocol_server_test.go +++ b/runtime/protocol_server_test.go @@ -14,7 +14,6 @@ import ( "os" "path/filepath" "reflect" - "slices" "strings" "sync" "testing" @@ -55,7 +54,8 @@ func openProtocolServer(t *testing.T, r *Runtime) *ProtocolServer { func openProtocolOwner(t *testing.T, store harness.Storage) (*Runtime, *ownerEnv) { t.Helper() e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) return r, e } @@ -281,7 +281,7 @@ func TestProtocolServerSessionFamily(t *testing.T) { health, err := client.GetHealthWithResponse(ctx) testassert.NoError(t, err) testassert.Require(t, health.JSON200 != nil && health.HTTPResponse.StatusCode == http.StatusOK, "health = (%d, %v), want the 200 typed body", health.HTTPResponse.StatusCode, health.JSON200) - testassert.Require(t, health.JSON200.InstanceId == ps.instance && health.JSON200.ProtocolVersion == protocol.N6, "health = %+v, want instance %q and protocol version %q", health.JSON200, ps.instance, protocol.N6) + testassert.Require(t, health.JSON200.InstanceId == ps.instance && health.JSON200.ProtocolVersion == protocol.N8, "health = %+v, want instance %q and protocol version %q", health.JSON200, ps.instance, protocol.N8) workspace := filepath.Join(e.home, "mounted") created, err := client.CreateSessionWithResponse(ctx, protocol.CreateSessionRequest{Workspace: workspace, AgentType: "solo"}) @@ -687,7 +687,8 @@ func TestProtocolServerCredentialFlowRawWireSecretFree(t *testing.T) { // One malformed line makes the startup LoadDotEnv diagnostic run, so // the captured stderr sink is genuinely exercised before the scan. writeDotEnv(t, e.home, "MALFORMED LINE\n") - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer closeProjectionRuntime(r) ps := openProtocolServer(t, r) @@ -1060,7 +1061,10 @@ func TestProtocolServerConfigurationFamily(t *testing.T) { ArchiveAfterDays: archiveDays, DeleteAfterArchiveDays: archiveDays, }, - Plugins: protocol.PluginsSettings{}, + Tools: json.RawMessage("{}"), + Jobs: json.RawMessage("{}"), + Compaction: json.RawMessage("{}"), + Plugins: protocol.PluginsSettings{}, }}) testassert.NoError(t, err) if settings.JSON200 == nil || !settings.JSON200.Result.Sessions.AutoArchive || settings.JSON200.Result.Sessions.ArchiveAfterDays != archiveDays { @@ -1090,8 +1094,11 @@ func TestProtocolServerConfigurationFamily(t *testing.T) { {"non-object document", protocol.PluginsSettings{"core": `1`}}, } { response, err := client.UpdateConfigurationSettingsWithResponse(ctx, protocol.UpdateSettingsRequest{Settings: protocol.Settings{ - Sessions: protocol.SessionsSettings{AutoArchive: true, ArchiveAfterDays: archiveDays, DeleteAfterArchiveDays: archiveDays}, - Plugins: row.plugins, + Sessions: protocol.SessionsSettings{AutoArchive: true, ArchiveAfterDays: archiveDays, DeleteAfterArchiveDays: archiveDays}, + Tools: json.RawMessage("{}"), + Jobs: json.RawMessage("{}"), + Compaction: json.RawMessage("{}"), + Plugins: row.plugins, }}) testassert.Require(t, err == nil, "%s: UpdateConfigurationSettings: %v", row.name, err) if response.HTTPResponse.StatusCode != http.StatusUnprocessableEntity || response.JSONDefault == nil || response.JSONDefault.Code != protocol.Configuration { @@ -1307,7 +1314,8 @@ func TestProtocolServerProviderPostStateAndKeyRules(t *testing.T) { func TestProtocolServerAgentModelEdit(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, agents.PathForConfig(e.configPath), `{"solo": {"model": "prov/m", "system_prompt": "simple"}, "..": {"model": "prov/m", "system_prompt": "simple"}}`) - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer closeProjectionRuntime(r) ps := openProtocolServer(t, r) @@ -1326,62 +1334,6 @@ func TestProtocolServerAgentModelEdit(t *testing.T) { testassert.Require(t, unknown.JSONDefault != nil && unknown.JSONDefault.Code == protocol.Invalid, "unknown agent type edit = %+v, want the typed invalid refusal", unknown.JSONDefault) } -// TestProtocolServerRetainedFamily drives the retained pre-cutover snapshot -// operation family through the mounted server: the turn listing under the -// exact 8-hex legacy identity and its workspace proof, the code-only restore -// to the recorded preimage, and the identity boundary that refuses a target -// 32-hex Session ID on the retained route. -func TestProtocolServerRetainedFamily(t *testing.T) { - eachPrepStore(t, func(t *testing.T, store harness.Storage) { - r, e := openProjectionRuntime(t, store) - defer closeProjectionRuntime(r) - ps := openProtocolServer(t, r) - client := protocolClient(t, ps) - ctx := context.Background() - - workspace := filepath.Join(e.home, "legacy-ws") - testassert.NoError(t, os.MkdirAll(workspace, 0o700)) - legacyID := "feedface" - sessionDir := seedRetainedSession(t, r.config.loader.Home(), workspace, legacyID) - file := filepath.Join(workspace, "partial.txt") - testassert.NoError(t, os.WriteFile(file, []byte("p0"), 0o600)) - recordRetainedMutation(t, sessionDir, 1, file, file, []byte("p1")) - - turns, err := client.GetRetainedCodeSnapshotsWithResponse(ctx, &protocol.GetRetainedCodeSnapshotsParams{Workspace: workspace, SessionId: legacyID}) - testassert.NoError(t, err) - if turns.JSON200 == nil || len(turns.JSON200.Turns) != 1 { - body, _ := json.Marshal(turns.JSON200) - t.Fatalf("retained turns = %s (status %d, %v), want one recorded turn", body, turns.HTTPResponse.StatusCode, turns.JSONDefault) - } - turn := turns.JSON200.Turns[0] - if turn.Turn != 1 || len(turn.Files) != 1 || turn.Files[0].Path != file || !turn.Files[0].Existed { - body, _ := json.Marshal(turn) - t.Fatalf("retained turn = %s, want the recorded file identity", body) - } - - revert, err := client.RevertRetainedCodeWithResponse(ctx, protocol.RetainedRevertRequest{Workspace: workspace, SessionId: legacyID, AfterTurn: 0}) - testassert.NoError(t, err) - if revert.JSON200 == nil || !slices.Contains(revert.JSON200.Restored, file) || revert.JSON200.Error != nil { - body, _ := json.Marshal(revert.JSON200) - t.Fatalf("retained revert = %s (status %d), want the completed restore", body, revert.HTTPResponse.StatusCode) - } - { - restored, err := os.ReadFile(file) - testassert.Require(t, err == nil && string(restored) == "p0", "restored retained file = (%q, %v), want its recorded preimage", restored, err) - } - - // The identity boundary is one rule: a target 32-hex Session ID is - // refused on the retained route before any snapshot read. - target := strings.Repeat("a", 32) - wrong, err := client.GetRetainedCodeSnapshotsWithResponse(ctx, &protocol.GetRetainedCodeSnapshotsParams{Workspace: workspace, SessionId: target}) - testassert.NoError(t, err) - if wrong.JSONDefault == nil || wrong.JSONDefault.Code != protocol.Invalid { - body, _ := json.Marshal(wrong.JSONDefault) - t.Fatalf("target identity on the retained route = %s, want the typed invalid refusal", body) - } - }) -} - // TestProtocolServerIdentityQueryRoundTrips pins the identity rule: every // retained-valid provider identifier is a query value, never a path segment, // so the directory-like and URL-significant spellings round-trip through the diff --git a/runtime/protocol_transport.go b/runtime/protocol_transport.go index 3febfda5..4b27112b 100644 --- a/runtime/protocol_transport.go +++ b/runtime/protocol_transport.go @@ -83,6 +83,11 @@ type enumValue interface { var contentPartType = reflect.TypeOf(protocol.ContentPart{}) +// rawMessageType is the one generated alias whose bytes are the opaque JSON +// value itself: the schema still requires an object, but the transport must +// never re-interpret or renumber its members. +var rawMessageType = reflect.TypeOf(json.RawMessage(nil)) + // validateDecodedValue checks the decoded request value against its raw JSON // bytes: every required member (no omitempty tag) is present, every present // declared member is non-null (no request member is nullable), every @@ -97,6 +102,8 @@ func validateDecodedValue(raw []byte, v reflect.Value, path string) error { } t := v.Type() switch { + case t == rawMessageType: + return nil case t == contentPartType: return validateContentPart(raw, v.Interface().(protocol.ContentPart), path) case t.Kind() == reflect.String: @@ -158,6 +165,7 @@ func validateStructMembers(raw []byte, v reflect.Value, path string) error { return invalidRequest("%s must be a JSON object: %v", path, err) } t := v.Type() + settingsType := reflect.TypeOf(protocol.Settings{}) for i := 0; i < t.NumField(); i++ { field := t.Field(i) if !field.IsExported() || field.Tag.Get("json") == "" || field.Tag.Get("json") == "-" { @@ -176,6 +184,14 @@ func validateStructMembers(raw []byte, v reflect.Value, path string) error { if isJSONNull(member) { return invalidRequest("%s member %q must not be null", path, name) } + // The three generated settings section aliases are raw messages, but + // their schema type is an object. The boundary enforces only that + // shape; every consumed member stays the candidate's validation. + if t == settingsType && field.Type == rawMessageType { + if err := requireSettingsObject(name, member); err != nil { + return invalidRequest("%s member %q: %v", path, name, err) + } + } if err := validateDecodedValue(member, v.Field(i), path+"."+name); err != nil { return err } diff --git a/runtime/protocol_transport_test.go b/runtime/protocol_transport_test.go index 1daa223d..391478c3 100644 --- a/runtime/protocol_transport_test.go +++ b/runtime/protocol_transport_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "os" @@ -103,7 +104,7 @@ func TestProtocolServerAuthenticationOrder(t *testing.T) { t.Fatalf("decode versioned health: %v", err) } versionedResponse.Body.Close() - testassert.Equal(t, health.ProtocolVersion, protocol.N6) + testassert.Equal(t, health.ProtocolVersion, protocol.N8) }) } @@ -502,46 +503,151 @@ func TestProtocolServerErrorClasses(t *testing.T) { t.Fatalf("list around corruption = %s, want only the valid sibling", body) } - // An invalid settings write fails invalid before any candidate work, - // with the previous revision intact. + // Settings transport validation: a complete settings body whose + // plugins value is an object instead of the required document string + // is a method-shape rejection before any candidate work. Negative + // session days are supported input. + const settingsSessions = `{"auto_archive":true,"archive_after_days":-5,"delete_after_archive_days":0}` + buildSettings := func(values map[string]string, plugins, extra string) string { + parts := []string{`"sessions":` + settingsSessions} + for _, name := range []string{"tools", "jobs", "compaction"} { + if value, ok := values[name]; ok { + parts = append(parts, `"`+name+`":`+value) + } + } + if plugins != "" { + parts = append(parts, `"plugins":`+plugins) + } + if extra != "" { + parts = append(parts, extra) + } + return `{"settings":{` + strings.Join(parts, ",") + `}}` + } + baseSettingsValues := func() map[string]string { + return map[string]string{"tools": "{}", "jobs": "{}", "compaction": "{}"} + } before, err := client.GetConfigurationWithResponse(ctx) testassert.Require(t, err == nil && before.JSON200 != nil, "read before invalid settings: %v", err) invalidSettings := rawProtocol(t, http.MethodPut, protocolTarget(ps, "/v1/configuration/settings"), ps.credential, - `{"settings":{"sessions":{"auto_archive":true,"archive_after_days":-5,"delete_after_archive_days":0},"plugins":{"unknown-plugin":{}}}}`) + buildSettings(baseSettingsValues(), `{"unknown-plugin":{}}`, "")) testassert.Equal(t, invalidSettings.StatusCode, http.StatusBadRequest) { typed := decodeProtocolError(t, invalidSettings) - testassert.Require(t, typed.Code == protocol.Invalid, "invalid settings error = %+v, want invalid", typed) + testassert.Require(t, typed.Code == protocol.Invalid && strings.Contains(typed.Message, "plugins"), "invalid settings shape error = %+v, want invalid naming plugins", typed) } after, err := client.GetConfigurationWithResponse(ctx) testassert.Require(t, err == nil && after.JSON200 != nil, "read after invalid settings: %v", err) - testassert.Require(t, after.JSON200.ConfigurationRevision.Generation == before.JSON200.ConfigurationRevision.Generation, "an invalid candidate changed the published revision") - - // The settings outer shape stays closed and its plugins member is a - // required complete map: a missing or null member, a wrong-typed - // member, a null document value, and an unknown settings member are - // all invalid-request failures before any candidate work. - for _, row := range []struct{ name, body string }{ - {"missing plugins member", `{"settings":{"sessions":{"auto_archive":true,"archive_after_days":1,"delete_after_archive_days":0}}}`}, - {"null plugins member", `{"settings":{"sessions":{"auto_archive":true,"archive_after_days":1,"delete_after_archive_days":0},"plugins":null}}`}, - {"wrong-typed plugins member", `{"settings":{"sessions":{"auto_archive":true,"archive_after_days":1,"delete_after_archive_days":0},"plugins":[]}}`}, - {"null document value", `{"settings":{"sessions":{"auto_archive":true,"archive_after_days":1,"delete_after_archive_days":0},"plugins":{"alpha":null}}}`}, - {"unknown settings member", `{"settings":{"sessions":{"auto_archive":true,"archive_after_days":1,"delete_after_archive_days":0},"plugins":{},"forged":1}}`}, + testassert.Require(t, after.JSON200.ConfigurationRevision.Generation == before.JSON200.ConfigurationRevision.Generation, "an invalid settings shape changed the published revision") + + // A schema-valid unknown plugin document reaches the candidate and is + // refused there, with the existing owning-file/revision/warning/event + // observation. + unknownSub, err := r.Subscribe(64) + testassert.NoError(t, err) + defer unknownSub.Close() + unknownBaseline, unknownGeneration, unknownWarn := runtimeMutationBaseline(t, r) + unknownPlugin := rawProtocol(t, http.MethodPut, protocolTarget(ps, "/v1/configuration/settings"), ps.credential, + buildSettings(baseSettingsValues(), `{"unknown-plugin":"{}"}`, "")) + testassert.Equal(t, unknownPlugin.StatusCode, http.StatusUnprocessableEntity) + { + typed := decodeProtocolError(t, unknownPlugin) + testassert.Require(t, typed.Code == protocol.Configuration, "unknown plugin settings error = %+v, want configuration", typed) + } + assertRuntimeMutationRefused(t, r, unknownSub, unknownBaseline, unknownGeneration, unknownWarn, + fmt.Errorf("mounted unknown plugin settings: %w", ErrConfiguration), ErrConfiguration) + + // The settings outer shape is closed and its three baseline objects and + // plugins map are required: missing, null, array, number, boolean and + // string members are all method-boundary invalid-request failures. + shapeRows := []struct { + name, body, member string + }{} + for _, member := range []string{"tools", "jobs", "compaction"} { + values := baseSettingsValues() + delete(values, member) + shapeRows = append(shapeRows, struct{ name, body, member string }{member + " missing", buildSettings(values, "{}", ""), member}) + for _, wrong := range []struct{ name, value string }{ + {"null", "null"}, {"array", "[]"}, {"number", "7"}, {"boolean", "true"}, {"string", `"value"`}, + } { + values := baseSettingsValues() + values[member] = wrong.value + shapeRows = append(shapeRows, struct{ name, body, member string }{member + " " + wrong.name, buildSettings(values, "{}", ""), member}) + } + } + for _, row := range []struct{ name, body, member string }{ + {"missing plugins member", buildSettings(baseSettingsValues(), "", ""), "plugins"}, + {"null plugins member", buildSettings(baseSettingsValues(), "null", ""), "plugins"}, + {"wrong-typed plugins member", buildSettings(baseSettingsValues(), "[]", ""), "plugins"}, + {"null document value", buildSettings(baseSettingsValues(), `{"alpha":null}`, ""), "alpha"}, + {"unknown settings member", buildSettings(baseSettingsValues(), "{}", `"forged":1`), "forged"}, } { + shapeRows = append(shapeRows, row) + } + for _, row := range shapeRows { response := rawProtocol(t, http.MethodPut, protocolTarget(ps, "/v1/configuration/settings"), ps.credential, row.body) if response.StatusCode != http.StatusBadRequest { response.Body.Close() t.Fatalf("%s = %d, want the typed invalid 400", row.name, response.StatusCode) } - { - typed := decodeProtocolError(t, response) - testassert.Require(t, typed.Code == protocol.Invalid, "%s error = %+v, want invalid", row.name, typed) - } + typed := decodeProtocolError(t, response) + testassert.Require(t, typed.Code == protocol.Invalid, "%s error = %+v, want invalid", row.name, typed) + testassert.Require(t, strings.Contains(typed.Message, row.member), "%s diagnostic = %q, want the addressed member %q", row.name, typed.Message, row.member) } finalRead, err := client.GetConfigurationWithResponse(ctx) testassert.Require(t, err == nil && finalRead.JSON200 != nil, "read after settings shape failures: %v", err) testassert.Require(t, finalRead.JSON200.ConfigurationRevision.Generation == before.JSON200.ConfigurationRevision.Generation, "a rejected settings shape changed the published revision") + // Unknown members in the three baseline objects stay opaque: null, + // arrays, scalars and exact large/exponent numeric lexemes survive the + // accepted settings write without transport recursion or number + // coercion. + opaqueValues := map[string]string{ + "tools": `{"command_timeout":60,"max_output_bytes":4096,"read_line_max_chars":3000,"read_max_lines":100,"unknown_null":null,"unknown_array":[1,2],"unknown_scalar":"x","exact":9007199254740993}`, + "jobs": `{"max_background_processes":5,"max_output_bytes":8192,"read_line_max_chars":4000,"unknown_null":null,"unknown_array":[],"unknown_scalar":false,"big":1e400}`, + "compaction": `{"unknown_null":null,"unknown_array":[{"deep":null}],"unknown_scalar":1,"exact":9007199254740993,"big":1e400}`, + } + accepted := rawProtocol(t, http.MethodPut, protocolTarget(ps, "/v1/configuration/settings"), ps.credential, + buildSettings(opaqueValues, "{}", "")) + testassert.Equal(t, accepted.StatusCode, http.StatusOK) + acceptedBody, err := io.ReadAll(accepted.Body) + accepted.Body.Close() + testassert.NoError(t, err) + testassert.Require(t, strings.Contains(string(acceptedBody), `"exact":9007199254740993`) && strings.Contains(string(acceptedBody), `"big":1e400`), "accepted settings result = %s, want the opaque exact lexemes preserved", acceptedBody) + + // Valid objects whose consumed values are invalid remain candidate + // failures: the boundary accepts the object shape, then the owning + // validator refuses the complete candidate under the existing + // ownership/revision/warning/event observations. + refusalSub, err := r.Subscribe(64) + testassert.NoError(t, err) + defer refusalSub.Close() + refusalFile, refusalGeneration, refusalWarn := runtimeMutationBaseline(t, r) + for _, row := range []struct { + name string + values map[string]string + }{ + {"tools zero", map[string]string{"tools": `{"max_output_bytes":0}`}}, + {"jobs range", map[string]string{"jobs": `{"max_background_processes":0}`}}, + {"malformed compaction model", map[string]string{"compaction": `{"model":"bad"}`}}, + {"empty compaction prompt", map[string]string{"compaction": `{"prompt":""}`}}, + {"null compaction prompt", map[string]string{"compaction": `{"prompt":null}`}}, + } { + values := baseSettingsValues() + for name, value := range row.values { + values[name] = value + } + response := rawProtocol(t, http.MethodPut, protocolTarget(ps, "/v1/configuration/settings"), ps.credential, + buildSettings(values, "{}", "")) + if response.StatusCode != http.StatusUnprocessableEntity { + response.Body.Close() + t.Fatalf("%s = %d, want the typed configuration 422", row.name, response.StatusCode) + } + typed := decodeProtocolError(t, response) + testassert.Require(t, typed.Code == protocol.Configuration, "%s error = %+v, want configuration", row.name, typed) + assertRuntimeMutationRefused(t, r, refusalSub, refusalFile, refusalGeneration, refusalWarn, + fmt.Errorf("mounted settings refusal %s: %w", row.name, ErrConfiguration), ErrConfiguration) + } + // A restore attempt against a busy Session answers the conflict // class from the one idle gate. session := projectionSession(t, r, filepath.Join(e.home, "busy"), "solo").Identity.SessionID diff --git a/runtime/protocol_warnings_test.go b/runtime/protocol_warnings_test.go index 1111a787..e89cd32b 100644 --- a/runtime/protocol_warnings_test.go +++ b/runtime/protocol_warnings_test.go @@ -434,7 +434,7 @@ func TestWarningsCompactClosureRecordsWithPostEncodeFailure(t *testing.T) { })) t.Cleanup(server.Close) writeServiceFile(t, sh.configPath, warnCompactConfigDocument(server.URL)) - writeServiceFile(t, agents.PathForConfig(sh.configPath), agentsWithCompact(`{"model":"prov/m2"}`)) + writeServiceFile(t, agents.PathForConfig(sh.configPath), prepAgentsDocument) owner, cancel := context.WithCancel(context.Background()) defer cancel() comp := mustComposition(t, compactToolsPlugin()) @@ -448,7 +448,7 @@ func TestWarningsCompactClosureRecordsWithPostEncodeFailure(t *testing.T) { ws := newWorkspaceScopes(owner, comp, []*scope{runtimeScope}, r.obs) adapter := newObservationAdapter(r.obs, r.warnings) adapter.h = r.harness - p := newPreparation(svc, comp, runtimeScope, ws, sh.home, nil, adapter, nil, nil) + p := newPreparation(svc, comp, runtimeScope, ws, sh.home, "", "", nil, adapter, nil, nil) request := compactPrepRequest() request.Session.Identity.SessionID = sessionID @@ -534,12 +534,13 @@ func TestWarningsCompactClosureRecordsWithPostEncodeFailure(t *testing.T) { }) } -// warnCompactConfigDocument points both the conversation and the compact -// model at the given failing endpoint with must-preserve metadata. +// warnCompactConfigDocument points both the conversation and the top-level +// compact model at the given failing endpoint with must-preserve metadata. func warnCompactConfigDocument(endpoint string) string { return `{"providers":{"prov":` + nominalProvMember(endpoint, "PREP_COMPACT_KEY", `{"m":{"name":"M","context_window":4096,"protocol_metadata":{"must_preserve":["proto_keep"]}},`+ - `"m2":{"name":"M2","context_window":2048,"protocol_metadata":{"must_preserve":["proto_keep"]}}}`) + `}}` + `"m2":{"name":"M2","context_window":2048,"protocol_metadata":{"must_preserve":["proto_keep"]}}}`) + `}, + "compaction":{"model":"prov/m2"}}` } // TestWarningStoreReadOwnsReturnedWarnings pins the shared read producer's diff --git a/runtime/runtime.go b/runtime/runtime.go index d8d4a6dc..197c4095 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "reflect" "strconv" "sync" "time" @@ -15,6 +14,8 @@ import ( "github.com/MMinasyan/lightcode/internal/atomicfs" "github.com/MMinasyan/lightcode/internal/catalog" "github.com/MMinasyan/lightcode/internal/config" + "github.com/MMinasyan/lightcode/internal/jobs" + "github.com/MMinasyan/lightcode/internal/storage" ) // ErrOwned reports advisory-lock contention: another Runtime process owns the @@ -30,6 +31,12 @@ type Options struct { DataDir string ConfigPath string Plugins []Plugin + + // Identity is the application-supplied prompt persona text. GlobalRulesDir + // is the explicit directory holding the global AGENTS.md. Zero values + // supply no identity and no global rules, never an implicit fallback. + Identity string + GlobalRulesDir string } // Open constructs the complete managed Runtime with concrete production @@ -39,9 +46,11 @@ type Options struct { // one admitted-call gate, and the joined shutdown. func Open(ctx context.Context, opts Options) (*Runtime, error) { return open(ctx, options{ - DataDir: opts.DataDir, - ConfigPath: opts.ConfigPath, - Plugins: opts.Plugins, + DataDir: opts.DataDir, + ConfigPath: opts.ConfigPath, + Plugins: opts.Plugins, + Identity: opts.Identity, + GlobalRulesDir: opts.GlobalRulesDir, }) } @@ -56,6 +65,8 @@ func Open(ctx context.Context, opts Options) (*Runtime, error) { type options struct { DataDir, ConfigPath string Plugins []Plugin + Identity string + GlobalRulesDir string sweepTicks <-chan time.Time } @@ -78,6 +89,13 @@ type Runtime struct { harness *harness.Harness artifacts *artifactIntervals dataDir string + + // jobs and closeStore are the directly constructed baseline services: + // the jobs service (also the Harness job-stop seam) and the included + // store's closer. Both close after Harness settlement, the jobs work + // group joining before the store closes and the lock releases. + jobs jobsService + closeStore func() error // protocol is the one attached isolated protocol server, set under mu by // OpenProtocol and frozen once closure begins; nil when none is attached. protocol *ProtocolServer @@ -97,22 +115,23 @@ type Runtime struct { // finished. It requires nonempty paths, normalizes both // paths once with filepath.Abs, and validates the context before any // initialization. Startup order is fixed: validate declarations (definitions -// and capability declarations, including exactly one Runtime-scoped export -// declared as harness.Storage, all before any factory), acquire the +// and plugin capability declarations, all before any factory), acquire the // process-lifetime lock at /runtime.lock, load the managed dotenv and -// publish the initial configuration snapshot, construct the complete Runtime -// scope in the composition's stable topological order, obtain the private Core -// storage export and the optional job-stop seam, run restart recovery against -// the storage, and construct the Harness with the bound preparation and seam. -// The storage factory initializes its backend -// under the held ownership; other factories receive no canonical Storage. +// publish the initial configuration snapshot, directly construct the included +// SQLite store and the jobs service under the held ownership and the standard +// tool values over them, construct the complete Runtime scope in the +// composition's stable topological order with those values bound as its own +// provided capabilities, run restart recovery against the storage, and +// construct the Harness with the bound preparation and the jobs stop seam. +// Other factories receive no canonical Storage. // The automatic sweep's owned ticker loop is then registered as Runtime work // and one initial pass runs, both before the completed-owner publication. -// Failure or cancellation before completed construction unwinds every -// acquired resource, closes state access before releasing the lock, and -// preserves committed repair. Construction never returns a non-nil Runtime -// together with an error; the Harness and every scope stay private until this -// call returns. +// Failure or cancellation before completed construction unwinds every acquired +// resource in reverse acquisition order — the constructed Runtime scope's +// instances, then the jobs work group joining, then the store — closing state +// access before releasing the lock, and preserves committed repair. +// Construction never returns a non-nil Runtime together with an error; the +// Harness and every scope stay private until this call returns. func open(ctx context.Context, options options) (*Runtime, error) { if ctx == nil { return nil, errors.New("runtime: construction requires a non-nil context") @@ -142,9 +161,6 @@ func open(ctx context.Context, options options) (*Runtime, error) { if err != nil { return nil, err } - if err := requireCoreStorage(c); err != nil { - return nil, err - } lock, ok, err := atomicfs.TryAcquire(filepath.Join(dataDir, "runtime.lock")) if err != nil { @@ -187,28 +203,43 @@ func open(ctx context.Context, options options) (*Runtime, error) { return unlock(err) } - runtimeScope, err := c.openScope(work, ScopeInfo{Kind: ScopeRuntime, DataDir: dataDir}, nil) - if err != nil { - return unlock(err) - } - // The observation is attached after construction: no subscriber can - // exist before the owner is published, so the Runtime scope announces - // only its closure, and descendant scopes inherit the publisher from it. - runtimeScope.obs = obs + // unwind releases the acquired prefix in reverse acquisition order: each + // closer joins its own work — the jobs work group covers every monitor and + // completion callback — and the ownership lock releases last. A failed + // construction never appends its own resource: openScope already cleaned + // the partial scope it built. + var acquired []func() error unwind := func(cause error) (*Runtime, error) { cancelWork() - return nil, errors.Join(cause, runtimeScope.cleanup(), lock.Release()) + errs := []error{cause} + for i := len(acquired) - 1; i >= 0; i-- { + errs = append(errs, acquired[i]()) + } + errs = append(errs, lock.Release()) + return nil, errors.Join(errs...) } - storage, err := coreStorage(runtimeScope) + // The baseline services are acquired before the optional Runtime scope + // opens, so a Runtime-scoped factory's declared dependency on a standard + // tool resolves from the scope's own bindings when its factory runs. + store, closeStorage, err := openStore(work, dataDir) if err != nil { return unwind(err) } - jobs, err := jobStopper(c, runtimeScope) + acquired = append(acquired, closeStorage) + jobsService := newJobs(dataDir) + acquired = append(acquired, jobsService.Close) + c.standardValues = newStandardTools(dataDir, jobsService) + runtimeScope, err := c.openScope(work, ScopeInfo{Kind: ScopeRuntime, DataDir: dataDir}, nil) if err != nil { return unwind(err) } - if err := harness.Recover(work, storage); err != nil { + acquired = append(acquired, runtimeScope.cleanup) + // The observation is attached after construction: no subscriber can + // exist before the owner is published, so the Runtime scope announces + // only its closure, and descendant scopes inherit the publisher from it. + runtimeScope.obs = obs + if err := harness.Recover(work, store); err != nil { return unwind(err) } @@ -240,9 +271,9 @@ func open(ctx context.Context, options options) (*Runtime, error) { } } h, err := harness.New(work, harness.Dependencies{ - Storage: storage, - Jobs: jobs, - Prepare: newPreparation(configService, c, runtimeScope, workspaces, home, background, adapter, artifacts, subprocessEnv).bind(), + Storage: store, + Jobs: jobsService, + Prepare: newPreparation(configService, c, runtimeScope, workspaces, home, options.Identity, options.GlobalRulesDir, background, adapter, artifacts, subprocessEnv).bind(), Observe: adapter.observe, }) if err != nil { @@ -268,6 +299,8 @@ func open(ctx context.Context, options options) (*Runtime, error) { harness: h, artifacts: artifacts, dataDir: dataDir, + jobs: jobsService, + closeStore: closeStorage, shutdownDone: make(chan struct{}), } sweepTicks, stopSweepTicker := options.sweepTicks, func() {} @@ -279,75 +312,32 @@ func open(ctx context.Context, options options) (*Runtime, error) { return r.publishOwner(work) } -// requireCoreStorage enforces the canonical-state contract on the compiled -// declarations before any factory: exactly one export is declared as -// harness.Storage, and its plugin is Runtime-scoped. Names grant nothing; an -// unrelated ordinary capability may be named storage. -func requireCoreStorage(c *composition) error { - switch { - case len(c.coreExports) == 0: - return fmt.Errorf("no export is declared as harness.Storage: %w", ErrComposition) - case len(c.coreExports) > 1: - return fmt.Errorf("%d exports are declared as harness.Storage: %w", len(c.coreExports), ErrComposition) - case c.coreExports[0].scope != ScopeRuntime: - return fmt.Errorf("Core storage export %q is %s-scoped: %w", c.coreExports[0].id, c.coreExports[0].scope, ErrComposition) - } - return nil +// openStore is the owner's included storage construction: the SQLite database +// derived from the owned data directory, returned together with its closer. +// It checks the construction context before touching the filesystem and runs +// under the held ownership lock; in-package construction and unwind fixtures +// replace it, and no other entry supplies the Harness storage. +// jobsService is the directly constructed background process service: the +// standard tools' jobs contract, the Harness job-stop seam, and the shutdown +// closer. newJobs is its construction seam, which the in-package fixtures +// point at a scripted service; no composition declaration supplies it. +type jobsService interface { + jobs.Jobs + harness.JobStopper + Close() error } -// coreStorage obtains the constructed Runtime scope's private Core export for -// the owner. All capabilities of the storage plugin already share its ordinary -// scope lifecycle; only the Runtime passes this value to the Harness and -// recovery. An invalid success unwinds through the same scope rollback and -// Instance.Close as other invalid exports. -func coreStorage(runtimeScope *scope) (harness.Storage, error) { - var value any - for _, stored := range runtimeScope.storages { - value = stored - } - storage, ok := value.(harness.Storage) - if !ok { - return nil, fmt.Errorf("Core storage export supplies %T, not harness.Storage: %w", value, ErrComposition) - } - if isTypedNil(storage) { - return nil, fmt.Errorf("Core storage export supplies a typed-nil harness.Storage: %w", ErrComposition) - } - return storage, nil -} +var newJobs = func(dataDir string) jobsService { return jobs.New(dataDir) } -// isTypedNil reports whether v's dynamic value is a nil of a nil-able kind: -// the one typed-nil shape shared by the private Harness seam bindings. -func isTypedNil(v any) bool { - rv := reflect.ValueOf(v) - switch rv.Kind() { - case reflect.Pointer, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.UnsafePointer: - return rv.IsNil() - default: - return false +var openStore = func(ctx context.Context, dataDir string) (harness.Storage, func() error, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err } -} - -// jobStopper resolves the optional Harness job-stop seam over the Runtime -// scope: composition recorded every export declared exactly as -// harness.JobStopper, zero leaves the seam nil, and the single -// Runtime-scoped export resolves from the scope's private Core seam values -// after the coreStorage-shape typed-nil check — multiple or narrower-scoped -// exports fail composition. It runs after the Runtime scope's bindings commit -// and before Harness construction, so a failure never publishes a Harness. -func jobStopper(c *composition, runtimeScope *scope) (harness.JobStopper, error) { - switch { - case len(c.jobStoppers) == 0: - return nil, nil - case len(c.jobStoppers) > 1: - return nil, fmt.Errorf("%d exports are declared as harness.JobStopper: %w", len(c.jobStoppers), ErrComposition) - case c.jobStoppers[0].scope != ScopeRuntime: - return nil, fmt.Errorf("job stopper export %q is %s-scoped: %w", c.jobStoppers[0].id, c.jobStoppers[0].scope, ErrComposition) - } - stopper := runtimeScope.jobStoppers[c.jobStoppers[0].id].(harness.JobStopper) - if isTypedNil(stopper) { - return nil, fmt.Errorf("job stopper export %q supplies a typed-nil harness.JobStopper: %w", c.jobStoppers[0].id, ErrComposition) - } - return stopper, nil + store, err := storage.OpenSQLite(filepath.Join(dataDir, "lightcode.db")) + if err != nil { + return nil, nil, err + } + return store, store.Close, nil } // publishOwner registers the constructor context's cancellation watcher and @@ -519,8 +509,10 @@ func (r *Runtime) beginShutdown() { // its context cancellation has settled every in-flight preparation, // execution, and required terminal commit, then closes every // live Workspace scope in sorted path order and the Runtime scope in reverse -// dependency order, closes every passive subscription once those cleanup -// events have been published, withdraws the attached protocol server's +// dependency order, closes the jobs service — joining every monitor and +// completion callback — before closing the included store, which closes last +// among the owned resources, closes every passive subscription once those +// cleanup events have been published, withdraws the attached protocol server's // remembered discovery record, and releases the lock as the final ownership // action. Every required close is attempted and all errors are joined; no // state-owning mutex is held across any of it. @@ -539,6 +531,12 @@ func (r *Runtime) joinShutdown() error { if err := r.runtimeScope.close(); err != nil { errs = append(errs, err) } + if err := r.jobs.Close(); err != nil { + errs = append(errs, err) + } + if err := r.closeStore(); err != nil { + errs = append(errs, err) + } r.obs.closeAll() // The remembered discovery record is withdrawn after the Harness and // every scope have converged and before the owner lock is released, so diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go index bd5cacde..89c4e86d 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -106,6 +106,48 @@ func isolateBundledCredentials(t *testing.T) { } } +// fixtureStore points the owner's store construction seam at the supplied +// store: every later open in the row treats it as the owner's own constructed +// store, disposed at the owner's storage-close point, and the seam returns to +// the production one when the row's test ends. +func (e *ownerEnv) fixtureStore(store harness.Storage) { + e.fixtureStoreClose(store, func() error { e.events.add("close:core"); return nil }) +} + +// fixtureStoreClose is the sibling over a scripted store closer: the row +// controls when the storage close returns and with what error. +func (e *ownerEnv) fixtureStoreClose(store harness.Storage, closeFn func() error) { + previous := openStore + openStore = func(ctx context.Context, dataDir string) (harness.Storage, func() error, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return store, closeFn, nil + } + e.t.Cleanup(func() { openStore = previous }) +} + +// fixtureStoreFailure is the sibling: the owner's store construction fails +// with the supplied error before any closer exists. +func (e *ownerEnv) fixtureStoreFailure(err error) { + previous := openStore + openStore = func(ctx context.Context, dataDir string) (harness.Storage, func() error, error) { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + return nil, nil, err + } + e.t.Cleanup(func() { openStore = previous }) +} + +// fixtureJobs points the owner's jobs construction seam at the supplied +// scripted service for every later open in the row. +func (e *ownerEnv) fixtureJobs(service jobsService) { + previous := newJobs + newJobs = func(string) jobsService { return service } + e.t.Cleanup(func() { newJobs = previous }) +} + func (e *ownerEnv) options(plugins ...Plugin) options { return options{DataDir: e.dataDir, ConfigPath: e.configPath, Plugins: plugins} } @@ -128,8 +170,9 @@ func (e *ownerEnv) openSwept(ctx context.Context, plugins ...Plugin) (*Runtime, } // corePlugin is one Runtime-scoped plugin whose single export is declared -// exactly as harness.Storage: the private Core binding. Its Close records the -// disposal event only; the test retains the store itself. +// exactly as harness.Storage: an ordinary capability export the owner never +// consumes. Its Close records the disposal event only; the test retains the +// store itself. func (e *ownerEnv) corePlugin(id, exportID string, value any, closeFn func() error) Plugin { return Plugin{ ID: id, @@ -143,13 +186,6 @@ func (e *ownerEnv) corePlugin(id, exportID string, value any, closeFn func() err } } -func (e *ownerEnv) storagePlugin(value any) Plugin { - return e.corePlugin("core", "session_store", value, func() error { - e.events.add("close:core") - return nil - }) -} - // ordinaryPlugin provides one value capability and records its construction // and disposal order. func (e *ownerEnv) ordinaryPlugin(id string, scope ScopeKind, providesID string, requires ...CapabilitySpec) Plugin { @@ -294,7 +330,8 @@ func wantFailedOpen(t *testing.T, e *ownerEnv, plugins []Plugin, wantIs error) { // assertLockReleased proves the failed attempt released the ownership lock. func (e *ownerEnv) assertLockReleased(t *testing.T) { t.Helper() - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) testassert.NoError(t, r.Close(context.Background())) } @@ -467,8 +504,8 @@ func TestRuntimeOpenValidatesOptionsAndNormalizesPaths(t *testing.T) { name string opts options }{ - {"empty data directory", options{ConfigPath: e.configPath, Plugins: []Plugin{e.storagePlugin(nil)}}}, - {"empty config path", options{DataDir: e.dataDir, Plugins: []Plugin{e.storagePlugin(nil)}}}, + {"empty data directory", options{ConfigPath: e.configPath}}, + {"empty config path", options{DataDir: e.dataDir}}, } for _, tc := range cases { r, err := open(context.Background(), tc.opts) @@ -481,7 +518,8 @@ func TestRuntimeOpenValidatesOptionsAndNormalizesPaths(t *testing.T) { e := newOwnerEnv(t) ctx, cancel := context.WithCancel(context.Background()) cancel() - r, err := e.open(ctx, e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(ctx) testassert.Require(t, r == nil && errors.Is(err, context.Canceled), "open = (%v, %v), want (nil, context.Canceled)", r, err) e.assertNoOwnershipSideEffects(t) }) @@ -521,7 +559,8 @@ func TestRuntimeOpenValidatesOptionsAndNormalizesPaths(t *testing.T) { writeServiceFile(t, configPath, ownerConfigDocument(e.server.URL)) writeServiceFile(t, agents.PathForConfig(configPath), ownerAgentsDocument) e.configPath = configPath - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) { _, err := os.Stat(filepath.Join(configDir, "agents.json")) @@ -539,7 +578,8 @@ func TestRuntimeOpenValidatesOptionsAndNormalizesPaths(t *testing.T) { func TestRuntimeOwnershipLockIsExclusive(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) // A contender with its own configuration override still targets the same // owner root: identity is the normalized data directory. @@ -553,7 +593,8 @@ func TestRuntimeOwnershipLockIsExclusive(t *testing.T) { t.Fatalf("contender factory events = %v, want no factory invoked while losing ownership", rival.events.all()) } testassert.NoError(t, r.Close(context.Background())) - again, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + again, err := e.open(context.Background()) testassert.NoError(t, err) testassert.NoError(t, again.Close(context.Background())) } @@ -656,14 +697,16 @@ func TestRuntimeStartupFailuresUnwindAcquiredResources(t *testing.T) { e := newOwnerEnv(t) duplicateA := e.ordinaryPlugin("same", ScopeRuntime, "a.cap") duplicateB := e.ordinaryPlugin("same", ScopeRuntime, "b.cap") - wantFailedOpen(t, e, []Plugin{e.storagePlugin(storage.NewMemory()), duplicateA, duplicateB}, ErrComposition) + e.fixtureStore(storage.NewMemory()) + wantFailedOpen(t, e, []Plugin{duplicateA, duplicateB}, ErrComposition) e.assertNoOwnershipSideEffects(t) }) t.Run("configuration failure releases the lock with no factory", func(t *testing.T) { e := newOwnerEnv(t) writeServiceFile(t, e.configPath, `{not json`) - wantFailedOpen(t, e, []Plugin{e.storagePlugin(storage.NewMemory())}, ErrConfiguration) + e.fixtureStore(storage.NewMemory()) + wantFailedOpen(t, e, nil, ErrConfiguration) { events := e.events.all() testassert.Require(t, len(events) == 0, "factory events = %v, want the configuration rejection to precede any factory", events) @@ -679,28 +722,26 @@ func TestRuntimeStartupFailuresUnwindAcquiredResources(t *testing.T) { e.events.add("open:failing") return Instance{}, errFactory } - wantFailedOpen(t, e, []Plugin{e.storagePlugin(storage.NewMemory()), e.ordinaryPlugin("first", ScopeRuntime, "first.cap"), failing}, errFactory) + e.fixtureStore(storage.NewMemory()) + e.fixtureJobs(&stubStopper{events: e.events}) + wantFailedOpen(t, e, []Plugin{e.ordinaryPlugin("first", ScopeRuntime, "first.cap"), failing}, errFactory) + // Reverse acquisition order: the constructed scope's instances, then the + // jobs work group joining, then the store closing; the failing factory + // disposed nothing, and the ownership lock — which carries no event — + // releases last, proven by the re-acquisition below. events := eventNames(e.events.all()) - testassert.Require(t, orderedSubset(events, "close:first") && !slices.Contains(events, "close:failing"), "factory events = %v, want only the constructed prefix disposed", events) + testassert.Require(t, orderedSubset(events, "close:first", "close:jobs", "close:core") && !slices.Contains(events, "close:failing"), "factory events = %v, want the scope disposed, then the jobs group joined, then the store closed, and never the failing factory", events) e.assertLockReleased(t) }) - t.Run("untyped-nil Core storage rolls back through its closer", func(t *testing.T) { + t.Run("store construction failure unwinds the acquired resources", func(t *testing.T) { e := newOwnerEnv(t) - wantFailedOpen(t, e, []Plugin{e.storagePlugin(nil)}, ErrComposition) + errStore := errors.New("fixture store construction failure") + e.fixtureStoreFailure(errStore) + wantFailedOpen(t, e, nil, errStore) { events := eventNames(e.events.all()) - testassert.Require(t, slices.Contains(events, "close:core"), "factory events = %v, want the invalid instance disposed", events) - } - e.assertLockReleased(t) - }) - - t.Run("typed-nil Core storage rolls back through its closer", func(t *testing.T) { - e := newOwnerEnv(t) - wantFailedOpen(t, e, []Plugin{e.storagePlugin((*nilStorage)(nil))}, ErrComposition) - { - events := eventNames(e.events.all()) - testassert.Require(t, slices.Contains(events, "close:core"), "factory events = %v, want the invalid instance disposed", events) + testassert.Require(t, !slices.Contains(events, "close:core"), "factory events = %v, want no storage disposal for a store that never constructed", events) } e.assertLockReleased(t) }) @@ -709,7 +750,8 @@ func TestRuntimeStartupFailuresUnwindAcquiredResources(t *testing.T) { e := newOwnerEnv(t) errRecover := errors.New("test recovery failure") store := &failingListStore{Storage: storage.NewMemory(), err: errRecover} - wantFailedOpen(t, e, []Plugin{e.storagePlugin(store)}, errRecover) + e.fixtureStore(store) + wantFailedOpen(t, e, nil, errRecover) { events := eventNames(e.events.all()) testassert.Require(t, slices.Contains(events, "close:core"), "factory events = %v, want the constructed Runtime scope disposed", events) @@ -741,7 +783,8 @@ func TestRuntimeStartupFailuresUnwindAcquiredResources(t *testing.T) { <-arrive cancel() }() - r, err := e.open(ctx, e.storagePlugin(storage.NewMemory()), first, second) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(ctx, first, second) if r != nil { cancel() t.Fatalf("canceled construction returned a non-nil Runtime (%v)", err) @@ -772,7 +815,8 @@ func TestRuntimeStartupFailuresUnwindAcquiredResources(t *testing.T) { } done := make(chan outcome, 1) go func() { - r, err := e.open(ctx, e.storagePlugin(storage.NewMemory()), gate) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(ctx, gate) done <- outcome{r, err} }() <-arrive @@ -798,7 +842,8 @@ func TestRuntimeStartupFailuresUnwindAcquiredResources(t *testing.T) { writeServiceFile(t, e.configPath, ownerGatedConfigDocument(e.server.URL)) gate := e.ordinaryPlugin("gate", ScopeRuntime, "gate.cap") gate.ValidateConfig = func(json.RawMessage) error { return ErrClosed } - wantFailedOpen(t, e, []Plugin{e.storagePlugin(storage.NewMemory()), gate}, ErrClosed) + e.fixtureStore(storage.NewMemory()) + wantFailedOpen(t, e, []Plugin{gate}, ErrClosed) { events := e.events.all() testassert.Require(t, len(events) == 0, "factory events = %v, want the validator rejection preserved before any factory", events) @@ -823,7 +868,8 @@ func TestRuntimeStartupFailuresUnwindAcquiredResources(t *testing.T) { } done := make(chan outcome, 1) go func() { - r, err := e.open(ctx, e.storagePlugin(storage.NewMemory()), gate) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(ctx, gate) done <- outcome{r, err} }() <-arrive @@ -850,7 +896,7 @@ func TestRuntimeCloseSharesOneCleanupAndReleasesTheLockLast(t *testing.T) { errXClose := errors.New("test scope close failure") blockClose := make(chan struct{}) entered := make(chan struct{}, 1) - storagePlugin := e.corePlugin("core", "session_store", store, func() error { + e.fixtureStoreClose(store, func() error { select { case entered <- struct{}{}: default: @@ -873,11 +919,11 @@ func TestRuntimeCloseSharesOneCleanupAndReleasesTheLockLast(t *testing.T) { } w := e.ordinaryPlugin("w", ScopeWorkspace, "w.cap") - r, err := e.open(context.Background(), storagePlugin, y, x, w) + r, err := e.open(context.Background(), y, x, w) testassert.NoError(t, err) { events := eventNames(e.events.all()) - testassert.Require(t, orderedSubset(events, "open:core", "open:x", "open:y"), "factory events = %v, want dependencies constructed before consumers in stable topological order", events) + testassert.Require(t, orderedSubset(events, "open:x", "open:y"), "factory events = %v, want dependencies constructed before consumers in stable topological order", events) } { _, err := r.workspaces.get(context.Background(), ScopeInfo{Kind: ScopeWorkspace, DataDir: e.dataDir, Workspace: "/ws"}) @@ -900,7 +946,7 @@ func TestRuntimeCloseSharesOneCleanupAndReleasesTheLockLast(t *testing.T) { err := r.Close(canceledCtx) testassert.Require(t, errors.Is(err, context.Canceled), "canceled Close waiter = %v, want its own context error while the shared cleanup continues", err) } - if got, err := e.open(context.Background(), e.corePlugin("contender", "session_store", storage.NewMemory(), nil)); err == nil || !errors.Is(err, ErrOwned) { + if got, err := e.open(context.Background()); err == nil || !errors.Is(err, ErrOwned) { close(blockClose) t.Fatalf("contender open during blocked disposal = (%v, %v), want the lock held until every required close has run", got, err) } @@ -945,7 +991,8 @@ func TestRuntimeReloadSerializesAndShutdownJoinsTheBuild(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { t.Run("two reloads publish successive revisions", func(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) defer func() { { @@ -977,7 +1024,8 @@ func TestRuntimeReloadSerializesAndShutdownJoinsTheBuild(t *testing.T) { validator := &gatedValidator{} gate := e.ordinaryPlugin("gate", ScopeRuntime, "gate.cap") gate.ValidateConfig = validator.ValidateConfig - r, err := e.open(context.Background(), e.storagePlugin(store), gate) + e.fixtureStore(store) + r, err := e.open(context.Background(), gate) testassert.NoError(t, err) defer func() { { @@ -1012,7 +1060,8 @@ func TestRuntimeReloadSerializesAndShutdownJoinsTheBuild(t *testing.T) { validator := &gatedValidator{} gate := e.ordinaryPlugin("gate", ScopeRuntime, "gate.cap") gate.ValidateConfig = validator.ValidateConfig - r, err := e.open(context.Background(), e.storagePlugin(store), gate) + e.fixtureStore(store) + r, err := e.open(context.Background(), gate) testassert.NoError(t, err) arrive := validator.arm() reloadDone := make(chan error, 1) @@ -1051,7 +1100,8 @@ func TestRuntimeLifetimeBelongsToTheProcessNotItsClients(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { t.Run("a canceled request leaves the owner and other work intact", func(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) { _, err := r.createSession(context.Background(), e.dataDir, "solo") @@ -1086,7 +1136,8 @@ func TestRuntimeLifetimeBelongsToTheProcessNotItsClients(t *testing.T) { t.Run("a canceled Runtime context rejects entry before the cancellation watcher runs", func(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) // The exact post-cancellation pre-watcher state: the owned work // context is done while no closure has begun. @@ -1137,14 +1188,16 @@ func TestRuntimeLifetimeBelongsToTheProcessNotItsClients(t *testing.T) { default: t.Fatal("the watcher-started shutdown disposed no plugin") } - again, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + again, err := e.open(context.Background()) testassert.NoError(t, err) testassert.NoError(t, again.Close(context.Background())) }) t.Run("closed access rejects and repeated Close joins the same result", func(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(store)) + e.fixtureStore(store) + r, err := e.open(context.Background()) testassert.NoError(t, err) testassert.NoError(t, r.Close(context.Background())) { @@ -1176,7 +1229,8 @@ func TestRuntimeLifetimeBelongsToTheProcessNotItsClients(t *testing.T) { func TestRuntimePublishOwnerBarrier(t *testing.T) { t.Run("observed cancellation unwinds and reports the context error", func(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -1191,7 +1245,8 @@ func TestRuntimePublishOwnerBarrier(t *testing.T) { t.Run("publication wins while cancellation starts shutdown behind it", func(t *testing.T) { e := newOwnerEnv(t) - r, err := e.open(context.Background(), e.storagePlugin(storage.NewMemory())) + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background()) testassert.NoError(t, err) got, err := r.publishOwner(context.Background()) testassert.Require(t, got == r && err == nil, "publishOwner = (%v, %v), want the same completed owner", got, err) @@ -1206,7 +1261,8 @@ func TestRuntimeWorkspaceIdentityIsLexicalNormalization(t *testing.T) { e := newOwnerEnv(t) opens := make(chan int64, 16) ws := e.countedPlugin("ws", ScopeWorkspace, "ws.cap", opens) - r, err := e.open(context.Background(), e.storagePlugin(store), ws) + e.fixtureStore(store) + r, err := e.open(context.Background(), ws) testassert.NoError(t, err) defer func() { { @@ -1295,56 +1351,225 @@ func TestRuntimeWorkspaceIdentityIsLexicalNormalization(t *testing.T) { }) } -// --- Core storage binding contract --- +// --- the directly constructed baseline services --- -func TestRuntimeCoreStorageBindingContract(t *testing.T) { - t.Run("invalid canonical storage declarations reject before any factory", func(t *testing.T) { - cases := []struct { - name string - plugins func(e *ownerEnv) []Plugin - }{ - {"missing", func(e *ownerEnv) []Plugin { return []Plugin{e.ordinaryPlugin("plain", ScopeRuntime, "plain.cap")} }}, - {"duplicate plugins", func(e *ownerEnv) []Plugin { - return []Plugin{e.storagePlugin(storage.NewMemory()), e.storagePlugin(storage.NewMemory())} - }}, - {"duplicate exports", func(e *ownerEnv) []Plugin { - plugin := e.storagePlugin(storage.NewMemory()) - plugin.Provides = append(plugin.Provides, Spec[harness.Storage]("second_store")) - return []Plugin{plugin} - }}, - {"wrong scope", func(e *ownerEnv) []Plugin { - plugin := e.storagePlugin(storage.NewMemory()) - plugin.Scope = ScopeWorkspace - return []Plugin{plugin} - }}, - {"required as an ordinary dependency", func(e *ownerEnv) []Plugin { - return []Plugin{ - e.storagePlugin(storage.NewMemory()), - e.ordinaryPlugin("needs", ScopeRuntime, "needs.cap", Spec[harness.Storage]("session_store")), - } - }}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - e := newOwnerEnv(t) - wantFailedOpen(t, e, tc.plugins(e), ErrComposition) - e.assertNoOwnershipSideEffects(t) - }) +// TestRuntimeBaselineServicesAreDirectlyConstructed proves the store and jobs +// services have no declaration side: the owner composes its own store over the +// data root and binds its own jobs service even when a plugin exports the +// same shapes, and the one export identity the composition rejects is a +// standard tool name. +func TestRuntimeBaselineServicesAreDirectlyConstructed(t *testing.T) { + t.Run("storage and job-stop exports are ordinary capabilities", func(t *testing.T) { + e := newOwnerEnv(t) + exported := &countingStore{Storage: storage.NewMemory()} + shadow := &stubStopper{} + r, err := e.open(context.Background(), + e.corePlugin("core", "session_store", exported, nil), + e.corePlugin("core.second", "second_store", exported, nil), + jobStopperExportPlugin("shadow", shadow), + ) + testassert.NoError(t, err) + spawned := false + session, err := r.createSession(context.Background(), filepath.Join(e.home, "baseline-ws"), "solo") + testassert.NoError(t, err) + var completionID string + err = startJobThrough(r, session.Identity.SessionID, "0a1b2c3d", func(_ context.Context, id string) error { + spawned = true + completionID = id + return nil + }) + testassert.NoError(t, err) + testassert.Require(t, spawned, "the job never started through the constructed service") + // The exports are ordinary capabilities: the job-stop export never + // becomes the owner's constructed stopper, so the constructed service + // knows nothing of this raw member and its terminal report is owed + // directly before shutdown converges. + testassert.NoError(t, r.withHarness(context.Background(), func(ctx context.Context, h *harness.Harness) error { + return h.DeliverBackgroundCompletion(ctx, session.Identity.SessionID, completionID, "job finished") + })) + testassert.NoError(t, r.Close(context.Background())) + { + _, err := os.Stat(filepath.Join(e.dataDir, "lightcode.db")) + testassert.Require(t, err == nil, "the owner never composed its own store at the data root: %v", err) } + testassert.Equal(t, exported.listCount(), 0) }) - t.Run("names neither grant nor hide canonical authority", func(t *testing.T) { + t.Run("recovery runs on the constructed store", func(t *testing.T) { eachPrepStore(t, func(t *testing.T, store harness.Storage) { e := newOwnerEnv(t) canonical := &countingStore{Storage: store} named := e.ordinaryPlugin("storage", ScopeRuntime, "storage") - plugin := e.corePlugin("state", "whatever", canonical, func() error { e.events.add("close:state"); return nil }) - r, err := e.open(context.Background(), plugin, named) + e.fixtureStore(canonical) + r, err := e.open(context.Background(), named) testassert.NoError(t, err) testassert.NoError(t, r.Close(context.Background())) - testassert.Require(t, canonical.listCount() != 0, "recovery never used the type-identified Core binding") + testassert.Require(t, canonical.listCount() != 0, "recovery never used the constructed store") }) }) + + t.Run("a standard tool name is the rejected export identity", func(t *testing.T) { + e := newOwnerEnv(t) + shadow := Plugin{ + ID: "shadow", + Scope: ScopeRuntime, + Provides: []CapabilitySpec{ToolSpec("write_file", func(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { + return ToolDescription{}, nil + })}, + Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { + return Instance{Values: map[string]any{"write_file": nil}}, nil + }, + } + wantFailedOpen(t, e, []Plugin{shadow}, ErrComposition) + e.assertNoOwnershipSideEffects(t) + }) +} + +// TestRuntimeStandardToolsAreOwnerProvidedCapabilities proves the one +// capability and binding path for the directly constructed baseline tools: the +// owner installs them in the Runtime scope's bindings before any optional +// factory runs, a plugin at every shorter lifetime binds the actual value +// through Bind and can run it, and the nearest forbidden siblings — a duplicate +// export, a wrongly typed dependency — reject before any factory dispatches. +func TestRuntimeStandardToolsAreOwnerProvidedCapabilities(t *testing.T) { + t.Run("the owner's Runtime factory binds the actual read_file value", func(t *testing.T) { + e := newOwnerEnv(t) + workspace := filepath.Join(e.home, "owner-tool-ws") + testassert.NoError(t, os.MkdirAll(workspace, 0o700)) + testassert.NoError(t, os.WriteFile(filepath.Join(workspace, "held.txt"), []byte("owner provided\n"), 0o600)) + var bound Tool + consumer := e.ordinaryPlugin("consumer", ScopeRuntime, "consumer.cap", Spec[Tool]("read_file")) + consumer.Open = func(_ context.Context, _ ScopeInfo, deps Bindings) (Instance, error) { + tool, err := Bind[Tool](deps, "read_file") + if err != nil { + return Instance{}, err + } + bound = tool + e.events.add("open:consumer") + return Instance{Values: map[string]any{"consumer.cap": "consumer"}}, nil + } + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background(), consumer) + testassert.NoError(t, err) + testassert.NoError(t, r.Close(context.Background())) + testassert.Require(t, bound != nil, "the Runtime factory never bound the owner-provided read_file value") + // The bound value is the owner's real tool: it reads one Workspace + // file through its own preparation and execution. + tc := ToolContext{ + Workspace: workspace, + AdmittedEntry: harness.EntryRef{SessionID: testSessionID, EntryID: testEntryID}, + } + arguments, err := bound.Normalize(tc, model.ToolCall{ID: "call-1", Name: "read_file", Arguments: json.RawMessage(`{"path":"held.txt"}`)}) + testassert.NoError(t, err) + prepared := bound.Prepare(context.Background(), tc, model.ToolCall{ID: "call-1", Name: "read_file", Arguments: arguments}) + testassert.Require(t, prepared.Execute != nil, "prepared tool = %+v, want an executable plan", prepared) + outcome := prepared.Execute(context.Background()) + testassert.Require(t, outcome.Result.Status == model.ResultSuccess && strings.Contains(outcome.Result.Content, "owner provided"), "bound tool read = %+v, want the file content", outcome.Result) + }) + + t.Run("Workspace, Operation and Agent consumers bind the owner-provided value", func(t *testing.T) { + var bound []string + consumer := func(id string, kind ScopeKind) Plugin { + return Plugin{ + ID: id, + Scope: kind, + Provides: []CapabilitySpec{Spec[any](id + ".cap")}, + Requires: []CapabilitySpec{Spec[Tool]("read_file")}, + Open: func(_ context.Context, _ ScopeInfo, deps Bindings) (Instance, error) { + tool, err := Bind[Tool](deps, "read_file") + if err != nil { + return Instance{}, err + } + if tool == nil { + return Instance{}, errors.New("the standard tool binding supplied no value") + } + bound = append(bound, string(kind)) + return Instance{Values: map[string]any{id + ".cap": id}}, nil + }, + } + } + c := mustComposition(t, + consumer("rt", ScopeRuntime), + consumer("ws", ScopeWorkspace), + consumer("op", ScopeOperation), + consumer("ag", ScopeAgent)) + c.standardValues = newStandardTools(t.TempDir(), &fakeJobs{}) + rt := mustOpenScope(t, c, context.Background(), runtimeScopeInfo(), nil) + ws := mustOpenScope(t, c, context.Background(), workspaceScopeInfo("/ws/one"), []*scope{rt}) + op := mustOpenScope(t, c, context.Background(), ScopeInfo{Kind: ScopeOperation, DataDir: "/owner/data", Workspace: "/ws/one", SessionID: "s", OperationID: "o"}, []*scope{rt, ws}) + ag := mustOpenScope(t, c, context.Background(), ScopeInfo{Kind: ScopeAgent, DataDir: "/owner/data", Workspace: "/ws/one", SessionID: "s", OperationID: "o"}, []*scope{rt, ws, op}) + for _, sc := range []*scope{rt, ws, op, ag} { + defer func() { _ = sc.close() }() + } + testassert.Require(t, slices.Equal(bound, []string{"runtime", "workspace", "operation", "agent"}), "standard tool bindings = %q, want every lifetime's factory to receive the owner-provided value", bound) + }) + + t.Run("a wrongly typed dependency on a standard tool rejects before any factory", func(t *testing.T) { + e := newOwnerEnv(t) + consumer := e.ordinaryPlugin("consumer", ScopeRuntime, "consumer.cap", Spec[greeter]("read_file")) + wantFailedOpen(t, e, []Plugin{consumer}, ErrComposition) + e.assertNoOwnershipSideEffects(t) + }) + + t.Run("a duplicate export of a standard tool rejects before any factory", func(t *testing.T) { + e := newOwnerEnv(t) + shadow := Plugin{ + ID: "shadow", + Scope: ScopeRuntime, + Provides: []CapabilitySpec{ToolSpec("read_file", func(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { + return ToolDescription{Definition: model.ToolDefinition{Name: "read_file", Parameters: json.RawMessage(`{}`)}}, nil + })}, + Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { + return Instance{Values: map[string]any{"read_file": readTool{}}}, nil + }, + } + wantFailedOpen(t, e, []Plugin{shadow}, ErrComposition) + e.assertNoOwnershipSideEffects(t) + }) +} + +// TestRuntimeSettingsSectionsHaveOneOwner proves the settings-owner split: +// a registered Plugin whose ID names a baseline section owns only its own +// plugins entry, while the top-level tools and jobs sections stay the +// directly constructed baseline services' inputs and never share bytes. +func TestRuntimeSettingsSectionsHaveOneOwner(t *testing.T) { + e := newOwnerEnv(t) + writeServiceFile(t, e.configPath, strings.TrimSuffix(ownerConfigDocument(e.server.URL), "}")+ + `,"tools":{"read_max_lines":7},"jobs":{"max_background_processes":3},`+ + `"plugins":{"capowner":{"kept":1},"tools":{"kept":"plugin tools"},"jobs":{"kept":"plugin jobs"}}}`) + received := map[string]json.RawMessage{} + validator := func(id string) func(json.RawMessage) error { + return func(raw json.RawMessage) error { + received[id] = append(json.RawMessage(nil), raw...) + return nil + } + } + plugins := []Plugin{ + e.ordinaryPlugin("capowner", ScopeRuntime, "capowner.cap"), + e.ordinaryPlugin("tools", ScopeRuntime, "tools.cap"), + e.ordinaryPlugin("jobs", ScopeRuntime, "jobs.cap"), + } + plugins[0].ValidateConfig = validator("capowner") + plugins[1].ValidateConfig = validator("tools") + plugins[2].ValidateConfig = validator("jobs") + e.fixtureStore(storage.NewMemory()) + r, err := e.open(context.Background(), plugins...) + testassert.NoError(t, err) + defer func() { testassert.NoError(t, r.Close(context.Background())) }() + + testassert.Equal(t, string(received["capowner"]), `{"kept":1}`) + testassert.Equal(t, string(received["tools"]), `{"kept":"plugin tools"}`) + testassert.Equal(t, string(received["jobs"]), `{"kept":"plugin jobs"}`) + snapshot := r.config.current() + testassert.Require(t, snapshot != nil, "no published configuration") + testassert.Equal(t, compactJSON(t, snapshot.tools), `{"read_max_lines":7}`) + testassert.Equal(t, compactJSON(t, snapshot.jobs), `{"max_background_processes":3}`) + invocation := Invocation{snapshot: snapshot} + testassert.Equal(t, string(invocation.Config("tools")), `{"kept":"plugin tools"}`) + testassert.Equal(t, string(invocation.Config("jobs")), `{"kept":"plugin jobs"}`) + testassert.Equal(t, compactJSON(t, invocation.toolsSettings()), `{"read_max_lines":7}`) + testassert.Equal(t, compactJSON(t, invocation.jobsSettings()), `{"max_background_processes":3}`) + testassert.Require(t, string(invocation.Config("tools")) != string(invocation.toolsSettings()), "the plugin and baseline tools channels share bytes") } // --- restart recovery before execution --- @@ -1393,8 +1618,8 @@ func TestRuntimeRecoveryRepairsBeforeExecution(t *testing.T) { e := newOwnerEnv(t) sessionID, operationID := seedRunningOperation(t, store, filepath.Join(e.home, "seeded")) var opOpens, agOpens atomic.Int64 + e.fixtureStore(&countingStore{Storage: store}) r, err := e.open(context.Background(), - e.storagePlugin(&countingStore{Storage: store}), shortScopeProbe("probe", ScopeOperation, &opOpens), shortScopeProbe("probe-agent", ScopeAgent, &agOpens)) testassert.NoError(t, err) @@ -1447,9 +1672,9 @@ func TestRuntimeRecoverySettlesQuiescentChildLossWithoutParentCompletion(t *test var opOpens, agOpens atomic.Int64 stopper := &stubStopper{events: e.events} + e.fixtureStore(store) + e.fixtureJobs(stopper) r, err := e.open(ctx, - e.storagePlugin(store), - jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper), shortScopeProbe("probe", ScopeOperation, &opOpens), shortScopeProbe("probe-agent", ScopeAgent, &agOpens)) testassert.NoError(t, err) @@ -1499,9 +1724,9 @@ func TestRuntimeRecoverySettlesQuiescentChildLossWithoutParentCompletion(t *test // A repeated recovery over the same durable state changes nothing. requestsBefore := e.server.requests() - again, err := e.open(ctx, - e.storagePlugin(store), - jobStopperPlugin(e, "jobs", "job-stopper", ScopeRuntime, stopper)) + e.fixtureStore(store) + e.fixtureJobs(stopper) + again, err := e.open(ctx) testassert.NoError(t, err) testassert.Equal(t, e.server.requests(), requestsBefore) testassert.Equal(t, eventsNamed(e.events.all(), "stopjob"), 0) diff --git a/runtime/sqlite_plugin_test.go b/runtime/sqlite_store_test.go similarity index 73% rename from runtime/sqlite_plugin_test.go rename to runtime/sqlite_store_test.go index 5e95c55b..3a6cb4e3 100644 --- a/runtime/sqlite_plugin_test.go +++ b/runtime/sqlite_store_test.go @@ -1,4 +1,4 @@ -package runtime_test +package runtime import ( "context" @@ -14,20 +14,17 @@ import ( "time" "github.com/MMinasyan/lightcode/harness" - "github.com/MMinasyan/lightcode/internal/plugins/builtin" - "github.com/MMinasyan/lightcode/internal/plugins/sqlite" "github.com/MMinasyan/lightcode/internal/storage" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" "github.com/MMinasyan/lightcode/protocol" - "github.com/MMinasyan/lightcode/runtime" ) -// External composition integration: the actual sqlite.Plugin is assembled -// through the test-build OpenForTest bridge, without a runtime-to-plugin -// import cycle. Every test isolates HOME and every bundled provider -// credential, so construction reaches no network, starts no user's Runtime, -// and touches no real .env or discovery cache. +// Composition integration for the included store: the Runtime owner +// constructs the real SQLite store directly under its ownership lock through +// the owner-private store opener. Every test isolates HOME and every bundled +// provider credential, so construction reaches no network, starts no user's +// Runtime, and touches no real .env or discovery cache. const composedConfigDocument = `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}}}` @@ -50,38 +47,10 @@ func newComposeEnv(t *testing.T) *composeEnv { return &composeEnv{t: t, home: home, dataDir: dataDir, configPath: configPath} } -// isolateBundledCredentials empties every api_key_env declared by the bundled -// catalog so a captured build never starts a network attempt. -func isolateBundledCredentials(t *testing.T) { - t.Helper() - entries, err := os.ReadDir(filepath.Join("..", "internal", "catalog", "builtin")) - testassert.NoError(t, err) - for _, entry := range entries { - if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { - continue - } - data, err := os.ReadFile(filepath.Join("..", "internal", "catalog", "builtin", entry.Name())) - if err != nil { - t.Fatalf("read bundled provider %s: %v", entry.Name(), err) - } - var provider struct { - Transport struct { - APIKeyEnv string `json:"api_key_env"` - } `json:"transport"` - } - if err := json.Unmarshal(data, &provider); err != nil { - t.Fatalf("decode bundled provider %s: %v", entry.Name(), err) - } - if provider.Transport.APIKeyEnv != "" { - t.Setenv(provider.Transport.APIKeyEnv, "") - } - } -} - -// openComposed assembles the real sqlite plugin through the test bridge. -func (e *composeEnv) openComposed(ctx context.Context) (*runtime.Runtime, error) { +// openComposed constructs the Runtime over the included store. +func (e *composeEnv) openComposed(ctx context.Context) (*Runtime, error) { e.t.Helper() - return runtime.OpenForTest(ctx, e.dataDir, e.configPath, []runtime.Plugin{sqlite.Plugin()}) + return Open(ctx, Options{DataDir: e.dataDir, ConfigPath: e.configPath}) } // treeFiles maps every file under root, relative slash path to bytes. @@ -143,33 +112,20 @@ func keys(m map[string]string) []string { return out } -// TestSQLitePluginDeclaration exercises the plugin's ordinary Plugin/Instance -// contract directly, the same surface an externally supplied capability -// implements. -func TestSQLitePluginDeclaration(t *testing.T) { - p := sqlite.Plugin() - testassert.Require(t, p.ID == "sqlite" && p.Scope == runtime.ScopeRuntime && len(p.Provides) == 1 && len(p.Requires) == 0, "got %v, want %v; got %v, want %v; got %v, want %v; got %v, want %v", p.ID, "sqlite", p.Scope, runtime.ScopeRuntime, len(p.Provides), 1, len(p.Requires), 0) - testassert.Require(t, p.ValidateConfig == nil, "plugin declares a settings validator, want none") - testassert.Require(t, p.Open != nil, "plugin declares no Open") - - t.Run("open derives the exact database path and returns the declared store with its Close", func(t *testing.T) { +// TestIncludedStoreConstruction exercises the owner's direct store opener: +// the exact database path under the data root, the returned store and its +// closer, and the context check before any filesystem touch. +func TestIncludedStoreConstruction(t *testing.T) { + t.Run("open derives the exact database path and returns the store with its closer", func(t *testing.T) { dir := t.TempDir() - inst, err := p.Open(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dir}, runtime.Bindings{}) + store, closeStore, err := openStore(context.Background(), dir) testassert.NoError(t, err) - testassert.Equal(t, len(inst.Values), 1) - var store harness.Storage - for _, value := range inst.Values { - storageValue, ok := value.(harness.Storage) - testassert.Require(t, ok, "instance export supplies %T, not harness.Storage", value) - store = storageValue - } - testassert.Require(t, store != nil, "instance export supplies a nil harness.Storage") - testassert.Require(t, inst.Close != nil, "instance declares no Close for the opened store") + testassert.Require(t, store != nil && closeStore != nil, "openStore returned (%v, closer supplied %v), want the store and its closer", store, closeStore != nil) { _, err := os.Stat(filepath.Join(dir, "lightcode.db")) testassert.NoError(t, err) } - testassert.NoError(t, inst.Close()) + testassert.NoError(t, closeStore()) reopened, err := storage.OpenSQLite(filepath.Join(dir, "lightcode.db")) testassert.NoError(t, err) testassert.NoError(t, reopened.Close()) @@ -179,18 +135,18 @@ func TestSQLitePluginDeclaration(t *testing.T) { dir := t.TempDir() ctx, cancel := context.WithCancel(context.Background()) cancel() - inst, err := p.Open(ctx, runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: dir}, runtime.Bindings{}) - testassert.Require(t, inst.Values == nil && inst.Close == nil, "canceled Open returned the instance %+v", inst) - testassert.Require(t, errors.Is(err, context.Canceled), "canceled Open error = %v, want the context error", err) + store, closeStore, err := openStore(ctx, dir) + testassert.Require(t, store == nil && closeStore == nil, "canceled openStore returned (%v, closer supplied %v)", store, closeStore != nil) + testassert.Require(t, errors.Is(err, context.Canceled), "canceled openStore error = %v, want the context error", err) { _, err := os.Stat(filepath.Join(dir, "lightcode.db")) - testassert.Require(t, os.IsNotExist(err), "a canceled Open touched the filesystem: stat err = %v", err) + testassert.Require(t, os.IsNotExist(err), "a canceled openStore touched the filesystem: stat err = %v", err) } }) } -// TestComposedSQLiteRuntime assembles the real plugin through the test-build -// bridge over real temporary SQLite: R1-R3 and R13 composition behavior. +// TestComposedSQLiteRuntime constructs the Runtime over the included +// store: R1-R3 and R13 composition behavior. func TestComposedSQLiteRuntime(t *testing.T) { ctx := context.Background() @@ -231,7 +187,7 @@ func TestComposedSQLiteRuntime(t *testing.T) { contender := newComposeEnv(t) rivalData := e.dataDir contender.dataDir = rivalData - if got, err := contender.openComposed(ctx); err == nil || !errors.Is(err, runtime.ErrOwned) { + if got, err := contender.openComposed(ctx); err == nil || !errors.Is(err, ErrOwned) { _ = r.Close(ctx) t.Fatalf("contender open = (%v, %v), want (nil, ErrOwned)", got, err) } @@ -260,11 +216,11 @@ func TestComposedSQLiteRuntime(t *testing.T) { testassert.NoError(t, os.WriteFile(filepath.Join(e.dataDir, "lightcode.db"), []byte("not a database at all"), 0o600)) r, err := e.openComposed(ctx) testassert.Require(t, r == nil, "failed assembly returned a Runtime (%v)", err) - testassert.Require(t, err != nil && !errors.Is(err, runtime.ErrOwned), "failed assembly error = %v, want the storage failure, not the ownership identity", err) + testassert.Require(t, err != nil && !errors.Is(err, ErrOwned), "failed assembly error = %v, want the storage failure, not the ownership identity", err) // The lock was released: the next attempt fails at storage again rather // than reporting ownership contention, and no partial owner remains. again, err := e.openComposed(ctx) - testassert.Require(t, again == nil && err != nil && !errors.Is(err, runtime.ErrOwned), "open over the failed assembly = (%v, %v), want (nil, the same storage failure)", again, err) + testassert.Require(t, again == nil && err != nil && !errors.Is(err, ErrOwned), "open over the failed assembly = (%v, %v), want (nil, the same storage failure)", again, err) }) t.Run("startup recovery repairs the seeded running state before execution", func(t *testing.T) { @@ -274,7 +230,7 @@ func TestComposedSQLiteRuntime(t *testing.T) { // Registered before the seed's own cleanup, so the parked execution is // released before the store closes. t.Cleanup(func() { _ = store.Close() }) - sessionID, executions := seedRunningOperation(t, store, filepath.Join(e.home, "workspace")) + sessionID, executions := seedRunningOperationCounted(t, store, filepath.Join(e.home, "workspace")) key := harness.RegisterKey{SessionID: sessionID, Kind: harness.RegisterOperation, OperationID: "seed-op"} reg, err := store.ReadRegister(ctx, key) @@ -305,11 +261,11 @@ func TestComposedSQLiteRuntime(t *testing.T) { }) } -// seedRunningOperation commits one running Operation through a standalone +// seedRunningOperationCounted commits one running Operation through a standalone // Harness whose model effect never completes: abandoning that owner is the // durable state restart recovery consumes. The returned counter observes the // model effect, and the parked execution is released during cleanup. -func seedRunningOperation(t *testing.T, store harness.Storage, workspace string) (string, *atomic.Int32) { +func seedRunningOperationCounted(t *testing.T, store harness.Storage, workspace string) (string, *atomic.Int32) { t.Helper() arrived := make(chan struct{}, 1) release := make(chan struct{}) @@ -406,19 +362,19 @@ func readSessionRegister(t *testing.T, store harness.Storage, sessionID string) } // nextComposedEvent reads one event with the test budget applied. -func nextComposedEvent(t *testing.T, sub *runtime.Subscription) (runtime.Event, bool) { +func nextComposedEvent(t *testing.T, sub *Subscription) (Event, bool) { t.Helper() select { case event, ok := <-sub.Events(): return event, ok case <-time.After(10 * time.Second): t.Fatal("no event arrived within the test budget") - return runtime.Event{}, false + return Event{}, false } } // composedEventKind reads one composed event's discriminator. -func composedEventKind(t *testing.T, event runtime.Event) string { +func composedEventKind(t *testing.T, event Event) string { t.Helper() kind, err := event.Discriminator() testassert.NoError(t, err) @@ -427,7 +383,7 @@ func composedEventKind(t *testing.T, event runtime.Event) string { // composedEventGeneration reads one composed configuration event's // generation. -func composedEventGeneration(t *testing.T, event runtime.Event) string { +func composedEventGeneration(t *testing.T, event Event) string { t.Helper() body, err := event.AsConfigurationChangedEvent() testassert.NoError(t, err) @@ -435,7 +391,7 @@ func composedEventGeneration(t *testing.T, event runtime.Event) string { } // composedEventJSON renders one composed event for failure diagnostics. -func composedEventJSON(event runtime.Event) string { +func composedEventJSON(event Event) string { data, err := event.MarshalJSON() if err != nil { return "" @@ -445,7 +401,7 @@ func composedEventJSON(event runtime.Event) string { // composedConfigurationEvent builds one configuration event through the // generated constructor for the composed order oracles. -func composedConfigurationEvent(generation uint64) runtime.Event { +func composedConfigurationEvent(generation uint64) Event { var scope protocol.Scope _ = scope.FromRuntimeScope(protocol.RuntimeScope{Kind: protocol.RuntimeScopeKindRuntime}) // plain members; the marshal cannot fail var event protocol.Event @@ -486,7 +442,7 @@ func TestComposedSQLiteRuntimeObservationShutdownAndOwnership(t *testing.T) { testassert.Require(t, !ok, "saturated subscriber still open at the second publication, want it removed and closed") } sat.Close() - var observed []runtime.Event + var observed []Event for _, want := range []string{"2", "3"} { event, ok := nextComposedEvent(t, healthy) if !ok || composedEventKind(t, event) != "configuration_changed" || composedEventGeneration(t, event) != want { @@ -506,42 +462,20 @@ func TestComposedSQLiteRuntimeObservationShutdownAndOwnership(t *testing.T) { } observed = append(observed, event) } - want := []runtime.Event{ + want := []Event{ composedConfigurationEvent(2), composedConfigurationEvent(3), composedRuntimeScopeClosed(), } - testassert.Require(t, slices.EqualFunc(observed, want, runtime.EqualEventForTest), "healthy subscriber events = %+v, want the observed revisions plus the Runtime closure in publication order %+v", observed, want) + testassert.Require(t, slices.EqualFunc(observed, want, EqualEventForTest), "healthy subscriber events = %+v, want the observed revisions plus the Runtime closure in publication order %+v", observed, want) again, err := e.openComposed(ctx) testassert.NoError(t, err) testassert.NoError(t, again.Close(ctx)) } -// TestBuiltinRegistrationComposes proves the shipped registration set — the -// SQLite storage plugin, the native tools, the bundled adaptation, the LSP -// tools, and the background jobs plugin — composes through the real -// construction path: the complete declared set opens over the real plugin's -// database, reload publishes, and shutdown converges leaving exactly the -// plugin-owned files. -func TestBuiltinRegistrationComposes(t *testing.T) { - ctx := context.Background() - e := newComposeEnv(t) - r, err := runtime.OpenForTest(ctx, e.dataDir, e.configPath, builtin.Plugins()) - testassert.NoError(t, err) - { - revision, err := r.Reload(ctx) - testassert.Require(t, err == nil && revision == "2", "Reload = (%q, %v), want 2", revision, err) - } - testassert.NoError(t, r.Close(ctx)) - { - got := sortedNames(t, e.dataDir) - testassert.Require(t, slices.Equal(got, []string{"lightcode.db", "runtime.lock"}), "data root after shutdown = %v, want exactly the real SQLite plugin's files", got) - } -} - // composedRuntimeScopeClosed builds the Runtime closure scope event for the // composed order oracles. -func composedRuntimeScopeClosed() runtime.Event { +func composedRuntimeScopeClosed() Event { var scope protocol.Scope _ = scope.FromRuntimeScope(protocol.RuntimeScope{Kind: protocol.RuntimeScopeKindRuntime}) // plain members; the marshal cannot fail var event protocol.Event diff --git a/internal/plugins/tools/commands_test.go b/runtime/standard_commands_test.go similarity index 96% rename from internal/plugins/tools/commands_test.go rename to runtime/standard_commands_test.go index 9a5987ac..4b4eddb7 100644 --- a/internal/plugins/tools/commands_test.go +++ b/runtime/standard_commands_test.go @@ -1,4 +1,4 @@ -package tools +package runtime import ( "context" @@ -8,12 +8,11 @@ import ( "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/runtime" ) func TestRunCommandNormalization(t *testing.T) { byID := directTools(t.TempDir()) - tc := callToolContext(t.TempDir(), runtime.ToolConstraints{}) + tc := callToolContext(t.TempDir(), ToolConstraints{}) runCommand := byID["run_command"] t.Run("applies the default timeout and strips private fields", func(t *testing.T) { @@ -104,7 +103,7 @@ func TestCommandOutcomeClassifiesByDeliveredCause(t *testing.T) { cancel() return result, err } - outcome := commandOutcome(ctx, "call-1", "kill -TERM $$", "", 0, defaultSettings(), t.TempDir(), nil) + outcome := commandOutcome(ctx, "call-1", "kill -TERM $$", "", 0, defaultToolSettings(), t.TempDir(), nil) testassert.Require(t, outcome.Result.Status == model.ResultError && outcome.Result.Content == "Error: Exit code -1\n", "result = (%v, %q), want the real signal-death result as an error", outcome.Result.Status, outcome.Result.Content) }) @@ -116,7 +115,7 @@ func TestCommandOutcomeClassifiesByDeliveredCause(t *testing.T) { cancel() return result, err } - outcome := commandOutcome(ctx, "call-2", "sleep 5", "", 1, defaultSettings(), t.TempDir(), nil) + outcome := commandOutcome(ctx, "call-2", "sleep 5", "", 1, defaultToolSettings(), t.TempDir(), nil) testassert.Require(t, outcome.Result.Status == model.ResultError && strings.HasPrefix(outcome.Result.Content, "Error: Exit code -1 (timeout)\n"), "result = (%v, %q), want the retained timeout text as an error", outcome.Result.Status, outcome.Result.Content) }) } diff --git a/internal/plugins/tools/descriptions.go b/runtime/standard_tool_descriptions.go similarity index 98% rename from internal/plugins/tools/descriptions.go rename to runtime/standard_tool_descriptions.go index a94b4cbb..37fec0a2 100644 --- a/internal/plugins/tools/descriptions.go +++ b/runtime/standard_tool_descriptions.go @@ -1,4 +1,4 @@ -package tools +package runtime // Model-facing tool descriptions and JSON parameter schemas. The texts mirror // the retained concrete-tool surface; the target read_file description drops @@ -99,7 +99,7 @@ const runCommandDescription = `Executes a shell command and returns combined std - Each call starts a fresh shell in the project root. Environment variables, aliases, and working directory do not persist between calls. Use "cd /path && command" if you need a different working directory. - Foreground commands use the default timeout. Background commands run until they exit, are killed, or reach an explicit timeout parameter. - For commands that may run for a long time, keep producing output, wait on external state, and are not needed before your next step, set background=true. It returns immediately with a process ID. You will be notified when it finishes. To read output while it is still running, use sleep to wait, then process to read the output. To kill it, use process. Do not use background=true for commands that will probably finish in a few seconds. -- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use .` +- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.` const runCommandParameters = `{ "type": "object", diff --git a/internal/plugins/tools/tools.go b/runtime/standard_tools.go similarity index 69% rename from internal/plugins/tools/tools.go rename to runtime/standard_tools.go index 49a733a5..e82dc42f 100644 --- a/internal/plugins/tools/tools.go +++ b/runtime/standard_tools.go @@ -1,10 +1,12 @@ -// Package tools declares Lightcode's native file-tools plugin: read_file, -// write_file, edit_file and apply_patch as ordinary Runtime-scoped Tool -// exports. It is an ordinary composition unit selected through static -// registration — the same Plugin/Instance contract an externally supplied -// capability implements, with no additional Runtime authority and no legacy -// authorization wrapper. -package tools +package runtime + +// The baseline standard tools: the seven owner-constructed Tool +// implementations — read_file, write_file, edit_file, and apply_patch over the +// shared file preparation, snapshot bookkeeping, and artifact-derived code +// groups, plus the foreground/background command, process, and sleep tools. +// They are ordinary composition members compiled directly into every +// Runtime: they are never optional plugin exports, and file tools carry no +// process dependency. import ( "bytes" @@ -20,69 +22,72 @@ import ( "github.com/MMinasyan/lightcode/harness" "github.com/MMinasyan/lightcode/internal/config" "github.com/MMinasyan/lightcode/internal/editpreview" + "github.com/MMinasyan/lightcode/internal/jobs" "github.com/MMinasyan/lightcode/internal/pathutil" "github.com/MMinasyan/lightcode/internal/shellparse" "github.com/MMinasyan/lightcode/internal/snapshot" "github.com/MMinasyan/lightcode/internal/tool" "github.com/MMinasyan/lightcode/internal/toolargs" "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/plugins/jobs" - "github.com/MMinasyan/lightcode/runtime" ) -const ( - pluginID = "tools" - - // maxDiagnosticBytes is the durable diagnostic bound for the - // validation-class diagnostics this plugin produces, matching the - // Harness's own tool-boundary bound. - maxDiagnosticBytes = 512 -) +// maxDiagnosticBytes is the durable diagnostic bound for the +// validation-class diagnostics these tools produce, matching the Harness's +// own tool-boundary bound. +const maxDiagnosticBytes = 512 -// settings is the plugin's private configuration, decoded with json.Unmarshal -// from the plugins.tools section. Nil input and missing or null-valued +// toolSettings is the tools' private configuration, decoded with json.Unmarshal +// from the top-level tools section. Nil input and missing or null-valued // members keep the defaults; every consumed limit must be positive, and // command_timeout must fit its seconds-to-time.Duration conversion. Every // other member is ignored, including max_background_processes regardless of // its JSON value. -type settings struct { +type toolSettings struct { MaxOutputBytes int `json:"max_output_bytes"` ReadMaxLines int `json:"read_max_lines"` ReadLineMaxChars int `json:"read_line_max_chars"` CommandTimeout int `json:"command_timeout"` } -func defaultSettings() settings { - return settings{MaxOutputBytes: 15360, ReadMaxLines: 500, ReadLineMaxChars: 5000, CommandTimeout: 120} +func defaultToolSettings() toolSettings { + return toolSettings{MaxOutputBytes: 15360, ReadMaxLines: 500, ReadLineMaxChars: 5000, CommandTimeout: 120} } -// decodeSettings decodes one supplied plugins.tools section on top of the -// defaults and validates every consumed limit. -func decodeSettings(raw json.RawMessage) (settings, error) { - s := defaultSettings() +// decodeToolSettings decodes one supplied top-level tools section on top of +// the defaults and validates every consumed limit. +func decodeToolSettings(raw json.RawMessage) (toolSettings, error) { + s := defaultToolSettings() if len(bytes.TrimSpace(raw)) != 0 { if err := json.Unmarshal(raw, &s); err != nil { - return settings{}, fmt.Errorf("tools: invalid settings: %w", err) + return toolSettings{}, fmt.Errorf("tools: invalid settings: %w", err) } } switch { case s.MaxOutputBytes <= 0: - return settings{}, errors.New("tools: max_output_bytes must be positive") + return toolSettings{}, errors.New("tools: max_output_bytes must be positive") case s.ReadMaxLines <= 0: - return settings{}, errors.New("tools: read_max_lines must be positive") + return toolSettings{}, errors.New("tools: read_max_lines must be positive") case s.ReadLineMaxChars <= 0: - return settings{}, errors.New("tools: read_line_max_chars must be positive") + return toolSettings{}, errors.New("tools: read_line_max_chars must be positive") case s.CommandTimeout <= 0: - return settings{}, errors.New("tools: command_timeout must be positive") + return toolSettings{}, errors.New("tools: command_timeout must be positive") case int64(s.CommandTimeout) > math.MaxInt64/int64(time.Second): - return settings{}, errors.New("tools: command_timeout overflows the seconds-to-duration conversion") + return toolSettings{}, errors.New("tools: command_timeout overflows the seconds-to-duration conversion") } return s, nil } +// validateToolsSettings validates one captured top-level tools section; the +// configuration candidate construction calls it directly for every +// publication. +func validateToolsSettings(raw json.RawMessage) error { + _, err := decodeToolSettings(raw) + return err +} + // toolsConfig projects the decoded settings onto the shared file-tool // configuration. -func (s settings) toolsConfig() config.ToolsConfig { +func (s toolSettings) toolsConfig() config.ToolsConfig { return config.ToolsConfig{ MaxOutputBytes: s.MaxOutputBytes, ReadMaxLines: s.ReadMaxLines, @@ -90,21 +95,19 @@ func (s settings) toolsConfig() config.ToolsConfig { } } -// instance is one Open's constructed state: the owner data root and the jobs -// capability the background command and process paths call. It keeps no -// Session-indexed state, no handle tags, no cached read records and no -// reset or eviction hooks: every mutating call opens its code group fresh -// from the calling Operation's admitted-input identity and drops it -// afterward. No environment key set is captured at Open — the command paths -// resolve the live subprocess environment through the ToolContext's -// call-time producer, so keys connected after this Open are still scrubbed. -type instance struct { - dataDir string - jobs jobs.Jobs -} +// standardTools is the constructed file-tool state: the owner data root the +// mutating calls derive their code groups from and the command spills derive +// their output directory from. It keeps no Session-indexed state, no handle +// tags, no cached read records and no reset or eviction hooks, and no process +// dependency: every mutating call opens its code group fresh from the calling +// Operation's admitted-input identity and drops it afterward. No environment +// key set is captured here — the command paths resolve the live subprocess +// environment through the ToolContext's call-time producer, and the jobs +// service is a field of the two tools that call it. +type standardTools struct{ dataDir string } -func (in *instance) settings(inv runtime.Invocation) (settings, error) { - return decodeSettings(inv.Config(pluginID)) +func (in *standardTools) settings(inv Invocation) (toolSettings, error) { + return decodeToolSettings(inv.toolsSettings()) } // openCodeGroup opens the calling Operation's one disk code group, keyed by @@ -114,34 +117,9 @@ func (in *instance) settings(inv runtime.Invocation) (settings, error) { // so every normally delivered head — steering included — opens its own group. // The handle is opened per call, shared by that call's sub-operations, and // not retained. -func (in *instance) openCodeGroup(tc runtime.ToolContext) (codeGroupStore, error) { +func (in *standardTools) openCodeGroup(tc ToolContext) (*snapshot.CodeStore, error) { directory := filepath.Join(in.dataDir, "code", tc.AdmittedEntry.SessionID, tc.AdmittedEntry.EntryID) - store, err := snapshot.OpenCodeStore(directory) - if err != nil { - return codeGroupStore{}, err - } - return codeGroupStore{CodeStore: store}, nil -} - -// codeGroupStore adapts one per-call snapshot.CodeStore to the shared -// internal/tool SnapshotStore interface. The shared bodies type-assert and -// prefer the transactional path — the promoted SnapshotResolvedEntry plus the -// discard/retain/lock/identity methods — so Snapshot and CurrentTurn exist -// only to satisfy the interface; the transactional path preempts them. They -// are interface compliance, not dead code. -type codeGroupStore struct { - *snapshot.CodeStore -} - -// CurrentTurn pins the group's single turn: an Operation-backed code group -// holds one preimage set, not a session turn history. -func (codeGroupStore) CurrentTurn() int { return 1 } - -// Snapshot satisfies the plain SnapshotStore surface through the resolved -// entry capture; it never runs because the transactional path preempts it. -func (s codeGroupStore) Snapshot(turn int, absPath string) error { - _, _, err := s.SnapshotResolvedEntry(turn, absPath, absPath) - return err + return snapshot.OpenCodeStore(directory) } // immediateError is the normalization-class immediate outcome: status error @@ -161,7 +139,7 @@ func immediateDenied(callID string) harness.PreparedTool { }} } -// boundedDiagnostic renders one plugin-produced validation diagnostic within +// boundedDiagnostic renders one tool-produced validation diagnostic within // the durable diagnostic bound. func boundedDiagnostic(cause error) string { msg := cause.Error() @@ -193,7 +171,7 @@ func canonicalPaths(root, writeDir string) (workspace, boundary string, err erro // canonicalPathsFn is the one seam over preparation canonicalization: both // containment inputs route through it, so tests flip a symlink between the -// plugin's resolutions and the shared preparation's binding. Nil-free in +// tool's resolutions and the shared preparation's binding. Nil-free in // production; it simply delegates to canonicalPaths. var canonicalPathsFn = canonicalPaths @@ -214,9 +192,9 @@ func permissionPairs(permission string, targets []tool.PreparedTarget) []harness // stringOutcome runs one prepared string-result call and wraps the outcome: // the model-visible content is the shared body's own bounded output, and an // optional metadata converter turns the committed normalized arguments plus -// the result into the plugin-owned metadata shape. A nil metadata map or a -// failed well-formedness marshal drops the optional metadata; the result -// itself always commits. +// the result into the owned metadata shape. A nil metadata map or a failed +// well-formedness marshal drops the optional metadata; the result itself +// always commits. func stringOutcome(ctx context.Context, callID string, call *tool.PreparedCall, args string, metadata func(args, result string) map[string]any) harness.ToolOutcome { result, err := call.Execute(ctx) if err != nil { @@ -266,12 +244,12 @@ func normalizeCallArguments(call model.ToolCall, normalize func(map[string]any) return json.Marshal(normalized) } -// readTool is the read_file export: always available, no metadata, the +// readTool is the read_file tool: always available, no metadata, the // nil-tracker read path that always returns the bounded requested content. -type readTool struct{ inst *instance } +type readTool struct{ inst *standardTools } -func (readTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return runtime.ToolDescription{ +func (readTool) describe(_ Invocation, _ ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { + return ToolDescription{ Definition: model.ToolDefinition{ Name: "read_file", Description: readFileDescription, @@ -281,7 +259,7 @@ func (readTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ harn }, nil } -func (t readTool) Normalize(tc runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { +func (t readTool) Normalize(tc ToolContext, call model.ToolCall) (json.RawMessage, error) { s, err := t.inst.settings(tc.Invocation) if err != nil { return nil, err @@ -291,7 +269,7 @@ func (t readTool) Normalize(tc runtime.ToolContext, call model.ToolCall) (json.R }) } -func (t readTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { +func (t readTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { s, err := t.inst.settings(tc.Invocation) if err != nil { return immediateError(call.ID, err) @@ -329,11 +307,11 @@ type mutationPrepared struct { // prepareMutation is the one shared mutation-Prepare body: the strict // decode-and-pass of the committed normalized arguments, the canonical -// root/write-dir binding, the per-call -// code group, and the assembled executor plan with its file.write pairs. -// The per-tool callback runs the shared preparation and wraps the outcome; -// its error is a failed canonical preparation. -func (in *instance) prepareMutation(tc runtime.ToolContext, call model.ToolCall, run func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group codeGroupStore, args map[string]any) (mutationPrepared, error)) harness.PreparedTool { +// root/write-dir binding, the per-call code group, and the assembled executor +// plan with its file.write pairs. The per-tool callback runs the shared +// preparation and wraps the outcome; its error is a failed canonical +// preparation. +func (in *standardTools) prepareMutation(tc ToolContext, call model.ToolCall, run func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group *snapshot.CodeStore, args map[string]any) (mutationPrepared, error)) harness.PreparedTool { args, err := toolargs.Decode(call.Arguments) if err != nil { return immediateError(call.ID, err) @@ -358,19 +336,19 @@ func (in *instance) prepareMutation(tc runtime.ToolContext, call model.ToolCall, } } -// writeTool is the write_file export. -type writeTool struct{ inst *instance } +// writeTool is the write_file tool. +type writeTool struct{ inst *standardTools } -func (t writeTool) describe(_ runtime.Invocation, constraints runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return mutationDescription("write_file", writeFileDescription, writeFileParameters, false, constraints) +func (t writeTool) describe(_ Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { + return mutationDescription("write_file", writeFileDescription, writeFileParameters, constraints) } -func (writeTool) Normalize(_ runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { +func (writeTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { return normalizeCallArguments(call, tool.NormalizeWriteArgs) } -func (t writeTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { - return t.inst.prepareMutation(tc, call, func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group codeGroupStore, args map[string]any) (mutationPrepared, error) { +func (t writeTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { + return t.inst.prepareMutation(tc, call, func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group *snapshot.CodeStore, args map[string]any) (mutationPrepared, error) { prepared, err := tool.PrepareWriteCall(root, rootCanonical, writeDirCanonical, opts, group, args) if err != nil { return mutationPrepared{}, err @@ -384,20 +362,20 @@ func (t writeTool) Prepare(_ context.Context, tc runtime.ToolContext, call model }) } -// editTool is the edit_file export; its metadata carries the edit_preview -// diff built from the committed normalized arguments and the result. -type editTool struct{ inst *instance } +// editTool is the edit_file tool; its metadata carries the edit_preview diff +// built from the committed normalized arguments and the result. +type editTool struct{ inst *standardTools } -func (t editTool) describe(_ runtime.Invocation, constraints runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return mutationDescription("edit_file", editFileDescription, editFileParameters, false, constraints) +func (t editTool) describe(_ Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { + return mutationDescription("edit_file", editFileDescription, editFileParameters, constraints) } -func (editTool) Normalize(_ runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { +func (editTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { return normalizeCallArguments(call, tool.NormalizeEditArgs) } -func (t editTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { - return t.inst.prepareMutation(tc, call, func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group codeGroupStore, args map[string]any) (mutationPrepared, error) { +func (t editTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { + return t.inst.prepareMutation(tc, call, func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group *snapshot.CodeStore, args map[string]any) (mutationPrepared, error) { prepared, err := tool.PrepareEditCall(root, rootCanonical, writeDirCanonical, opts, group, args) if err != nil { return mutationPrepared{}, err @@ -411,20 +389,20 @@ func (t editTool) Prepare(_ context.Context, tc runtime.ToolContext, call model. }) } -// patchTool is the apply_patch export; it is DefaultHidden like its legacy -// counterpart, so only model adaptations that include it can see it. -type patchTool struct{ inst *instance } +// patchTool is the apply_patch tool; it is advertised exactly like every +// other selected eligible tool. +type patchTool struct{ inst *standardTools } -func (t patchTool) describe(_ runtime.Invocation, constraints runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return mutationDescription("apply_patch", applyPatchDescription, applyPatchParameters, true, constraints) +func (t patchTool) describe(_ Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { + return mutationDescription("apply_patch", applyPatchDescription, applyPatchParameters, constraints) } -func (patchTool) Normalize(_ runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { +func (patchTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { return normalizeCallArguments(call, tool.NormalizePatchArgs) } -func (t patchTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { - return t.inst.prepareMutation(tc, call, func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group codeGroupStore, args map[string]any) (mutationPrepared, error) { +func (t patchTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { + return t.inst.prepareMutation(tc, call, func(root, rootCanonical, writeDirCanonical string, opts tool.CapabilityOptions, group *snapshot.CodeStore, args map[string]any) (mutationPrepared, error) { prepared, err := tool.PreparePatchCall(root, rootCanonical, writeDirCanonical, opts, group, args) if err != nil { return mutationPrepared{}, err @@ -438,7 +416,7 @@ func (t patchTool) Prepare(_ context.Context, tc runtime.ToolContext, call model }) } -// runCommandTool is the run_command export: always available; the calling +// runCommandTool is the run_command tool: always available; the calling // Agent's readonly constraint selects the implementation. The readonly // variant authorizes and executes the read-only allowlist's rewritten // command; its foreground path keeps the captured default timeout — a model @@ -449,15 +427,20 @@ func (t patchTool) Prepare(_ context.Context, tc runtime.ToolContext, call model // segment, falls back to one complete target for everything else, and always // executes through the shell. background=true starts a job through the // background bridge and returns the retained immediate text; every start -// failure renders through the one wrapper. -type runCommandTool struct{ inst *instance } +// failure renders through the one wrapper. Its own fields are the file-tool +// state (for the spill directory and settings) and the jobs service the +// background start calls. +type runCommandTool struct { + inst *standardTools + jobs jobs.Jobs +} -func (runCommandTool) describe(_ runtime.Invocation, constraints runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { +func (runCommandTool) describe(_ Invocation, constraints ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { description := runCommandDescription if constraints.Readonly { description = tool.ReadOnlyRunCommandDescription } - return runtime.ToolDescription{ + return ToolDescription{ Definition: model.ToolDefinition{ Name: "run_command", Description: description, @@ -467,7 +450,7 @@ func (runCommandTool) describe(_ runtime.Invocation, constraints runtime.ToolCon }, nil } -func (t runCommandTool) Normalize(tc runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { +func (t runCommandTool) Normalize(tc ToolContext, call model.ToolCall) (json.RawMessage, error) { s, err := t.inst.settings(tc.Invocation) if err != nil { return nil, err @@ -477,7 +460,7 @@ func (t runCommandTool) Normalize(tc runtime.ToolContext, call model.ToolCall) ( }) } -func (t runCommandTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { +func (t runCommandTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { s, err := t.inst.settings(tc.Invocation) if err != nil { return immediateError(call.ID, err) @@ -570,7 +553,7 @@ var errNoSubprocessEnv = errors.New("run_command: no subprocess environment prod // the ToolContext's call-time producer. A missing callback — or a producer // reporting no environment — is an ordinary error the caller renders with no // process start; there is no ambient-environment fallback. -func commandEnvironment(tc runtime.ToolContext) ([]string, error) { +func commandEnvironment(tc ToolContext) ([]string, error) { if tc.SubprocessEnv == nil { return nil, errNoSubprocessEnv } @@ -594,11 +577,11 @@ func commandEnvironmentFailure(callID string, cause error) harness.ToolOutcome { // reservation when the bridge or the start rejects, and returns the retained // immediate template on success. Every start failure — reserve, handoff, or // spawn — renders through the one legacy wrapper. -func (t runCommandTool) backgroundOutcome(ctx context.Context, callID, command string, timeoutSec int, tc runtime.ToolContext) harness.ToolOutcome { +func (t runCommandTool) backgroundOutcome(ctx context.Context, callID, command string, timeoutSec int, tc ToolContext) harness.ToolOutcome { if tc.SubprocessEnv == nil { return commandEnvironmentFailure(callID, errNoSubprocessEnv) } - jobsInst := t.inst.jobs + jobsInst := t.jobs sessionID := tc.AdmittedEntry.SessionID jobID, err := jobsInst.Reserve() if err != nil { @@ -619,7 +602,7 @@ func (t runCommandTool) backgroundOutcome(ctx context.Context, callID, command s Command: command, TimeoutSec: timeoutSec, Env: env, - Config: tc.Invocation.Config("jobs"), + Config: tc.Invocation.jobsSettings(), OnExit: func(er jobs.ExitResult) { _ = tc.Background.DeliverCompletion(context.Background(), sessionID, completionID, legacyCompletionText(er)) // terminal for the job's completion; the exit callback has nowhere to propagate }, @@ -690,11 +673,11 @@ func truncateCompletionText(text string, limit int) string { // completed run settles as success or — for a nonzero exit — the same // ExitError output the legacy engine reports as an error; a configured // timeout settles as an error result with the retained timeout text; the -// runner's delivered-cause cancellation settles as an interrupted result -// with the retained cancellation text. The classification reads the cause -// the runner flagged on the error, never the context after the runner has -// settled the real result. -func commandOutcome(ctx context.Context, callID, command, dir string, timeoutSec int, s settings, spillDir string, env []string) harness.ToolOutcome { +// runner's delivered-cause cancellation settles as an interrupted result with +// the retained cancellation text. The classification reads the cause the +// runner flagged on the error, never the context after the runner has settled +// the real result. +func commandOutcome(ctx context.Context, callID, command, dir string, timeoutSec int, s toolSettings, spillDir string, env []string) harness.ToolOutcome { result, err := runForegroundCommandFn(ctx, command, dir, timeoutSec, s.MaxOutputBytes, s.ReadLineMaxChars, spillDir, env) var exitErr *tool.ExitError if errors.As(err, &exitErr) { @@ -710,13 +693,13 @@ func commandOutcome(ctx context.Context, callID, command, dir string, timeoutSec return harness.ToolOutcome{Result: model.ToolResult{CallID: callID, Status: model.ResultSuccess, Content: result}} } -// sleepTool is the sleep export: normalization clamps to integer seconds +// sleepTool is the sleep tool: normalization clamps to integer seconds // 1..300, the fixed target * is the one the built-in policy allows, and // execution observes cancellation as an interrupted result. type sleepTool struct{} -func (sleepTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return runtime.ToolDescription{ +func (sleepTool) describe(_ Invocation, _ ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { + return ToolDescription{ Definition: model.ToolDefinition{ Name: "sleep", Description: sleepDescription, @@ -726,11 +709,11 @@ func (sleepTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ har }, nil } -func (sleepTool) Normalize(_ runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { +func (sleepTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { return normalizeCallArguments(call, tool.NormalizeSleepArgs) } -func (sleepTool) Prepare(_ context.Context, _ runtime.ToolContext, call model.ToolCall) harness.PreparedTool { +func (sleepTool) Prepare(_ context.Context, _ ToolContext, call model.ToolCall) harness.PreparedTool { args, err := toolargs.Decode(call.Arguments) if err != nil { return immediateError(call.ID, err) @@ -757,14 +740,14 @@ func (sleepTool) Prepare(_ context.Context, _ runtime.ToolContext, call model.To } } -// processTool is the process export: always available; it maps the retained -// read/kill/list actions onto the jobs capability under the owner-scoped +// processTool is the process tool: always available; it maps the retained +// read/kill/list actions onto the jobs service under the owner-scoped // canonical target / (read/kill) or the fixed target * -// (list). -type processTool struct{ inst *instance } +// (list). The jobs service is its own field. +type processTool struct{ jobs jobs.Jobs } -func (processTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ harness.SessionIdentity) (runtime.ToolDescription, error) { - return runtime.ToolDescription{ +func (processTool) describe(_ Invocation, _ ToolConstraints, _ harness.SessionIdentity) (ToolDescription, error) { + return ToolDescription{ Definition: model.ToolDefinition{ Name: "process", Description: processDescription, @@ -774,11 +757,11 @@ func (processTool) describe(_ runtime.Invocation, _ runtime.ToolConstraints, _ h }, nil } -func (processTool) Normalize(_ runtime.ToolContext, call model.ToolCall) (json.RawMessage, error) { +func (processTool) Normalize(_ ToolContext, call model.ToolCall) (json.RawMessage, error) { return normalizeCallArguments(call, normalizeProcessArgs) } -func (t processTool) Prepare(_ context.Context, tc runtime.ToolContext, call model.ToolCall) harness.PreparedTool { +func (t processTool) Prepare(_ context.Context, tc ToolContext, call model.ToolCall) harness.PreparedTool { args, err := toolargs.Decode(call.Arguments) if err != nil { return immediateError(call.ID, err) @@ -799,13 +782,13 @@ func (t processTool) Prepare(_ context.Context, tc runtime.ToolContext, call mod Permissions: []harness.PermissionRequest{{Permission: harness.PermissionProcessOwn, Target: target}}, Execute: func(_ context.Context) harness.ToolOutcome { if action == "read" { - output, err := t.inst.jobs.Read(sessionID, id) + output, err := t.jobs.Read(sessionID, id) if err != nil { return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: err.Error()}} } return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: output}} } - if err := t.inst.jobs.Kill(sessionID, id); err != nil { + if err := t.jobs.Kill(sessionID, id); err != nil { return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultError, Content: err.Error()}} } return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: fmt.Sprintf("Process %s terminated.", id)}} @@ -815,7 +798,7 @@ func (t processTool) Prepare(_ context.Context, tc runtime.ToolContext, call mod return harness.PreparedTool{ Permissions: []harness.PermissionRequest{{Permission: harness.PermissionProcessOwn, Target: "*"}}, Execute: func(_ context.Context) harness.ToolOutcome { - return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: t.inst.jobs.List(sessionID)}} + return harness.ToolOutcome{Result: model.ToolResult{CallID: call.ID, Status: model.ResultSuccess, Content: t.jobs.List(sessionID)}} }, } default: @@ -842,7 +825,7 @@ func normalizeProcessArgs(args map[string]any) (map[string]any, error) { return clean, nil } -// isJobID reports whether id is inside the jobs capability's +// isJobID reports whether id is inside the jobs service's // 8-lowercase-hex identity namespace. func isJobID(id string) bool { if len(id) != 8 { @@ -862,72 +845,49 @@ func isJobID(id string) bool { // dir — with a nonempty write dir the Harness confines every write to it, so // the tool stays available. The description consumes the hard constraints // only; no execution instance is constructed. -func mutationDescription(name, description, parameters string, defaultHidden bool, constraints runtime.ToolConstraints) (runtime.ToolDescription, error) { - return runtime.ToolDescription{ +func mutationDescription(name, description, parameters string, constraints ToolConstraints) (ToolDescription, error) { + return ToolDescription{ Definition: model.ToolDefinition{ Name: name, Description: description, Parameters: json.RawMessage(parameters), }, - Available: !constraints.Readonly || constraints.WriteDir != "", - DefaultHidden: defaultHidden, + Available: !constraints.Readonly || constraints.WriteDir != "", }, nil } -// Plugin returns the Runtime-scoped plugin whose seven exports are the native -// file tools plus the foreground/background command, process, and sleep -// tools. It requires the jobs capability and binds it strictly — there is no -// degraded mode without it. ValidateConfig validates the owned -// plugins.tools section; Open captures the owner data root the mutating -// calls derive their code groups from and the command spills their output -// directory from, and the bound jobs capability the background and process -// paths call. -func Plugin() runtime.Plugin { - return runtime.Plugin{ - ID: pluginID, - Scope: runtime.ScopeRuntime, - Provides: []runtime.CapabilitySpec{ - runtime.ToolSpec("read_file", readTool{}.describe), - runtime.ToolSpec("write_file", writeTool{}.describe), - runtime.ToolSpec("edit_file", editTool{}.describe), - runtime.ToolSpec("apply_patch", patchTool{}.describe), - runtime.ToolSpec("run_command", runCommandTool{}.describe), - runtime.ToolSpec("process", processTool{}.describe), - runtime.ToolSpec("sleep", sleepTool{}.describe), - }, - Requires: []runtime.CapabilitySpec{ - runtime.Spec[jobs.Jobs]("jobs"), - }, - ValidateConfig: func(raw json.RawMessage) error { - _, err := decodeSettings(raw) - return err - }, - Open: open, - } -} - -// open checks the scope context, binds the declared jobs capability -// strictly, and captures the scope identity's owner data root. Six of the -// seven tool values share that one instance (sleep needs no instance state); -// no per-Session state is created here and no environment key set is -// captured — the command paths resolve the live subprocess environment -// through the ToolContext's call-time producer. -func open(ctx context.Context, info runtime.ScopeInfo, bindings runtime.Bindings) (runtime.Instance, error) { - if err := ctx.Err(); err != nil { - return runtime.Instance{}, err - } - jobsInst, err := runtime.Bind[jobs.Jobs](bindings, "jobs") - if err != nil { - return runtime.Instance{}, err - } - inst := &instance{dataDir: info.DataDir, jobs: jobsInst} - return runtime.Instance{Values: map[string]any{ +// standardToolSpecs returns the seven standard tool declarations in +// declaration order. Every description is pure over the captured Invocation, +// the hard constraints, and the literal description constants, so the +// declarations compile once for every composition with no constructed state. +func standardToolSpecs() []CapabilitySpec { + return []CapabilitySpec{ + ToolSpec("read_file", readTool{}.describe), + ToolSpec("write_file", writeTool{}.describe), + ToolSpec("edit_file", editTool{}.describe), + ToolSpec("apply_patch", patchTool{}.describe), + ToolSpec("run_command", runCommandTool{}.describe), + ToolSpec("process", processTool{}.describe), + ToolSpec("sleep", sleepTool{}.describe), + } +} + +// newStandardTools constructs the seven standard tool values over the owner +// data root and the jobs service: the file tools share one state that carries +// no process dependency, run_command carries the jobs service beside that +// state, process carries only the jobs service, and sleep needs neither; no +// per-Session state is created here and no environment key set is captured — +// the command paths resolve the live subprocess environment through the +// ToolContext's call-time producer. +func newStandardTools(dataDir string, jobsService jobs.Jobs) map[string]Tool { + inst := &standardTools{dataDir: dataDir} + return map[string]Tool{ "read_file": readTool{inst}, "write_file": writeTool{inst}, "edit_file": editTool{inst}, "apply_patch": patchTool{inst}, - "run_command": runCommandTool{inst}, - "process": processTool{inst}, + "run_command": runCommandTool{inst, jobsService}, + "process": processTool{jobsService}, "sleep": sleepTool{}, - }}, nil + } } diff --git a/internal/plugins/tools/tools_test.go b/runtime/standard_tools_test.go similarity index 84% rename from internal/plugins/tools/tools_test.go rename to runtime/standard_tools_test.go index ffe43bb5..27f4a062 100644 --- a/internal/plugins/tools/tools_test.go +++ b/runtime/standard_tools_test.go @@ -1,4 +1,4 @@ -package tools +package runtime import ( "context" @@ -13,16 +13,12 @@ import ( "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/internal/tool" "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/runtime" ) -// Direct plugin contract tests. Outside the runtime package only the zero -// Invocation is constructible, so Normalize/Prepare here exercise the -// defaults path; configuration-bearing behavior is covered by -// ValidateConfig directly and by the runtime-composed tests in the runtime -// package. These tests never open the plugin — the tools plugin requires the -// jobs capability, whose binding only the composed Runtime scope constructs — -// so the concrete tool values are built from one instance directly. +// Direct standard-tool contract tests: the baseline tool values are built +// from one constructed state the way the owner composes them; the zero +// Invocation exercises the defaults path, and configuration-bearing behavior +// is covered by the publication tests in the runtime package. // testEntry is a valid-looking Harness admitted-input identity. const ( @@ -30,29 +26,20 @@ const ( testEntryID = "fedcba9876543210fedcba9876543210" ) -// directTools constructs the plugin's tool exports from one instance the -// way open publishes them, without opening the plugin. -func directTools(dataDir string) map[string]runtime.Tool { - inst := &instance{dataDir: dataDir} - return map[string]runtime.Tool{ - "read_file": readTool{inst}, - "write_file": writeTool{inst}, - "edit_file": editTool{inst}, - "apply_patch": patchTool{inst}, - "run_command": runCommandTool{inst}, - "process": processTool{inst}, - "sleep": sleepTool{}, - } +// directTools constructs the baseline tool values over one data root with no +// jobs service, the way newStandardTools composes them. +func directTools(dataDir string) map[string]Tool { + return newStandardTools(dataDir, nil) } // callToolContext is the ToolContext the direct tests prepare with: a real // temporary Workspace, a valid-looking admitted-input identity, the zero // Invocation (defaults), and the given constraints. -func callToolContext(workspace string, constraints runtime.ToolConstraints) runtime.ToolContext { - return runtime.ToolContext{ +func callToolContext(workspace string, constraints ToolConstraints) ToolContext { + return ToolContext{ Workspace: workspace, AdmittedEntry: harness.EntryRef{SessionID: testSessionID, EntryID: testEntryID}, - Invocation: runtime.Invocation{}, + Invocation: Invocation{}, Constraints: constraints, } } @@ -65,13 +52,13 @@ func makeCall(t *testing.T, name, args string) model.ToolCall { } // prepare normalizes and prepares one call, returning the plan. -func prepare(t *testing.T, tool runtime.Tool, tc runtime.ToolContext, name, args string) harness.PreparedTool { +func prepare(t *testing.T, tool Tool, tc ToolContext, name, args string) harness.PreparedTool { t.Helper() return tool.Prepare(context.Background(), tc, makeCall(t, name, args)) } // normalize runs one tool's normalization over raw argument bytes. -func normalize(t *testing.T, tool runtime.Tool, tc runtime.ToolContext, name, args string) (json.RawMessage, error) { +func normalize(t *testing.T, tool Tool, tc ToolContext, name, args string) (json.RawMessage, error) { t.Helper() return tool.Normalize(tc, makeCall(t, name, args)) } @@ -84,7 +71,7 @@ func groupDir(dataDir, sessionID, entryID string) string { func TestValidateConfigSettings(t *testing.T) { validate := func(raw string) error { - return Plugin().ValidateConfig(json.RawMessage(raw)) + return validateToolsSettings(json.RawMessage(raw)) } for _, raw := range []string{ "", " ", "{}", "null", @@ -125,7 +112,7 @@ func TestDescribeAvailabilityAndDefinitions(t *testing.T) { // The describe functions are static method values needing no instance; // the composed open contract is asserted in the runtime package's // composed tools suite. - describe := map[string]func(runtime.Invocation, runtime.ToolConstraints, harness.SessionIdentity) (runtime.ToolDescription, error){ + describe := map[string]func(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error){ "read_file": readTool{}.describe, "write_file": writeTool{}.describe, "edit_file": editTool{}.describe, @@ -137,20 +124,18 @@ func TestDescribeAvailabilityAndDefinitions(t *testing.T) { cases := []struct { name string - constraints runtime.ToolConstraints + constraints ToolConstraints available map[string]bool }{ - {"unconstrained agent", runtime.ToolConstraints{}, map[string]bool{"read_file": true, "write_file": true, "edit_file": true, "apply_patch": true, "run_command": true, "process": true, "sleep": true}}, - {"readonly without write dir", runtime.ToolConstraints{Readonly: true}, map[string]bool{"read_file": true, "write_file": false, "edit_file": false, "apply_patch": false, "run_command": true, "process": true, "sleep": true}}, - {"readonly with write dir", runtime.ToolConstraints{Readonly: true, WriteDir: "/w"}, map[string]bool{"read_file": true, "write_file": true, "edit_file": true, "apply_patch": true, "run_command": true, "process": true, "sleep": true}}, + {"unconstrained agent", ToolConstraints{}, map[string]bool{"read_file": true, "write_file": true, "edit_file": true, "apply_patch": true, "run_command": true, "process": true, "sleep": true}}, + {"readonly without write dir", ToolConstraints{Readonly: true}, map[string]bool{"read_file": true, "write_file": false, "edit_file": false, "apply_patch": false, "run_command": true, "process": true, "sleep": true}}, + {"readonly with write dir", ToolConstraints{Readonly: true, WriteDir: "/w"}, map[string]bool{"read_file": true, "write_file": true, "edit_file": true, "apply_patch": true, "run_command": true, "process": true, "sleep": true}}, } for _, tc := range cases { for name, want := range tc.available { - got, err := describe[name](runtime.Invocation{}, tc.constraints, harness.SessionIdentity{}) + got, err := describe[name](Invocation{}, tc.constraints, harness.SessionIdentity{}) testassert.Require(t, err == nil, "%s: describe %s: %v", tc.name, name, err) testassert.Check(t, got.Available == want, "%s: describe %s Available = %v, want %v", tc.name, name, got.Available, want) - wantHidden := name == "apply_patch" - testassert.Check(t, got.DefaultHidden == wantHidden, "%s: describe %s DefaultHidden = %v, want %v", tc.name, name, got.DefaultHidden, wantHidden) testassert.Check(t, got.Definition.Name == name, "%s: describe %s declares name %q", tc.name, name, got.Definition.Name) testassert.Check(t, got.Definition.Description != "" && json.Valid(got.Definition.Parameters), "%s: describe %s produced an unusable definition", tc.name, name) } @@ -182,7 +167,7 @@ const wantRunCommandDescription = `Executes a shell command and returns combined - Each call starts a fresh shell in the project root. Environment variables, aliases, and working directory do not persist between calls. Use "cd /path && command" if you need a different working directory. - Foreground commands use the default timeout. Background commands run until they exit, are killed, or reach an explicit timeout parameter. - For commands that may run for a long time, keep producing output, wait on external state, and are not needed before your next step, set background=true. It returns immediately with a process ID. You will be notified when it finishes. To read output while it is still running, use sleep to wait, then process to read the output. To kill it, use process. Do not use background=true for commands that will probably finish in a few seconds. -- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use .` +- Do not use this tool to read file contents — use read_file. Do not use this tool to edit files — use edit_file or write_file.` // wantRunCommandParameters is the independent golden fixture for the one // run_command schema the ordinary and readonly surfaces share. @@ -236,12 +221,12 @@ const wantProcessParameters = `{ // expectations are independent test-local fixtures, not the production // constants. func TestRunCommandDescriptionsAndSchemasMatchRetained(t *testing.T) { - ordinary, err := runCommandTool{}.describe(runtime.Invocation{}, runtime.ToolConstraints{}, harness.SessionIdentity{}) + ordinary, err := runCommandTool{}.describe(Invocation{}, ToolConstraints{}, harness.SessionIdentity{}) testassert.NoError(t, err) testassert.Check(t, ordinary.Definition.Description == wantRunCommandDescription, "ordinary description = %q, want the retained description %q", ordinary.Definition.Description, wantRunCommandDescription) assertJSONEqual(t, ordinary.Definition.Parameters, json.RawMessage(wantRunCommandParameters)) - readonly, err := runCommandTool{}.describe(runtime.Invocation{}, runtime.ToolConstraints{Readonly: true}, harness.SessionIdentity{}) + readonly, err := runCommandTool{}.describe(Invocation{}, ToolConstraints{Readonly: true}, harness.SessionIdentity{}) testassert.NoError(t, err) testassert.Check(t, readonly.Definition.Description == tool.ReadOnlyRunCommandDescription, "readonly description = %q, want the retained readonly description", readonly.Definition.Description) assertJSONEqual(t, readonly.Definition.Parameters, json.RawMessage(wantRunCommandParameters)) @@ -251,16 +236,16 @@ func TestRunCommandDescriptionsAndSchemasMatchRetained(t *testing.T) { // TestProcessDescriptionAndSchemaMatchRetained pins the process tool's // model-visible surface to the independent test-local fixtures. func TestProcessDescriptionAndSchemaMatchRetained(t *testing.T) { - got, err := processTool{}.describe(runtime.Invocation{}, runtime.ToolConstraints{}, harness.SessionIdentity{}) + got, err := processTool{}.describe(Invocation{}, ToolConstraints{}, harness.SessionIdentity{}) testassert.NoError(t, err) testassert.Check(t, got.Definition.Description == wantProcessDescription, "process description = %q, want the retained description %q", got.Definition.Description, wantProcessDescription) assertJSONEqual(t, got.Definition.Parameters, json.RawMessage(wantProcessParameters)) - testassert.Check(t, got.Definition.Name == "process" && got.Available && !got.DefaultHidden, "process definition = %+v, want name process, available, not hidden", got) + testassert.Check(t, got.Definition.Name == "process" && got.Available, "process definition = %+v, want name process and available", got) } func TestNormalizeArguments(t *testing.T) { byID := directTools(t.TempDir()) - tc := callToolContext(t.TempDir(), runtime.ToolConstraints{}) + tc := callToolContext(t.TempDir(), ToolConstraints{}) t.Run("read_file applies defaults and strips private fields", func(t *testing.T) { raw, err := normalize(t, byID["read_file"], tc, "read_file", `{"path":"a.txt","_lightcode_receipt":"x"}`) @@ -379,7 +364,7 @@ func TestPrepareBindingSurvivesCanonicalPathsRepoint(t *testing.T) { t.Run(cell.name, func(t *testing.T) { byID := directTools(t.TempDir()) var workspace, link, flipTo, preFlipWriteDir string - var constraints runtime.ToolConstraints + var constraints ToolConstraints if cell.slot == "root" { realA, realB := t.TempDir(), t.TempDir() flipTo = realB @@ -393,7 +378,7 @@ func TestPrepareBindingSurvivesCanonicalPathsRepoint(t *testing.T) { preFlipWriteDir = wdA link = filepath.Join(workspace, "wd") testassert.NoError(t, os.Symlink(wdA, link)) - constraints = runtime.ToolConstraints{WriteDir: "wd"} + constraints = ToolConstraints{WriteDir: "wd"} } if cell.content != "" { testassert.NoError(t, os.WriteFile(filepath.Join(flipTo, "target.txt"), []byte(cell.content), 0o644)) diff --git a/runtime/surface.go b/runtime/surface.go index d1d0d211..62b3e617 100644 --- a/runtime/surface.go +++ b/runtime/surface.go @@ -2,64 +2,70 @@ package runtime import ( "maps" + "os" + goruntime "runtime" "slices" + "strings" "time" "github.com/MMinasyan/lightcode/harness" "github.com/MMinasyan/lightcode/internal/adaptation" - "github.com/MMinasyan/lightcode/internal/prompt" "github.com/MMinasyan/lightcode/model" + "github.com/MMinasyan/lightcode/prompt" ) // surfaceRequest is the captured pure input of one composed prompt and tool -// surface: the Runtime's once-resolved home, the preparation's Workspace and -// session-start environment, the selected definition's prompt fields, the -// captured Invocation, hard constraints, and session identity, the -// composition's tool specs in the agent's selection order after -// first-occurrence dedupe, the bound ModelAdaptation (nil when unselected), -// and the active model ref. +// surface: the host-supplied identity and global rules directory, the +// preparation's Workspace and session-start environment, the selected +// definition's prompt fields, the captured Invocation, hard constraints, and +// session identity, the compiled tool specs in the agent's selection order +// after first-occurrence dedupe, the bound ModelAdaptation (nil when +// unselected), and the active model ref. type surfaceRequest struct { - home string - workspace string - sessionStart time.Time - promptSize string - promptBody string - invocation Invocation - constraints ToolConstraints - identity harness.SessionIdentity - toolSpecs []CapabilitySpec - adaptation ModelAdaptation - modelRef model.ModelRef + hostIdentity string + globalRulesDir string + workspace string + sessionStart time.Time + promptSize prompt.Mode + promptBody string + invocation Invocation + constraints ToolConstraints + identity harness.SessionIdentity + toolSpecs []CapabilitySpec + adaptation ModelAdaptation + modelRef model.ModelRef +} + +// surfaceResult is one composed prompt with its captured rule diagnostics. +type surfaceResult struct { + Prompt string + Warnings []prompt.Warning } // composeSurface converts the captured inputs into one final owned prompt and // advertised tool surface, with no harness or storage I/O. A non-nil -// adaptation resolves exactly once for the model ref; its error aborts -// preparation. The public value converts once into internal/prompt's existing -// pure input (containers copied, no second matcher), the prompt assembles -// through the shared Service with every retained invariant (none skips rules -// and adaptation; simple and full share the one assembly), and the advertised -// surface walks the tool specs in the agent's selection order after -// first-occurrence dedupe: eligible tools minus the -// excluded names (exclusion always wins over inclusion), minus default-hidden -// tools unless included — an include can never revive a tool the hard +// adaptation resolves exactly once for the model ref and prompt mode; its +// error aborts preparation. The prompt reads the explicitly supplied global +// and Workspace rules, assembles the baseline through the public pure +// assembler, then appends the resolved adaptation's ordered paragraphs last. +// The advertised surface walks the tool specs in the agent's selection order +// after first-occurrence dedupe: every selected eligible tool is advertised +// minus the excluded names — an exclusion can never revive a tool the hard // constraints made ineligible. Every advertised description renders through // the existing adaptation rendering helper. A zero session start samples the // compose time once, so the resulting prompt is captured once. -func composeSurface(req surfaceRequest) (prompt.Result, []model.ToolDefinition, error) { +func composeSurface(req surfaceRequest) (surfaceResult, []model.ToolDefinition, error) { var resolved Adaptation var conv *adaptation.Adaptation if req.adaptation != nil { - value, err := req.adaptation.Resolve(req.modelRef) + value, err := req.adaptation.Resolve(req.modelRef, req.promptSize) if err != nil { - return prompt.Result{}, nil, err + return surfaceResult{}, nil, err } resolved = value conv = &adaptation.Adaptation{ ExcludeTools: slices.Clone(resolved.ExcludeTools), - IncludeTools: slices.Clone(resolved.IncludeTools), - Blocks: slices.Clone(resolved.Blocks), - Additions: maps.Clone(resolved.Additions), + Append: slices.Clone(resolved.Append), ToolDescriptionReplacements: maps.Clone(resolved.ToolDescriptionReplacements), } } @@ -68,19 +74,40 @@ func composeSurface(req surfaceRequest) (prompt.Result, []model.ToolDefinition, if sessionStart.IsZero() { sessionStart = time.Now() } - result := prompt.NewService(req.home).Assemble(req.workspace, sessionStart, prompt.Spec{Size: req.promptSize, Body: req.promptBody, Adapt: conv}) + rules, err := prompt.ReadRules(req.globalRulesDir, req.workspace, req.promptSize) + if err != nil { + return surfaceResult{}, nil, err + } + input := prompt.AssemblyInput{ + Identity: req.hostIdentity, + Instructions: req.promptBody, + Mode: req.promptSize, + Rules: rules, + Workspace: req.workspace, + SessionStart: sessionStart, + } + if req.promptSize != prompt.ModeNone { + input.OS = goruntime.GOOS + input.Shell = captureShell() + input.OSDescription = captureOSDescription() + } + assembled, err := prompt.Assemble(input) + if err != nil { + return surfaceResult{}, nil, err + } + result := surfaceResult{Prompt: adaptation.TransformPrompt(assembled, conv), Warnings: rules.Warnings} advertised := make([]model.ToolDefinition, 0, len(req.toolSpecs)) for _, spec := range req.toolSpecs { description, err := spec.describe(req.invocation, req.constraints, req.identity) if err != nil { - return prompt.Result{}, nil, err + return surfaceResult{}, nil, err } if !description.Available { continue } name := description.Definition.Name - if slices.Contains(resolved.ExcludeTools, name) || (description.DefaultHidden && !slices.Contains(resolved.IncludeTools, name)) { + if slices.Contains(resolved.ExcludeTools, name) { continue } definition := description.Definition @@ -89,3 +116,28 @@ func composeSurface(req surfaceRequest) (prompt.Result, []model.ToolDefinition, } return result, advertised, nil } + +// captureShell samples the process shell once per composed surface. +func captureShell() string { + shell := os.Getenv("SHELL") + if shell == "" { + return "unknown" + } + return shell +} + +// captureOSDescription samples the platform description once per composed +// surface. +func captureOSDescription() string { + if goruntime.GOOS == "linux" { + if data, err := os.ReadFile("/etc/os-release"); err == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "PRETTY_NAME=") { + name := strings.TrimPrefix(line, "PRETTY_NAME=") + return strings.Trim(name, `"`) + } + } + } + } + return goruntime.GOOS + "/" + goruntime.GOARCH +} diff --git a/runtime/tasks_plugin_test.go b/runtime/tasks_plugin_test.go index 3437ff62..1ff84a05 100644 --- a/runtime/tasks_plugin_test.go +++ b/runtime/tasks_plugin_test.go @@ -51,7 +51,7 @@ func openTask(t *testing.T) runtime.Tool { // admitted-input identity, the given Invocation, and the scripted bridge. func taskContext(t *testing.T, sections map[string]string, background runtime.BackgroundServices) runtime.ToolContext { t.Helper() - invocation, err := runtime.ConfiguredInvocationForTest(sections) + invocation, err := runtime.ConfiguredInvocationForTest("", "", "", sections) testassert.NoError(t, err) return runtime.ToolContext{ AdmittedEntry: harness.EntryRef{SessionID: testSessionID, EntryID: testEntryID}, @@ -87,7 +87,7 @@ func (*taskBackground) DeliverCompletion(context.Context, string, string, string // definition. func TestTaskDescriptionComposesFromRoster(t *testing.T) { spec := tasks.Plugin().Provides[0] - invocation, err := runtime.ConfiguredInvocationForTest(nil) + invocation, err := runtime.ConfiguredInvocationForTest("", "", "", nil) testassert.NoError(t, err) root, err := runtime.DescribeToolForTest(spec, invocation, runtime.ToolConstraints{}, harness.SessionIdentity{SessionID: testSessionID}) @@ -251,7 +251,7 @@ func TestComposedTaskPermissionDenialSettlesOnlyTheCall(t *testing.T) { policy := harness.ResolvePermissionPolicy(json.RawMessage(`{"rules":[ {"permission":"child.launch","target":"secondary","access":"deny"} ]}`), nil) - invocation, err := runtime.ConfiguredInvocationForTest(nil) + invocation, err := runtime.ConfiguredInvocationForTest("", "", "", nil) testassert.NoError(t, err) background := &taskBackground{result: harness.LaunchChildResult{ChildSessionID: taskChildID}} attempt := 0 @@ -270,14 +270,7 @@ func TestComposedTaskPermissionDenialSettlesOnlyTheCall(t *testing.T) { return stopStream("done"), nil } } - th := newToolsHarnessWith(t, modelFn, toolsHarnessOpts{ - advertise: []string{"task"}, - permissions: policy, - invocation: invocation, - background: background, - extraPlugin: tasks.Plugin(), - extraToolID: "task", - }) + th := newTaskHarness(t, modelFn, policy, invocation, background) session := th.createSession() th.submit(session, "op-1", "exercise the denied and allowed launches") rec := th.awaitSettled(session, "op-1") diff --git a/runtime/tools.go b/runtime/tools.go index 8e2d723e..c9147653 100644 --- a/runtime/tools.go +++ b/runtime/tools.go @@ -26,13 +26,11 @@ type ToolConstraints struct { } // ToolDescription is one static tool description: the model-visible -// definition, whether the tool is available under the hard constraints -// (never a permission filter), and whether it stays hidden from the default -// advertisement. +// definition and whether the tool is available under the hard constraints +// (never a permission filter). type ToolDescription struct { - Definition model.ToolDefinition - Available bool - DefaultHidden bool + Definition model.ToolDefinition + Available bool } // BackgroundServices is the background-work surface a prepared call carries: diff --git a/runtime/tools_plugin_test.go b/runtime/tools_composed_test.go similarity index 86% rename from runtime/tools_plugin_test.go rename to runtime/tools_composed_test.go index d73bb350..916c9a05 100644 --- a/runtime/tools_plugin_test.go +++ b/runtime/tools_composed_test.go @@ -1,4 +1,4 @@ -package runtime_test +package runtime import ( "context" @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "regexp" + "slices" "strings" "sync" "testing" @@ -18,62 +19,65 @@ import ( "github.com/MMinasyan/lightcode/harness" "github.com/MMinasyan/lightcode/internal/config" - "github.com/MMinasyan/lightcode/internal/plugins/sqlite" - "github.com/MMinasyan/lightcode/internal/plugins/tools" + "github.com/MMinasyan/lightcode/internal/jobs" "github.com/MMinasyan/lightcode/internal/storage" "github.com/MMinasyan/lightcode/internal/testassert" "github.com/MMinasyan/lightcode/internal/tool" "github.com/MMinasyan/lightcode/model" - "github.com/MMinasyan/lightcode/plugins/jobs" - "github.com/MMinasyan/lightcode/runtime" ) -// The tools-plugin composition tests: the real native tools plugin is -// assembled through the test-build OpenForTest bridge like the SQLite -// plugin, and its prepared tools run through a real Harness over real -// temporary SQLite — allow/deny at the automatic policy boundary, file -// effects and code-group creation on disk, and interruption recovery. The -// direct open of the plugin happens through the composed scope helper, where -// a test jobs plugin is registered and the runtime constructs the binding -// in-package; the plugin requires the jobs capability, so no test opens it -// with empty bindings. +// The composed standard-tools tests: the directly constructed baseline tools +// run through a real Harness over real temporary SQLite — allow/deny at the +// automatic policy boundary, file effects and code-group creation on disk, +// and interruption recovery. The tools are built from owner state over one +// data root with a scripted or real jobs service; no optional registration is +// involved in opening them. -const toolsComposedDocument = `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}},"plugins":{"tools":{"read_max_lines":77}}}` +const toolsComposedDocument = `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}},"tools":{"read_max_lines":77}}` -const toolsComposedInvalidDocument = `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}},"plugins":{"tools":{"max_output_bytes":0}}}` +const toolsComposedInvalidDocument = `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}},"tools":{"max_output_bytes":0}}` -const toolsComposedNullDocument = `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}},"plugins":{"tools":null}}` +const toolsComposedNullDocument = `{"providers":{"prov":{"transport":{"base_url":"https://prov.test/v1","api_key_env":""},"discovery":false,"models":{"m":{"name":"M","context_window":4096}}}},"tools":null}` func writeToolsConfig(t *testing.T, path, doc string) { t.Helper() + testassert.Require(t, json.Valid([]byte(doc)), "fixture document is not valid JSON: %s", doc) testassert.NoError(t, os.WriteFile(path, []byte(doc), 0o600)) } -// TestComposedToolsPluginPublication composes the real tools plugin through -// the OpenForTest bridge and proves the plugins.tools section publication -// matrix: a valid section publishes, a consumed-limit violation and a null -// section fail publication without replacing the live revision, and the next -// success continues the generation sequence. -func TestComposedToolsPluginPublication(t *testing.T) { +// TestComposedToolsSettingsPublication proves the top-level tools section +// publication matrix over the directly composed baseline: a valid section +// publishes, a consumed-limit violation and a null section fail publication +// without replacing the live revision, and the next success continues the +// generation sequence. No optional registration is involved. +func TestComposedToolsSettingsPublication(t *testing.T) { ctx := context.Background() e := newComposeEnv(t) writeToolsConfig(t, e.configPath, toolsComposedDocument) - r, err := runtime.OpenForTest(ctx, e.dataDir, e.configPath, []runtime.Plugin{sqlite.Plugin(), fakeJobsPlugin(&fakeJobs{}), tools.Plugin()}) + r, err := Open(ctx, Options{DataDir: e.dataDir, ConfigPath: e.configPath}) testassert.NoError(t, err) if rev, err := r.Reload(ctx); err != nil || rev != "2" { _ = r.Close(ctx) t.Fatalf("first Reload = (%q, %v), want revision 2", rev, err) } writeToolsConfig(t, e.configPath, toolsComposedInvalidDocument) - if _, err := r.Reload(ctx); err == nil || !errors.Is(err, runtime.ErrConfiguration) { + if _, err := r.Reload(ctx); err == nil || !errors.Is(err, ErrConfiguration) { _ = r.Close(ctx) t.Fatalf("Reload with a nonpositive consumed limit = %v, want a complete publication failure", err) } writeToolsConfig(t, e.configPath, toolsComposedNullDocument) - if _, err := r.Reload(ctx); err == nil || !errors.Is(err, runtime.ErrConfiguration) { + if _, err := r.Reload(ctx); err == nil || !errors.Is(err, ErrConfiguration) { _ = r.Close(ctx) t.Fatalf("Reload with a null tools section = %v, want the publisher's non-object rejection", err) } + if current := r.config.current(); current == nil || current.generation != 2 { + _ = r.Close(ctx) + t.Fatalf("current revision after the null tools rejection = %+v, want the unchanged generation 2", current) + } + if data, err := os.ReadFile(e.configPath); err != nil || string(data) != toolsComposedNullDocument { + _ = r.Close(ctx) + t.Fatalf("config file after the null tools rejection = %q (%v), want the unchanged null fixture", data, err) + } writeToolsConfig(t, e.configPath, toolsComposedDocument) rev, err := r.Reload(ctx) if err != nil || rev != "3" { @@ -83,13 +87,7 @@ func TestComposedToolsPluginPublication(t *testing.T) { testassert.NoError(t, r.Close(ctx)) } -// The admitted-input identity the direct preparation tests prepare with. -const ( - testSessionID = "0123456789abcdef0123456789abcdef" - testEntryID = "fedcba9876543210fedcba9876543210" -) - -// toolExportIDs are the tools plugin's tool exports in declaration order. +// toolExportIDs are the baseline standard tools in declaration order. var toolExportIDs = []string{"read_file", "write_file", "edit_file", "apply_patch", "run_command", "process", "sleep"} // fakeJobs is the scriptable jobs.Jobs test double: it records every call @@ -139,78 +137,99 @@ func (f *fakeJobs) List(string) string { func (f *fakeJobs) Live(string) []string { return f.live } -// fakeJobsPlugin registers one test jobs capability under the id the tools -// plugin requires. -func fakeJobsPlugin(j *fakeJobs) runtime.Plugin { - return runtime.Plugin{ - ID: "jobs", - Scope: runtime.ScopeRuntime, - Provides: []runtime.CapabilitySpec{runtime.Spec[jobs.Jobs]("jobs")}, - Open: func(context.Context, runtime.ScopeInfo, runtime.Bindings) (runtime.Instance, error) { - return runtime.Instance{Values: map[string]any{"jobs": jobs.Jobs(j)}}, nil - }, - } +// realJobs constructs the real jobs service over one data root and registers +// its Close with the test. +func realJobs(t *testing.T, dataDir string) *jobs.Manager { + t.Helper() + manager := jobs.New(dataDir) + t.Cleanup(func() { _ = manager.Close() }) + return manager } -// openToolsComposed opens the real tools plugin through the composed scope — -// the runtime constructs the jobs binding in-package — with one optional -// extra plugin composed in the same scope (the zero Plugin composes none), -// and returns its tool exports plus every bound capability value. -func openToolsComposed(t *testing.T, dataDir string, jobsPlugin, extra runtime.Plugin) (map[string]runtime.Tool, map[string]any) { +// openToolsComposed constructs the baseline standard tool values directly over +// the given data root and jobs service, optionally composing one extra plugin +// as an isolated Runtime scope (the zero Plugin composes none) and returning +// its bound capability values. +func openToolsComposed(t *testing.T, dataDir string, jobsValue jobs.Jobs, extra Plugin) (map[string]Tool, map[string]any) { t.Helper() - plugins := []runtime.Plugin{jobsPlugin, tools.Plugin()} + byID := newStandardTools(dataDir, jobsValue) + var values map[string]any if extra.ID != "" { - plugins = append(plugins, extra) - } - values, closeScope, err := runtime.ComposeScopeForTest(context.Background(), runtime.ScopeInfo{ - Kind: runtime.ScopeRuntime, - DataDir: dataDir, - }, plugins) - testassert.NoError(t, err) - t.Cleanup(func() { _ = closeScope() }) - byID := make(map[string]runtime.Tool, len(toolExportIDs)) - for _, id := range toolExportIDs { - value, ok := values[id] - testassert.Require(t, ok, "composition is missing the tool export %q", id) - toolValue, ok := value.(runtime.Tool) - testassert.Require(t, ok, "export %q supplies %T, not a runtime.Tool", id, value) - byID[id] = toolValue + composed, closeScope, err := ComposeScopeForTest(context.Background(), ScopeInfo{ + Kind: ScopeRuntime, + DataDir: dataDir, + }, []Plugin{extra}) + testassert.NoError(t, err) + t.Cleanup(func() { _ = closeScope() }) + values = composed } return byID, values } -func openTools(t *testing.T, dataDir string) map[string]runtime.Tool { +func openTools(t *testing.T, dataDir string) map[string]Tool { t.Helper() - byID, _ := openToolsComposed(t, dataDir, fakeJobsPlugin(&fakeJobs{}), runtime.Plugin{}) - return byID -} - -// TestComposedToolsPluginContract proves the plugin declaration and the -// composed open: seven tool exports, the strict jobs dependency, and every -// export usable as a runtime.Tool once a jobs provider is registered. -func TestComposedToolsPluginContract(t *testing.T) { - p := tools.Plugin() - testassert.Require(t, p.ID == "tools" && p.Scope == runtime.ScopeRuntime && p.ValidateConfig != nil && p.Open != nil && len(p.Provides) == 7, "plugin declaration = %+v, want the Runtime-scoped tools plugin with a validator and an opener; got %v, want %v", p, len(p.Provides), 7) - // Without a jobs provider the composed open fails, naming the required - // capability: there is no degraded mode. + return newStandardTools(dataDir, &fakeJobs{}) +} + +// TestStandardToolsCompiledIntoComposition proves the baseline tool universe: +// the seven standard tool declarations compile into every composition without +// any registration, they are the capability universe's fixed prefix beside the +// tool universe, an optional export naming one collides as a duplicate +// provider, and an ordinary dependency on one resolves through the same +// provider rules as any other capability. +func TestStandardToolsCompiledIntoComposition(t *testing.T) { + c, err := newComposition(nil) + testassert.Require(t, err == nil, "newComposition(nil): %v", err) + baseline := make([]string, 0, len(standardToolSpecs())) + for _, spec := range standardToolSpecs() { + baseline = append(baseline, spec.id) + } + testassert.Require(t, slices.Equal(c.capabilityIDs[:len(baseline)], baseline), "capability universe prefix = %v, want the seven standard tools first", c.capabilityIDs) + testassert.Require(t, slices.Equal(c.toolIDs[:len(baseline)], baseline), "tool universe prefix = %v, want the seven standard tools first", c.toolIDs) + for _, id := range toolExportIDs { + spec, ok := c.toolSpec(id) + testassert.Require(t, ok && spec.id == id && spec.describe != nil, "tool %q has no compiled baseline declaration", id) + testassert.Require(t, slices.Contains(c.capabilityIDs, id), "capability universe = %q, want %q selectable", c.capabilityIDs, id) + } { - _, _, err := runtime.ComposeScopeForTest(context.Background(), runtime.ScopeInfo{Kind: runtime.ScopeRuntime, DataDir: t.TempDir()}, []runtime.Plugin{tools.Plugin()}) - testassert.Require(t, err != nil && strings.Contains(err.Error(), `"jobs"`), "open without a jobs provider = %v, want the unresolved jobs dependency error", err) + _, err := newComposition([]Plugin{{ + ID: "shadow", + Scope: ScopeRuntime, + Provides: []CapabilitySpec{ToolSpec("run_command", func(Invocation, ToolConstraints, harness.SessionIdentity) (ToolDescription, error) { + return ToolDescription{}, nil + })}, + Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { + return Instance{Values: map[string]any{"run_command": nil}}, nil + }, + }}) + testassert.Require(t, err != nil && errors.Is(err, ErrComposition) && strings.Contains(err.Error(), `capability "run_command" already exported by the owner's standard tools`), "plugin exporting a standard tool name = %v, want the duplicate-provider collision", err) } - byID, _ := openToolsComposed(t, t.TempDir(), fakeJobsPlugin(&fakeJobs{}), runtime.Plugin{}) - testassert.Equal(t, len(byID), 7) -} - -// callToolContext is the ToolContext the direct tests prepare with: a real -// temporary Workspace, a valid-looking admitted-input identity, the zero -// Invocation (defaults), and the given constraints. -func callToolContext(workspace string, constraints runtime.ToolConstraints) runtime.ToolContext { - return runtime.ToolContext{ - Workspace: workspace, - AdmittedEntry: harness.EntryRef{SessionID: testSessionID, EntryID: testEntryID}, - Invocation: runtime.Invocation{}, - Constraints: constraints, + { + _, err := newComposition([]Plugin{{ + ID: "dependent", + Scope: ScopeWorkspace, + Provides: []CapabilitySpec{Spec[any]("marker")}, + Requires: []CapabilitySpec{Spec[Tool]("write_file")}, + Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { + return Instance{Values: map[string]any{"marker": ""}}, nil + }, + }}) + testassert.Require(t, err == nil, "plugin depending on a standard tool name = %v, want the ordinary provider resolution", err) } + { + _, err := newComposition([]Plugin{{ + ID: "dependent", + Scope: ScopeRuntime, + Provides: []CapabilitySpec{Spec[any]("marker")}, + Requires: []CapabilitySpec{Spec[greeter]("write_file")}, + Open: func(context.Context, ScopeInfo, Bindings) (Instance, error) { + return Instance{Values: map[string]any{"marker": ""}}, nil + }, + }}) + testassert.Require(t, err != nil && errors.Is(err, ErrComposition) && strings.Contains(err.Error(), "is not assignable to required"), "plugin depending on a standard tool name with the wrong type = %v, want the ordinary type rule", err) + } + byID, _ := openToolsComposed(t, t.TempDir(), &fakeJobs{}, Plugin{}) + testassert.Equal(t, len(byID), 7) } // stubManagedEnv returns one real manager bound to a throwaway .env whose @@ -224,38 +243,13 @@ func stubManagedEnv(t *testing.T) *config.ManagedEnv { // envCallContext is a callToolContext whose call-time environment producer // is one empty real manager: the ambient environment passes through // unfiltered. -func envCallContext(t *testing.T, workspace string, constraints runtime.ToolConstraints) runtime.ToolContext { +func envCallContext(t *testing.T, workspace string, constraints ToolConstraints) ToolContext { t.Helper() tc := callToolContext(workspace, constraints) tc.SubprocessEnv = stubManagedEnv(t).SubprocessEnv return tc } -func makeCall(t *testing.T, name, args string) model.ToolCall { - t.Helper() - call, err := model.NewToolCall(model.ToolCall{ID: "call-1", Name: name, Arguments: json.RawMessage(args)}) - testassert.NoError(t, err) - return call -} - -// prepare prepares one call, returning the plan. -func prepare(t *testing.T, tool runtime.Tool, tc runtime.ToolContext, name, args string) harness.PreparedTool { - t.Helper() - return tool.Prepare(context.Background(), tc, makeCall(t, name, args)) -} - -// normalize runs one tool's normalization over raw argument bytes. -func normalize(t *testing.T, tool runtime.Tool, tc runtime.ToolContext, name, args string) (json.RawMessage, error) { - t.Helper() - return tool.Normalize(tc, makeCall(t, name, args)) -} - -// groupDir is the exact per-Operation code-group directory the mutating -// calls derive from the ToolContext's admitted-input identity. -func groupDir(dataDir, sessionID, entryID string) string { - return filepath.Join(dataDir, "code", sessionID, entryID) -} - // scriptBackground is the test BackgroundServices over no Harness: StartJob // runs the spawn inline (or surfaces the scripted failure); DeliverCompletion // is a no-op. @@ -377,16 +371,17 @@ func toolsCapture(names []string) harness.ExecutionCapture { } } -// toolsHarnessOpts varies one composed tools Harness: the jobs provider, the -// advertised tool set, the resolved permission policy, the background bridge, -// the captured invocation, and the call-time subprocess environment -// producer. +// toolsHarnessOpts varies one composed tools Harness: the jobs provider (a +// fake or the real service), the advertised tool set, the resolved permission +// policy, the background bridge, the captured invocation, and the call-time +// subprocess environment producer. type toolsHarnessOpts struct { - jobsPlugin runtime.Plugin + jobsValue jobs.Jobs // the tool-facing jobs provider; realJobs wins when set + realJobs bool // construct the real jobs service over the fixture data root advertise []string permissions harness.PermissionPolicy - background runtime.BackgroundServices - invocation runtime.Invocation + background BackgroundServices + invocation Invocation subprocessEnv func() []string // store, when non-nil, is the durable store behind the fixture's // Harness; the zero value opens a temporary SQLite store. @@ -394,7 +389,7 @@ type toolsHarnessOpts struct { // extraPlugin, when non-empty, composes beside the tools plugin in the // same scope, and extraToolID is additionally bound as a tool export — // the fixture's seam for a caller-owned plugin's tool. - extraPlugin runtime.Plugin + extraPlugin Plugin extraToolID string } @@ -432,16 +427,21 @@ func newToolsHarnessWith(t *testing.T, modelFn func(context.Context, model.Reque store = opened } - jobsPlugin := opts.jobsPlugin - if jobsPlugin.ID == "" { - jobsPlugin = fakeJobsPlugin(&fakeJobs{}) + var jobsValue jobs.Jobs + switch { + case opts.realJobs: + jobsValue = realJobs(t, dataDir) + case opts.jobsValue != nil: + jobsValue = opts.jobsValue + default: + jobsValue = &fakeJobs{} } - byID, values := openToolsComposed(t, dataDir, jobsPlugin, opts.extraPlugin) + byID, values := openToolsComposed(t, dataDir, jobsValue, opts.extraPlugin) if opts.extraToolID != "" { value, ok := values[opts.extraToolID] testassert.Require(t, ok, "composition is missing the extra tool export %q", opts.extraToolID) - toolValue, ok := value.(runtime.Tool) - testassert.Require(t, ok, "export %q supplies %T, not a runtime.Tool", opts.extraToolID, value) + toolValue, ok := value.(Tool) + testassert.Require(t, ok, "export %q supplies %T, not a Tool", opts.extraToolID, value) byID[opts.extraToolID] = toolValue } advertise := opts.advertise @@ -460,11 +460,11 @@ func newToolsHarnessWith(t *testing.T, modelFn func(context.Context, model.Reque th.mu.Lock() th.admission = admission.AdmittedEntry th.mu.Unlock() - tc := runtime.ToolContext{ + tc := ToolContext{ Workspace: workspace, AdmittedEntry: admission.AdmittedEntry, Invocation: opts.invocation, - Constraints: runtime.ToolConstraints{Readonly: admission.Execution.Readonly, WriteDir: admission.Execution.WriteDir}, + Constraints: ToolConstraints{Readonly: admission.Execution.Readonly, WriteDir: admission.Execution.WriteDir}, Background: opts.background, SubprocessEnv: opts.subprocessEnv, } @@ -490,9 +490,10 @@ func newToolsHarnessWith(t *testing.T, modelFn func(context.Context, model.Reque }, nil }, } - // The composed jobs capability also declares the Harness job-stop seam; - // wire it exactly as production wires it so background starts admit. - if stopper, ok := values["job-stopper"].(harness.JobStopper); ok { + // The directly constructed jobs service is also the Harness job-stop + // seam; wire it exactly as production wires it so background starts + // admit. + if stopper, ok := jobsValue.(harness.JobStopper); ok { deps.Jobs = stopper } h, err := harness.New(owner, deps) @@ -721,7 +722,7 @@ func TestComposedToolsHarnessRecovery(t *testing.T) { // A fresh Runtime composition over the same data root repairs the // abandoned running state before any execution can start. e := newComposeEnv(t) - r, err := runtime.OpenForTest(ctx, th.dataDir, e.configPath, []runtime.Plugin{sqlite.Plugin(), fakeJobsPlugin(&fakeJobs{}), tools.Plugin()}) + r, err := Open(ctx, Options{DataDir: th.dataDir, ConfigPath: e.configPath}) testassert.NoError(t, err) testassert.NoError(t, r.Close(ctx)) key := harness.RegisterKey{SessionID: session, Kind: harness.RegisterOperation, OperationID: "op-1"} @@ -743,7 +744,7 @@ func TestRunCommandPrepareTargets(t *testing.T) { ws := t.TempDir() t.Run("conclusive simple commands declare one target per segment", func(t *testing.T) { - plan := prepare(t, byID["run_command"], envCallContext(t, ws, runtime.ToolConstraints{}), "run_command", `{"command":"echo one; echo two && echo three"}`) + plan := prepare(t, byID["run_command"], envCallContext(t, ws, ToolConstraints{}), "run_command", `{"command":"echo one; echo two && echo three"}`) testassert.Require(t, plan.Immediate == nil && plan.Execute != nil && len(plan.Permissions) == 3, "plan = %+v, want an executor plan; pairs = %+v, want one command.run pair per segment", plan, plan.Permissions) want := []string{"echo one", "echo two", "echo three"} for i, pair := range plan.Permissions { @@ -764,27 +765,27 @@ func TestRunCommandPrepareTargets(t *testing.T) { // byte — the heredoc body and the trailing newline — preserved. {"{\"command\":\" \\ncat <